Compare commits
101 커밋
feature/ad
...
main
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| b669b25f6e | |||
| 3b24e68547 | |||
| 04a205c9ec | |||
| c7c7bcdc45 | |||
| 846c63eae9 | |||
| 7de0b008c4 | |||
| d07bd3e0f1 | |||
| 9a85cb545c | |||
| 4a730d1582 | |||
| 1980463904 | |||
| 26b86a5a4b | |||
| 2ee4df5afb | |||
| 784b36e69b | |||
| c5dc5c60c5 | |||
| 9f4a578af3 | |||
| 1a31795970 | |||
| e31cb9b764 | |||
| c46bf50348 | |||
| 13bda6d15b | |||
| 650bb2b035 | |||
| 749453b9d1 | |||
| 0a9a5f433e | |||
| 3525e22590 | |||
| ee90b2efdb | |||
| bdb9f95b96 | |||
| 5987dcb991 | |||
| 798002580f | |||
| 0c61041974 | |||
| ea208cbf52 | |||
| c219adf2d9 | |||
| 2f5d2fdb1b | |||
| cd4717f303 | |||
| 7a5028226b | |||
| a6b0e92a8e | |||
| 765d3fb9d2 | |||
| 38d931db65 | |||
| 28544d5c8f | |||
| 10510a0410 | |||
| 1da2553694 | |||
| 7fa3fa6a2e | |||
| 1f66723060 | |||
| 2d6827c0a9 | |||
| 2082e9a79b | |||
| 988cc47e9f | |||
| 0daae3c807 | |||
| fa5c7f518f | |||
| 72ead1140f | |||
| 938665e323 | |||
| 29c5293ce7 | |||
| ae0a17990b | |||
| 6b19d34e5b | |||
| 679649ab8c | |||
| 279dcbc0e1 | |||
| 2fe9deeabe | |||
| 388116aa88 | |||
| 3eb66e2e54 | |||
| 15ca946a00 | |||
| 20d5c08bc7 | |||
| 8a0e5daf60 | |||
| c12e747b14 | |||
| 69b01fca9e | |||
| fef7583eb5 | |||
| f47aeef3ce | |||
| 8093727efe | |||
| 547e83e617 | |||
| af4ab9dd80 | |||
| 28931d9a5e | |||
| ad24445101 | |||
| 4f5260ae12 | |||
| 9630b1daac | |||
| bf0de764c6 | |||
| 965b238b08 | |||
| 2640d882da | |||
| 5de10662a7 | |||
| cc3e0c5596 | |||
| 5b36ea3991 | |||
| 5489bb0db5 | |||
| 42d749426e | |||
| 7a8e2ddea1 | |||
| 625b15e395 | |||
| c40711cae1 | |||
| 2c0f43962b | |||
| 71cdc634c6 | |||
| d71c43ae5a | |||
| 29477e4e2a | |||
| 94e0837072 | |||
| ebe76176e3 | |||
| bc7e966cb1 | |||
| 6c68d04fc3 | |||
| a55d3c18c2 | |||
| 84fa49189c | |||
| 5f622c7520 | |||
| 7cef385c3a | |||
| 36829b9ff4 | |||
| 16db2e1925 | |||
| 6b9ed4e06e | |||
| 99c2e8d6ae | |||
| 3ad24a6e1a | |||
| 714bac9f24 | |||
| ecca827098 | |||
| dc4be29cfc |
@ -5,7 +5,30 @@
|
|||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(*)"
|
"Bash(*)",
|
||||||
|
"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": [
|
"deny": [
|
||||||
"Bash(git push --force*)",
|
"Bash(git push --force*)",
|
||||||
@ -61,5 +84,8 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"enabledPlugins": {
|
||||||
|
"frontend-design@claude-plugins-official": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"applied_global_version": "1.6.1",
|
"applied_global_version": "1.6.1",
|
||||||
"applied_date": "2026-03-31",
|
"applied_date": "2026-04-17",
|
||||||
"project_type": "react-ts",
|
"project_type": "react-ts",
|
||||||
"gitea_url": "https://gitea.gc-si.dev",
|
"gitea_url": "https://gitea.gc-si.dev",
|
||||||
"custom_pre_commit": true
|
"custom_pre_commit": true
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
# commit-msg hook
|
# commit-msg hook
|
||||||
# Conventional Commits 형식 검증 (한/영 혼용 지원)
|
# Conventional Commits 형식 검증 (한/영 혼용 지원)
|
||||||
#==============================================================================
|
#==============================================================================
|
||||||
|
export LC_ALL=en_US.UTF-8 2>/dev/null || export LC_ALL=C.UTF-8 2>/dev/null || true
|
||||||
|
|
||||||
COMMIT_MSG_FILE="$1"
|
COMMIT_MSG_FILE="$1"
|
||||||
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
|
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
|
||||||
|
|||||||
17
.github/java-upgrade/hooks/scripts/recordToolUse.ps1
vendored
Normal file
17
.github/java-upgrade/hooks/scripts/recordToolUse.ps1
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Records run_in_terminal and appmod-* tool calls as JSONL for the extension to process.
|
||||||
|
|
||||||
|
$raw = [Console]::In.ReadToEnd()
|
||||||
|
|
||||||
|
if ($raw -notmatch '"tool_name"\s*:\s*"([^"]+)"') { exit 0 }
|
||||||
|
$toolName = $Matches[1]
|
||||||
|
|
||||||
|
if ($toolName -ne 'run_in_terminal' -and $toolName -notlike 'appmod-*') { exit 0 }
|
||||||
|
|
||||||
|
if ($raw -notmatch '"session_id"\s*:\s*"([^"]+)"') { exit 0 }
|
||||||
|
$sessionId = $Matches[1]
|
||||||
|
|
||||||
|
$hooksDir = '.github\java-upgrade\hooks'
|
||||||
|
if (-not (Test-Path $hooksDir)) { New-Item -ItemType Directory -Path $hooksDir -Force | Out-Null }
|
||||||
|
|
||||||
|
$line = ($raw -replace '[\r\n]+', ' ').Trim() + "`n"
|
||||||
|
[System.IO.File]::AppendAllText("$hooksDir\$sessionId.json", $line, [System.Text.UTF8Encoding]::new($false))
|
||||||
27
.github/java-upgrade/hooks/scripts/recordToolUse.sh
vendored
Normal file
27
.github/java-upgrade/hooks/scripts/recordToolUse.sh
vendored
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Records run_in_terminal and appmod-* tool calls as JSONL for the extension to process.
|
||||||
|
|
||||||
|
INPUT=$(cat)
|
||||||
|
|
||||||
|
TOOL_NAME="${INPUT#*\"tool_name\":\"}"
|
||||||
|
TOOL_NAME="${TOOL_NAME%%\"*}"
|
||||||
|
|
||||||
|
case "$TOOL_NAME" in
|
||||||
|
run_in_terminal|appmod-*) ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$INPUT" in
|
||||||
|
*'"session_id":"'*) ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
SESSION_ID="${INPUT#*\"session_id\":\"}"
|
||||||
|
SESSION_ID="${SESSION_ID%%\"*}"
|
||||||
|
[ -z "$SESSION_ID" ] && exit 0
|
||||||
|
|
||||||
|
HOOKS_DIR=".github/java-upgrade/hooks"
|
||||||
|
mkdir -p "$HOOKS_DIR"
|
||||||
|
|
||||||
|
LINE=$(printf '%s' "$INPUT" | tr -d '\r\n')
|
||||||
|
printf '%s\n' "$LINE" >> "$HOOKS_DIR/${SESSION_ID}.json"
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -79,6 +79,9 @@ prediction/image/**/*.pth
|
|||||||
frontend/public/hns-manual/pages/
|
frontend/public/hns-manual/pages/
|
||||||
frontend/public/hns-manual/images/
|
frontend/public/hns-manual/images/
|
||||||
|
|
||||||
|
# HNS import pipeline outputs (local, 1회성 생성물)
|
||||||
|
backend/scripts/hns-import/out/
|
||||||
|
|
||||||
# Claude Code (team workflow tracked, override global gitignore)
|
# Claude Code (team workflow tracked, override global gitignore)
|
||||||
!.claude/
|
!.claude/
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
@ -103,3 +106,6 @@ frontend/public/hns-manual/images/
|
|||||||
|
|
||||||
# mcp
|
# mcp
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
|
||||||
|
# python
|
||||||
|
.venv
|
||||||
25
CLAUDE.md
25
CLAUDE.md
@ -54,7 +54,7 @@ wing/
|
|||||||
│ │ ├── types/ backtrack, boomLine, hns, navigation
|
│ │ ├── types/ backtrack, boomLine, hns, navigation
|
||||||
│ │ ├── utils/ coordinates, geo, sanitize, cn.ts
|
│ │ ├── utils/ coordinates, geo, sanitize, cn.ts
|
||||||
│ │ └── data/ layerData.ts (UI 레이어 트리)
|
│ │ └── data/ layerData.ts (UI 레이어 트리)
|
||||||
│ └── tabs/ 탭 단위 패키지 (@tabs/ alias)
|
│ └── components/ 탭 단위 패키지 (@components/ alias)
|
||||||
│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐)
|
│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐)
|
||||||
│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산)
|
│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산)
|
||||||
│ ├── rescue/ 구조 시나리오
|
│ ├── rescue/ 구조 시나리오
|
||||||
@ -96,7 +96,7 @@ wing/
|
|||||||
### Path Alias
|
### Path Alias
|
||||||
|
|
||||||
- `@common/*` -> `src/common/*` (공통 모듈)
|
- `@common/*` -> `src/common/*` (공통 모듈)
|
||||||
- `@tabs/*` -> `src/tabs/*` (탭 패키지)
|
- `@components/*` -> `src/components/*` (탭 패키지)
|
||||||
|
|
||||||
## 팀 컨벤션
|
## 팀 컨벤션
|
||||||
|
|
||||||
@ -107,6 +107,8 @@ wing/
|
|||||||
- `naming.md` -- 네이밍 규칙
|
- `naming.md` -- 네이밍 규칙
|
||||||
- `testing.md` -- 테스트 규칙
|
- `testing.md` -- 테스트 규칙
|
||||||
- `subagent-policy.md` -- 서브에이전트 활용 정책
|
- `subagent-policy.md` -- 서브에이전트 활용 정책
|
||||||
|
- `design-system.md` -- AI 에이전트 UI 디자인 시스템 규칙 (영문, 실사용)
|
||||||
|
- `design-system-ko.md` -- 디자인 시스템 규칙 (한국어 참고용)
|
||||||
|
|
||||||
## 개발 문서 (docs/)
|
## 개발 문서 (docs/)
|
||||||
|
|
||||||
@ -125,25 +127,6 @@ wing/
|
|||||||
- API 인터페이스 변경 시 `memory/api-types.md` 갱신
|
- API 인터페이스 변경 시 `memory/api-types.md` 갱신
|
||||||
- 개별 탭 개발자는 공통 가이드를 참조하여 연동 구현
|
- 개별 탭 개발자는 공통 가이드를 참조하여 연동 구현
|
||||||
|
|
||||||
## 진행 중 작업 (완료 후 삭제)
|
|
||||||
|
|
||||||
### 디자인 시스템 폰트+색상 통일 작업
|
|
||||||
|
|
||||||
compact 후 반드시 `memory/design-system-work.md`를 읽고 작업 상태(완료/미완료 컴포넌트)를 확인할 것.
|
|
||||||
|
|
||||||
**색상 규칙:**
|
|
||||||
- 하드코딩 색상(`#ef4444`, `#a855f7` 등) → CSS 변수 전환
|
|
||||||
- `rgba(59,130,246,...)` 등 비-accent 계열 → `rgba(6,182,212,...)` (accent cyan)
|
|
||||||
- 시맨틱 컬러(`color-accent`, `color-info`, `color-caution` 등)는 다양하게 사용 가능하되, 강조 색상은 **최대 2가지**로 제한
|
|
||||||
- `linear-gradient` → 단색으로 단순화
|
|
||||||
- 장식용 `border-top`, `border-left` → 제거 여부를 유저에게 확인 후 진행
|
|
||||||
|
|
||||||
**폰트 규칙:**
|
|
||||||
- 하드코딩 `fontSize`/`fontWeight` → Tailwind 토큰 (`text-title-2`, `text-caption` 등)
|
|
||||||
- `fontFamily: monospace` → `var(--font-mono)`
|
|
||||||
- `fontFamily: sans-serif` / `'Noto Sans KR'` → `var(--font-korean)`
|
|
||||||
- 인라인 `style={{ fontSize, padding }}` → Tailwind 클래스 전환 (가능한 범위)
|
|
||||||
|
|
||||||
## 환경 설정
|
## 환경 설정
|
||||||
|
|
||||||
- Node.js 20 (`.node-version`, fnm 사용)
|
- Node.js 20 (`.node-version`, fnm 사용)
|
||||||
|
|||||||
@ -77,7 +77,7 @@ cd backend && npm run db:seed # DB 초기 데이터
|
|||||||
|
|
||||||
## 프로젝트 구조
|
## 프로젝트 구조
|
||||||
|
|
||||||
Path Alias: `@common/*` -> `src/common/*`, `@tabs/*` -> `src/tabs/*`
|
Path Alias: `@common/*` -> `src/common/*`, `@components/*` -> `src/components/*`
|
||||||
|
|
||||||
```
|
```
|
||||||
wing/
|
wing/
|
||||||
@ -95,7 +95,7 @@ wing/
|
|||||||
│ │ ├── types/ backtrack, boomLine, hns, navigation
|
│ │ ├── types/ backtrack, boomLine, hns, navigation
|
||||||
│ │ ├── utils/ coordinates, geo, sanitize, cn.ts
|
│ │ ├── utils/ coordinates, geo, sanitize, cn.ts
|
||||||
│ │ └── data/ layerData.ts (UI 레이어 트리)
|
│ │ └── data/ layerData.ts (UI 레이어 트리)
|
||||||
│ └── tabs/ 탭 단위 패키지 (@tabs/ alias)
|
│ └── tabs/ 탭 단위 패키지 (@components/ alias)
|
||||||
│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐)
|
│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐)
|
||||||
│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산)
|
│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산)
|
||||||
│ ├── rescue/ 구조 시나리오
|
│ ├── rescue/ 구조 시나리오
|
||||||
|
|||||||
53
backend/package-lock.json
generated
53
backend/package-lock.json
generated
@ -22,6 +22,7 @@
|
|||||||
"pg": "^8.19.0"
|
"pg": "^8.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.89.0",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
@ -34,6 +35,37 @@
|
|||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@anthropic-ai/sdk": {
|
||||||
|
"version": "0.89.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.89.0.tgz",
|
||||||
|
"integrity": "sha512-nyGau0zex62EpU91hsHa0zod973YEoiMgzWZ9hC55WdiOLrE4AGpcg4wXI7lFqtvMLqMcLfewQU9sHgQB6psow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"json-schema-to-ts": "^3.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"anthropic-ai-sdk": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||||
|
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||||
@ -1689,6 +1721,20 @@
|
|||||||
"bignumber.js": "^9.0.0"
|
"bignumber.js": "^9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema-to-ts": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.18.3",
|
||||||
|
"ts-algebra": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsonwebtoken": {
|
"node_modules/jsonwebtoken": {
|
||||||
"version": "9.0.3",
|
"version": "9.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
@ -2618,6 +2664,13 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-algebra": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
"pg": "^8.19.0"
|
"pg": "^8.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.89.0",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
|
|||||||
140
backend/scripts/hns-import/README.md
Normal file
140
backend/scripts/hns-import/README.md
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
# HNS 물질 Import 파이프라인
|
||||||
|
|
||||||
|
`C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm` 외부 자료를 `HNS_SUBSTANCE` DB로 변환하는 1회성 파이프라인.
|
||||||
|
|
||||||
|
## 파이프라인 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
[Excel xlsm] [PDF 물질정보집 (193종)]
|
||||||
|
├─ (1) extract-excel.py (2b) extract-pdf.py
|
||||||
|
│ → out/base.json → out/pdf-data.json
|
||||||
|
└─ (2a) extract-images.py ──────────────────────────┐
|
||||||
|
→ out/images/*.png │
|
||||||
|
↓ │
|
||||||
|
(3) ocr-images.ts │
|
||||||
|
→ out/ocr.json │
|
||||||
|
↓ ↓
|
||||||
|
(4) merge-data.ts ←──────────────┘
|
||||||
|
→ frontend/src/data/hnsSubstanceData.json
|
||||||
|
↓
|
||||||
|
(5) tsx src/db/seedHns.ts
|
||||||
|
→ HNS_SUBSTANCE 테이블
|
||||||
|
```
|
||||||
|
|
||||||
|
**병합 우선순위**: `pdf-data.json` > `base.json` > `ocr.json`
|
||||||
|
|
||||||
|
## 전제 조건
|
||||||
|
|
||||||
|
- Python 3.9+ with `openpyxl`, `PyMuPDF(fitz)`
|
||||||
|
- Node.js 20
|
||||||
|
- `ANTHROPIC_API_KEY` 환경변수 (Claude Vision API, OCR 실행 시에만 필요)
|
||||||
|
- Excel 원본: `C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm`
|
||||||
|
- PDF 원본: `C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf`
|
||||||
|
|
||||||
|
## 실행 순서
|
||||||
|
|
||||||
|
### 1) Excel 메타 시트 파싱
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python scripts/hns-import/extract-excel.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- 입력: `C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm`
|
||||||
|
- 처리 시트: `화물적부도 화물코드`(1,345개), `동의어`(215개), `IBC CODE`(분류)
|
||||||
|
- 출력: `scripts/hns-import/out/base.json`
|
||||||
|
|
||||||
|
### 2a) 이미지 225개 추출 (선택 — OCR 실행 시만 필요)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/hns-import/extract-images.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- 출력: `out/images/{nameKr}.png`, `out/image-map.json`
|
||||||
|
|
||||||
|
### 2b) PDF 물질정보집 파싱 ★ 권장
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/hns-import/extract-pdf.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- 입력: `C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf`
|
||||||
|
- 해양경찰청 발행 193종, 텍스트 직접 추출 (OCR 불필요)
|
||||||
|
- 출력: `scripts/hns-import/out/pdf-data.json`
|
||||||
|
|
||||||
|
### 3) Claude Vision OCR (선택 — pdf-data.json 없을 때 보조)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||||
|
cd backend
|
||||||
|
npx tsx scripts/hns-import/ocr-images.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- 이미지 한 장당 Claude API 1회 호출, 동시 5개 병렬
|
||||||
|
- 출력: `out/ocr.json` `{ [nameKr]: OcrResult }`
|
||||||
|
|
||||||
|
### 4) 최종 JSON 병합
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npx tsx scripts/hns-import/merge-data.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- 입력: `out/base.json` + `out/pdf-data.json` + `out/ocr.json` (없으면 건너뜀)
|
||||||
|
- 출력: `frontend/src/data/hnsSubstanceData.json` (전량 덮어쓰기)
|
||||||
|
|
||||||
|
### 5) DB 재시드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npx tsx src/db/seedHns.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- 기존 `DELETE FROM HNS_SUBSTANCE` → 새 514종 INSERT
|
||||||
|
|
||||||
|
## 빠른 재실행 (PDF 추출 → 병합 → 시드)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python scripts/hns-import/extract-pdf.py && \
|
||||||
|
npx tsx scripts/hns-import/merge-data.ts && \
|
||||||
|
npx tsx src/db/seedHns.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 현재 데이터 현황 (2024-04 기준)
|
||||||
|
|
||||||
|
| 항목 | 이전 (OCR) | 현재 (PDF) |
|
||||||
|
|------|-----------|-----------|
|
||||||
|
| 총 물질 수 | 514종 | 514종 |
|
||||||
|
| 상세정보 보유 (인화점 있음) | 152종 | 195종 |
|
||||||
|
| NFPA 코드 있음 | ~150종 | 201종 |
|
||||||
|
| CAS 번호 있음 | ~380종 | 504종 |
|
||||||
|
| 해양거동 있음 | ~0종 | 206종 |
|
||||||
|
| 한국어 유사명 있음 | ~200종 | 449종 |
|
||||||
|
|
||||||
|
## 재실행 안내
|
||||||
|
|
||||||
|
- `out/` 디렉토리는 `.gitignore` 처리되어 커밋되지 않음
|
||||||
|
- OCR 결과는 비결정적이므로 재실행 시 약간 달라질 수 있음
|
||||||
|
- PDF 추출은 결정적(동일 입력 → 동일 출력)이므로 재실행 안전
|
||||||
|
|
||||||
|
## 알려진 이슈
|
||||||
|
|
||||||
|
### 1) PDF 매칭 실패 35종
|
||||||
|
|
||||||
|
PDF 국문명과 base.json 국문명이 달라 매칭되지 않는 항목이 35개 존재.
|
||||||
|
`out/pdf-unmatched.json`에서 목록 확인 가능. 해당 항목은 OCR 데이터로 보조.
|
||||||
|
|
||||||
|
**원인:**
|
||||||
|
- 영문제품명이 국문명으로 등록된 경우 (예: `DER 383 Epoxy resin` ↔ `디이알 383`)
|
||||||
|
- 동일 CAS 충돌 (예: `컨덴세이트`와 `나프타`가 같은 CAS)
|
||||||
|
- 표기 차이 (예: `아이소파-G` ↔ `아이소파 G`)
|
||||||
|
|
||||||
|
### 2) 2열 레이아웃 파싱 노이즈 (약 9건)
|
||||||
|
|
||||||
|
PDF 물질특성 블록이 2열로 구성되어 있어, 일부 항목에서 비중 값이 온도값으로 오추출될 수 있음.
|
||||||
|
영향 범위 최소 (벤젠 등 9종, 값이 100 이상이면 의심).
|
||||||
|
|
||||||
|
### 3) SEBC/CAS/UN 번호 varchar 길이 초과
|
||||||
|
|
||||||
|
`base.json` 생성 시 Excel에서 복수 CAS/UN 번호를 줄바꿈으로 결합해 저장하여, `HNS_SUBSTANCE` 테이블의 `VARCHAR(20)` 등 제약을 초과했음. 현재는 [`seedHns.ts`](../../../backend/src/db/seedHns.ts) 의 `firstToken()` 헬퍼로 첫 토큰만 검색 컬럼에 저장하고 원본 전체는 `DATA` JSONB에 보존.
|
||||||
236
backend/scripts/hns-import/extract-excel.py
Normal file
236
backend/scripts/hns-import/extract-excel.py
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
"""Excel 메타 시트 → base.json 변환.
|
||||||
|
|
||||||
|
처리 시트:
|
||||||
|
- 화물적부도 화물코드: 1,345개 기본 레코드
|
||||||
|
- 동의어: 215개 한/영 유사명
|
||||||
|
- IBC CODE: IMO IBC 분류
|
||||||
|
|
||||||
|
출력: HNSSearchSubstance 스키마(frontend/src/common/types/hns.ts)에 맞춘 JSON 배열.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||||
|
OUT_DIR = SCRIPT_DIR / 'out'
|
||||||
|
OUT_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
SOURCE_XLSX = Path(os.environ.get(
|
||||||
|
'HNS_SOURCE_XLSX',
|
||||||
|
r'C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm',
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def norm_key(s: str | None) -> str:
|
||||||
|
if not s:
|
||||||
|
return ''
|
||||||
|
return re.sub(r'\s+', '', str(s)).strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def split_synonyms(raw: str | None) -> str:
|
||||||
|
if not raw:
|
||||||
|
return ''
|
||||||
|
# 원본은 "·" 또는 "/" 구분, 개행 포함
|
||||||
|
parts = re.split(r'[·/\n]+', str(raw))
|
||||||
|
cleaned = [p.strip() for p in parts if p and p.strip()]
|
||||||
|
return ' / '.join(cleaned)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_text(v) -> str:
|
||||||
|
if v is None:
|
||||||
|
return ''
|
||||||
|
return str(v).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
print(f'[읽기] {SOURCE_XLSX}')
|
||||||
|
if not SOURCE_XLSX.exists():
|
||||||
|
raise SystemExit(f'소스 파일 없음: {SOURCE_XLSX}')
|
||||||
|
|
||||||
|
wb = openpyxl.load_workbook(SOURCE_XLSX, read_only=True, data_only=True, keep_vba=False)
|
||||||
|
|
||||||
|
# ────────── 화물적부도 화물코드 ──────────
|
||||||
|
ws = wb['화물적부도 화물코드']
|
||||||
|
rows = list(ws.iter_rows(values_only=True))
|
||||||
|
# 헤더 row6: 연번, 약자/제품명, 영어명, 영문명 동의어, 국문명, 국문명 동의어, 주요 사용처, UN번호, CAS번호
|
||||||
|
cargo_rows = [r for r in rows[6:] if r[0] is not None and isinstance(r[0], (int, float))]
|
||||||
|
print(f'[화물적부도] 데이터 행 {len(cargo_rows)}개')
|
||||||
|
|
||||||
|
# ────────── 동의어 시트 ──────────
|
||||||
|
ws_syn = wb['동의어']
|
||||||
|
syn_rows = list(ws_syn.iter_rows(values_only=True))
|
||||||
|
# 헤더 row2: 연번, 국문명, 영문명, cas, un, 한글 유사명, 영문 유사명
|
||||||
|
syn_map: dict[str, dict] = {}
|
||||||
|
for r in syn_rows[2:]:
|
||||||
|
if not r or r[0] is None:
|
||||||
|
continue
|
||||||
|
name_kr = clean_text(r[1])
|
||||||
|
cas = clean_text(r[3])
|
||||||
|
if not name_kr and not cas:
|
||||||
|
continue
|
||||||
|
key = norm_key(name_kr) or norm_key(cas)
|
||||||
|
syn_map[key] = {
|
||||||
|
'synonymsKr': split_synonyms(r[5]) if len(r) > 5 else '',
|
||||||
|
'synonymsEn': split_synonyms(r[6]) if len(r) > 6 else '',
|
||||||
|
}
|
||||||
|
print(f'[동의어] {len(syn_map)}개')
|
||||||
|
|
||||||
|
# ────────── IBC CODE 시트 ──────────
|
||||||
|
ws_ibc = wb['IBC CODE']
|
||||||
|
ibc_map: dict[str, dict] = {}
|
||||||
|
for i, r in enumerate(ws_ibc.iter_rows(values_only=True)):
|
||||||
|
if i < 2:
|
||||||
|
continue # header 2 rows
|
||||||
|
if not r or not r[0]:
|
||||||
|
continue
|
||||||
|
name_en = clean_text(r[0])
|
||||||
|
key = norm_key(name_en)
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
ibc_map[key] = {
|
||||||
|
'ibcHazard': clean_text(r[2]), # 위험성 S/P
|
||||||
|
'ibcShipType': clean_text(r[3]), # 선박형식
|
||||||
|
'ibcTankType': clean_text(r[4]), # 탱크형식
|
||||||
|
'ibcDetection': clean_text(r[10]) if len(r) > 10 else '', # 탐지장치
|
||||||
|
'ibcFireFighting': clean_text(r[12]) if len(r) > 12 else '', # 화재대응
|
||||||
|
'ibcMinRequirement': clean_text(r[14]) if len(r) > 14 else '', # 구체적운영상 요건
|
||||||
|
}
|
||||||
|
print(f'[IBC CODE] {len(ibc_map)}개')
|
||||||
|
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
# ────────── 통합 레코드 생성 ──────────
|
||||||
|
# 동일 CAS/국문명 기준으로 cargoCodes 그룹화
|
||||||
|
groups: dict[str, list] = defaultdict(list)
|
||||||
|
for r in cargo_rows:
|
||||||
|
_, abbr, name_en, syn_en, name_kr, syn_kr, usage, un, cas = r[:9]
|
||||||
|
# 그룹 키: CAS 우선, 없으면 국문명
|
||||||
|
cas_s = clean_text(cas)
|
||||||
|
group_key = cas_s if cas_s else norm_key(name_kr)
|
||||||
|
groups[group_key].append({
|
||||||
|
'abbreviation': clean_text(abbr),
|
||||||
|
'nameKr': clean_text(name_kr),
|
||||||
|
'nameEn': clean_text(name_en),
|
||||||
|
'synonymsKr': split_synonyms(syn_kr),
|
||||||
|
'synonymsEn': split_synonyms(syn_en),
|
||||||
|
'usage': clean_text(usage),
|
||||||
|
'unNumber': clean_text(un),
|
||||||
|
'casNumber': cas_s,
|
||||||
|
})
|
||||||
|
|
||||||
|
records: list[dict] = []
|
||||||
|
next_id = 1
|
||||||
|
for group_key, entries in groups.items():
|
||||||
|
# 대표 레코드: 가장 먼저 등장 (동의어 필드가 있는 걸 우선)
|
||||||
|
primary = max(entries, key=lambda e: (bool(e['synonymsKr']), bool(e['synonymsEn']), len(e['nameKr'])))
|
||||||
|
|
||||||
|
name_kr_key = norm_key(primary['nameKr'])
|
||||||
|
name_en_key = norm_key(primary['nameEn'])
|
||||||
|
|
||||||
|
# 동의어 병합
|
||||||
|
syn_extra = syn_map.get(name_kr_key, {})
|
||||||
|
synonyms_kr = ' / '.join(filter(None, [primary['synonymsKr'], syn_extra.get('synonymsKr', '')]))
|
||||||
|
synonyms_en = ' / '.join(filter(None, [primary['synonymsEn'], syn_extra.get('synonymsEn', '')]))
|
||||||
|
|
||||||
|
# IBC 병합 (영문명 기준)
|
||||||
|
ibc = ibc_map.get(name_en_key, {})
|
||||||
|
|
||||||
|
# cargoCodes 집계
|
||||||
|
cargo_codes = [
|
||||||
|
{
|
||||||
|
'code': e['abbreviation'],
|
||||||
|
'name': e['nameEn'] or e['nameKr'],
|
||||||
|
'company': '국제공통',
|
||||||
|
'source': '적부도',
|
||||||
|
}
|
||||||
|
for e in entries
|
||||||
|
if e['abbreviation']
|
||||||
|
]
|
||||||
|
|
||||||
|
record = {
|
||||||
|
'id': next_id,
|
||||||
|
'abbreviation': primary['abbreviation'],
|
||||||
|
'nameKr': primary['nameKr'],
|
||||||
|
'nameEn': primary['nameEn'],
|
||||||
|
'synonymsKr': synonyms_kr,
|
||||||
|
'synonymsEn': synonyms_en,
|
||||||
|
'unNumber': primary['unNumber'],
|
||||||
|
'casNumber': primary['casNumber'],
|
||||||
|
'transportMethod': '',
|
||||||
|
'sebc': '',
|
||||||
|
# 물리·화학 (OCR 단계에서 채움)
|
||||||
|
'usage': primary['usage'],
|
||||||
|
'state': '',
|
||||||
|
'color': '',
|
||||||
|
'odor': '',
|
||||||
|
'flashPoint': '',
|
||||||
|
'autoIgnition': '',
|
||||||
|
'boilingPoint': '',
|
||||||
|
'density': '',
|
||||||
|
'solubility': '',
|
||||||
|
'vaporPressure': '',
|
||||||
|
'vaporDensity': '',
|
||||||
|
'explosionRange': '',
|
||||||
|
# 위험도
|
||||||
|
'nfpa': {'health': 0, 'fire': 0, 'reactivity': 0, 'special': ''},
|
||||||
|
'hazardClass': '',
|
||||||
|
'ergNumber': '',
|
||||||
|
'idlh': '',
|
||||||
|
'aegl2': '',
|
||||||
|
'erpg2': '',
|
||||||
|
# 방제
|
||||||
|
'responseDistanceFire': '',
|
||||||
|
'responseDistanceSpillDay': '',
|
||||||
|
'responseDistanceSpillNight': '',
|
||||||
|
'marineResponse': '',
|
||||||
|
'ppeClose': '',
|
||||||
|
'ppeFar': '',
|
||||||
|
# MSDS
|
||||||
|
'msds': {
|
||||||
|
'hazard': '',
|
||||||
|
'firstAid': '',
|
||||||
|
'fireFighting': '',
|
||||||
|
'spillResponse': '',
|
||||||
|
'exposure': '',
|
||||||
|
'regulation': '',
|
||||||
|
},
|
||||||
|
# IBC
|
||||||
|
'ibcHazard': ibc.get('ibcHazard', ''),
|
||||||
|
'ibcShipType': ibc.get('ibcShipType', ''),
|
||||||
|
'ibcTankType': ibc.get('ibcTankType', ''),
|
||||||
|
'ibcDetection': ibc.get('ibcDetection', ''),
|
||||||
|
'ibcFireFighting': ibc.get('ibcFireFighting', ''),
|
||||||
|
'ibcMinRequirement': ibc.get('ibcMinRequirement', ''),
|
||||||
|
# EmS (OCR에서 채움)
|
||||||
|
'emsCode': '',
|
||||||
|
'emsFire': '',
|
||||||
|
'emsSpill': '',
|
||||||
|
'emsFirstAid': '',
|
||||||
|
# cargoCodes / portFrequency
|
||||||
|
'cargoCodes': cargo_codes,
|
||||||
|
'portFrequency': [],
|
||||||
|
}
|
||||||
|
records.append(record)
|
||||||
|
next_id += 1
|
||||||
|
|
||||||
|
print(f'[통합] 그룹화 결과 {len(records)}종 (화물적부도 {len(cargo_rows)}행 기준)')
|
||||||
|
|
||||||
|
# 저장
|
||||||
|
out_path = OUT_DIR / 'base.json'
|
||||||
|
with open(out_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(records, f, ensure_ascii=False, indent=2)
|
||||||
|
print(f'[완료] {out_path} ({out_path.stat().st_size / 1024:.0f} KB)')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
170
backend/scripts/hns-import/extract-images.py
Normal file
170
backend/scripts/hns-import/extract-images.py
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
"""물질별 시트에서 메인 카드 이미지(100KB+) 추출.
|
||||||
|
|
||||||
|
엑셀 워크시트 → drawing → image 관계 체인을 추적해
|
||||||
|
각 물질 시트의 핵심 이미지만 out/images/{nameKr}.png 로 저장.
|
||||||
|
|
||||||
|
동시에 out/image-map.json 생성 (파일명 ↔ 시트명/국문명 매핑).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||||
|
OUT_DIR = SCRIPT_DIR / 'out'
|
||||||
|
IMG_DIR = OUT_DIR / 'images'
|
||||||
|
IMG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
SOURCE_XLSX = Path(os.environ.get(
|
||||||
|
'HNS_SOURCE_XLSX',
|
||||||
|
r'C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm',
|
||||||
|
))
|
||||||
|
|
||||||
|
NS = {
|
||||||
|
'm': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
|
||||||
|
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
||||||
|
'pr': 'http://schemas.openxmlformats.org/package/2006/relationships',
|
||||||
|
}
|
||||||
|
|
||||||
|
# 메타 시트(데이터 시트)는 스킵
|
||||||
|
SKIP_SHEETS = {
|
||||||
|
'화물적부도 화물코드',
|
||||||
|
'항구별 코드',
|
||||||
|
'동의어',
|
||||||
|
'IBC CODE',
|
||||||
|
'경계선',
|
||||||
|
}
|
||||||
|
# 지침서 번호 시트(115~171) 패턴: 순수 숫자
|
||||||
|
SKIP_PATTERN = re.compile(r'^\d{3}$')
|
||||||
|
|
||||||
|
# 최소 이미지 크기 (주요 카드만 대상, 작은 아이콘 제외)
|
||||||
|
MIN_IMAGE_SIZE = 50_000 # 50 KB
|
||||||
|
|
||||||
|
|
||||||
|
def safe_filename(name: str) -> str:
|
||||||
|
name = name.strip().rstrip(',').strip()
|
||||||
|
name = re.sub(r'[<>:"/\\|?*]', '_', name)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def norm_path(p: str) -> str:
|
||||||
|
return os.path.normpath(p).replace(os.sep, '/')
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
print(f'[읽기] {SOURCE_XLSX}')
|
||||||
|
if not SOURCE_XLSX.exists():
|
||||||
|
raise SystemExit(f'소스 파일 없음: {SOURCE_XLSX}')
|
||||||
|
|
||||||
|
image_map: dict[str, dict] = {}
|
||||||
|
saved = 0
|
||||||
|
skipped = 0
|
||||||
|
missing = 0
|
||||||
|
|
||||||
|
with zipfile.ZipFile(SOURCE_XLSX) as z:
|
||||||
|
# 1) workbook.xml → sheet 목록
|
||||||
|
with z.open('xl/workbook.xml') as f:
|
||||||
|
wb_root = ET.parse(f).getroot()
|
||||||
|
sheets = []
|
||||||
|
for s in wb_root.findall('m:sheets/m:sheet', NS):
|
||||||
|
sheets.append({
|
||||||
|
'name': s.get('name'),
|
||||||
|
'rid': s.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id'),
|
||||||
|
})
|
||||||
|
with z.open('xl/_rels/workbook.xml.rels') as f:
|
||||||
|
rels_root = ET.parse(f).getroot()
|
||||||
|
rid_target = {r.get('Id'): r.get('Target') for r in rels_root.findall('pr:Relationship', NS)}
|
||||||
|
for s in sheets:
|
||||||
|
s['target'] = rid_target.get(s['rid'])
|
||||||
|
|
||||||
|
print(f'[시트] 총 {len(sheets)}개')
|
||||||
|
|
||||||
|
for s in sheets:
|
||||||
|
name = s['name']
|
||||||
|
if name in SKIP_SHEETS or SKIP_PATTERN.match(name or ''):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
sheet_file = 'xl/' + s['target']
|
||||||
|
rels_file = os.path.dirname(sheet_file) + '/_rels/' + os.path.basename(sheet_file) + '.rels'
|
||||||
|
try:
|
||||||
|
with z.open(rels_file) as f:
|
||||||
|
srels = ET.parse(f).getroot()
|
||||||
|
except KeyError:
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 시트 → drawing
|
||||||
|
drawing_rel = None
|
||||||
|
for r in srels.findall('pr:Relationship', NS):
|
||||||
|
t = r.get('Target') or ''
|
||||||
|
if 'drawing' in (r.get('Type') or '').lower() and 'drawings/' in t:
|
||||||
|
drawing_rel = t
|
||||||
|
break
|
||||||
|
if not drawing_rel:
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
drawing_path = norm_path(os.path.join(os.path.dirname(sheet_file), drawing_rel))
|
||||||
|
drawing_rels_path = os.path.dirname(drawing_path) + '/_rels/' + os.path.basename(drawing_path) + '.rels'
|
||||||
|
try:
|
||||||
|
with z.open(drawing_rels_path) as f:
|
||||||
|
drels = ET.parse(f).getroot()
|
||||||
|
except KeyError:
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# drawing → images
|
||||||
|
image_paths: list[str] = []
|
||||||
|
for r in drels.findall('pr:Relationship', NS):
|
||||||
|
t = r.get('Target') or ''
|
||||||
|
if 'image' in t.lower():
|
||||||
|
img_path = norm_path(os.path.join(os.path.dirname(drawing_path), t))
|
||||||
|
image_paths.append(img_path)
|
||||||
|
if not image_paths:
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 가장 큰 이미지 선택 (실제 카드 이미지는 100KB+, 아이콘은 수 KB)
|
||||||
|
sized = [(z.getinfo(p).file_size, p) for p in image_paths]
|
||||||
|
sized.sort(reverse=True)
|
||||||
|
largest_size, largest_path = sized[0]
|
||||||
|
if largest_size < MIN_IMAGE_SIZE:
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 저장
|
||||||
|
safe = safe_filename(name)
|
||||||
|
ext = os.path.splitext(largest_path)[1].lower() or '.png'
|
||||||
|
out_name = f'{safe}{ext}'
|
||||||
|
out_path = IMG_DIR / out_name
|
||||||
|
with z.open(largest_path) as fin, open(out_path, 'wb') as fout:
|
||||||
|
fout.write(fin.read())
|
||||||
|
image_map[out_name] = {
|
||||||
|
'sheetName': name,
|
||||||
|
'nameKr': safe,
|
||||||
|
'source': largest_path,
|
||||||
|
'sizeBytes': largest_size,
|
||||||
|
}
|
||||||
|
saved += 1
|
||||||
|
if saved % 25 == 0:
|
||||||
|
print(f' {saved}개 저장 완료')
|
||||||
|
|
||||||
|
print(f'\n[결과] 저장 {saved} / 스킵(메타) {skipped} / 이미지없음 {missing}')
|
||||||
|
|
||||||
|
map_path = OUT_DIR / 'image-map.json'
|
||||||
|
with open(map_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(image_map, f, ensure_ascii=False, indent=2)
|
||||||
|
print(f'[완료] 매핑 파일: {map_path}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
707
backend/scripts/hns-import/extract-pdf.py
Normal file
707
backend/scripts/hns-import/extract-pdf.py
Normal file
@ -0,0 +1,707 @@
|
|||||||
|
"""PDF 물질정보집 → pdf-data.json 변환.
|
||||||
|
|
||||||
|
원본: C:\\Projects\\MeterialDB\\해상화학사고_대응_물질정보집.pdf
|
||||||
|
해양경찰청 발행 193종 물질 정보
|
||||||
|
|
||||||
|
PDF 구조:
|
||||||
|
- 페이지 1-21: 표지/머리말/목차
|
||||||
|
- 페이지 22-407: 193종 × 2페이지 물질 카드
|
||||||
|
- 요약 카드 (홀수 순서): 인화점·발화점·증기압·증기밀도·폭발범위·NFPA·해양거동 등
|
||||||
|
- 상세 카드 (짝수 순서): 유사명·CAS·UN·GHS분류·물질특성·인체유해성·응급조치
|
||||||
|
- 물질 NO(1-193) → 0-인덱스 시작 페이지: 21 + (NO-1) * 2
|
||||||
|
|
||||||
|
출력: out/pdf-data.json
|
||||||
|
{ [nameKr]: OcrResult } — merge-data.ts 와 동일한 키 구조
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||||
|
OUT_DIR = SCRIPT_DIR / 'out'
|
||||||
|
OUT_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
PDF_PATH = Path(os.environ.get(
|
||||||
|
'HNS_PDF_PATH',
|
||||||
|
r'C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf',
|
||||||
|
))
|
||||||
|
|
||||||
|
# 전각 문자 → 반각 변환 테이블
|
||||||
|
_FULLWIDTH = str.maketrans(
|
||||||
|
'()tC°℃ ,',
|
||||||
|
'()tC℃℃ ,',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clean(s: str) -> str:
|
||||||
|
"""텍스트 정리."""
|
||||||
|
if not s:
|
||||||
|
return ''
|
||||||
|
s = s.translate(_FULLWIDTH)
|
||||||
|
# 온도 기호 통일: 仁/七/부/사 → ℃ (OCR 오인식)
|
||||||
|
s = re.sub(r'(?<=[0-9])\s*[仁七부사 ](?=\s|$|이|이하)', '℃', s)
|
||||||
|
s = re.sub(r'(?<=[0-9])\s*[tT](?=\s|$|이|이하)', '℃', s)
|
||||||
|
s = re.sub(r'\s+', ' ', s)
|
||||||
|
return s.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def norm_key(s: str) -> str:
|
||||||
|
"""정규화 키: 공백/특수문자 제거 + 소문자."""
|
||||||
|
if not s:
|
||||||
|
return ''
|
||||||
|
return re.sub(r'[\s,./\-_()\[\]··]+', '', s).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_cas(raw: str) -> str:
|
||||||
|
"""CAS 번호 정규화: OCR 노이즈 제거 후 X-XX-X 형식 반환."""
|
||||||
|
if not raw:
|
||||||
|
return ''
|
||||||
|
# 혼합물
|
||||||
|
if '혼합물' in raw:
|
||||||
|
return ''
|
||||||
|
# 특수 대시 → -
|
||||||
|
s = raw.replace('—', '-').replace('-', '-').replace('–', '-')
|
||||||
|
# OCR 오인식: 이,이, 공백 등 → 0
|
||||||
|
s = re.sub(r'[이oO0]', '0', s)
|
||||||
|
s = re.sub(r'["\'\s ]', '', s) # 잡자 제거
|
||||||
|
# CAS 포맷 검증 후 반환
|
||||||
|
m = re.match(r'^(\d{2,7}-\d{2}-\d)$', s)
|
||||||
|
if m:
|
||||||
|
return m.group(1).lstrip('0') or '0' # 앞자리 0 제거
|
||||||
|
# 완전히 일치 안 하면 숫자+대시만 남기고 검증
|
||||||
|
s2 = re.sub(r'[^0-9\-]', '', s)
|
||||||
|
m2 = re.match(r'^(\d{2,7}-\d{2}-\d)$', s2)
|
||||||
|
if m2:
|
||||||
|
return m2.group(1).lstrip('0') or '0'
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def find_cas_in_text(text: str) -> str:
|
||||||
|
"""텍스트에서 CAS 번호 패턴 검색."""
|
||||||
|
# 표준 CAS 패턴: 숫자-숫자2자리-숫자1자리
|
||||||
|
candidates = re.findall(r'\b(\d{1,7}[\-—-\s]{1,2}\d{2}[\-—-\s]{1,2}\d)\b', text)
|
||||||
|
for c in candidates:
|
||||||
|
cas = normalize_cas(c)
|
||||||
|
if cas and len(cas) >= 5:
|
||||||
|
return cas
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def parse_nfpa(text: str) -> dict | None:
|
||||||
|
"""NFPA 코드 파싱: '건강 : 3 화재 : 0 반응 : 1' 형태."""
|
||||||
|
m = re.search(r'건강\s*[::]\s*(\d)\s*화재\s*[::]\s*(\d)\s*반응\s*[::]\s*(\d)', text)
|
||||||
|
if m:
|
||||||
|
return {
|
||||||
|
'health': int(m.group(1)),
|
||||||
|
'fire': int(m.group(2)),
|
||||||
|
'reactivity': int(m.group(3)),
|
||||||
|
'special': '',
|
||||||
|
}
|
||||||
|
# 대안 패턴: 줄바꿈 포함
|
||||||
|
m2 = re.search(r'건강\s*[::]\s*(\d).*?화재\s*[::]\s*(\d).*?반응\s*[::]\s*(\d)', text, re.DOTALL)
|
||||||
|
if m2:
|
||||||
|
return {
|
||||||
|
'health': int(m2.group(1)),
|
||||||
|
'fire': int(m2.group(2)),
|
||||||
|
'reactivity': int(m2.group(3)),
|
||||||
|
'special': '',
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_field_after(text: str, label: str, max_chars: int = 80) -> str:
|
||||||
|
"""레이블 직후 값 추출 (단순 패턴)."""
|
||||||
|
idx = text.find(label)
|
||||||
|
if idx < 0:
|
||||||
|
return ''
|
||||||
|
snippet = text[idx + len(label): idx + len(label) + max_chars + 50]
|
||||||
|
# 첫 비공백 줄 추출
|
||||||
|
lines = snippet.split('\n')
|
||||||
|
for line in lines:
|
||||||
|
v = clean(line)
|
||||||
|
if v and v not in (':', ':', ''):
|
||||||
|
return v[:max_chars]
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def parse_summary_card(text: str, index_entry: dict) -> dict:
|
||||||
|
"""요약 카드(첫 번째 페이지) 파싱."""
|
||||||
|
result: dict = {}
|
||||||
|
|
||||||
|
# 인화점
|
||||||
|
m = re.search(r'인화점\s*\n([^\n화발증폭위※]+)', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and '위험' not in val and len(val) < 40:
|
||||||
|
result['flashPoint'] = val
|
||||||
|
|
||||||
|
# 발화점
|
||||||
|
m = re.search(r'발화점\s*\n([^\n화발증폭위※인]+)', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 40:
|
||||||
|
result['autoIgnition'] = val
|
||||||
|
|
||||||
|
# 증기압 (요약 카드에서는 값이 더 명확하게 나옴)
|
||||||
|
m = re.search(r'(?:증기압|흥기압)\s*\n?([^\n증기밀도폭발인화발화]+)', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
# 파편화된 텍스트 제거
|
||||||
|
if val and re.search(r'\d', val) and len(val) < 60:
|
||||||
|
result['vaporPressure'] = val
|
||||||
|
|
||||||
|
# 증기밀도 숫자값
|
||||||
|
m = re.search(r'증기밀도\s*\n?([0-9][^\n]{0,20})', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 20:
|
||||||
|
result['vaporDensity'] = val
|
||||||
|
|
||||||
|
# 폭발범위 (2열 레이아웃으로 값이 레이블에서 멀리 떨어질 수 있어 전문 탐색도 병행)
|
||||||
|
m = re.search(r'폭발범위\s*\n([^\n위험인화발화※]+)', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and '%' in val and len(val) < 30:
|
||||||
|
result['explosionRange'] = val
|
||||||
|
# 2열 레이아웃 폴백: 텍스트 전체에서 "숫자~숫자%" 패턴 검색
|
||||||
|
if not result.get('explosionRange'):
|
||||||
|
m = re.search(r'(\d+[\.,]?\d*\s*~\s*\d+[\.,]?\d*\s*%)', text)
|
||||||
|
if m:
|
||||||
|
result['explosionRange'] = clean(m.group(1))
|
||||||
|
|
||||||
|
# 화재시 대피거리
|
||||||
|
m = re.search(r'화재시\s*대피거리\s*\n?([^\n]+)', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val:
|
||||||
|
result['responseDistanceFire'] = val
|
||||||
|
|
||||||
|
# 해양거동
|
||||||
|
m = re.search(r'해양거동\s*\n([^\n상온이격방호방제]+)', text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'해양거동\s+([^\n]+)', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 80:
|
||||||
|
result['marineResponse'] = val
|
||||||
|
|
||||||
|
# 상온상태
|
||||||
|
m = re.search(r'상온상태\s*\n([^\n이격방호비중색상휘발냄새]+)', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 60:
|
||||||
|
result['state'] = val
|
||||||
|
|
||||||
|
# 냄새
|
||||||
|
m = re.search(r'냄새\s*\n([^\n이격방호색상비중상온휘발]+)', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 60:
|
||||||
|
result['odor'] = val
|
||||||
|
|
||||||
|
# 비중
|
||||||
|
m = re.search(r'비중\s*\n[^\n]*\n([0-9][^\n]{0,25})', text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'비중\s*\n([0-9][^\n]{0,25})', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 30:
|
||||||
|
result['density'] = val
|
||||||
|
|
||||||
|
# 색상
|
||||||
|
m = re.search(r'색상\s*\n([^\n이격방호냄새비중상온휘발]+)', text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 40:
|
||||||
|
result['color'] = val
|
||||||
|
|
||||||
|
# 이격거리 / 방호거리 거리 숫자 추출
|
||||||
|
m_hot = re.search(r'(?:이격거리|Hot\s*Zone).*?\n([^\n방호거리]+(?:\d+m|반경[^\n]+))', text, re.IGNORECASE)
|
||||||
|
if m_hot:
|
||||||
|
result['responseDistanceSpillDay'] = clean(m_hot.group(1))
|
||||||
|
|
||||||
|
m_warm = re.search(r'(?:방호거리|Warm\s*Zone).*?\n([^\n이격거리]+(?:\d+m|방향[^\n]+))', text, re.IGNORECASE)
|
||||||
|
if m_warm:
|
||||||
|
result['responseDistanceSpillNight'] = clean(m_warm.group(1))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def parse_detail_card(text: str) -> dict:
|
||||||
|
"""상세 카드(두 번째 페이지) 파싱."""
|
||||||
|
result: dict = {}
|
||||||
|
|
||||||
|
# ── nameKr 헤더에서 추출 ──────────────────────────────────────────
|
||||||
|
# 형식: "001 과산화수소" or "0이 과산화수소"
|
||||||
|
first_lines = text.strip().split('\n')[:4]
|
||||||
|
for line in first_lines:
|
||||||
|
line = line.strip()
|
||||||
|
# 숫자/OCR숫자로 시작하고 뒤에 한글이 오는 패턴
|
||||||
|
m = re.match(r'^[0-9이이아오0-9]{2,3}\s+([\w\s\-,./()()]+)$', line)
|
||||||
|
if m:
|
||||||
|
candidate = clean(m.group(1).strip())
|
||||||
|
if candidate and re.search(r'[가-힣A-Za-z]', candidate):
|
||||||
|
result['nameKr'] = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
# ── 분류 ──────────────────────────────────────────────────────────
|
||||||
|
m = re.search(r'(?:유해액체물질|위험물질|석유\s*및|해양환경관리법)[^\n]{0,60}', text)
|
||||||
|
if m:
|
||||||
|
result['hazardClass'] = clean(m.group(0))
|
||||||
|
|
||||||
|
# ── 물질요약 ───────────────────────────────────────────────────────
|
||||||
|
# 물질요약 레이블 이후 ~ 유사명/CAS 번호 전까지
|
||||||
|
m = re.search(r'(?:물질요약|= *닐으서|진 O야)(.*?)(?=유사명|CAS|$)', text, re.DOTALL)
|
||||||
|
if not m:
|
||||||
|
# 분류값 이후 ~ 유사명 전
|
||||||
|
m = re.search(r'(?:유해액체물질|석유 및)[^\n]*\n(.*?)(?=유사명|CAS)', text, re.DOTALL)
|
||||||
|
if m:
|
||||||
|
summary = re.sub(r'\s+', ' ', m.group(1)).strip()
|
||||||
|
if summary and len(summary) > 15:
|
||||||
|
result['materialSummary'] = summary[:500]
|
||||||
|
|
||||||
|
# ── 유사명 ─────────────────────────────────────────────────────────
|
||||||
|
m = re.search(r'유사명\s*\n?(.*?)(?=CAS|UN\s*번호|\d{4,7}-\d{2}-\d|분자식|$)', text, re.DOTALL)
|
||||||
|
if m:
|
||||||
|
synonyms_raw = re.sub(r'\s+', ' ', m.group(1)).strip()
|
||||||
|
# CAS 번호 형태면 제외
|
||||||
|
if synonyms_raw and not re.match(r'^\d{4,7}-\d{2}-\d', synonyms_raw) and len(synonyms_raw) < 300:
|
||||||
|
result['synonymsKr'] = synonyms_raw
|
||||||
|
|
||||||
|
# ── CAS 번호 ────────────────────────────────────────────────────────
|
||||||
|
# 1순위: "CAS번호" / "CAS 번호" 직후 줄
|
||||||
|
m = re.search(r'CAS\s*번호\s*\n\s*([^\n분자NFPA용도인화발화물질]+)', text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'CAS\s*번호\s*([0-9][^\n분자NFPA용도인화발화물질]{4,20})', text)
|
||||||
|
if m:
|
||||||
|
cas = normalize_cas(m.group(1).strip().split()[0])
|
||||||
|
if cas:
|
||||||
|
result['casNumber'] = cas
|
||||||
|
|
||||||
|
# 2순위: 텍스트 전체에서 CAS 패턴 검색
|
||||||
|
if not result.get('casNumber'):
|
||||||
|
cas = find_cas_in_text(text)
|
||||||
|
if cas:
|
||||||
|
result['casNumber'] = cas
|
||||||
|
|
||||||
|
# ── UN 번호 ─────────────────────────────────────────────────────────
|
||||||
|
# NFPA 코드 이후 줄에 있는 4자리 숫자
|
||||||
|
m = re.search(r'(?:UN\s*번호|UN번호)\s*\n?\s*([0-9]{3,4})', text)
|
||||||
|
if not m:
|
||||||
|
# NFPA 다음 4자리 숫자
|
||||||
|
m = re.search(r'반응\s*[::]\s*\d\s*\n\s*([0-9]{3,4})\s*\n', text)
|
||||||
|
if m:
|
||||||
|
result['unNumber'] = m.group(1).strip()
|
||||||
|
|
||||||
|
# ── NFPA 코드 ───────────────────────────────────────────────────────
|
||||||
|
nfpa = parse_nfpa(text)
|
||||||
|
if nfpa:
|
||||||
|
result['nfpa'] = nfpa
|
||||||
|
|
||||||
|
# ── 용도 ────────────────────────────────────────────────────────────
|
||||||
|
m = re.search(r'용도\s*\n(.*?)(?=물질특성|인체\s*유해|인체유해|흡입노출|보호복|초동|$)', text, re.DOTALL)
|
||||||
|
if m:
|
||||||
|
usage = re.sub(r'\s+', ' ', m.group(1)).strip()
|
||||||
|
# GHS 마크(특수문자 블록) 제거
|
||||||
|
usage = re.sub(r'<[^>]*>|[♦◆◇△▲▼▽★☆■□●○◐◑]+', '', usage).strip()
|
||||||
|
if usage and len(usage) < 200:
|
||||||
|
result['usage'] = usage
|
||||||
|
|
||||||
|
# ── 물질특성 블록 ───────────────────────────────────────────────────
|
||||||
|
props_start = text.find('물질특성')
|
||||||
|
props_end = text.find('인체 유해성')
|
||||||
|
if props_end < 0:
|
||||||
|
props_end = text.find('인체유해성')
|
||||||
|
if props_end < 0:
|
||||||
|
props_end = text.find('흡입노출')
|
||||||
|
props_text = text[props_start:props_end] if 0 <= props_start < props_end else text
|
||||||
|
|
||||||
|
# 인화점 (상세) — 단일 알파벳(X/O 등 위험도 마크) 제외, 숫자 포함 값만 허용
|
||||||
|
m = re.search(r'인화점\s+([^\n발화끓는수용상온]+)', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'인화점\s*\n\s*([^\n발화끓는수용상온]+)', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 40 and (re.search(r'\d', val) or re.search(r'없음|해당없음|N/A', val)):
|
||||||
|
result['flashPoint'] = val
|
||||||
|
|
||||||
|
# 발화점 (상세) — 숫자 포함 값만 허용
|
||||||
|
m = re.search(r'발화점\s+([^\n인화끓는수용상온]+)', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'발화점\s*\n\s*([^\n인화끓는수용상온]+)', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 40 and (re.search(r'\d', val) or re.search(r'없음|해당없음|N/A', val)):
|
||||||
|
result['autoIgnition'] = val
|
||||||
|
|
||||||
|
# 끓는점
|
||||||
|
m = re.search(r'끓는점\s+([^\n인화발화수용상온]+)', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'끓는점\s*\n\s*([^\n인화발화수용상온]+)', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 40:
|
||||||
|
result['boilingPoint'] = val
|
||||||
|
|
||||||
|
# 수용해도
|
||||||
|
m = re.search(r'수용해도\s+([^\n인화발화끓는상온]+)', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'수용해도\s*\n\s*([^\n인화발화끓는상온]+)', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 50:
|
||||||
|
result['solubility'] = val
|
||||||
|
|
||||||
|
# 상온상태 (상세)
|
||||||
|
m = re.search(r'상온상태\s+([^\n색상냄새비중증기인화발화]+)', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'상온상태\s*\n\s*([^\n색상냄새비중증기인화발화]+)', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1)).strip('()')
|
||||||
|
if val and len(val) < 60:
|
||||||
|
result['state'] = val
|
||||||
|
|
||||||
|
# 색상 (상세)
|
||||||
|
m = re.search(r'색상\s+([^\n상온냄새비중증기인화발화]+)', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'색상\s*\n\s*([^\n상온냄새비중증기인화발화]+)', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 40:
|
||||||
|
result['color'] = val
|
||||||
|
|
||||||
|
# 냄새 (상세)
|
||||||
|
m = re.search(r'냄새\s+([^\n상온색상비중증기인화발화]+)', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'냄새\s*\n\s*([^\n상온색상비중증기인화발화]+)', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 60:
|
||||||
|
result['odor'] = val
|
||||||
|
|
||||||
|
# 비중 (상세)
|
||||||
|
m = re.search(r'비중\s+([0-9][^\n증기점도휘발]{0,25})', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'비중\s*\n\s*([0-9][^\n증기점도휘발]{0,25})', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 30:
|
||||||
|
result['density'] = val
|
||||||
|
|
||||||
|
# 증기압 (상세)
|
||||||
|
m = re.search(r'증기압\s+([^\n증기밀도점도휘발]{3,40})', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'증기압\s*\n\s*([^\n증기밀도점도휘발]{3,40})', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and re.search(r'\d', val):
|
||||||
|
result['vaporPressure'] = val
|
||||||
|
|
||||||
|
# 증기밀도 (상세)
|
||||||
|
m = re.search(r'증기밀도\s+([0-9,\.][^\n]{0,15})', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'증기밀도\s*\n\s*([0-9,\.][^\n]{0,15})', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 20:
|
||||||
|
result['vaporDensity'] = val
|
||||||
|
|
||||||
|
# 점도
|
||||||
|
m = re.search(r'점도\s+([0-9][^\n]{0,25})', props_text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'점도\s*\n\s*([0-9][^\n]{0,25})', props_text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and len(val) < 30:
|
||||||
|
result['viscosity'] = val
|
||||||
|
|
||||||
|
# ── 인체유해성 블록 ─────────────────────────────────────────────────
|
||||||
|
hazard_start = max(text.find('인체 유해성'), text.find('인체유해성'))
|
||||||
|
if hazard_start < 0:
|
||||||
|
hazard_start = text.find('급성독성')
|
||||||
|
response_start = text.find('초동대응')
|
||||||
|
hazard_text = text[hazard_start:response_start] if 0 <= hazard_start < response_start else ''
|
||||||
|
|
||||||
|
# IDLH
|
||||||
|
m = re.search(r'I?DLH[^\n]{0,20}\n?\s*([0-9][^\n]{0,20})', hazard_text or text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and re.search(r'\d', val):
|
||||||
|
result['idlh'] = val
|
||||||
|
|
||||||
|
# TWA
|
||||||
|
m = re.search(r'TWA[^\n]{0,20}\n?\s*([0-9][^\n]{0,20})', hazard_text or text)
|
||||||
|
if m:
|
||||||
|
val = clean(m.group(1))
|
||||||
|
if val and re.search(r'\d', val):
|
||||||
|
result['twa'] = val
|
||||||
|
|
||||||
|
# ── 응급조치 ─────────────────────────────────────────────────────────
|
||||||
|
fa_start = text.find('흡입노출')
|
||||||
|
fa_end = text.find('초동대응')
|
||||||
|
if fa_start >= 0:
|
||||||
|
fa_text = text[fa_start: fa_end if fa_end > fa_start else fa_start + 600]
|
||||||
|
fa = re.sub(r'\s+', ' ', fa_text).strip()
|
||||||
|
result['msds'] = {
|
||||||
|
'firstAid': fa[:600],
|
||||||
|
'spillResponse': '',
|
||||||
|
'hazard': '',
|
||||||
|
'fireFighting': '',
|
||||||
|
'exposure': '',
|
||||||
|
'regulation': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 초동대응 - 이격거리/방호거리 (상세카드에서) ─────────────────────
|
||||||
|
m = re.search(r'초기\s*이격거리[^\n]{0,10}m[^\n]{0,5}\n?\s*([0-9]+)', text)
|
||||||
|
if m:
|
||||||
|
result['responseDistanceSpillDay'] = m.group(1) + 'm'
|
||||||
|
|
||||||
|
m = re.search(r'방호거리[^\n]{0,10}m[^\n]{0,5}\n?\s*([0-9]+)', text)
|
||||||
|
if m:
|
||||||
|
result['responseDistanceSpillNight'] = m.group(1) + 'm'
|
||||||
|
|
||||||
|
m = re.search(r'화재\s*시\s*대피거리[^\n]{0,10}m[^\n]{0,5}\n?\s*([0-9]+)', text)
|
||||||
|
if m:
|
||||||
|
result['responseDistanceFire'] = m.group(1) + 'm'
|
||||||
|
|
||||||
|
# ── GHS 분류 ─────────────────────────────────────────────────────────
|
||||||
|
ghs_items = re.findall(
|
||||||
|
r'(?:인화성[^\n((]{2,40}|급성독성[^\n((]{2,40}|피부부식[^\n((]{2,40}|'
|
||||||
|
r'눈\s*손상[^\n((]{2,40}|발암성[^\n((]{2,40}|생식독성[^\n((]{2,40}|'
|
||||||
|
r'수생환경[^\n((]{2,40}|특정표적[^\n((]{2,40}|흡인유해[^\n((]{2,40})',
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
if ghs_items:
|
||||||
|
result['ghsClass'] = ' / '.join(clean(g) for g in ghs_items[:6])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def parse_index_pages(pdf: fitz.Document) -> dict[int, dict]:
|
||||||
|
"""목차 페이지(4-21)에서 NO → {nameKr, nameEn, casNumber} 매핑 구축."""
|
||||||
|
index: dict[int, dict] = {}
|
||||||
|
|
||||||
|
for page_idx in range(3, 21):
|
||||||
|
page = pdf[page_idx]
|
||||||
|
text = page.get_text()
|
||||||
|
lines = [ln.strip() for ln in text.split('\n') if ln.strip()]
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if not re.match(r'^\d{1,3}$', line):
|
||||||
|
continue
|
||||||
|
no = int(line)
|
||||||
|
if not (1 <= no <= 193):
|
||||||
|
continue
|
||||||
|
if no in index:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 탐색 창: NO 앞 1~4줄
|
||||||
|
cas, name_en, name_kr = '', '', ''
|
||||||
|
if i >= 1:
|
||||||
|
# CAS 줄: 숫자-숫자-숫자 패턴 (OCR 노이즈 허용)
|
||||||
|
raw_cas = lines[i - 1]
|
||||||
|
cas = normalize_cas(raw_cas) if re.match(r'^[0-9이이아oO0-9\-—-"\'\. ]{5,30}$|혼합물', raw_cas) else ''
|
||||||
|
if not cas and '혼합물' in raw_cas:
|
||||||
|
cas = '혼합물'
|
||||||
|
|
||||||
|
if cas or '혼합물' in (lines[i - 1] if i >= 1 else ''):
|
||||||
|
if i >= 2:
|
||||||
|
name_en = lines[i - 2]
|
||||||
|
if i >= 3:
|
||||||
|
name_kr = lines[i - 3]
|
||||||
|
elif i >= 2:
|
||||||
|
# CAS가 없는 경우(매칭 실패) - 줄 이동해서 재탐색
|
||||||
|
raw_cas2 = lines[i - 2] if i >= 2 else ''
|
||||||
|
cas = normalize_cas(raw_cas2) if re.match(r'^[0-9이이아oO0-9\-—-"\'\. ]{5,30}$|혼합물', raw_cas2) else ''
|
||||||
|
if cas or '혼합물' in raw_cas2:
|
||||||
|
name_en = lines[i - 1] if i >= 1 else ''
|
||||||
|
# name_kr는 찾기 어려움
|
||||||
|
|
||||||
|
if not name_kr and i >= 3:
|
||||||
|
# 이름이 공백/짧으면 더 위 줄에서 찾기
|
||||||
|
for j in range(3, min(6, i + 1)):
|
||||||
|
cand = lines[i - j]
|
||||||
|
if re.search(r'[가-힣]', cand) and len(cand) > 1:
|
||||||
|
name_kr = cand
|
||||||
|
break
|
||||||
|
|
||||||
|
index[no] = {
|
||||||
|
'no': no,
|
||||||
|
'nameKr': name_kr,
|
||||||
|
'nameEn': name_en,
|
||||||
|
'casNumber': cas if cas != '혼합물' else '',
|
||||||
|
}
|
||||||
|
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def extract_name_from_summary(text: str) -> tuple[str, str]:
|
||||||
|
"""요약 카드에서 nameKr, nameEn 추출."""
|
||||||
|
name_kr, name_en = '', ''
|
||||||
|
lines = text.strip().split('\n')
|
||||||
|
|
||||||
|
# 1~6번 줄에서 한글 이름 탐색 (헤더 "해상화학사고 대응 물질정보집" 이후)
|
||||||
|
found_header = False
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
# 제목 줄 건너뜀
|
||||||
|
if '해상화학사고' in line or '대응' in line or '물질정보집' in line:
|
||||||
|
found_header = True
|
||||||
|
continue
|
||||||
|
# 3자리 번호 줄 건너뜀
|
||||||
|
if re.match(r'^\d{1,3}$', line):
|
||||||
|
continue
|
||||||
|
# 한글이 있으면 nameKr 후보
|
||||||
|
if re.search(r'[가-힣]', line) and len(line) > 1 and '위험' not in line and '분류' not in line:
|
||||||
|
if not name_kr:
|
||||||
|
name_kr = clean(line)
|
||||||
|
|
||||||
|
# 영문명: (영문명) 형태
|
||||||
|
m_en = re.search(r'[((]([A-Za-z][^))]{3,60})[))]', line)
|
||||||
|
if m_en and not name_en:
|
||||||
|
name_en = clean(m_en.group(1))
|
||||||
|
|
||||||
|
if name_kr and name_en:
|
||||||
|
break
|
||||||
|
|
||||||
|
return name_kr, name_en
|
||||||
|
|
||||||
|
|
||||||
|
def parse_substance(pdf: fitz.Document, no: int, index_entry: dict) -> dict | None:
|
||||||
|
"""물질 번호 no에 해당하는 2페이지를 파싱하여 통합 레코드 반환."""
|
||||||
|
start_idx = 21 + (no - 1) * 2
|
||||||
|
if start_idx + 1 >= pdf.page_count:
|
||||||
|
return None
|
||||||
|
|
||||||
|
summary_text = pdf[start_idx].get_text()
|
||||||
|
detail_text = pdf[start_idx + 1].get_text()
|
||||||
|
|
||||||
|
summary = parse_summary_card(summary_text, index_entry)
|
||||||
|
detail = parse_detail_card(detail_text)
|
||||||
|
|
||||||
|
# nameKr 결정 우선순위: 인덱스 > 상세카드 헤더 > 요약카드
|
||||||
|
name_kr = index_entry.get('nameKr', '')
|
||||||
|
if not name_kr:
|
||||||
|
name_kr = detail.get('nameKr', '')
|
||||||
|
if not name_kr:
|
||||||
|
name_kr, _ = extract_name_from_summary(summary_text)
|
||||||
|
|
||||||
|
# nameEn
|
||||||
|
name_en = index_entry.get('nameEn', '')
|
||||||
|
|
||||||
|
# 통합: detail 우선, 없으면 summary
|
||||||
|
merged: dict = {
|
||||||
|
'nameKr': name_kr,
|
||||||
|
'nameEn': name_en,
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in ['casNumber', 'unNumber', 'usage', 'synonymsKr',
|
||||||
|
'flashPoint', 'autoIgnition', 'boilingPoint', 'density', 'solubility',
|
||||||
|
'vaporPressure', 'vaporDensity', 'volatility', 'explosionRange',
|
||||||
|
'state', 'color', 'odor', 'viscosity', 'idlh', 'twa',
|
||||||
|
'responseDistanceFire', 'responseDistanceSpillDay', 'responseDistanceSpillNight',
|
||||||
|
'marineResponse', 'hazardClass', 'ghsClass', 'materialSummary', 'msds']:
|
||||||
|
detail_val = detail.get(key)
|
||||||
|
summary_val = summary.get(key)
|
||||||
|
if detail_val:
|
||||||
|
merged[key] = detail_val
|
||||||
|
elif summary_val:
|
||||||
|
merged[key] = summary_val
|
||||||
|
|
||||||
|
# CAS: 인덱스 우선
|
||||||
|
if index_entry.get('casNumber') and not merged.get('casNumber'):
|
||||||
|
merged['casNumber'] = index_entry['casNumber']
|
||||||
|
|
||||||
|
# NFPA: detail 우선
|
||||||
|
if 'nfpa' in detail:
|
||||||
|
merged['nfpa'] = detail['nfpa']
|
||||||
|
|
||||||
|
if 'msds' not in merged:
|
||||||
|
merged['msds'] = {
|
||||||
|
'firstAid': '', 'spillResponse': '', 'hazard': '',
|
||||||
|
'fireFighting': '', 'exposure': '', 'regulation': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
merged['_no'] = no
|
||||||
|
merged['_pageIdx'] = start_idx
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if not PDF_PATH.exists():
|
||||||
|
raise SystemExit(f'PDF 파일 없음: {PDF_PATH}')
|
||||||
|
|
||||||
|
print(f'[읽기] {PDF_PATH}')
|
||||||
|
pdf = fitz.open(str(PDF_PATH))
|
||||||
|
print(f'[PDF] 총 {pdf.page_count}페이지')
|
||||||
|
|
||||||
|
# 1. 인덱스 파싱
|
||||||
|
print('[인덱스] 목차 페이지 파싱 중...')
|
||||||
|
index = parse_index_pages(pdf)
|
||||||
|
print(f'[인덱스] {len(index)}개 항목 발견')
|
||||||
|
|
||||||
|
# 2. 물질 카드 파싱
|
||||||
|
results: dict[str, dict] = {}
|
||||||
|
failed: list[int] = []
|
||||||
|
|
||||||
|
for no in range(1, 194):
|
||||||
|
entry = index.get(no, {'no': no, 'nameKr': '', 'nameEn': '', 'casNumber': ''})
|
||||||
|
try:
|
||||||
|
rec = parse_substance(pdf, no, entry)
|
||||||
|
if rec:
|
||||||
|
name_kr = rec.get('nameKr', '')
|
||||||
|
if name_kr:
|
||||||
|
key = name_kr
|
||||||
|
if key in results:
|
||||||
|
key = f'{name_kr}_{no}'
|
||||||
|
results[key] = rec
|
||||||
|
else:
|
||||||
|
print(f' [경고] NO={no} nameKr 없음 - 건너뜀')
|
||||||
|
failed.append(no)
|
||||||
|
except Exception as e:
|
||||||
|
print(f' [오류] NO={no}: {e}')
|
||||||
|
failed.append(no)
|
||||||
|
|
||||||
|
pdf.close()
|
||||||
|
|
||||||
|
# 3. 저장
|
||||||
|
out_path = OUT_DIR / 'pdf-data.json'
|
||||||
|
with open(out_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
size_kb = out_path.stat().st_size / 1024
|
||||||
|
print(f'\n[완료] {out_path} ({size_kb:.0f} KB, {len(results)}종)')
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
print(f'[경고] 파싱 실패 {len(failed)}종: {failed}')
|
||||||
|
|
||||||
|
# 4. 통계
|
||||||
|
with_flash = sum(1 for v in results.values() if v.get('flashPoint'))
|
||||||
|
with_nfpa = sum(1 for v in results.values() if v.get('nfpa'))
|
||||||
|
with_cas = sum(1 for v in results.values() if v.get('casNumber'))
|
||||||
|
with_syn = sum(1 for v in results.values() if v.get('synonymsKr'))
|
||||||
|
print(f'[통계] 인화점: {with_flash}종, NFPA: {with_nfpa}종, CAS: {with_cas}종, 유사명: {with_syn}종')
|
||||||
|
|
||||||
|
# 5. 샘플 출력
|
||||||
|
print('\n[샘플] 주요 항목:')
|
||||||
|
sample_keys = ['과산화수소', '나프탈렌', '벤젠', '톨루엔']
|
||||||
|
for k in sample_keys:
|
||||||
|
if k in results:
|
||||||
|
v = results[k]
|
||||||
|
print(f' {k}: fp={v.get("flashPoint","")} nfpa={v.get("nfpa")} cas={v.get("casNumber","")}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
23
backend/scripts/hns-import/merge-batch.py
Normal file
23
backend/scripts/hns-import/merge-batch.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
"""배치 JSON을 ocr.json에 병합."""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||||
|
OUT_DIR = SCRIPT_DIR / 'out'
|
||||||
|
OCR_PATH = OUT_DIR / 'ocr.json'
|
||||||
|
BATCH_PATH = OUT_DIR / 'batch.json'
|
||||||
|
|
||||||
|
with open(OCR_PATH, encoding='utf-8') as f:
|
||||||
|
ocr = json.load(f)
|
||||||
|
with open(BATCH_PATH, encoding='utf-8') as f:
|
||||||
|
batch = json.load(f)
|
||||||
|
|
||||||
|
added = [k for k in batch if k not in ocr]
|
||||||
|
updated = [k for k in batch if k in ocr]
|
||||||
|
ocr.update(batch)
|
||||||
|
|
||||||
|
with open(OCR_PATH, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(ocr, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f'merged: +{len(added)} added, ~{len(updated)} updated, total {len(ocr)}')
|
||||||
362
backend/scripts/hns-import/merge-data.ts
Normal file
362
backend/scripts/hns-import/merge-data.ts
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
/**
|
||||||
|
* base.json + pdf-data.json + ocr.json → frontend/src/data/hnsSubstanceData.json
|
||||||
|
*
|
||||||
|
* 우선순위: pdf-data (PDF 텍스트 추출, 최고 정확도) > base.json > ocr.json (이미지 OCR, 낮은 정확도)
|
||||||
|
* 매칭 키 순서:
|
||||||
|
* 1. CAS 번호 (가장 신뢰할 수 있는 식별자)
|
||||||
|
* 2. 국문명(nameKr) 정규화 비교
|
||||||
|
* 3. 동의어(synonymsKr) 정규화 비교
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const OUT_DIR = resolve(__dirname, 'out');
|
||||||
|
const BASE_PATH = resolve(OUT_DIR, 'base.json');
|
||||||
|
const PDF_PATH = resolve(OUT_DIR, 'pdf-data.json');
|
||||||
|
const OCR_PATH = resolve(OUT_DIR, 'ocr.json');
|
||||||
|
const TARGET_PATH = resolve(__dirname, '../../../frontend/src/data/hnsSubstanceData.json');
|
||||||
|
|
||||||
|
function normalizeName(s: string | undefined): string {
|
||||||
|
if (!s) return '';
|
||||||
|
return s
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
.replace(/[,.·/\-_()[\]()]/g, '')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCas(s: string | undefined): string {
|
||||||
|
if (!s) return '';
|
||||||
|
// 앞자리 0 제거 후 정규화
|
||||||
|
return s
|
||||||
|
.replace(/[^0-9\-]/g, '')
|
||||||
|
.replace(/^0+/, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NfpaBlock {
|
||||||
|
health: number;
|
||||||
|
fire: number;
|
||||||
|
reactivity: number;
|
||||||
|
special: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MsdsBlock {
|
||||||
|
hazard: string;
|
||||||
|
firstAid: string;
|
||||||
|
fireFighting: string;
|
||||||
|
spillResponse: string;
|
||||||
|
exposure: string;
|
||||||
|
regulation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseRecord {
|
||||||
|
id: number;
|
||||||
|
abbreviation: string;
|
||||||
|
nameKr: string;
|
||||||
|
nameEn: string;
|
||||||
|
synonymsEn: string;
|
||||||
|
synonymsKr: string;
|
||||||
|
unNumber: string;
|
||||||
|
casNumber: string;
|
||||||
|
transportMethod: string;
|
||||||
|
sebc: string;
|
||||||
|
usage: string;
|
||||||
|
state: string;
|
||||||
|
color: string;
|
||||||
|
odor: string;
|
||||||
|
flashPoint: string;
|
||||||
|
autoIgnition: string;
|
||||||
|
boilingPoint: string;
|
||||||
|
density: string;
|
||||||
|
solubility: string;
|
||||||
|
vaporPressure: string;
|
||||||
|
vaporDensity: string;
|
||||||
|
explosionRange: string;
|
||||||
|
nfpa: NfpaBlock;
|
||||||
|
hazardClass: string;
|
||||||
|
ergNumber: string;
|
||||||
|
idlh: string;
|
||||||
|
aegl2: string;
|
||||||
|
erpg2: string;
|
||||||
|
responseDistanceFire: string;
|
||||||
|
responseDistanceSpillDay: string;
|
||||||
|
responseDistanceSpillNight: string;
|
||||||
|
marineResponse: string;
|
||||||
|
ppeClose: string;
|
||||||
|
ppeFar: string;
|
||||||
|
msds: MsdsBlock;
|
||||||
|
ibcHazard: string;
|
||||||
|
ibcShipType: string;
|
||||||
|
ibcTankType: string;
|
||||||
|
ibcDetection: string;
|
||||||
|
ibcFireFighting: string;
|
||||||
|
ibcMinRequirement: string;
|
||||||
|
emsCode: string;
|
||||||
|
emsFire: string;
|
||||||
|
emsSpill: string;
|
||||||
|
emsFirstAid: string;
|
||||||
|
cargoCodes: Array<{ code: string; name: string; company: string; source: string }>;
|
||||||
|
portFrequency: Array<{ port: string; portCode: string; lastImport: string; frequency: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PdfResult {
|
||||||
|
[key: string]: unknown;
|
||||||
|
casNumber?: string;
|
||||||
|
nameKr?: string;
|
||||||
|
nfpa?: Partial<NfpaBlock>;
|
||||||
|
msds?: Partial<MsdsBlock>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OcrResult {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstString(...values: Array<unknown>): string {
|
||||||
|
for (const v of values) {
|
||||||
|
if (typeof v === 'string' && v.trim().length > 0) return v.trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNfpa(source: PdfResult | OcrResult): NfpaBlock | null {
|
||||||
|
const n = source.nfpa as Partial<NfpaBlock> | undefined;
|
||||||
|
if (!n || typeof n !== 'object') return null;
|
||||||
|
const h = Number(n.health);
|
||||||
|
const f = Number(n.fire);
|
||||||
|
const r = Number(n.reactivity);
|
||||||
|
if ([h, f, r].some((x) => !Number.isFinite(x))) return null;
|
||||||
|
return {
|
||||||
|
health: h,
|
||||||
|
fire: f,
|
||||||
|
reactivity: r,
|
||||||
|
special: typeof n.special === 'string' ? n.special : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickMsds(
|
||||||
|
pdf: PdfResult | undefined,
|
||||||
|
ocr: OcrResult | undefined,
|
||||||
|
base: MsdsBlock,
|
||||||
|
): MsdsBlock {
|
||||||
|
const p = (pdf?.msds ?? {}) as Partial<MsdsBlock>;
|
||||||
|
const o = (ocr?.msds ?? {}) as Partial<MsdsBlock>;
|
||||||
|
return {
|
||||||
|
hazard: firstString(base.hazard, p.hazard, o.hazard),
|
||||||
|
firstAid: firstString(base.firstAid, p.firstAid, o.firstAid),
|
||||||
|
fireFighting: firstString(base.fireFighting, p.fireFighting, o.fireFighting),
|
||||||
|
spillResponse: firstString(base.spillResponse, p.spillResponse, o.spillResponse),
|
||||||
|
exposure: firstString(base.exposure, p.exposure, o.exposure),
|
||||||
|
regulation: firstString(base.regulation, p.regulation, o.regulation),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function merge(
|
||||||
|
base: BaseRecord,
|
||||||
|
pdf: PdfResult | undefined,
|
||||||
|
ocr: OcrResult | undefined,
|
||||||
|
): BaseRecord {
|
||||||
|
const nfpaFromPdf = pdf ? pickNfpa(pdf) : null;
|
||||||
|
const nfpaFromOcr = ocr ? pickNfpa(ocr) : null;
|
||||||
|
// pdf NFPA 우선, 없으면 ocr, 없으면 base
|
||||||
|
const nfpa = nfpaFromPdf ?? nfpaFromOcr ?? base.nfpa;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
// pdf > base > ocr 우선순위
|
||||||
|
unNumber: firstString(pdf?.unNumber, base.unNumber, ocr?.unNumber),
|
||||||
|
casNumber: firstString(pdf?.casNumber, base.casNumber, ocr?.casNumber),
|
||||||
|
synonymsKr: firstString(pdf?.synonymsKr, base.synonymsKr, ocr?.synonymsKr),
|
||||||
|
transportMethod: firstString(base.transportMethod, pdf?.transportMethod, ocr?.transportMethod),
|
||||||
|
sebc: firstString(base.sebc, pdf?.sebc, ocr?.sebc),
|
||||||
|
usage: firstString(pdf?.usage, base.usage, ocr?.usage),
|
||||||
|
state: firstString(pdf?.state, base.state, ocr?.state),
|
||||||
|
color: firstString(pdf?.color, base.color, ocr?.color),
|
||||||
|
odor: firstString(pdf?.odor, base.odor, ocr?.odor),
|
||||||
|
flashPoint: firstString(pdf?.flashPoint, base.flashPoint, ocr?.flashPoint),
|
||||||
|
autoIgnition: firstString(pdf?.autoIgnition, base.autoIgnition, ocr?.autoIgnition),
|
||||||
|
boilingPoint: firstString(pdf?.boilingPoint, base.boilingPoint, ocr?.boilingPoint),
|
||||||
|
density: firstString(pdf?.density, base.density, ocr?.density),
|
||||||
|
solubility: firstString(pdf?.solubility, base.solubility, ocr?.solubility),
|
||||||
|
vaporPressure: firstString(pdf?.vaporPressure, base.vaporPressure, ocr?.vaporPressure),
|
||||||
|
vaporDensity: firstString(pdf?.vaporDensity, base.vaporDensity, ocr?.vaporDensity),
|
||||||
|
explosionRange: firstString(pdf?.explosionRange, base.explosionRange, ocr?.explosionRange),
|
||||||
|
nfpa,
|
||||||
|
hazardClass: firstString(pdf?.hazardClass, base.hazardClass, ocr?.hazardClass),
|
||||||
|
ergNumber: firstString(base.ergNumber, pdf?.ergNumber, ocr?.ergNumber),
|
||||||
|
idlh: firstString(pdf?.idlh, base.idlh, ocr?.idlh),
|
||||||
|
aegl2: firstString(base.aegl2, pdf?.aegl2, ocr?.aegl2),
|
||||||
|
erpg2: firstString(base.erpg2, pdf?.erpg2, ocr?.erpg2),
|
||||||
|
responseDistanceFire: firstString(pdf?.responseDistanceFire, base.responseDistanceFire, ocr?.responseDistanceFire),
|
||||||
|
responseDistanceSpillDay: firstString(pdf?.responseDistanceSpillDay, base.responseDistanceSpillDay, ocr?.responseDistanceSpillDay),
|
||||||
|
responseDistanceSpillNight: firstString(pdf?.responseDistanceSpillNight, base.responseDistanceSpillNight, ocr?.responseDistanceSpillNight),
|
||||||
|
marineResponse: firstString(pdf?.marineResponse, base.marineResponse, ocr?.marineResponse),
|
||||||
|
ppeClose: firstString(base.ppeClose, pdf?.ppeClose, ocr?.ppeClose),
|
||||||
|
ppeFar: firstString(base.ppeFar, pdf?.ppeFar, ocr?.ppeFar),
|
||||||
|
msds: pickMsds(pdf, ocr, base.msds),
|
||||||
|
emsCode: firstString(base.emsCode, pdf?.emsCode, ocr?.emsCode),
|
||||||
|
emsFire: firstString(base.emsFire, pdf?.emsFire, ocr?.emsFire),
|
||||||
|
emsSpill: firstString(base.emsSpill, pdf?.emsSpill, ocr?.emsSpill),
|
||||||
|
emsFirstAid: firstString(base.emsFirstAid, pdf?.emsFirstAid, ocr?.emsFirstAid),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if (!existsSync(BASE_PATH)) {
|
||||||
|
console.error(`base.json 없음: ${BASE_PATH}`);
|
||||||
|
console.error('→ extract-excel.py 를 먼저 실행하세요.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base: BaseRecord[] = JSON.parse(readFileSync(BASE_PATH, 'utf-8'));
|
||||||
|
|
||||||
|
// PDF 데이터 로드
|
||||||
|
const pdfRaw: Record<string, PdfResult> = existsSync(PDF_PATH)
|
||||||
|
? JSON.parse(readFileSync(PDF_PATH, 'utf-8'))
|
||||||
|
: {};
|
||||||
|
|
||||||
|
// OCR 데이터 로드
|
||||||
|
const ocr: Record<string, OcrResult> = existsSync(OCR_PATH)
|
||||||
|
? JSON.parse(readFileSync(OCR_PATH, 'utf-8'))
|
||||||
|
: {};
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[입력] base ${base.length}종, pdf ${Object.keys(pdfRaw).length}종, ocr ${Object.keys(ocr).length}종`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── PDF 인덱스 구축 ─────────────────────────────────────────────────
|
||||||
|
// 1) nameKr 정규화 인덱스
|
||||||
|
const pdfByName = new Map<string, PdfResult>();
|
||||||
|
// 2) CAS 번호 인덱스
|
||||||
|
const pdfByCas = new Map<string, PdfResult>();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(pdfRaw)) {
|
||||||
|
const normKey = normalizeName(key);
|
||||||
|
if (normKey) pdfByName.set(normKey, value);
|
||||||
|
|
||||||
|
const cas = normalizeCas(value.casNumber);
|
||||||
|
if (cas) {
|
||||||
|
if (!pdfByCas.has(cas)) pdfByCas.set(cas, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OCR 인덱스 구축 ─────────────────────────────────────────────────
|
||||||
|
const ocrByName = new Map<string, OcrResult>();
|
||||||
|
const ocrNormToOrig = new Map<string, string>();
|
||||||
|
for (const [key, value] of Object.entries(ocr)) {
|
||||||
|
const normKey = normalizeName(key);
|
||||||
|
if (normKey) {
|
||||||
|
ocrByName.set(normKey, value);
|
||||||
|
ocrNormToOrig.set(normKey, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 병합 ──────────────────────────────────────────────────────────
|
||||||
|
let pdfMatchedByName = 0;
|
||||||
|
let pdfMatchedByCas = 0;
|
||||||
|
let pdfMatchedBySynonym = 0;
|
||||||
|
let ocrMatched = 0;
|
||||||
|
const pdfUnmatched = new Set(Object.keys(pdfRaw));
|
||||||
|
const ocrUnmatched = new Set(ocrByName.keys());
|
||||||
|
|
||||||
|
const merged = base.map((record) => {
|
||||||
|
let pdfResult: PdfResult | undefined;
|
||||||
|
let ocrResult: OcrResult | undefined;
|
||||||
|
|
||||||
|
// ── PDF 매칭 ────────────────────────────────────────────────────
|
||||||
|
// 1. CAS 번호 매칭 (가장 정확)
|
||||||
|
const baseCas = normalizeCas(record.casNumber);
|
||||||
|
if (baseCas) {
|
||||||
|
pdfResult = pdfByCas.get(baseCas);
|
||||||
|
if (pdfResult) {
|
||||||
|
pdfMatchedByCas++;
|
||||||
|
const origKey = pdfResult.nameKr as string | undefined;
|
||||||
|
if (origKey) pdfUnmatched.delete(origKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. nameKr 정규화 매칭
|
||||||
|
if (!pdfResult) {
|
||||||
|
const normKr = normalizeName(record.nameKr);
|
||||||
|
pdfResult = pdfByName.get(normKr);
|
||||||
|
if (pdfResult) {
|
||||||
|
pdfMatchedByName++;
|
||||||
|
const origKey = pdfResult.nameKr as string | undefined;
|
||||||
|
if (origKey) pdfUnmatched.delete(origKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. synonymsKr 동의어 매칭
|
||||||
|
if (!pdfResult && record.synonymsKr) {
|
||||||
|
const synonyms = record.synonymsKr.split(' / ');
|
||||||
|
for (const syn of synonyms) {
|
||||||
|
const normSyn = normalizeName(syn);
|
||||||
|
if (!normSyn) continue;
|
||||||
|
pdfResult = pdfByName.get(normSyn);
|
||||||
|
if (pdfResult) {
|
||||||
|
pdfMatchedBySynonym++;
|
||||||
|
const origKey = pdfResult.nameKr as string | undefined;
|
||||||
|
if (origKey) pdfUnmatched.delete(origKey);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OCR 매칭 (PDF 없는 경우 보조) ────────────────────────────────
|
||||||
|
const normKr = normalizeName(record.nameKr);
|
||||||
|
const ocrByNameResult = ocrByName.get(normKr);
|
||||||
|
if (ocrByNameResult) {
|
||||||
|
ocrResult = ocrByNameResult;
|
||||||
|
ocrMatched++;
|
||||||
|
ocrUnmatched.delete(normKr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ocrResult && record.synonymsKr) {
|
||||||
|
const synonyms = record.synonymsKr.split(' / ');
|
||||||
|
for (const syn of synonyms) {
|
||||||
|
const normSyn = normalizeName(syn);
|
||||||
|
if (!normSyn) continue;
|
||||||
|
const synOcrResult = ocrByName.get(normSyn);
|
||||||
|
if (synOcrResult) {
|
||||||
|
ocrResult = synOcrResult;
|
||||||
|
ocrMatched++;
|
||||||
|
ocrUnmatched.delete(normSyn);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merge(record, pdfResult, ocrResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 통계 출력 ──────────────────────────────────────────────────────
|
||||||
|
const pdfTotal = pdfMatchedByCas + pdfMatchedByName + pdfMatchedBySynonym;
|
||||||
|
console.log(
|
||||||
|
`[PDF 매칭] 총 ${pdfTotal}종 (CAS: ${pdfMatchedByCas}, 국문명: ${pdfMatchedByName}, 동의어: ${pdfMatchedBySynonym})`,
|
||||||
|
);
|
||||||
|
console.log(`[OCR 매칭] ${ocrMatched}종`);
|
||||||
|
|
||||||
|
if (pdfUnmatched.size > 0) {
|
||||||
|
const unmatchedList = Array.from(pdfUnmatched).sort();
|
||||||
|
const unmatchedPath = resolve(OUT_DIR, 'pdf-unmatched.json');
|
||||||
|
writeFileSync(
|
||||||
|
unmatchedPath,
|
||||||
|
JSON.stringify({ count: unmatchedList.length, keys: unmatchedList }, null, 2),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
console.warn(
|
||||||
|
`[경고] PDF 매칭 실패 ${unmatchedList.length}개 → ${unmatchedPath}`,
|
||||||
|
);
|
||||||
|
unmatchedList.slice(0, 10).forEach((k) => console.warn(` - ${k}`));
|
||||||
|
if (unmatchedList.length > 10) console.warn(` ... +${unmatchedList.length - 10}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(TARGET_PATH, JSON.stringify(merged, null, 2), 'utf-8');
|
||||||
|
const sizeKb = (JSON.stringify(merged).length / 1024).toFixed(0);
|
||||||
|
console.log(`[완료] ${TARGET_PATH} (${sizeKb} KB, ${merged.length}종)`);
|
||||||
|
console.log(` 상세 정보 보유: ${merged.filter((r) => r.flashPoint).length}종`);
|
||||||
|
console.log(` NFPA 있음: ${merged.filter((r) => r.nfpa.health || r.nfpa.fire || r.nfpa.reactivity).length}종`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
300
backend/scripts/hns-import/ocr-claude-vision.ts
Normal file
300
backend/scripts/hns-import/ocr-claude-vision.ts
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* Claude Vision API 로 HNS 카드 이미지 → 구조화 JSON 변환.
|
||||||
|
*
|
||||||
|
* 입력: out/images/*.png (222개)
|
||||||
|
* 출력: out/ocr.json { [nameKr]: Partial<HNSSearchSubstance> }
|
||||||
|
*
|
||||||
|
* 환경변수: ANTHROPIC_API_KEY
|
||||||
|
* 모델: claude-sonnet-4-5 (Vision + 비용 효율)
|
||||||
|
* 동시성: 5, 재시도 3회
|
||||||
|
*
|
||||||
|
* 재실행 시 기존 ocr.json 의 결과는 유지하고 누락된 이미지만 처리한다.
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import Anthropic from '@anthropic-ai/sdk';
|
||||||
|
import { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs';
|
||||||
|
import { resolve, dirname, basename, extname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const OUT_DIR = resolve(__dirname, 'out');
|
||||||
|
const IMG_DIR = process.env.HNS_OCR_IMG_DIR
|
||||||
|
? resolve(process.env.HNS_OCR_IMG_DIR)
|
||||||
|
: resolve(OUT_DIR, 'images');
|
||||||
|
const OCR_PATH = process.env.HNS_OCR_OUT
|
||||||
|
? resolve(process.env.HNS_OCR_OUT)
|
||||||
|
: resolve(OUT_DIR, 'ocr.json');
|
||||||
|
const FAIL_PATH = process.env.HNS_OCR_FAIL
|
||||||
|
? resolve(process.env.HNS_OCR_FAIL)
|
||||||
|
: resolve(OUT_DIR, 'ocr-failures.json');
|
||||||
|
const OCR_LIMIT = process.env.HNS_OCR_LIMIT ? parseInt(process.env.HNS_OCR_LIMIT, 10) : undefined;
|
||||||
|
const OCR_ONLY = process.env.HNS_OCR_ONLY
|
||||||
|
? process.env.HNS_OCR_ONLY.split(',').map((s) => s.trim()).filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const CONCURRENCY = 5;
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const MODEL = process.env.HNS_OCR_MODEL ?? 'claude-sonnet-4-5';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `당신은 한국 해양 방제용 HNS 비상대응 카드 이미지를 구조화 JSON으로 추출하는 전문 파서입니다.
|
||||||
|
|
||||||
|
이미지는 표준 템플릿을 따르며 다음 섹션을 포함합니다:
|
||||||
|
- 상단: 국문명, 영문명
|
||||||
|
- 물질특성: CAS번호, UN번호, 운송방법, 유사명, 특성(독성/부식성/인화성/유해성), 용도, 상태/색상/냄새, 인화점/발화점/끓는점, 용해도/증기압/증기밀도, 비중/폭발범위, NFPA 다이아몬드(건강/인화/반응), GHS 픽토그램, ERG 지침서 번호
|
||||||
|
- 대응방법: 주요 장비(PPE 근거리/원거리), 화재 대응(EmS F-x), 해상 유출(EmS S-x), 초기이격거리, 방호활동거리
|
||||||
|
- 인체유해성: TWA / STEL / AEGL-2 / IDLH, 흡입/피부/안구/경구 증상·응급조치
|
||||||
|
|
||||||
|
아래 JSON 스키마를 **엄격히** 준수하여 응답하세요. 값이 없거나 읽을 수 없는 경우 빈 문자열 "" 또는 null.
|
||||||
|
숫자는 단위 포함 원문 문자열로 유지 (예: "80℃", "2,410 mmHg (25℃)").
|
||||||
|
NFPA 건강/인화/반응은 0~4 정수. special 은 문자열(특수 표시).
|
||||||
|
|
||||||
|
응답은 **순수 JSON 객체만** 반환 (코드블록이나 설명문 없이).
|
||||||
|
|
||||||
|
스키마:
|
||||||
|
{
|
||||||
|
"transportMethod": "",
|
||||||
|
"state": "",
|
||||||
|
"color": "",
|
||||||
|
"odor": "",
|
||||||
|
"flashPoint": "",
|
||||||
|
"autoIgnition": "",
|
||||||
|
"boilingPoint": "",
|
||||||
|
"density": "",
|
||||||
|
"solubility": "",
|
||||||
|
"vaporPressure": "",
|
||||||
|
"vaporDensity": "",
|
||||||
|
"explosionRange": "",
|
||||||
|
"nfpa": { "health": 0, "fire": 0, "reactivity": 0, "special": "" },
|
||||||
|
"hazardClass": "",
|
||||||
|
"ergNumber": "",
|
||||||
|
"idlh": "",
|
||||||
|
"aegl2": "",
|
||||||
|
"erpg2": "",
|
||||||
|
"twa": "",
|
||||||
|
"stel": "",
|
||||||
|
"responseDistanceFire": "",
|
||||||
|
"responseDistanceSpillDay": "",
|
||||||
|
"responseDistanceSpillNight": "",
|
||||||
|
"marineResponse": "",
|
||||||
|
"ppeClose": "",
|
||||||
|
"ppeFar": "",
|
||||||
|
"msds": {
|
||||||
|
"hazard": "",
|
||||||
|
"firstAid": "",
|
||||||
|
"fireFighting": "",
|
||||||
|
"spillResponse": "",
|
||||||
|
"exposure": "",
|
||||||
|
"regulation": ""
|
||||||
|
},
|
||||||
|
"emsCode": "",
|
||||||
|
"emsFire": "",
|
||||||
|
"emsSpill": "",
|
||||||
|
"emsFirstAid": "",
|
||||||
|
"sebc": ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
interface OcrResult {
|
||||||
|
transportMethod?: string;
|
||||||
|
state?: string;
|
||||||
|
color?: string;
|
||||||
|
odor?: string;
|
||||||
|
flashPoint?: string;
|
||||||
|
autoIgnition?: string;
|
||||||
|
boilingPoint?: string;
|
||||||
|
density?: string;
|
||||||
|
solubility?: string;
|
||||||
|
vaporPressure?: string;
|
||||||
|
vaporDensity?: string;
|
||||||
|
explosionRange?: string;
|
||||||
|
nfpa?: { health: number; fire: number; reactivity: number; special: string };
|
||||||
|
hazardClass?: string;
|
||||||
|
ergNumber?: string;
|
||||||
|
idlh?: string;
|
||||||
|
aegl2?: string;
|
||||||
|
erpg2?: string;
|
||||||
|
twa?: string;
|
||||||
|
stel?: string;
|
||||||
|
responseDistanceFire?: string;
|
||||||
|
responseDistanceSpillDay?: string;
|
||||||
|
responseDistanceSpillNight?: string;
|
||||||
|
marineResponse?: string;
|
||||||
|
ppeClose?: string;
|
||||||
|
ppeFar?: string;
|
||||||
|
msds?: {
|
||||||
|
hazard?: string;
|
||||||
|
firstAid?: string;
|
||||||
|
fireFighting?: string;
|
||||||
|
spillResponse?: string;
|
||||||
|
exposure?: string;
|
||||||
|
regulation?: string;
|
||||||
|
};
|
||||||
|
emsCode?: string;
|
||||||
|
emsFire?: string;
|
||||||
|
emsSpill?: string;
|
||||||
|
emsFirstAid?: string;
|
||||||
|
sebc?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadExisting<T>(path: string, fallback: T): T {
|
||||||
|
if (!existsSync(path)) return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(path, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJson(text: string): OcrResult | null {
|
||||||
|
const cleaned = text.replace(/```json\s*/gi, '').replace(/```\s*$/g, '').trim();
|
||||||
|
const firstBrace = cleaned.indexOf('{');
|
||||||
|
const lastBrace = cleaned.lastIndexOf('}');
|
||||||
|
if (firstBrace < 0 || lastBrace < 0) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(cleaned.slice(firstBrace, lastBrace + 1));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callVision(client: Anthropic, imagePath: string): Promise<OcrResult> {
|
||||||
|
const imageData = readFileSync(imagePath).toString('base64');
|
||||||
|
const ext = extname(imagePath).slice(1).toLowerCase();
|
||||||
|
const mediaType = (ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : 'image/png') as
|
||||||
|
| 'image/png'
|
||||||
|
| 'image/jpeg';
|
||||||
|
|
||||||
|
const response = await client.messages.create({
|
||||||
|
model: MODEL,
|
||||||
|
max_tokens: 4096,
|
||||||
|
system: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: SYSTEM_PROMPT,
|
||||||
|
cache_control: { type: 'ephemeral' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
source: { type: 'base64', media_type: mediaType, data: imageData },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: '이 HNS 비상대응 카드 이미지에서 모든 필드를 추출해 JSON으로 반환하세요.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const textBlock = response.content.find((b) => b.type === 'text');
|
||||||
|
if (!textBlock || textBlock.type !== 'text') {
|
||||||
|
throw new Error('응답에 텍스트 블록 없음');
|
||||||
|
}
|
||||||
|
const result = extractJson(textBlock.text);
|
||||||
|
if (!result) {
|
||||||
|
throw new Error(`JSON 파싱 실패: ${textBlock.text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processWithRetry(
|
||||||
|
client: Anthropic,
|
||||||
|
imagePath: string,
|
||||||
|
nameKr: string,
|
||||||
|
): Promise<OcrResult> {
|
||||||
|
let lastErr: unknown;
|
||||||
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
return await callVision(client, imagePath);
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
const wait = 1000 * Math.pow(2, attempt - 1);
|
||||||
|
console.warn(`[${nameKr}] 시도 ${attempt} 실패, ${wait}ms 후 재시도: ${String(err).slice(0, 120)}`);
|
||||||
|
await new Promise((r) => setTimeout(r, wait));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPool<T>(items: T[], worker: (item: T, idx: number) => Promise<void>) {
|
||||||
|
let cursor = 0;
|
||||||
|
const workers = Array.from({ length: CONCURRENCY }, async () => {
|
||||||
|
while (cursor < items.length) {
|
||||||
|
const idx = cursor++;
|
||||||
|
await worker(items[idx], idx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!process.env.ANTHROPIC_API_KEY) {
|
||||||
|
console.error('ANTHROPIC_API_KEY 환경변수가 없습니다.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const client = new Anthropic();
|
||||||
|
|
||||||
|
if (!existsSync(IMG_DIR)) {
|
||||||
|
console.error(`이미지 디렉토리 없음: ${IMG_DIR}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allImages = readdirSync(IMG_DIR).filter((f) => /\.(png|jpg|jpeg)$/i.test(f));
|
||||||
|
const images = OCR_ONLY
|
||||||
|
? allImages.filter((f) => OCR_ONLY.includes(basename(f, extname(f))))
|
||||||
|
: allImages;
|
||||||
|
const existing: Record<string, OcrResult> = loadExisting(OCR_PATH, {});
|
||||||
|
const failures: Record<string, string> = loadExisting(FAIL_PATH, {});
|
||||||
|
|
||||||
|
let pending = images.filter((f) => {
|
||||||
|
const nameKr = basename(f, extname(f));
|
||||||
|
return !(nameKr in existing);
|
||||||
|
});
|
||||||
|
if (OCR_LIMIT && Number.isFinite(OCR_LIMIT)) {
|
||||||
|
pending = pending.slice(0, OCR_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[OCR] 전체 ${allImages.length}개 중 대상 ${images.length}개, 이미 처리 ${Object.keys(existing).length}개, 이번 실행 ${pending.length}개`);
|
||||||
|
console.log(`[모델] ${MODEL}, 동시 ${CONCURRENCY}, 재시도 최대 ${MAX_RETRIES}`);
|
||||||
|
console.log(`[출력] ${OCR_PATH}`);
|
||||||
|
|
||||||
|
let done = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
await runPool(pending, async (file, idx) => {
|
||||||
|
const nameKr = basename(file, extname(file));
|
||||||
|
const path = resolve(IMG_DIR, file);
|
||||||
|
try {
|
||||||
|
const result = await processWithRetry(client, path, nameKr);
|
||||||
|
existing[nameKr] = result;
|
||||||
|
delete failures[nameKr];
|
||||||
|
done++;
|
||||||
|
if (done % 10 === 0 || done === pending.length) {
|
||||||
|
writeFileSync(OCR_PATH, JSON.stringify(existing, null, 2), 'utf-8');
|
||||||
|
console.log(` 진행 ${done}/${pending.length} (실패 ${failed}) - 중간 저장`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
failed++;
|
||||||
|
failures[nameKr] = String(err).slice(0, 500);
|
||||||
|
console.error(`[실패] ${nameKr}: ${String(err).slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
writeFileSync(OCR_PATH, JSON.stringify(existing, null, 2), 'utf-8');
|
||||||
|
writeFileSync(FAIL_PATH, JSON.stringify(failures, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
console.log(`\n[완료] 성공 ${Object.keys(existing).length} / 실패 ${Object.keys(failures).length}`);
|
||||||
|
console.log(` OCR 결과: ${OCR_PATH}`);
|
||||||
|
if (Object.keys(failures).length > 0) {
|
||||||
|
console.log(` 실패 목록: ${FAIL_PATH} (재실행하면 실패분만 재시도)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
324
backend/scripts/hns-import/ocr-local.py
Normal file
324
backend/scripts/hns-import/ocr-local.py
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
"""로컬 EasyOCR 기반 HNS 카드 이미지 파싱.
|
||||||
|
|
||||||
|
전용 venv(.venv)에 설치된 easyocr을 사용한다.
|
||||||
|
|
||||||
|
1. 이미지 → EasyOCR → (bbox, text, conf) 리스트
|
||||||
|
2. y좌표로 행 그룹화 후 각 행 내 x좌표 정렬
|
||||||
|
3. 레이블 키워드 기반 필드 매핑 (정규식)
|
||||||
|
4. 결과를 out/ocr.json 에 누적 저장 (재실행 가능)
|
||||||
|
|
||||||
|
실행:
|
||||||
|
cd backend/scripts/hns-import
|
||||||
|
source .venv/Scripts/activate # Windows Git Bash
|
||||||
|
python ocr-local.py [--limit N] [--only 벤젠,톨루엔,...]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||||
|
OUT_DIR = SCRIPT_DIR / 'out'
|
||||||
|
IMG_DIR = OUT_DIR / 'images'
|
||||||
|
OCR_PATH_DEFAULT = OUT_DIR / 'ocr.json'
|
||||||
|
FAIL_PATH_DEFAULT = OUT_DIR / 'ocr-failures.json'
|
||||||
|
|
||||||
|
|
||||||
|
# ────────── 필드 레이블 패턴 (EasyOCR 오인식 변형 포함) ──────────
|
||||||
|
# 각 필드의 후보 레이블 문자열(공백 제거 후 비교). 한글 OCR이 종종 비슷한 글자로 오인식되므로
|
||||||
|
# 대표적인 변형도 함께 등록 (예: "인화점" ↔ "인회점", "끓는점" ↔ "꿈는점" ↔ "끝는점").
|
||||||
|
LABEL_CANDIDATES: dict[str, list[str]] = {
|
||||||
|
'casNumber': ['CAS번호', 'CASNO', 'CAS'],
|
||||||
|
'unNumber': ['UN번호', 'UNNO', 'UN'],
|
||||||
|
'transportMethod': ['운송방법', '운승방벌', '운송방벌', '운송방립', '운송'],
|
||||||
|
'usage': ['용도'],
|
||||||
|
'state': ['성상', '상태', '형태'],
|
||||||
|
'color': ['색상', '색'],
|
||||||
|
'odor': ['냄새'],
|
||||||
|
'flashPoint': ['인화점', '인회점', '인하점', '인호점'],
|
||||||
|
'autoIgnition': ['발화점', '발회점', '발하점'],
|
||||||
|
'boilingPoint': ['끓는점', '꿈는점', '끝는점', '끊는점'],
|
||||||
|
'density': ['비중'],
|
||||||
|
'solubility': ['용해도', '용해'],
|
||||||
|
'vaporPressure': ['증기압', '증기압력'],
|
||||||
|
'vaporDensity': ['증기밀도'],
|
||||||
|
'explosionRange': ['폭발범위', '곡발범위', '폭범위', '폭발한계'],
|
||||||
|
'idlh': ['IDLH'],
|
||||||
|
'aegl2': ['AEGL-2', 'AEGL2'],
|
||||||
|
'erpg2': ['ERPG-2', 'ERPG2'],
|
||||||
|
'twa': ['TWA'],
|
||||||
|
'stel': ['STEL'],
|
||||||
|
'ergNumber': ['ERG번호', 'ERG'],
|
||||||
|
'hazardClass': ['위험분류', '위험', '분류'],
|
||||||
|
'synonymsKr': ['유사명'],
|
||||||
|
'responseDistanceFire': ['대피거리', '머피거리'],
|
||||||
|
'ppeClose': ['근거리(레벨A)', '근거리레벨A', '근거리', '레벨A'],
|
||||||
|
'ppeFar': ['원거리(레벨C)', '원거리레벨C', '원거리', '레벨C'],
|
||||||
|
'emsFire': ['화재(F-E)', '화재(F-C)', '화재(F-D)', '화재대응'],
|
||||||
|
'emsSpill': ['유출(S-U)', '유출(S-O)', '유출(S-D)', '해상유출'],
|
||||||
|
'marineResponse': ['해상대응', '해상'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_label(s: str) -> str:
|
||||||
|
"""공백/특수문자 제거 후 비교용 정규화."""
|
||||||
|
return re.sub(r'[\s,.·()\[\]:;\'"-]+', '', s).strip()
|
||||||
|
|
||||||
|
|
||||||
|
LABEL_INDEX: dict[str, str] = {}
|
||||||
|
for _field, _candidates in LABEL_CANDIDATES.items():
|
||||||
|
for _cand in _candidates:
|
||||||
|
LABEL_INDEX[_norm_label(_cand)] = _field
|
||||||
|
|
||||||
|
# NFPA 셀 값(한 자릿수 0~4) 추출용
|
||||||
|
NFPA_VALUE_RE = re.compile(r'^[0-4]$')
|
||||||
|
|
||||||
|
|
||||||
|
def group_rows(items: list[dict], y_tolerance_ratio: float = 0.6) -> list[list[dict]]:
|
||||||
|
"""텍스트 조각들을 y 좌표 기준으로 행 단위로 그룹화 (글자 높이 비례 허용치)."""
|
||||||
|
if not items:
|
||||||
|
return []
|
||||||
|
heights = [it['y1'] - it['y0'] for it in items]
|
||||||
|
median_h = sorted(heights)[len(heights) // 2]
|
||||||
|
y_tol = max(8, median_h * y_tolerance_ratio)
|
||||||
|
|
||||||
|
sorted_items = sorted(items, key=lambda it: it['cy'])
|
||||||
|
rows: list[list[dict]] = []
|
||||||
|
for it in sorted_items:
|
||||||
|
if rows and abs(it['cy'] - rows[-1][-1]['cy']) <= y_tol:
|
||||||
|
rows[-1].append(it)
|
||||||
|
else:
|
||||||
|
rows.append([it])
|
||||||
|
for row in rows:
|
||||||
|
row.sort(key=lambda it: it['cx'])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _match_label(text: str) -> str | None:
|
||||||
|
key = _norm_label(text)
|
||||||
|
if not key:
|
||||||
|
return None
|
||||||
|
# 정확 일치 우선
|
||||||
|
if key in LABEL_INDEX:
|
||||||
|
return LABEL_INDEX[key]
|
||||||
|
# 접두 일치 (OCR이 뒤에 잡티를 붙이는 경우)
|
||||||
|
for cand_key, field in LABEL_INDEX.items():
|
||||||
|
if len(cand_key) >= 2 and key.startswith(cand_key):
|
||||||
|
return field
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_card(items: list[dict]) -> dict[str, Any]:
|
||||||
|
"""OCR 결과 목록을 필드 dict로 변환."""
|
||||||
|
rows = group_rows(items)
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
|
||||||
|
# 1) 행 내 "레이블 → 값" 쌍 추출
|
||||||
|
# 같은 행에서 레이블 바로 뒤의 첫 non-label 텍스트를 값으로 사용.
|
||||||
|
for row in rows:
|
||||||
|
# 여러 레이블이 같은 행에 있을 수 있음 (2컬럼 표 구조)
|
||||||
|
idx = 0
|
||||||
|
while idx < len(row):
|
||||||
|
field = _match_label(row[idx]['text'])
|
||||||
|
if field:
|
||||||
|
# 다음 non-label 조각을 값으로 취함
|
||||||
|
value_parts: list[str] = []
|
||||||
|
j = idx + 1
|
||||||
|
while j < len(row):
|
||||||
|
nxt = row[j]
|
||||||
|
if _match_label(nxt['text']):
|
||||||
|
break
|
||||||
|
value_parts.append(nxt['text'])
|
||||||
|
j += 1
|
||||||
|
if value_parts and field not in result:
|
||||||
|
value = ' '.join(value_parts).strip()
|
||||||
|
if value and value not in ('-', '–', 'N/A'):
|
||||||
|
result[field] = value
|
||||||
|
idx = j
|
||||||
|
else:
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
# 2) NFPA 추출: "NFPA" 단어 주변의 0~4 숫자 3개
|
||||||
|
nfpa_idx_row: int | None = None
|
||||||
|
for ri, row in enumerate(rows):
|
||||||
|
for cell in row:
|
||||||
|
if re.search(r'NFPA', cell['text']):
|
||||||
|
nfpa_idx_row = ri
|
||||||
|
break
|
||||||
|
if nfpa_idx_row is not None:
|
||||||
|
break
|
||||||
|
if nfpa_idx_row is not None:
|
||||||
|
# 해당 행 + 다음 2개 행에서 0~4 숫자 수집
|
||||||
|
candidates: list[int] = []
|
||||||
|
for ri in range(nfpa_idx_row, min(nfpa_idx_row + 3, len(rows))):
|
||||||
|
for cell in rows[ri]:
|
||||||
|
m = NFPA_VALUE_RE.match(cell['text'].strip())
|
||||||
|
if m:
|
||||||
|
candidates.append(int(cell['text'].strip()))
|
||||||
|
if len(candidates) >= 3:
|
||||||
|
break
|
||||||
|
if len(candidates) >= 3:
|
||||||
|
break
|
||||||
|
if len(candidates) >= 3:
|
||||||
|
result['nfpa'] = {
|
||||||
|
'health': candidates[0],
|
||||||
|
'fire': candidates[1],
|
||||||
|
'reactivity': candidates[2],
|
||||||
|
'special': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3) EmS 코드 (F-x / S-x 패턴)
|
||||||
|
all_text = ' '.join(cell['text'] for row in rows for cell in row)
|
||||||
|
f_match = re.search(r'F\s*-\s*([A-Z])', all_text)
|
||||||
|
s_match = re.search(r'S\s*-\s*([A-Z])', all_text)
|
||||||
|
if f_match or s_match:
|
||||||
|
parts = []
|
||||||
|
if f_match:
|
||||||
|
parts.append(f'F-{f_match.group(1)}')
|
||||||
|
if s_match:
|
||||||
|
parts.append(f'S-{s_match.group(1)}')
|
||||||
|
if parts:
|
||||||
|
result['emsCode'] = ', '.join(parts)
|
||||||
|
|
||||||
|
# 4) ERG 번호 (3자리 숫자, P 접미사 가능, "ERG" 키워드 근처)
|
||||||
|
erg_match = re.search(r'ERG[^\d]{0,10}(\d{3}P?)', all_text)
|
||||||
|
if erg_match:
|
||||||
|
result['ergNumber'] = erg_match.group(1)
|
||||||
|
|
||||||
|
# 5) EmS F-x / S-x 코드 뒤의 본문 (생략 - 이미지 내 텍스트 밀도가 낮아 행 단위로 이미 잡힘)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _preprocess_image(pil_img, upscale: float = 2.5):
|
||||||
|
"""한글 OCR 정확도 향상을 위한 업스케일 + 샤프닝 + 대비 향상."""
|
||||||
|
from PIL import Image, ImageEnhance, ImageFilter
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
if pil_img.mode != 'RGB':
|
||||||
|
pil_img = pil_img.convert('RGB')
|
||||||
|
|
||||||
|
# 1) 업스케일 (LANCZOS)
|
||||||
|
w, h = pil_img.size
|
||||||
|
pil_img = pil_img.resize((int(w * upscale), int(h * upscale)), Image.LANCZOS)
|
||||||
|
|
||||||
|
# 2) 대비 향상
|
||||||
|
pil_img = ImageEnhance.Contrast(pil_img).enhance(1.3)
|
||||||
|
|
||||||
|
# 3) 샤프닝
|
||||||
|
pil_img = pil_img.filter(ImageFilter.UnsharpMask(radius=1.5, percent=150, threshold=2))
|
||||||
|
|
||||||
|
return np.array(pil_img)
|
||||||
|
|
||||||
|
|
||||||
|
def run_ocr(image_path: Path, reader, upscale: float = 2.5) -> list[dict]:
|
||||||
|
# OpenCV가 Windows에서 한글 경로를 못 읽으므로 PIL로 로드 후 전처리
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(image_path) as pil:
|
||||||
|
img = _preprocess_image(pil, upscale=upscale)
|
||||||
|
raw = reader.readtext(img, detail=1, paragraph=False)
|
||||||
|
items: list[dict] = []
|
||||||
|
for bbox, text, conf in raw:
|
||||||
|
if not text or not str(text).strip():
|
||||||
|
continue
|
||||||
|
xs = [p[0] for p in bbox]
|
||||||
|
ys = [p[1] for p in bbox]
|
||||||
|
items.append({
|
||||||
|
'text': str(text).strip(),
|
||||||
|
'cx': sum(xs) / 4.0,
|
||||||
|
'cy': sum(ys) / 4.0,
|
||||||
|
'x0': min(xs),
|
||||||
|
'x1': max(xs),
|
||||||
|
'y0': min(ys),
|
||||||
|
'y1': max(ys),
|
||||||
|
'conf': float(conf),
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: Path, fallback):
|
||||||
|
if not path.exists():
|
||||||
|
return fallback
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--limit', type=int, default=None)
|
||||||
|
parser.add_argument('--only', type=str, default=None,
|
||||||
|
help='파이프(|)로 구분된 물질명 리스트')
|
||||||
|
parser.add_argument('--img-dir', type=Path, default=IMG_DIR)
|
||||||
|
parser.add_argument('--out', type=Path, default=OCR_PATH_DEFAULT)
|
||||||
|
parser.add_argument('--fail', type=Path, default=FAIL_PATH_DEFAULT)
|
||||||
|
parser.add_argument('--debug', action='store_true',
|
||||||
|
help='파싱 중간 결과(row 단위) 함께 출력')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
import easyocr # noqa: WPS433
|
||||||
|
|
||||||
|
print('[로딩] EasyOCR 모델 (ko + en)... (최초 실행 시 수 분 소요)')
|
||||||
|
reader = easyocr.Reader(['ko', 'en'], gpu=False, verbose=False)
|
||||||
|
print('[로딩] 완료')
|
||||||
|
|
||||||
|
images = sorted([p for p in args.img_dir.iterdir() if p.suffix.lower() in {'.png', '.jpg', '.jpeg'}])
|
||||||
|
if args.only:
|
||||||
|
only_set = {s.strip() for s in args.only.split('|') if s.strip()}
|
||||||
|
images = [p for p in images if p.stem in only_set]
|
||||||
|
|
||||||
|
existing: dict[str, Any] = load_json(args.out, {})
|
||||||
|
failures: dict[str, str] = load_json(args.fail, {})
|
||||||
|
|
||||||
|
pending = [p for p in images if p.stem not in existing]
|
||||||
|
if args.limit:
|
||||||
|
pending = pending[: args.limit]
|
||||||
|
|
||||||
|
print(f'[대상] {len(images)}개 중 대기 {len(pending)}개, 이미 처리 {len(existing)}개')
|
||||||
|
|
||||||
|
ok = 0
|
||||||
|
fail = 0
|
||||||
|
for i, path in enumerate(pending, start=1):
|
||||||
|
name = path.stem
|
||||||
|
try:
|
||||||
|
items = run_ocr(path, reader)
|
||||||
|
parsed = parse_card(items)
|
||||||
|
if args.debug:
|
||||||
|
print(f'\n--- {name} (텍스트 {len(items)}개) ---')
|
||||||
|
for row in group_rows(items):
|
||||||
|
print(' |', ' │ '.join(f'{c["text"]}' for c in row))
|
||||||
|
print(f' → parsed: {parsed}')
|
||||||
|
existing[name] = parsed
|
||||||
|
if name in failures:
|
||||||
|
del failures[name]
|
||||||
|
ok += 1
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
failures[name] = f'{type(e).__name__}: {e}'[:500]
|
||||||
|
fail += 1
|
||||||
|
print(f'[실패] {name}: {e}')
|
||||||
|
|
||||||
|
if i % 10 == 0 or i == len(pending):
|
||||||
|
args.out.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||||
|
args.fail.write_text(json.dumps(failures, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||||
|
print(f' 진행 {i}/{len(pending)} (성공 {ok}, 실패 {fail}) - 중간 저장')
|
||||||
|
|
||||||
|
args.out.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||||
|
args.fail.write_text(json.dumps(failures, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||||
|
|
||||||
|
print(f'\n[완료] 성공 {ok} / 실패 {fail}')
|
||||||
|
print(f' 결과: {args.out}')
|
||||||
|
if failures:
|
||||||
|
print(f' 실패 목록: {args.fail}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
29
backend/scripts/hns-import/requirements.txt
Normal file
29
backend/scripts/hns-import/requirements.txt
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# extract-excel.py 용
|
||||||
|
openpyxl>=3.1.0
|
||||||
|
|
||||||
|
# ocr-local.py 용 (EasyOCR 기반 로컬 OCR, 대안 파이프라인)
|
||||||
|
easyocr==1.7.2
|
||||||
|
filelock==3.19.1
|
||||||
|
fsspec==2025.10.0
|
||||||
|
ImageIO==2.37.2
|
||||||
|
Jinja2==3.1.6
|
||||||
|
lazy-loader==0.5
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
mpmath==1.3.0
|
||||||
|
networkx==3.2.1
|
||||||
|
ninja==1.13.0
|
||||||
|
numpy==2.0.2
|
||||||
|
opencv-python-headless==4.13.0.92
|
||||||
|
packaging==26.1
|
||||||
|
pillow==11.3.0
|
||||||
|
pyclipper==1.3.0.post6
|
||||||
|
python-bidi==0.6.7
|
||||||
|
PyYAML==6.0.3
|
||||||
|
scikit-image==0.24.0
|
||||||
|
scipy==1.13.1
|
||||||
|
shapely==2.0.7
|
||||||
|
sympy==1.14.0
|
||||||
|
tifffile==2024.8.30
|
||||||
|
torch==2.8.0
|
||||||
|
torchvision==0.23.0
|
||||||
|
typing_extensions==4.15.0
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import { mkdirSync, existsSync } from 'fs';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
import {
|
import {
|
||||||
listMedia,
|
listMedia,
|
||||||
createMedia,
|
createMedia,
|
||||||
@ -25,6 +27,29 @@ import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const stitchUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
|
const stitchUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
|
||||||
|
|
||||||
|
const mediaUpload = multer({
|
||||||
|
storage: multer.diskStorage({
|
||||||
|
destination: (_req, _file, cb) => {
|
||||||
|
const dir = path.resolve('uploads', 'aerial');
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
cb(null, dir);
|
||||||
|
},
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
cb(null, `${randomUUID()}${ext}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
limits: { fileSize: 2 * 1024 * 1024 * 1024 }, // 2GB
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
const allowed = /\.(jpe?g|png|tiff?|geotiff|mp4|mov)$/i;
|
||||||
|
if (allowed.test(path.extname(file.originalname))) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('허용되지 않는 파일 형식입니다.'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// AERIAL_MEDIA 라우트
|
// AERIAL_MEDIA 라우트
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -73,6 +98,96 @@ router.post('/media', requireAuth, requirePermission('aerial', 'CREATE'), async
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/aerial/media/upload — 파일 업로드 + 메타 등록
|
||||||
|
router.post('/media/upload', requireAuth, requirePermission('aerial', 'CREATE'), mediaUpload.single('file'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const file = req.file;
|
||||||
|
if (!file) {
|
||||||
|
res.status(400).json({ error: '파일이 필요합니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { equipTpCd, equipNm, mediaTpCd, acdntSn, memo } = req.body as {
|
||||||
|
equipTpCd?: string;
|
||||||
|
equipNm?: string;
|
||||||
|
mediaTpCd?: string;
|
||||||
|
acdntSn?: string;
|
||||||
|
memo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isVideo = file.mimetype.startsWith('video/');
|
||||||
|
const detectedMediaType = mediaTpCd ?? (isVideo ? '영상' : '사진');
|
||||||
|
const fileSzMb = (file.size / (1024 * 1024)).toFixed(2) + ' MB';
|
||||||
|
|
||||||
|
const result = await createMedia({
|
||||||
|
fileNm: file.filename,
|
||||||
|
orgnlNm: file.originalname,
|
||||||
|
filePath: file.path,
|
||||||
|
equipTpCd: equipTpCd ?? 'drone',
|
||||||
|
equipNm: equipNm ?? '기타',
|
||||||
|
mediaTpCd: detectedMediaType,
|
||||||
|
fileSz: fileSzMb,
|
||||||
|
acdntSn: acdntSn ? parseInt(acdntSn, 10) : undefined,
|
||||||
|
locDc: memo ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[aerial] 미디어 업로드 오류:', err);
|
||||||
|
res.status(500).json({ error: '미디어 업로드 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/aerial/media/:sn/view — 원본 이미지 뷰어용 (inline 표시)
|
||||||
|
router.get('/media/:sn/view', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sn = parseInt(req.params['sn'] as string, 10);
|
||||||
|
if (!isValidNumber(sn, 1, 999999)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 미디어 번호' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const media = await getMediaBySn(sn);
|
||||||
|
if (!media) {
|
||||||
|
res.status(404).json({ error: '미디어를 찾을 수 없습니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로컬 업로드 파일이면 직접 서빙
|
||||||
|
if (media.filePath) {
|
||||||
|
const absPath = path.resolve(media.filePath);
|
||||||
|
if (existsSync(absPath)) {
|
||||||
|
const ext = path.extname(absPath).toLowerCase();
|
||||||
|
const mimeMap: Record<string, string> = {
|
||||||
|
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
||||||
|
'.tif': 'image/tiff', '.tiff': 'image/tiff',
|
||||||
|
'.mp4': 'video/mp4', '.mov': 'video/quicktime',
|
||||||
|
};
|
||||||
|
res.setHeader('Content-Type', mimeMap[ext] ?? 'application/octet-stream');
|
||||||
|
res.setHeader('Content-Disposition', 'inline');
|
||||||
|
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||||
|
res.sendFile(absPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId = media.fileNm.substring(0, 36);
|
||||||
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
if (!UUID_PATTERN.test(fileId) || !media.equipNm) {
|
||||||
|
res.status(404).json({ error: '표시 가능한 이미지가 없습니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await fetchOriginalImage(media.equipNm, fileId);
|
||||||
|
res.setHeader('Content-Type', 'image/jpeg');
|
||||||
|
res.setHeader('Content-Disposition', 'inline');
|
||||||
|
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||||
|
res.send(buffer);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[aerial] 이미지 뷰어 오류:', err);
|
||||||
|
res.status(502).json({ error: '이미지 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/aerial/media/:sn/download — 원본 이미지 다운로드
|
// GET /api/aerial/media/:sn/download — 원본 이미지 다운로드
|
||||||
router.get('/media/:sn/download', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
router.get('/media/:sn/download', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -368,7 +368,7 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis
|
|||||||
// OIL INFERENCE (GPU 서버 프록시)
|
// OIL INFERENCE (GPU 서버 프록시)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001';
|
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://211.208.115.83:5001';
|
||||||
const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090';
|
const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090';
|
||||||
const INFERENCE_TIMEOUT_MS = 10_000;
|
const INFERENCE_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
|||||||
@ -24,8 +24,14 @@ async function seedHnsSubstances() {
|
|||||||
|
|
||||||
let inserted = 0
|
let inserted = 0
|
||||||
|
|
||||||
|
// varchar 길이 제한에 맞춰 첫 번째 토큰만 검색 컬럼에 저장 (원본은 DATA JSONB에 보존)
|
||||||
|
const firstToken = (v: unknown, max: number): string | null => {
|
||||||
|
if (v == null) return null
|
||||||
|
const s = String(v).split(/[\n,;/]/)[0].trim()
|
||||||
|
return s ? s.slice(0, max) : null
|
||||||
|
}
|
||||||
|
|
||||||
for (const s of HNS_SEARCH_DB) {
|
for (const s of HNS_SEARCH_DB) {
|
||||||
// 검색용 컬럼 추출, 나머지는 DATA JSONB로 저장
|
|
||||||
const { abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, ...detailData } = s
|
const { abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, ...detailData } = s
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
@ -39,7 +45,16 @@ async function seedHnsSubstances() {
|
|||||||
CAS_NO = EXCLUDED.CAS_NO,
|
CAS_NO = EXCLUDED.CAS_NO,
|
||||||
SEBC = EXCLUDED.SEBC,
|
SEBC = EXCLUDED.SEBC,
|
||||||
DATA = EXCLUDED.DATA`,
|
DATA = EXCLUDED.DATA`,
|
||||||
[s.id, abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, JSON.stringify(detailData)]
|
[
|
||||||
|
s.id,
|
||||||
|
firstToken(abbreviation, 50),
|
||||||
|
firstToken(nameKr, 200) ?? '',
|
||||||
|
firstToken(nameEn, 200),
|
||||||
|
firstToken(unNumber, 10),
|
||||||
|
firstToken(casNumber, 20),
|
||||||
|
firstToken(sebc, 50),
|
||||||
|
JSON.stringify(detailData),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
inserted++
|
inserted++
|
||||||
|
|||||||
20
backend/src/gsc/gscAccidentsRouter.ts
Normal file
20
backend/src/gsc/gscAccidentsRouter.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../auth/authMiddleware.js';
|
||||||
|
import { listGscAccidents } from './gscAccidentsService.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GET /api/gsc/accidents — 외부 수집 사고 목록 (최신 20건)
|
||||||
|
// ============================================================
|
||||||
|
router.get('/', requireAuth, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const accidents = await listGscAccidents(20);
|
||||||
|
res.json(accidents);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[gsc] 사고 목록 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '사고 목록 조회 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
44
backend/src/gsc/gscAccidentsService.ts
Normal file
44
backend/src/gsc/gscAccidentsService.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { wingPool } from '../db/wingDb.js';
|
||||||
|
|
||||||
|
export interface GscAccidentListItem {
|
||||||
|
acdntSn: number;
|
||||||
|
acdntMngNo: string;
|
||||||
|
pollNm: string;
|
||||||
|
pollDate: string | null;
|
||||||
|
lat: number | null;
|
||||||
|
lon: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGscAccidents(limit = 20): Promise<GscAccidentListItem[]> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
ACDNT_SN AS "acdntSn",
|
||||||
|
ACDNT_CD AS "acdntMngNo",
|
||||||
|
ACDNT_NM AS "pollNm",
|
||||||
|
to_char(OCCRN_DTM, 'YYYY-MM-DD"T"HH24:MI') AS "pollDate",
|
||||||
|
LAT AS "lat",
|
||||||
|
LNG AS "lon"
|
||||||
|
FROM wing.ACDNT
|
||||||
|
WHERE ACDNT_NM IS NOT NULL
|
||||||
|
ORDER BY OCCRN_DTM DESC NULLS LAST
|
||||||
|
LIMIT $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await wingPool.query<{
|
||||||
|
acdntSn: number;
|
||||||
|
acdntMngNo: string;
|
||||||
|
pollNm: string;
|
||||||
|
pollDate: string | null;
|
||||||
|
lat: string | null;
|
||||||
|
lon: string | null;
|
||||||
|
}>(sql, [limit]);
|
||||||
|
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
acdntSn: row.acdntSn,
|
||||||
|
acdntMngNo: row.acdntMngNo,
|
||||||
|
pollNm: row.pollNm,
|
||||||
|
pollDate: row.pollDate,
|
||||||
|
lat: row.lat != null ? Number(row.lat) : null,
|
||||||
|
lon: row.lon != null ? Number(row.lon) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@ -12,11 +12,13 @@ const router = express.Router()
|
|||||||
// GET /api/hns/analyses — 분석 목록
|
// GET /api/hns/analyses — 분석 목록
|
||||||
router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (req, res) => {
|
router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { status, substance, search } = req.query
|
const { status, substance, search, acdntSn } = req.query
|
||||||
|
const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined
|
||||||
const items = await listAnalyses({
|
const items = await listAnalyses({
|
||||||
status: status as string | undefined,
|
status: status as string | undefined,
|
||||||
substance: substance as string | undefined,
|
substance: substance as string | undefined,
|
||||||
search: search as string | undefined,
|
search: search as string | undefined,
|
||||||
|
acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
|
||||||
})
|
})
|
||||||
res.json(items)
|
res.json(items)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -48,13 +50,15 @@ router.get('/analyses/:sn', requireAuth, requirePermission('hns', 'READ'), async
|
|||||||
// POST /api/hns/analyses — 분석 생성
|
// POST /api/hns/analyses — 분석 생성
|
||||||
router.post('/analyses', requireAuth, requirePermission('hns', 'CREATE'), async (req, res) => {
|
router.post('/analyses', requireAuth, requirePermission('hns', 'CREATE'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { anlysNm, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm } = req.body
|
const { anlysNm, acdntSn, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm } = req.body
|
||||||
if (!anlysNm) {
|
if (!anlysNm) {
|
||||||
res.status(400).json({ error: '분석명은 필수입니다.' })
|
res.status(400).json({ error: '분석명은 필수입니다.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const acdntSnNum = acdntSn != null ? parseInt(String(acdntSn), 10) : undefined
|
||||||
const result = await createAnalysis({
|
const result = await createAnalysis({
|
||||||
anlysNm, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm,
|
anlysNm, acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
|
||||||
|
acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm,
|
||||||
})
|
})
|
||||||
res.status(201).json(result)
|
res.status(201).json(result)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -94,6 +94,7 @@ export async function searchSubstances(params: HnsSearchParams) {
|
|||||||
|
|
||||||
interface HnsAnalysisItem {
|
interface HnsAnalysisItem {
|
||||||
hnsAnlysSn: number
|
hnsAnlysSn: number
|
||||||
|
acdntSn: number | null
|
||||||
anlysNm: string
|
anlysNm: string
|
||||||
acdntDtm: string | null
|
acdntDtm: string | null
|
||||||
locNm: string | null
|
locNm: string | null
|
||||||
@ -118,11 +119,13 @@ interface ListAnalysesInput {
|
|||||||
status?: string
|
status?: string
|
||||||
substance?: string
|
substance?: string
|
||||||
search?: string
|
search?: string
|
||||||
|
acdntSn?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
|
function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
|
||||||
return {
|
return {
|
||||||
hnsAnlysSn: r.hns_anlys_sn as number,
|
hnsAnlysSn: r.hns_anlys_sn as number,
|
||||||
|
acdntSn: (r.acdnt_sn as number) ?? null,
|
||||||
anlysNm: r.anlys_nm as string,
|
anlysNm: r.anlys_nm as string,
|
||||||
acdntDtm: r.acdnt_dtm as string | null,
|
acdntDtm: r.acdnt_dtm as string | null,
|
||||||
locNm: r.loc_nm as string | null,
|
locNm: r.loc_nm as string | null,
|
||||||
@ -146,7 +149,7 @@ function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
|
|||||||
|
|
||||||
export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysisItem[]> {
|
export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysisItem[]> {
|
||||||
const conditions: string[] = ["USE_YN = 'Y'"]
|
const conditions: string[] = ["USE_YN = 'Y'"]
|
||||||
const params: string[] = []
|
const params: (string | number)[] = []
|
||||||
let idx = 1
|
let idx = 1
|
||||||
|
|
||||||
if (input.status) {
|
if (input.status) {
|
||||||
@ -162,9 +165,13 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysi
|
|||||||
params.push(input.search)
|
params.push(input.search)
|
||||||
idx++
|
idx++
|
||||||
}
|
}
|
||||||
|
if (input.acdntSn != null) {
|
||||||
|
conditions.push(`ACDNT_SN = $${idx++}`)
|
||||||
|
params.push(input.acdntSn)
|
||||||
|
}
|
||||||
|
|
||||||
const { rows } = await wingPool.query(
|
const { rows } = await wingPool.query(
|
||||||
`SELECT HNS_ANLYS_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
`SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
||||||
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
||||||
WIND_SPD, WIND_DIR, EXEC_STTS_CD, RISK_CD, ANALYST_NM,
|
WIND_SPD, WIND_DIR, EXEC_STTS_CD, RISK_CD, ANALYST_NM,
|
||||||
RSLT_DATA, REG_DTM
|
RSLT_DATA, REG_DTM
|
||||||
@ -179,7 +186,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysi
|
|||||||
|
|
||||||
export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
|
export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
|
||||||
const { rows } = await wingPool.query(
|
const { rows } = await wingPool.query(
|
||||||
`SELECT HNS_ANLYS_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
`SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
||||||
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
||||||
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
|
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
|
||||||
EXEC_STTS_CD, RISK_CD, ANALYST_NM,
|
EXEC_STTS_CD, RISK_CD, ANALYST_NM,
|
||||||
@ -194,6 +201,7 @@ export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
|
|||||||
|
|
||||||
export async function createAnalysis(input: {
|
export async function createAnalysis(input: {
|
||||||
anlysNm: string
|
anlysNm: string
|
||||||
|
acdntSn?: number
|
||||||
acdntDtm?: string
|
acdntDtm?: string
|
||||||
locNm?: string
|
locNm?: string
|
||||||
lon?: number
|
lon?: number
|
||||||
@ -213,21 +221,21 @@ export async function createAnalysis(input: {
|
|||||||
}): Promise<{ hnsAnlysSn: number }> {
|
}): Promise<{ hnsAnlysSn: number }> {
|
||||||
const { rows } = await wingPool.query(
|
const { rows } = await wingPool.query(
|
||||||
`INSERT INTO HNS_ANALYSIS (
|
`INSERT INTO HNS_ANALYSIS (
|
||||||
ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
||||||
GEOM, LOC_DC,
|
GEOM, LOC_DC,
|
||||||
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
||||||
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
|
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
|
||||||
ANALYST_NM, EXEC_STTS_CD
|
ANALYST_NM, EXEC_STTS_CD
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4::numeric, $5::numeric,
|
$1, $2, $3, $4, $5::numeric, $6::numeric,
|
||||||
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($4::double precision, $5::double precision), 4326) END,
|
CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($5::double precision, $6::double precision), 4326) END,
|
||||||
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN $4::text || ' + ' || $5::text END,
|
CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN $5::text || ' + ' || $6::text END,
|
||||||
$6, $7, $8, $9, $10, $11,
|
$7, $8, $9, $10, $11, $12,
|
||||||
$12, $13, $14, $15, $16,
|
$13, $14, $15, $16, $17,
|
||||||
$17, 'PENDING'
|
$18, 'PENDING'
|
||||||
) RETURNING HNS_ANLYS_SN`,
|
) RETURNING HNS_ANLYS_SN`,
|
||||||
[
|
[
|
||||||
input.anlysNm, input.acdntDtm || null, input.locNm || null, input.lon || null, input.lat || null,
|
input.acdntSn || null, input.anlysNm, input.acdntDtm || null, input.locNm || null, input.lon || null, input.lat || null,
|
||||||
input.sbstNm || null, input.spilQty || null, input.spilUnitCd || 'KL',
|
input.sbstNm || null, input.spilQty || null, input.spilUnitCd || 'KL',
|
||||||
input.fcstHr || null, input.algoCd || null, input.critMdlCd || null,
|
input.fcstHr || null, input.algoCd || null, input.critMdlCd || null,
|
||||||
input.windSpd || null, input.windDir || null, input.temp || null, input.humid || null, input.atmStblCd || null,
|
input.windSpd || null, input.windDir || null, input.temp || null, input.humid || null, input.atmStblCd || null,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
getIncidentWeather,
|
getIncidentWeather,
|
||||||
saveIncidentWeather,
|
saveIncidentWeather,
|
||||||
getIncidentMedia,
|
getIncidentMedia,
|
||||||
|
getIncidentImageAnalysis,
|
||||||
} from './incidentsService.js';
|
} from './incidentsService.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@ -133,4 +134,26 @@ router.get('/:sn/media', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GET /api/incidents/:sn/image-analysis — 이미지 분석 데이터
|
||||||
|
// ============================================================
|
||||||
|
router.get('/:sn/image-analysis', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sn = parseInt(req.params.sn as string, 10);
|
||||||
|
if (isNaN(sn)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 사고 번호입니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await getIncidentImageAnalysis(sn);
|
||||||
|
if (!data) {
|
||||||
|
res.status(404).json({ error: '이미지 분석 데이터가 없습니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[incidents] 이미지 분석 데이터 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '이미지 분석 데이터 조회 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -24,7 +24,11 @@ interface IncidentListItem {
|
|||||||
spilQty: number | null;
|
spilQty: number | null;
|
||||||
spilUnitCd: string | null;
|
spilUnitCd: string | null;
|
||||||
fcstHr: number | null;
|
fcstHr: number | null;
|
||||||
|
hasPredCompleted: boolean;
|
||||||
|
hasHnsCompleted: boolean;
|
||||||
|
hasRescueCompleted: boolean;
|
||||||
mediaCnt: number;
|
mediaCnt: number;
|
||||||
|
hasImgAnalysis: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PredExecItem {
|
interface PredExecItem {
|
||||||
@ -111,11 +115,29 @@ export async function listIncidents(filters: {
|
|||||||
a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM,
|
a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM,
|
||||||
a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM,
|
a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM,
|
||||||
s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR,
|
s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR,
|
||||||
|
COALESCE(s.HAS_IMG_ANALYSIS, FALSE) AS has_img_analysis,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM wing.PRED_EXEC pe
|
||||||
|
WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
) AS has_pred_completed,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM wing.HNS_ANALYSIS h
|
||||||
|
WHERE h.ACDNT_SN = a.ACDNT_SN
|
||||||
|
AND h.EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
AND h.USE_YN = 'Y'
|
||||||
|
) AS has_hns_completed,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM wing.RESCUE_OPS r
|
||||||
|
WHERE r.ACDNT_SN = a.ACDNT_SN
|
||||||
|
AND r.STTS_CD = 'RESOLVED'
|
||||||
|
AND r.USE_YN = 'Y'
|
||||||
|
) AS has_rescue_completed,
|
||||||
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
|
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
|
||||||
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
|
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
|
||||||
FROM wing.ACDNT a
|
FROM wing.ACDNT a
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR
|
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR,
|
||||||
|
IMG_RSLT_DATA IS NOT NULL AS HAS_IMG_ANALYSIS
|
||||||
FROM wing.SPIL_DATA
|
FROM wing.SPIL_DATA
|
||||||
WHERE ACDNT_SN = a.ACDNT_SN
|
WHERE ACDNT_SN = a.ACDNT_SN
|
||||||
ORDER BY SPIL_DATA_SN
|
ORDER BY SPIL_DATA_SN
|
||||||
@ -148,7 +170,11 @@ export async function listIncidents(filters: {
|
|||||||
spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null,
|
spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null,
|
||||||
spilUnitCd: (r.spil_unit_cd as string) ?? null,
|
spilUnitCd: (r.spil_unit_cd as string) ?? null,
|
||||||
fcstHr: (r.fcst_hr as number) ?? null,
|
fcstHr: (r.fcst_hr as number) ?? null,
|
||||||
|
hasPredCompleted: r.has_pred_completed as boolean,
|
||||||
|
hasHnsCompleted: r.has_hns_completed as boolean,
|
||||||
|
hasRescueCompleted: r.has_rescue_completed as boolean,
|
||||||
mediaCnt: Number(r.media_cnt),
|
mediaCnt: Number(r.media_cnt),
|
||||||
|
hasImgAnalysis: (r.has_img_analysis as boolean) ?? false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,11 +188,29 @@ export async function getIncident(acdntSn: number): Promise<IncidentDetail | nul
|
|||||||
a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM,
|
a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM,
|
||||||
a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM,
|
a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM,
|
||||||
s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR,
|
s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR,
|
||||||
|
COALESCE(s.HAS_IMG_ANALYSIS, FALSE) AS has_img_analysis,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM wing.PRED_EXEC pe
|
||||||
|
WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
) AS has_pred_completed,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM wing.HNS_ANALYSIS h
|
||||||
|
WHERE h.ACDNT_SN = a.ACDNT_SN
|
||||||
|
AND h.EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
AND h.USE_YN = 'Y'
|
||||||
|
) AS has_hns_completed,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM wing.RESCUE_OPS r
|
||||||
|
WHERE r.ACDNT_SN = a.ACDNT_SN
|
||||||
|
AND r.STTS_CD = 'RESOLVED'
|
||||||
|
AND r.USE_YN = 'Y'
|
||||||
|
) AS has_rescue_completed,
|
||||||
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
|
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
|
||||||
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
|
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
|
||||||
FROM wing.ACDNT a
|
FROM wing.ACDNT a
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR
|
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR,
|
||||||
|
IMG_RSLT_DATA IS NOT NULL AS HAS_IMG_ANALYSIS
|
||||||
FROM wing.SPIL_DATA
|
FROM wing.SPIL_DATA
|
||||||
WHERE ACDNT_SN = a.ACDNT_SN
|
WHERE ACDNT_SN = a.ACDNT_SN
|
||||||
ORDER BY SPIL_DATA_SN
|
ORDER BY SPIL_DATA_SN
|
||||||
@ -205,7 +249,11 @@ export async function getIncident(acdntSn: number): Promise<IncidentDetail | nul
|
|||||||
spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null,
|
spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null,
|
||||||
spilUnitCd: (r.spil_unit_cd as string) ?? null,
|
spilUnitCd: (r.spil_unit_cd as string) ?? null,
|
||||||
fcstHr: (r.fcst_hr as number) ?? null,
|
fcstHr: (r.fcst_hr as number) ?? null,
|
||||||
|
hasPredCompleted: r.has_pred_completed as boolean,
|
||||||
|
hasHnsCompleted: r.has_hns_completed as boolean,
|
||||||
|
hasRescueCompleted: r.has_rescue_completed as boolean,
|
||||||
mediaCnt: Number(r.media_cnt),
|
mediaCnt: Number(r.media_cnt),
|
||||||
|
hasImgAnalysis: (r.has_img_analysis as boolean) ?? false,
|
||||||
predictions,
|
predictions,
|
||||||
weather,
|
weather,
|
||||||
media,
|
media,
|
||||||
@ -419,3 +467,21 @@ export async function getIncidentMedia(acdntSn: number): Promise<MediaInfo | nul
|
|||||||
cctvMeta: (r.cctv_meta as Record<string, unknown>) ?? null,
|
cctvMeta: (r.cctv_meta as Record<string, unknown>) ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 이미지 분석 데이터 조회
|
||||||
|
// ============================================================
|
||||||
|
export async function getIncidentImageAnalysis(acdntSn: number): Promise<Record<string, unknown> | null> {
|
||||||
|
const sql = `
|
||||||
|
SELECT IMG_RSLT_DATA
|
||||||
|
FROM wing.SPIL_DATA
|
||||||
|
WHERE ACDNT_SN = $1 AND IMG_RSLT_DATA IS NOT NULL
|
||||||
|
ORDER BY SPIL_DATA_SN
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await wingPool.query(sql, [acdntSn]);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
return (rows[0] as Record<string, unknown>).img_rslt_data as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|||||||
@ -74,7 +74,7 @@ function parseMeta(metaStr: string): { lat: number; lon: number; occurredAt: str
|
|||||||
return { lat, lon, occurredAt };
|
return { lat, lon, occurredAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function analyzeImageFile(imageBuffer: Buffer, originalName: string): Promise<ImageAnalyzeResult> {
|
export async function analyzeImageFile(imageBuffer: Buffer, originalName: string, acdntNmOverride?: string): Promise<ImageAnalyzeResult> {
|
||||||
const fileId = crypto.randomUUID();
|
const fileId = crypto.randomUUID();
|
||||||
|
|
||||||
// camTy는 현재 "mx15hdi"로 하드코딩한다.
|
// camTy는 현재 "mx15hdi"로 하드코딩한다.
|
||||||
@ -122,7 +122,7 @@ export async function analyzeImageFile(imageBuffer: Buffer, originalName: string
|
|||||||
const volume = firstOil?.volume ?? 0;
|
const volume = firstOil?.volume ?? 0;
|
||||||
|
|
||||||
// ACDNT INSERT
|
// ACDNT INSERT
|
||||||
const acdntNm = `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
|
const acdntNm = acdntNmOverride?.trim() || `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
|
||||||
const acdntRes = await wingPool.query(
|
const acdntRes = await wingPool.query(
|
||||||
`INSERT INTO wing.ACDNT
|
`INSERT INTO wing.ACDNT
|
||||||
(ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM)
|
(ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM)
|
||||||
@ -145,7 +145,7 @@ export async function analyzeImageFile(imageBuffer: Buffer, originalName: string
|
|||||||
await wingPool.query(
|
await wingPool.query(
|
||||||
`INSERT INTO wing.SPIL_DATA
|
`INSERT INTO wing.SPIL_DATA
|
||||||
(ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, IMG_RSLT_DATA, REG_DTM)
|
(ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, IMG_RSLT_DATA, REG_DTM)
|
||||||
VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 48, $4, NOW())`,
|
VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 6, $4, NOW())`,
|
||||||
[
|
[
|
||||||
acdntSn,
|
acdntSn,
|
||||||
OIL_DB_CODE_MAP[oilType] ?? 'BUNKER_C',
|
OIL_DB_CODE_MAP[oilType] ?? 'BUNKER_C',
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
|
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
|
||||||
getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn,
|
getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn,
|
||||||
getPredictionParticlesGeojsonByAcdntSn, getSensitivityEvaluationGeojsonByAcdntSn,
|
getPredictionParticlesGeojsonByAcdntSn, getSensitivityEvaluationGeojsonByAcdntSn,
|
||||||
|
getOilSpillSummary,
|
||||||
} from './predictionService.js';
|
} from './predictionService.js';
|
||||||
import { analyzeImageFile } from './imageAnalyzeService.js';
|
import { analyzeImageFile } from './imageAnalyzeService.js';
|
||||||
import { isValidNumber } from '../middleware/security.js';
|
import { isValidNumber } from '../middleware/security.js';
|
||||||
@ -70,6 +71,27 @@ router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('pred
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/prediction/analyses/:acdntSn/oil-summary — 유출유 확산 요약 (분할 패널용)
|
||||||
|
router.get('/analyses/:acdntSn/oil-summary', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
||||||
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const predRunSn = req.query.predRunSn ? parseInt(req.query.predRunSn as string, 10) : undefined;
|
||||||
|
const result = await getOilSpillSummary(acdntSn, predRunSn);
|
||||||
|
if (!result) {
|
||||||
|
res.json({ primary: null, byModel: {} });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[prediction] oil-summary 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: 'oil-summary 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/prediction/analyses/:acdntSn/sensitive-resources — 예측 영역 내 민감자원 집계
|
// GET /api/prediction/analyses/:acdntSn/sensitive-resources — 예측 영역 내 민감자원 집계
|
||||||
router.get('/analyses/:acdntSn/sensitive-resources', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
router.get('/analyses/:acdntSn/sensitive-resources', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@ -230,7 +252,8 @@ router.post(
|
|||||||
res.status(400).json({ error: '이미지 파일이 필요합니다' });
|
res.status(400).json({ error: '이미지 파일이 필요합니다' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await analyzeImageFile(req.file.buffer, req.file.originalname);
|
const acdntNm = typeof req.body?.acdntNm === 'string' ? req.body.acdntNm : undefined;
|
||||||
|
const result = await analyzeImageFile(req.file.buffer, req.file.originalname, acdntNm);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
|
|||||||
@ -1,6 +1,16 @@
|
|||||||
import { wingPool } from '../db/wingDb.js';
|
import { wingPool } from '../db/wingDb.js';
|
||||||
import { runBacktrackAnalysis } from './backtrackAnalysisService.js';
|
import { runBacktrackAnalysis } from './backtrackAnalysisService.js';
|
||||||
|
|
||||||
|
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const R = 6371;
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||||
|
const a = Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||||
|
Math.sin(dLon / 2) ** 2;
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
}
|
||||||
|
|
||||||
interface PredictionAnalysis {
|
interface PredictionAnalysis {
|
||||||
acdntSn: number;
|
acdntSn: number;
|
||||||
acdntNm: string;
|
acdntNm: string;
|
||||||
@ -812,3 +822,116 @@ export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
|
|||||||
regDtm: String(r['reg_dtm'] ?? ''),
|
regDtm: String(r['reg_dtm'] ?? ''),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 유출유 확산 요약 (통합조회 분할 패널용) ──────────────
|
||||||
|
export interface OilSpillSummary {
|
||||||
|
model: string;
|
||||||
|
forecastDurationHr: number | null;
|
||||||
|
maxSpreadDistanceKm: number | null;
|
||||||
|
coastArrivalTimeHr: number | null;
|
||||||
|
affectedCoastlineKm: number | null;
|
||||||
|
weatheringRatePct: number | null;
|
||||||
|
remainingVolumeKl: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OilSpillSummaryResponse {
|
||||||
|
primary: OilSpillSummary;
|
||||||
|
byModel: Record<string, OilSpillSummary>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOilSpillSummary(acdntSn: number, predRunSn?: number): Promise<OilSpillSummaryResponse | null> {
|
||||||
|
const baseSql = `
|
||||||
|
SELECT pe.ALGO_CD, pe.RSLT_DATA,
|
||||||
|
sd.FCST_HR,
|
||||||
|
ST_Y(a.LOC_GEOM) AS spil_lat,
|
||||||
|
ST_X(a.LOC_GEOM) AS spil_lon
|
||||||
|
FROM wing.PRED_EXEC pe
|
||||||
|
LEFT JOIN wing.SPIL_DATA sd ON sd.ACDNT_SN = pe.ACDNT_SN
|
||||||
|
LEFT JOIN wing.ACDNT a ON a.ACDNT_SN = pe.ACDNT_SN
|
||||||
|
WHERE pe.ACDNT_SN = $1
|
||||||
|
AND pe.ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
|
||||||
|
AND pe.EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
AND pe.RSLT_DATA IS NOT NULL
|
||||||
|
`;
|
||||||
|
const sql = predRunSn != null
|
||||||
|
? baseSql + ' AND pe.PRED_RUN_SN = $2 ORDER BY pe.CMPL_DTM DESC'
|
||||||
|
: baseSql + ' ORDER BY pe.CMPL_DTM DESC';
|
||||||
|
const params = predRunSn != null ? [acdntSn, predRunSn] : [acdntSn];
|
||||||
|
const { rows } = await wingPool.query(sql, params);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const byModel: Record<string, OilSpillSummary> = {};
|
||||||
|
|
||||||
|
// OpenDrift 우선, 없으면 POSEIDON
|
||||||
|
const opendriftRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'OPENDRIFT');
|
||||||
|
const poseidonRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'POSEIDON');
|
||||||
|
const primaryRow = opendriftRow ?? poseidonRow ?? null;
|
||||||
|
|
||||||
|
for (const row of rows as Array<Record<string, unknown>>) {
|
||||||
|
const rsltData = row['rslt_data'] as TrajectoryTimeStep[] | null;
|
||||||
|
if (!rsltData || rsltData.length === 0) continue;
|
||||||
|
|
||||||
|
const algoCd = String(row['algo_cd'] ?? '');
|
||||||
|
const modelName = ALGO_CD_TO_MODEL[algoCd] ?? algoCd;
|
||||||
|
const fcstHr = row['fcst_hr'] != null ? Number(row['fcst_hr']) : null;
|
||||||
|
const spilLat = row['spil_lat'] != null ? Number(row['spil_lat']) : null;
|
||||||
|
const spilLon = row['spil_lon'] != null ? Number(row['spil_lon']) : null;
|
||||||
|
const totalSteps = rsltData.length;
|
||||||
|
const lastStep = rsltData[totalSteps - 1];
|
||||||
|
|
||||||
|
// 최대 확산거리 — 사고 위치 또는 첫 파티클 위치를 원점으로 사용
|
||||||
|
let maxDist: number | null = null;
|
||||||
|
const originLat = spilLat ?? rsltData[0]?.particles[0]?.lat ?? null;
|
||||||
|
const originLon = spilLon ?? rsltData[0]?.particles[0]?.lon ?? null;
|
||||||
|
if (originLat != null && originLon != null) {
|
||||||
|
let maxVal = 0;
|
||||||
|
for (const step of rsltData) {
|
||||||
|
for (const p of step.particles) {
|
||||||
|
const d = haversineKm(originLat, originLon, p.lat, p.lon);
|
||||||
|
if (d > maxVal) maxVal = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maxDist = maxVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 해안 도달 시간 (stranded===1 최초 등장 step)
|
||||||
|
let coastArrivalHr: number | null = null;
|
||||||
|
for (let i = 0; i < totalSteps; i++) {
|
||||||
|
if (rsltData[i].particles.some((p) => p.stranded === 1)) {
|
||||||
|
coastArrivalHr = fcstHr != null && totalSteps > 1
|
||||||
|
? parseFloat(((i / (totalSteps - 1)) * fcstHr).toFixed(1))
|
||||||
|
: i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 풍화율
|
||||||
|
const totalVol = lastStep.remaining_volume_m3 + lastStep.weathered_volume_m3 + lastStep.beached_volume_m3;
|
||||||
|
const weatheringPct = totalVol > 0
|
||||||
|
? parseFloat(((lastStep.weathered_volume_m3 / totalVol) * 100).toFixed(1))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
byModel[modelName] = {
|
||||||
|
model: modelName,
|
||||||
|
forecastDurationHr: fcstHr,
|
||||||
|
maxSpreadDistanceKm: maxDist != null ? parseFloat(maxDist.toFixed(1)) : null,
|
||||||
|
coastArrivalTimeHr: coastArrivalHr,
|
||||||
|
affectedCoastlineKm: lastStep.pollution_coast_length_m != null
|
||||||
|
? parseFloat((lastStep.pollution_coast_length_m / 1000).toFixed(1))
|
||||||
|
: null,
|
||||||
|
weatheringRatePct: weatheringPct,
|
||||||
|
remainingVolumeKl: lastStep.remaining_volume_m3 != null
|
||||||
|
? parseFloat(lastStep.remaining_volume_m3.toFixed(1))
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!primaryRow) return null;
|
||||||
|
const primaryAlgo = String(primaryRow['algo_cd'] ?? '');
|
||||||
|
const primaryModel = ALGO_CD_TO_MODEL[primaryAlgo] ?? primaryAlgo;
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary: byModel[primaryModel] ?? Object.values(byModel)[0],
|
||||||
|
byModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -10,11 +10,13 @@ const router = express.Router();
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
router.get('/ops', requireAuth, requirePermission('rescue', 'READ'), async (req, res) => {
|
router.get('/ops', requireAuth, requirePermission('rescue', 'READ'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { sttsCd, acdntTpCd, search } = req.query;
|
const { sttsCd, acdntTpCd, search, acdntSn } = req.query;
|
||||||
|
const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined;
|
||||||
const items = await listOps({
|
const items = await listOps({
|
||||||
sttsCd: sttsCd as string | undefined,
|
sttsCd: sttsCd as string | undefined,
|
||||||
acdntTpCd: acdntTpCd as string | undefined,
|
acdntTpCd: acdntTpCd as string | undefined,
|
||||||
search: search as string | undefined,
|
search: search as string | undefined,
|
||||||
|
acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
|
||||||
});
|
});
|
||||||
res.json(items);
|
res.json(items);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -59,6 +59,7 @@ interface ListOpsInput {
|
|||||||
sttsCd?: string;
|
sttsCd?: string;
|
||||||
acdntTpCd?: string;
|
acdntTpCd?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
acdntSn?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -82,6 +83,10 @@ export async function listOps(input?: ListOpsInput): Promise<RescueOpsListItem[]
|
|||||||
conditions.push(`VESSEL_NM ILIKE '%' || $${idx++} || '%'`);
|
conditions.push(`VESSEL_NM ILIKE '%' || $${idx++} || '%'`);
|
||||||
params.push(input.search);
|
params.push(input.search);
|
||||||
}
|
}
|
||||||
|
if (input?.acdntSn != null) {
|
||||||
|
conditions.push(`ACDNT_SN = $${idx++}`);
|
||||||
|
params.push(input.acdntSn);
|
||||||
|
}
|
||||||
|
|
||||||
const where = 'WHERE ' + conditions.join(' AND ');
|
const where = 'WHERE ' + conditions.join(' AND ');
|
||||||
|
|
||||||
|
|||||||
@ -20,9 +20,9 @@ const POLL_TIMEOUT_MS = 30 * 60 * 1000 // 30분
|
|||||||
const OIL_TYPE_MAP: Record<string, string> = {
|
const OIL_TYPE_MAP: Record<string, string> = {
|
||||||
'벙커C유': 'GENERIC BUNKER C',
|
'벙커C유': 'GENERIC BUNKER C',
|
||||||
'경유': 'GENERIC DIESEL',
|
'경유': 'GENERIC DIESEL',
|
||||||
'원유': 'WEST TEXAS INTERMEDIATE (WTI)',
|
'원유': 'WEST TEXAS INTERMEDIATE',
|
||||||
'중유': 'GENERIC HEAVY FUEL OIL',
|
'중유': 'GENERIC HEAVY FUEL OIL',
|
||||||
'등유': 'FUEL OIL NO.1 (KEROSENE)',
|
'등유': 'FUEL OIL NO.1 (KEROSENE) ',
|
||||||
'휘발유': 'GENERIC GASOLINE',
|
'휘발유': 'GENERIC GASOLINE',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,12 +19,15 @@ import hnsRouter from './hns/hnsRouter.js'
|
|||||||
import reportsRouter from './reports/reportsRouter.js'
|
import reportsRouter from './reports/reportsRouter.js'
|
||||||
import assetsRouter from './assets/assetsRouter.js'
|
import assetsRouter from './assets/assetsRouter.js'
|
||||||
import incidentsRouter from './incidents/incidentsRouter.js'
|
import incidentsRouter from './incidents/incidentsRouter.js'
|
||||||
|
import gscAccidentsRouter from './gsc/gscAccidentsRouter.js'
|
||||||
import scatRouter from './scat/scatRouter.js'
|
import scatRouter from './scat/scatRouter.js'
|
||||||
import predictionRouter from './prediction/predictionRouter.js'
|
import predictionRouter from './prediction/predictionRouter.js'
|
||||||
import aerialRouter from './aerial/aerialRouter.js'
|
import aerialRouter from './aerial/aerialRouter.js'
|
||||||
import rescueRouter from './rescue/rescueRouter.js'
|
import rescueRouter from './rescue/rescueRouter.js'
|
||||||
import mapBaseRouter from './map-base/mapBaseRouter.js'
|
import mapBaseRouter from './map-base/mapBaseRouter.js'
|
||||||
import monitorRouter from './monitor/monitorRouter.js'
|
import monitorRouter from './monitor/monitorRouter.js'
|
||||||
|
import vesselRouter from './vessels/vesselRouter.js'
|
||||||
|
import { startVesselScheduler } from './vessels/vesselScheduler.js'
|
||||||
import {
|
import {
|
||||||
sanitizeBody,
|
sanitizeBody,
|
||||||
sanitizeQuery,
|
sanitizeQuery,
|
||||||
@ -168,6 +171,7 @@ app.use('/api/hns', hnsRouter)
|
|||||||
app.use('/api/reports', reportsRouter)
|
app.use('/api/reports', reportsRouter)
|
||||||
app.use('/api/assets', assetsRouter)
|
app.use('/api/assets', assetsRouter)
|
||||||
app.use('/api/incidents', incidentsRouter)
|
app.use('/api/incidents', incidentsRouter)
|
||||||
|
app.use('/api/gsc/accidents', gscAccidentsRouter)
|
||||||
app.use('/api/scat', scatRouter)
|
app.use('/api/scat', scatRouter)
|
||||||
app.use('/api/prediction', predictionRouter)
|
app.use('/api/prediction', predictionRouter)
|
||||||
app.use('/api/aerial', aerialRouter)
|
app.use('/api/aerial', aerialRouter)
|
||||||
@ -175,6 +179,7 @@ app.use('/api/rescue', rescueRouter)
|
|||||||
app.use('/api/map-base', mapBaseRouter)
|
app.use('/api/map-base', mapBaseRouter)
|
||||||
app.use('/api/monitor', monitorRouter)
|
app.use('/api/monitor', monitorRouter)
|
||||||
app.use('/api/tiles', tilesRouter)
|
app.use('/api/tiles', tilesRouter)
|
||||||
|
app.use('/api/vessels', vesselRouter)
|
||||||
|
|
||||||
// 헬스 체크
|
// 헬스 체크
|
||||||
app.get('/health', (_req, res) => {
|
app.get('/health', (_req, res) => {
|
||||||
@ -210,6 +215,9 @@ app.use((err: Error, _req: express.Request, res: express.Response, _next: expres
|
|||||||
app.listen(PORT, async () => {
|
app.listen(PORT, async () => {
|
||||||
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
|
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
|
||||||
|
|
||||||
|
// 선박 신호 스케줄러 시작 (한국 전 해역 1분 폴링)
|
||||||
|
startVesselScheduler()
|
||||||
|
|
||||||
// wing DB 연결 확인 (wing + auth 스키마 통합)
|
// wing DB 연결 확인 (wing + auth 스키마 통합)
|
||||||
const connected = await testWingDbConnection()
|
const connected = await testWingDbConnection()
|
||||||
if (connected) {
|
if (connected) {
|
||||||
|
|||||||
33
backend/src/vessels/vesselRouter.ts
Normal file
33
backend/src/vessels/vesselRouter.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getVesselsInBounds, getCacheStatus } from './vesselService.js';
|
||||||
|
import type { BoundingBox } from './vesselTypes.js';
|
||||||
|
|
||||||
|
const vesselRouter = Router();
|
||||||
|
|
||||||
|
// POST /api/vessels/in-area
|
||||||
|
// 현재 뷰포트 bbox 안의 선박 목록 반환 (메모리 캐시에서 필터링)
|
||||||
|
vesselRouter.post('/in-area', (req, res) => {
|
||||||
|
const { bounds } = req.body as { bounds?: BoundingBox };
|
||||||
|
|
||||||
|
if (
|
||||||
|
!bounds ||
|
||||||
|
typeof bounds.minLon !== 'number' ||
|
||||||
|
typeof bounds.minLat !== 'number' ||
|
||||||
|
typeof bounds.maxLon !== 'number' ||
|
||||||
|
typeof bounds.maxLat !== 'number'
|
||||||
|
) {
|
||||||
|
res.status(400).json({ error: '유효한 bounds 정보가 필요합니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vessels = getVesselsInBounds(bounds);
|
||||||
|
res.json(vessels);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/vessels/status — 캐시 상태 확인 (디버그용)
|
||||||
|
vesselRouter.get('/status', (_req, res) => {
|
||||||
|
const status = getCacheStatus();
|
||||||
|
res.json(status);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default vesselRouter;
|
||||||
96
backend/src/vessels/vesselScheduler.ts
Normal file
96
backend/src/vessels/vesselScheduler.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { updateVesselCache } from './vesselService.js';
|
||||||
|
import type { VesselPosition } from './vesselTypes.js';
|
||||||
|
|
||||||
|
const VESSEL_TRACK_API_URL =
|
||||||
|
process.env.VESSEL_TRACK_API_URL ?? 'https://guide.gc-si.dev/signal-batch';
|
||||||
|
const POLL_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
|
// 개별 쿠키 환경변수를 조합하여 Cookie 헤더 문자열 생성
|
||||||
|
function buildVesselCookie(): string {
|
||||||
|
const entries: [string, string | undefined][] = [
|
||||||
|
['apt.uid', process.env.VESSEL_COOKIE_APT_UID],
|
||||||
|
['g_state', process.env.VESSEL_COOKIE_G_STATE],
|
||||||
|
['gc_proxy_auth', process.env.VESSEL_COOKIE_GC_PROXY_AUTH],
|
||||||
|
['GC_SESSION', process.env.VESSEL_COOKIE_GC_SESSION],
|
||||||
|
// 기존 단일 쿠키 변수 폴백 (레거시 지원)
|
||||||
|
];
|
||||||
|
const parts = entries
|
||||||
|
.filter(([, v]) => v)
|
||||||
|
.map(([k, v]) => `${k}=${v}`);
|
||||||
|
|
||||||
|
// 기존 VESSEL_TRACK_COOKIE 폴백 (단일 문자열로 설정된 경우)
|
||||||
|
if (parts.length === 0 && process.env.VESSEL_TRACK_COOKIE) {
|
||||||
|
return process.env.VESSEL_TRACK_COOKIE;
|
||||||
|
}
|
||||||
|
return parts.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 한국 전 해역 고정 폴리곤 (124~132°E, 32~38°N)
|
||||||
|
const KOREA_WATERS_POLYGON = [
|
||||||
|
[120, 31],
|
||||||
|
[132, 31],
|
||||||
|
[132, 41],
|
||||||
|
[120, 41],
|
||||||
|
[120, 31],
|
||||||
|
];
|
||||||
|
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
async function pollVesselSignals(): Promise<void> {
|
||||||
|
const url = `${VESSEL_TRACK_API_URL}/api/v1/vessels/recent-positions-detail`;
|
||||||
|
const body = {
|
||||||
|
minutes: 5,
|
||||||
|
coordinates: KOREA_WATERS_POLYGON,
|
||||||
|
polygonFilter: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cookie = buildVesselCookie();
|
||||||
|
const requestHeaders: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(cookie ? { Cookie: cookie } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: requestHeaders,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
console.error(`[vesselScheduler] 선박 신호 API 오류: ${res.status}`, text.substring(0, 200));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = res.headers.get('content-type') ?? '';
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
console.error('[vesselScheduler] 선박 신호 API가 JSON이 아닌 응답 반환:', text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as VesselPosition[];
|
||||||
|
updateVesselCache(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[vesselScheduler] 선박 신호 폴링 실패:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startVesselScheduler(): void {
|
||||||
|
if (intervalId !== null) return;
|
||||||
|
|
||||||
|
// 서버 시작 시 즉시 1회 실행 후 주기적 폴링
|
||||||
|
pollVesselSignals();
|
||||||
|
intervalId = setInterval(pollVesselSignals, POLL_INTERVAL_MS);
|
||||||
|
console.log('[vesselScheduler] 선박 신호 스케줄러 시작 (1분 간격)');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopVesselScheduler(): void {
|
||||||
|
if (intervalId !== null) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
console.log('[vesselScheduler] 선박 신호 스케줄러 중지');
|
||||||
|
}
|
||||||
|
}
|
||||||
55
backend/src/vessels/vesselService.ts
Normal file
55
backend/src/vessels/vesselService.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import type { VesselPosition, BoundingBox } from './vesselTypes.js';
|
||||||
|
|
||||||
|
const VESSEL_TTL_MS = 10 * 60 * 1000; // 10분
|
||||||
|
|
||||||
|
const cachedVessels = new Map<string, VesselPosition>();
|
||||||
|
let lastUpdated: Date | null = null;
|
||||||
|
|
||||||
|
// lastUpdate가 TTL을 초과한 선박을 캐시에서 제거.
|
||||||
|
// lastUpdate 파싱이 불가능한 경우 보수적으로 유지한다.
|
||||||
|
function evictStale(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [mmsi, vessel] of cachedVessels) {
|
||||||
|
const ts = Date.parse(vessel.lastUpdate);
|
||||||
|
if (Number.isNaN(ts)) continue;
|
||||||
|
if (now - ts > VESSEL_TTL_MS) {
|
||||||
|
cachedVessels.delete(mmsi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateVesselCache(vessels: VesselPosition[]): void {
|
||||||
|
for (const vessel of vessels) {
|
||||||
|
if (!vessel.mmsi) continue;
|
||||||
|
cachedVessels.set(vessel.mmsi, vessel);
|
||||||
|
}
|
||||||
|
evictStale();
|
||||||
|
lastUpdated = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVesselsInBounds(bounds: BoundingBox): VesselPosition[] {
|
||||||
|
const result: VesselPosition[] = [];
|
||||||
|
for (const v of cachedVessels.values()) {
|
||||||
|
if (
|
||||||
|
v.lon >= bounds.minLon &&
|
||||||
|
v.lon <= bounds.maxLon &&
|
||||||
|
v.lat >= bounds.minLat &&
|
||||||
|
v.lat <= bounds.maxLat
|
||||||
|
) {
|
||||||
|
result.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCacheStatus(): {
|
||||||
|
count: number;
|
||||||
|
bangjeCount: number;
|
||||||
|
lastUpdated: Date | null;
|
||||||
|
} {
|
||||||
|
let bangjeCount = 0;
|
||||||
|
for (const v of cachedVessels.values()) {
|
||||||
|
if (v.shipNm && v.shipNm.toUpperCase().includes('BANGJE')) bangjeCount++;
|
||||||
|
}
|
||||||
|
return { count: cachedVessels.size, bangjeCount, lastUpdated };
|
||||||
|
}
|
||||||
26
backend/src/vessels/vesselTypes.ts
Normal file
26
backend/src/vessels/vesselTypes.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export interface VesselPosition {
|
||||||
|
mmsi: string;
|
||||||
|
imo?: number;
|
||||||
|
lon: number;
|
||||||
|
lat: number;
|
||||||
|
sog?: number;
|
||||||
|
cog?: number;
|
||||||
|
heading?: number;
|
||||||
|
shipNm?: string;
|
||||||
|
shipTy?: string;
|
||||||
|
shipKindCode?: string;
|
||||||
|
nationalCode?: string;
|
||||||
|
lastUpdate: string;
|
||||||
|
status?: string;
|
||||||
|
destination?: string;
|
||||||
|
length?: number;
|
||||||
|
width?: number;
|
||||||
|
draught?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoundingBox {
|
||||||
|
minLon: number;
|
||||||
|
minLat: number;
|
||||||
|
maxLon: number;
|
||||||
|
maxLat: number;
|
||||||
|
}
|
||||||
@ -293,7 +293,7 @@ CREATE TABLE SPIL_DATA (
|
|||||||
SPIL_DATA_SN SERIAL NOT NULL, -- 유출정보순번
|
SPIL_DATA_SN SERIAL NOT NULL, -- 유출정보순번
|
||||||
ACDNT_SN INTEGER NOT NULL, -- 사고순번
|
ACDNT_SN INTEGER NOT NULL, -- 사고순번
|
||||||
OIL_TP_CD VARCHAR(50) NOT NULL, -- 유종코드
|
OIL_TP_CD VARCHAR(50) NOT NULL, -- 유종코드
|
||||||
SPIL_QTY NUMERIC(12,2), -- 유출량
|
SPIL_QTY NUMERIC(14,10), -- 유출량
|
||||||
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', -- 유출단위코드
|
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', -- 유출단위코드
|
||||||
SPIL_TP_CD VARCHAR(20), -- 유출유형코드
|
SPIL_TP_CD VARCHAR(20), -- 유출유형코드
|
||||||
SPIL_LOC_GEOM GEOMETRY(Point, 4326), -- 유출위치지오메트리
|
SPIL_LOC_GEOM GEOMETRY(Point, 4326), -- 유출위치지오메트리
|
||||||
|
|||||||
@ -40,7 +40,7 @@ CREATE TABLE IF NOT EXISTS SPIL_DATA (
|
|||||||
SPIL_DATA_SN SERIAL NOT NULL,
|
SPIL_DATA_SN SERIAL NOT NULL,
|
||||||
ACDNT_SN INTEGER NOT NULL,
|
ACDNT_SN INTEGER NOT NULL,
|
||||||
OIL_TP_CD VARCHAR(50) NOT NULL,
|
OIL_TP_CD VARCHAR(50) NOT NULL,
|
||||||
SPIL_QTY NUMERIC(12,2),
|
SPIL_QTY NUMERIC(14,10),
|
||||||
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL',
|
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL',
|
||||||
SPIL_TP_CD VARCHAR(20),
|
SPIL_TP_CD VARCHAR(20),
|
||||||
FCST_HR INTEGER,
|
FCST_HR INTEGER,
|
||||||
|
|||||||
@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS HNS_ANALYSIS (
|
|||||||
SBST_NM VARCHAR(100),
|
SBST_NM VARCHAR(100),
|
||||||
UN_NO VARCHAR(10),
|
UN_NO VARCHAR(10),
|
||||||
CAS_NO VARCHAR(20),
|
CAS_NO VARCHAR(20),
|
||||||
SPIL_QTY NUMERIC(10,2),
|
SPIL_QTY NUMERIC(14,10),
|
||||||
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL',
|
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL',
|
||||||
SPIL_TP_CD VARCHAR(20),
|
SPIL_TP_CD VARCHAR(20),
|
||||||
FCST_HR INTEGER,
|
FCST_HR INTEGER,
|
||||||
|
|||||||
7
database/migration/031_spil_qty_precision.sql
Normal file
7
database/migration/031_spil_qty_precision.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
-- 031: 유출량(SPIL_QTY) 소수점 정밀도 확대
|
||||||
|
-- 이미지 분석 결과로 1e-7 수준의 매우 작은 유출량을 저장할 수 있도록
|
||||||
|
-- NUMERIC(12,2) / NUMERIC(10,2) → NUMERIC(14,10) 으로 변경
|
||||||
|
-- 정수부 최대 4자리, 소수부 10자리
|
||||||
|
|
||||||
|
ALTER TABLE wing.SPIL_DATA ALTER COLUMN SPIL_QTY TYPE NUMERIC(14,10);
|
||||||
|
ALTER TABLE wing.HNS_ANALY ALTER COLUMN SPIL_QTY TYPE NUMERIC(14,10);
|
||||||
118
database/migration/032_sync_gsc_accidents_to_wing.sql
Normal file
118
database/migration/032_sync_gsc_accidents_to_wing.sql
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 032: gsc.tgs_acdnt_info → wing.ACDNT 동기화 (2026-04-10 이후)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 목적
|
||||||
|
-- 3개 예측 탭(유출유확산예측 / HNS 대기확산 / 긴급구난)의 사고
|
||||||
|
-- 선택 셀렉트박스에 노출되는 gsc 사고 레코드를 wing.ACDNT에
|
||||||
|
-- 이관하여 wing 운영 로직과 동일한 사고 마스터를 공유한다.
|
||||||
|
--
|
||||||
|
-- 필터 정책 (backend/src/gsc/gscAccidentsService.ts 의 listGscAccidents 와 동일)
|
||||||
|
-- - acdnt_asort_code IN (12개 코드)
|
||||||
|
-- - acdnt_title IS NOT NULL
|
||||||
|
-- - 좌표(tgs_acdnt_lc.la, lo) 존재
|
||||||
|
-- - rcept_dt >= '2026-04-10' (본 이관 추가 조건)
|
||||||
|
--
|
||||||
|
-- ACDNT_CD 생성 규칙
|
||||||
|
-- 'INC-YYYY-NNNN' (YYYY = rcept_dt 의 연도, NNNN = 해당 연도 내 순번 4자리)
|
||||||
|
-- 기존 wing.ACDNT 에 이미 부여된 'INC-YYYY-NNNN' 중 같은 연도의 최대 순번을
|
||||||
|
-- 구해 이어서 증가시킨다.
|
||||||
|
--
|
||||||
|
-- 중복 방지
|
||||||
|
-- (ACDNT_NM = acdnt_title, OCCRN_DTM = rcept_dt) 조합이 이미 존재하면 제외.
|
||||||
|
-- acdnt_mng_no 를 별도 컬럼으로 보관하지 않으므로 이 조합을 자연 키로 사용.
|
||||||
|
--
|
||||||
|
-- ACDNT_TP_CD
|
||||||
|
-- gsc.tcm_code.code_nm 으로 치환 (JOIN: tcm_code.code = acdnt_asort_code)
|
||||||
|
-- 매핑 누락 시 원본 코드값으로 폴백.
|
||||||
|
--
|
||||||
|
-- 사전 확인 쿼리 (실행 전 참고)
|
||||||
|
-- SELECT COUNT(DISTINCT a.acdnt_mng_no)
|
||||||
|
-- FROM gsc.tgs_acdnt_info a JOIN gsc.tgs_acdnt_lc b USING (acdnt_mng_no)
|
||||||
|
-- WHERE a.acdnt_asort_code = ANY(ARRAY[
|
||||||
|
-- '055001001','055001002','055001003','055001004','055001005','055001006',
|
||||||
|
-- '055003001','055003002','055003003','055003004','055003005','055004003'
|
||||||
|
-- ]::varchar[])
|
||||||
|
-- AND a.acdnt_title IS NOT NULL
|
||||||
|
-- AND a.rcept_dt >= '2026-04-10';
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
WITH src AS (
|
||||||
|
SELECT DISTINCT ON (a.acdnt_mng_no)
|
||||||
|
a.acdnt_mng_no,
|
||||||
|
a.acdnt_title,
|
||||||
|
a.acdnt_asort_code,
|
||||||
|
a.rcept_dt,
|
||||||
|
b.la,
|
||||||
|
b.lo
|
||||||
|
FROM gsc.tgs_acdnt_info AS a
|
||||||
|
JOIN gsc.tgs_acdnt_lc AS b ON a.acdnt_mng_no = b.acdnt_mng_no
|
||||||
|
WHERE a.acdnt_asort_code = ANY(ARRAY[
|
||||||
|
'055001001','055001002','055001003','055001004','055001005','055001006',
|
||||||
|
'055003001','055003002','055003003','055003004','055003005','055004003'
|
||||||
|
]::varchar[])
|
||||||
|
AND a.acdnt_title IS NOT NULL
|
||||||
|
AND a.rcept_dt >= '2026-04-10'::timestamptz
|
||||||
|
AND b.la IS NOT NULL AND b.lo IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM wing.ACDNT w
|
||||||
|
WHERE w.ACDNT_NM = a.acdnt_title
|
||||||
|
AND w.OCCRN_DTM = a.rcept_dt
|
||||||
|
)
|
||||||
|
ORDER BY a.acdnt_mng_no, b.acdnt_lc_sn ASC
|
||||||
|
),
|
||||||
|
numbered AS (
|
||||||
|
SELECT
|
||||||
|
src.*,
|
||||||
|
EXTRACT(YEAR FROM src.rcept_dt)::int AS yr,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY EXTRACT(YEAR FROM src.rcept_dt)
|
||||||
|
ORDER BY src.rcept_dt ASC, src.acdnt_mng_no ASC
|
||||||
|
) AS rn_in_year
|
||||||
|
FROM src
|
||||||
|
),
|
||||||
|
year_max AS (
|
||||||
|
SELECT
|
||||||
|
(split_part(ACDNT_CD, '-', 2))::int AS yr,
|
||||||
|
MAX((split_part(ACDNT_CD, '-', 3))::int) AS max_seq
|
||||||
|
FROM wing.ACDNT
|
||||||
|
WHERE ACDNT_CD ~ '^INC-[0-9]{4}-[0-9]+$'
|
||||||
|
GROUP BY split_part(ACDNT_CD, '-', 2)
|
||||||
|
)
|
||||||
|
INSERT INTO wing.ACDNT (
|
||||||
|
ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, ACDNT_STTS_CD,
|
||||||
|
LAT, LNG, LOC_GEOM, LOC_DC, OCCRN_DTM, REG_DTM, MDFCN_DTM
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'INC-' || lpad(n.yr::text, 4, '0') || '-' ||
|
||||||
|
lpad((COALESCE(ym.max_seq, 0) + n.rn_in_year)::text, 4, '0') AS ACDNT_CD,
|
||||||
|
n.acdnt_title AS ACDNT_NM,
|
||||||
|
COALESCE(c.code_nm, n.acdnt_asort_code) AS ACDNT_TP_CD,
|
||||||
|
'ACTIVE' AS ACDNT_STTS_CD,
|
||||||
|
n.la::numeric AS LAT,
|
||||||
|
n.lo::numeric AS LNG,
|
||||||
|
ST_SetSRID(ST_MakePoint(n.lo::float8, n.la::float8), 4326) AS LOC_GEOM,
|
||||||
|
NULL AS LOC_DC,
|
||||||
|
n.rcept_dt AS OCCRN_DTM,
|
||||||
|
NOW(), NOW()
|
||||||
|
FROM numbered n
|
||||||
|
LEFT JOIN year_max ym ON ym.yr = n.yr
|
||||||
|
LEFT JOIN gsc.tcm_code c ON c.code = n.acdnt_asort_code
|
||||||
|
ORDER BY n.rcept_dt ASC, n.acdnt_mng_no ASC;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 사후 검증 (필요 시 주석 해제 실행)
|
||||||
|
-- SELECT COUNT(*) FROM wing.ACDNT WHERE OCCRN_DTM >= '2026-04-10';
|
||||||
|
--
|
||||||
|
-- SELECT ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, ST_AsText(LOC_GEOM), OCCRN_DTM
|
||||||
|
-- FROM wing.ACDNT
|
||||||
|
-- WHERE OCCRN_DTM >= '2026-04-10'
|
||||||
|
-- ORDER BY ACDNT_CD DESC
|
||||||
|
-- LIMIT 20;
|
||||||
|
--
|
||||||
|
-- SELECT ACDNT_TP_CD, COUNT(*)
|
||||||
|
-- FROM wing.ACDNT
|
||||||
|
-- WHERE OCCRN_DTM >= '2026-04-10'
|
||||||
|
-- GROUP BY 1
|
||||||
|
-- ORDER BY 2 DESC;
|
||||||
|
-- ============================================================
|
||||||
@ -378,7 +378,7 @@ PUT, DELETE, PATCH 등 기타 메서드는 사용하지 않는다.
|
|||||||
각 탭은 `tabs/{탭명}/services/{탭명}Api.ts`에 API 함수를 정의한다.
|
각 탭은 `tabs/{탭명}/services/{탭명}Api.ts`에 API 함수를 정의한다.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/tabs/board/services/boardApi.ts
|
// frontend/src/components/board/services/boardApi.ts
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
// 인터페이스 정의
|
// 인터페이스 정의
|
||||||
@ -490,7 +490,7 @@ interface MenuConfigItem {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/common/store/newStore.ts (공통) 또는
|
// frontend/src/common/store/newStore.ts (공통) 또는
|
||||||
// frontend/src/tabs/{탭}/store/newStore.ts (탭 전용)
|
// frontend/src/components/{탭}/store/newStore.ts (탭 전용)
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
interface MyState {
|
interface MyState {
|
||||||
@ -514,7 +514,7 @@ export const useMyStore = create<MyState>((set) => ({
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { fetchBoardPosts, createBoardPost } from '@tabs/board/services/boardApi';
|
import { fetchBoardPosts, createBoardPost } from '@components/board/services/boardApi';
|
||||||
|
|
||||||
// 조회 (캐싱 + 자동 리페치)
|
// 조회 (캐싱 + 자동 리페치)
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
@ -1491,13 +1491,13 @@ const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1'
|
|||||||
### 파일 위치
|
### 파일 위치
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/src/tabs/{탭명}/services/{탭명}Api.ts
|
frontend/src/components/{탭명}/services/{탭명}Api.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
### 작성 패턴
|
### 작성 패턴
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/tabs/{탭명}/services/{탭명}Api.ts
|
// frontend/src/components/{탭명}/services/{탭명}Api.ts
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -736,13 +736,13 @@ ON CONFLICT DO NOTHING;
|
|||||||
### 파일 위치
|
### 파일 위치
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/src/tabs/{탭명}/services/{tabName}Api.ts
|
frontend/src/components/{탭명}/services/{tabName}Api.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
### 기본 구조
|
### 기본 구조
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// frontend/src/tabs/{탭명}/services/{tabName}Api.ts
|
// frontend/src/components/{탭명}/services/{tabName}Api.ts
|
||||||
|
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
@ -1376,7 +1376,7 @@ export default router;
|
|||||||
### 4단계: 프론트엔드 API 서비스
|
### 4단계: 프론트엔드 API 서비스
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// frontend/src/tabs/assets/services/equipmentApi.ts
|
// frontend/src/components/assets/services/equipmentApi.ts
|
||||||
|
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
|
|||||||
@ -163,11 +163,11 @@ Frontend에서 두 가지 경로 별칭을 사용한다:
|
|||||||
| Alias | 실제 경로 | 용도 |
|
| Alias | 실제 경로 | 용도 |
|
||||||
|-------|----------|------|
|
|-------|----------|------|
|
||||||
| `@common/*` | `src/common/*` | 공통 모듈 (컴포넌트, 훅, 서비스, 스토어) |
|
| `@common/*` | `src/common/*` | 공통 모듈 (컴포넌트, 훅, 서비스, 스토어) |
|
||||||
| `@tabs/*` | `src/tabs/*` | 탭별 패키지 (11개 탭) |
|
| `@components/*` | `src/components/*` | 탭별 패키지 (11개 탭) |
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { useAuth } from '@common/hooks/useAuth';
|
import { useAuth } from '@common/hooks/useAuth';
|
||||||
import OilSpillView from '@tabs/prediction/components/OilSpillView';
|
import OilSpillView from '@components/prediction/components/OilSpillView';
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -495,7 +495,7 @@ pre-commit: [backend] 타입 체크 성공
|
|||||||
git status
|
git status
|
||||||
|
|
||||||
# 스테이징 (파일 지정)
|
# 스테이징 (파일 지정)
|
||||||
git add frontend/src/tabs/incidents/components/IncidentDetailView.tsx
|
git add frontend/src/components/incidents/components/IncidentDetailView.tsx
|
||||||
git add backend/src/incidents/incidentService.ts
|
git add backend/src/incidents/incidentService.ts
|
||||||
|
|
||||||
# 커밋 (pre-commit + commit-msg 검증 자동 실행)
|
# 커밋 (pre-commit + commit-msg 검증 자동 실행)
|
||||||
@ -540,7 +540,7 @@ curl -X POST "https://gitea.gc-si.dev/api/v1/repos/gc/wing-ops/pulls" \
|
|||||||
- 변경 내용을 1~3줄로 요약
|
- 변경 내용을 1~3줄로 요약
|
||||||
|
|
||||||
## 변경 파일
|
## 변경 파일
|
||||||
- `frontend/src/tabs/incidents/components/IncidentDetailView.tsx` (신규)
|
- `frontend/src/components/incidents/components/IncidentDetailView.tsx` (신규)
|
||||||
- `backend/src/incidents/incidentService.ts` (수정)
|
- `backend/src/incidents/incidentService.ts` (수정)
|
||||||
|
|
||||||
## Test plan
|
## Test plan
|
||||||
@ -754,8 +754,8 @@ chmod +x .githooks/pre-commit .githooks/commit-msg
|
|||||||
| `database/migration/017_incident_detail.sql` | DB 마이그레이션 (필요 시) |
|
| `database/migration/017_incident_detail.sql` | DB 마이그레이션 (필요 시) |
|
||||||
| `backend/src/incidents/incidentService.ts` | 상세 조회 함수 추가 |
|
| `backend/src/incidents/incidentService.ts` | 상세 조회 함수 추가 |
|
||||||
| `backend/src/incidents/incidentRouter.ts` | `GET /api/incidents/:id` 라우트 |
|
| `backend/src/incidents/incidentRouter.ts` | `GET /api/incidents/:id` 라우트 |
|
||||||
| `frontend/src/tabs/incidents/services/incidentsApi.ts` | API 호출 함수 |
|
| `frontend/src/components/incidents/services/incidentsApi.ts` | API 호출 함수 |
|
||||||
| `frontend/src/tabs/incidents/components/IncidentDetailView.tsx` | 상세 뷰 컴포넌트 |
|
| `frontend/src/components/incidents/components/IncidentDetailView.tsx` | 상세 뷰 컴포넌트 |
|
||||||
|
|
||||||
#### Step 2. 브랜치 생성
|
#### Step 2. 브랜치 생성
|
||||||
|
|
||||||
@ -797,7 +797,7 @@ router.get('/:id', requireAuth, async (req, res) => {
|
|||||||
**Frontend - API:**
|
**Frontend - API:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/tabs/incidents/services/incidentsApi.ts
|
// frontend/src/components/incidents/services/incidentsApi.ts
|
||||||
export async function fetchIncidentById(id: number) {
|
export async function fetchIncidentById(id: number) {
|
||||||
const { data } = await api.get(`/incidents/${id}`);
|
const { data } = await api.get(`/incidents/${id}`);
|
||||||
return data;
|
return data;
|
||||||
@ -807,7 +807,7 @@ export async function fetchIncidentById(id: number) {
|
|||||||
**Frontend - Component:**
|
**Frontend - Component:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// frontend/src/tabs/incidents/components/IncidentDetailView.tsx
|
// frontend/src/components/incidents/components/IncidentDetailView.tsx
|
||||||
const IncidentDetailView = ({ incidentId }: IncidentDetailViewProps) => {
|
const IncidentDetailView = ({ incidentId }: IncidentDetailViewProps) => {
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['incident', incidentId],
|
queryKey: ['incident', incidentId],
|
||||||
@ -829,7 +829,7 @@ cd ../backend && npx tsc --noEmit
|
|||||||
#### Step 5. 커밋 & 푸시
|
#### Step 5. 커밋 & 푸시
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add backend/src/incidents/ frontend/src/tabs/incidents/
|
git add backend/src/incidents/ frontend/src/components/incidents/
|
||||||
git commit -m "feat(incidents): 사고 상세 조회 페이지 추가"
|
git commit -m "feat(incidents): 사고 상세 조회 페이지 추가"
|
||||||
# pre-commit: TypeScript OK, ESLint OK
|
# pre-commit: TypeScript OK, ESLint OK
|
||||||
# commit-msg: Conventional Commits OK
|
# commit-msg: Conventional Commits OK
|
||||||
|
|||||||
@ -31,9 +31,9 @@ board 탭을 기준 템플릿으로 사용하며, 각 단계별 실제 코드
|
|||||||
|
|
||||||
| 단계 | 파일 | 작업 |
|
| 단계 | 파일 | 작업 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| **Step 1** | `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` | 뷰 컴포넌트 생성 |
|
| **Step 1** | `frontend/src/components/{탭명}/components/{TabName}View.tsx` | 뷰 컴포넌트 생성 |
|
||||||
| | `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` | API 서비스 생성 |
|
| | `frontend/src/components/{탭명}/services/{tabName}Api.ts` | API 서비스 생성 |
|
||||||
| | `frontend/src/tabs/{탭명}/index.ts` | re-export |
|
| | `frontend/src/components/{탭명}/index.ts` | re-export |
|
||||||
| **Step 2** | `frontend/src/common/types/navigation.ts` | MainTab 타입 추가 |
|
| **Step 2** | `frontend/src/common/types/navigation.ts` | MainTab 타입 추가 |
|
||||||
| | `frontend/src/App.tsx` | import + renderView case 추가 |
|
| | `frontend/src/App.tsx` | import + renderView case 추가 |
|
||||||
| | `frontend/src/common/hooks/useSubMenu.ts` | 서브메뉴 설정 (서브탭이 있는 경우) |
|
| | `frontend/src/common/hooks/useSubMenu.ts` | 서브메뉴 설정 (서브탭이 있는 경우) |
|
||||||
@ -52,7 +52,7 @@ board 탭을 기준 템플릿으로 사용하며, 각 단계별 실제 코드
|
|||||||
### 1-1. 디렉토리 구조
|
### 1-1. 디렉토리 구조
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/src/tabs/{탭명}/
|
frontend/src/components/{탭명}/
|
||||||
components/
|
components/
|
||||||
{TabName}View.tsx # 메인 뷰 컴포넌트
|
{TabName}View.tsx # 메인 뷰 컴포넌트
|
||||||
services/
|
services/
|
||||||
@ -65,7 +65,7 @@ frontend/src/tabs/{탭명}/
|
|||||||
서브탭이 **없는** 간단한 탭:
|
서브탭이 **없는** 간단한 탭:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// frontend/src/tabs/monitoring/components/MonitoringView.tsx
|
// frontend/src/components/monitoring/components/MonitoringView.tsx
|
||||||
|
|
||||||
export function MonitoringView() {
|
export function MonitoringView() {
|
||||||
return (
|
return (
|
||||||
@ -91,7 +91,7 @@ export function MonitoringView() {
|
|||||||
서브탭이 **있는** 탭 (board 패턴):
|
서브탭이 **있는** 탭 (board 패턴):
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// frontend/src/tabs/monitoring/components/MonitoringView.tsx
|
// frontend/src/components/monitoring/components/MonitoringView.tsx
|
||||||
|
|
||||||
import { useSubMenu } from '@common/hooks/useSubMenu';
|
import { useSubMenu } from '@common/hooks/useSubMenu';
|
||||||
|
|
||||||
@ -122,7 +122,7 @@ export function MonitoringView() {
|
|||||||
### 1-3. API 서비스 (보일러플레이트)
|
### 1-3. API 서비스 (보일러플레이트)
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// frontend/src/tabs/monitoring/services/monitoringApi.ts
|
// frontend/src/components/monitoring/services/monitoringApi.ts
|
||||||
|
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
@ -180,7 +180,7 @@ export async function createMonitoring(input: CreateMonitoringInput): Promise<{
|
|||||||
### 1-4. index.ts (re-export)
|
### 1-4. index.ts (re-export)
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// frontend/src/tabs/monitoring/index.ts
|
// frontend/src/components/monitoring/index.ts
|
||||||
|
|
||||||
export { MonitoringView } from './components/MonitoringView';
|
export { MonitoringView } from './components/MonitoringView';
|
||||||
```
|
```
|
||||||
@ -209,7 +209,7 @@ export type MainTab = 'prediction' | 'hns' | 'rescue' | ... | 'monitoring' | 'ad
|
|||||||
// frontend/src/App.tsx
|
// frontend/src/App.tsx
|
||||||
|
|
||||||
// 1. import 추가
|
// 1. import 추가
|
||||||
import { MonitoringView } from '@tabs/monitoring';
|
import { MonitoringView } from '@components/monitoring';
|
||||||
|
|
||||||
// 2. renderView switch에 case 추가
|
// 2. renderView switch에 case 추가
|
||||||
const renderView = () => {
|
const renderView = () => {
|
||||||
@ -577,13 +577,13 @@ CREATE INDEX IF NOT EXISTS IDX_MONITORING_REG_DTM ON MONITORING(REG_DTM DESC);
|
|||||||
### 1단계: 프론트엔드 파일 생성
|
### 1단계: 프론트엔드 파일 생성
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p frontend/src/tabs/monitoring/components
|
mkdir -p frontend/src/components/monitoring/components
|
||||||
mkdir -p frontend/src/tabs/monitoring/services
|
mkdir -p frontend/src/components/monitoring/services
|
||||||
```
|
```
|
||||||
|
|
||||||
- `frontend/src/tabs/monitoring/components/MonitoringView.tsx` 생성
|
- `frontend/src/components/monitoring/components/MonitoringView.tsx` 생성
|
||||||
- `frontend/src/tabs/monitoring/services/monitoringApi.ts` 생성
|
- `frontend/src/components/monitoring/services/monitoringApi.ts` 생성
|
||||||
- `frontend/src/tabs/monitoring/index.ts` 생성
|
- `frontend/src/components/monitoring/index.ts` 생성
|
||||||
|
|
||||||
### 2단계: 프론트엔드 기존 파일 수정
|
### 2단계: 프론트엔드 기존 파일 수정
|
||||||
|
|
||||||
@ -592,7 +592,7 @@ mkdir -p frontend/src/tabs/monitoring/services
|
|||||||
+ export type MainTab = '...' | 'monitoring' | 'admin';
|
+ export type MainTab = '...' | 'monitoring' | 'admin';
|
||||||
|
|
||||||
--- frontend/src/App.tsx
|
--- frontend/src/App.tsx
|
||||||
+ import { MonitoringView } from '@tabs/monitoring';
|
+ import { MonitoringView } from '@components/monitoring';
|
||||||
// renderView switch 내:
|
// renderView switch 내:
|
||||||
+ case 'monitoring':
|
+ case 'monitoring':
|
||||||
+ return <MonitoringView />;
|
+ return <MonitoringView />;
|
||||||
@ -644,9 +644,9 @@ cd backend && npx tsc --noEmit # 백엔드 컴파일 검증
|
|||||||
## 체크리스트
|
## 체크리스트
|
||||||
|
|
||||||
### 프론트엔드
|
### 프론트엔드
|
||||||
- [ ] `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` 생성
|
- [ ] `frontend/src/components/{탭명}/components/{TabName}View.tsx` 생성
|
||||||
- [ ] `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` 생성
|
- [ ] `frontend/src/components/{탭명}/services/{tabName}Api.ts` 생성
|
||||||
- [ ] `frontend/src/tabs/{탭명}/index.ts` re-export 생성
|
- [ ] `frontend/src/components/{탭명}/index.ts` re-export 생성
|
||||||
- [ ] `navigation.ts` MainTab 타입에 새 ID 추가
|
- [ ] `navigation.ts` MainTab 타입에 새 ID 추가
|
||||||
- [ ] `App.tsx` import + renderView switch case 추가
|
- [ ] `App.tsx` import + renderView switch case 추가
|
||||||
- [ ] `useSubMenu.ts` subMenuConfigs + subMenuState 추가 (서브탭 있는 경우)
|
- [ ] `useSubMenu.ts` subMenuConfigs + subMenuState 추가 (서브탭 있는 경우)
|
||||||
|
|||||||
@ -49,7 +49,7 @@ git checkout -b feature/{탭명}-crud
|
|||||||
```bash
|
```bash
|
||||||
# 탭 디렉토리 내 mock 데이터 검색
|
# 탭 디렉토리 내 mock 데이터 검색
|
||||||
grep -rn "mock\|Mock\|MOCK\|sample\|initial\|hardcod\|localStorage" \
|
grep -rn "mock\|Mock\|MOCK\|sample\|initial\|hardcod\|localStorage" \
|
||||||
frontend/src/tabs/{탭명}/
|
frontend/src/components/{탭명}/
|
||||||
|
|
||||||
# 공통 디렉토리에서 해당 탭 관련 데이터 확인 (반드시!)
|
# 공통 디렉토리에서 해당 탭 관련 데이터 확인 (반드시!)
|
||||||
grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/
|
grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/
|
||||||
@ -302,7 +302,7 @@ app.use('/api/{탭명}', newtabRouter);
|
|||||||
|
|
||||||
**1) API 서비스 파일 생성:**
|
**1) API 서비스 파일 생성:**
|
||||||
|
|
||||||
파일 위치: `frontend/src/tabs/{탭명}/services/{탭명}Api.ts`
|
파일 위치: `frontend/src/components/{탭명}/services/{탭명}Api.ts`
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
@ -476,7 +476,7 @@ CRUD 전체 흐름(생성 -> 조회 -> 수정 -> 삭제)을 확인하고 테스
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 해당 탭 디렉토리에서 mock 잔여 검색
|
# 해당 탭 디렉토리에서 mock 잔여 검색
|
||||||
grep -rn "mock\|Mock\|MOCK\|localStorage" frontend/src/tabs/{탭명}/
|
grep -rn "mock\|Mock\|MOCK\|localStorage" frontend/src/components/{탭명}/
|
||||||
|
|
||||||
# 공통 mock/data 디렉토리에서 해당 탭 관련 검색
|
# 공통 mock/data 디렉토리에서 해당 탭 관련 검색
|
||||||
grep -rn "{탭명}" frontend/src/common/mock/
|
grep -rn "{탭명}" frontend/src/common/mock/
|
||||||
@ -497,7 +497,7 @@ git status
|
|||||||
git add database/migration/017_{탭명}.sql
|
git add database/migration/017_{탭명}.sql
|
||||||
git add backend/src/{탭명}/
|
git add backend/src/{탭명}/
|
||||||
git add backend/src/server.ts
|
git add backend/src/server.ts
|
||||||
git add frontend/src/tabs/{탭명}/
|
git add frontend/src/components/{탭명}/
|
||||||
|
|
||||||
# 커밋 (Conventional Commits, 한국어)
|
# 커밋 (Conventional Commits, 한국어)
|
||||||
git commit -m "feat({탭명}): mock 데이터를 PostgreSQL + REST API로 전환"
|
git commit -m "feat({탭명}): mock 데이터를 PostgreSQL + REST API로 전환"
|
||||||
@ -602,7 +602,7 @@ AUTH_USER 주요 컬럼 참조:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 불충분 -- 탭 디렉토리만 검색
|
# 불충분 -- 탭 디렉토리만 검색
|
||||||
grep -rn "mock" frontend/src/tabs/{탭명}/
|
grep -rn "mock" frontend/src/components/{탭명}/
|
||||||
|
|
||||||
# 반드시 공통 디렉토리도 검색
|
# 반드시 공통 디렉토리도 검색
|
||||||
grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/
|
grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/
|
||||||
@ -780,8 +780,8 @@ export async function fetchCategories(): Promise<Category[]> {
|
|||||||
- [ ] 프론트 타입 체크 통과: `cd frontend && npx tsc --noEmit`
|
- [ ] 프론트 타입 체크 통과: `cd frontend && npx tsc --noEmit`
|
||||||
- [ ] ESLint 통과: `cd frontend && npx eslint .`
|
- [ ] ESLint 통과: `cd frontend && npx eslint .`
|
||||||
- [ ] CRUD 테스트: curl로 생성/조회/수정/삭제 정상 동작 확인
|
- [ ] CRUD 테스트: curl로 생성/조회/수정/삭제 정상 동작 확인
|
||||||
- [ ] Mock 잔여 0건: `grep -rn "mock\|Mock" frontend/src/tabs/{탭명}/` (UI 상수 제외)
|
- [ ] Mock 잔여 0건: `grep -rn "mock\|Mock" frontend/src/components/{탭명}/` (UI 상수 제외)
|
||||||
- [ ] PUT/DELETE 사용 0건: `grep -rn "api\.put\|api\.delete" frontend/src/tabs/{탭명}/`
|
- [ ] PUT/DELETE 사용 0건: `grep -rn "api\.put\|api\.delete" frontend/src/components/{탭명}/`
|
||||||
- [ ] 라우터 등록 확인: `server.ts`에 `app.use('/api/{탭명}', ...)` 추가됨
|
- [ ] 라우터 등록 확인: `server.ts`에 `app.use('/api/{탭명}', ...)` 추가됨
|
||||||
- [ ] 마이그레이션 실행 확인: psql로 테이블 생성 및 검증 SELECT 통과
|
- [ ] 마이그레이션 실행 확인: psql로 테이블 생성 및 검증 SELECT 통과
|
||||||
- [ ] 커밋 + 푸시 + MR 생성
|
- [ ] 커밋 + 푸시 + MR 생성
|
||||||
|
|||||||
@ -66,7 +66,7 @@ wing/
|
|||||||
│ │ ├── utils/ cn, coordinates, geo, sanitize
|
│ │ ├── utils/ cn, coordinates, geo, sanitize
|
||||||
│ │ ├── styles/ base.css, components.css, wing.css (@layer)
|
│ │ ├── styles/ base.css, components.css, wing.css (@layer)
|
||||||
│ │ └── constants/ featureIds.ts (FEATURE_ID 상수 체계)
|
│ │ └── constants/ featureIds.ts (FEATURE_ID 상수 체계)
|
||||||
│ └── tabs/ @tabs/ alias (11개 탭)
|
│ └── tabs/ @components/ alias (11개 탭)
|
||||||
│ ├── prediction/ 유류 확산 예측
|
│ ├── prediction/ 유류 확산 예측
|
||||||
│ ├── hns/ HNS 분석
|
│ ├── hns/ HNS 분석
|
||||||
│ ├── rescue/ 구조 시나리오
|
│ ├── rescue/ 구조 시나리오
|
||||||
@ -103,7 +103,7 @@ wing/
|
|||||||
| Alias | 경로 |
|
| Alias | 경로 |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| `@common/*` | `src/common/*` |
|
| `@common/*` | `src/common/*` |
|
||||||
| `@tabs/*` | `src/tabs/*` |
|
| `@components/*` | `src/components/*` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,66 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2026-04-17]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- HNS: 물질 DB 데이터 확장 및 데이터 구조 개선 (PDF 추출 스크립트, 병합 스크립트 개선, 물질 상세 패널 업데이트)
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 디자인 시스템: color 토큰 Definition 팔레트로 마이그레이션 (bg/stroke/fg 쿨톤 전환, Primary #0099DD 적용)
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- 빌드 에러 수정 - 타입 import 정리 및 미사용 코드 제거
|
||||||
|
|
||||||
|
## [2026-04-16]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- HNS: AEGL 등농도선 표출 및 자동 줌·동적 도메인 기능 추가
|
||||||
|
- 사건사고: 통합 분석 패널 HNS/구난 연동 및 사고 목록을 wing.ACDNT 기반으로 전환
|
||||||
|
- 사건사고: 통합 분석 패널 분할 뷰 및 이전 분석 결과 비교 표출 + 분석 선택 모달 추가
|
||||||
|
- 확산예측: 유출유 확산 요약 API 신규 (`/analyses/:acdntSn/oil-summary`, primary + byModel)
|
||||||
|
- HNS: 분석 생성 시 `acdntSn` 연결 지원
|
||||||
|
- GSC: 사고 목록 응답에 `acdntSn` 노출 및 민감자원 누적 카테고리 관리 + HNS 확산 레이어 유틸 추가
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 탭 디렉토리를 MPA 컴포넌트 구조로 재편 (src/tabs → src/components, src/interfaces, src/types)
|
||||||
|
- TimelineControl 분리 및 aerial/hns 컴포넌트 개선
|
||||||
|
|
||||||
|
## [2026-04-15]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 확산예측·HNS 대기확산·긴급구난: GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (사고명·발생시각·위경도 자동 입력 + 지도 이동)
|
||||||
|
- 실시간 선박 신호 지도 표출: 한국 해역 1분 주기 폴링 스케줄러, 호버 툴팁·클릭 팝업·상세 모달 제공 (확산예측·HNS·긴급구난·사건사고 탭 연동)
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용
|
||||||
|
- aerial 이미지 분석 API 기본 URL 변경
|
||||||
|
|
||||||
|
## [2026-04-14]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 디자인 시스템: HNS·사건사고·확산예측·SCAT·기상 탭 디자인 시스템 토큰 전면 적용
|
||||||
|
- 관리자: 비식별화조치 메뉴 및 패널 추가
|
||||||
|
- 긴급구난/예측도 OSM 지도 적용 및 관리자 패널 추가
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 디자인 시스템: 폰트 업스케일 토큰 값 변경 및 전체 탭 색상·폰트 통일
|
||||||
|
|
||||||
|
## [2026-04-13]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 사고별 이미지 분석 데이터 조회 API 추가
|
||||||
|
- 사고 리스트에 항공 미디어 연동 및 이미지 분석 뱃지 표시
|
||||||
|
- 사고 마커 클릭 팝업 디자인 리뉴얼
|
||||||
|
- 지도에 필터링된 사고만 표시되도록 개선
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 이미지 분석 시 사고명 파라미터 지원
|
||||||
|
- 기본 예측시간 48시간 → 6시간으로 변경
|
||||||
|
- 유출량(SPIL_QTY) 정밀도 NUMERIC(14,10)으로 확대
|
||||||
|
- OpenDrift 유종 매핑 수정 (원유, 등유)
|
||||||
|
- 소량 유출량 과학적 표기법으로 표시
|
||||||
|
|
||||||
## [2026-04-09]
|
## [2026-04-09]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
311
frontend/package-lock.json
generated
311
frontend/package-lock.json
generated
@ -1945,9 +1945,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
|
||||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -1959,9 +1959,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
|
||||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1973,9 +1973,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
|
||||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1987,9 +1987,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
|
||||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2001,9 +2001,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
|
||||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -2015,9 +2015,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
|
||||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2029,9 +2029,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
|
||||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -2043,9 +2043,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
|
||||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -2057,9 +2057,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -2071,9 +2071,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -2085,9 +2085,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@ -2099,9 +2099,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@ -2113,9 +2113,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@ -2127,9 +2127,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@ -2141,9 +2141,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@ -2155,9 +2155,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@ -2169,9 +2169,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@ -2183,9 +2183,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2197,9 +2197,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2211,9 +2211,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
|
||||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2225,9 +2225,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
|
||||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -2239,9 +2239,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
|
||||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -2253,9 +2253,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
|
||||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@ -2267,9 +2267,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2281,9 +2281,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
|
||||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2711,9 +2711,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -2721,13 +2721,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^2.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
@ -2873,9 +2873,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -2927,9 +2927,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/anymatch/node_modules/picomatch": {
|
"node_modules/anymatch/node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -3015,14 +3015,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.5",
|
"version": "1.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
||||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.11",
|
"follow-redirects": "^1.15.11",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
@ -3077,9 +3077,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -3946,9 +3946,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-parser": {
|
"node_modules/fast-xml-parser": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz",
|
||||||
"integrity": "sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==",
|
"integrity": "sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@ -4055,16 +4055,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.3.3",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.15.11",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@ -4904,9 +4904,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/micromatch/node_modules/picomatch": {
|
"node_modules/micromatch/node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -4938,9 +4938,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -5169,9 +5169,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -5409,10 +5409,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
@ -5569,9 +5572,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readdirp/node_modules/picomatch": {
|
"node_modules/readdirp/node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -5633,9 +5636,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -5649,31 +5652,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
"@rollup/rollup-android-arm-eabi": "4.60.1",
|
||||||
"@rollup/rollup-android-arm64": "4.57.1",
|
"@rollup/rollup-android-arm64": "4.60.1",
|
||||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
"@rollup/rollup-darwin-arm64": "4.60.1",
|
||||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
"@rollup/rollup-darwin-x64": "4.60.1",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
"@rollup/rollup-freebsd-arm64": "4.60.1",
|
||||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
"@rollup/rollup-freebsd-x64": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
"@rollup/rollup-linux-arm64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
"@rollup/rollup-linux-loong64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
"@rollup/rollup-linux-x64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
"@rollup/rollup-linux-x64-musl": "4.60.1",
|
||||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
"@rollup/rollup-openbsd-x64": "4.60.1",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
"@rollup/rollup-openharmony-arm64": "4.60.1",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
"@rollup/rollup-win32-x64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
"@rollup/rollup-win32-x64-msvc": "4.60.1",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -5801,9 +5804,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socket.io-parser": {
|
"node_modules/socket.io-parser": {
|
||||||
"version": "4.2.5",
|
"version": "4.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||||
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
|
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
@ -6285,9 +6288,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -1,25 +1,25 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import { GoogleOAuthProvider } from '@react-oauth/google';
|
import { GoogleOAuthProvider } from '@react-oauth/google';
|
||||||
import type { MainTab } from '@common/types/navigation';
|
import type { MainTab } from '@/types/navigation';
|
||||||
import { MainLayout } from '@common/components/layout/MainLayout';
|
import { MainLayout } from '@components/common/layout/MainLayout';
|
||||||
import { LoginPage } from '@common/components/auth/LoginPage';
|
import { LoginPage } from '@components/common/auth/LoginPage';
|
||||||
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu';
|
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu';
|
||||||
import { useAuthStore } from '@common/store/authStore';
|
import { useAuthStore } from '@common/store/authStore';
|
||||||
import { useMenuStore } from '@common/store/menuStore';
|
import { useMenuStore } from '@common/store/menuStore';
|
||||||
import { useMapStore } from '@common/store/mapStore';
|
import { useMapStore } from '@common/store/mapStore';
|
||||||
import { API_BASE_URL } from '@common/services/api';
|
import { API_BASE_URL } from '@common/services/api';
|
||||||
import { OilSpillView } from '@tabs/prediction';
|
import { OilSpillView } from '@components/prediction';
|
||||||
import { ReportsView } from '@tabs/reports';
|
import { ReportsView } from '@components/reports';
|
||||||
import { HNSView } from '@tabs/hns';
|
import { HNSView } from '@components/hns';
|
||||||
import { AerialView } from '@tabs/aerial';
|
import { AerialView } from '@components/aerial';
|
||||||
import { AssetsView } from '@tabs/assets';
|
import { AssetsView } from '@components/assets';
|
||||||
import { BoardView } from '@tabs/board';
|
import { BoardView } from '@components/board';
|
||||||
import { WeatherView } from '@tabs/weather';
|
import { WeatherView } from '@components/weather';
|
||||||
import { IncidentsView } from '@tabs/incidents';
|
import { IncidentsView } from '@components/incidents';
|
||||||
import { AdminView } from '@tabs/admin';
|
import { AdminView } from '@components/admin';
|
||||||
import { ScatView } from '@tabs/scat';
|
import { ScatView } from '@components/scat';
|
||||||
import { RescueView } from '@tabs/rescue';
|
import { RescueView } from '@components/rescue';
|
||||||
import { DesignPage } from '@/pages/design/DesignPage';
|
import { DesignPage } from '@/pages/design/DesignPage';
|
||||||
|
|
||||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || '';
|
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || '';
|
||||||
|
|||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
1187
frontend/src/common/data/chapters.json
Normal file
1187
frontend/src/common/data/chapters.json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
30
frontend/src/common/data/manualChapters.ts
Normal file
30
frontend/src/common/data/manualChapters.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import chaptersJson from './chapters.json';
|
||||||
|
|
||||||
|
export interface InputItem {
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
required: boolean;
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScreenItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
menuPath: string;
|
||||||
|
imageIndex: number;
|
||||||
|
overview: string;
|
||||||
|
description?: string;
|
||||||
|
procedure?: string[];
|
||||||
|
inputs?: InputItem[];
|
||||||
|
notes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Chapter {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
screens: ScreenItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHAPTERS = chaptersJson as Chapter[];
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import type { StyleSpecification } from 'maplibre-gl';
|
import type { StyleSpecification } from 'maplibre-gl';
|
||||||
import { useMapStore } from '@common/store/mapStore';
|
import { useMapStore } from '@common/store/mapStore';
|
||||||
import { LIGHT_STYLE, SATELLITE_3D_STYLE, ENC_EMPTY_STYLE } from '@common/components/map/mapStyles';
|
import { LIGHT_STYLE, SATELLITE_3D_STYLE, ENC_EMPTY_STYLE } from '@components/common/map/mapStyles';
|
||||||
|
|
||||||
export function useBaseMapStyle(): StyleSpecification {
|
export function useBaseMapStyle(): StyleSpecification {
|
||||||
const mapToggles = useMapStore((s) => s.mapToggles);
|
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useSyncExternalStore } from 'react';
|
import { useEffect, useSyncExternalStore } from 'react';
|
||||||
import type { MainTab } from '../types/navigation';
|
import type { MainTab } from '@/types/navigation';
|
||||||
import { useAuthStore } from '@common/store/authStore';
|
import { useAuthStore } from '@common/store/authStore';
|
||||||
import { API_BASE_URL } from '@common/services/api';
|
import { API_BASE_URL } from '@common/services/api';
|
||||||
|
|
||||||
@ -61,6 +61,7 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
|
|||||||
{ id: 'manual', label: '해경매뉴얼', icon: '📘' },
|
{ id: 'manual', label: '해경매뉴얼', icon: '📘' },
|
||||||
],
|
],
|
||||||
weather: null,
|
weather: null,
|
||||||
|
monitor: null,
|
||||||
admin: null, // 관리자 화면은 자체 사이드바 사용 (AdminSidebar.tsx)
|
admin: null, // 관리자 화면은 자체 사이드바 사용 (AdminSidebar.tsx)
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -76,6 +77,7 @@ const subMenuState: Record<MainTab, string> = {
|
|||||||
incidents: '',
|
incidents: '',
|
||||||
board: 'all',
|
board: 'all',
|
||||||
weather: '',
|
weather: '',
|
||||||
|
monitor: '',
|
||||||
admin: 'users',
|
admin: 'users',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
79
frontend/src/common/hooks/useVesselSignals.ts
Normal file
79
frontend/src/common/hooks/useVesselSignals.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
createVesselSignalClient,
|
||||||
|
type VesselSignalClient,
|
||||||
|
} from '@common/services/vesselSignalClient';
|
||||||
|
import {
|
||||||
|
getInitialVesselSnapshot,
|
||||||
|
isVesselInitEnabled,
|
||||||
|
} from '@common/services/vesselApi';
|
||||||
|
import type { VesselPosition, MapBounds } from '@/types/vessel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선박 신호 실시간 수신 훅
|
||||||
|
*
|
||||||
|
* 개발환경(VITE_VESSEL_SIGNAL_MODE=polling):
|
||||||
|
* - 60초마다 백엔드 REST API(/api/vessels/in-area)를 현재 뷰포트 bbox로 호출
|
||||||
|
*
|
||||||
|
* 운영환경(VITE_VESSEL_SIGNAL_MODE=websocket):
|
||||||
|
* - 운영 WebSocket 서버(VITE_VESSEL_WS_URL)에 직접 연결하여 실시간 수신
|
||||||
|
* - 수신된 전체 데이터를 현재 뷰포트 bbox로 프론트에서 필터링
|
||||||
|
*
|
||||||
|
* @param mapBounds MapView의 onBoundsChange로 전달받은 현재 뷰포트 bbox
|
||||||
|
* @returns 현재 뷰포트 내 선박 목록
|
||||||
|
*/
|
||||||
|
export function useVesselSignals(mapBounds: MapBounds | null): VesselPosition[] {
|
||||||
|
const [vessels, setVessels] = useState<VesselPosition[]>([]);
|
||||||
|
const boundsRef = useRef<MapBounds | null>(mapBounds);
|
||||||
|
const clientRef = useRef<VesselSignalClient | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
boundsRef.current = mapBounds;
|
||||||
|
}, [mapBounds]);
|
||||||
|
|
||||||
|
const getViewportBounds = useCallback(() => boundsRef.current, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const client = createVesselSignalClient();
|
||||||
|
clientRef.current = client;
|
||||||
|
|
||||||
|
// 운영 환경: 로그인/새로고침 직후 최근 10분치 스냅샷을 먼저 1회 로드.
|
||||||
|
// 이후 WebSocket 수신이 시작되면 최신 신호로 갱신된다.
|
||||||
|
// VITE_VESSEL_INIT_ENABLED=true 일 때만 활성화(기본 비활성).
|
||||||
|
if (isVesselInitEnabled()) {
|
||||||
|
getInitialVesselSnapshot()
|
||||||
|
.then((initial) => {
|
||||||
|
const bounds = boundsRef.current;
|
||||||
|
const filtered = bounds
|
||||||
|
? initial.filter(
|
||||||
|
(v) =>
|
||||||
|
v.lon >= bounds.minLon &&
|
||||||
|
v.lon <= bounds.maxLon &&
|
||||||
|
v.lat >= bounds.minLat &&
|
||||||
|
v.lat <= bounds.maxLat,
|
||||||
|
)
|
||||||
|
: initial;
|
||||||
|
// WS 첫 메시지가 먼저 도착해 이미 채워졌다면 덮어쓰지 않음
|
||||||
|
setVessels((prev) => (prev.length === 0 ? filtered : prev));
|
||||||
|
})
|
||||||
|
.catch((e) => console.warn('[useVesselSignals] 초기 스냅샷 실패', e));
|
||||||
|
}
|
||||||
|
|
||||||
|
client.start(setVessels, getViewportBounds);
|
||||||
|
return () => {
|
||||||
|
client.stop();
|
||||||
|
clientRef.current = null;
|
||||||
|
};
|
||||||
|
}, [getViewportBounds]);
|
||||||
|
|
||||||
|
// mapBounds가 바뀔 때마다(최초 채워질 때 + 이후 뷰포트 이동/줌마다) 즉시 1회 새로고침.
|
||||||
|
// MapView의 onBoundsChange는 moveend/zoomend에서만 호출되므로 드래그 중 스팸은 없다.
|
||||||
|
// 이후에도 60초 인터벌 폴링은 백그라운드에서 계속 동작.
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapBounds && clientRef.current) {
|
||||||
|
clientRef.current.refresh();
|
||||||
|
}
|
||||||
|
}, [mapBounds]);
|
||||||
|
|
||||||
|
return vessels;
|
||||||
|
}
|
||||||
@ -1,613 +1,4 @@
|
|||||||
export interface Vessel {
|
// Deprecated: Mock 선박 데이터는 제거되었습니다.
|
||||||
mmsi: number;
|
// 실제 선박 신호는 @common/hooks/useVesselSignals + @components/common/map/VesselLayer 를 사용합니다.
|
||||||
imo: string;
|
// 범례는 @components/common/map/VesselLayer 의 VESSEL_LEGEND 를 import 하세요.
|
||||||
name: string;
|
export {};
|
||||||
typS: string;
|
|
||||||
flag: string;
|
|
||||||
status: string;
|
|
||||||
speed: number;
|
|
||||||
heading: number;
|
|
||||||
lat: number;
|
|
||||||
lng: number;
|
|
||||||
draft: number;
|
|
||||||
depart: string;
|
|
||||||
arrive: string;
|
|
||||||
etd: string;
|
|
||||||
eta: string;
|
|
||||||
gt: string;
|
|
||||||
dwt: string;
|
|
||||||
loa: string;
|
|
||||||
beam: string;
|
|
||||||
built: string;
|
|
||||||
yard: string;
|
|
||||||
callSign: string;
|
|
||||||
cls: string;
|
|
||||||
cargo: string;
|
|
||||||
color: string;
|
|
||||||
markerType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VESSEL_TYPE_COLORS: Record<string, string> = {
|
|
||||||
Tanker: '#ef4444',
|
|
||||||
Chemical: '#ef4444',
|
|
||||||
Cargo: '#22c55e',
|
|
||||||
Bulk: '#22c55e',
|
|
||||||
Container: '#3b82f6',
|
|
||||||
Passenger: '#a855f7',
|
|
||||||
Fishing: '#f97316',
|
|
||||||
Tug: '#06b6d4',
|
|
||||||
Navy: '#6b7280',
|
|
||||||
Sailing: '#fbbf24',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VESSEL_LEGEND = [
|
|
||||||
{ type: 'Tanker', color: '#ef4444' },
|
|
||||||
{ type: 'Cargo', color: '#22c55e' },
|
|
||||||
{ type: 'Container', color: '#3b82f6' },
|
|
||||||
{ type: 'Fishing', color: '#f97316' },
|
|
||||||
{ type: 'Passenger', color: '#a855f7' },
|
|
||||||
{ type: 'Tug', color: '#06b6d4' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const mockVessels: Vessel[] = [
|
|
||||||
{
|
|
||||||
mmsi: 440123456,
|
|
||||||
imo: '9812345',
|
|
||||||
name: 'HANKUK CHEMI',
|
|
||||||
typS: 'Tanker',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 8.2,
|
|
||||||
heading: 330,
|
|
||||||
lat: 34.6,
|
|
||||||
lng: 127.5,
|
|
||||||
draft: 5.8,
|
|
||||||
depart: '여수항',
|
|
||||||
arrive: '부산항',
|
|
||||||
etd: '2026-02-25 08:00',
|
|
||||||
eta: '2026-02-25 18:30',
|
|
||||||
gt: '29,246',
|
|
||||||
dwt: '49,999',
|
|
||||||
loa: '183.0m',
|
|
||||||
beam: '32.2m',
|
|
||||||
built: '2018',
|
|
||||||
yard: '현대미포조선',
|
|
||||||
callSign: 'HLKC',
|
|
||||||
cls: '한국선급(KR)',
|
|
||||||
cargo: 'BUNKER-C · 1,200kL · IMO Class 3',
|
|
||||||
color: '#ef4444',
|
|
||||||
markerType: 'tanker',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440234567,
|
|
||||||
imo: '9823456',
|
|
||||||
name: 'DONG-A GLAUCOS',
|
|
||||||
typS: 'Cargo',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 11.4,
|
|
||||||
heading: 245,
|
|
||||||
lat: 34.78,
|
|
||||||
lng: 127.8,
|
|
||||||
draft: 7.2,
|
|
||||||
depart: '울산항',
|
|
||||||
arrive: '광양항',
|
|
||||||
etd: '2026-02-25 06:30',
|
|
||||||
eta: '2026-02-25 16:00',
|
|
||||||
gt: '12,450',
|
|
||||||
dwt: '18,800',
|
|
||||||
loa: '144.0m',
|
|
||||||
beam: '22.6m',
|
|
||||||
built: '2015',
|
|
||||||
yard: 'STX조선',
|
|
||||||
callSign: 'HLDG',
|
|
||||||
cls: '한국선급(KR)',
|
|
||||||
cargo: '철강재 · 4,500t',
|
|
||||||
color: '#22c55e',
|
|
||||||
markerType: 'cargo',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440345678,
|
|
||||||
imo: '9834567',
|
|
||||||
name: 'HMM ALGECIRAS',
|
|
||||||
typS: 'Container',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 18.5,
|
|
||||||
heading: 195,
|
|
||||||
lat: 35.0,
|
|
||||||
lng: 128.8,
|
|
||||||
draft: 14.5,
|
|
||||||
depart: '부산항',
|
|
||||||
arrive: '싱가포르',
|
|
||||||
etd: '2026-02-25 04:00',
|
|
||||||
eta: '2026-03-02 08:00',
|
|
||||||
gt: '228,283',
|
|
||||||
dwt: '223,092',
|
|
||||||
loa: '399.9m',
|
|
||||||
beam: '61.0m',
|
|
||||||
built: '2020',
|
|
||||||
yard: '대우조선해양',
|
|
||||||
callSign: 'HLHM',
|
|
||||||
cls: "Lloyd's Register",
|
|
||||||
cargo: '컨테이너 · 16,420 TEU',
|
|
||||||
color: '#3b82f6',
|
|
||||||
markerType: 'container',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 355678901,
|
|
||||||
imo: '9756789',
|
|
||||||
name: 'STELLAR DAISY',
|
|
||||||
typS: 'Tanker',
|
|
||||||
flag: '🇵🇦',
|
|
||||||
status: '⚠ 사고(좌초)',
|
|
||||||
speed: 0.0,
|
|
||||||
heading: 0,
|
|
||||||
lat: 34.72,
|
|
||||||
lng: 127.72,
|
|
||||||
draft: 8.1,
|
|
||||||
depart: '여수항',
|
|
||||||
arrive: '—',
|
|
||||||
etd: '2026-01-18 12:00',
|
|
||||||
eta: '—',
|
|
||||||
gt: '35,120',
|
|
||||||
dwt: '58,000',
|
|
||||||
loa: '190.0m',
|
|
||||||
beam: '34.0m',
|
|
||||||
built: '2012',
|
|
||||||
yard: 'CSBC Taiwan',
|
|
||||||
callSign: '3FZA7',
|
|
||||||
cls: 'NK',
|
|
||||||
cargo: 'BUNKER-C · 150kL 유출 · ⚠ 사고선박',
|
|
||||||
color: '#ef4444',
|
|
||||||
markerType: 'tanker',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440456789,
|
|
||||||
imo: '—',
|
|
||||||
name: '제72 금양호',
|
|
||||||
typS: 'Fishing',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '조업중',
|
|
||||||
speed: 4.1,
|
|
||||||
heading: 120,
|
|
||||||
lat: 34.55,
|
|
||||||
lng: 127.35,
|
|
||||||
draft: 2.1,
|
|
||||||
depart: '여수 국동항',
|
|
||||||
arrive: '여수 국동항',
|
|
||||||
etd: '2026-02-25 04:30',
|
|
||||||
eta: '2026-02-25 18:00',
|
|
||||||
gt: '78',
|
|
||||||
dwt: '—',
|
|
||||||
loa: '24.5m',
|
|
||||||
beam: '6.2m',
|
|
||||||
built: '2008',
|
|
||||||
yard: '통영조선',
|
|
||||||
callSign: '—',
|
|
||||||
cls: '한국선급',
|
|
||||||
cargo: '어획물',
|
|
||||||
color: '#f97316',
|
|
||||||
markerType: 'fishing',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440567890,
|
|
||||||
imo: '9867890',
|
|
||||||
name: 'PAN OCEAN GLORY',
|
|
||||||
typS: 'Bulk',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 12.8,
|
|
||||||
heading: 170,
|
|
||||||
lat: 35.6,
|
|
||||||
lng: 126.4,
|
|
||||||
draft: 10.3,
|
|
||||||
depart: '군산항',
|
|
||||||
arrive: '포항항',
|
|
||||||
etd: '2026-02-25 07:00',
|
|
||||||
eta: '2026-02-26 04:00',
|
|
||||||
gt: '43,800',
|
|
||||||
dwt: '76,500',
|
|
||||||
loa: '229.0m',
|
|
||||||
beam: '32.3m',
|
|
||||||
built: '2019',
|
|
||||||
yard: '현대삼호중공업',
|
|
||||||
callSign: 'HLPO',
|
|
||||||
cls: '한국선급(KR)',
|
|
||||||
cargo: '석탄 · 65,000t',
|
|
||||||
color: '#22c55e',
|
|
||||||
markerType: 'cargo',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440678901,
|
|
||||||
imo: '—',
|
|
||||||
name: '여수예인1호',
|
|
||||||
typS: 'Tug',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '방제지원',
|
|
||||||
speed: 6.3,
|
|
||||||
heading: 355,
|
|
||||||
lat: 34.68,
|
|
||||||
lng: 127.6,
|
|
||||||
draft: 3.2,
|
|
||||||
depart: '여수항',
|
|
||||||
arrive: '사고현장',
|
|
||||||
etd: '2026-01-18 16:30',
|
|
||||||
eta: '—',
|
|
||||||
gt: '280',
|
|
||||||
dwt: '—',
|
|
||||||
loa: '32.0m',
|
|
||||||
beam: '9.5m',
|
|
||||||
built: '2016',
|
|
||||||
yard: '삼성중공업',
|
|
||||||
callSign: 'HLYT',
|
|
||||||
cls: '한국선급',
|
|
||||||
cargo: '방제장비 · 오일붐 500m',
|
|
||||||
color: '#06b6d4',
|
|
||||||
markerType: 'tug',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 235012345,
|
|
||||||
imo: '9456789',
|
|
||||||
name: 'QUEEN MARY',
|
|
||||||
typS: 'Passenger',
|
|
||||||
flag: '🇬🇧',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 15.2,
|
|
||||||
heading: 10,
|
|
||||||
lat: 33.8,
|
|
||||||
lng: 127.0,
|
|
||||||
draft: 8.5,
|
|
||||||
depart: '상하이',
|
|
||||||
arrive: '부산항',
|
|
||||||
etd: '2026-02-24 18:00',
|
|
||||||
eta: '2026-02-26 06:00',
|
|
||||||
gt: '148,528',
|
|
||||||
dwt: '18,000',
|
|
||||||
loa: '345.0m',
|
|
||||||
beam: '41.0m',
|
|
||||||
built: '2004',
|
|
||||||
yard: "Chantiers de l'Atlantique",
|
|
||||||
callSign: 'GBQM2',
|
|
||||||
cls: "Lloyd's Register",
|
|
||||||
cargo: '승객 2,620명',
|
|
||||||
color: '#a855f7',
|
|
||||||
markerType: 'passenger',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 353012345,
|
|
||||||
imo: '9811000',
|
|
||||||
name: 'EVER GIVEN',
|
|
||||||
typS: 'Container',
|
|
||||||
flag: '🇹🇼',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 14.7,
|
|
||||||
heading: 220,
|
|
||||||
lat: 35.2,
|
|
||||||
lng: 129.2,
|
|
||||||
draft: 15.7,
|
|
||||||
depart: '부산항',
|
|
||||||
arrive: '카오슝',
|
|
||||||
etd: '2026-02-25 02:00',
|
|
||||||
eta: '2026-02-28 14:00',
|
|
||||||
gt: '220,940',
|
|
||||||
dwt: '199,629',
|
|
||||||
loa: '400.0m',
|
|
||||||
beam: '59.0m',
|
|
||||||
built: '2018',
|
|
||||||
yard: '今治造船',
|
|
||||||
callSign: 'BIXE9',
|
|
||||||
cls: 'ABS',
|
|
||||||
cargo: '컨테이너 · 14,800 TEU',
|
|
||||||
color: '#3b82f6',
|
|
||||||
markerType: 'container',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440789012,
|
|
||||||
imo: '—',
|
|
||||||
name: '제85 대성호',
|
|
||||||
typS: 'Fishing',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '조업중',
|
|
||||||
speed: 3.8,
|
|
||||||
heading: 85,
|
|
||||||
lat: 34.4,
|
|
||||||
lng: 126.3,
|
|
||||||
draft: 1.8,
|
|
||||||
depart: '목포항',
|
|
||||||
arrive: '목포항',
|
|
||||||
etd: '2026-02-25 03:00',
|
|
||||||
eta: '2026-02-25 17:00',
|
|
||||||
gt: '65',
|
|
||||||
dwt: '—',
|
|
||||||
loa: '22.0m',
|
|
||||||
beam: '5.8m',
|
|
||||||
built: '2010',
|
|
||||||
yard: '목포조선',
|
|
||||||
callSign: '—',
|
|
||||||
cls: '한국선급',
|
|
||||||
cargo: '어획물',
|
|
||||||
color: '#f97316',
|
|
||||||
markerType: 'fishing',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440890123,
|
|
||||||
imo: '9878901',
|
|
||||||
name: 'SK INNOVATION',
|
|
||||||
typS: 'Chemical',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 9.6,
|
|
||||||
heading: 340,
|
|
||||||
lat: 35.8,
|
|
||||||
lng: 126.6,
|
|
||||||
draft: 6.5,
|
|
||||||
depart: '대산항',
|
|
||||||
arrive: '여수항',
|
|
||||||
etd: '2026-02-25 10:00',
|
|
||||||
eta: '2026-02-26 02:00',
|
|
||||||
gt: '11,200',
|
|
||||||
dwt: '16,800',
|
|
||||||
loa: '132.0m',
|
|
||||||
beam: '20.4m',
|
|
||||||
built: '2020',
|
|
||||||
yard: '현대미포조선',
|
|
||||||
callSign: 'HLSK',
|
|
||||||
cls: '한국선급(KR)',
|
|
||||||
cargo: '톨루엔 · 8,500kL · IMO Class 3',
|
|
||||||
color: '#ef4444',
|
|
||||||
markerType: 'tanker',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440901234,
|
|
||||||
imo: '9889012',
|
|
||||||
name: 'KOREA EXPRESS',
|
|
||||||
typS: 'Cargo',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 10.1,
|
|
||||||
heading: 190,
|
|
||||||
lat: 36.2,
|
|
||||||
lng: 128.5,
|
|
||||||
draft: 6.8,
|
|
||||||
depart: '동해항',
|
|
||||||
arrive: '포항항',
|
|
||||||
etd: '2026-02-25 09:00',
|
|
||||||
eta: '2026-02-25 15:00',
|
|
||||||
gt: '8,500',
|
|
||||||
dwt: '12,000',
|
|
||||||
loa: '118.0m',
|
|
||||||
beam: '18.2m',
|
|
||||||
built: '2014',
|
|
||||||
yard: '대한조선',
|
|
||||||
callSign: 'HLKE',
|
|
||||||
cls: '한국선급',
|
|
||||||
cargo: '일반화물',
|
|
||||||
color: '#22c55e',
|
|
||||||
markerType: 'cargo',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440012345,
|
|
||||||
imo: '—',
|
|
||||||
name: 'ROKS SEJONG',
|
|
||||||
typS: 'Navy',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '작전중',
|
|
||||||
speed: 16.0,
|
|
||||||
heading: 270,
|
|
||||||
lat: 35.3,
|
|
||||||
lng: 129.5,
|
|
||||||
draft: 6.3,
|
|
||||||
depart: '부산 해군기지',
|
|
||||||
arrive: '—',
|
|
||||||
etd: '—',
|
|
||||||
eta: '—',
|
|
||||||
gt: '7,600',
|
|
||||||
dwt: '—',
|
|
||||||
loa: '165.9m',
|
|
||||||
beam: '21.4m',
|
|
||||||
built: '2008',
|
|
||||||
yard: '현대중공업',
|
|
||||||
callSign: 'HLNS',
|
|
||||||
cls: '군용',
|
|
||||||
cargo: '군사작전',
|
|
||||||
color: '#6b7280',
|
|
||||||
markerType: 'military',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440023456,
|
|
||||||
imo: '—',
|
|
||||||
name: '군산예인3호',
|
|
||||||
typS: 'Tug',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '대기중',
|
|
||||||
speed: 5.5,
|
|
||||||
heading: 140,
|
|
||||||
lat: 35.9,
|
|
||||||
lng: 126.9,
|
|
||||||
draft: 2.8,
|
|
||||||
depart: '군산항',
|
|
||||||
arrive: '군산항',
|
|
||||||
etd: '—',
|
|
||||||
eta: '—',
|
|
||||||
gt: '180',
|
|
||||||
dwt: '—',
|
|
||||||
loa: '28.0m',
|
|
||||||
beam: '8.2m',
|
|
||||||
built: '2019',
|
|
||||||
yard: '통영조선',
|
|
||||||
callSign: 'HLGS',
|
|
||||||
cls: '한국선급',
|
|
||||||
cargo: '—',
|
|
||||||
color: '#06b6d4',
|
|
||||||
markerType: 'tug',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440034567,
|
|
||||||
imo: '—',
|
|
||||||
name: 'JEJU WIND',
|
|
||||||
typS: 'Sailing',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 6.8,
|
|
||||||
heading: 290,
|
|
||||||
lat: 33.35,
|
|
||||||
lng: 126.65,
|
|
||||||
draft: 2.5,
|
|
||||||
depart: '제주항',
|
|
||||||
arrive: '제주항',
|
|
||||||
etd: '2026-02-25 10:00',
|
|
||||||
eta: '2026-02-25 16:00',
|
|
||||||
gt: '45',
|
|
||||||
dwt: '—',
|
|
||||||
loa: '18.0m',
|
|
||||||
beam: '5.0m',
|
|
||||||
built: '2022',
|
|
||||||
yard: '제주요트',
|
|
||||||
callSign: '—',
|
|
||||||
cls: '—',
|
|
||||||
cargo: '—',
|
|
||||||
color: '#fbbf24',
|
|
||||||
markerType: 'sail',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440045678,
|
|
||||||
imo: '—',
|
|
||||||
name: '제33 삼양호',
|
|
||||||
typS: 'Fishing',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '조업중',
|
|
||||||
speed: 2.4,
|
|
||||||
heading: 55,
|
|
||||||
lat: 35.1,
|
|
||||||
lng: 127.4,
|
|
||||||
draft: 1.6,
|
|
||||||
depart: '통영항',
|
|
||||||
arrive: '통영항',
|
|
||||||
etd: '2026-02-25 05:00',
|
|
||||||
eta: '2026-02-25 19:00',
|
|
||||||
gt: '52',
|
|
||||||
dwt: '—',
|
|
||||||
loa: '20.0m',
|
|
||||||
beam: '5.4m',
|
|
||||||
built: '2006',
|
|
||||||
yard: '거제조선',
|
|
||||||
callSign: '—',
|
|
||||||
cls: '한국선급',
|
|
||||||
cargo: '어획물',
|
|
||||||
color: '#f97316',
|
|
||||||
markerType: 'fishing',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 255012345,
|
|
||||||
imo: '9703291',
|
|
||||||
name: 'MSC OSCAR',
|
|
||||||
typS: 'Container',
|
|
||||||
flag: '🇨🇭',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 17.3,
|
|
||||||
heading: 355,
|
|
||||||
lat: 34.1,
|
|
||||||
lng: 128.1,
|
|
||||||
draft: 14.0,
|
|
||||||
depart: '카오슝',
|
|
||||||
arrive: '부산항',
|
|
||||||
etd: '2026-02-23 08:00',
|
|
||||||
eta: '2026-02-25 22:00',
|
|
||||||
gt: '197,362',
|
|
||||||
dwt: '199,272',
|
|
||||||
loa: '395.4m',
|
|
||||||
beam: '59.0m',
|
|
||||||
built: '2015',
|
|
||||||
yard: '대우조선해양',
|
|
||||||
callSign: '9HA4713',
|
|
||||||
cls: 'DNV',
|
|
||||||
cargo: '컨테이너 · 18,200 TEU',
|
|
||||||
color: '#3b82f6',
|
|
||||||
markerType: 'container',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440056789,
|
|
||||||
imo: '9890567',
|
|
||||||
name: 'SAEHAN PIONEER',
|
|
||||||
typS: 'Tanker',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 7.9,
|
|
||||||
heading: 310,
|
|
||||||
lat: 34.9,
|
|
||||||
lng: 127.1,
|
|
||||||
draft: 5.2,
|
|
||||||
depart: '여수항',
|
|
||||||
arrive: '대산항',
|
|
||||||
etd: '2026-02-25 11:00',
|
|
||||||
eta: '2026-02-26 08:00',
|
|
||||||
gt: '8,900',
|
|
||||||
dwt: '14,200',
|
|
||||||
loa: '120.0m',
|
|
||||||
beam: '18.0m',
|
|
||||||
built: '2017',
|
|
||||||
yard: '현대미포조선',
|
|
||||||
callSign: 'HLSP',
|
|
||||||
cls: '한국선급(KR)',
|
|
||||||
cargo: '경유 · 10,000kL',
|
|
||||||
color: '#ef4444',
|
|
||||||
markerType: 'tanker',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440067890,
|
|
||||||
imo: '9891678',
|
|
||||||
name: 'DONGHAE STAR',
|
|
||||||
typS: 'Cargo',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 11.0,
|
|
||||||
heading: 155,
|
|
||||||
lat: 37.55,
|
|
||||||
lng: 129.3,
|
|
||||||
draft: 6.0,
|
|
||||||
depart: '속초항',
|
|
||||||
arrive: '동해항',
|
|
||||||
etd: '2026-02-25 12:00',
|
|
||||||
eta: '2026-02-25 16:30',
|
|
||||||
gt: '6,200',
|
|
||||||
dwt: '8,500',
|
|
||||||
loa: '105.0m',
|
|
||||||
beam: '16.5m',
|
|
||||||
built: '2013',
|
|
||||||
yard: '대한조선',
|
|
||||||
callSign: 'HLDS',
|
|
||||||
cls: '한국선급',
|
|
||||||
cargo: '일반화물 · 목재',
|
|
||||||
color: '#22c55e',
|
|
||||||
markerType: 'cargo',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440078901,
|
|
||||||
imo: '—',
|
|
||||||
name: '제18 한라호',
|
|
||||||
typS: 'Fishing',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '귀항중',
|
|
||||||
speed: 3.2,
|
|
||||||
heading: 70,
|
|
||||||
lat: 33.3,
|
|
||||||
lng: 126.3,
|
|
||||||
draft: 1.9,
|
|
||||||
depart: '서귀포항',
|
|
||||||
arrive: '서귀포항',
|
|
||||||
etd: '2026-02-25 04:00',
|
|
||||||
eta: '2026-02-25 15:00',
|
|
||||||
gt: '58',
|
|
||||||
dwt: '—',
|
|
||||||
loa: '21.0m',
|
|
||||||
beam: '5.6m',
|
|
||||||
built: '2011',
|
|
||||||
yard: '제주조선',
|
|
||||||
callSign: '—',
|
|
||||||
cls: '한국선급',
|
|
||||||
cargo: '어획물 · 갈치/고등어',
|
|
||||||
color: '#f97316',
|
|
||||||
markerType: 'fishing',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|||||||
35
frontend/src/common/services/vesselApi.ts
Normal file
35
frontend/src/common/services/vesselApi.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
import type { VesselPosition, MapBounds } from '@/types/vessel';
|
||||||
|
|
||||||
|
export async function getVesselsInArea(bounds: MapBounds): Promise<VesselPosition[]> {
|
||||||
|
const res = await api.post<VesselPosition[]>('/vessels/in-area', { bounds });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인/새로고침 직후 1회 호출하는 초기 스냅샷 API.
|
||||||
|
* 운영 환경의 별도 REST 서버가 현재 시각 기준 최근 10분치 선박 신호를 반환한다.
|
||||||
|
* URL은 VITE_VESSEL_INIT_API_URL 로 주입(운영에서 실제 URL로 교체).
|
||||||
|
*/
|
||||||
|
export async function getInitialVesselSnapshot(): Promise<VesselPosition[]> {
|
||||||
|
const url = import.meta.env.VITE_VESSEL_INIT_API_URL as string | undefined;
|
||||||
|
if (!url) return [];
|
||||||
|
const res = await fetch(url, { method: 'GET' });
|
||||||
|
if (!res.ok) throw new Error(`vessel init snapshot ${res.status}`);
|
||||||
|
return (await res.json()) as VesselPosition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVesselInitEnabled(): boolean {
|
||||||
|
return import.meta.env.VITE_VESSEL_INIT_ENABLED === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VesselCacheStatus {
|
||||||
|
count: number;
|
||||||
|
bangjeCount: number;
|
||||||
|
lastUpdated: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVesselCacheStatus(): Promise<VesselCacheStatus> {
|
||||||
|
const res = await api.get<VesselCacheStatus>('/vessels/status');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
125
frontend/src/common/services/vesselSignalClient.ts
Normal file
125
frontend/src/common/services/vesselSignalClient.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import type { VesselPosition, MapBounds } from '@/types/vessel';
|
||||||
|
import { getVesselsInArea } from './vesselApi';
|
||||||
|
|
||||||
|
export interface VesselSignalClient {
|
||||||
|
start(
|
||||||
|
onVessels: (vessels: VesselPosition[]) => void,
|
||||||
|
getViewportBounds: () => MapBounds | null,
|
||||||
|
): void;
|
||||||
|
stop(): void;
|
||||||
|
/**
|
||||||
|
* 즉시 1회 새로고침. 폴링 모드에선 현재 bbox로 REST 호출,
|
||||||
|
* WebSocket 모드에선 no-op(서버 push에 의존).
|
||||||
|
*/
|
||||||
|
refresh(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 개발환경: setInterval(60s) → 백엔드 REST API 호출
|
||||||
|
class PollingVesselClient implements VesselSignalClient {
|
||||||
|
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private onVessels: ((vessels: VesselPosition[]) => void) | null = null;
|
||||||
|
private getViewportBounds: (() => MapBounds | null) | null = null;
|
||||||
|
|
||||||
|
private async poll(): Promise<void> {
|
||||||
|
const bounds = this.getViewportBounds?.();
|
||||||
|
if (!bounds || !this.onVessels) return;
|
||||||
|
try {
|
||||||
|
const vessels = await getVesselsInArea(bounds);
|
||||||
|
this.onVessels(vessels);
|
||||||
|
} catch {
|
||||||
|
// 폴링 실패 시 무시 (다음 인터벌에 재시도)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start(
|
||||||
|
onVessels: (vessels: VesselPosition[]) => void,
|
||||||
|
getViewportBounds: () => MapBounds | null,
|
||||||
|
): void {
|
||||||
|
this.onVessels = onVessels;
|
||||||
|
this.getViewportBounds = getViewportBounds;
|
||||||
|
|
||||||
|
// 즉시 1회 실행 후 60초 간격으로 반복
|
||||||
|
this.poll();
|
||||||
|
this.intervalId = setInterval(() => this.poll(), 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.intervalId !== null) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
this.onVessels = null;
|
||||||
|
this.getViewportBounds = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(): void {
|
||||||
|
this.poll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 운영환경: 실시간 WebSocket 서버에 직접 연결
|
||||||
|
class DirectWebSocketVesselClient implements VesselSignalClient {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private readonly wsUrl: string;
|
||||||
|
|
||||||
|
constructor(wsUrl: string) {
|
||||||
|
this.wsUrl = wsUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(
|
||||||
|
onVessels: (vessels: VesselPosition[]) => void,
|
||||||
|
getViewportBounds: () => MapBounds | null,
|
||||||
|
): void {
|
||||||
|
this.ws = new WebSocket(this.wsUrl);
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const allVessels = JSON.parse(event.data as string) as VesselPosition[];
|
||||||
|
const bounds = getViewportBounds();
|
||||||
|
|
||||||
|
if (!bounds) {
|
||||||
|
onVessels(allVessels);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = allVessels.filter(
|
||||||
|
(v) =>
|
||||||
|
v.lon >= bounds.minLon &&
|
||||||
|
v.lon <= bounds.maxLon &&
|
||||||
|
v.lat >= bounds.minLat &&
|
||||||
|
v.lat <= bounds.maxLat,
|
||||||
|
);
|
||||||
|
onVessels(filtered);
|
||||||
|
} catch {
|
||||||
|
// 파싱 실패 무시
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
console.error('[vesselSignalClient] WebSocket 연결 오류');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
console.warn('[vesselSignalClient] WebSocket 연결 종료');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(): void {
|
||||||
|
// 운영 WS 모드에선 서버 push에 의존하므로 별도 새로고침 동작 없음
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVesselSignalClient(): VesselSignalClient {
|
||||||
|
if (import.meta.env.VITE_VESSEL_SIGNAL_MODE === 'websocket') {
|
||||||
|
const wsUrl = import.meta.env.VITE_VESSEL_WS_URL as string;
|
||||||
|
return new DirectWebSocketVesselClient(wsUrl);
|
||||||
|
}
|
||||||
|
return new PollingVesselClient();
|
||||||
|
}
|
||||||
@ -1,44 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import type { WeatherSnapshot } from '@interfaces/weather/WeatherInterface';
|
||||||
|
|
||||||
export interface WeatherSnapshot {
|
export type { WeatherSnapshot };
|
||||||
stationName: string;
|
|
||||||
capturedAt: string;
|
|
||||||
wind: {
|
|
||||||
speed: number;
|
|
||||||
direction: number;
|
|
||||||
directionLabel: string;
|
|
||||||
speed_1k: number;
|
|
||||||
speed_3k: number;
|
|
||||||
};
|
|
||||||
wave: {
|
|
||||||
height: number;
|
|
||||||
maxHeight: number;
|
|
||||||
period: number;
|
|
||||||
direction: string;
|
|
||||||
};
|
|
||||||
temperature: {
|
|
||||||
current: number;
|
|
||||||
feelsLike: number;
|
|
||||||
};
|
|
||||||
pressure: number;
|
|
||||||
visibility: number;
|
|
||||||
salinity: number;
|
|
||||||
astronomy?: {
|
|
||||||
sunrise: string;
|
|
||||||
sunset: string;
|
|
||||||
moonrise: string;
|
|
||||||
moonset: string;
|
|
||||||
moonPhase: string;
|
|
||||||
tidalRange: number;
|
|
||||||
};
|
|
||||||
alert?: string;
|
|
||||||
forecast?: Array<{
|
|
||||||
time: string;
|
|
||||||
icon: string;
|
|
||||||
temperature: number;
|
|
||||||
windSpeed: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WeatherSnapshotStore {
|
interface WeatherSnapshotStore {
|
||||||
snapshot: WeatherSnapshot | null;
|
snapshot: WeatherSnapshot | null;
|
||||||
|
|||||||
@ -31,25 +31,25 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
/* bg — Background */
|
/* bg — Background */
|
||||||
--bg-base: #0a0e1a;
|
--bg-base: #121418;
|
||||||
--bg-surface: #0f1524;
|
--bg-surface: #1B1E23;
|
||||||
--bg-elevated: #121929;
|
--bg-elevated: #24272D;
|
||||||
--bg-card: #1a2236;
|
--bg-card: #24272D;
|
||||||
--bg-surface-hover: #1e2844;
|
--bg-surface-hover: #3A3F49;
|
||||||
/* stroke — Border */
|
/* stroke — Border */
|
||||||
--stroke-default: #1e2a42;
|
--stroke-default: #24272D;
|
||||||
--stroke-light: #2a3a5c;
|
--stroke-light: #1B1E23;
|
||||||
/* fg — Foreground */
|
/* fg — Foreground */
|
||||||
--fg-default: #edf0f7;
|
--fg-default: #F8F9FC;
|
||||||
--fg-sub: #c0c8dc;
|
--fg-sub: #B9C1C9;
|
||||||
--fg-disabled: #9ba3b8;
|
--fg-disabled: #808892;
|
||||||
/* color — Palette */
|
/* color — Palette */
|
||||||
--color-info: #3b82f6;
|
--color-info: #0099DD;
|
||||||
--color-accent: #06b6d4;
|
--color-accent: #0099DD;
|
||||||
--color-accent-muted: #0e7490;
|
--color-accent-muted: #007AB1;
|
||||||
--color-danger: #ef4444;
|
--color-danger: #D61111;
|
||||||
--color-warning: #f97316;
|
--color-warning: #f97316;
|
||||||
--color-caution: #eab308;
|
--color-caution: #FEDA4A;
|
||||||
--color-success: #22c55e;
|
--color-success: #22c55e;
|
||||||
--color-tertiary: #a855f7;
|
--color-tertiary: #a855f7;
|
||||||
--color-boom: #f59e0b;
|
--color-boom: #f59e0b;
|
||||||
@ -79,15 +79,15 @@
|
|||||||
--font-size-title-1: 1.125rem;
|
--font-size-title-1: 1.125rem;
|
||||||
--font-size-subtitle: 0.9375rem;
|
--font-size-subtitle: 0.9375rem;
|
||||||
--font-size-title-2: 1rem;
|
--font-size-title-2: 1rem;
|
||||||
--font-size-title-3: 0.875rem;
|
--font-size-title-3: 1rem;
|
||||||
--font-size-title-4: 0.8125rem;
|
--font-size-title-4: 0.875rem;
|
||||||
--font-size-title-5: 0.75rem;
|
--font-size-title-5: 0.8125rem;
|
||||||
--font-size-title-6: 0.6875rem;
|
--font-size-title-6: 0.75rem;
|
||||||
--font-size-body-1: 0.875rem;
|
--font-size-body-1: 1rem;
|
||||||
--font-size-body-2: 0.8125rem;
|
--font-size-body-2: 0.875rem;
|
||||||
--font-size-label-1: 0.75rem;
|
--font-size-label-1: 0.8125rem;
|
||||||
--font-size-label-2: 0.6875rem;
|
--font-size-label-2: 0.75rem;
|
||||||
--font-size-caption: 0.6875rem;
|
--font-size-caption: 0.75rem;
|
||||||
/* typography — font-weight */
|
/* typography — font-weight */
|
||||||
--font-weight-thin: 300;
|
--font-weight-thin: 300;
|
||||||
--font-weight-regular: 400;
|
--font-weight-regular: 400;
|
||||||
@ -111,29 +111,34 @@
|
|||||||
--static-black: #131415;
|
--static-black: #131415;
|
||||||
--static-white: #ffffff;
|
--static-white: #ffffff;
|
||||||
|
|
||||||
/* Gray */
|
/* Gray (Definition cool-tone, 15 steps) */
|
||||||
--gray-100: #f1f5f9;
|
--gray-0: #FFFFFF;
|
||||||
--gray-200: #e2e8f0;
|
--gray-50: #F8F9FC;
|
||||||
--gray-300: #cbd5e1;
|
--gray-100: #F3F6FB;
|
||||||
--gray-400: #94a3b8;
|
--gray-200: #EBEFF5;
|
||||||
--gray-500: #64748b;
|
--gray-250: #E1E6EC;
|
||||||
--gray-600: #475569;
|
--gray-300: #D6DBE1;
|
||||||
--gray-700: #334155;
|
--gray-400: #B9C1C9;
|
||||||
--gray-800: #1e293b;
|
--gray-500: #808892;
|
||||||
--gray-900: #0f172a;
|
--gray-550: #6C747E;
|
||||||
--gray-1000: #020617;
|
--gray-600: #565B64;
|
||||||
|
--gray-700: #3A3F49;
|
||||||
|
--gray-800: #24272D;
|
||||||
|
--gray-850: #1B1E23;
|
||||||
|
--gray-900: #121418;
|
||||||
|
--gray-1000: #000000;
|
||||||
|
|
||||||
/* Blue */
|
/* Blue (Primary Blue-Cyan, hue ~200°) */
|
||||||
--blue-100: #dbeafe;
|
--blue-100: #E6F4FB;
|
||||||
--blue-200: #bfdbfe;
|
--blue-200: #B3E0F5;
|
||||||
--blue-300: #93c5fd;
|
--blue-300: #80CCEE;
|
||||||
--blue-400: #60a5fa;
|
--blue-400: #4DB8E8;
|
||||||
--blue-500: #3b82f6;
|
--blue-500: #0099DD;
|
||||||
--blue-600: #2563eb;
|
--blue-600: #007AB1;
|
||||||
--blue-700: #1d4ed8;
|
--blue-700: #005C85;
|
||||||
--blue-800: #1e40af;
|
--blue-800: #003D59;
|
||||||
--blue-900: #1e3a8a;
|
--blue-900: #001F2D;
|
||||||
--blue-1000: #172554;
|
--blue-1000: #001520;
|
||||||
|
|
||||||
/* Green */
|
/* Green */
|
||||||
--green-100: #dcfce7;
|
--green-100: #dcfce7;
|
||||||
@ -152,7 +157,7 @@
|
|||||||
--yellow-200: #fef08a;
|
--yellow-200: #fef08a;
|
||||||
--yellow-300: #fde047;
|
--yellow-300: #fde047;
|
||||||
--yellow-400: #facc15;
|
--yellow-400: #facc15;
|
||||||
--yellow-500: #eab308;
|
--yellow-500: #FEDA4A;
|
||||||
--yellow-600: #ca8a04;
|
--yellow-600: #ca8a04;
|
||||||
--yellow-700: #a16207;
|
--yellow-700: #a16207;
|
||||||
--yellow-800: #854d0e;
|
--yellow-800: #854d0e;
|
||||||
@ -160,12 +165,12 @@
|
|||||||
--yellow-1000: #422006;
|
--yellow-1000: #422006;
|
||||||
|
|
||||||
/* Red */
|
/* Red */
|
||||||
--red-100: #fee2e2;
|
--red-100: #7A2D2D;
|
||||||
--red-200: #fecaca;
|
--red-200: #fecaca;
|
||||||
--red-300: #fca5a5;
|
--red-300: #fca5a5;
|
||||||
--red-400: #f87171;
|
--red-400: #f87171;
|
||||||
--red-500: #ef4444;
|
--red-500: #DE4141;
|
||||||
--red-600: #dc2626;
|
--red-600: #D61111;
|
||||||
--red-700: #b91c1c;
|
--red-700: #b91c1c;
|
||||||
--red-800: #991b1b;
|
--red-800: #991b1b;
|
||||||
--red-900: #7f1d1d;
|
--red-900: #7f1d1d;
|
||||||
@ -189,19 +194,19 @@
|
|||||||
|
|
||||||
/* ── Light theme overrides ── */
|
/* ── Light theme overrides ── */
|
||||||
[data-theme='light'] {
|
[data-theme='light'] {
|
||||||
--bg-base: #f8fafc;
|
--bg-base: #FFFFFF;
|
||||||
--bg-surface: #ffffff;
|
--bg-surface: #FFFFFF;
|
||||||
--bg-elevated: #f1f5f9;
|
--bg-elevated: #F3F6FB;
|
||||||
--bg-card: #ffffff;
|
--bg-card: #FFFFFF;
|
||||||
--bg-surface-hover: #e2e8f0;
|
--bg-surface-hover: #EBEFF5;
|
||||||
--stroke-default: #cbd5e1;
|
--stroke-default: #B9C1C9;
|
||||||
--stroke-light: #e2e8f0;
|
--stroke-light: #E1E6EC;
|
||||||
--fg-default: #0f172a;
|
--fg-default: #121418;
|
||||||
--fg-sub: #475569;
|
--fg-sub: #24272D;
|
||||||
--fg-disabled: #94a3b8;
|
--fg-disabled: #808892;
|
||||||
--hover-overlay: rgba(0, 0, 0, 0.04);
|
--hover-overlay: rgba(0, 0, 0, 0.04);
|
||||||
--dropdown-bg: rgba(255, 255, 255, 0.97);
|
--dropdown-bg: rgba(255, 255, 255, 0.97);
|
||||||
--color-accent-muted: #0891b2;
|
--color-accent-muted: #007AB1;
|
||||||
--color-navy: #1d4ed8;
|
--color-navy: #1d4ed8;
|
||||||
--color-navy-hover: #2563eb;
|
--color-navy-hover: #2563eb;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,21 @@
|
|||||||
z-index: 500;
|
z-index: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 사고 팝업 — @layer 밖에 위치해야 MapLibre 기본 스타일을 덮어씀 */
|
||||||
|
.incident-popup .maplibregl-popup-content {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.incident-popup .maplibregl-popup-tip {
|
||||||
|
border-top-color: var(--bg-elevated);
|
||||||
|
border-bottom-color: var(--bg-elevated);
|
||||||
|
border-left-color: transparent;
|
||||||
|
border-right-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
/* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */
|
/* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */
|
||||||
.cctv-dark-popup .maplibregl-popup-content {
|
.cctv-dark-popup .maplibregl-popup-content {
|
||||||
@ -30,6 +45,22 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══ Incidents 사고 팝업 ✕ 버튼 — 라이트 지도 기준 검은색 고정 ═══ */
|
||||||
|
.incident-popup .maplibregl-popup-close-button {
|
||||||
|
color: #1a1d21;
|
||||||
|
background: transparent;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 16px;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
}
|
||||||
|
.incident-popup .maplibregl-popup-close-button:hover {
|
||||||
|
color: #000;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══ Scrollbar ═══ */
|
/* ═══ Scrollbar ═══ */
|
||||||
.scrollbar-thin {
|
.scrollbar-thin {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@ -63,7 +94,7 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--fg-default);
|
color: var(--fg-default);
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,7 +119,7 @@
|
|||||||
|
|
||||||
.prd-date-input,
|
.prd-date-input,
|
||||||
.prd-time-input {
|
.prd-time-input {
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,7 +127,7 @@
|
|||||||
.prd-time-input::-webkit-datetime-edit {
|
.prd-time-input::-webkit-datetime-edit {
|
||||||
color: var(--fg-sub);
|
color: var(--fg-sub);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,7 +207,7 @@
|
|||||||
background: #1a1f2e;
|
background: #1a1f2e;
|
||||||
color: var(--fg-default);
|
color: var(--fg-default);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -263,7 +294,7 @@
|
|||||||
|
|
||||||
.combo-item {
|
.combo-item {
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
color: var(--fg-sub);
|
color: var(--fg-sub);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -294,7 +325,7 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 5px 4px;
|
padding: 5px 4px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
font-size: 9px;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -313,7 +344,7 @@
|
|||||||
|
|
||||||
/* .prd-mc.on::before {
|
/* .prd-mc.on::before {
|
||||||
content: '✓ ';
|
content: '✓ ';
|
||||||
font-size: 9px;
|
font-size: 0.6875rem;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
} */
|
} */
|
||||||
|
|
||||||
@ -329,7 +360,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 12px;
|
font-size: 0.8125rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: none;
|
||||||
@ -355,7 +386,7 @@
|
|||||||
border: 1px solid rgba(6, 182, 212, 0.2);
|
border: 1px solid rgba(6, 182, 212, 0.2);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
font-size: 9px;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@ -380,7 +411,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 12px;
|
font-size: 0.8125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
background: rgba(15, 21, 36, 0.75);
|
background: rgba(15, 21, 36, 0.75);
|
||||||
@ -419,7 +450,7 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 5px 14px;
|
padding: 5px 14px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.6875rem;
|
font-size: 0.75rem;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
@ -460,7 +491,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wii-value {
|
.wii-value {
|
||||||
font-size: 0.6875rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@ -507,7 +538,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 1rem;
|
||||||
transition: 0.2s;
|
transition: 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -536,7 +567,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tll {
|
.tll {
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
@ -590,7 +621,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: -18px;
|
top: -18px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
font-size: 12px;
|
font-size: 0.8125rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
filter: drop-shadow(0 0 4px rgba(245, 158, 11, 0.5));
|
filter: drop-shadow(0 0 4px rgba(245, 158, 11, 0.5));
|
||||||
}
|
}
|
||||||
@ -605,7 +636,7 @@
|
|||||||
border: 1px solid var(--color-boom);
|
border: 1px solid var(--color-boom);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
color: var(--color-boom);
|
color: var(--color-boom);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
@ -641,7 +672,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tlct {
|
.tlct {
|
||||||
font-size: 14px;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@ -656,7 +687,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tlsl {
|
.tlsl {
|
||||||
@ -684,7 +715,7 @@
|
|||||||
border: 1px solid var(--stroke-default);
|
border: 1px solid var(--stroke-default);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
color: var(--fg-sub);
|
color: var(--fg-sub);
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
@ -735,7 +766,7 @@
|
|||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
color: var(--fg-sub);
|
color: var(--fg-sub);
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
}
|
}
|
||||||
@ -810,7 +841,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.layer-icon {
|
.layer-icon {
|
||||||
font-size: 14px;
|
font-size: 1rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -820,7 +851,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.layer-count {
|
.layer-count {
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
@ -841,7 +872,7 @@
|
|||||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-boom);
|
color: var(--color-boom);
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
@ -872,10 +903,10 @@
|
|||||||
background: var(--bg-base);
|
background: var(--bg-base);
|
||||||
border: 1px solid var(--stroke-default);
|
border: 1px solid var(--stroke-default);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--color-accent);
|
color: var(--color-default);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 400;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
@ -911,7 +942,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -921,7 +952,7 @@
|
|||||||
border: 1px solid var(--stroke-default);
|
border: 1px solid var(--stroke-default);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@ -1084,7 +1115,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
font-size: 12px;
|
font-size: 0.8125rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--fg-default);
|
color: var(--fg-default);
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
@ -1108,7 +1139,7 @@
|
|||||||
|
|
||||||
.lyr-h1-cnt {
|
.lyr-h1-cnt {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@ -1137,7 +1168,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--fg-sub);
|
color: var(--fg-sub);
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
@ -1161,7 +1192,7 @@
|
|||||||
|
|
||||||
.lyr-h2-cnt {
|
.lyr-h2-cnt {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@ -1184,7 +1215,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
color: var(--fg-sub);
|
color: var(--fg-sub);
|
||||||
transition:
|
transition:
|
||||||
color 0.15s,
|
color 0.15s,
|
||||||
@ -1200,7 +1231,7 @@
|
|||||||
|
|
||||||
.lyr-cnt {
|
.lyr-cnt {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@ -1314,7 +1345,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lyr-ccustom label {
|
.lyr-ccustom label {
|
||||||
font-size: 9px;
|
font-size: 0.75rem;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
}
|
}
|
||||||
@ -1338,7 +1369,7 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
.lyr-style-label {
|
.lyr-style-label {
|
||||||
font-size: 9px;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
@ -1355,7 +1386,7 @@
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
.lyr-style-name {
|
.lyr-style-name {
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
min-width: 32px;
|
min-width: 32px;
|
||||||
@ -1380,7 +1411,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.lyr-style-val {
|
.lyr-style-val {
|
||||||
font-size: 9px;
|
font-size: 0.75rem;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
|
|||||||
@ -181,7 +181,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wing-tab {
|
.wing-tab {
|
||||||
@apply flex-1 py-2 px-1 text-xs font-semibold rounded-md text-center cursor-pointer font-korean;
|
@apply flex-1 py-2 px-1 text-caption font-semibold rounded-md text-center cursor-pointer font-korean;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
/* HNS 물질 검색 데이터 타입 */
|
|
||||||
|
|
||||||
export interface HNSSearchSubstance {
|
|
||||||
id: number;
|
|
||||||
abbreviation: string; // 약자/제품명 (화물적부도 코드)
|
|
||||||
nameKr: string; // 국문명
|
|
||||||
nameEn: string; // 영문명
|
|
||||||
synonymsEn: string; // 영문 동의어
|
|
||||||
synonymsKr: string; // 국문 동의어/용도
|
|
||||||
unNumber: string; // UN번호
|
|
||||||
casNumber: string; // CAS번호
|
|
||||||
transportMethod: string; // 운송방법
|
|
||||||
sebc: string; // SEBC 거동분류
|
|
||||||
/* 물리·화학적 특성 */
|
|
||||||
usage: string;
|
|
||||||
state: string;
|
|
||||||
color: string;
|
|
||||||
odor: string;
|
|
||||||
flashPoint: string;
|
|
||||||
autoIgnition: string;
|
|
||||||
boilingPoint: string;
|
|
||||||
density: string; // 비중 (물=1)
|
|
||||||
solubility: string;
|
|
||||||
vaporPressure: string;
|
|
||||||
vaporDensity: string; // 증기밀도 (공기=1)
|
|
||||||
explosionRange: string; // 폭발범위
|
|
||||||
/* 위험등급·농도기준 */
|
|
||||||
nfpa: { health: number; fire: number; reactivity: number; special: string };
|
|
||||||
hazardClass: string;
|
|
||||||
ergNumber: string;
|
|
||||||
idlh: string;
|
|
||||||
aegl2: string;
|
|
||||||
erpg2: string;
|
|
||||||
/* 방제거리 */
|
|
||||||
responseDistanceFire: string;
|
|
||||||
responseDistanceSpillDay: string;
|
|
||||||
responseDistanceSpillNight: string;
|
|
||||||
marineResponse: string;
|
|
||||||
/* PPE */
|
|
||||||
ppeClose: string;
|
|
||||||
ppeFar: string;
|
|
||||||
/* MSDS 요약 */
|
|
||||||
msds: {
|
|
||||||
hazard: string;
|
|
||||||
firstAid: string;
|
|
||||||
fireFighting: string;
|
|
||||||
spillResponse: string;
|
|
||||||
exposure: string;
|
|
||||||
regulation: string;
|
|
||||||
};
|
|
||||||
/* IBC CODE */
|
|
||||||
ibcHazard: string;
|
|
||||||
ibcShipType: string;
|
|
||||||
ibcTankType: string;
|
|
||||||
ibcDetection: string;
|
|
||||||
ibcFireFighting: string;
|
|
||||||
ibcMinRequirement: string;
|
|
||||||
/* EmS */
|
|
||||||
emsCode: string;
|
|
||||||
emsFire: string;
|
|
||||||
emsSpill: string;
|
|
||||||
emsFirstAid: string;
|
|
||||||
/* 화물적부도 코드 */
|
|
||||||
cargoCodes: Array<{ code: string; name: string; company: string; source: string }>;
|
|
||||||
/* 항구별 반입 */
|
|
||||||
portFrequency: Array<{ port: string; portCode: string; lastImport: string; frequency: string }>;
|
|
||||||
}
|
|
||||||
@ -3,7 +3,7 @@ import type {
|
|||||||
BoomLineCoord,
|
BoomLineCoord,
|
||||||
AlgorithmSettings,
|
AlgorithmSettings,
|
||||||
ContainmentResult,
|
ContainmentResult,
|
||||||
} from '../types/boomLine';
|
} from '@/types/boomLine';
|
||||||
|
|
||||||
const DEG2RAD = Math.PI / 180;
|
const DEG2RAD = Math.PI / 180;
|
||||||
const RAD2DEG = 180 / Math.PI;
|
const RAD2DEG = 180 / Math.PI;
|
||||||
@ -217,8 +217,6 @@ export function generateAIBoomLines(
|
|||||||
const totalDist = haversineDistance(incident, centroid);
|
const totalDist = haversineDistance(incident, centroid);
|
||||||
|
|
||||||
// 입자 분산 폭 계산 (최종 시간 기준)
|
// 입자 분산 폭 계산 (최종 시간 기준)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const perpBearing = (mainBearing + 90) % 360;
|
|
||||||
let maxSpread = 0;
|
let maxSpread = 0;
|
||||||
for (const p of finalPoints) {
|
for (const p of finalPoints) {
|
||||||
const bearing = computeBearing(incident, p);
|
const bearing = computeBearing(incident, p);
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { ImageAnalyzeResult } from '@tabs/prediction/services/predictionApi';
|
import type { ImageAnalyzeResult } from '@interfaces/prediction/PredictionInterface';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 항공탐색(유출유면적분석) → 유출유 확산예측 탭 간 데이터 전달용 모듈 레벨 시그널.
|
* 항공탐색(유출유면적분석) → 유출유 확산예측 탭 간 데이터 전달용 모듈 레벨 시그널.
|
||||||
|
|||||||
@ -31,45 +31,6 @@ export function stripHtmlTags(html: string): string {
|
|||||||
return html.replace(/<[^>]*>/g, '');
|
return html.replace(/<[^>]*>/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 안전한 HTML 살균
|
|
||||||
* 허용된 태그만 남기고 위험한 태그/속성 제거
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const ALLOWED_TAGS = new Set([
|
|
||||||
'b',
|
|
||||||
'i',
|
|
||||||
'u',
|
|
||||||
'strong',
|
|
||||||
'em',
|
|
||||||
'br',
|
|
||||||
'p',
|
|
||||||
'span',
|
|
||||||
'div',
|
|
||||||
'h1',
|
|
||||||
'h2',
|
|
||||||
'h3',
|
|
||||||
'h4',
|
|
||||||
'h5',
|
|
||||||
'h6',
|
|
||||||
'ul',
|
|
||||||
'ol',
|
|
||||||
'li',
|
|
||||||
'a',
|
|
||||||
'img',
|
|
||||||
'table',
|
|
||||||
'thead',
|
|
||||||
'tbody',
|
|
||||||
'tr',
|
|
||||||
'th',
|
|
||||||
'td',
|
|
||||||
'sup',
|
|
||||||
'sub',
|
|
||||||
'hr',
|
|
||||||
'blockquote',
|
|
||||||
'pre',
|
|
||||||
'code',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const DANGEROUS_ATTRS = /\s*on\w+\s*=|javascript\s*:|vbscript\s*:|expression\s*\(/gi;
|
const DANGEROUS_ATTRS = /\s*on\w+\s*=|javascript\s*:|vbscript\s*:|expression\s*\(/gi;
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ interface AdminPlaceholderProps {
|
|||||||
const AdminPlaceholder = ({ label }: AdminPlaceholderProps) => (
|
const AdminPlaceholder = ({ label }: AdminPlaceholderProps) => (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||||
<div className="text-4xl opacity-20">🚧</div>
|
<div className="text-4xl opacity-20">🚧</div>
|
||||||
<div className="text-sm font-korean text-fg-sub font-semibold">{label}</div>
|
<div className="text-body-2 font-korean text-fg-sub font-semibold">{label}</div>
|
||||||
<div className="text-label-2 font-korean text-fg-disabled">해당 기능은 준비 중입니다.</div>
|
<div className="text-label-2 font-korean text-fg-disabled">해당 기능은 준비 중입니다.</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -107,7 +107,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
|||||||
>
|
>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="px-4 py-3 border-b border-stroke bg-bg-elevated shrink-0">
|
<div className="px-4 py-3 border-b border-stroke bg-bg-elevated shrink-0">
|
||||||
<div className="text-xs font-bold text-fg font-korean flex items-center gap-1.5">
|
<div className="text-caption font-bold text-fg font-korean flex items-center gap-1.5">
|
||||||
<span>⚙️</span> 관리자 설정
|
<span>⚙️</span> 관리자 설정
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -129,7 +129,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
|||||||
color: hasActiveChild ? 'var(--color-accent)' : 'var(--fg-default)',
|
color: hasActiveChild ? 'var(--color-accent)' : 'var(--fg-default)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-sm">{section.icon}</span>
|
<span className="text-body-2">{section.icon}</span>
|
||||||
<span className="flex-1 text-left">{section.label}</span>
|
<span className="flex-1 text-left">{section.label}</span>
|
||||||
<span
|
<span
|
||||||
className="text-caption text-fg-disabled transition-transform"
|
className="text-caption text-fg-disabled transition-transform"
|
||||||
4
frontend/src/tabs/admin/components/AdminView.tsx → frontend/src/components/admin/components/AdminView.tsx
Executable file → Normal file
4
frontend/src/tabs/admin/components/AdminView.tsx → frontend/src/components/admin/components/AdminView.tsx
Executable file → Normal file
@ -69,7 +69,9 @@ export function AdminView() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-1 overflow-hidden bg-bg-base">
|
<div className="flex flex-1 overflow-hidden bg-bg-base">
|
||||||
<AdminSidebar activeMenu={activeMenu} onSelect={setActiveMenu} />
|
<AdminSidebar activeMenu={activeMenu} onSelect={setActiveMenu} />
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">{renderContent()}</div>
|
<div className="flex-1 flex flex-col overflow-hidden" key={activeMenu}>
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { fetchUploadLogs } from '@tabs/assets/services/assetsApi';
|
import { fetchUploadLogs } from '@components/assets/services/assetsApi';
|
||||||
import type { UploadLogItem } from '@tabs/assets/services/assetsApi';
|
import type { UploadLogItem } from '@interfaces/assets/AssetsInterface';
|
||||||
|
|
||||||
const ASSET_CATEGORIES = [
|
const ASSET_CATEGORIES = [
|
||||||
'전체',
|
'전체',
|
||||||
@ -20,29 +20,29 @@ const PERM_ITEMS = [
|
|||||||
icon: '👑',
|
icon: '👑',
|
||||||
role: '시스템관리자',
|
role: '시스템관리자',
|
||||||
desc: '전체 자산 업로드/삭제 가능',
|
desc: '전체 자산 업로드/삭제 가능',
|
||||||
bg: 'rgba(245,158,11,0.15)',
|
bg: 'rgba(6,182,212,0.12)',
|
||||||
color: 'text-yellow-400',
|
color: 'text-color-accent',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '🔧',
|
icon: '🔧',
|
||||||
role: '운영관리자',
|
role: '운영관리자',
|
||||||
desc: '관할청 내 자산 업로드 가능',
|
desc: '관할청 내 자산 업로드 가능',
|
||||||
bg: 'rgba(6,182,212,0.15)',
|
bg: 'rgba(6,182,212,0.08)',
|
||||||
color: 'text-color-accent',
|
color: 'text-color-accent',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '👁',
|
icon: '👁',
|
||||||
role: '조회자',
|
role: '조회자',
|
||||||
desc: '현황 조회만 가능',
|
desc: '현황 조회만 가능',
|
||||||
bg: 'rgba(148,163,184,0.15)',
|
bg: 'rgba(6,182,212,0.08)',
|
||||||
color: 'text-fg-sub',
|
color: 'text-fg-sub',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '🚫',
|
icon: '🚫',
|
||||||
role: '게스트',
|
role: '게스트',
|
||||||
desc: '접근 불가',
|
desc: '접근 불가',
|
||||||
bg: 'rgba(239,68,68,0.1)',
|
bg: 'rgba(6,182,212,0.08)',
|
||||||
color: 'text-red-400',
|
color: 'text-fg-sub',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -102,8 +102,8 @@ function AssetUploadPanel() {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="px-6 py-4 border-b border-stroke flex-shrink-0">
|
<div className="px-6 py-4 border-b border-stroke flex-shrink-0">
|
||||||
<h1 className="text-lg font-bold text-fg font-korean">자산 현행화</h1>
|
<h1 className="text-title-1 font-bold text-fg font-korean">자산 현행화</h1>
|
||||||
<p className="text-xs text-fg-disabled mt-1 font-korean">
|
<p className="text-caption text-fg-disabled mt-1 font-korean">
|
||||||
자산 데이터를 업로드하여 현행화합니다
|
자산 데이터를 업로드하여 현행화합니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -115,7 +115,7 @@ function AssetUploadPanel() {
|
|||||||
<div className="flex-1 max-w-[560px] space-y-4">
|
<div className="flex-1 max-w-[560px] space-y-4">
|
||||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||||
<div className="px-5 py-3 border-b border-stroke">
|
<div className="px-5 py-3 border-b border-stroke">
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">파일 업로드</h2>
|
<h2 className="text-body-2 font-bold text-fg font-korean">파일 업로드</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4 space-y-4">
|
<div className="px-5 py-4 space-y-4">
|
||||||
{/* 드롭존 */}
|
{/* 드롭존 */}
|
||||||
@ -130,17 +130,17 @@ function AssetUploadPanel() {
|
|||||||
className={`rounded-lg border-2 border-dashed py-8 text-center cursor-pointer transition-colors ${
|
className={`rounded-lg border-2 border-dashed py-8 text-center cursor-pointer transition-colors ${
|
||||||
dragging
|
dragging
|
||||||
? 'border-color-accent bg-[rgba(6,182,212,0.05)]'
|
? 'border-color-accent bg-[rgba(6,182,212,0.05)]'
|
||||||
: 'border-stroke hover:border-[rgba(6,182,212,0.5)] bg-bg-elevated'
|
: 'border-stroke hover:border-[rgba(6,182,212,0.3)] bg-bg-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="text-3xl mb-2 opacity-40">📁</div>
|
<div className="text-3xl mb-2 opacity-40">📁</div>
|
||||||
{selectedFile ? (
|
{selectedFile ? (
|
||||||
<div className="text-xs font-semibold text-color-accent font-korean mb-1">
|
<div className="text-caption font-semibold text-color-accent font-korean mb-1">
|
||||||
{selectedFile.name}
|
{selectedFile.name}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="text-xs font-semibold text-fg-sub font-korean mb-1">
|
<div className="text-caption font-semibold text-fg-sub font-korean mb-1">
|
||||||
파일을 드래그하거나 클릭하여 업로드
|
파일을 드래그하거나 클릭하여 업로드
|
||||||
</div>
|
</div>
|
||||||
<div className="text-caption text-fg-disabled font-korean mb-3">
|
<div className="text-caption text-fg-disabled font-korean mb-3">
|
||||||
@ -148,7 +148,7 @@ function AssetUploadPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="px-4 py-1.5 text-xs font-semibold rounded-md bg-color-accent text-bg-0
|
className="px-4 py-1.5 text-caption font-semibold rounded-md bg-color-accent text-bg-0
|
||||||
hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -176,7 +176,7 @@ function AssetUploadPanel() {
|
|||||||
<select
|
<select
|
||||||
value={assetCategory}
|
value={assetCategory}
|
||||||
onChange={(e) => setAssetCategory(e.target.value)}
|
onChange={(e) => setAssetCategory(e.target.value)}
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md
|
||||||
text-fg focus:border-color-accent focus:outline-none font-korean"
|
text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
{ASSET_CATEGORIES.map((c) => (
|
{ASSET_CATEGORIES.map((c) => (
|
||||||
@ -195,7 +195,7 @@ function AssetUploadPanel() {
|
|||||||
<select
|
<select
|
||||||
value={jurisdiction}
|
value={jurisdiction}
|
||||||
onChange={(e) => setJurisdiction(e.target.value)}
|
onChange={(e) => setJurisdiction(e.target.value)}
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md
|
||||||
text-fg focus:border-color-accent focus:outline-none font-korean"
|
text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
{JURISDICTIONS.map((j) => (
|
{JURISDICTIONS.map((j) => (
|
||||||
@ -212,7 +212,7 @@ function AssetUploadPanel() {
|
|||||||
업로드 방식
|
업로드 방식
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-fg-sub font-korean">
|
<label className="flex items-center gap-1.5 cursor-pointer text-caption text-fg-sub font-korean">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
checked={uploadMode === 'add'}
|
checked={uploadMode === 'add'}
|
||||||
@ -221,7 +221,7 @@ function AssetUploadPanel() {
|
|||||||
/>
|
/>
|
||||||
추가 (기존 + 신규)
|
추가 (기존 + 신규)
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-fg-sub font-korean">
|
<label className="flex items-center gap-1.5 cursor-pointer text-caption text-fg-sub font-korean">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
checked={uploadMode === 'replace'}
|
checked={uploadMode === 'replace'}
|
||||||
@ -238,7 +238,7 @@ function AssetUploadPanel() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleUpload}
|
onClick={handleUpload}
|
||||||
disabled={!selectedFile || uploaded}
|
disabled={!selectedFile || uploaded}
|
||||||
className={`w-full py-2.5 text-xs font-semibold rounded-md transition-all font-korean disabled:opacity-50 ${
|
className={`w-full py-2.5 text-caption font-semibold rounded-md transition-all font-korean disabled:opacity-50 ${
|
||||||
uploaded
|
uploaded
|
||||||
? 'bg-[rgba(34,197,94,0.15)] text-color-success border border-status-green/30'
|
? 'bg-[rgba(34,197,94,0.15)] text-color-success border border-status-green/30'
|
||||||
: 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
: 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||||
@ -255,7 +255,7 @@ function AssetUploadPanel() {
|
|||||||
{/* 수정 권한 체계 */}
|
{/* 수정 권한 체계 */}
|
||||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||||
<div className="px-5 py-3 border-b border-stroke">
|
<div className="px-5 py-3 border-b border-stroke">
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">수정 권한 체계</h2>
|
<h2 className="text-body-2 font-bold text-fg font-korean">수정 권한 체계</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4 space-y-2">
|
<div className="px-5 py-4 space-y-2">
|
||||||
{PERM_ITEMS.map((p) => (
|
{PERM_ITEMS.map((p) => (
|
||||||
@ -264,13 +264,15 @@ function AssetUploadPanel() {
|
|||||||
className="flex items-center gap-3 px-4 py-3 bg-bg-elevated border border-stroke rounded-md"
|
className="flex items-center gap-3 px-4 py-3 bg-bg-elevated border border-stroke rounded-md"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0"
|
className="w-8 h-8 rounded-full flex items-center justify-center text-body-2 flex-shrink-0"
|
||||||
style={{ background: p.bg }}
|
style={{ background: p.bg }}
|
||||||
>
|
>
|
||||||
{p.icon}
|
{p.icon}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className={`text-xs font-bold font-korean ${p.color}`}>{p.role}</div>
|
<div className={`text-caption font-bold font-korean ${p.color}`}>
|
||||||
|
{p.role}
|
||||||
|
</div>
|
||||||
<div className="text-caption text-fg-disabled font-korean mt-0.5">
|
<div className="text-caption text-fg-disabled font-korean mt-0.5">
|
||||||
{p.desc}
|
{p.desc}
|
||||||
</div>
|
</div>
|
||||||
@ -283,7 +285,7 @@ function AssetUploadPanel() {
|
|||||||
{/* 최근 업로드 이력 */}
|
{/* 최근 업로드 이력 */}
|
||||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||||
<div className="px-5 py-3 border-b border-stroke">
|
<div className="px-5 py-3 border-b border-stroke">
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">최근 업로드 이력</h2>
|
<h2 className="text-body-2 font-bold text-fg font-korean">최근 업로드 이력</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4 space-y-2">
|
<div className="px-5 py-4 space-y-2">
|
||||||
{uploadHistory.length === 0 ? (
|
{uploadHistory.length === 0 ? (
|
||||||
@ -297,7 +299,9 @@ function AssetUploadPanel() {
|
|||||||
className="flex justify-between items-center px-4 py-3 bg-bg-elevated border border-stroke rounded-md"
|
className="flex justify-between items-center px-4 py-3 bg-bg-elevated border border-stroke rounded-md"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold text-fg font-korean">{h.fileNm}</div>
|
<div className="text-caption font-semibold text-fg font-korean">
|
||||||
|
{h.fileNm}
|
||||||
|
</div>
|
||||||
<div className="text-caption text-fg-disabled mt-0.5 font-korean">
|
<div className="text-caption text-fg-disabled mt-0.5 font-korean">
|
||||||
{formatDate(h.regDtm)} · {h.uploaderNm} · {h.uploadCnt.toLocaleString()}건
|
{formatDate(h.regDtm)} · {h.uploaderNm} · {h.uploadCnt.toLocaleString()}건
|
||||||
</div>
|
</div>
|
||||||
@ -1,10 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import { fetchBoardPosts, adminDeleteBoardPost } from '@components/board/services/boardApi';
|
||||||
fetchBoardPosts,
|
import type { BoardPostItem, BoardListResponse } from '@interfaces/board/BoardInterface';
|
||||||
adminDeleteBoardPost,
|
|
||||||
type BoardPostItem,
|
|
||||||
type BoardListResponse,
|
|
||||||
} from '@tabs/board/services/boardApi';
|
|
||||||
|
|
||||||
// ─── 상수 ──────────────────────────────────────────────────
|
// ─── 상수 ──────────────────────────────────────────────────
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@ -118,21 +114,21 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1">
|
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke">
|
||||||
<h2 className="text-sm font-semibold text-fg">게시판 관리</h2>
|
<h2 className="text-body-2 font-semibold text-fg">게시판 관리</h2>
|
||||||
<span className="text-xs text-fg-disabled">총 {data?.totalCount ?? 0}건</span>
|
<span className="text-caption text-fg-disabled">총 {data?.totalCount ?? 0}건</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 카테고리 탭 + 검색 */}
|
{/* 카테고리 탭 + 검색 */}
|
||||||
<div className="flex items-center gap-3 px-5 py-2 border-b border-stroke-1">
|
<div className="flex items-center gap-3 px-5 py-2 border-b border-stroke">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{CATEGORY_TABS.map((tab) => (
|
{CATEGORY_TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.code}
|
key={tab.code}
|
||||||
onClick={() => handleCategoryChange(tab.code)}
|
onClick={() => handleCategoryChange(tab.code)}
|
||||||
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
className={`px-3 py-1 text-caption rounded-full transition-colors ${
|
||||||
activeCategory === tab.code
|
activeCategory === tab.code
|
||||||
? 'bg-blue-500/20 text-blue-400 font-medium'
|
? 'bg-[rgba(6,182,212,0.15)] text-color-accent font-medium'
|
||||||
: 'text-fg-disabled hover:text-fg-sub hover:bg-bg-elevated'
|
: 'text-fg-disabled hover:text-fg-sub hover:bg-bg-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -146,11 +142,11 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
|||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
placeholder="제목/작성자 검색"
|
placeholder="제목/작성자 검색"
|
||||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-fg placeholder:text-text-4 w-48"
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-fg placeholder:text-text-4 w-48"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-fg-sub hover:bg-bg-card"
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-fg-sub hover:bg-bg-card"
|
||||||
>
|
>
|
||||||
검색
|
검색
|
||||||
</button>
|
</button>
|
||||||
@ -158,11 +154,11 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 액션 바 */}
|
{/* 액션 바 */}
|
||||||
<div className="flex items-center gap-2 px-5 py-2 border-b border-stroke-1">
|
<div className="flex items-center gap-2 px-5 py-2 border-b border-stroke">
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={selected.size === 0 || deleting}
|
disabled={selected.size === 0 || deleting}
|
||||||
className="px-3 py-1 text-xs rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-40 disabled:cursor-not-allowed"
|
className="px-3 py-1 text-caption rounded bg-[rgba(239,68,68,0.15)] text-color-danger hover:bg-[rgba(239,68,68,0.25)] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{deleting ? '삭제 중...' : `선택 삭제 (${selected.size})`}
|
{deleting ? '삭제 중...' : `선택 삭제 (${selected.size})`}
|
||||||
</button>
|
</button>
|
||||||
@ -170,9 +166,9 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
|||||||
|
|
||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-caption">
|
||||||
<thead className="sticky top-0 bg-bg-surface z-10">
|
<thead className="sticky top-0 bg-bg-surface z-10">
|
||||||
<tr className="border-b border-stroke-1 text-fg-disabled">
|
<tr className="border-b border-stroke text-fg-disabled">
|
||||||
<th className="w-8 py-2 text-center">
|
<th className="w-8 py-2 text-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -218,11 +214,11 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
|||||||
|
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-center gap-1 py-2 border-t border-stroke-1">
|
<div className="flex items-center justify-center gap-1 py-2 border-t border-stroke">
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
className="px-2 py-1 text-xs rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
className="px-2 py-1 text-caption rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
||||||
>
|
>
|
||||||
<
|
<
|
||||||
</button>
|
</button>
|
||||||
@ -234,9 +230,9 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
|||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
onClick={() => setPage(p)}
|
onClick={() => setPage(p)}
|
||||||
className={`w-7 h-7 text-xs rounded ${
|
className={`w-7 h-7 text-caption rounded ${
|
||||||
p === page
|
p === page
|
||||||
? 'bg-blue-500/20 text-blue-400 font-medium'
|
? 'bg-[rgba(6,182,212,0.15)] text-color-accent font-medium'
|
||||||
: 'text-fg-disabled hover:bg-bg-elevated'
|
: 'text-fg-disabled hover:bg-bg-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -247,7 +243,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
disabled={page >= totalPages}
|
disabled={page >= totalPages}
|
||||||
className="px-2 py-1 text-xs rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
className="px-2 py-1 text-caption rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
||||||
>
|
>
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
@ -266,7 +262,7 @@ interface PostRowProps {
|
|||||||
|
|
||||||
function PostRow({ post, checked, onToggle }: PostRowProps) {
|
function PostRow({ post, checked, onToggle }: PostRowProps) {
|
||||||
return (
|
return (
|
||||||
<tr className="border-b border-stroke-1 hover:bg-bg-surface/50 transition-colors">
|
<tr className="border-b border-stroke hover:bg-bg-surface/50 transition-colors">
|
||||||
<td className="py-2 text-center">
|
<td className="py-2 text-center">
|
||||||
<input type="checkbox" checked={checked} onChange={onToggle} className="accent-blue-500" />
|
<input type="checkbox" checked={checked} onChange={onToggle} className="accent-blue-500" />
|
||||||
</td>
|
</td>
|
||||||
@ -275,17 +271,17 @@ function PostRow({ post, checked, onToggle }: PostRowProps) {
|
|||||||
<span
|
<span
|
||||||
className={`inline-block px-2 py-0.5 rounded-full text-caption font-medium ${
|
className={`inline-block px-2 py-0.5 rounded-full text-caption font-medium ${
|
||||||
post.categoryCd === 'NOTICE'
|
post.categoryCd === 'NOTICE'
|
||||||
? 'bg-red-500/15 text-red-400'
|
? 'bg-[rgba(6,182,212,0.08)] text-fg-sub'
|
||||||
: post.categoryCd === 'QNA'
|
: post.categoryCd === 'QNA'
|
||||||
? 'bg-purple-500/15 text-purple-400'
|
? 'bg-[rgba(6,182,212,0.08)] text-fg-sub'
|
||||||
: 'bg-blue-500/15 text-blue-400'
|
: 'bg-[rgba(6,182,212,0.08)] text-fg-sub'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{CATEGORY_LABELS[post.categoryCd] ?? post.categoryCd}
|
{CATEGORY_LABELS[post.categoryCd] ?? post.categoryCd}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pl-3 text-fg truncate max-w-[300px]">
|
<td className="py-2 pl-3 text-fg truncate max-w-[300px]">
|
||||||
{post.pinnedYn === 'Y' && <span className="text-caption text-orange-400 mr-1">[고정]</span>}
|
{post.pinnedYn === 'Y' && <span className="text-caption text-color-accent mr-1">[고정]</span>}
|
||||||
{post.title}
|
{post.title}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-center text-fg-sub">{post.authorName}</td>
|
<td className="py-2 text-center text-fg-sub">{post.authorName}</td>
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { fetchOrganizations } from '@tabs/assets/services/assetsApi';
|
import { fetchOrganizations } from '@components/assets/services/assetsApi';
|
||||||
import type { AssetOrgCompat } from '@tabs/assets/services/assetsApi';
|
import type { AssetOrgCompat } from '@interfaces/assets/AssetsInterface';
|
||||||
import { typeTagCls } from '@tabs/assets/components/assetTypes';
|
import { typeTagCls } from '@components/assets/components/assetTypes';
|
||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
@ -98,14 +99,16 @@ function CleanupEquipPanel() {
|
|||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-bold text-fg font-korean">방제장비 현황</h1>
|
<h1 className="text-title-1 font-bold text-fg font-korean">방제장비 현황</h1>
|
||||||
<p className="text-xs text-fg-disabled mt-1 font-korean">총 {filtered.length}개 기관</p>
|
<p className="text-caption text-fg-disabled mt-1 font-korean">
|
||||||
|
총 {filtered.length}개 기관
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<select
|
<select
|
||||||
value={regionFilter}
|
value={regionFilter}
|
||||||
onChange={handleFilterChange(setRegionFilter)}
|
onChange={handleFilterChange(setRegionFilter)}
|
||||||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="전체">전체 관할청</option>
|
<option value="전체">전체 관할청</option>
|
||||||
<option value="남해">남해청</option>
|
<option value="남해">남해청</option>
|
||||||
@ -117,7 +120,7 @@ function CleanupEquipPanel() {
|
|||||||
<select
|
<select
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
onChange={handleFilterChange(setTypeFilter)}
|
onChange={handleFilterChange(setTypeFilter)}
|
||||||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="전체">전체 유형</option>
|
<option value="전체">전체 유형</option>
|
||||||
{typeOptions.map((t) => (
|
{typeOptions.map((t) => (
|
||||||
@ -129,7 +132,7 @@ function CleanupEquipPanel() {
|
|||||||
<select
|
<select
|
||||||
value={equipFilter}
|
value={equipFilter}
|
||||||
onChange={handleFilterChange(setEquipFilter)}
|
onChange={handleFilterChange(setEquipFilter)}
|
||||||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="전체">전체 장비</option>
|
<option value="전체">전체 장비</option>
|
||||||
<option value="방제선">방제선</option>
|
<option value="방제선">방제선</option>
|
||||||
@ -146,11 +149,11 @@ function CleanupEquipPanel() {
|
|||||||
setSearchTerm(e.target.value);
|
setSearchTerm(e.target.value);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
className="w-56 px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
className="w-56 px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={load}
|
onClick={load}
|
||||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-bg-elevated border border-stroke text-fg-sub hover:border-color-accent hover:text-color-accent transition-all font-korean"
|
className="px-4 py-2 text-caption font-semibold rounded-md bg-bg-elevated border border-stroke text-fg-sub hover:border-color-accent hover:text-color-accent transition-all font-korean"
|
||||||
>
|
>
|
||||||
새로고침
|
새로고침
|
||||||
</button>
|
</button>
|
||||||
@ -160,7 +163,7 @@ function CleanupEquipPanel() {
|
|||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">
|
<div className="flex items-center justify-center h-32 text-fg-disabled text-body-2 font-korean">
|
||||||
불러오는 중...
|
불러오는 중...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -217,7 +220,7 @@ function CleanupEquipPanel() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={11}
|
colSpan={11}
|
||||||
className="px-6 py-10 text-center text-xs text-fg-disabled font-korean"
|
className="px-6 py-10 text-center text-caption text-fg-disabled font-korean"
|
||||||
>
|
>
|
||||||
조회된 기관이 없습니다.
|
조회된 기관이 없습니다.
|
||||||
</td>
|
</td>
|
||||||
@ -339,16 +342,11 @@ function CleanupEquipPanel() {
|
|||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
onClick={() => setCurrentPage(p)}
|
onClick={() => setCurrentPage(p)}
|
||||||
className="px-2.5 py-1 text-label-2 border rounded transition-colors"
|
className={`px-2.5 py-1 text-label-2 border rounded transition-colors ${
|
||||||
style={
|
|
||||||
p === safePage
|
p === safePage
|
||||||
? {
|
? 'border-color-accent text-color-accent bg-[rgba(6,182,212,0.08)]'
|
||||||
borderColor: 'var(--color-accent)',
|
: 'border-stroke text-fg-sub'
|
||||||
color: 'var(--color-accent)',
|
}`}
|
||||||
background: 'rgba(6,182,212,0.1)',
|
|
||||||
}
|
|
||||||
: { borderColor: 'var(--border)', color: 'var(--text-2)' }
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{p}
|
{p}
|
||||||
</button>
|
</button>
|
||||||
@ -176,9 +176,9 @@ function getCollectStatus(item: HrCollectItem): { label: string; color: string }
|
|||||||
return { label: '비활성', color: 'text-t3 bg-bg-elevated' };
|
return { label: '비활성', color: 'text-t3 bg-bg-elevated' };
|
||||||
}
|
}
|
||||||
if (item.etaClctList.length > 0) {
|
if (item.etaClctList.length > 0) {
|
||||||
return { label: '완료', color: 'text-emerald-400 bg-emerald-500/10' };
|
return { label: '완료', color: 'text-color-success bg-[rgba(34,197,94,0.08)]' };
|
||||||
}
|
}
|
||||||
return { label: '대기', color: 'text-yellow-400 bg-yellow-500/10' };
|
return { label: '대기', color: 'text-color-caution bg-[rgba(234,179,8,0.08)]' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── cron 표현식 → 읽기 쉬운 형태 ─────────────────────────
|
// ─── cron 표현식 → 읽기 쉬운 형태 ─────────────────────────
|
||||||
@ -211,13 +211,13 @@ const HEADERS = [
|
|||||||
function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean }) {
|
function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-caption border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
{HEADERS.map((h) => (
|
{HEADERS.map((h) => (
|
||||||
<th
|
<th
|
||||||
key={h}
|
key={h}
|
||||||
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{h}
|
{h}
|
||||||
</th>
|
</th>
|
||||||
@ -227,7 +227,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean })
|
|||||||
<tbody>
|
<tbody>
|
||||||
{loading && rows.length === 0
|
{loading && rows.length === 0
|
||||||
? Array.from({ length: 5 }).map((_, i) => (
|
? Array.from({ length: 5 }).map((_, i) => (
|
||||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
<tr key={i} className="border-b border-stroke animate-pulse">
|
||||||
{HEADERS.map((_, j) => (
|
{HEADERS.map((_, j) => (
|
||||||
<td key={j} className="px-3 py-2">
|
<td key={j} className="px-3 py-2">
|
||||||
<div className="h-3 bg-bg-elevated rounded w-14" />
|
<div className="h-3 bg-bg-elevated rounded w-14" />
|
||||||
@ -240,7 +240,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean })
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={`${row.seq}`}
|
key={`${row.seq}`}
|
||||||
className="border-b border-stroke-1 hover:bg-bg-surface/50"
|
className="border-b border-stroke hover:bg-bg-surface/50"
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2 text-t2 text-center">{idx + 1}</td>
|
<td className="px-3 py-2 text-t2 text-center">{idx + 1}</td>
|
||||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
|
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
|
||||||
@ -258,7 +258,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean })
|
|||||||
<span
|
<span
|
||||||
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${
|
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${
|
||||||
row.activeYn === 'Y'
|
row.activeYn === 'Y'
|
||||||
? 'text-emerald-400 bg-emerald-500/10'
|
? 'text-color-success bg-[rgba(34,197,94,0.08)]'
|
||||||
: 'text-t3 bg-bg-elevated'
|
: 'text-t3 bg-bg-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -316,11 +316,11 @@ export default function CollectHrPanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
|
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke shrink-0">
|
||||||
<h2 className="text-sm font-semibold text-t1">인사정보 수집 현황</h2>
|
<h2 className="text-body-2 font-semibold text-t1">인사정보 수집 현황</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{lastUpdate && (
|
{lastUpdate && (
|
||||||
<span className="text-xs text-t3">
|
<span className="text-caption text-t3">
|
||||||
갱신:{' '}
|
갱신:{' '}
|
||||||
{lastUpdate.toLocaleTimeString('ko-KR', {
|
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@ -332,7 +332,7 @@ export default function CollectHrPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={fetchData}
|
onClick={fetchData}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||||
@ -353,12 +353,12 @@ export default function CollectHrPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 상태 표시줄 */}
|
{/* 상태 표시줄 */}
|
||||||
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
|
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke bg-bg-base">
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-500/10 text-emerald-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(34,197,94,0.08)] text-color-success">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-color-success" />
|
||||||
수집 완료 {completedCount}건
|
수집 완료 {completedCount}건
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-t3">
|
<span className="text-caption text-t3">
|
||||||
전체 {rows.length}건 (활성: {activeCount} / 비활성: {rows.length - activeCount})
|
전체 {rows.length}건 (활성: {activeCount} / 비활성: {rows.length - activeCount})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
567
frontend/src/components/admin/components/DeidentifyPanel.tsx
Normal file
567
frontend/src/components/admin/components/DeidentifyPanel.tsx
Normal file
@ -0,0 +1,567 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { TaskTable } from './contents/TaskTable';
|
||||||
|
import { AuditLogModal } from './contents/AuditLogModal';
|
||||||
|
import { WizardModal } from './contents/WizardModal';
|
||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
|
||||||
|
// ─── 타입 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type TaskStatus = '완료' | '진행중' | '대기' | '오류';
|
||||||
|
|
||||||
|
export interface AuditLogEntry {
|
||||||
|
id: string;
|
||||||
|
time: string;
|
||||||
|
operator: string;
|
||||||
|
operatorId: string;
|
||||||
|
action: string;
|
||||||
|
targetData: string;
|
||||||
|
result: string;
|
||||||
|
resultType: '성공' | '실패' | '거부' | '진행중';
|
||||||
|
ip: string;
|
||||||
|
browser: string;
|
||||||
|
detail: {
|
||||||
|
dataCount: number;
|
||||||
|
rulesApplied: string;
|
||||||
|
processedCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeidentifyTask {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
target: string;
|
||||||
|
status: TaskStatus;
|
||||||
|
startTime: string;
|
||||||
|
progress: number;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SourceType = 'db' | 'file' | 'api';
|
||||||
|
export type ProcessMode = 'immediate' | 'scheduled' | 'oneshot';
|
||||||
|
export type RepeatType = 'daily' | 'weekly' | 'monthly';
|
||||||
|
export type DeidentifyTechnique = '마스킹' | '삭제' | '범주화' | '암호화' | '샘플링' | '가명처리' | '유지';
|
||||||
|
|
||||||
|
export interface FieldConfig {
|
||||||
|
name: string;
|
||||||
|
dataType: string;
|
||||||
|
technique: DeidentifyTechnique;
|
||||||
|
configValue: string;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbConfig {
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
database: string;
|
||||||
|
tableName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiConfig {
|
||||||
|
url: string;
|
||||||
|
method: 'GET' | 'POST';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleConfig {
|
||||||
|
hour: string;
|
||||||
|
repeatType: RepeatType;
|
||||||
|
weekday: string;
|
||||||
|
startDate: string;
|
||||||
|
notifyOnComplete: boolean;
|
||||||
|
notifyOnError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneshotConfig {
|
||||||
|
date: string;
|
||||||
|
hour: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WizardState {
|
||||||
|
step: number;
|
||||||
|
taskName: string;
|
||||||
|
sourceType: SourceType;
|
||||||
|
dbConfig: DbConfig;
|
||||||
|
apiConfig: ApiConfig;
|
||||||
|
fields: FieldConfig[];
|
||||||
|
processMode: ProcessMode;
|
||||||
|
scheduleConfig: ScheduleConfig;
|
||||||
|
oneshotConfig: OneshotConfig;
|
||||||
|
saveAsTemplate: boolean;
|
||||||
|
applyTemplate: string;
|
||||||
|
confirmed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mock 데이터 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const MOCK_TASKS: DeidentifyTask[] = [
|
||||||
|
{
|
||||||
|
id: '001',
|
||||||
|
name: 'customer_2024',
|
||||||
|
target: '선박/운항 - 선장·선원 성명',
|
||||||
|
status: '완료',
|
||||||
|
startTime: '2026-04-10 14:30',
|
||||||
|
progress: 100,
|
||||||
|
createdBy: '관리자',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '002',
|
||||||
|
name: 'transaction_04',
|
||||||
|
target: '사고 현장 - 현장사진, 영상내 인물',
|
||||||
|
status: '진행중',
|
||||||
|
startTime: '2026-04-10 14:15',
|
||||||
|
progress: 82,
|
||||||
|
createdBy: '김담당',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '003',
|
||||||
|
name: 'employee_info',
|
||||||
|
target: '인사정보 - 계정, 로그인 정보',
|
||||||
|
status: '대기',
|
||||||
|
startTime: '2026-04-10 22:00',
|
||||||
|
progress: 0,
|
||||||
|
createdBy: '이담당',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '004',
|
||||||
|
name: 'vendor_data',
|
||||||
|
target: 'HNS 대응 - 화학물질 취급자, 방제업체 연락처',
|
||||||
|
status: '오류',
|
||||||
|
startTime: '2026-04-09 13:45',
|
||||||
|
progress: 45,
|
||||||
|
createdBy: '관리자',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '005',
|
||||||
|
name: 'partner_contacts',
|
||||||
|
target: '시스템 운영 - 관리자, 운영자 접속로그',
|
||||||
|
status: '완료',
|
||||||
|
startTime: '2026-04-08 09:00',
|
||||||
|
progress: 100,
|
||||||
|
createdBy: '박담당',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_FIELDS: FieldConfig[] = [
|
||||||
|
{ name: '고객ID', dataType: '문자열', technique: '삭제', configValue: '-', selected: true },
|
||||||
|
{
|
||||||
|
name: '이름',
|
||||||
|
dataType: '문자열',
|
||||||
|
technique: '마스킹',
|
||||||
|
configValue: '*로 치환',
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '휴대폰',
|
||||||
|
dataType: '문자열',
|
||||||
|
technique: '마스킹',
|
||||||
|
configValue: '010-****-****',
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '주소',
|
||||||
|
dataType: '문자열',
|
||||||
|
technique: '범주화',
|
||||||
|
configValue: '시/도만 표시',
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '이메일',
|
||||||
|
dataType: '문자열',
|
||||||
|
technique: '가명처리',
|
||||||
|
configValue: '키: random_001',
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '생년월일',
|
||||||
|
dataType: '날짜',
|
||||||
|
technique: '범주화',
|
||||||
|
configValue: '연도만 표시',
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
{ name: '회사', dataType: '문자열', technique: '유지', configValue: '변경 없음', selected: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TECHNIQUES: DeidentifyTechnique[] = [
|
||||||
|
'마스킹',
|
||||||
|
'삭제',
|
||||||
|
'범주화',
|
||||||
|
'암호화',
|
||||||
|
'샘플링',
|
||||||
|
'가명처리',
|
||||||
|
'유지',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const HOURS = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`);
|
||||||
|
|
||||||
|
export const WEEKDAYS = ['월', '화', '수', '목', '금', '토', '일'];
|
||||||
|
|
||||||
|
export const TEMPLATES = ['기본 개인정보', '금융데이터', '의료데이터'];
|
||||||
|
|
||||||
|
export const MOCK_AUDIT_LOGS: Record<string, AuditLogEntry[]> = {
|
||||||
|
'001': [
|
||||||
|
{
|
||||||
|
id: 'LOG_20260410_001',
|
||||||
|
time: '2026-04-10 14:30:45',
|
||||||
|
operator: '김철수',
|
||||||
|
operatorId: 'user_12345',
|
||||||
|
action: '처리완료',
|
||||||
|
targetData: 'customer_2024',
|
||||||
|
result: '성공 (100%)',
|
||||||
|
resultType: '성공',
|
||||||
|
ip: '192.168.1.100',
|
||||||
|
browser: 'Chrome 123.0',
|
||||||
|
detail: {
|
||||||
|
dataCount: 15240,
|
||||||
|
rulesApplied: '마스킹 3, 범주화 2, 삭제 2',
|
||||||
|
processedCount: 15240,
|
||||||
|
errorCount: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'LOG_20260410_002',
|
||||||
|
time: '2026-04-10 14:15:10',
|
||||||
|
operator: '김철수',
|
||||||
|
operatorId: 'user_12345',
|
||||||
|
action: '처리시작',
|
||||||
|
targetData: 'customer_2024',
|
||||||
|
result: '성공',
|
||||||
|
resultType: '성공',
|
||||||
|
ip: '192.168.1.100',
|
||||||
|
browser: 'Chrome 123.0',
|
||||||
|
detail: {
|
||||||
|
dataCount: 15240,
|
||||||
|
rulesApplied: '마스킹 3, 범주화 2, 삭제 2',
|
||||||
|
processedCount: 0,
|
||||||
|
errorCount: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'LOG_20260410_003',
|
||||||
|
time: '2026-04-10 14:10:30',
|
||||||
|
operator: '김철수',
|
||||||
|
operatorId: 'user_12345',
|
||||||
|
action: '규칙설정',
|
||||||
|
targetData: 'customer_2024',
|
||||||
|
result: '성공',
|
||||||
|
resultType: '성공',
|
||||||
|
ip: '192.168.1.100',
|
||||||
|
browser: 'Chrome 123.0',
|
||||||
|
detail: { dataCount: 15240, rulesApplied: '7개 규칙 적용', processedCount: 0, errorCount: 0 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'002': [
|
||||||
|
{
|
||||||
|
id: 'LOG_20260410_004',
|
||||||
|
time: '2026-04-10 14:15:22',
|
||||||
|
operator: '이영희',
|
||||||
|
operatorId: 'user_23456',
|
||||||
|
action: '처리시작',
|
||||||
|
targetData: 'transaction_04',
|
||||||
|
result: '진행중 (82%)',
|
||||||
|
resultType: '진행중',
|
||||||
|
ip: '192.168.1.101',
|
||||||
|
browser: 'Firefox 124.0',
|
||||||
|
detail: {
|
||||||
|
dataCount: 8920,
|
||||||
|
rulesApplied: '마스킹 2, 암호화 1, 삭제 3',
|
||||||
|
processedCount: 7314,
|
||||||
|
errorCount: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'003': [
|
||||||
|
{
|
||||||
|
id: 'LOG_20260410_005',
|
||||||
|
time: '2026-04-10 13:45:30',
|
||||||
|
operator: '박민준',
|
||||||
|
operatorId: 'user_34567',
|
||||||
|
action: '규칙수정',
|
||||||
|
targetData: 'employee_info',
|
||||||
|
result: '성공',
|
||||||
|
resultType: '성공',
|
||||||
|
ip: '192.168.1.102',
|
||||||
|
browser: 'Chrome 123.0',
|
||||||
|
detail: {
|
||||||
|
dataCount: 3200,
|
||||||
|
rulesApplied: '마스킹 4, 가명처리 1',
|
||||||
|
processedCount: 0,
|
||||||
|
errorCount: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'004': [
|
||||||
|
{
|
||||||
|
id: 'LOG_20260409_001',
|
||||||
|
time: '2026-04-09 13:45:30',
|
||||||
|
operator: '관리자',
|
||||||
|
operatorId: 'user_admin',
|
||||||
|
action: '처리오류',
|
||||||
|
targetData: 'vendor_data',
|
||||||
|
result: '오류 (45%)',
|
||||||
|
resultType: '실패',
|
||||||
|
ip: '192.168.1.100',
|
||||||
|
browser: 'Chrome 123.0',
|
||||||
|
detail: {
|
||||||
|
dataCount: 5100,
|
||||||
|
rulesApplied: '마스킹 2, 범주화 1, 삭제 1',
|
||||||
|
processedCount: 2295,
|
||||||
|
errorCount: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'LOG_20260409_002',
|
||||||
|
time: '2026-04-09 13:40:15',
|
||||||
|
operator: '김철수',
|
||||||
|
operatorId: 'user_12345',
|
||||||
|
action: '규칙조회',
|
||||||
|
targetData: 'vendor_data',
|
||||||
|
result: '성공',
|
||||||
|
resultType: '성공',
|
||||||
|
ip: '192.168.1.100',
|
||||||
|
browser: 'Chrome 123.0',
|
||||||
|
detail: { dataCount: 5100, rulesApplied: '4개 규칙', processedCount: 0, errorCount: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'LOG_20260409_003',
|
||||||
|
time: '2026-04-09 09:25:00',
|
||||||
|
operator: '이영희',
|
||||||
|
operatorId: 'user_23456',
|
||||||
|
action: '삭제시도',
|
||||||
|
targetData: 'vendor_data',
|
||||||
|
result: '거부 (권한부족)',
|
||||||
|
resultType: '거부',
|
||||||
|
ip: '192.168.1.101',
|
||||||
|
browser: 'Firefox 124.0',
|
||||||
|
detail: { dataCount: 5100, rulesApplied: '-', processedCount: 0, errorCount: 0 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'005': [
|
||||||
|
{
|
||||||
|
id: 'LOG_20260408_001',
|
||||||
|
time: '2026-04-08 09:15:00',
|
||||||
|
operator: '박담당',
|
||||||
|
operatorId: 'user_45678',
|
||||||
|
action: '처리완료',
|
||||||
|
targetData: 'partner_contacts',
|
||||||
|
result: '성공 (100%)',
|
||||||
|
resultType: '성공',
|
||||||
|
ip: '192.168.1.103',
|
||||||
|
browser: 'Edge 122.0',
|
||||||
|
detail: {
|
||||||
|
dataCount: 1850,
|
||||||
|
rulesApplied: '마스킹 2, 유지 3',
|
||||||
|
processedCount: 1850,
|
||||||
|
errorCount: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function fetchTasks(): Promise<DeidentifyTask[]> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => resolve(MOCK_TASKS), 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 상태 뱃지 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getStatusBadgeClass(status: TaskStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case '완료':
|
||||||
|
return 'text-color-success bg-[rgba(34,197,94,0.1)]';
|
||||||
|
case '진행중':
|
||||||
|
return 'text-color-accent bg-[rgba(6,182,212,0.1)]';
|
||||||
|
case '대기':
|
||||||
|
return 'text-color-caution bg-[rgba(234,179,8,0.1)]';
|
||||||
|
case '오류':
|
||||||
|
return 'text-color-danger bg-[rgba(239,68,68,0.1)]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 진행률 바 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const TABLE_HEADERS = ['작업ID', '작업명', '대상', '상태', '시작시간', '진행률', '등록자', '액션'];
|
||||||
|
|
||||||
|
export const STEP_LABELS = ['소스선택', '데이터검증', '비식별화규칙', '처리방식', '최종확인'];
|
||||||
|
|
||||||
|
export const INITIAL_WIZARD: WizardState = {
|
||||||
|
step: 1,
|
||||||
|
taskName: '',
|
||||||
|
sourceType: 'db',
|
||||||
|
dbConfig: { host: '', port: '5432', database: '', tableName: '' },
|
||||||
|
apiConfig: { url: '', method: 'GET' },
|
||||||
|
fields: DEFAULT_FIELDS,
|
||||||
|
processMode: 'immediate',
|
||||||
|
scheduleConfig: {
|
||||||
|
hour: '02:00',
|
||||||
|
repeatType: 'daily',
|
||||||
|
weekday: '월',
|
||||||
|
startDate: '',
|
||||||
|
notifyOnComplete: true,
|
||||||
|
notifyOnError: true,
|
||||||
|
},
|
||||||
|
oneshotConfig: { date: '', hour: '02:00' },
|
||||||
|
saveAsTemplate: false,
|
||||||
|
applyTemplate: '',
|
||||||
|
confirmed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 메인 패널 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
type FilterStatus = '모두' | TaskStatus;
|
||||||
|
|
||||||
|
export default function DeidentifyPanel() {
|
||||||
|
const [tasks, setTasks] = useState<DeidentifyTask[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
|
const [auditTask, setAuditTask] = useState<DeidentifyTask | null>(null);
|
||||||
|
const [searchName, setSearchName] = useState('');
|
||||||
|
const [filterStatus, setFilterStatus] = useState<FilterStatus>('모두');
|
||||||
|
const [filterPeriod, setFilterPeriod] = useState<'7' | '30' | '90'>('30');
|
||||||
|
|
||||||
|
const loadTasks = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await fetchTasks();
|
||||||
|
setTasks(data);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
void Promise.resolve().then(() => {
|
||||||
|
if (isMounted) void loadTasks();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [tasks.length, loadTasks]);
|
||||||
|
|
||||||
|
const handleAction = useCallback((action: string, task: DeidentifyTask) => {
|
||||||
|
// TODO: 실제 API 연동 시 각 액션에 맞는 API 호출로 교체
|
||||||
|
if (action === 'delete') {
|
||||||
|
setTasks((prev) => prev.filter((t) => t.id !== task.id));
|
||||||
|
} else if (action === 'audit') {
|
||||||
|
setAuditTask(task);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleWizardSubmit = useCallback(
|
||||||
|
(wizard: WizardState) => {
|
||||||
|
const selectedFields = wizard.fields.filter((f) => f.selected).map((f) => f.name);
|
||||||
|
const newTask: DeidentifyTask = {
|
||||||
|
id: String(tasks.length + 1).padStart(3, '0'),
|
||||||
|
name: wizard.taskName,
|
||||||
|
target: selectedFields.join(', ') || '-',
|
||||||
|
status: wizard.processMode === 'immediate' ? '진행중' : '대기',
|
||||||
|
startTime: new Date()
|
||||||
|
.toLocaleString('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
.replace(/\. /g, '-')
|
||||||
|
.replace('.', ''),
|
||||||
|
progress: 0,
|
||||||
|
createdBy: '관리자',
|
||||||
|
};
|
||||||
|
setTasks((prev) => [newTask, ...prev]);
|
||||||
|
},
|
||||||
|
[tasks.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredTasks = tasks.filter((t) => {
|
||||||
|
if (searchName && !t.name.includes(searchName)) return false;
|
||||||
|
if (filterStatus !== '모두' && t.status !== filterStatus) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedCount = tasks.filter((t) => t.status === '완료').length;
|
||||||
|
const inProgressCount = tasks.filter((t) => t.status === '진행중').length;
|
||||||
|
const errorCount = tasks.filter((t) => t.status === '오류').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke shrink-0">
|
||||||
|
<h2 className="text-body-2 font-semibold text-t1">비식별화조치</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowWizard(true)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-color-accent hover:bg-color-accent-muted text-white transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
새 작업
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 요약 */}
|
||||||
|
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke bg-bg-base">
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(34,197,94,0.1)] text-color-success">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-color-success" />
|
||||||
|
완료 {completedCount}건
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(6,182,212,0.1)] text-color-accent">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-color-accent" />
|
||||||
|
진행중 {inProgressCount}건
|
||||||
|
</span>
|
||||||
|
{errorCount > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(239,68,68,0.1)] text-color-danger">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-color-danger" />
|
||||||
|
오류 {errorCount}건
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-caption text-t3">전체 {tasks.length}건</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색/필터 */}
|
||||||
|
<div className="flex items-center gap-2 px-5 py-2.5 shrink-0 border-b border-stroke">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchName}
|
||||||
|
onChange={(e) => setSearchName(e.target.value)}
|
||||||
|
placeholder="작업명 검색"
|
||||||
|
className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 placeholder:text-t3 focus:outline-none focus:border-color-accent w-40"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={(e) => setFilterStatus(e.target.value as FilterStatus)}
|
||||||
|
className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
|
||||||
|
>
|
||||||
|
{(['모두', '완료', '진행중', '대기', '오류'] as FilterStatus[]).map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={filterPeriod}
|
||||||
|
onChange={(e) => setFilterPeriod(e.target.value as '7' | '30' | '90')}
|
||||||
|
className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
|
||||||
|
>
|
||||||
|
<option value="7">최근 7일</option>
|
||||||
|
<option value="30">최근 30일</option>
|
||||||
|
<option value="90">최근 90일</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto p-5">
|
||||||
|
<TaskTable rows={filteredTasks} loading={loading} onAction={handleAction} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 감사로그 모달 */}
|
||||||
|
{auditTask && <AuditLogModal task={auditTask} onClose={() => setAuditTask(null)} />}
|
||||||
|
|
||||||
|
{/* 마법사 모달 */}
|
||||||
|
{showWizard && (
|
||||||
|
<WizardModal onClose={() => setShowWizard(false)} onSubmit={handleWizardSubmit} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@ import { GeoJsonLayer } from '@deck.gl/layers';
|
|||||||
import type { Layer } from '@deck.gl/core';
|
import type { Layer } from '@deck.gl/core';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||||||
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
|
import { S57EncOverlay } from '@components/common/map/S57EncOverlay';
|
||||||
import { useMapStore } from '@common/store/mapStore';
|
import { useMapStore } from '@common/store/mapStore';
|
||||||
|
|
||||||
const MAP_CENTER: [number, number] = [127.5, 36.0];
|
const MAP_CENTER: [number, number] = [127.5, 36.0];
|
||||||
@ -119,7 +119,7 @@ const DispersingZonePanel = () => {
|
|||||||
const isConsider = zone === 'consider';
|
const isConsider = zone === 'consider';
|
||||||
const showLayer = isConsider ? showConsider : showRestrict;
|
const showLayer = isConsider ? showConsider : showRestrict;
|
||||||
const setShowLayer = isConsider ? setShowConsider : setShowRestrict;
|
const setShowLayer = isConsider ? setShowConsider : setShowRestrict;
|
||||||
const swatchColor = isConsider ? 'bg-blue-500' : 'bg-red-500';
|
const swatchColor = isConsider ? 'bg-color-info' : 'bg-color-danger';
|
||||||
const isExpanded = expandedZone === zone;
|
const isExpanded = expandedZone === zone;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -130,7 +130,9 @@ const DispersingZonePanel = () => {
|
|||||||
onClick={() => handleToggleExpand(zone)}
|
onClick={() => handleToggleExpand(zone)}
|
||||||
>
|
>
|
||||||
<span className={`w-3 h-3 rounded-sm shrink-0 ${swatchColor}`} />
|
<span className={`w-3 h-3 rounded-sm shrink-0 ${swatchColor}`} />
|
||||||
<span className="flex-1 text-xs font-semibold text-fg font-korean">{info.label}</span>
|
<span className="flex-1 text-caption font-semibold text-fg font-korean">
|
||||||
|
{info.label}
|
||||||
|
</span>
|
||||||
{/* 토글 스위치 */}
|
{/* 토글 스위치 */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@ -195,11 +197,11 @@ const DispersingZonePanel = () => {
|
|||||||
{/* 범례 */}
|
{/* 범례 */}
|
||||||
<div className="absolute bottom-4 left-4 bg-bg-surface border border-stroke rounded-lg px-3 py-2 flex flex-col gap-1.5">
|
<div className="absolute bottom-4 left-4 bg-bg-surface border border-stroke rounded-lg px-3 py-2 flex flex-col gap-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-3 h-3 rounded-sm bg-blue-500 opacity-80" />
|
<span className="w-3 h-3 rounded-sm bg-color-info opacity-80" />
|
||||||
<span className="text-label-2 text-fg-sub font-korean">사용고려해역</span>
|
<span className="text-label-2 text-fg-sub font-korean">사용고려해역</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-3 h-3 rounded-sm bg-red-500 opacity-80" />
|
<span className="w-3 h-3 rounded-sm bg-color-danger opacity-80" />
|
||||||
<span className="text-label-2 text-fg-sub font-korean">사용제한해역</span>
|
<span className="text-label-2 text-fg-sub font-korean">사용제한해역</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -209,7 +211,7 @@ const DispersingZonePanel = () => {
|
|||||||
<div className="w-[280px] bg-bg-surface border-l border-stroke flex flex-col overflow-hidden shrink-0">
|
<div className="w-[280px] bg-bg-surface border-l border-stroke flex flex-col overflow-hidden shrink-0">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="px-4 py-4 border-b border-stroke shrink-0">
|
<div className="px-4 py-4 border-b border-stroke shrink-0">
|
||||||
<h1 className="text-sm font-bold text-fg font-korean">유처리제 제한구역</h1>
|
<h1 className="text-body-2 font-bold text-fg font-korean">유처리제 제한구역</h1>
|
||||||
<p className="text-label-2 text-fg-disabled mt-0.5 font-korean">해양환경관리법 기준</p>
|
<p className="text-label-2 text-fg-disabled mt-0.5 font-korean">해양환경관리법 기준</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -186,7 +186,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
|||||||
};
|
};
|
||||||
|
|
||||||
const inputCls =
|
const inputCls =
|
||||||
'w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none';
|
'w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none';
|
||||||
const labelCls = 'block text-label-2 font-semibold text-fg-sub font-korean mb-1.5';
|
const labelCls = 'block text-label-2 font-semibold text-fg-sub font-korean mb-1.5';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -194,7 +194,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
|||||||
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
|
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke shrink-0">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke shrink-0">
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">
|
<h2 className="text-body-2 font-bold text-fg font-korean">
|
||||||
{mode === 'create' ? '레이어 등록' : '레이어 수정'}
|
{mode === 'create' ? '레이어 등록' : '레이어 수정'}
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
|
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
|
||||||
@ -214,7 +214,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
|||||||
? handleParentChange(e.target.value)
|
? handleParentChange(e.target.value)
|
||||||
: handleField('upLayerCd', e.target.value)
|
: handleField('upLayerCd', e.target.value)
|
||||||
}
|
}
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">(없음)</option>
|
<option value="">(없음)</option>
|
||||||
{options
|
{options
|
||||||
@ -229,7 +229,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
|||||||
{/* 레이어코드 */}
|
{/* 레이어코드 */}
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>
|
<label className={labelCls}>
|
||||||
레이어코드 <span className="text-red-400">*</span>
|
레이어코드 <span className="text-color-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -243,7 +243,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
|||||||
{/* 레이어명 */}
|
{/* 레이어명 */}
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>
|
<label className={labelCls}>
|
||||||
레이어명 <span className="text-red-400">*</span>
|
레이어명 <span className="text-color-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -261,7 +261,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
|||||||
{/* 레이어전체명 */}
|
{/* 레이어전체명 */}
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>
|
<label className={labelCls}>
|
||||||
레이어전체명 <span className="text-red-400">*</span>
|
레이어전체명 <span className="text-color-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -311,7 +311,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
|||||||
<select
|
<select
|
||||||
value={form.useYn}
|
value={form.useYn}
|
||||||
onChange={(e) => handleField('useYn', e.target.value)}
|
onChange={(e) => handleField('useYn', e.target.value)}
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="Y">사용</option>
|
<option value="Y">사용</option>
|
||||||
<option value="N">미사용</option>
|
<option value="N">미사용</option>
|
||||||
@ -321,7 +321,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
|||||||
{/* 에러 */}
|
{/* 에러 */}
|
||||||
{formError && (
|
{formError && (
|
||||||
<div className="px-6 pb-2">
|
<div className="px-6 pb-2">
|
||||||
<p className="text-label-2 text-red-400 font-korean">{formError}</p>
|
<p className="text-label-2 text-color-danger font-korean">{formError}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* 버튼 */}
|
{/* 버튼 */}
|
||||||
@ -329,14 +329,14 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-3 py-1.5 text-xs border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
className="px-3 py-1.5 text-caption border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="px-3 py-1.5 text-xs bg-color-accent text-bg-0 rounded hover:opacity-90 disabled:opacity-50 transition-all font-korean"
|
className="px-3 py-1.5 text-caption bg-color-accent text-bg-0 rounded hover:opacity-90 disabled:opacity-50 transition-all font-korean"
|
||||||
>
|
>
|
||||||
{saving ? '저장 중...' : mode === 'create' ? '등록' : '저장'}
|
{saving ? '저장 중...' : mode === 'create' ? '등록' : '저장'}
|
||||||
</button>
|
</button>
|
||||||
@ -448,12 +448,12 @@ const LayerPanel = () => {
|
|||||||
<div className="px-6 py-4 border-b border-stroke shrink-0">
|
<div className="px-6 py-4 border-b border-stroke shrink-0">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-bold text-fg font-korean">레이어 관리</h1>
|
<h1 className="text-title-1 font-bold text-fg font-korean">레이어 관리</h1>
|
||||||
<p className="text-xs text-fg-disabled mt-1 font-korean">총 {total}개</p>
|
<p className="text-caption text-fg-disabled mt-1 font-korean">총 {total}개</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setModal({ mode: 'create' })}
|
onClick={() => setModal({ mode: 'create' })}
|
||||||
className="px-3 py-1.5 text-xs font-semibold bg-color-accent text-bg-0 rounded hover:opacity-90 transition-opacity font-korean"
|
className="px-3 py-1.5 text-caption font-semibold bg-color-accent text-bg-0 rounded hover:opacity-90 transition-opacity font-korean"
|
||||||
>
|
>
|
||||||
신규 등록
|
신규 등록
|
||||||
</button>
|
</button>
|
||||||
@ -465,12 +465,12 @@ const LayerPanel = () => {
|
|||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
placeholder="레이어코드 / 레이어명 검색"
|
placeholder="레이어코드 / 레이어명 검색"
|
||||||
className="flex-1 px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
className="flex-1 px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={filterUseYn}
|
value={filterUseYn}
|
||||||
onChange={(e) => setFilterUseYn(e.target.value)}
|
onChange={(e) => setFilterUseYn(e.target.value)}
|
||||||
className="px-2 py-1.5 text-xs bg-bg-elevated border border-stroke rounded text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="px-2 py-1.5 text-caption bg-bg-elevated border border-stroke rounded text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="">전체</option>
|
<option value="">전체</option>
|
||||||
<option value="Y">사용</option>
|
<option value="Y">사용</option>
|
||||||
@ -478,7 +478,7 @@ const LayerPanel = () => {
|
|||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
onClick={handleSearch}
|
onClick={handleSearch}
|
||||||
className="px-3 py-1.5 text-xs border border-stroke text-fg-sub rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
className="px-3 py-1.5 text-caption border border-stroke text-fg-sub rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||||
>
|
>
|
||||||
검색
|
검색
|
||||||
</button>
|
</button>
|
||||||
@ -487,7 +487,7 @@ const LayerPanel = () => {
|
|||||||
|
|
||||||
{/* 오류 메시지 */}
|
{/* 오류 메시지 */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="px-6 py-2 text-xs text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-stroke shrink-0 font-korean">
|
<div className="px-6 py-2 text-caption text-color-danger bg-[rgba(239,68,68,0.08)] border-b border-stroke shrink-0 font-korean">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -495,7 +495,7 @@ const LayerPanel = () => {
|
|||||||
{/* 테이블 영역 */}
|
{/* 테이블 영역 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-full text-fg-disabled text-sm font-korean">
|
<div className="flex items-center justify-center h-full text-fg-disabled text-body-2 font-korean">
|
||||||
불러오는 중...
|
불러오는 중...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -539,7 +539,7 @@ const LayerPanel = () => {
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={10}
|
colSpan={10}
|
||||||
className="px-4 py-12 text-center text-fg-disabled text-sm font-korean"
|
className="px-4 py-12 text-center text-fg-disabled text-body-2 font-korean"
|
||||||
>
|
>
|
||||||
데이터가 없습니다.
|
데이터가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
@ -551,15 +551,15 @@ const LayerPanel = () => {
|
|||||||
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||||
>
|
>
|
||||||
{/* 번호 */}
|
{/* 번호 */}
|
||||||
<td className="px-4 py-3 text-xs text-fg-disabled font-mono">
|
<td className="px-4 py-3 text-caption text-fg-disabled font-mono">
|
||||||
{(page - 1) * PAGE_SIZE + idx + 1}
|
{(page - 1) * PAGE_SIZE + idx + 1}
|
||||||
</td>
|
</td>
|
||||||
{/* 레이어코드 */}
|
{/* 레이어코드 */}
|
||||||
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">{item.layerCd}</td>
|
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">{item.layerCd}</td>
|
||||||
{/* 레이어명 */}
|
{/* 레이어명 */}
|
||||||
<td className="px-4 py-3 text-xs text-fg font-korean">{item.layerNm}</td>
|
<td className="px-4 py-3 text-caption text-fg font-korean">{item.layerNm}</td>
|
||||||
{/* 레이어전체명 */}
|
{/* 레이어전체명 */}
|
||||||
<td className="px-4 py-3 text-xs text-fg-sub font-korean max-w-[200px]">
|
<td className="px-4 py-3 text-caption text-fg-sub font-korean max-w-[200px]">
|
||||||
<span className="block truncate" title={item.layerFullNm}>
|
<span className="block truncate" title={item.layerFullNm}>
|
||||||
{item.layerFullNm}
|
{item.layerFullNm}
|
||||||
</span>
|
</span>
|
||||||
@ -575,7 +575,7 @@ const LayerPanel = () => {
|
|||||||
{item.wmsLayerNm ?? <span className="text-fg-disabled">-</span>}
|
{item.wmsLayerNm ?? <span className="text-fg-disabled">-</span>}
|
||||||
</td>
|
</td>
|
||||||
{/* 정렬순서 */}
|
{/* 정렬순서 */}
|
||||||
<td className="px-4 py-3 text-xs text-fg-disabled text-center font-mono">
|
<td className="px-4 py-3 text-caption text-fg-disabled text-center font-mono">
|
||||||
{item.sortOrd}
|
{item.sortOrd}
|
||||||
</td>
|
</td>
|
||||||
{/* 등록일시 */}
|
{/* 등록일시 */}
|
||||||
@ -598,7 +598,7 @@ const LayerPanel = () => {
|
|||||||
item.useYn === 'Y' && item.parentUseYn !== 'N'
|
item.useYn === 'Y' && item.parentUseYn !== 'N'
|
||||||
? 'bg-color-accent'
|
? 'bg-color-accent'
|
||||||
: item.useYn === 'Y' && item.parentUseYn === 'N'
|
: item.useYn === 'Y' && item.parentUseYn === 'N'
|
||||||
? 'bg-[rgba(6,182,212,0.4)]'
|
? 'bg-[rgba(6,182,212,0.3)]'
|
||||||
: 'bg-[rgba(255,255,255,0.08)] border border-stroke'
|
: 'bg-[rgba(255,255,255,0.08)] border border-stroke'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -614,13 +614,13 @@ const LayerPanel = () => {
|
|||||||
<div className="flex items-center justify-center gap-1.5 flex-nowrap">
|
<div className="flex items-center justify-center gap-1.5 flex-nowrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => setModal({ mode: 'edit', data: item })}
|
onClick={() => setModal({ mode: 'edit', data: item })}
|
||||||
className="px-3 py-1 text-xs rounded bg-[rgba(6,182,212,0.15)] text-color-accent hover:bg-[rgba(6,182,212,0.25)] font-korean whitespace-nowrap"
|
className="px-3 py-1 text-caption rounded bg-[rgba(6,182,212,0.15)] text-color-accent hover:bg-[rgba(6,182,212,0.25)] font-korean whitespace-nowrap"
|
||||||
>
|
>
|
||||||
수정
|
수정
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(item.layerCd)}
|
onClick={() => handleDelete(item.layerCd)}
|
||||||
className="px-3 py-1 text-xs rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 font-korean whitespace-nowrap"
|
className="px-3 py-1 text-caption rounded bg-[rgba(239,68,68,0.15)] text-color-danger hover:bg-[rgba(239,68,68,0.25)] font-korean whitespace-nowrap"
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</button>
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
import { useMapStore } from '@common/store/mapStore';
|
import { useMapStore } from '@common/store/mapStore';
|
||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
|
||||||
// ─── 타입 ─────────────────────────────────────────────────
|
// ─── 타입 ─────────────────────────────────────────────────
|
||||||
interface MapBaseItem {
|
interface MapBaseItem {
|
||||||
@ -78,7 +79,7 @@ function MapBaseModal({
|
|||||||
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[520px] max-h-[90vh] flex flex-col">
|
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[520px] max-h-[90vh] flex flex-col">
|
||||||
{/* 모달 헤더 */}
|
{/* 모달 헤더 */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">
|
<h2 className="text-body-2 font-bold text-fg font-korean">
|
||||||
{isEdit ? '지도 수정' : '지도 등록'}
|
{isEdit ? '지도 수정' : '지도 등록'}
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
|
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
|
||||||
@ -101,21 +102,21 @@ function MapBaseModal({
|
|||||||
{/* 지도 이름 */}
|
{/* 지도 이름 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
||||||
지도 이름 <span className="text-red-400">*</span>
|
지도 이름 <span className="text-color-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.mapNm}
|
value={form.mapNm}
|
||||||
onChange={(e) => setField('mapNm', e.target.value)}
|
onChange={(e) => setField('mapNm', e.target.value)}
|
||||||
placeholder="지도 이름을 입력하세요"
|
placeholder="지도 이름을 입력하세요"
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 지도 키 */}
|
{/* 지도 키 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
||||||
지도 키 <span className="text-red-400">*</span>
|
지도 키 <span className="text-color-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -123,7 +124,7 @@ function MapBaseModal({
|
|||||||
onChange={(e) => setField('mapKey', e.target.value)}
|
onChange={(e) => setField('mapKey', e.target.value)}
|
||||||
placeholder="고유 식별 키 (영문/숫자)"
|
placeholder="고유 식별 키 (영문/숫자)"
|
||||||
disabled={isEdit}
|
disabled={isEdit}
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -135,7 +136,7 @@ function MapBaseModal({
|
|||||||
<select
|
<select
|
||||||
value={form.mapLevelCd}
|
value={form.mapLevelCd}
|
||||||
onChange={(e) => setField('mapLevelCd', e.target.value)}
|
onChange={(e) => setField('mapLevelCd', e.target.value)}
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="">선택</option>
|
<option value="">선택</option>
|
||||||
{MAP_LEVEL_OPTIONS.map((opt) => (
|
{MAP_LEVEL_OPTIONS.map((opt) => (
|
||||||
@ -156,7 +157,7 @@ function MapBaseModal({
|
|||||||
value={form.mapSrc}
|
value={form.mapSrc}
|
||||||
onChange={(e) => setField('mapSrc', e.target.value)}
|
onChange={(e) => setField('mapSrc', e.target.value)}
|
||||||
placeholder="타일 URL 또는 파일 경로"
|
placeholder="타일 URL 또는 파일 경로"
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -170,7 +171,7 @@ function MapBaseModal({
|
|||||||
value={form.mapDc}
|
value={form.mapDc}
|
||||||
onChange={(e) => setField('mapDc', e.target.value)}
|
onChange={(e) => setField('mapDc', e.target.value)}
|
||||||
placeholder="지도에 대한 설명을 입력하세요"
|
placeholder="지도에 대한 설명을 입력하세요"
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean resize-none"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -184,7 +185,7 @@ function MapBaseModal({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setField('useYn', form.useYn === 'Y' ? 'N' : 'Y')}
|
onClick={() => setField('useYn', form.useYn === 'Y' ? 'N' : 'Y')}
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
form.useYn === 'Y' ? 'bg-color-accent' : 'bg-border'
|
form.useYn === 'Y' ? 'bg-color-accent' : 'bg-bg-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@ -193,14 +194,14 @@ function MapBaseModal({
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-fg-sub font-korean">
|
<span className="text-caption text-fg-sub font-korean">
|
||||||
{form.useYn === 'Y' ? '사용' : '미사용'}
|
{form.useYn === 'Y' ? '사용' : '미사용'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 에러 */}
|
{/* 에러 */}
|
||||||
{modalError && <p className="text-label-2 text-red-400 font-korean">{modalError}</p>}
|
{modalError && <p className="text-label-2 text-color-danger font-korean">{modalError}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 모달 푸터 */}
|
{/* 모달 푸터 */}
|
||||||
@ -208,14 +209,14 @@ function MapBaseModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-xs border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
className="px-4 py-2 text-caption border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
|
className="px-4 py-2 text-caption font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
|
||||||
>
|
>
|
||||||
{saving ? '저장 중...' : isEdit ? '수정' : '등록'}
|
{saving ? '저장 중...' : isEdit ? '수정' : '등록'}
|
||||||
</button>
|
</button>
|
||||||
@ -349,12 +350,12 @@ function MapBasePanel() {
|
|||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-bold text-fg font-korean">지도 관리</h1>
|
<h1 className="text-title-1 font-bold text-fg font-korean">지도 관리</h1>
|
||||||
<p className="text-xs text-fg-disabled mt-1 font-korean">총 {total}건</p>
|
<p className="text-caption text-fg-disabled mt-1 font-korean">총 {total}건</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => openModal(null)}
|
onClick={() => openModal(null)}
|
||||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
className="px-4 py-2 text-caption font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
||||||
>
|
>
|
||||||
+ 등록
|
+ 등록
|
||||||
</button>
|
</button>
|
||||||
@ -365,8 +366,8 @@ function MapBasePanel() {
|
|||||||
<div
|
<div
|
||||||
className={`mx-6 mt-2 px-3 py-2 text-label-2 rounded-md font-korean ${
|
className={`mx-6 mt-2 px-3 py-2 text-label-2 rounded-md font-korean ${
|
||||||
message.type === 'success'
|
message.type === 'success'
|
||||||
? 'text-green-400 bg-[rgba(74,222,128,0.08)] border border-[rgba(74,222,128,0.2)]'
|
? 'text-color-success bg-[rgba(34,197,94,0.08)] border border-[rgba(34,197,94,0.2)]'
|
||||||
: 'text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)]'
|
: 'text-color-danger bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{message.text}
|
{message.text}
|
||||||
@ -375,7 +376,7 @@ function MapBasePanel() {
|
|||||||
|
|
||||||
{/* 테이블 영역 */}
|
{/* 테이블 영역 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-caption">
|
||||||
<thead className="sticky top-0 bg-bg-surface z-10">
|
<thead className="sticky top-0 bg-bg-surface z-10">
|
||||||
<tr className="border-b border-stroke text-fg-disabled">
|
<tr className="border-b border-stroke text-fg-disabled">
|
||||||
<th className="w-12 py-3 text-center">번호</th>
|
<th className="w-12 py-3 text-center">번호</th>
|
||||||
@ -419,7 +420,7 @@ function MapBasePanel() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||||
item.useYn === 'Y' ? 'bg-color-accent' : 'bg-border'
|
item.useYn === 'Y' ? 'bg-color-accent' : 'bg-bg-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@ -433,7 +434,7 @@ function MapBasePanel() {
|
|||||||
<td className="py-3 text-center">
|
<td className="py-3 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => openModal(item)}
|
onClick={() => openModal(item)}
|
||||||
className="px-3 py-1 text-xs rounded bg-[rgba(6,182,212,0.15)] text-color-accent hover:bg-[rgba(6,182,212,0.25)]"
|
className="px-3 py-1 text-caption rounded bg-[rgba(6,182,212,0.15)] text-color-accent hover:bg-[rgba(6,182,212,0.25)]"
|
||||||
>
|
>
|
||||||
수정
|
수정
|
||||||
</button>
|
</button>
|
||||||
@ -441,7 +442,7 @@ function MapBasePanel() {
|
|||||||
<td className="py-3 text-center">
|
<td className="py-3 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(item)}
|
onClick={() => handleDelete(item)}
|
||||||
className="px-3 py-1 text-xs rounded bg-red-500/20 text-red-400 hover:bg-red-500/30"
|
className="px-3 py-1 text-caption rounded bg-[rgba(239,68,68,0.15)] text-color-danger hover:bg-[rgba(239,68,68,0.25)]"
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</button>
|
||||||
@ -452,7 +453,7 @@ function MapBasePanel() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{!loading && items.length === 0 && (
|
{!loading && items.length === 0 && (
|
||||||
<div className="flex items-center justify-center h-32 text-xs text-fg-disabled font-korean">
|
<div className="flex items-center justify-center h-32 text-caption text-fg-disabled font-korean">
|
||||||
등록된 지도가 없습니다.
|
등록된 지도가 없습니다.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -464,7 +465,7 @@ function MapBasePanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
className="px-2 py-1 text-xs rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
className="px-2 py-1 text-caption rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
||||||
>
|
>
|
||||||
<
|
<
|
||||||
</button>
|
</button>
|
||||||
@ -476,9 +477,9 @@ function MapBasePanel() {
|
|||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
onClick={() => setPage(p)}
|
onClick={() => setPage(p)}
|
||||||
className={`w-7 h-7 text-xs rounded ${
|
className={`w-7 h-7 text-caption rounded ${
|
||||||
p === page
|
p === page
|
||||||
? 'bg-blue-500/20 text-blue-400 font-medium'
|
? 'bg-[rgba(6,182,212,0.15)] text-color-accent font-medium'
|
||||||
: 'text-fg-disabled hover:bg-bg-elevated'
|
: 'text-fg-disabled hover:bg-bg-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -489,7 +490,7 @@ function MapBasePanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
disabled={page >= totalPages}
|
disabled={page >= totalPages}
|
||||||
className="px-2 py-1 text-xs rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
className="px-2 py-1 text-caption rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
||||||
>
|
>
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
@ -124,7 +124,7 @@ function MenusPanel() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-fg-disabled text-sm font-korean">메뉴 설정을 불러오는 중...</div>
|
<div className="text-fg-disabled text-body-2 font-korean">메뉴 설정을 불러오는 중...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -135,15 +135,15 @@ function MenusPanel() {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-bold text-fg font-korean">메뉴 관리</h1>
|
<h1 className="text-title-1 font-bold text-fg font-korean">메뉴 관리</h1>
|
||||||
<p className="text-xs text-fg-disabled mt-1 font-korean">
|
<p className="text-caption text-fg-disabled mt-1 font-korean">
|
||||||
메뉴 표시 여부, 순서, 라벨, 아이콘을 관리합니다
|
메뉴 표시 여부, 순서, 라벨, 아이콘을 관리합니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!hasChanges || saving}
|
disabled={!hasChanges || saving}
|
||||||
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean ${
|
className={`px-4 py-2 text-caption font-semibold rounded-md transition-all font-korean ${
|
||||||
hasChanges && !saving
|
hasChanges && !saving
|
||||||
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||||
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
|
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
|
||||||
@ -188,7 +188,7 @@ function MenusPanel() {
|
|||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
{activeMenu ? (
|
{activeMenu ? (
|
||||||
<div className="flex items-center gap-3 px-4 py-3 rounded-md border border-color-accent bg-bg-surface shadow-lg opacity-90 max-w-[700px]">
|
<div className="flex items-center gap-3 px-4 py-3 rounded-md border border-color-accent bg-bg-surface shadow-lg opacity-90 max-w-[700px]">
|
||||||
<span className="text-fg-disabled text-xs">⠿</span>
|
<span className="text-fg-disabled text-caption">⠿</span>
|
||||||
<span className="text-title-2">{activeMenu.icon}</span>
|
<span className="text-title-2">{activeMenu.icon}</span>
|
||||||
<span className="text-title-4 font-semibold text-fg font-korean">
|
<span className="text-title-4 font-semibold text-fg font-korean">
|
||||||
{activeMenu.label}
|
{activeMenu.label}
|
||||||
@ -45,24 +45,24 @@ function formatTime(iso: string | null): string {
|
|||||||
|
|
||||||
function StatusCell({ row }: { row: NumericalDataStatus }) {
|
function StatusCell({ row }: { row: NumericalDataStatus }) {
|
||||||
if (row.lastStatus === 'COMPLETED') {
|
if (row.lastStatus === 'COMPLETED') {
|
||||||
return <span className="text-emerald-400 text-xs">정상</span>;
|
return <span className="text-color-success text-caption">정상</span>;
|
||||||
}
|
}
|
||||||
if (row.lastStatus === 'FAILED') {
|
if (row.lastStatus === 'FAILED') {
|
||||||
return (
|
return (
|
||||||
<span className="text-red-400 text-xs">
|
<span className="text-color-danger text-caption">
|
||||||
오류{row.consecutiveFailures > 0 ? ` (${row.consecutiveFailures}회 연속)` : ''}
|
오류{row.consecutiveFailures > 0 ? ` (${row.consecutiveFailures}회 연속)` : ''}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (row.lastStatus === 'STARTED') {
|
if (row.lastStatus === 'STARTED') {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 text-cyan-400 text-xs">
|
<span className="inline-flex items-center gap-1 text-color-accent text-caption">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
<span className="w-1.5 h-1.5 rounded-full bg-color-accent animate-pulse" />
|
||||||
실행 중
|
실행 중
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <span className="text-t3 text-xs">-</span>;
|
return <span className="text-t3 text-caption">-</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({
|
function StatusBadge({
|
||||||
@ -76,31 +76,31 @@ function StatusBadge({
|
|||||||
}) {
|
}) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-elevated text-t2">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-bg-elevated text-t2">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
<span className="w-1.5 h-1.5 rounded-full bg-color-accent animate-pulse" />
|
||||||
조회 중...
|
조회 중...
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (errorCount === total && total > 0) {
|
if (errorCount === total && total > 0) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-500/10 text-red-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(239,68,68,0.08)] text-color-danger">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-color-danger" />
|
||||||
연계 오류
|
연계 오류
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (errorCount > 0) {
|
if (errorCount > 0) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-yellow-500/10 text-yellow-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(234,179,8,0.08)] text-color-caution">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-color-caution" />
|
||||||
일부 오류 ({errorCount}/{total})
|
일부 오류 ({errorCount}/{total})
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-500/10 text-emerald-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(34,197,94,0.08)] text-color-success">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-color-success" />
|
||||||
정상
|
정상
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@ -118,13 +118,13 @@ const TABLE_HEADERS = [
|
|||||||
function ForecastTable({ rows, loading }: { rows: NumericalDataStatus[]; loading: boolean }) {
|
function ForecastTable({ rows, loading }: { rows: NumericalDataStatus[]; loading: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-caption border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
{TABLE_HEADERS.map((h) => (
|
{TABLE_HEADERS.map((h) => (
|
||||||
<th
|
<th
|
||||||
key={h}
|
key={h}
|
||||||
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{h}
|
{h}
|
||||||
</th>
|
</th>
|
||||||
@ -134,7 +134,7 @@ function ForecastTable({ rows, loading }: { rows: NumericalDataStatus[]; loading
|
|||||||
<tbody>
|
<tbody>
|
||||||
{loading && rows.length === 0
|
{loading && rows.length === 0
|
||||||
? Array.from({ length: 6 }).map((_, i) => (
|
? Array.from({ length: 6 }).map((_, i) => (
|
||||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
<tr key={i} className="border-b border-stroke animate-pulse">
|
||||||
{TABLE_HEADERS.map((_, j) => (
|
{TABLE_HEADERS.map((_, j) => (
|
||||||
<td key={j} className="px-3 py-2">
|
<td key={j} className="px-3 py-2">
|
||||||
<div className="h-3 bg-bg-elevated rounded w-16" />
|
<div className="h-3 bg-bg-elevated rounded w-16" />
|
||||||
@ -143,7 +143,7 @@ function ForecastTable({ rows, loading }: { rows: NumericalDataStatus[]; loading
|
|||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
: rows.map((row) => (
|
: rows.map((row) => (
|
||||||
<tr key={row.jobName} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
<tr key={row.jobName} className="border-b border-stroke hover:bg-bg-surface/50">
|
||||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
|
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
|
||||||
{row.modelName}
|
{row.modelName}
|
||||||
</td>
|
</td>
|
||||||
@ -192,11 +192,11 @@ export default function MonitorForecastPanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
|
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke shrink-0">
|
||||||
<h2 className="text-sm font-semibold text-t1">수치예측자료 모니터링</h2>
|
<h2 className="text-body-2 font-semibold text-t1">수치예측자료 모니터링</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{lastUpdate && (
|
{lastUpdate && (
|
||||||
<span className="text-xs text-t3">
|
<span className="text-caption text-t3">
|
||||||
갱신:{' '}
|
갱신:{' '}
|
||||||
{lastUpdate.toLocaleTimeString('ko-KR', {
|
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@ -208,7 +208,7 @@ export default function MonitorForecastPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => void fetchData()}
|
onClick={() => void fetchData()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||||
@ -229,14 +229,14 @@ export default function MonitorForecastPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 탭 */}
|
{/* 탭 */}
|
||||||
<div className="flex gap-0 border-b border-stroke-1 shrink-0 px-5">
|
<div className="flex gap-0 border-b border-stroke shrink-0 px-5">
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`px-4 py-2.5 text-xs font-medium border-b-2 transition-colors ${
|
className={`px-4 py-2.5 text-caption font-medium border-b-2 transition-colors ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-cyan-400 text-cyan-400'
|
? 'border-color-accent text-color-accent'
|
||||||
: 'border-transparent text-t3 hover:text-t2'
|
: 'border-transparent text-t3 hover:text-t2'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -246,9 +246,11 @@ export default function MonitorForecastPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 상태 표시줄 */}
|
{/* 상태 표시줄 */}
|
||||||
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
|
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke bg-bg-base">
|
||||||
<StatusBadge loading={loading} errorCount={errorCount} total={totalCount} />
|
<StatusBadge loading={loading} errorCount={errorCount} total={totalCount} />
|
||||||
{!loading && totalCount > 0 && <span className="text-xs text-t3">모델 {totalCount}개</span>}
|
{!loading && totalCount > 0 && (
|
||||||
|
<span className="text-caption text-t3">모델 {totalCount}개</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
@ -1,18 +1,17 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import { getRecentObservation, OBS_STATION_CODES } from '@components/weather/services/khoaApi';
|
||||||
getRecentObservation,
|
|
||||||
OBS_STATION_CODES,
|
|
||||||
type RecentObservation,
|
|
||||||
} from '@tabs/weather/services/khoaApi';
|
|
||||||
import {
|
import {
|
||||||
getUltraShortForecast,
|
getUltraShortForecast,
|
||||||
getMarineForecast,
|
getMarineForecast,
|
||||||
convertToGridCoords,
|
convertToGridCoords,
|
||||||
getCurrentBaseDateTime,
|
getCurrentBaseDateTime,
|
||||||
MARINE_REGIONS,
|
MARINE_REGIONS,
|
||||||
type WeatherForecastData,
|
} from '@components/weather/services/weatherApi';
|
||||||
type MarineWeatherData,
|
import type {
|
||||||
} from '@tabs/weather/services/weatherApi';
|
RecentObservation,
|
||||||
|
WeatherForecastData,
|
||||||
|
MarineWeatherData,
|
||||||
|
} from '@interfaces/weather/WeatherInterface';
|
||||||
|
|
||||||
const KEY_TO_NAME: Record<string, string> = {
|
const KEY_TO_NAME: Record<string, string> = {
|
||||||
incheon: '인천',
|
incheon: '인천',
|
||||||
@ -84,31 +83,31 @@ function StatusBadge({
|
|||||||
}) {
|
}) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-elevated text-t2">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-bg-elevated text-t2">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
<span className="w-1.5 h-1.5 rounded-full bg-color-accent animate-pulse" />
|
||||||
조회 중...
|
조회 중...
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (errorCount === total) {
|
if (errorCount === total) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-500/10 text-red-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(239,68,68,0.08)] text-color-danger">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-color-danger" />
|
||||||
연계 오류
|
연계 오류
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (errorCount > 0) {
|
if (errorCount > 0) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-yellow-500/10 text-yellow-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(234,179,8,0.08)] text-color-caution">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-color-caution" />
|
||||||
일부 오류 ({errorCount}/{total})
|
일부 오류 ({errorCount}/{total})
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-500/10 text-emerald-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(34,197,94,0.08)] text-color-success">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-color-success" />
|
||||||
정상
|
정상
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@ -130,13 +129,13 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-caption border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
{headers.map((h) => (
|
{headers.map((h) => (
|
||||||
<th
|
<th
|
||||||
key={h}
|
key={h}
|
||||||
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{h}
|
{h}
|
||||||
</th>
|
</th>
|
||||||
@ -146,7 +145,7 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{loading && rows.length === 0
|
{loading && rows.length === 0
|
||||||
? Array.from({ length: 5 }).map((_, i) => (
|
? Array.from({ length: 5 }).map((_, i) => (
|
||||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
<tr key={i} className="border-b border-stroke animate-pulse">
|
||||||
{headers.map((_, j) => (
|
{headers.map((_, j) => (
|
||||||
<td key={j} className="px-3 py-2">
|
<td key={j} className="px-3 py-2">
|
||||||
<div className="h-3 bg-bg-elevated rounded w-12" />
|
<div className="h-3 bg-bg-elevated rounded w-12" />
|
||||||
@ -157,7 +156,7 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
|
|||||||
: rows.map((row) => (
|
: rows.map((row) => (
|
||||||
<tr
|
<tr
|
||||||
key={row.stationName}
|
key={row.stationName}
|
||||||
className="border-b border-stroke-1 hover:bg-bg-surface/50"
|
className="border-b border-stroke hover:bg-bg-surface/50"
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
|
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
|
||||||
{row.stationName}
|
{row.stationName}
|
||||||
@ -172,11 +171,11 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
|
|||||||
<td className="px-3 py-2 text-t2">{fmt(row.data?.tide_level, 0)}</td>
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.tide_level, 0)}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{row.error ? (
|
{row.error ? (
|
||||||
<span className="text-red-400 text-xs">오류</span>
|
<span className="text-color-danger text-caption">오류</span>
|
||||||
) : row.data ? (
|
) : row.data ? (
|
||||||
<span className="text-emerald-400 text-xs">정상</span>
|
<span className="text-color-success text-caption">정상</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-t3 text-xs">-</span>
|
<span className="text-t3 text-caption">-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -201,13 +200,13 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-caption border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
{headers.map((h) => (
|
{headers.map((h) => (
|
||||||
<th
|
<th
|
||||||
key={h}
|
key={h}
|
||||||
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{h}
|
{h}
|
||||||
</th>
|
</th>
|
||||||
@ -217,7 +216,7 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea
|
|||||||
<tbody>
|
<tbody>
|
||||||
{loading && rows.length === 0
|
{loading && rows.length === 0
|
||||||
? Array.from({ length: 3 }).map((_, i) => (
|
? Array.from({ length: 3 }).map((_, i) => (
|
||||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
<tr key={i} className="border-b border-stroke animate-pulse">
|
||||||
{headers.map((_, j) => (
|
{headers.map((_, j) => (
|
||||||
<td key={j} className="px-3 py-2">
|
<td key={j} className="px-3 py-2">
|
||||||
<div className="h-3 bg-bg-elevated rounded w-12" />
|
<div className="h-3 bg-bg-elevated rounded w-12" />
|
||||||
@ -228,7 +227,7 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea
|
|||||||
: rows.map((row) => (
|
: rows.map((row) => (
|
||||||
<tr
|
<tr
|
||||||
key={row.stationName}
|
key={row.stationName}
|
||||||
className="border-b border-stroke-1 hover:bg-bg-surface/50"
|
className="border-b border-stroke hover:bg-bg-surface/50"
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
|
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
|
||||||
{row.stationName}
|
{row.stationName}
|
||||||
@ -241,11 +240,11 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea
|
|||||||
<td className="px-3 py-2 text-t2">{fmt(row.data?.humidity, 0)}</td>
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.humidity, 0)}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{row.error ? (
|
{row.error ? (
|
||||||
<span className="text-red-400 text-xs">오류</span>
|
<span className="text-color-danger text-caption">오류</span>
|
||||||
) : row.data ? (
|
) : row.data ? (
|
||||||
<span className="text-emerald-400 text-xs">정상</span>
|
<span className="text-color-success text-caption">정상</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-t3 text-xs">-</span>
|
<span className="text-t3 text-caption">-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -261,13 +260,13 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-caption border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
{headers.map((h) => (
|
{headers.map((h) => (
|
||||||
<th
|
<th
|
||||||
key={h}
|
key={h}
|
||||||
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{h}
|
{h}
|
||||||
</th>
|
</th>
|
||||||
@ -277,7 +276,7 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean })
|
|||||||
<tbody>
|
<tbody>
|
||||||
{loading && rows.length === 0
|
{loading && rows.length === 0
|
||||||
? Array.from({ length: 4 }).map((_, i) => (
|
? Array.from({ length: 4 }).map((_, i) => (
|
||||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
<tr key={i} className="border-b border-stroke animate-pulse">
|
||||||
{headers.map((_, j) => (
|
{headers.map((_, j) => (
|
||||||
<td key={j} className="px-3 py-2">
|
<td key={j} className="px-3 py-2">
|
||||||
<div className="h-3 bg-bg-elevated rounded w-14" />
|
<div className="h-3 bg-bg-elevated rounded w-14" />
|
||||||
@ -286,7 +285,7 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean })
|
|||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
: rows.map((row) => (
|
: rows.map((row) => (
|
||||||
<tr key={row.regId} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
<tr key={row.regId} className="border-b border-stroke hover:bg-bg-surface/50">
|
||||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.name}</td>
|
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.name}</td>
|
||||||
<td className="px-3 py-2 text-t2">{fmt(row.data?.waveHeight)}</td>
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.waveHeight)}</td>
|
||||||
<td className="px-3 py-2 text-t2">{fmt(row.data?.windSpeed)}</td>
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.windSpeed)}</td>
|
||||||
@ -294,11 +293,11 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean })
|
|||||||
<td className="px-3 py-2 text-t2">{fmt(row.data?.temperature)}</td>
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.temperature)}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{row.error ? (
|
{row.error ? (
|
||||||
<span className="text-red-400 text-xs">오류</span>
|
<span className="text-color-danger text-caption">오류</span>
|
||||||
) : row.data ? (
|
) : row.data ? (
|
||||||
<span className="text-emerald-400 text-xs">정상</span>
|
<span className="text-color-success text-caption">정상</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-t3 text-xs">-</span>
|
<span className="text-t3 text-caption">-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -440,11 +439,11 @@ export default function MonitorRealtimePanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
|
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke shrink-0">
|
||||||
<h2 className="text-sm font-semibold text-t1">실시간 관측자료 모니터링</h2>
|
<h2 className="text-body-2 font-semibold text-t1">실시간 관측자료 모니터링</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{lastUpdate && (
|
{lastUpdate && (
|
||||||
<span className="text-xs text-t3">
|
<span className="text-caption text-t3">
|
||||||
갱신:{' '}
|
갱신:{' '}
|
||||||
{lastUpdate.toLocaleTimeString('ko-KR', {
|
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@ -456,7 +455,7 @@ export default function MonitorRealtimePanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin' : ''}`}
|
className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin' : ''}`}
|
||||||
@ -477,14 +476,14 @@ export default function MonitorRealtimePanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 탭 */}
|
{/* 탭 */}
|
||||||
<div className="flex gap-0 border-b border-stroke-1 shrink-0 px-5">
|
<div className="flex gap-0 border-b border-stroke shrink-0 px-5">
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`px-4 py-2.5 text-xs font-medium border-b-2 transition-colors ${
|
className={`px-4 py-2.5 text-caption font-medium border-b-2 transition-colors ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-cyan-400 text-cyan-400'
|
? 'border-color-accent text-color-accent'
|
||||||
: 'border-transparent text-t3 hover:text-t2'
|
: 'border-transparent text-t3 hover:text-t2'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -494,9 +493,9 @@ export default function MonitorRealtimePanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 상태 표시줄 */}
|
{/* 상태 표시줄 */}
|
||||||
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
|
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke bg-bg-base">
|
||||||
<StatusBadge loading={isLoading} errorCount={errorCount} total={totalCount} />
|
<StatusBadge loading={isLoading} errorCount={errorCount} total={totalCount} />
|
||||||
<span className="text-xs text-t3">
|
<span className="text-caption text-t3">
|
||||||
{activeTab === 'khoa' && `관측소 ${totalCount}개`}
|
{activeTab === 'khoa' && `관측소 ${totalCount}개`}
|
||||||
{activeTab === 'kma-ultra' && `지점 ${totalCount}개`}
|
{activeTab === 'kma-ultra' && `지점 ${totalCount}개`}
|
||||||
{activeTab === 'kma-marine' && `해역 ${totalCount}개`}
|
{activeTab === 'kma-marine' && `해역 ${totalCount}개`}
|
||||||
@ -300,8 +300,8 @@ function StatusBadge({
|
|||||||
}) {
|
}) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-elevated text-t2">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-bg-elevated text-t2">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
<span className="w-1.5 h-1.5 rounded-full bg-color-accent animate-pulse" />
|
||||||
조회 중...
|
조회 중...
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@ -309,23 +309,23 @@ function StatusBadge({
|
|||||||
const offCount = total - onCount;
|
const offCount = total - onCount;
|
||||||
if (offCount === total) {
|
if (offCount === total) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-500/10 text-red-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(239,68,68,0.08)] text-color-danger">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-color-danger" />
|
||||||
전체 OFF
|
전체 OFF
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (offCount > 0) {
|
if (offCount > 0) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-yellow-500/10 text-yellow-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(234,179,8,0.08)] text-color-caution">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-color-caution" />
|
||||||
일부 OFF ({offCount}/{total})
|
일부 OFF ({offCount}/{total})
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-500/10 text-emerald-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(34,197,94,0.08)] text-color-success">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-color-success" />
|
||||||
전체 정상
|
전체 정상
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@ -342,7 +342,7 @@ function ConnectionBadge({
|
|||||||
if (isNormal) {
|
if (isNormal) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-start gap-0.5">
|
<div className="flex flex-col items-start gap-0.5">
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-label-2 font-semibold bg-blue-600 text-white">
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-label-2 font-semibold bg-color-accent text-white">
|
||||||
ON
|
ON
|
||||||
</span>
|
</span>
|
||||||
{lastMessageTime && <span className="text-caption text-t3">{lastMessageTime}</span>}
|
{lastMessageTime && <span className="text-caption text-t3">{lastMessageTime}</span>}
|
||||||
@ -351,7 +351,7 @@ function ConnectionBadge({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-start gap-0.5">
|
<div className="flex flex-col items-start gap-0.5">
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-label-2 font-semibold bg-orange-500 text-white">
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-label-2 font-semibold bg-color-warning text-white">
|
||||||
OFF
|
OFF
|
||||||
</span>
|
</span>
|
||||||
{lastMessageTime && <span className="text-caption text-t3">{lastMessageTime}</span>}
|
{lastMessageTime && <span className="text-caption text-t3">{lastMessageTime}</span>}
|
||||||
@ -376,13 +376,13 @@ const HEADERS = [
|
|||||||
function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boolean }) {
|
function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-caption border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
{HEADERS.map((h) => (
|
{HEADERS.map((h) => (
|
||||||
<th
|
<th
|
||||||
key={h}
|
key={h}
|
||||||
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{h}
|
{h}
|
||||||
</th>
|
</th>
|
||||||
@ -392,7 +392,7 @@ function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boo
|
|||||||
<tbody>
|
<tbody>
|
||||||
{loading && rows.length === 0
|
{loading && rows.length === 0
|
||||||
? Array.from({ length: 8 }).map((_, i) => (
|
? Array.from({ length: 8 }).map((_, i) => (
|
||||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
<tr key={i} className="border-b border-stroke animate-pulse">
|
||||||
{HEADERS.map((_, j) => (
|
{HEADERS.map((_, j) => (
|
||||||
<td key={j} className="px-3 py-2">
|
<td key={j} className="px-3 py-2">
|
||||||
<div className="h-3 bg-bg-elevated rounded w-14" />
|
<div className="h-3 bg-bg-elevated rounded w-14" />
|
||||||
@ -403,7 +403,7 @@ function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boo
|
|||||||
: rows.map((row, idx) => (
|
: rows.map((row, idx) => (
|
||||||
<tr
|
<tr
|
||||||
key={`${row.institutionCode}-${row.systemName}`}
|
key={`${row.institutionCode}-${row.systemName}`}
|
||||||
className="border-b border-stroke-1 hover:bg-bg-surface/50"
|
className="border-b border-stroke hover:bg-bg-surface/50"
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2 text-t2 text-center">{idx + 1}</td>
|
<td className="px-3 py-2 text-t2 text-center">{idx + 1}</td>
|
||||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
|
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
|
||||||
@ -461,11 +461,11 @@ export default function MonitorVesselPanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
|
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke shrink-0">
|
||||||
<h2 className="text-sm font-semibold text-t1">선박위치정보 모니터링</h2>
|
<h2 className="text-body-2 font-semibold text-t1">선박위치정보 모니터링</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{lastUpdate && (
|
{lastUpdate && (
|
||||||
<span className="text-xs text-t3">
|
<span className="text-caption text-t3">
|
||||||
갱신:{' '}
|
갱신:{' '}
|
||||||
{lastUpdate.toLocaleTimeString('ko-KR', {
|
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@ -477,7 +477,7 @@ export default function MonitorVesselPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={fetchData}
|
onClick={fetchData}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||||
@ -498,9 +498,9 @@ export default function MonitorVesselPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 상태 표시줄 */}
|
{/* 상태 표시줄 */}
|
||||||
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
|
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke bg-bg-base">
|
||||||
<StatusBadge loading={loading} onCount={onCount} total={rows.length} />
|
<StatusBadge loading={loading} onCount={onCount} total={rows.length} />
|
||||||
<span className="text-xs text-t3">
|
<span className="text-caption text-t3">
|
||||||
연계 채널 {rows.length}개 (ON: {onCount} / OFF: {rows.length - onCount})
|
연계 채널 {rows.length}개 (ON: {onCount} / OFF: {rows.length - onCount})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
422
frontend/src/components/admin/components/PermissionsPanel.tsx
Normal file
422
frontend/src/components/admin/components/PermissionsPanel.tsx
Normal file
@ -0,0 +1,422 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
fetchRoles,
|
||||||
|
fetchPermTree,
|
||||||
|
updatePermissionsApi,
|
||||||
|
createRoleApi,
|
||||||
|
deleteRoleApi,
|
||||||
|
updateRoleApi,
|
||||||
|
updateRoleDefaultApi,
|
||||||
|
type RoleWithPermissions,
|
||||||
|
type PermTreeNode,
|
||||||
|
} from '@common/services/authApi';
|
||||||
|
import { RolePermTab } from './contents/RolePermTab';
|
||||||
|
import { UserPermTab } from './contents/UserPermTab';
|
||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
|
||||||
|
// ─── 오퍼레이션 코드 ─────────────────────────────────
|
||||||
|
export const OPER_CODES = ['READ', 'CREATE', 'UPDATE', 'DELETE'] as const;
|
||||||
|
export type OperCode = (typeof OPER_CODES)[number];
|
||||||
|
export const OPER_LABELS: Record<OperCode, string> = { READ: 'R', CREATE: 'C', UPDATE: 'U', DELETE: 'D' };
|
||||||
|
export const OPER_FULL_LABELS: Record<OperCode, string> = {
|
||||||
|
READ: '조회',
|
||||||
|
CREATE: '생성',
|
||||||
|
UPDATE: '수정',
|
||||||
|
DELETE: '삭제',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 권한 상태 타입 ─────────────────────────────────────
|
||||||
|
export type PermState = 'explicit-granted' | 'inherited-granted' | 'explicit-denied' | 'forced-denied';
|
||||||
|
|
||||||
|
// ─── 키 유틸 ──────────────────────────────────────────
|
||||||
|
export function makeKey(rsrc: string, oper: string): string {
|
||||||
|
return `${rsrc}::${oper}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 유틸: 플랫 노드 목록 추출 (트리 DFS) ─────────────
|
||||||
|
export function flattenTree(nodes: PermTreeNode[]): PermTreeNode[] {
|
||||||
|
const result: PermTreeNode[] = [];
|
||||||
|
function walk(list: PermTreeNode[]) {
|
||||||
|
for (const n of list) {
|
||||||
|
result.push(n);
|
||||||
|
if (n.children.length > 0) walk(n.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(nodes);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 유틸: 권한 상태 계산 (오퍼레이션별) ──────────────
|
||||||
|
function resolvePermStateForOper(
|
||||||
|
code: string,
|
||||||
|
parentCode: string | null,
|
||||||
|
operCd: string,
|
||||||
|
explicitPerms: Map<string, boolean>,
|
||||||
|
cache: Map<string, PermState>,
|
||||||
|
): PermState {
|
||||||
|
const key = makeKey(code, operCd);
|
||||||
|
const cached = cache.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const explicit = explicitPerms.get(key);
|
||||||
|
|
||||||
|
if (parentCode === null) {
|
||||||
|
const state: PermState =
|
||||||
|
explicit === true
|
||||||
|
? 'explicit-granted'
|
||||||
|
: explicit === false
|
||||||
|
? 'explicit-denied'
|
||||||
|
: 'explicit-denied';
|
||||||
|
cache.set(key, state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부모 READ 확인 (접근 게이트)
|
||||||
|
const parentReadKey = makeKey(parentCode, 'READ');
|
||||||
|
const parentReadState = cache.get(parentReadKey);
|
||||||
|
if (parentReadState === 'explicit-denied' || parentReadState === 'forced-denied') {
|
||||||
|
cache.set(key, 'forced-denied');
|
||||||
|
return 'forced-denied';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (explicit === true) {
|
||||||
|
cache.set(key, 'explicit-granted');
|
||||||
|
return 'explicit-granted';
|
||||||
|
}
|
||||||
|
if (explicit === false) {
|
||||||
|
cache.set(key, 'explicit-denied');
|
||||||
|
return 'explicit-denied';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부모의 같은 오퍼레이션 상속
|
||||||
|
const parentOperKey = makeKey(parentCode, operCd);
|
||||||
|
const parentOperState = cache.get(parentOperKey);
|
||||||
|
if (parentOperState === 'explicit-granted' || parentOperState === 'inherited-granted') {
|
||||||
|
cache.set(key, 'inherited-granted');
|
||||||
|
return 'inherited-granted';
|
||||||
|
}
|
||||||
|
if (parentOperState === 'forced-denied') {
|
||||||
|
cache.set(key, 'forced-denied');
|
||||||
|
return 'forced-denied';
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(key, 'explicit-denied');
|
||||||
|
return 'explicit-denied';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEffectiveStates(
|
||||||
|
flatNodes: PermTreeNode[],
|
||||||
|
explicitPerms: Map<string, boolean>,
|
||||||
|
): Map<string, PermState> {
|
||||||
|
const cache = new Map<string, PermState>();
|
||||||
|
for (const node of flatNodes) {
|
||||||
|
// READ 먼저 (CUD는 READ에 의존)
|
||||||
|
resolvePermStateForOper(node.code, node.parentCode, 'READ', explicitPerms, cache);
|
||||||
|
for (const oper of OPER_CODES) {
|
||||||
|
if (oper === 'READ') continue;
|
||||||
|
resolvePermStateForOper(node.code, node.parentCode, oper, explicitPerms, cache);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActiveTab = 'role' | 'user';
|
||||||
|
|
||||||
|
function PermissionsPanel() {
|
||||||
|
const [activeTab, setActiveTab] = useState<ActiveTab>('role');
|
||||||
|
const [roles, setRoles] = useState<RoleWithPermissions[]>([]);
|
||||||
|
const [permTree, setPermTree] = useState<PermTreeNode[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [newRoleCode, setNewRoleCode] = useState('');
|
||||||
|
const [newRoleName, setNewRoleName] = useState('');
|
||||||
|
const [newRoleDesc, setNewRoleDesc] = useState('');
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState('');
|
||||||
|
const [editingRoleSn, setEditingRoleSn] = useState<number | null>(null);
|
||||||
|
const [editRoleName, setEditRoleName] = useState('');
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
|
const [selectedRoleSn, setSelectedRoleSn] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 역할별 명시적 권한: Map<roleSn, Map<"rsrc::oper", boolean>>
|
||||||
|
const [rolePerms, setRolePerms] = useState<Map<number, Map<string, boolean>>>(new Map());
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [rolesData, treeData] = await Promise.all([fetchRoles(), fetchPermTree()]);
|
||||||
|
setRoles(rolesData);
|
||||||
|
setPermTree(treeData);
|
||||||
|
|
||||||
|
// 명시적 권한 맵 초기화 (rsrc::oper 키 형식)
|
||||||
|
const permsMap = new Map<number, Map<string, boolean>>();
|
||||||
|
for (const role of rolesData) {
|
||||||
|
const roleMap = new Map<string, boolean>();
|
||||||
|
for (const p of role.permissions) {
|
||||||
|
roleMap.set(makeKey(p.resourceCode, p.operationCode), p.granted);
|
||||||
|
}
|
||||||
|
permsMap.set(role.sn, roleMap);
|
||||||
|
}
|
||||||
|
setRolePerms(permsMap);
|
||||||
|
|
||||||
|
// 최상위 노드 기본 펼침
|
||||||
|
setExpanded(new Set(treeData.map((n) => n.code)));
|
||||||
|
// 첫 번째 역할 선택
|
||||||
|
if (rolesData.length > 0 && !selectedRoleSn) {
|
||||||
|
setSelectedRoleSn(rolesData[0].sn);
|
||||||
|
}
|
||||||
|
setDirty(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('권한 데이터 조회 실패:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- 초기 마운트 시 1회만 실행
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// 플랫 노드 목록
|
||||||
|
const flatNodes = flattenTree(permTree);
|
||||||
|
|
||||||
|
const handleToggleExpand = useCallback((code: string) => {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(code)) next.delete(code);
|
||||||
|
else next.add(code);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTogglePerm = useCallback(
|
||||||
|
(code: string, oper: OperCode, currentState: PermState) => {
|
||||||
|
if (!selectedRoleSn) return;
|
||||||
|
|
||||||
|
setRolePerms((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const roleMap = new Map(next.get(selectedRoleSn) ?? new Map());
|
||||||
|
|
||||||
|
const key = makeKey(code, oper);
|
||||||
|
const node = flatNodes.find((n) => n.code === code);
|
||||||
|
const isRoot = node ? node.parentCode === null : false;
|
||||||
|
|
||||||
|
switch (currentState) {
|
||||||
|
case 'explicit-granted':
|
||||||
|
roleMap.set(key, false);
|
||||||
|
break;
|
||||||
|
case 'inherited-granted':
|
||||||
|
roleMap.set(key, false);
|
||||||
|
break;
|
||||||
|
case 'explicit-denied':
|
||||||
|
if (isRoot) {
|
||||||
|
roleMap.set(key, true);
|
||||||
|
} else {
|
||||||
|
roleMap.delete(key);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
next.set(selectedRoleSn, roleMap);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setDirty(true);
|
||||||
|
},
|
||||||
|
[selectedRoleSn, flatNodes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
|
try {
|
||||||
|
for (const role of roles) {
|
||||||
|
const perms = rolePerms.get(role.sn);
|
||||||
|
if (!perms) continue;
|
||||||
|
|
||||||
|
const permsList: Array<{ resourceCode: string; operationCode: string; granted: boolean }> =
|
||||||
|
[];
|
||||||
|
for (const [key, granted] of perms) {
|
||||||
|
const sepIdx = key.indexOf('::');
|
||||||
|
permsList.push({
|
||||||
|
resourceCode: key.substring(0, sepIdx),
|
||||||
|
operationCode: key.substring(sepIdx + 2),
|
||||||
|
granted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await updatePermissionsApi(role.sn, permsList);
|
||||||
|
}
|
||||||
|
setDirty(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('권한 저장 실패:', err);
|
||||||
|
setSaveError('권한 저장에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateRole = async () => {
|
||||||
|
setCreating(true);
|
||||||
|
setCreateError('');
|
||||||
|
try {
|
||||||
|
await createRoleApi({
|
||||||
|
code: newRoleCode,
|
||||||
|
name: newRoleName,
|
||||||
|
description: newRoleDesc || undefined,
|
||||||
|
});
|
||||||
|
await loadData();
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setNewRoleCode('');
|
||||||
|
setNewRoleName('');
|
||||||
|
setNewRoleDesc('');
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '역할 생성에 실패했습니다.';
|
||||||
|
setCreateError(message);
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRole = async (roleSn: number, roleName: string) => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`"${roleName}" 역할을 삭제하시겠습니까?\n이 역할을 가진 모든 사용자에서 해당 역할이 제거됩니다.`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteRoleApi(roleSn);
|
||||||
|
if (selectedRoleSn === roleSn) setSelectedRoleSn(null);
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('역할 삭제 실패:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartEditName = (role: RoleWithPermissions) => {
|
||||||
|
setEditingRoleSn(role.sn);
|
||||||
|
setEditRoleName(role.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveRoleName = async (roleSn: number) => {
|
||||||
|
if (!editRoleName.trim()) return;
|
||||||
|
try {
|
||||||
|
await updateRoleApi(roleSn, { name: editRoleName.trim() });
|
||||||
|
setRoles((prev) =>
|
||||||
|
prev.map((r) => (r.sn === roleSn ? { ...r, name: editRoleName.trim() } : r)),
|
||||||
|
);
|
||||||
|
setEditingRoleSn(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('역할 이름 수정 실패:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDefault = async (roleSn: number) => {
|
||||||
|
const role = roles.find((r) => r.sn === roleSn);
|
||||||
|
if (!role) return;
|
||||||
|
const newValue = !role.isDefault;
|
||||||
|
try {
|
||||||
|
await updateRoleDefaultApi(roleSn, newValue);
|
||||||
|
setRoles((prev) => prev.map((r) => (r.sn === roleSn ? { ...r, isDefault: newValue } : r)));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('기본 역할 변경 실패:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-32 text-fg-disabled text-body-2 font-korean">
|
||||||
|
불러오는 중...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-2.5 border-b border-stroke"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-body-2 font-bold text-fg font-korean">권한 관리</h1>
|
||||||
|
<p className="text-caption text-fg-disabled mt-0.5 font-korean">
|
||||||
|
역할별 리소스 × CRUD 권한 설정
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* 탭 전환 */}
|
||||||
|
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg border border-stroke">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('role')}
|
||||||
|
className={`px-4 py-1.5 text-caption font-semibold rounded-md transition-all font-korean ${
|
||||||
|
activeTab === 'role'
|
||||||
|
? 'bg-color-accent text-bg-0 shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||||||
|
: 'text-fg-disabled hover:text-fg-sub'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
그룹별
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('user')}
|
||||||
|
className={`px-4 py-1.5 text-caption font-semibold rounded-md transition-all font-korean ${
|
||||||
|
activeTab === 'user'
|
||||||
|
? 'bg-color-accent text-bg-0 shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||||||
|
: 'text-fg-disabled hover:text-fg-sub'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
사용자별
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'role' ? (
|
||||||
|
<RolePermTab
|
||||||
|
roles={roles}
|
||||||
|
permTree={permTree}
|
||||||
|
rolePerms={rolePerms}
|
||||||
|
setRolePerms={setRolePerms}
|
||||||
|
selectedRoleSn={selectedRoleSn}
|
||||||
|
setSelectedRoleSn={setSelectedRoleSn}
|
||||||
|
dirty={dirty}
|
||||||
|
saving={saving}
|
||||||
|
saveError={saveError}
|
||||||
|
handleSave={handleSave}
|
||||||
|
handleToggleExpand={handleToggleExpand}
|
||||||
|
handleTogglePerm={handleTogglePerm}
|
||||||
|
expanded={expanded}
|
||||||
|
flatNodes={flatNodes}
|
||||||
|
editingRoleSn={editingRoleSn}
|
||||||
|
editRoleName={editRoleName}
|
||||||
|
setEditRoleName={setEditRoleName}
|
||||||
|
handleStartEditName={handleStartEditName}
|
||||||
|
handleSaveRoleName={handleSaveRoleName}
|
||||||
|
setEditingRoleSn={setEditingRoleSn}
|
||||||
|
toggleDefault={toggleDefault}
|
||||||
|
handleDeleteRole={handleDeleteRole}
|
||||||
|
showCreateForm={showCreateForm}
|
||||||
|
setShowCreateForm={setShowCreateForm}
|
||||||
|
setCreateError={setCreateError}
|
||||||
|
newRoleCode={newRoleCode}
|
||||||
|
setNewRoleCode={setNewRoleCode}
|
||||||
|
newRoleName={newRoleName}
|
||||||
|
setNewRoleName={setNewRoleName}
|
||||||
|
newRoleDesc={newRoleDesc}
|
||||||
|
setNewRoleDesc={setNewRoleDesc}
|
||||||
|
creating={creating}
|
||||||
|
createError={createError}
|
||||||
|
handleCreateRole={handleCreateRole}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserPermTab roles={roles} permTree={permTree} rolePerms={rolePerms} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PermissionsPanel;
|
||||||
@ -257,34 +257,34 @@ function fetchHnsAtmosData(): Promise<HnsAtmosData> {
|
|||||||
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
|
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getPipelineStatusStyle(status: PipelineStatus): string {
|
function getPipelineStatusStyle(status: PipelineStatus): string {
|
||||||
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
|
if (status === '정상') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
|
||||||
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
|
if (status === '지연') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
|
||||||
return 'text-red-400 bg-red-500/10';
|
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPipelineBorderStyle(status: PipelineStatus): string {
|
function getPipelineBorderStyle(status: PipelineStatus): string {
|
||||||
if (status === '정상') return 'border-l-emerald-500';
|
if (status === '정상') return 'border-l-color-success';
|
||||||
if (status === '지연') return 'border-l-yellow-500';
|
if (status === '지연') return 'border-l-color-caution';
|
||||||
return 'border-l-red-500';
|
return 'border-l-color-danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReceiveStatusStyle(status: ReceiveStatus): string {
|
function getReceiveStatusStyle(status: ReceiveStatus): string {
|
||||||
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
|
if (status === '수신완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
|
||||||
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
|
if (status === '수신대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
|
||||||
return 'text-red-400 bg-red-500/10';
|
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProcessStatusStyle(status: ProcessStatus): string {
|
function getProcessStatusStyle(status: ProcessStatus): string {
|
||||||
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
|
if (status === '처리완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
|
||||||
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
|
if (status === '처리중') return 'text-color-accent bg-[rgba(6,182,212,0.08)]';
|
||||||
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
|
if (status === '대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
|
||||||
return 'text-red-400 bg-red-500/10';
|
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAlertStyle(level: AlertLevel): string {
|
function getAlertStyle(level: AlertLevel): string {
|
||||||
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
|
if (level === '경고') return 'text-color-danger bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)]';
|
||||||
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
|
if (level === '주의') return 'text-color-caution bg-[rgba(234,179,8,0.08)] border-[rgba(234,179,8,0.3)]';
|
||||||
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
|
return 'text-color-accent bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
|
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
|
||||||
@ -295,9 +295,9 @@ function PipelineCard({ node }: { node: PipelineNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
|
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
|
||||||
>
|
>
|
||||||
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
|
<div className="text-caption font-medium text-t1 leading-snug">{node.name}</div>
|
||||||
<span
|
<span
|
||||||
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
|
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
|
||||||
>
|
>
|
||||||
@ -316,7 +316,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
|
|||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<div key={i} className="flex items-center gap-1">
|
<div key={i} className="flex items-center gap-1">
|
||||||
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
|
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
|
||||||
{i < 4 && <span className="text-t3 text-sm px-0.5">→</span>}
|
{i < 4 && <span className="text-t3 text-body-2 px-0.5">→</span>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -328,9 +328,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
|
|||||||
{nodes.map((node, idx) => (
|
{nodes.map((node, idx) => (
|
||||||
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
||||||
<PipelineCard node={node} />
|
<PipelineCard node={node} />
|
||||||
{idx < nodes.length - 1 && (
|
{idx < nodes.length - 1 && <span className="text-t3 text-body-2 shrink-0 px-0.5">→</span>}
|
||||||
<span className="text-t3 text-sm shrink-0 px-0.5">→</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -369,13 +367,13 @@ const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '
|
|||||||
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
|
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-caption border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
{LOG_HEADERS.map((h) => (
|
{LOG_HEADERS.map((h) => (
|
||||||
<th
|
<th
|
||||||
key={h}
|
key={h}
|
||||||
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{h}
|
{h}
|
||||||
</th>
|
</th>
|
||||||
@ -385,7 +383,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
|
|||||||
<tbody>
|
<tbody>
|
||||||
{loading && rows.length === 0
|
{loading && rows.length === 0
|
||||||
? Array.from({ length: 8 }).map((_, i) => (
|
? Array.from({ length: 8 }).map((_, i) => (
|
||||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
<tr key={i} className="border-b border-stroke animate-pulse">
|
||||||
{LOG_HEADERS.map((_, j) => (
|
{LOG_HEADERS.map((_, j) => (
|
||||||
<td key={j} className="px-3 py-2">
|
<td key={j} className="px-3 py-2">
|
||||||
<div className="h-3 bg-bg-elevated rounded w-16" />
|
<div className="h-3 bg-bg-elevated rounded w-16" />
|
||||||
@ -394,10 +392,8 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
|
|||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
: rows.map((row) => (
|
: rows.map((row) => (
|
||||||
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
<tr key={row.id} className="border-b border-stroke hover:bg-bg-surface/50">
|
||||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.timestamp}</td>
|
||||||
{row.timestamp}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
||||||
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
||||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
||||||
@ -444,7 +440,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (alerts.length === 0) {
|
if (alerts.length === 0) {
|
||||||
return <p className="text-xs text-t3 py-2">활성 알림이 없습니다.</p>;
|
return <p className="text-caption text-t3 py-2">활성 알림이 없습니다.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -452,7 +448,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
|
|||||||
{alerts.map((alert) => (
|
{alerts.map((alert) => (
|
||||||
<div
|
<div
|
||||||
key={alert.id}
|
key={alert.id}
|
||||||
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
|
className={`flex items-start gap-2 px-3 py-2 rounded border text-caption ${getAlertStyle(alert.level)}`}
|
||||||
>
|
>
|
||||||
<span className="font-semibold shrink-0">[{alert.level}]</span>
|
<span className="font-semibold shrink-0">[{alert.level}]</span>
|
||||||
<span className="flex-1">{alert.message}</span>
|
<span className="flex-1">{alert.message}</span>
|
||||||
@ -511,12 +507,12 @@ export default function RndHnsAtmosPanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* ── 헤더 ── */}
|
{/* ── 헤더 ── */}
|
||||||
<div className="shrink-0 border-b border-stroke-1">
|
<div className="shrink-0 border-b border-stroke">
|
||||||
<div className="flex items-center justify-between px-5 py-3">
|
<div className="flex items-center justify-between px-5 py-3">
|
||||||
<h2 className="text-sm font-semibold text-t1">HNS 대기확산 (충북대) 연계 모니터링</h2>
|
<h2 className="text-body-2 font-semibold text-t1">HNS 대기확산 (충북대) 연계 모니터링</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{lastUpdate && (
|
{lastUpdate && (
|
||||||
<span className="text-xs text-t3">
|
<span className="text-caption text-t3">
|
||||||
갱신:{' '}
|
갱신:{' '}
|
||||||
{lastUpdate.toLocaleTimeString('ko-KR', {
|
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@ -528,7 +524,7 @@ export default function RndHnsAtmosPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => void fetchData()}
|
onClick={() => void fetchData()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||||
@ -548,23 +544,21 @@ export default function RndHnsAtmosPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 요약 통계 바 */}
|
{/* 요약 통계 바 */}
|
||||||
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
|
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-caption text-t3 border-t border-stroke">
|
||||||
<span>
|
<span>
|
||||||
정상 수신:{' '}
|
정상 수신: <span className="text-color-success font-medium">{totalReceived}건</span>
|
||||||
<span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-stroke-1">|</span>
|
<span className="text-stroke-1">|</span>
|
||||||
<span>
|
<span>
|
||||||
지연: <span className="text-yellow-400 font-medium">{totalDelayed}건</span>
|
지연: <span className="text-color-caution font-medium">{totalDelayed}건</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-stroke-1">|</span>
|
<span className="text-stroke-1">|</span>
|
||||||
<span>
|
<span>
|
||||||
실패: <span className="text-red-400 font-medium">{totalFailed}건</span>
|
실패: <span className="text-color-danger font-medium">{totalFailed}건</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-stroke-1">|</span>
|
<span className="text-stroke-1">|</span>
|
||||||
<span>
|
<span>
|
||||||
금일 예측 완료:{' '}
|
금일 예측 완료: <span className="text-color-accent font-medium">2 / 4회</span>
|
||||||
<span className="text-cyan-400 font-medium">2 / 4회</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -572,17 +566,17 @@ export default function RndHnsAtmosPanel() {
|
|||||||
{/* ── 스크롤 영역 ── */}
|
{/* ── 스크롤 영역 ── */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{/* 파이프라인 현황 */}
|
{/* 파이프라인 현황 */}
|
||||||
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
<section className="px-5 pt-4 pb-3 border-b border-stroke">
|
||||||
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
|
<h3 className="text-caption font-semibold text-t2 mb-3 uppercase tracking-wide">
|
||||||
데이터 파이프라인 현황
|
데이터 파이프라인 현황
|
||||||
</h3>
|
</h3>
|
||||||
<PipelineFlow nodes={pipeline} loading={loading} />
|
<PipelineFlow nodes={pipeline} loading={loading} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 필터 바 + 수신 이력 테이블 */}
|
{/* 필터 바 + 수신 이력 테이블 */}
|
||||||
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
<section className="px-5 pt-4 pb-3 border-b border-stroke">
|
||||||
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
||||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
|
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide shrink-0">
|
||||||
데이터 수신 이력
|
데이터 수신 이력
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@ -590,7 +584,7 @@ export default function RndHnsAtmosPanel() {
|
|||||||
<select
|
<select
|
||||||
value={filterSource}
|
value={filterSource}
|
||||||
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
|
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
|
||||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
|
||||||
>
|
>
|
||||||
<option value="all">모두 (소스)</option>
|
<option value="all">모두 (소스)</option>
|
||||||
<option value="HYCOM">HYCOM</option>
|
<option value="HYCOM">HYCOM</option>
|
||||||
@ -601,7 +595,7 @@ export default function RndHnsAtmosPanel() {
|
|||||||
<select
|
<select
|
||||||
value={filterReceive}
|
value={filterReceive}
|
||||||
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
|
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
|
||||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
|
||||||
>
|
>
|
||||||
<option value="all">모두 (상태)</option>
|
<option value="all">모두 (상태)</option>
|
||||||
<option value="수신완료">수신완료</option>
|
<option value="수신완료">수신완료</option>
|
||||||
@ -613,13 +607,13 @@ export default function RndHnsAtmosPanel() {
|
|||||||
<select
|
<select
|
||||||
value={filterPeriod}
|
value={filterPeriod}
|
||||||
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
|
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
|
||||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
|
||||||
>
|
>
|
||||||
<option value="6h">최근 6시간</option>
|
<option value="6h">최근 6시간</option>
|
||||||
<option value="12h">최근 12시간</option>
|
<option value="12h">최근 12시간</option>
|
||||||
<option value="24h">최근 24시간</option>
|
<option value="24h">최근 24시간</option>
|
||||||
</select>
|
</select>
|
||||||
<span className="text-xs text-t3">{filteredLogs.length}건</span>
|
<span className="text-caption text-t3">{filteredLogs.length}건</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DataLogTable rows={filteredLogs} loading={loading} />
|
<DataLogTable rows={filteredLogs} loading={loading} />
|
||||||
@ -627,9 +621,7 @@ export default function RndHnsAtmosPanel() {
|
|||||||
|
|
||||||
{/* 알림 현황 */}
|
{/* 알림 현황 */}
|
||||||
<section className="px-5 pt-4 pb-5">
|
<section className="px-5 pt-4 pb-5">
|
||||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
|
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide mb-3">알림 현황</h3>
|
||||||
알림 현황
|
|
||||||
</h3>
|
|
||||||
<AlertList alerts={alerts} loading={loading} />
|
<AlertList alerts={alerts} loading={loading} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@ -257,34 +257,34 @@ function fetchKospsData(): Promise<KospsData> {
|
|||||||
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
|
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getPipelineStatusStyle(status: PipelineStatus): string {
|
function getPipelineStatusStyle(status: PipelineStatus): string {
|
||||||
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
|
if (status === '정상') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
|
||||||
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
|
if (status === '지연') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
|
||||||
return 'text-red-400 bg-red-500/10';
|
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPipelineBorderStyle(status: PipelineStatus): string {
|
function getPipelineBorderStyle(status: PipelineStatus): string {
|
||||||
if (status === '정상') return 'border-l-emerald-500';
|
if (status === '정상') return 'border-l-color-success';
|
||||||
if (status === '지연') return 'border-l-yellow-500';
|
if (status === '지연') return 'border-l-color-caution';
|
||||||
return 'border-l-red-500';
|
return 'border-l-color-danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReceiveStatusStyle(status: ReceiveStatus): string {
|
function getReceiveStatusStyle(status: ReceiveStatus): string {
|
||||||
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
|
if (status === '수신완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
|
||||||
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
|
if (status === '수신대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
|
||||||
return 'text-red-400 bg-red-500/10';
|
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProcessStatusStyle(status: ProcessStatus): string {
|
function getProcessStatusStyle(status: ProcessStatus): string {
|
||||||
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
|
if (status === '처리완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
|
||||||
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
|
if (status === '처리중') return 'text-color-accent bg-[rgba(6,182,212,0.08)]';
|
||||||
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
|
if (status === '대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
|
||||||
return 'text-red-400 bg-red-500/10';
|
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAlertStyle(level: AlertLevel): string {
|
function getAlertStyle(level: AlertLevel): string {
|
||||||
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
|
if (level === '경고') return 'text-color-danger bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)]';
|
||||||
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
|
if (level === '주의') return 'text-color-caution bg-[rgba(234,179,8,0.08)] border-[rgba(234,179,8,0.3)]';
|
||||||
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
|
return 'text-color-accent bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
|
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
|
||||||
@ -295,9 +295,9 @@ function PipelineCard({ node }: { node: PipelineNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
|
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
|
||||||
>
|
>
|
||||||
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
|
<div className="text-caption font-medium text-t1 leading-snug">{node.name}</div>
|
||||||
<span
|
<span
|
||||||
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
|
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
|
||||||
>
|
>
|
||||||
@ -316,7 +316,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
|
|||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<div key={i} className="flex items-center gap-1">
|
<div key={i} className="flex items-center gap-1">
|
||||||
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
|
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
|
||||||
{i < 4 && <span className="text-t3 text-sm px-0.5">→</span>}
|
{i < 4 && <span className="text-t3 text-body-2 px-0.5">→</span>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -328,9 +328,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
|
|||||||
{nodes.map((node, idx) => (
|
{nodes.map((node, idx) => (
|
||||||
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
||||||
<PipelineCard node={node} />
|
<PipelineCard node={node} />
|
||||||
{idx < nodes.length - 1 && (
|
{idx < nodes.length - 1 && <span className="text-t3 text-body-2 shrink-0 px-0.5">→</span>}
|
||||||
<span className="text-t3 text-sm shrink-0 px-0.5">→</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -369,13 +367,13 @@ const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '
|
|||||||
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
|
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-caption border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
{LOG_HEADERS.map((h) => (
|
{LOG_HEADERS.map((h) => (
|
||||||
<th
|
<th
|
||||||
key={h}
|
key={h}
|
||||||
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{h}
|
{h}
|
||||||
</th>
|
</th>
|
||||||
@ -385,7 +383,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
|
|||||||
<tbody>
|
<tbody>
|
||||||
{loading && rows.length === 0
|
{loading && rows.length === 0
|
||||||
? Array.from({ length: 8 }).map((_, i) => (
|
? Array.from({ length: 8 }).map((_, i) => (
|
||||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
<tr key={i} className="border-b border-stroke animate-pulse">
|
||||||
{LOG_HEADERS.map((_, j) => (
|
{LOG_HEADERS.map((_, j) => (
|
||||||
<td key={j} className="px-3 py-2">
|
<td key={j} className="px-3 py-2">
|
||||||
<div className="h-3 bg-bg-elevated rounded w-16" />
|
<div className="h-3 bg-bg-elevated rounded w-16" />
|
||||||
@ -394,10 +392,8 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
|
|||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
: rows.map((row) => (
|
: rows.map((row) => (
|
||||||
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
<tr key={row.id} className="border-b border-stroke hover:bg-bg-surface/50">
|
||||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.timestamp}</td>
|
||||||
{row.timestamp}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
||||||
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
||||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
||||||
@ -444,7 +440,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (alerts.length === 0) {
|
if (alerts.length === 0) {
|
||||||
return <p className="text-xs text-t3 py-2">활성 알림이 없습니다.</p>;
|
return <p className="text-caption text-t3 py-2">활성 알림이 없습니다.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -452,7 +448,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
|
|||||||
{alerts.map((alert) => (
|
{alerts.map((alert) => (
|
||||||
<div
|
<div
|
||||||
key={alert.id}
|
key={alert.id}
|
||||||
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
|
className={`flex items-start gap-2 px-3 py-2 rounded border text-caption ${getAlertStyle(alert.level)}`}
|
||||||
>
|
>
|
||||||
<span className="font-semibold shrink-0">[{alert.level}]</span>
|
<span className="font-semibold shrink-0">[{alert.level}]</span>
|
||||||
<span className="flex-1">{alert.message}</span>
|
<span className="flex-1">{alert.message}</span>
|
||||||
@ -511,12 +507,12 @@ export default function RndKospsPanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* ── 헤더 ── */}
|
{/* ── 헤더 ── */}
|
||||||
<div className="shrink-0 border-b border-stroke-1">
|
<div className="shrink-0 border-b border-stroke">
|
||||||
<div className="flex items-center justify-between px-5 py-3">
|
<div className="flex items-center justify-between px-5 py-3">
|
||||||
<h2 className="text-sm font-semibold text-t1">유출유확산예측 (KOSPS) 연계 모니터링</h2>
|
<h2 className="text-body-2 font-semibold text-t1">유출유확산예측 (KOSPS) 연계 모니터링</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{lastUpdate && (
|
{lastUpdate && (
|
||||||
<span className="text-xs text-t3">
|
<span className="text-caption text-t3">
|
||||||
갱신:{' '}
|
갱신:{' '}
|
||||||
{lastUpdate.toLocaleTimeString('ko-KR', {
|
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@ -528,7 +524,7 @@ export default function RndKospsPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => void fetchData()}
|
onClick={() => void fetchData()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||||
@ -548,23 +544,21 @@ export default function RndKospsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 요약 통계 바 */}
|
{/* 요약 통계 바 */}
|
||||||
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
|
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-caption text-t3 border-t border-stroke">
|
||||||
<span>
|
<span>
|
||||||
정상 수신:{' '}
|
정상 수신: <span className="text-color-success font-medium">{totalReceived}건</span>
|
||||||
<span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-stroke-1">|</span>
|
<span className="text-stroke-1">|</span>
|
||||||
<span>
|
<span>
|
||||||
지연: <span className="text-yellow-400 font-medium">{totalDelayed}건</span>
|
지연: <span className="text-color-caution font-medium">{totalDelayed}건</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-stroke-1">|</span>
|
<span className="text-stroke-1">|</span>
|
||||||
<span>
|
<span>
|
||||||
실패: <span className="text-red-400 font-medium">{totalFailed}건</span>
|
실패: <span className="text-color-danger font-medium">{totalFailed}건</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-stroke-1">|</span>
|
<span className="text-stroke-1">|</span>
|
||||||
<span>
|
<span>
|
||||||
금일 예측 완료:{' '}
|
금일 예측 완료: <span className="text-color-accent font-medium">3 / 6회</span>
|
||||||
<span className="text-cyan-400 font-medium">3 / 6회</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -572,17 +566,17 @@ export default function RndKospsPanel() {
|
|||||||
{/* ── 스크롤 영역 ── */}
|
{/* ── 스크롤 영역 ── */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{/* 파이프라인 현황 */}
|
{/* 파이프라인 현황 */}
|
||||||
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
<section className="px-5 pt-4 pb-3 border-b border-stroke">
|
||||||
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
|
<h3 className="text-caption font-semibold text-t2 mb-3 uppercase tracking-wide">
|
||||||
데이터 파이프라인 현황
|
데이터 파이프라인 현황
|
||||||
</h3>
|
</h3>
|
||||||
<PipelineFlow nodes={pipeline} loading={loading} />
|
<PipelineFlow nodes={pipeline} loading={loading} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 필터 바 + 수신 이력 테이블 */}
|
{/* 필터 바 + 수신 이력 테이블 */}
|
||||||
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
<section className="px-5 pt-4 pb-3 border-b border-stroke">
|
||||||
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
||||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
|
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide shrink-0">
|
||||||
데이터 수신 이력
|
데이터 수신 이력
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@ -590,7 +584,7 @@ export default function RndKospsPanel() {
|
|||||||
<select
|
<select
|
||||||
value={filterSource}
|
value={filterSource}
|
||||||
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
|
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
|
||||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
|
||||||
>
|
>
|
||||||
<option value="all">모두 (소스)</option>
|
<option value="all">모두 (소스)</option>
|
||||||
<option value="HYCOM">HYCOM</option>
|
<option value="HYCOM">HYCOM</option>
|
||||||
@ -601,7 +595,7 @@ export default function RndKospsPanel() {
|
|||||||
<select
|
<select
|
||||||
value={filterReceive}
|
value={filterReceive}
|
||||||
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
|
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
|
||||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
|
||||||
>
|
>
|
||||||
<option value="all">모두 (상태)</option>
|
<option value="all">모두 (상태)</option>
|
||||||
<option value="수신완료">수신완료</option>
|
<option value="수신완료">수신완료</option>
|
||||||
@ -613,13 +607,13 @@ export default function RndKospsPanel() {
|
|||||||
<select
|
<select
|
||||||
value={filterPeriod}
|
value={filterPeriod}
|
||||||
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
|
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
|
||||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
|
||||||
>
|
>
|
||||||
<option value="6h">최근 6시간</option>
|
<option value="6h">최근 6시간</option>
|
||||||
<option value="12h">최근 12시간</option>
|
<option value="12h">최근 12시간</option>
|
||||||
<option value="24h">최근 24시간</option>
|
<option value="24h">최근 24시간</option>
|
||||||
</select>
|
</select>
|
||||||
<span className="text-xs text-t3">{filteredLogs.length}건</span>
|
<span className="text-caption text-t3">{filteredLogs.length}건</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DataLogTable rows={filteredLogs} loading={loading} />
|
<DataLogTable rows={filteredLogs} loading={loading} />
|
||||||
@ -627,9 +621,7 @@ export default function RndKospsPanel() {
|
|||||||
|
|
||||||
{/* 알림 현황 */}
|
{/* 알림 현황 */}
|
||||||
<section className="px-5 pt-4 pb-5">
|
<section className="px-5 pt-4 pb-5">
|
||||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
|
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide mb-3">알림 현황</h3>
|
||||||
알림 현황
|
|
||||||
</h3>
|
|
||||||
<AlertList alerts={alerts} loading={loading} />
|
<AlertList alerts={alerts} loading={loading} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@ -284,34 +284,34 @@ function fetchPoseidonData(): Promise<PoseidonData> {
|
|||||||
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
|
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getPipelineStatusStyle(status: PipelineStatus): string {
|
function getPipelineStatusStyle(status: PipelineStatus): string {
|
||||||
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
|
if (status === '정상') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
|
||||||
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
|
if (status === '지연') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
|
||||||
return 'text-red-400 bg-red-500/10';
|
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPipelineBorderStyle(status: PipelineStatus): string {
|
function getPipelineBorderStyle(status: PipelineStatus): string {
|
||||||
if (status === '정상') return 'border-l-emerald-500';
|
if (status === '정상') return 'border-l-color-success';
|
||||||
if (status === '지연') return 'border-l-yellow-500';
|
if (status === '지연') return 'border-l-color-caution';
|
||||||
return 'border-l-red-500';
|
return 'border-l-color-danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReceiveStatusStyle(status: ReceiveStatus): string {
|
function getReceiveStatusStyle(status: ReceiveStatus): string {
|
||||||
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
|
if (status === '수신완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
|
||||||
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
|
if (status === '수신대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
|
||||||
return 'text-red-400 bg-red-500/10';
|
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProcessStatusStyle(status: ProcessStatus): string {
|
function getProcessStatusStyle(status: ProcessStatus): string {
|
||||||
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
|
if (status === '처리완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
|
||||||
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
|
if (status === '처리중') return 'text-color-accent bg-[rgba(6,182,212,0.08)]';
|
||||||
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
|
if (status === '대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
|
||||||
return 'text-red-400 bg-red-500/10';
|
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAlertStyle(level: AlertLevel): string {
|
function getAlertStyle(level: AlertLevel): string {
|
||||||
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
|
if (level === '경고') return 'text-color-danger bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)]';
|
||||||
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
|
if (level === '주의') return 'text-color-caution bg-[rgba(234,179,8,0.08)] border-[rgba(234,179,8,0.3)]';
|
||||||
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
|
return 'text-color-accent bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
|
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
|
||||||
@ -322,9 +322,9 @@ function PipelineCard({ node }: { node: PipelineNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
|
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
|
||||||
>
|
>
|
||||||
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
|
<div className="text-caption font-medium text-t1 leading-snug">{node.name}</div>
|
||||||
<span
|
<span
|
||||||
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
|
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
|
||||||
>
|
>
|
||||||
@ -343,7 +343,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
|
|||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<div key={i} className="flex items-center gap-1">
|
<div key={i} className="flex items-center gap-1">
|
||||||
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
|
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
|
||||||
{i < 4 && <span className="text-t3 text-sm px-0.5">→</span>}
|
{i < 4 && <span className="text-t3 text-body-2 px-0.5">→</span>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -355,9 +355,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
|
|||||||
{nodes.map((node, idx) => (
|
{nodes.map((node, idx) => (
|
||||||
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
||||||
<PipelineCard node={node} />
|
<PipelineCard node={node} />
|
||||||
{idx < nodes.length - 1 && (
|
{idx < nodes.length - 1 && <span className="text-t3 text-body-2 shrink-0 px-0.5">→</span>}
|
||||||
<span className="text-t3 text-sm shrink-0 px-0.5">→</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -396,13 +394,13 @@ const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '
|
|||||||
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
|
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-caption border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
{LOG_HEADERS.map((h) => (
|
{LOG_HEADERS.map((h) => (
|
||||||
<th
|
<th
|
||||||
key={h}
|
key={h}
|
||||||
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{h}
|
{h}
|
||||||
</th>
|
</th>
|
||||||
@ -412,7 +410,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
|
|||||||
<tbody>
|
<tbody>
|
||||||
{loading && rows.length === 0
|
{loading && rows.length === 0
|
||||||
? Array.from({ length: 8 }).map((_, i) => (
|
? Array.from({ length: 8 }).map((_, i) => (
|
||||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
<tr key={i} className="border-b border-stroke animate-pulse">
|
||||||
{LOG_HEADERS.map((_, j) => (
|
{LOG_HEADERS.map((_, j) => (
|
||||||
<td key={j} className="px-3 py-2">
|
<td key={j} className="px-3 py-2">
|
||||||
<div className="h-3 bg-bg-elevated rounded w-16" />
|
<div className="h-3 bg-bg-elevated rounded w-16" />
|
||||||
@ -421,10 +419,8 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
|
|||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
: rows.map((row) => (
|
: rows.map((row) => (
|
||||||
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
<tr key={row.id} className="border-b border-stroke hover:bg-bg-surface/50">
|
||||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.timestamp}</td>
|
||||||
{row.timestamp}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
||||||
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
||||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
||||||
@ -471,7 +467,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (alerts.length === 0) {
|
if (alerts.length === 0) {
|
||||||
return <p className="text-xs text-t3 py-2">활성 알림이 없습니다.</p>;
|
return <p className="text-caption text-t3 py-2">활성 알림이 없습니다.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -479,7 +475,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
|
|||||||
{alerts.map((alert) => (
|
{alerts.map((alert) => (
|
||||||
<div
|
<div
|
||||||
key={alert.id}
|
key={alert.id}
|
||||||
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
|
className={`flex items-start gap-2 px-3 py-2 rounded border text-caption ${getAlertStyle(alert.level)}`}
|
||||||
>
|
>
|
||||||
<span className="font-semibold shrink-0">[{alert.level}]</span>
|
<span className="font-semibold shrink-0">[{alert.level}]</span>
|
||||||
<span className="flex-1">{alert.message}</span>
|
<span className="flex-1">{alert.message}</span>
|
||||||
@ -538,12 +534,12 @@ export default function RndPoseidonPanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* ── 헤더 ── */}
|
{/* ── 헤더 ── */}
|
||||||
<div className="shrink-0 border-b border-stroke-1">
|
<div className="shrink-0 border-b border-stroke">
|
||||||
<div className="flex items-center justify-between px-5 py-3">
|
<div className="flex items-center justify-between px-5 py-3">
|
||||||
<h2 className="text-sm font-semibold text-t1">유출유확산예측 (포세이돈) 연계 모니터링</h2>
|
<h2 className="text-body-2 font-semibold text-t1">유출유확산예측 (포세이돈) 연계 모니터링</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{lastUpdate && (
|
{lastUpdate && (
|
||||||
<span className="text-xs text-t3">
|
<span className="text-caption text-t3">
|
||||||
갱신:{' '}
|
갱신:{' '}
|
||||||
{lastUpdate.toLocaleTimeString('ko-KR', {
|
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@ -555,7 +551,7 @@ export default function RndPoseidonPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => void fetchData()}
|
onClick={() => void fetchData()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||||
@ -575,23 +571,21 @@ export default function RndPoseidonPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 요약 통계 바 */}
|
{/* 요약 통계 바 */}
|
||||||
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
|
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-caption text-t3 border-t border-stroke">
|
||||||
<span>
|
<span>
|
||||||
정상 수신:{' '}
|
정상 수신: <span className="text-color-success font-medium">{totalReceived}건</span>
|
||||||
<span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-stroke-1">|</span>
|
<span className="text-stroke-1">|</span>
|
||||||
<span>
|
<span>
|
||||||
지연: <span className="text-yellow-400 font-medium">{totalDelayed}건</span>
|
지연: <span className="text-color-caution font-medium">{totalDelayed}건</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-stroke-1">|</span>
|
<span className="text-stroke-1">|</span>
|
||||||
<span>
|
<span>
|
||||||
실패: <span className="text-red-400 font-medium">{totalFailed}건</span>
|
실패: <span className="text-color-danger font-medium">{totalFailed}건</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-stroke-1">|</span>
|
<span className="text-stroke-1">|</span>
|
||||||
<span>
|
<span>
|
||||||
금일 예측 완료:{' '}
|
금일 예측 완료: <span className="text-color-accent font-medium">4 / 8회</span>
|
||||||
<span className="text-cyan-400 font-medium">4 / 8회</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -599,17 +593,17 @@ export default function RndPoseidonPanel() {
|
|||||||
{/* ── 스크롤 영역 ── */}
|
{/* ── 스크롤 영역 ── */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{/* 파이프라인 현황 */}
|
{/* 파이프라인 현황 */}
|
||||||
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
<section className="px-5 pt-4 pb-3 border-b border-stroke">
|
||||||
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
|
<h3 className="text-caption font-semibold text-t2 mb-3 uppercase tracking-wide">
|
||||||
데이터 파이프라인 현황
|
데이터 파이프라인 현황
|
||||||
</h3>
|
</h3>
|
||||||
<PipelineFlow nodes={pipeline} loading={loading} />
|
<PipelineFlow nodes={pipeline} loading={loading} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 필터 바 + 수신 이력 테이블 */}
|
{/* 필터 바 + 수신 이력 테이블 */}
|
||||||
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
<section className="px-5 pt-4 pb-3 border-b border-stroke">
|
||||||
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
||||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
|
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide shrink-0">
|
||||||
데이터 수신 이력
|
데이터 수신 이력
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@ -617,7 +611,7 @@ export default function RndPoseidonPanel() {
|
|||||||
<select
|
<select
|
||||||
value={filterSource}
|
value={filterSource}
|
||||||
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
|
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
|
||||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
|
||||||
>
|
>
|
||||||
<option value="all">모두 (소스)</option>
|
<option value="all">모두 (소스)</option>
|
||||||
<option value="HYCOM">HYCOM</option>
|
<option value="HYCOM">HYCOM</option>
|
||||||
@ -628,7 +622,7 @@ export default function RndPoseidonPanel() {
|
|||||||
<select
|
<select
|
||||||
value={filterReceive}
|
value={filterReceive}
|
||||||
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
|
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
|
||||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
|
||||||
>
|
>
|
||||||
<option value="all">모두 (상태)</option>
|
<option value="all">모두 (상태)</option>
|
||||||
<option value="수신완료">수신완료</option>
|
<option value="수신완료">수신완료</option>
|
||||||
@ -640,13 +634,13 @@ export default function RndPoseidonPanel() {
|
|||||||
<select
|
<select
|
||||||
value={filterPeriod}
|
value={filterPeriod}
|
||||||
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
|
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
|
||||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
|
||||||
>
|
>
|
||||||
<option value="6h">최근 6시간</option>
|
<option value="6h">최근 6시간</option>
|
||||||
<option value="12h">최근 12시간</option>
|
<option value="12h">최근 12시간</option>
|
||||||
<option value="24h">최근 24시간</option>
|
<option value="24h">최근 24시간</option>
|
||||||
</select>
|
</select>
|
||||||
<span className="text-xs text-t3">{filteredLogs.length}건</span>
|
<span className="text-caption text-t3">{filteredLogs.length}건</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DataLogTable rows={filteredLogs} loading={loading} />
|
<DataLogTable rows={filteredLogs} loading={loading} />
|
||||||
@ -654,9 +648,7 @@ export default function RndPoseidonPanel() {
|
|||||||
|
|
||||||
{/* 알림 현황 */}
|
{/* 알림 현황 */}
|
||||||
<section className="px-5 pt-4 pb-5">
|
<section className="px-5 pt-4 pb-5">
|
||||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
|
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide mb-3">알림 현황</h3>
|
||||||
알림 현황
|
|
||||||
</h3>
|
|
||||||
<AlertList alerts={alerts} loading={loading} />
|
<AlertList alerts={alerts} loading={loading} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@ -257,34 +257,34 @@ function fetchRescueData(): Promise<RescueData> {
|
|||||||
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
|
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getPipelineStatusStyle(status: PipelineStatus): string {
|
function getPipelineStatusStyle(status: PipelineStatus): string {
|
||||||
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
|
if (status === '정상') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
|
||||||
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
|
if (status === '지연') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
|
||||||
return 'text-red-400 bg-red-500/10';
|
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPipelineBorderStyle(status: PipelineStatus): string {
|
function getPipelineBorderStyle(status: PipelineStatus): string {
|
||||||
if (status === '정상') return 'border-l-emerald-500';
|
if (status === '정상') return 'border-l-color-success';
|
||||||
if (status === '지연') return 'border-l-yellow-500';
|
if (status === '지연') return 'border-l-color-caution';
|
||||||
return 'border-l-red-500';
|
return 'border-l-color-danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReceiveStatusStyle(status: ReceiveStatus): string {
|
function getReceiveStatusStyle(status: ReceiveStatus): string {
|
||||||
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
|
if (status === '수신완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
|
||||||
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
|
if (status === '수신대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
|
||||||
return 'text-red-400 bg-red-500/10';
|
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProcessStatusStyle(status: ProcessStatus): string {
|
function getProcessStatusStyle(status: ProcessStatus): string {
|
||||||
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
|
if (status === '처리완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
|
||||||
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
|
if (status === '처리중') return 'text-color-accent bg-[rgba(6,182,212,0.08)]';
|
||||||
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
|
if (status === '대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
|
||||||
return 'text-red-400 bg-red-500/10';
|
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAlertStyle(level: AlertLevel): string {
|
function getAlertStyle(level: AlertLevel): string {
|
||||||
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
|
if (level === '경고') return 'text-color-danger bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)]';
|
||||||
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
|
if (level === '주의') return 'text-color-caution bg-[rgba(234,179,8,0.08)] border-[rgba(234,179,8,0.3)]';
|
||||||
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
|
return 'text-color-accent bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
|
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
|
||||||
@ -295,9 +295,9 @@ function PipelineCard({ node }: { node: PipelineNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
|
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
|
||||||
>
|
>
|
||||||
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
|
<div className="text-caption font-medium text-t1 leading-snug">{node.name}</div>
|
||||||
<span
|
<span
|
||||||
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
|
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
|
||||||
>
|
>
|
||||||
@ -316,7 +316,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
|
|||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<div key={i} className="flex items-center gap-1">
|
<div key={i} className="flex items-center gap-1">
|
||||||
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
|
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
|
||||||
{i < 4 && <span className="text-t3 text-sm px-0.5">→</span>}
|
{i < 4 && <span className="text-t3 text-body-2 px-0.5">→</span>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -328,9 +328,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
|
|||||||
{nodes.map((node, idx) => (
|
{nodes.map((node, idx) => (
|
||||||
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
||||||
<PipelineCard node={node} />
|
<PipelineCard node={node} />
|
||||||
{idx < nodes.length - 1 && (
|
{idx < nodes.length - 1 && <span className="text-t3 text-body-2 shrink-0 px-0.5">→</span>}
|
||||||
<span className="text-t3 text-sm shrink-0 px-0.5">→</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -369,13 +367,13 @@ const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '
|
|||||||
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
|
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-caption border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
{LOG_HEADERS.map((h) => (
|
{LOG_HEADERS.map((h) => (
|
||||||
<th
|
<th
|
||||||
key={h}
|
key={h}
|
||||||
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{h}
|
{h}
|
||||||
</th>
|
</th>
|
||||||
@ -385,7 +383,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
|
|||||||
<tbody>
|
<tbody>
|
||||||
{loading && rows.length === 0
|
{loading && rows.length === 0
|
||||||
? Array.from({ length: 8 }).map((_, i) => (
|
? Array.from({ length: 8 }).map((_, i) => (
|
||||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
<tr key={i} className="border-b border-stroke animate-pulse">
|
||||||
{LOG_HEADERS.map((_, j) => (
|
{LOG_HEADERS.map((_, j) => (
|
||||||
<td key={j} className="px-3 py-2">
|
<td key={j} className="px-3 py-2">
|
||||||
<div className="h-3 bg-bg-elevated rounded w-16" />
|
<div className="h-3 bg-bg-elevated rounded w-16" />
|
||||||
@ -394,10 +392,8 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
|
|||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
: rows.map((row) => (
|
: rows.map((row) => (
|
||||||
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
<tr key={row.id} className="border-b border-stroke hover:bg-bg-surface/50">
|
||||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.timestamp}</td>
|
||||||
{row.timestamp}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
||||||
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
||||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
||||||
@ -444,7 +440,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (alerts.length === 0) {
|
if (alerts.length === 0) {
|
||||||
return <p className="text-xs text-t3 py-2">활성 알림이 없습니다.</p>;
|
return <p className="text-caption text-t3 py-2">활성 알림이 없습니다.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -452,7 +448,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
|
|||||||
{alerts.map((alert) => (
|
{alerts.map((alert) => (
|
||||||
<div
|
<div
|
||||||
key={alert.id}
|
key={alert.id}
|
||||||
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
|
className={`flex items-start gap-2 px-3 py-2 rounded border text-caption ${getAlertStyle(alert.level)}`}
|
||||||
>
|
>
|
||||||
<span className="font-semibold shrink-0">[{alert.level}]</span>
|
<span className="font-semibold shrink-0">[{alert.level}]</span>
|
||||||
<span className="flex-1">{alert.message}</span>
|
<span className="flex-1">{alert.message}</span>
|
||||||
@ -511,12 +507,12 @@ export default function RndRescuePanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* ── 헤더 ── */}
|
{/* ── 헤더 ── */}
|
||||||
<div className="shrink-0 border-b border-stroke-1">
|
<div className="shrink-0 border-b border-stroke">
|
||||||
<div className="flex items-center justify-between px-5 py-3">
|
<div className="flex items-center justify-between px-5 py-3">
|
||||||
<h2 className="text-sm font-semibold text-t1">긴급구난과제 연계 모니터링</h2>
|
<h2 className="text-body-2 font-semibold text-t1">긴급구난과제 연계 모니터링</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{lastUpdate && (
|
{lastUpdate && (
|
||||||
<span className="text-xs text-t3">
|
<span className="text-caption text-t3">
|
||||||
갱신:{' '}
|
갱신:{' '}
|
||||||
{lastUpdate.toLocaleTimeString('ko-KR', {
|
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@ -528,7 +524,7 @@ export default function RndRescuePanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => void fetchData()}
|
onClick={() => void fetchData()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||||
@ -548,23 +544,21 @@ export default function RndRescuePanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 요약 통계 바 */}
|
{/* 요약 통계 바 */}
|
||||||
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
|
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-caption text-t3 border-t border-stroke">
|
||||||
<span>
|
<span>
|
||||||
정상 수신:{' '}
|
정상 수신: <span className="text-color-success font-medium">{totalReceived}건</span>
|
||||||
<span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-stroke-1">|</span>
|
<span className="text-stroke-1">|</span>
|
||||||
<span>
|
<span>
|
||||||
지연: <span className="text-yellow-400 font-medium">{totalDelayed}건</span>
|
지연: <span className="text-color-caution font-medium">{totalDelayed}건</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-stroke-1">|</span>
|
<span className="text-stroke-1">|</span>
|
||||||
<span>
|
<span>
|
||||||
실패: <span className="text-red-400 font-medium">{totalFailed}건</span>
|
실패: <span className="text-color-danger font-medium">{totalFailed}건</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-stroke-1">|</span>
|
<span className="text-stroke-1">|</span>
|
||||||
<span>
|
<span>
|
||||||
금일 분석 완료:{' '}
|
금일 분석 완료: <span className="text-color-accent font-medium">5 / 6회</span>
|
||||||
<span className="text-cyan-400 font-medium">5 / 6회</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -572,17 +566,17 @@ export default function RndRescuePanel() {
|
|||||||
{/* ── 스크롤 영역 ── */}
|
{/* ── 스크롤 영역 ── */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{/* 파이프라인 현황 */}
|
{/* 파이프라인 현황 */}
|
||||||
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
<section className="px-5 pt-4 pb-3 border-b border-stroke">
|
||||||
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
|
<h3 className="text-caption font-semibold text-t2 mb-3 uppercase tracking-wide">
|
||||||
데이터 파이프라인 현황
|
데이터 파이프라인 현황
|
||||||
</h3>
|
</h3>
|
||||||
<PipelineFlow nodes={pipeline} loading={loading} />
|
<PipelineFlow nodes={pipeline} loading={loading} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 필터 바 + 수신 이력 테이블 */}
|
{/* 필터 바 + 수신 이력 테이블 */}
|
||||||
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
<section className="px-5 pt-4 pb-3 border-b border-stroke">
|
||||||
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
||||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
|
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide shrink-0">
|
||||||
데이터 수신 이력
|
데이터 수신 이력
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@ -590,7 +584,7 @@ export default function RndRescuePanel() {
|
|||||||
<select
|
<select
|
||||||
value={filterSource}
|
value={filterSource}
|
||||||
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
|
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
|
||||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
|
||||||
>
|
>
|
||||||
<option value="all">모두 (소스)</option>
|
<option value="all">모두 (소스)</option>
|
||||||
<option value="HYCOM">HYCOM</option>
|
<option value="HYCOM">HYCOM</option>
|
||||||
@ -601,7 +595,7 @@ export default function RndRescuePanel() {
|
|||||||
<select
|
<select
|
||||||
value={filterReceive}
|
value={filterReceive}
|
||||||
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
|
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
|
||||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
|
||||||
>
|
>
|
||||||
<option value="all">모두 (상태)</option>
|
<option value="all">모두 (상태)</option>
|
||||||
<option value="수신완료">수신완료</option>
|
<option value="수신완료">수신완료</option>
|
||||||
@ -613,13 +607,13 @@ export default function RndRescuePanel() {
|
|||||||
<select
|
<select
|
||||||
value={filterPeriod}
|
value={filterPeriod}
|
||||||
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
|
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
|
||||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
|
||||||
>
|
>
|
||||||
<option value="6h">최근 6시간</option>
|
<option value="6h">최근 6시간</option>
|
||||||
<option value="12h">최근 12시간</option>
|
<option value="12h">최근 12시간</option>
|
||||||
<option value="24h">최근 24시간</option>
|
<option value="24h">최근 24시간</option>
|
||||||
</select>
|
</select>
|
||||||
<span className="text-xs text-t3">{filteredLogs.length}건</span>
|
<span className="text-caption text-t3">{filteredLogs.length}건</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DataLogTable rows={filteredLogs} loading={loading} />
|
<DataLogTable rows={filteredLogs} loading={loading} />
|
||||||
@ -627,9 +621,7 @@ export default function RndRescuePanel() {
|
|||||||
|
|
||||||
{/* 알림 현황 */}
|
{/* 알림 현황 */}
|
||||||
<section className="px-5 pt-4 pb-5">
|
<section className="px-5 pt-4 pb-5">
|
||||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
|
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide mb-3">알림 현황</h3>
|
||||||
알림 현황
|
|
||||||
</h3>
|
|
||||||
<AlertList alerts={alerts} loading={loading} />
|
<AlertList alerts={alerts} loading={loading} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@ -135,8 +135,8 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
<div className="px-6 py-4 border-b border-stroke shrink-0">
|
<div className="px-6 py-4 border-b border-stroke shrink-0">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-bold text-fg font-korean">{title}</h1>
|
<h1 className="text-title-1 font-bold text-fg font-korean">{title}</h1>
|
||||||
<p className="text-xs text-fg-disabled mt-1 font-korean">총 {total}개</p>
|
<p className="text-caption text-fg-disabled mt-1 font-korean">총 {total}개</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -146,12 +146,12 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
placeholder="레이어코드 / 레이어명 검색"
|
placeholder="레이어코드 / 레이어명 검색"
|
||||||
className="flex-1 px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
className="flex-1 px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={filterUseYn}
|
value={filterUseYn}
|
||||||
onChange={(e) => setFilterUseYn(e.target.value)}
|
onChange={(e) => setFilterUseYn(e.target.value)}
|
||||||
className="px-2 py-1.5 text-xs bg-bg-elevated border border-stroke rounded text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="px-2 py-1.5 text-caption bg-bg-elevated border border-stroke rounded text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="">전체</option>
|
<option value="">전체</option>
|
||||||
<option value="Y">사용</option>
|
<option value="Y">사용</option>
|
||||||
@ -159,7 +159,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
onClick={handleSearch}
|
onClick={handleSearch}
|
||||||
className="px-3 py-1.5 text-xs border border-stroke text-fg-sub rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
className="px-3 py-1.5 text-caption border border-stroke text-fg-sub rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||||
>
|
>
|
||||||
검색
|
검색
|
||||||
</button>
|
</button>
|
||||||
@ -168,7 +168,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
|
|
||||||
{/* 오류 메시지 */}
|
{/* 오류 메시지 */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="px-6 py-2 text-xs text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-stroke shrink-0 font-korean">
|
<div className="px-6 py-2 text-caption text-color-danger bg-[rgba(239,68,68,0.08)] border-b border-stroke shrink-0 font-korean">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -176,7 +176,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
{/* 테이블 영역 */}
|
{/* 테이블 영역 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-full text-fg-disabled text-sm font-korean">
|
<div className="flex items-center justify-center h-full text-fg-disabled text-body-2 font-korean">
|
||||||
불러오는 중...
|
불러오는 중...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -217,7 +217,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={9}
|
colSpan={9}
|
||||||
className="px-4 py-12 text-center text-fg-disabled text-sm font-korean"
|
className="px-4 py-12 text-center text-fg-disabled text-body-2 font-korean"
|
||||||
>
|
>
|
||||||
데이터가 없습니다.
|
데이터가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
@ -228,12 +228,12 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
key={item.layerCd}
|
key={item.layerCd}
|
||||||
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 text-xs text-fg-disabled font-mono">
|
<td className="px-4 py-3 text-caption text-fg-disabled font-mono">
|
||||||
{(page - 1) * PAGE_SIZE + idx + 1}
|
{(page - 1) * PAGE_SIZE + idx + 1}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">{item.layerCd}</td>
|
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">{item.layerCd}</td>
|
||||||
<td className="px-4 py-3 text-xs text-fg font-korean">{item.layerNm}</td>
|
<td className="px-4 py-3 text-caption text-fg font-korean">{item.layerNm}</td>
|
||||||
<td className="px-4 py-3 text-xs text-fg-sub font-korean max-w-[200px]">
|
<td className="px-4 py-3 text-caption text-fg-sub font-korean max-w-[200px]">
|
||||||
<span className="block truncate" title={item.layerFullNm}>
|
<span className="block truncate" title={item.layerFullNm}>
|
||||||
{item.layerFullNm}
|
{item.layerFullNm}
|
||||||
</span>
|
</span>
|
||||||
@ -246,7 +246,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">
|
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">
|
||||||
{item.wmsLayerNm ?? <span className="text-fg-disabled">-</span>}
|
{item.wmsLayerNm ?? <span className="text-fg-disabled">-</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-xs text-fg-disabled text-center font-mono">
|
<td className="px-4 py-3 text-caption text-fg-disabled text-center font-mono">
|
||||||
{item.sortOrd}
|
{item.sortOrd}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono">
|
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono">
|
||||||
@ -54,7 +54,7 @@ function SettingsPanel() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">
|
<div className="flex items-center justify-center h-32 text-fg-disabled text-body-2 font-korean">
|
||||||
불러오는 중...
|
불러오는 중...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -63,8 +63,8 @@ function SettingsPanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="px-6 py-4 border-b border-stroke">
|
<div className="px-6 py-4 border-b border-stroke">
|
||||||
<h1 className="text-lg font-bold text-fg font-korean">시스템 설정</h1>
|
<h1 className="text-title-1 font-bold text-fg font-korean">시스템 설정</h1>
|
||||||
<p className="text-xs text-fg-disabled mt-1 font-korean">
|
<p className="text-caption text-fg-disabled mt-1 font-korean">
|
||||||
사용자 등록 및 권한 관련 시스템 설정을 관리합니다
|
사용자 등록 및 권한 관련 시스템 설정을 관리합니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -74,7 +74,7 @@ function SettingsPanel() {
|
|||||||
{/* 사용자 등록 설정 */}
|
{/* 사용자 등록 설정 */}
|
||||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||||
<div className="px-5 py-3 border-b border-stroke">
|
<div className="px-5 py-3 border-b border-stroke">
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">사용자 등록 설정</h2>
|
<h2 className="text-body-2 font-bold text-fg font-korean">사용자 등록 설정</h2>
|
||||||
<p className="text-label-2 text-fg-disabled mt-0.5 font-korean">
|
<p className="text-label-2 text-fg-disabled mt-0.5 font-korean">
|
||||||
신규 사용자 등록 시 적용되는 정책을 설정합니다
|
신규 사용자 등록 시 적용되는 정책을 설정합니다
|
||||||
</p>
|
</p>
|
||||||
@ -87,9 +87,9 @@ function SettingsPanel() {
|
|||||||
<div className="text-title-4 font-semibold text-fg font-korean">자동 승인</div>
|
<div className="text-title-4 font-semibold text-fg font-korean">자동 승인</div>
|
||||||
<p className="text-label-2 text-fg-disabled mt-1 font-korean leading-relaxed">
|
<p className="text-label-2 text-fg-disabled mt-1 font-korean leading-relaxed">
|
||||||
활성화하면 신규 사용자가 등록 즉시{' '}
|
활성화하면 신규 사용자가 등록 즉시{' '}
|
||||||
<span className="text-green-400 font-semibold">ACTIVE</span> 상태가 됩니다.
|
<span className="text-color-success font-semibold">ACTIVE</span> 상태가 됩니다.
|
||||||
비활성화하면 관리자 승인 전까지{' '}
|
비활성화하면 관리자 승인 전까지{' '}
|
||||||
<span className="text-yellow-400 font-semibold">PENDING</span> 상태로
|
<span className="text-color-caution font-semibold">PENDING</span> 상태로
|
||||||
대기합니다.
|
대기합니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -140,7 +140,7 @@ function SettingsPanel() {
|
|||||||
{/* OAuth 설정 */}
|
{/* OAuth 설정 */}
|
||||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||||
<div className="px-5 py-3 border-b border-stroke">
|
<div className="px-5 py-3 border-b border-stroke">
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">Google OAuth 설정</h2>
|
<h2 className="text-body-2 font-bold text-fg font-korean">Google OAuth 설정</h2>
|
||||||
<p className="text-label-2 text-fg-disabled mt-0.5 font-korean">
|
<p className="text-label-2 text-fg-disabled mt-0.5 font-korean">
|
||||||
Google 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다
|
Google 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다
|
||||||
</p>
|
</p>
|
||||||
@ -152,8 +152,8 @@ function SettingsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-label-2 text-fg-disabled font-korean leading-relaxed mb-3">
|
<p className="text-label-2 text-fg-disabled font-korean leading-relaxed mb-3">
|
||||||
지정된 도메인의 Google 계정은 가입 즉시{' '}
|
지정된 도메인의 Google 계정은 가입 즉시{' '}
|
||||||
<span className="text-green-400 font-semibold">ACTIVE</span> 상태가 됩니다. 미지정
|
<span className="text-color-success font-semibold">ACTIVE</span> 상태가 됩니다. 미지정
|
||||||
도메인은 <span className="text-yellow-400 font-semibold">PENDING</span> 상태로
|
도메인은 <span className="text-color-caution font-semibold">PENDING</span> 상태로
|
||||||
관리자 승인이 필요합니다. 여러 도메인은 쉼표(,)로 구분합니다.
|
관리자 승인이 필요합니다. 여러 도메인은 쉼표(,)로 구분합니다.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@ -162,7 +162,7 @@ function SettingsPanel() {
|
|||||||
value={oauthDomainInput}
|
value={oauthDomainInput}
|
||||||
onChange={(e) => setOauthDomainInput(e.target.value)}
|
onChange={(e) => setOauthDomainInput(e.target.value)}
|
||||||
placeholder="gcsc.co.kr, example.com"
|
placeholder="gcsc.co.kr, example.com"
|
||||||
className="flex-1 px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
className="flex-1 px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@ -183,7 +183,7 @@ function SettingsPanel() {
|
|||||||
savingOAuth ||
|
savingOAuth ||
|
||||||
oauthDomainInput.trim() === (oauthSettings?.autoApproveDomains || '')
|
oauthDomainInput.trim() === (oauthSettings?.autoApproveDomains || '')
|
||||||
}
|
}
|
||||||
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean whitespace-nowrap ${
|
className={`px-4 py-2 text-caption font-semibold rounded-md transition-all font-korean whitespace-nowrap ${
|
||||||
oauthDomainInput.trim() !== (oauthSettings?.autoApproveDomains || '')
|
oauthDomainInput.trim() !== (oauthSettings?.autoApproveDomains || '')
|
||||||
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||||
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
|
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
|
||||||
@ -220,31 +220,31 @@ function SettingsPanel() {
|
|||||||
{/* 현재 설정 상태 요약 */}
|
{/* 현재 설정 상태 요약 */}
|
||||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||||
<div className="px-5 py-3 border-b border-stroke">
|
<div className="px-5 py-3 border-b border-stroke">
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">설정 상태 요약</h2>
|
<h2 className="text-body-2 font-bold text-fg font-korean">설정 상태 요약</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4">
|
<div className="px-5 py-4">
|
||||||
<div className="flex flex-col gap-3 text-label-1 font-korean">
|
<div className="flex flex-col gap-3 text-label-1 font-korean">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`w-2 h-2 rounded-full ${settings?.autoApprove ? 'bg-green-400' : 'bg-yellow-400'}`}
|
className={`w-2 h-2 rounded-full ${settings?.autoApprove ? 'bg-color-success' : 'bg-color-caution'}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-fg-sub">
|
<span className="text-fg-sub">
|
||||||
신규 사용자 등록 시{' '}
|
신규 사용자 등록 시{' '}
|
||||||
{settings?.autoApprove ? (
|
{settings?.autoApprove ? (
|
||||||
<span className="text-green-400 font-semibold">즉시 활성화</span>
|
<span className="text-color-success font-semibold">즉시 활성화</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-yellow-400 font-semibold">관리자 승인 필요</span>
|
<span className="text-color-caution font-semibold">관리자 승인 필요</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`w-2 h-2 rounded-full ${settings?.defaultRole ? 'bg-green-400' : 'bg-fg-disabled'}`}
|
className={`w-2 h-2 rounded-full ${settings?.defaultRole ? 'bg-color-success' : 'bg-fg-disabled'}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-fg-sub">
|
<span className="text-fg-sub">
|
||||||
기본 역할 자동 할당{' '}
|
기본 역할 자동 할당{' '}
|
||||||
{settings?.defaultRole ? (
|
{settings?.defaultRole ? (
|
||||||
<span className="text-green-400 font-semibold">활성</span>
|
<span className="text-color-success font-semibold">활성</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-fg-disabled font-semibold">비활성</span>
|
<span className="text-fg-disabled font-semibold">비활성</span>
|
||||||
)}
|
)}
|
||||||
@ -252,12 +252,12 @@ function SettingsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`w-2 h-2 rounded-full ${oauthSettings?.autoApproveDomains ? 'bg-blue-400' : 'bg-fg-disabled'}`}
|
className={`w-2 h-2 rounded-full ${oauthSettings?.autoApproveDomains ? 'bg-color-info' : 'bg-fg-disabled'}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-fg-sub">
|
<span className="text-fg-sub">
|
||||||
Google OAuth 자동 승인 도메인{' '}
|
Google OAuth 자동 승인 도메인{' '}
|
||||||
{oauthSettings?.autoApproveDomains ? (
|
{oauthSettings?.autoApproveDomains ? (
|
||||||
<span className="text-blue-400 font-semibold font-mono">
|
<span className="text-color-info font-semibold font-mono">
|
||||||
{oauthSettings.autoApproveDomains}
|
{oauthSettings.autoApproveDomains}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@ -71,7 +71,7 @@ function SortableMenuItem({
|
|||||||
<circle cx="9" cy="14" r="1.5" />
|
<circle cx="9" cy="14" r="1.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span className="text-fg-disabled text-xs font-mono w-6 text-center shrink-0">
|
<span className="text-fg-disabled text-caption font-mono w-6 text-center shrink-0">
|
||||||
{idx + 1}
|
{idx + 1}
|
||||||
</span>
|
</span>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
@ -152,14 +152,14 @@ function SortableMenuItem({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onMove(idx, -1)}
|
onClick={() => onMove(idx, -1)}
|
||||||
disabled={idx === 0}
|
disabled={idx === 0}
|
||||||
className="w-7 h-7 rounded border border-stroke bg-bg-elevated text-fg-disabled text-xs flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
className="w-7 h-7 rounded border border-stroke bg-bg-elevated text-fg-disabled text-caption flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
▲
|
▲
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onMove(idx, 1)}
|
onClick={() => onMove(idx, 1)}
|
||||||
disabled={idx === totalCount - 1}
|
disabled={idx === totalCount - 1}
|
||||||
className="w-7 h-7 rounded border border-stroke bg-bg-elevated text-fg-disabled text-xs flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
className="w-7 h-7 rounded border border-stroke bg-bg-elevated text-fg-disabled text-caption flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
▼
|
▼
|
||||||
</button>
|
</button>
|
||||||
57
frontend/src/components/admin/components/SystemArchPanel.tsx
Normal file
57
frontend/src/components/admin/components/SystemArchPanel.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { FrameworkTab } from './contents/FrameworkTab';
|
||||||
|
import { TargetArchTab } from './contents/TargetArchTab';
|
||||||
|
import { InterfaceTab } from './contents/InterfaceTab';
|
||||||
|
import { HeterogeneousTab } from './contents/HeterogeneousTab';
|
||||||
|
import { CommonFeaturesTab } from './contents/CommonFeaturesTab';
|
||||||
|
|
||||||
|
type TabId = 'framework' | 'target' | 'interface' | 'heterogeneous' | 'common-features';
|
||||||
|
|
||||||
|
const TABS: { id: TabId; label: string }[] = [
|
||||||
|
{ id: 'framework', label: '표준 프레임워크' },
|
||||||
|
{ id: 'target', label: '목표시스템 아키텍쳐' },
|
||||||
|
{ id: 'interface', label: '시스템 인터페이스 연계' },
|
||||||
|
{ id: 'heterogeneous', label: '이기종시스템연계' },
|
||||||
|
{ id: 'common-features', label: '공통기능' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 기술 스택 테이블 데이터 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function SystemArchPanel() {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>('framework');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke shrink-0">
|
||||||
|
<h2 className="text-body-2 font-semibold text-t1">시스템구조</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 버튼 */}
|
||||||
|
<div className="flex gap-1.5 px-5 py-2.5 border-b border-stroke shrink-0 bg-bg-base">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`px-3 py-1.5 text-caption font-medium rounded transition-colors ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-color-accent text-white'
|
||||||
|
: 'bg-bg-elevated text-t2 hover:bg-bg-card'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 콘텐츠 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{activeTab === 'framework' && <FrameworkTab />}
|
||||||
|
{activeTab === 'target' && <TargetArchTab />}
|
||||||
|
{activeTab === 'interface' && <InterfaceTab />}
|
||||||
|
{activeTab === 'heterogeneous' && <HeterogeneousTab />}
|
||||||
|
{activeTab === 'common-features' && <CommonFeaturesTab />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
563
frontend/src/components/admin/components/UsersPanel.tsx
Normal file
563
frontend/src/components/admin/components/UsersPanel.tsx
Normal file
@ -0,0 +1,563 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
fetchUsers,
|
||||||
|
fetchRoles,
|
||||||
|
fetchOrgs,
|
||||||
|
updateUserApi,
|
||||||
|
approveUserApi,
|
||||||
|
rejectUserApi,
|
||||||
|
assignRolesApi,
|
||||||
|
type UserListItem,
|
||||||
|
type RoleWithPermissions,
|
||||||
|
type OrgItem,
|
||||||
|
} from '@common/services/authApi';
|
||||||
|
import { getRoleColor, statusLabels } from './adminConstants';
|
||||||
|
import { RegisterModal } from './contents/RegisterModal';
|
||||||
|
import { UserDetailModal } from './contents/UserDetailModal';
|
||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
|
||||||
|
const PAGE_SIZE = 15;
|
||||||
|
|
||||||
|
// ─── 포맷 헬퍼 ─────────────────────────────────────────────────
|
||||||
|
export function formatDate(dateStr: string | null) {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return new Date(dateStr).toLocaleString('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function UsersPanel() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||||
|
const [orgFilter, setOrgFilter] = useState<string>('');
|
||||||
|
const [users, setUsers] = useState<UserListItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [allRoles, setAllRoles] = useState<RoleWithPermissions[]>([]);
|
||||||
|
const [allOrgs, setAllOrgs] = useState<OrgItem[]>([]);
|
||||||
|
const [roleEditUserId, setRoleEditUserId] = useState<string | null>(null);
|
||||||
|
const [selectedRoleSns, setSelectedRoleSns] = useState<number[]>([]);
|
||||||
|
const [showRegisterModal, setShowRegisterModal] = useState(false);
|
||||||
|
const [detailUser, setDetailUser] = useState<UserListItem | null>(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const roleDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const loadUsers = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchUsers(searchTerm || undefined, statusFilter || undefined);
|
||||||
|
setUsers(data);
|
||||||
|
setCurrentPage(1);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('사용자 목록 조회 실패:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchTerm, statusFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers();
|
||||||
|
}, [loadUsers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoles().then(setAllRoles).catch(console.error);
|
||||||
|
fetchOrgs().then(setAllOrgs).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (roleDropdownRef.current && !roleDropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setRoleEditUserId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (roleEditUserId) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [roleEditUserId]);
|
||||||
|
|
||||||
|
// ─── 필터링 (org 클라이언트 사이드) ───────────────────────────
|
||||||
|
const filteredUsers = orgFilter ? users.filter((u) => String(u.orgSn) === orgFilter) : users;
|
||||||
|
|
||||||
|
// ─── 페이지네이션 ──────────────────────────────────────────────
|
||||||
|
const totalCount = filteredUsers.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
|
||||||
|
const pagedUsers = filteredUsers.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
||||||
|
|
||||||
|
// ─── 액션 핸들러 ──────────────────────────────────────────────
|
||||||
|
const handleUnlock = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
await updateUserApi(userId, { status: 'ACTIVE' });
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('계정 잠금 해제 실패:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApprove = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
await approveUserApi(userId);
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('사용자 승인 실패:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
await rejectUserApi(userId);
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('사용자 거절 실패:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeactivate = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
await updateUserApi(userId, { status: 'INACTIVE' });
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('사용자 비활성화 실패:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActivate = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
await updateUserApi(userId, { status: 'ACTIVE' });
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('사용자 활성화 실패:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenRoleEdit = (user: UserListItem) => {
|
||||||
|
setRoleEditUserId(user.id);
|
||||||
|
setSelectedRoleSns(user.roleSns || []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRoleSelection = (roleSn: number) => {
|
||||||
|
setSelectedRoleSns((prev) =>
|
||||||
|
prev.includes(roleSn) ? prev.filter((s) => s !== roleSn) : [...prev, roleSn],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveRoles = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
await assignRolesApi(userId, selectedRoleSns);
|
||||||
|
await loadUsers();
|
||||||
|
setRoleEditUserId(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('역할 할당 실패:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingCount = users.filter((u) => u.status === 'PENDING').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-title-1 font-bold text-fg font-korean">사용자 관리</h1>
|
||||||
|
<p className="text-caption text-fg-disabled mt-1 font-korean">
|
||||||
|
총 {filteredUsers.length}명
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<span className="px-2.5 py-1 text-caption font-bold rounded-full bg-[rgba(234,179,8,0.15)] text-color-caution border border-[rgba(234,179,8,0.3)] animate-pulse font-korean">
|
||||||
|
승인대기 {pendingCount}명
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* 소속 필터 */}
|
||||||
|
<select
|
||||||
|
value={orgFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setOrgFilter(e.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
|
>
|
||||||
|
<option value="">전체 소속</option>
|
||||||
|
{allOrgs.map((org) => (
|
||||||
|
<option key={org.orgSn} value={String(org.orgSn)}>
|
||||||
|
{org.orgAbbrNm || org.orgNm}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{/* 상태 필터 */}
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
|
>
|
||||||
|
<option value="">전체 상태</option>
|
||||||
|
<option value="PENDING">승인대기</option>
|
||||||
|
<option value="ACTIVE">활성</option>
|
||||||
|
<option value="LOCKED">잠김</option>
|
||||||
|
<option value="INACTIVE">비활성</option>
|
||||||
|
<option value="REJECTED">거절됨</option>
|
||||||
|
</select>
|
||||||
|
{/* 텍스트 검색 */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="이름, 계정 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-56 px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRegisterModal(true)}
|
||||||
|
className="px-4 py-2 text-caption font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
+ 사용자 등록
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-fg-disabled text-body-2 font-korean">
|
||||||
|
불러오는 중...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-stroke bg-bg-surface">
|
||||||
|
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean w-10">
|
||||||
|
번호
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-mono">
|
||||||
|
ID
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean">
|
||||||
|
사용자명
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean">
|
||||||
|
직급
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean">
|
||||||
|
소속
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean">
|
||||||
|
이메일
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean">
|
||||||
|
역할
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean">
|
||||||
|
승인상태
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-right text-caption font-semibold text-fg-disabled font-korean">
|
||||||
|
관리
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pagedUsers.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={9}
|
||||||
|
className="px-6 py-10 text-center text-caption text-fg-disabled font-korean"
|
||||||
|
>
|
||||||
|
조회된 사용자가 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
pagedUsers.map((user, idx) => {
|
||||||
|
const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE;
|
||||||
|
const rowNum = (currentPage - 1) * PAGE_SIZE + idx + 1;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={user.id}
|
||||||
|
className="border-b border-stroke hover:bg-[rgba(6,182,212,0.04)] transition-colors"
|
||||||
|
>
|
||||||
|
{/* 번호 */}
|
||||||
|
<td className="px-4 py-3 text-caption text-fg-disabled font-mono text-center">
|
||||||
|
{rowNum}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* ID(account) */}
|
||||||
|
<td className="px-4 py-3 text-caption text-fg-sub font-mono">
|
||||||
|
{user.account}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 사용자명 */}
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setDetailUser(user)}
|
||||||
|
className="text-caption text-color-accent font-semibold font-korean hover:underline"
|
||||||
|
>
|
||||||
|
{user.name}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 직급 */}
|
||||||
|
<td className="px-4 py-3 text-caption text-fg-sub font-korean">
|
||||||
|
{user.rank || '-'}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 소속 */}
|
||||||
|
<td className="px-4 py-3 text-caption text-fg-sub font-korean">
|
||||||
|
{user.orgAbbr || user.orgName || '-'}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 이메일 */}
|
||||||
|
<td className="px-4 py-3 text-caption text-fg-disabled font-mono">
|
||||||
|
{user.email || '-'}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 역할 (인라인 편집) */}
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap gap-1 cursor-pointer"
|
||||||
|
onClick={() => handleOpenRoleEdit(user)}
|
||||||
|
title="클릭하여 역할 변경"
|
||||||
|
>
|
||||||
|
{user.roles.length > 0 ? (
|
||||||
|
user.roles.map((roleCode) => {
|
||||||
|
const roleName =
|
||||||
|
allRoles.find((r) => r.code === roleCode)?.name || roleCode;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={roleCode}
|
||||||
|
className="px-2 py-0.5 text-caption font-semibold rounded-md font-korean text-fg-sub bg-bg-elevated border border-stroke-light"
|
||||||
|
>
|
||||||
|
{roleName}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<span className="text-caption text-fg-disabled font-korean">
|
||||||
|
역할 없음
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-caption text-fg-disabled ml-0.5">
|
||||||
|
<svg
|
||||||
|
width="10"
|
||||||
|
height="10"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{roleEditUserId === user.id && (
|
||||||
|
<div
|
||||||
|
ref={roleDropdownRef}
|
||||||
|
className="absolute z-20 top-full left-0 mt-1 p-2 bg-bg-surface border border-stroke rounded-lg shadow-lg min-w-[200px]"
|
||||||
|
>
|
||||||
|
<div className="text-caption text-fg-disabled font-korean font-semibold mb-1.5 px-1">
|
||||||
|
역할 선택
|
||||||
|
</div>
|
||||||
|
{allRoles.map((role, roleIdx) => {
|
||||||
|
const color = getRoleColor(role.code, roleIdx);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={role.sn}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 hover:bg-[rgba(6,182,212,0.08)] rounded cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedRoleSns.includes(role.sn)}
|
||||||
|
onChange={() => toggleRoleSelection(role.sn)}
|
||||||
|
style={{ accentColor: color }}
|
||||||
|
/>
|
||||||
|
<span className="text-caption font-korean" style={{ color }}>
|
||||||
|
{role.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-caption text-fg-disabled font-mono">
|
||||||
|
{role.code}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="flex justify-end gap-2 mt-2 pt-2 border-t border-stroke">
|
||||||
|
<button
|
||||||
|
onClick={() => setRoleEditUserId(null)}
|
||||||
|
className="px-3 py-1 text-caption text-fg-disabled border border-stroke rounded hover:bg-bg-surface-hover font-korean"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveRoles(user.id)}
|
||||||
|
disabled={selectedRoleSns.length === 0}
|
||||||
|
className="px-3 py-1 text-caption font-semibold rounded bg-color-accent text-bg-0 hover:shadow-[0_0_8px_rgba(6,182,212,0.3)] disabled:opacity-50 font-korean"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 승인상태 */}
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 text-caption font-semibold font-korean ${statusInfo.color}`}
|
||||||
|
>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot}`} />
|
||||||
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 관리 */}
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{user.status === 'PENDING' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleApprove(user.id)}
|
||||||
|
className="px-2 py-1 text-caption font-semibold text-color-success border border-color-success rounded hover:bg-[rgba(34,197,94,0.12)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
승인
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleReject(user.id)}
|
||||||
|
className="px-2 py-1 text-caption font-semibold text-color-danger border border-color-danger rounded hover:bg-[rgba(239,68,68,0.12)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
거절
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{user.status === 'LOCKED' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleUnlock(user.id)}
|
||||||
|
className="px-2 py-1 text-caption font-semibold text-color-caution border border-color-caution rounded hover:bg-[rgba(234,179,8,0.12)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
잠금해제
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{user.status === 'ACTIVE' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeactivate(user.id)}
|
||||||
|
className="px-2 py-1 text-caption font-semibold text-fg-disabled border border-stroke rounded hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
비활성화
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(user.status === 'INACTIVE' || user.status === 'REJECTED') && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleActivate(user.id)}
|
||||||
|
className="px-2 py-1 text-caption font-semibold text-color-success border border-color-success rounded hover:bg-[rgba(34,197,94,0.12)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
활성화
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{!loading && totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke bg-bg-surface">
|
||||||
|
<span className="text-label-2 text-fg-disabled font-korean">
|
||||||
|
{(currentPage - 1) * PAGE_SIZE + 1}–{Math.min(currentPage * PAGE_SIZE, totalCount)} /{' '}
|
||||||
|
{totalCount}명
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(6,182,212,0.08)] disabled:opacity-40 transition-all font-korean"
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
|
.filter((p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
|
||||||
|
.reduce<(number | '...')[]>((acc, p, i, arr) => {
|
||||||
|
if (
|
||||||
|
i > 0 &&
|
||||||
|
typeof arr[i - 1] === 'number' &&
|
||||||
|
(p as number) - (arr[i - 1] as number) > 1
|
||||||
|
) {
|
||||||
|
acc.push('...');
|
||||||
|
}
|
||||||
|
acc.push(p);
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
.map((item, i) =>
|
||||||
|
item === '...' ? (
|
||||||
|
<span key={`ellipsis-${i}`} className="px-2 text-label-2 text-fg-disabled">
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
onClick={() => setCurrentPage(item as number)}
|
||||||
|
className="px-2.5 py-1 text-label-2 border rounded transition-all font-mono"
|
||||||
|
style={
|
||||||
|
currentPage === item
|
||||||
|
? {
|
||||||
|
borderColor: 'var(--color-accent)',
|
||||||
|
color: 'var(--color-accent)',
|
||||||
|
background: 'rgba(6,182,212,0.1)',
|
||||||
|
}
|
||||||
|
: { borderColor: 'var(--border)', color: 'var(--fg-disabled)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(6,182,212,0.08)] disabled:opacity-40 transition-all font-korean"
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사용자 등록 모달 */}
|
||||||
|
{showRegisterModal && (
|
||||||
|
<RegisterModal
|
||||||
|
allRoles={allRoles}
|
||||||
|
allOrgs={allOrgs}
|
||||||
|
onClose={() => setShowRegisterModal(false)}
|
||||||
|
onSuccess={loadUsers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 사용자 상세/수정 모달 */}
|
||||||
|
{detailUser && (
|
||||||
|
<UserDetailModal
|
||||||
|
user={detailUser}
|
||||||
|
allRoles={allRoles}
|
||||||
|
allOrgs={allOrgs}
|
||||||
|
onClose={() => setDetailUser(null)}
|
||||||
|
onUpdated={() => {
|
||||||
|
loadUsers();
|
||||||
|
// 최신 정보로 모달 갱신을 위해 닫지 않음
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UsersPanel;
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { fetchOrganizations } from '@tabs/assets/services/assetsApi';
|
import { fetchOrganizations } from '@components/assets/services/assetsApi';
|
||||||
import type { AssetOrgCompat } from '@tabs/assets/services/assetsApi';
|
import type { AssetOrgCompat } from '@interfaces/assets/AssetsInterface';
|
||||||
import { typeTagCls } from '@tabs/assets/components/assetTypes';
|
import { typeTagCls } from '@components/assets/components/assetTypes';
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
@ -89,8 +89,8 @@ function VesselMaterialsPanel() {
|
|||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-bold text-fg font-korean">방제선 보유자재 현황</h1>
|
<h1 className="text-title-1 font-bold text-fg font-korean">방제선 보유자재 현황</h1>
|
||||||
<p className="text-xs text-fg-disabled mt-1 font-korean">
|
<p className="text-caption text-fg-disabled mt-1 font-korean">
|
||||||
총 {filtered.length}개 기관 (방제선 보유)
|
총 {filtered.length}개 기관 (방제선 보유)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -98,7 +98,7 @@ function VesselMaterialsPanel() {
|
|||||||
<select
|
<select
|
||||||
value={regionFilter}
|
value={regionFilter}
|
||||||
onChange={handleFilterChange(setRegionFilter)}
|
onChange={handleFilterChange(setRegionFilter)}
|
||||||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="전체">전체 관할청</option>
|
<option value="전체">전체 관할청</option>
|
||||||
<option value="남해">남해청</option>
|
<option value="남해">남해청</option>
|
||||||
@ -110,7 +110,7 @@ function VesselMaterialsPanel() {
|
|||||||
<select
|
<select
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
onChange={handleFilterChange(setTypeFilter)}
|
onChange={handleFilterChange(setTypeFilter)}
|
||||||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="전체">전체 유형</option>
|
<option value="전체">전체 유형</option>
|
||||||
{typeOptions.map((t) => (
|
{typeOptions.map((t) => (
|
||||||
@ -127,11 +127,11 @@ function VesselMaterialsPanel() {
|
|||||||
setSearchTerm(e.target.value);
|
setSearchTerm(e.target.value);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
className="w-56 px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
className="w-56 px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={load}
|
onClick={load}
|
||||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-bg-elevated border border-stroke text-fg-sub hover:border-color-accent hover:text-color-accent transition-all font-korean"
|
className="px-4 py-2 text-caption font-semibold rounded-md bg-bg-elevated border border-stroke text-fg-sub hover:border-color-accent hover:text-color-accent transition-all font-korean"
|
||||||
>
|
>
|
||||||
새로고침
|
새로고침
|
||||||
</button>
|
</button>
|
||||||
@ -141,7 +141,7 @@ function VesselMaterialsPanel() {
|
|||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">
|
<div className="flex items-center justify-center h-32 text-fg-disabled text-body-2 font-korean">
|
||||||
불러오는 중...
|
불러오는 중...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -188,7 +188,7 @@ function VesselMaterialsPanel() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={11}
|
colSpan={11}
|
||||||
className="px-6 py-10 text-center text-xs text-fg-disabled font-korean"
|
className="px-6 py-10 text-center text-caption text-fg-disabled font-korean"
|
||||||
>
|
>
|
||||||
조회된 기관이 없습니다.
|
조회된 기관이 없습니다.
|
||||||
</td>
|
</td>
|
||||||
@ -327,16 +327,11 @@ function VesselMaterialsPanel() {
|
|||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
onClick={() => setCurrentPage(p)}
|
onClick={() => setCurrentPage(p)}
|
||||||
className="px-2.5 py-1 text-label-2 border rounded transition-colors"
|
className={`px-2.5 py-1 text-label-2 border rounded transition-colors ${
|
||||||
style={
|
|
||||||
p === safePage
|
p === safePage
|
||||||
? {
|
? 'border-color-accent text-color-accent bg-[rgba(6,182,212,0.08)]'
|
||||||
borderColor: 'var(--color-accent)',
|
: 'border-stroke text-fg-sub'
|
||||||
color: 'var(--color-accent)',
|
}`}
|
||||||
background: 'rgba(6,182,212,0.1)',
|
|
||||||
}
|
|
||||||
: { borderColor: 'var(--border)', color: 'var(--text-2)' }
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{p}
|
{p}
|
||||||
</button>
|
</button>
|
||||||
@ -10,18 +10,10 @@ interface SignalSlot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── 상수 ──────────────────────────────────────────────────
|
// ─── 상수 ──────────────────────────────────────────────────
|
||||||
const SOURCE_COLORS: Record<SignalSource, string> = {
|
|
||||||
VTS: '#3b82f6',
|
|
||||||
'VTS-AIS': '#a855f7',
|
|
||||||
'V-PASS': '#22c55e',
|
|
||||||
'E-NAVI': '#f97316',
|
|
||||||
'S&P AIS': '#ec4899',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
ok: '#22c55e',
|
ok: 'var(--color-success)',
|
||||||
warn: '#eab308',
|
warn: 'var(--color-caution)',
|
||||||
error: '#ef4444',
|
error: 'var(--color-danger)',
|
||||||
none: 'rgba(255,255,255,0.06)',
|
none: 'rgba(255,255,255,0.06)',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -141,18 +133,18 @@ export default function VesselSignalPanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-6 py-3 border-b border-stroke-1">
|
<div className="flex items-center justify-between px-6 py-3 border-b border-stroke">
|
||||||
<h2 className="text-sm font-semibold text-fg">선박신호 수신 현황</h2>
|
<h2 className="text-body-2 font-semibold text-fg">선박신호 수신 현황</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={date}
|
value={date}
|
||||||
onChange={(e) => setDate(e.target.value)}
|
onChange={(e) => setDate(e.target.value)}
|
||||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-fg"
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-fg"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={load}
|
onClick={load}
|
||||||
className="px-3 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-fg-sub hover:bg-bg-card"
|
className="px-3 py-1 text-caption rounded bg-bg-elevated border border-stroke text-fg-sub hover:bg-bg-card"
|
||||||
>
|
>
|
||||||
새로고침
|
새로고침
|
||||||
</button>
|
</button>
|
||||||
@ -163,7 +155,7 @@ export default function VesselSignalPanel() {
|
|||||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<span className="text-xs text-fg-disabled">로딩 중...</span>
|
<span className="text-caption text-fg-disabled">로딩 중...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@ -172,7 +164,6 @@ export default function VesselSignalPanel() {
|
|||||||
{/* 시간축 높이 맞춤 빈칸 */}
|
{/* 시간축 높이 맞춤 빈칸 */}
|
||||||
<div className="h-5 mb-3" />
|
<div className="h-5 mb-3" />
|
||||||
{SIGNAL_SOURCES.map((src) => {
|
{SIGNAL_SOURCES.map((src) => {
|
||||||
const c = SOURCE_COLORS[src];
|
|
||||||
const st = stats.find((s) => s.src === src)!;
|
const st = stats.find((s) => s.src === src)!;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -180,10 +171,10 @@ export default function VesselSignalPanel() {
|
|||||||
className="flex flex-col justify-center mb-4"
|
className="flex flex-col justify-center mb-4"
|
||||||
style={{ height: 20 }}
|
style={{ height: 20 }}
|
||||||
>
|
>
|
||||||
<span className="text-label-1 font-semibold leading-tight" style={{ color: c }}>
|
<span className="text-label-1 font-semibold leading-tight text-fg">
|
||||||
{src}
|
{src}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-caption font-mono text-text-4 mt-0.5">{st.rate}%</span>
|
<span className="text-caption font-mono text-fg-sub mt-0.5">{st.rate}%</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -0,0 +1,212 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { AuditLogEntry, DeidentifyTask } from '../DeidentifyPanel';
|
||||||
|
import { MOCK_AUDIT_LOGS } from '../DeidentifyPanel';
|
||||||
|
|
||||||
|
function getAuditResultClass(type: AuditLogEntry['resultType']): string {
|
||||||
|
switch (type) {
|
||||||
|
case '성공':
|
||||||
|
return 'text-emerald-400 bg-emerald-500/10';
|
||||||
|
case '진행중':
|
||||||
|
return 'text-cyan-400 bg-cyan-500/10';
|
||||||
|
case '실패':
|
||||||
|
return 'text-red-400 bg-red-500/10';
|
||||||
|
case '거부':
|
||||||
|
return 'text-yellow-400 bg-yellow-500/10';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditLogModalProps {
|
||||||
|
task: DeidentifyTask;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditLogModal({ task, onClose }: AuditLogModalProps) {
|
||||||
|
const logs = MOCK_AUDIT_LOGS[task.id] ?? [];
|
||||||
|
const [selectedLog, setSelectedLog] = useState<AuditLogEntry | null>(null);
|
||||||
|
const [filterOperator, setFilterOperator] = useState('모두');
|
||||||
|
const [startDate, setStartDate] = useState('2026-04-01');
|
||||||
|
const [endDate, setEndDate] = useState('2026-04-11');
|
||||||
|
|
||||||
|
const operators = ['모두', ...Array.from(new Set(logs.map((l) => l.operator)))];
|
||||||
|
const filteredLogs = logs.filter((l) => {
|
||||||
|
if (filterOperator !== '모두' && l.operator !== filterOperator) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||||
|
<div className="bg-bg-card border border-stroke rounded-lg shadow-2xl w-full max-w-5xl max-h-[85vh] flex flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke shrink-0">
|
||||||
|
<h3 className="text-body-2 font-semibold text-t1">감시 감독 (감사로그) — {task.name}</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-t3 hover:text-t1 transition-colors text-lg leading-none"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 바 */}
|
||||||
|
<div className="flex items-center gap-3 px-5 py-2.5 border-b border-stroke shrink-0 bg-bg-base">
|
||||||
|
<span className="text-caption text-t3">기간:</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-caption text-t3">~</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-caption text-t3 ml-2">작업자:</span>
|
||||||
|
<select
|
||||||
|
value={filterOperator}
|
||||||
|
onChange={(e) => setFilterOperator(e.target.value)}
|
||||||
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
|
||||||
|
>
|
||||||
|
{operators.map((op) => (
|
||||||
|
<option key={op} value={op}>
|
||||||
|
{op}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 로그 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto px-5 py-3">
|
||||||
|
<table className="w-full text-caption border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
|
{['시간', '작업자', '작업', '대상 데이터', '결과', '상세'].map((h) => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredLogs.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-3 py-8 text-center text-t3">
|
||||||
|
감사로그가 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredLogs.map((log) => (
|
||||||
|
<tr
|
||||||
|
key={log.id}
|
||||||
|
className={`border-b border-stroke hover:bg-bg-surface/50 cursor-pointer ${selectedLog?.id === log.id ? 'bg-bg-surface/70' : ''}`}
|
||||||
|
onClick={() => setSelectedLog(log)}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
||||||
|
{log.time.split(' ')[1]}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-t1 whitespace-nowrap">{log.operator}</td>
|
||||||
|
<td className="px-3 py-2 text-t2 whitespace-nowrap">{log.action}</td>
|
||||||
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
||||||
|
{log.targetData}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2 py-0.5 rounded text-label-2 font-medium ${getAuditResultClass(log.resultType)}`}
|
||||||
|
>
|
||||||
|
{log.result}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedLog(log);
|
||||||
|
}}
|
||||||
|
className="px-2 py-0.5 text-label-2 rounded bg-bg-elevated hover:bg-bg-card text-color-accent transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
보기
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 로그 상세 정보 */}
|
||||||
|
{selectedLog && (
|
||||||
|
<div className="px-5 py-3 border-t border-stroke shrink-0 bg-bg-base">
|
||||||
|
<h4 className="text-caption font-semibold text-t1 mb-2">로그 상세 정보</h4>
|
||||||
|
<div className="bg-bg-elevated border border-stroke rounded p-3 text-caption grid grid-cols-2 gap-x-6 gap-y-1.5">
|
||||||
|
<div>
|
||||||
|
<span className="text-t3">로그ID:</span>{' '}
|
||||||
|
<span className="text-t1 font-mono">{selectedLog.id}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-t3">타임스탬프:</span>{' '}
|
||||||
|
<span className="text-t1 font-mono">{selectedLog.time}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-t3">작업자:</span>{' '}
|
||||||
|
<span className="text-t1">
|
||||||
|
{selectedLog.operator} ({selectedLog.operatorId})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-t3">작업 유형:</span>{' '}
|
||||||
|
<span className="text-t1">{selectedLog.action}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-t3">대상:</span>{' '}
|
||||||
|
<span className="text-t1">
|
||||||
|
{selectedLog.targetData} ({selectedLog.detail.dataCount.toLocaleString()}건)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-t3">적용 규칙:</span>{' '}
|
||||||
|
<span className="text-t1">{selectedLog.detail.rulesApplied}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-t3">결과:</span>{' '}
|
||||||
|
<span className="text-t1">
|
||||||
|
{selectedLog.result} (처리: {selectedLog.detail.processedCount.toLocaleString()},
|
||||||
|
오류: {selectedLog.detail.errorCount})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-t3">IP 주소:</span>{' '}
|
||||||
|
<span className="text-t1 font-mono">{selectedLog.ip}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-t3">브라우저:</span>{' '}
|
||||||
|
<span className="text-t1">{selectedLog.browser}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 하단 버튼 */}
|
||||||
|
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-stroke shrink-0">
|
||||||
|
<button className="px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors">
|
||||||
|
상세내용 다운로드 (암호화됨)
|
||||||
|
</button>
|
||||||
|
<button className="px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors">
|
||||||
|
기간별 보고서 생성
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user