Merge pull request 'release: Phase 1~5 리팩토링 통합 릴리즈' (#26) from develop into main
Some checks failed
Build and Deploy Wing-Demo / build-and-deploy (push) Failing after 17s
Some checks failed
Build and Deploy Wing-Demo / build-and-deploy (push) Failing after 17s
Reviewed-on: #26
This commit is contained in:
커밋
d702f0574b
2
.gitignore
vendored
2
.gitignore
vendored
@ -29,7 +29,7 @@ backend/data/*.db-wal
|
||||
|
||||
# Large reference data (keep locally, do not commit)
|
||||
_reference/
|
||||
scat/
|
||||
/scat/
|
||||
참고용/
|
||||
논문/
|
||||
|
||||
|
||||
50
CLAUDE.md
50
CLAUDE.md
@ -6,9 +6,10 @@
|
||||
|
||||
- **프로젝트 타입**: react-ts (모노레포)
|
||||
- **Frontend**: React 19 + Vite 7 + TypeScript 5.9 + Tailwind CSS 3
|
||||
- **Backend**: Express 4 + better-sqlite3 + TypeScript
|
||||
- **Backend**: Express 4 + PostgreSQL (pg) + TypeScript
|
||||
- **DB**: PostgreSQL 16 + PostGIS (wing 운영DB + wing_auth 인증DB)
|
||||
- **상태관리**: Zustand (클라이언트), TanStack Query (서버)
|
||||
- **지도**: Leaflet, OpenLayers
|
||||
- **지도**: Leaflet + react-leaflet
|
||||
- **실시간**: Socket.IO
|
||||
|
||||
## 빌드/실행
|
||||
@ -49,16 +50,27 @@ wing/
|
||||
├── frontend/ React 19 + Vite + TypeScript + Tailwind
|
||||
│ └── src/
|
||||
│ ├── App.tsx 메인 (탭 라우팅, 감사 로그 자동 기록)
|
||||
│ ├── components/ UI 컴포넌트
|
||||
│ │ ├── auth/ 로그인 페이지
|
||||
│ │ ├── views/ 탭별 페이지 뷰 (11개)
|
||||
│ │ ├── layout/ MainLayout, TopBar, LeftPanel, RightPanel
|
||||
│ │ └── ... analysis, board, incidents, map, weather 등
|
||||
│ ├── hooks/ 커스텀 훅
|
||||
│ ├── services/ API 서비스 (api, authApi, weatherApi 등)
|
||||
│ ├── store/ Zustand (authStore, menuStore)
|
||||
│ ├── types/ 타입 정의
|
||||
│ └── utils/ 유틸리티
|
||||
│ ├── common/ 공통 모듈 (@common/ alias)
|
||||
│ │ ├── components/ auth/, layer/, layout/, map/, ui/
|
||||
│ │ ├── hooks/ useLayers, useSubMenu
|
||||
│ │ ├── services/ api.ts, authApi.ts, layerService.ts
|
||||
│ │ ├── store/ authStore, menuStore (Zustand)
|
||||
│ │ ├── types/ backtrack, boomLine, hns, navigation
|
||||
│ │ ├── utils/ coordinates, geo, sanitize
|
||||
│ │ ├── data/ layerData.ts (UI 레이어 트리)
|
||||
│ │ └── mock/ vesselMockData, backtrackMockData
|
||||
│ └── tabs/ 탭 단위 패키지 (@tabs/ alias)
|
||||
│ ├── prediction/ 확산 예측 (OilSpillView, LeftPanel 등)
|
||||
│ ├── hns/ HNS 분석 (HNSView, HNSSubstanceView 등)
|
||||
│ ├── rescue/ 구조 시나리오
|
||||
│ ├── aerial/ 항공 방제
|
||||
│ ├── weather/ 해양 기상 (오버레이, hooks, services)
|
||||
│ ├── incidents/ 사건/사고 관리
|
||||
│ ├── board/ 게시판
|
||||
│ ├── reports/ 보고서
|
||||
│ ├── assets/ 자산 관리
|
||||
│ ├── scat/ Pre-SCAT 조사
|
||||
│ └── admin/ 관리자 (사용자/권한/메뉴/설정)
|
||||
├── backend/ Express + TypeScript
|
||||
│ └── src/
|
||||
│ ├── server.ts 진입점 + 라우터 등록
|
||||
@ -68,15 +80,23 @@ wing/
|
||||
│ ├── settings/ 시스템 설정
|
||||
│ ├── menus/ 메뉴 설정
|
||||
│ ├── audit/ 감사 로그
|
||||
│ ├── hns/ HNS 물질 검색 API
|
||||
│ ├── routes/ 레이어, 시뮬레이션
|
||||
│ ├── middleware/ 보안 (입력 살균, rate-limit)
|
||||
│ └── db/ DB 연결 (PostgreSQL, SQLite)
|
||||
├── database/ SQL 초기화 스크립트
|
||||
├── docs/ 개발 문서 (README, 가이드, 변경이력)
|
||||
│ └── db/ DB 연결 (wingDb, authDb), seed
|
||||
├── database/ SQL 스크립트
|
||||
│ ├── init.sql wing DB 초기 스키마
|
||||
│ ├── auth_init.sql wing_auth DB 초기 스키마
|
||||
│ └── migration/ 마이그레이션 (001_layer, 002_hns_substance)
|
||||
├── docs/ 개발 문서
|
||||
├── .claude/ 팀 워크플로우 (rules, skills, scripts)
|
||||
└── .githooks/ Git hooks (pre-commit, commit-msg)
|
||||
```
|
||||
|
||||
### Path Alias
|
||||
- `@common/*` → `src/common/*` (공통 모듈)
|
||||
- `@tabs/*` → `src/tabs/*` (탭 패키지)
|
||||
|
||||
## 팀 컨벤션
|
||||
`.claude/rules/` 디렉토리 참조:
|
||||
- `team-policy.md` — 보안/품질 정책
|
||||
|
||||
42
README.md
42
README.md
@ -90,17 +90,26 @@ cd frontend && npm install && npm run dev # localhost:5173
|
||||
wing/
|
||||
├── frontend/ React 19 + Vite + TypeScript + Tailwind
|
||||
│ └── src/
|
||||
│ ├── App.tsx 메인 (탭 라우팅, 감사 로그 자동 기록)
|
||||
│ ├── components/ UI 컴포넌트
|
||||
│ │ ├── auth/ 로그인 페이지
|
||||
│ │ ├── views/ 각 탭별 페이지 뷰 (11개)
|
||||
│ │ ├── layout/ MainLayout, TopBar, LeftPanel, RightPanel
|
||||
│ │ └── ... analysis, board, incidents, map, weather 등
|
||||
│ ├── hooks/ 커스텀 훅
|
||||
│ ├── services/ API 서비스 (api, authApi, weatherApi 등)
|
||||
│ ├── store/ Zustand 상태 (authStore, menuStore)
|
||||
│ ├── types/ 타입 정의
|
||||
│ └── utils/ 유틸리티
|
||||
│ ├── App.tsx 메인 (탭 라우팅, 감사 로그)
|
||||
│ ├── common/ 공통 모듈 (@common/ alias)
|
||||
│ │ ├── components/ auth/, layer/, layout/, map/, ui/
|
||||
│ │ ├── hooks/ useLayers, useSubMenu
|
||||
│ │ ├── services/ api.ts, authApi.ts, layerService.ts
|
||||
│ │ ├── store/ authStore, menuStore (Zustand)
|
||||
│ │ ├── types/ backtrack, boomLine, hns, navigation
|
||||
│ │ └── utils/ coordinates, geo, sanitize
|
||||
│ └── tabs/ 탭 단위 패키지 (@tabs/ alias)
|
||||
│ ├── prediction/ 확산 예측
|
||||
│ ├── hns/ HNS 분석
|
||||
│ ├── rescue/ 구조 시나리오
|
||||
│ ├── aerial/ 항공 방제
|
||||
│ ├── weather/ 해양 기상
|
||||
│ ├── incidents/ 사건/사고
|
||||
│ ├── board/ 게시판
|
||||
│ ├── reports/ 보고서
|
||||
│ ├── assets/ 자산 관리
|
||||
│ ├── scat/ Pre-SCAT
|
||||
│ └── admin/ 관리자
|
||||
├── backend/ Express + TypeScript
|
||||
│ └── src/
|
||||
│ ├── server.ts 진입점 + 라우터 등록
|
||||
@ -110,10 +119,11 @@ wing/
|
||||
│ ├── settings/ 시스템 설정
|
||||
│ ├── menus/ 메뉴 설정
|
||||
│ ├── audit/ 감사 로그
|
||||
│ ├── hns/ HNS 물질 검색 API
|
||||
│ ├── routes/ 레이어, 시뮬레이션
|
||||
│ ├── middleware/ 보안 (입력 살균, rate-limit)
|
||||
│ └── db/ DB 연결 (PostgreSQL, SQLite)
|
||||
├── database/ SQL 초기화 스크립트
|
||||
│ └── db/ DB 연결 (wingDb, authDb), seed
|
||||
├── database/ SQL 스크립트 + 마이그레이션
|
||||
├── docs/ 개발 문서
|
||||
├── .claude/ 팀 워크플로우 (rules, skills, scripts)
|
||||
└── .githooks/ Git hooks (pre-commit, commit-msg)
|
||||
@ -126,12 +136,12 @@ wing/
|
||||
| 영역 | 기술 |
|
||||
|------|------|
|
||||
| Frontend | React 19, Vite 7, TypeScript 5.9, Tailwind CSS 3 |
|
||||
| Backend | Express 4, TypeScript, better-sqlite3 (레이어), pg (인증) |
|
||||
| Backend | Express 4, TypeScript, PostgreSQL (pg) |
|
||||
| 상태 관리 | Zustand (클라이언트), TanStack Query (서버) |
|
||||
| 지도 | Leaflet, OpenLayers |
|
||||
| 지도 | Leaflet + react-leaflet |
|
||||
| 실시간 | Socket.IO |
|
||||
| 인증 | JWT (HttpOnly Cookie), Google OAuth |
|
||||
| DB | PostgreSQL 16 + PostGIS (운영 DB 직접 연결), SQLite |
|
||||
| DB | PostgreSQL 16 + PostGIS (wing 운영DB + wing_auth 인증DB) |
|
||||
| CI/CD | Gitea Actions |
|
||||
|
||||
---
|
||||
|
||||
402
backend/package-lock.json
generated
402
backend/package-lock.json
generated
@ -9,7 +9,6 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.3.1",
|
||||
@ -22,7 +21,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
@ -513,16 +511,6 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/better-sqlite3": {
|
||||
"version": "7.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
@ -773,17 +761,6 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "11.10.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
||||
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||
@ -793,26 +770,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
@ -873,30 +830,6 @@
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
@ -941,12 +874,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@ -1077,30 +1004,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@ -1120,15 +1023,6 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||
@ -1191,15 +1085,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@ -1287,15 +1172,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
@ -1404,12 +1280,6 @@
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
@ -1489,12 +1359,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@ -1598,12 +1462,6 @@
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
@ -1729,38 +1587,12 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
@ -1978,18 +1810,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
@ -2005,15 +1825,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
@ -2023,24 +1834,12 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@ -2050,18 +1849,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.87.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz",
|
||||
"integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
|
||||
@ -2153,15 +1940,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@ -2336,32 +2114,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@ -2375,16 +2127,6 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
|
||||
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
@ -2436,35 +2178,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
@ -2693,51 +2406,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
@ -2756,15 +2424,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
@ -2861,43 +2520,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@ -2927,18 +2549,6 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
@ -2982,12 +2592,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
@ -3121,12 +2725,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.3.1",
|
||||
@ -23,7 +22,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import { verifyToken, getTokenFromCookie } from './jwtProvider.js'
|
||||
import type { JwtPayload } from './jwtProvider.js'
|
||||
import { getUserInfo } from './authService.js'
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: JwtPayload
|
||||
resolvedPermissions?: Record<string, string[]>
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -43,3 +45,43 @@ export function requireRole(...roles: string[]) {
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스 + 오퍼레이션 기반 권한 검사 미들웨어.
|
||||
*
|
||||
* OPER_CD는 HTTP Method가 아닌 비즈니스 의미로 결정한다.
|
||||
* 오퍼레이션 미지정 시 기본 'READ'.
|
||||
*
|
||||
* 사용 예:
|
||||
* router.post('/notice/list', requirePermission('board:notice', 'READ'), handler)
|
||||
* router.post('/notice/create', requirePermission('board:notice', 'CREATE'), handler)
|
||||
* router.post('/notice/update', requirePermission('board:notice', 'UPDATE'), handler)
|
||||
* router.post('/notice/delete', requirePermission('board:notice', 'DELETE'), handler)
|
||||
*/
|
||||
export function requirePermission(resource: string, operation: string = 'READ') {
|
||||
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: '인증이 필요합니다.' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// req에 캐싱된 permissions 재사용 (요청당 1회만 DB 조회)
|
||||
if (!req.resolvedPermissions) {
|
||||
const userInfo = await getUserInfo(req.user.sub)
|
||||
req.resolvedPermissions = userInfo.permissions
|
||||
}
|
||||
|
||||
const allowedOps = req.resolvedPermissions[resource]
|
||||
if (allowedOps && allowedOps.includes(operation)) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
res.status(403).json({ error: '접근 권한이 없습니다.' })
|
||||
} catch (err) {
|
||||
console.error('[auth] 권한 확인 오류:', err)
|
||||
res.status(500).json({ error: '권한 확인 중 오류가 발생했습니다.' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@ import bcrypt from 'bcrypt'
|
||||
import { authPool } from '../db/authDb.js'
|
||||
import { signToken, setTokenCookie } from './jwtProvider.js'
|
||||
import type { Response } from 'express'
|
||||
import { resolvePermissions, makePermKey, grantedSetToRecord } from '../roles/permResolver.js'
|
||||
import { getPermTreeNodes } from '../roles/roleService.js'
|
||||
|
||||
const MAX_FAIL_COUNT = 5
|
||||
const SALT_ROUNDS = 10
|
||||
@ -24,7 +26,7 @@ interface AuthUserInfo {
|
||||
rank: string | null
|
||||
org: { sn: number; name: string; abbr: string } | null
|
||||
roles: string[]
|
||||
permissions: string[]
|
||||
permissions: Record<string, string[]>
|
||||
}
|
||||
|
||||
export async function login(
|
||||
@ -127,9 +129,9 @@ export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
|
||||
|
||||
const row = userResult.rows[0]
|
||||
|
||||
// 역할 조회
|
||||
// 역할 조회 (ROLE_SN + ROLE_CD)
|
||||
const rolesResult = await authPool.query(
|
||||
`SELECT r.ROLE_CD as role_cd
|
||||
`SELECT r.ROLE_SN as role_sn, r.ROLE_CD as role_cd
|
||||
FROM AUTH_USER_ROLE ur
|
||||
JOIN AUTH_ROLE r ON ur.ROLE_SN = r.ROLE_SN
|
||||
WHERE ur.USER_ID = $1`,
|
||||
@ -137,8 +139,37 @@ export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
|
||||
)
|
||||
|
||||
const roles = rolesResult.rows.map((r: { role_cd: string }) => r.role_cd)
|
||||
const roleSns = rolesResult.rows.map((r: { role_sn: number }) => r.role_sn)
|
||||
|
||||
// 권한 조회 (역할 기반)
|
||||
// 트리 기반 resolved permissions (리소스 × 오퍼레이션)
|
||||
let permissions: Record<string, string[]>
|
||||
try {
|
||||
const treeNodes = await getPermTreeNodes()
|
||||
|
||||
if (treeNodes.length > 0) {
|
||||
// AUTH_PERM_TREE가 존재 → 트리 기반 resolve
|
||||
const explicitPermsResult = await authPool.query(
|
||||
`SELECT ROLE_SN as role_sn, RSRC_CD as rsrc_cd, OPER_CD as oper_cd, GRANT_YN as grant_yn
|
||||
FROM AUTH_PERM WHERE ROLE_SN = ANY($1)`,
|
||||
[roleSns]
|
||||
)
|
||||
|
||||
const explicitPermsPerRole = new Map<number, Map<string, boolean>>()
|
||||
for (const sn of roleSns) {
|
||||
explicitPermsPerRole.set(sn, new Map())
|
||||
}
|
||||
for (const p of explicitPermsResult.rows) {
|
||||
const roleMap = explicitPermsPerRole.get(p.role_sn)
|
||||
if (roleMap) {
|
||||
const key = makePermKey(p.rsrc_cd, p.oper_cd)
|
||||
roleMap.set(key, p.grant_yn === 'Y')
|
||||
}
|
||||
}
|
||||
|
||||
const granted = resolvePermissions(treeNodes, explicitPermsPerRole)
|
||||
permissions = grantedSetToRecord(granted)
|
||||
} else {
|
||||
// AUTH_PERM_TREE 미존재 (마이그레이션 전) → 기존 플랫 방식 fallback
|
||||
const permsResult = await authPool.query(
|
||||
`SELECT DISTINCT p.RSRC_CD as rsrc_cd
|
||||
FROM AUTH_PERM p
|
||||
@ -146,8 +177,25 @@ export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
|
||||
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`,
|
||||
[userId]
|
||||
)
|
||||
|
||||
const permissions = permsResult.rows.map((p: { rsrc_cd: string }) => p.rsrc_cd)
|
||||
permissions = {}
|
||||
for (const p of permsResult.rows) {
|
||||
permissions[p.rsrc_cd] = ['READ']
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// AUTH_PERM_TREE 테이블 미존재 시 fallback
|
||||
const permsResult = await authPool.query(
|
||||
`SELECT DISTINCT p.RSRC_CD as rsrc_cd
|
||||
FROM AUTH_PERM p
|
||||
JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN
|
||||
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`,
|
||||
[userId]
|
||||
)
|
||||
permissions = {}
|
||||
for (const p of permsResult.rows) {
|
||||
permissions[p.rsrc_cd] = ['READ']
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.user_id,
|
||||
|
||||
137
backend/src/board/boardRouter.ts
Normal file
137
backend/src/board/boardRouter.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
||||
import { AuthError } from '../auth/authService.js'
|
||||
import { listPosts, getPost, createPost, updatePost, deletePost } from './boardService.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 카테고리 → 리소스 매핑
|
||||
const CATEGORY_RESOURCE: Record<string, string> = {
|
||||
NOTICE: 'board:notice',
|
||||
DATA: 'board:data',
|
||||
QNA: 'board:qna',
|
||||
MANUAL: 'board:manual',
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GET /api/board — 게시글 목록
|
||||
// ============================================================
|
||||
router.get('/', requireAuth, requirePermission('board', 'READ'), async (req, res) => {
|
||||
try {
|
||||
const { categoryCd, search, page, size } = req.query
|
||||
const result = await listPosts({
|
||||
categoryCd: categoryCd as string | undefined,
|
||||
search: search as string | undefined,
|
||||
page: page ? parseInt(page as string, 10) : undefined,
|
||||
size: size ? parseInt(size as string, 10) : undefined,
|
||||
})
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
console.error('[board] 목록 조회 오류:', err)
|
||||
res.status(500).json({ error: '게시글 목록 조회 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// GET /api/board/:sn — 게시글 상세
|
||||
// ============================================================
|
||||
router.get('/:sn', requireAuth, requirePermission('board', 'READ'), async (req, res) => {
|
||||
try {
|
||||
const sn = parseInt(req.params.sn as string, 10)
|
||||
if (isNaN(sn)) {
|
||||
res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' })
|
||||
return
|
||||
}
|
||||
const post = await getPost(sn)
|
||||
res.json(post)
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) {
|
||||
res.status(err.status).json({ error: err.message })
|
||||
return
|
||||
}
|
||||
console.error('[board] 상세 조회 오류:', err)
|
||||
res.status(500).json({ error: '게시글 조회 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// POST /api/board — 게시글 작성 (카테고리별 CREATE 권한)
|
||||
// ============================================================
|
||||
router.post('/', requireAuth, async (req, res, next) => {
|
||||
const resource = CATEGORY_RESOURCE[req.body.categoryCd] || 'board'
|
||||
requirePermission(resource, 'CREATE')(req, res, next)
|
||||
}, async (req, res) => {
|
||||
try {
|
||||
const { categoryCd, title, content, pinnedYn } = req.body
|
||||
|
||||
if (!categoryCd || !title) {
|
||||
res.status(400).json({ error: '카테고리와 제목은 필수입니다.' })
|
||||
return
|
||||
}
|
||||
|
||||
const result = await createPost({
|
||||
categoryCd,
|
||||
title,
|
||||
content,
|
||||
authorId: req.user!.sub,
|
||||
pinnedYn,
|
||||
})
|
||||
res.status(201).json(result)
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) {
|
||||
res.status(err.status).json({ error: err.message })
|
||||
return
|
||||
}
|
||||
console.error('[board] 작성 오류:', err)
|
||||
res.status(500).json({ error: '게시글 작성 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// PUT /api/board/:sn — 게시글 수정 (소유자 검증은 서비스에서)
|
||||
// ============================================================
|
||||
router.put('/:sn', requireAuth, requirePermission('board', 'UPDATE'), async (req, res) => {
|
||||
try {
|
||||
const sn = parseInt(req.params.sn as string, 10)
|
||||
if (isNaN(sn)) {
|
||||
res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' })
|
||||
return
|
||||
}
|
||||
|
||||
const { title, content, pinnedYn } = req.body
|
||||
await updatePost(sn, { title, content, pinnedYn }, req.user!.sub)
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) {
|
||||
res.status(err.status).json({ error: err.message })
|
||||
return
|
||||
}
|
||||
console.error('[board] 수정 오류:', err)
|
||||
res.status(500).json({ error: '게시글 수정 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// DELETE /api/board/:sn — 게시글 삭제 (논리 삭제, 소유자 검증)
|
||||
// ============================================================
|
||||
router.delete('/:sn', requireAuth, requirePermission('board', 'DELETE'), async (req, res) => {
|
||||
try {
|
||||
const sn = parseInt(req.params.sn as string, 10)
|
||||
if (isNaN(sn)) {
|
||||
res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' })
|
||||
return
|
||||
}
|
||||
|
||||
await deletePost(sn, req.user!.sub)
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) {
|
||||
res.status(err.status).json({ error: err.message })
|
||||
return
|
||||
}
|
||||
console.error('[board] 삭제 오류:', err)
|
||||
res.status(500).json({ error: '게시글 삭제 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
243
backend/src/board/boardService.ts
Normal file
243
backend/src/board/boardService.ts
Normal file
@ -0,0 +1,243 @@
|
||||
import { wingPool } from '../db/wingDb.js'
|
||||
import { AuthError } from '../auth/authService.js'
|
||||
|
||||
// ============================================================
|
||||
// 인터페이스
|
||||
// ============================================================
|
||||
|
||||
interface PostListItem {
|
||||
sn: number
|
||||
categoryCd: string
|
||||
title: string
|
||||
authorId: string
|
||||
authorName: string
|
||||
viewCnt: number
|
||||
pinnedYn: string
|
||||
regDtm: string
|
||||
}
|
||||
|
||||
interface PostDetail extends PostListItem {
|
||||
content: string | null
|
||||
mdfcnDtm: string | null
|
||||
}
|
||||
|
||||
interface ListPostsInput {
|
||||
categoryCd?: string
|
||||
search?: string
|
||||
page?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface ListPostsResult {
|
||||
items: PostListItem[]
|
||||
totalCount: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
interface CreatePostInput {
|
||||
categoryCd: string
|
||||
title: string
|
||||
content?: string
|
||||
authorId: string
|
||||
pinnedYn?: string
|
||||
}
|
||||
|
||||
interface UpdatePostInput {
|
||||
title?: string
|
||||
content?: string
|
||||
pinnedYn?: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CRUD 함수
|
||||
// ============================================================
|
||||
|
||||
const VALID_CATEGORIES = ['NOTICE', 'DATA', 'QNA', 'MANUAL']
|
||||
|
||||
export async function listPosts(input: ListPostsInput): Promise<ListPostsResult> {
|
||||
const page = input.page && input.page > 0 ? input.page : 1
|
||||
const size = input.size && input.size > 0 ? Math.min(input.size, 100) : 20
|
||||
const offset = (page - 1) * size
|
||||
|
||||
let whereClause = `WHERE bp.USE_YN = 'Y'`
|
||||
const params: (string | number)[] = []
|
||||
let paramIdx = 1
|
||||
|
||||
if (input.categoryCd) {
|
||||
whereClause += ` AND bp.CATEGORY_CD = $${paramIdx++}`
|
||||
params.push(input.categoryCd)
|
||||
}
|
||||
|
||||
if (input.search) {
|
||||
whereClause += ` AND (bp.TITLE ILIKE $${paramIdx} OR u.USER_NM ILIKE $${paramIdx})`
|
||||
params.push(`%${input.search}%`)
|
||||
paramIdx++
|
||||
}
|
||||
|
||||
// 전체 건수
|
||||
const countResult = await wingPool.query(
|
||||
`SELECT COUNT(*) as cnt
|
||||
FROM BOARD_POST bp
|
||||
JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID
|
||||
${whereClause}`,
|
||||
params
|
||||
)
|
||||
const totalCount = parseInt(countResult.rows[0].cnt, 10)
|
||||
|
||||
// 목록 (상단고정 우선, 등록일 내림차순)
|
||||
const listParams = [...params, size, offset]
|
||||
const listResult = await wingPool.query(
|
||||
`SELECT bp.POST_SN as sn, bp.CATEGORY_CD as category_cd, bp.TITLE as title,
|
||||
bp.AUTHOR_ID as author_id, u.USER_NM as author_name,
|
||||
bp.VIEW_CNT as view_cnt, bp.PINNED_YN as pinned_yn,
|
||||
bp.REG_DTM as reg_dtm
|
||||
FROM BOARD_POST bp
|
||||
JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID
|
||||
${whereClause}
|
||||
ORDER BY bp.PINNED_YN DESC, bp.REG_DTM DESC
|
||||
LIMIT $${paramIdx++} OFFSET $${paramIdx}`,
|
||||
listParams
|
||||
)
|
||||
|
||||
const items: PostListItem[] = listResult.rows.map((r: Record<string, unknown>) => ({
|
||||
sn: r.sn as number,
|
||||
categoryCd: r.category_cd as string,
|
||||
title: r.title as string,
|
||||
authorId: r.author_id as string,
|
||||
authorName: r.author_name as string,
|
||||
viewCnt: r.view_cnt as number,
|
||||
pinnedYn: r.pinned_yn as string,
|
||||
regDtm: r.reg_dtm as string,
|
||||
}))
|
||||
|
||||
return { items, totalCount, page, size }
|
||||
}
|
||||
|
||||
export async function getPost(postSn: number): Promise<PostDetail> {
|
||||
// 조회수 증가 + 상세 조회 (단일 쿼리)
|
||||
const result = await wingPool.query(
|
||||
`UPDATE BOARD_POST SET VIEW_CNT = VIEW_CNT + 1
|
||||
WHERE POST_SN = $1 AND USE_YN = 'Y'
|
||||
RETURNING POST_SN as sn, CATEGORY_CD as category_cd, TITLE as title,
|
||||
CONTENT as content, AUTHOR_ID as author_id,
|
||||
VIEW_CNT as view_cnt, PINNED_YN as pinned_yn,
|
||||
REG_DTM as reg_dtm, MDFCN_DTM as mdfcn_dtm`,
|
||||
[postSn]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
|
||||
}
|
||||
|
||||
const row = result.rows[0]
|
||||
|
||||
// 작성자명 조회
|
||||
const authorResult = await wingPool.query(
|
||||
'SELECT USER_NM as name FROM AUTH_USER WHERE USER_ID = $1',
|
||||
[row.author_id]
|
||||
)
|
||||
|
||||
return {
|
||||
sn: row.sn,
|
||||
categoryCd: row.category_cd,
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
authorId: row.author_id,
|
||||
authorName: authorResult.rows[0]?.name || '알 수 없음',
|
||||
viewCnt: row.view_cnt,
|
||||
pinnedYn: row.pinned_yn,
|
||||
regDtm: row.reg_dtm,
|
||||
mdfcnDtm: row.mdfcn_dtm,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPost(input: CreatePostInput): Promise<{ sn: number }> {
|
||||
if (!VALID_CATEGORIES.includes(input.categoryCd)) {
|
||||
throw new AuthError('유효하지 않은 카테고리입니다.', 400)
|
||||
}
|
||||
|
||||
if (!input.title || input.title.trim().length === 0) {
|
||||
throw new AuthError('제목은 필수입니다.', 400)
|
||||
}
|
||||
|
||||
const result = await wingPool.query(
|
||||
`INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID, PINNED_YN)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING POST_SN as sn`,
|
||||
[input.categoryCd, input.title.trim(), input.content || null, input.authorId, input.pinnedYn || 'N']
|
||||
)
|
||||
|
||||
return { sn: result.rows[0].sn }
|
||||
}
|
||||
|
||||
export async function updatePost(
|
||||
postSn: number,
|
||||
input: UpdatePostInput,
|
||||
requesterId: string
|
||||
): Promise<void> {
|
||||
// 게시글 존재 + 작성자 확인
|
||||
const existing = await wingPool.query(
|
||||
`SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`,
|
||||
[postSn]
|
||||
)
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
|
||||
}
|
||||
|
||||
if (existing.rows[0].author_id !== requesterId) {
|
||||
throw new AuthError('본인의 게시글만 수정할 수 있습니다.', 403)
|
||||
}
|
||||
|
||||
const sets: string[] = []
|
||||
const params: (string | number | null)[] = []
|
||||
let idx = 1
|
||||
|
||||
if (input.title !== undefined) {
|
||||
sets.push(`TITLE = $${idx++}`)
|
||||
params.push(input.title.trim())
|
||||
}
|
||||
if (input.content !== undefined) {
|
||||
sets.push(`CONTENT = $${idx++}`)
|
||||
params.push(input.content)
|
||||
}
|
||||
if (input.pinnedYn !== undefined) {
|
||||
sets.push(`PINNED_YN = $${idx++}`)
|
||||
params.push(input.pinnedYn)
|
||||
}
|
||||
|
||||
if (sets.length === 0) {
|
||||
throw new AuthError('수정할 항목이 없습니다.', 400)
|
||||
}
|
||||
|
||||
sets.push('MDFCN_DTM = NOW()')
|
||||
params.push(postSn)
|
||||
|
||||
await wingPool.query(
|
||||
`UPDATE BOARD_POST SET ${sets.join(', ')} WHERE POST_SN = $${idx}`,
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
export async function deletePost(postSn: number, requesterId: string): Promise<void> {
|
||||
// 게시글 존재 + 작성자 확인
|
||||
const existing = await wingPool.query(
|
||||
`SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`,
|
||||
[postSn]
|
||||
)
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
|
||||
}
|
||||
|
||||
if (existing.rows[0].author_id !== requesterId) {
|
||||
throw new AuthError('본인의 게시글만 삭제할 수 있습니다.', 403)
|
||||
}
|
||||
|
||||
// 논리 삭제
|
||||
await wingPool.query(
|
||||
`UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`,
|
||||
[postSn]
|
||||
)
|
||||
}
|
||||
@ -1,33 +1,13 @@
|
||||
import pg from 'pg'
|
||||
// ============================================================
|
||||
// 하위 호환: authPool → wingPool re-export
|
||||
// DB 통합으로 wing_auth DB가 wing DB의 auth 스키마로 이전됨.
|
||||
// 기존 코드에서 authPool을 import하는 곳에서 에러 없이 동작하도록 유지.
|
||||
// 신규 코드는 wingDb.ts의 wingPool을 직접 import할 것.
|
||||
// ============================================================
|
||||
import { wingPool, testWingDbConnection } from './wingDb.js'
|
||||
|
||||
const { Pool } = pg
|
||||
|
||||
const authPool = new Pool({
|
||||
host: process.env.AUTH_DB_HOST || 'localhost',
|
||||
port: Number(process.env.AUTH_DB_PORT) || 5432,
|
||||
database: process.env.AUTH_DB_NAME || 'wing_auth',
|
||||
user: process.env.AUTH_DB_USER || 'wing_auth',
|
||||
password: process.env.AUTH_DB_PASSWORD || 'WingAuth2026',
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
})
|
||||
|
||||
authPool.on('error', (err) => {
|
||||
console.error('[authDb] 예기치 않은 연결 오류:', err.message)
|
||||
})
|
||||
export const authPool = wingPool
|
||||
|
||||
export async function testAuthDbConnection(): Promise<boolean> {
|
||||
try {
|
||||
const client = await authPool.connect()
|
||||
await client.query('SELECT 1')
|
||||
client.release()
|
||||
console.log('[authDb] wing_auth 데이터베이스 연결 성공')
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn('[authDb] wing_auth 데이터베이스 연결 실패:', (err as Error).message)
|
||||
return false
|
||||
}
|
||||
return testWingDbConnection()
|
||||
}
|
||||
|
||||
export { authPool }
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const dbPath = join(__dirname, '../../data/layers.db')
|
||||
|
||||
export const db = new Database(dbPath)
|
||||
|
||||
// 데이터베이스 초기화
|
||||
export function initDatabase() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS layers (
|
||||
cmn_cd TEXT PRIMARY KEY,
|
||||
up_cmn_cd TEXT,
|
||||
cmn_cd_full_nm TEXT NOT NULL,
|
||||
cmn_cd_nm TEXT NOT NULL,
|
||||
cmn_cd_level INTEGER NOT NULL,
|
||||
clnm TEXT,
|
||||
FOREIGN KEY (up_cmn_cd) REFERENCES layers(cmn_cd)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_up_cmn_cd ON layers(up_cmn_cd);
|
||||
CREATE INDEX IF NOT EXISTS idx_cmn_cd_level ON layers(cmn_cd_level);
|
||||
`)
|
||||
}
|
||||
|
||||
export default db
|
||||
@ -1,52 +1,38 @@
|
||||
import 'dotenv/config'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname } from 'path'
|
||||
import db, { initDatabase } from './database.js'
|
||||
import { wingPool } from './wingDb.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
async function seedDatabase() {
|
||||
console.log('데이터베이스 초기화 중...')
|
||||
initDatabase()
|
||||
console.log('wing DB 레이어 시드 시작...')
|
||||
|
||||
// 기존 데이터 삭제
|
||||
db.exec('DELETE FROM layers')
|
||||
const client = await wingPool.connect()
|
||||
|
||||
try {
|
||||
// CSV 파일 읽기
|
||||
const csvPath = path.join(__dirname, '../../../LayerList.csv')
|
||||
const csvPath = path.join(__dirname, '../../../_reference/LayerList.csv')
|
||||
const csvContent = fs.readFileSync(csvPath, 'utf-8')
|
||||
|
||||
// CSV 파싱
|
||||
const lines = csvContent.split('\n')
|
||||
const headers = lines[0].split(',').map(h => h.replace(/"/g, '').trim())
|
||||
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO layers (cmn_cd, up_cmn_cd, cmn_cd_full_nm, cmn_cd_nm, cmn_cd_level, clnm)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
const insertMany = db.transaction((rows: any[]) => {
|
||||
for (const row of rows) {
|
||||
insert.run(row)
|
||||
}
|
||||
})
|
||||
|
||||
const rows = []
|
||||
const rows: (string | number | null)[][] = []
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim()
|
||||
if (!line) continue
|
||||
|
||||
// CSV 파싱 (쉼표로 구분, 따옴표 처리)
|
||||
const values = []
|
||||
const values: string[] = []
|
||||
let current = ''
|
||||
let inQuotes = false
|
||||
|
||||
for (let j = 0; j < line.length; j++) {
|
||||
const char = line[j]
|
||||
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
@ -58,7 +44,6 @@ async function seedDatabase() {
|
||||
}
|
||||
values.push(current.trim())
|
||||
|
||||
// NULL 값 처리
|
||||
const row = values.map(v => {
|
||||
if (v === 'NULL' || v === '') return null
|
||||
return v.replace(/"/g, '')
|
||||
@ -66,26 +51,56 @@ async function seedDatabase() {
|
||||
|
||||
if (row.length >= 6) {
|
||||
rows.push([
|
||||
row[0], // cmn_cd
|
||||
row[1], // up_cmn_cd
|
||||
row[2], // cmn_cd_full_nm
|
||||
row[3], // cmn_cd_nm
|
||||
parseInt(row[4] || '0'), // cmn_cd_level
|
||||
row[5], // clnm
|
||||
row[0], // LAYER_CD
|
||||
row[1], // UP_LAYER_CD
|
||||
row[2], // LAYER_FULL_NM
|
||||
row[3], // LAYER_NM
|
||||
parseInt(row[4] || '0', 10), // LAYER_LEVEL
|
||||
row[5], // WMS_LAYER_NM
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${rows.length}개의 레이어 데이터 삽입 중...`)
|
||||
insertMany(rows)
|
||||
|
||||
console.log('시드 완료!')
|
||||
await client.query('BEGIN')
|
||||
|
||||
// 기존 데이터 삭제
|
||||
await client.query('DELETE FROM LAYER')
|
||||
|
||||
// FK 제약 때문에 상위 레이어(낮은 레벨)부터 삽입
|
||||
const sortedRows = rows.sort((a, b) => (a[4] as number) - (b[4] as number))
|
||||
|
||||
for (const row of sortedRows) {
|
||||
await client.query(
|
||||
`INSERT INTO LAYER (LAYER_CD, UP_LAYER_CD, LAYER_FULL_NM, LAYER_NM, LAYER_LEVEL, WMS_LAYER_NM)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (LAYER_CD) DO UPDATE SET
|
||||
UP_LAYER_CD = EXCLUDED.UP_LAYER_CD,
|
||||
LAYER_FULL_NM = EXCLUDED.LAYER_FULL_NM,
|
||||
LAYER_NM = EXCLUDED.LAYER_NM,
|
||||
LAYER_LEVEL = EXCLUDED.LAYER_LEVEL,
|
||||
WMS_LAYER_NM = EXCLUDED.WMS_LAYER_NM`,
|
||||
row
|
||||
)
|
||||
}
|
||||
|
||||
await client.query('COMMIT')
|
||||
|
||||
// 결과 확인
|
||||
const count = db.prepare('SELECT COUNT(*) as count FROM layers').get() as { count: number }
|
||||
console.log(`총 ${count.count}개의 레이어가 저장되었습니다.`)
|
||||
|
||||
db.close()
|
||||
const { rows: countResult } = await client.query('SELECT COUNT(*) as count FROM LAYER')
|
||||
console.log(`시드 완료! 총 ${countResult[0].count}개의 레이어가 저장되었습니다.`)
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK')
|
||||
console.error('시드 실패:', err)
|
||||
throw err
|
||||
} finally {
|
||||
client.release()
|
||||
await wingPool.end()
|
||||
}
|
||||
}
|
||||
|
||||
seedDatabase().catch(console.error)
|
||||
seedDatabase().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
63
backend/src/db/seedHns.ts
Normal file
63
backend/src/db/seedHns.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import 'dotenv/config'
|
||||
import { wingPool } from './wingDb.js'
|
||||
|
||||
// 프론트엔드 정적 데이터를 직접 import (tsx로 실행)
|
||||
import { HNS_SEARCH_DB } from '../../../frontend/src/data/hnsSubstanceSearchData.js'
|
||||
|
||||
async function seedHnsSubstances() {
|
||||
console.log('HNS 물질정보 시드 시작...')
|
||||
console.log(`총 ${HNS_SEARCH_DB.length}종 물질 데이터 삽입 예정`)
|
||||
|
||||
const client = await wingPool.connect()
|
||||
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
|
||||
// 기존 데이터 삭제
|
||||
await client.query('DELETE FROM HNS_SUBSTANCE')
|
||||
|
||||
let inserted = 0
|
||||
|
||||
for (const s of HNS_SEARCH_DB) {
|
||||
// 검색용 컬럼 추출, 나머지는 DATA JSONB로 저장
|
||||
const { abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, ...detailData } = s
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO HNS_SUBSTANCE (SBST_SN, ABBREVIATION, NM_KR, NM_EN, UN_NO, CAS_NO, SEBC, DATA)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (SBST_SN) DO UPDATE SET
|
||||
ABBREVIATION = EXCLUDED.ABBREVIATION,
|
||||
NM_KR = EXCLUDED.NM_KR,
|
||||
NM_EN = EXCLUDED.NM_EN,
|
||||
UN_NO = EXCLUDED.UN_NO,
|
||||
CAS_NO = EXCLUDED.CAS_NO,
|
||||
SEBC = EXCLUDED.SEBC,
|
||||
DATA = EXCLUDED.DATA`,
|
||||
[s.id, abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, JSON.stringify(detailData)]
|
||||
)
|
||||
|
||||
inserted++
|
||||
if (inserted % 100 === 0) {
|
||||
console.log(` ${inserted}/${HNS_SEARCH_DB.length}건 삽입 완료...`)
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT')
|
||||
|
||||
// 결과 확인
|
||||
const { rows } = await client.query('SELECT COUNT(*) as count FROM HNS_SUBSTANCE')
|
||||
console.log(`시드 완료! 총 ${rows[0].count}종의 HNS 물질이 저장되었습니다.`)
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK')
|
||||
console.error('HNS 시드 실패:', err)
|
||||
throw err
|
||||
} finally {
|
||||
client.release()
|
||||
await wingPool.end()
|
||||
}
|
||||
}
|
||||
|
||||
seedHnsSubstances().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
44
backend/src/db/wingDb.ts
Normal file
44
backend/src/db/wingDb.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import pg from 'pg'
|
||||
|
||||
const { Pool } = pg
|
||||
|
||||
// ============================================================
|
||||
// wing DB 통합 Pool (wing 스키마 + auth 스키마)
|
||||
// - wing 스키마: 운영 데이터 (LAYER, BOARD_POST 등)
|
||||
// - auth 스키마: 인증/인가 데이터 (AUTH_USER, AUTH_ROLE 등)
|
||||
// - public 스키마: PostGIS 시스템 테이블만 유지
|
||||
// ============================================================
|
||||
const wingPool = new Pool({
|
||||
host: process.env.DB_HOST || process.env.WING_DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT || process.env.WING_DB_PORT) || 5432,
|
||||
database: process.env.DB_NAME || process.env.WING_DB_NAME || 'wing',
|
||||
user: process.env.DB_USER || process.env.WING_DB_USER || 'wing',
|
||||
password: process.env.DB_PASSWORD || process.env.WING_DB_PASSWORD || 'Wing2026',
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
})
|
||||
|
||||
// 연결 시 search_path 자동 설정 (public 미사용)
|
||||
wingPool.on('connect', (client) => {
|
||||
client.query('SET search_path = wing, auth, public')
|
||||
})
|
||||
|
||||
wingPool.on('error', (err) => {
|
||||
console.error('[db] 예기치 않은 연결 오류:', err.message)
|
||||
})
|
||||
|
||||
export async function testWingDbConnection(): Promise<boolean> {
|
||||
try {
|
||||
const client = await wingPool.connect()
|
||||
await client.query('SELECT 1')
|
||||
client.release()
|
||||
console.log('[db] wing 데이터베이스 연결 성공 (wing + auth 스키마)')
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn('[db] wing 데이터베이스 연결 실패:', (err as Error).message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export { wingPool }
|
||||
53
backend/src/hns/hnsRouter.ts
Normal file
53
backend/src/hns/hnsRouter.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import express from 'express'
|
||||
import { searchSubstances, getSubstanceById } from './hnsService.js'
|
||||
import { isValidNumber } from '../middleware/security.js'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// HNS 물질 검색
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const q = req.query.q as string | undefined
|
||||
const type = req.query.type as string | undefined
|
||||
const sebc = req.query.sebc as string | undefined
|
||||
const page = parseInt(req.query.page as string, 10) || 1
|
||||
const limit = parseInt(req.query.limit as string, 10) || 50
|
||||
|
||||
if (!isValidNumber(page, 1, 10000) || !isValidNumber(limit, 1, 100)) {
|
||||
return res.status(400).json({
|
||||
error: '유효하지 않은 페이지네이션',
|
||||
message: 'page는 1~10000, limit은 1~100 범위여야 합니다.',
|
||||
})
|
||||
}
|
||||
|
||||
const validTypes = ['abbreviation', 'nameKr', 'nameEn', 'casNumber', 'unNumber', 'cargoCode']
|
||||
const searchType = type && validTypes.includes(type)
|
||||
? type as 'abbreviation' | 'nameKr' | 'nameEn' | 'casNumber' | 'unNumber' | 'cargoCode'
|
||||
: undefined
|
||||
|
||||
const result = await searchSubstances({ q, type: searchType, sebc, page, limit })
|
||||
res.json(result)
|
||||
} catch {
|
||||
res.status(500).json({ error: 'HNS 물질 검색 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
// HNS 물질 상세 조회
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10)
|
||||
if (!isValidNumber(id, 1, 999999)) {
|
||||
return res.status(400).json({ error: '유효하지 않은 물질 ID' })
|
||||
}
|
||||
|
||||
const substance = await getSubstanceById(id)
|
||||
if (!substance) {
|
||||
return res.status(404).json({ error: '물질을 찾을 수 없습니다' })
|
||||
}
|
||||
res.json(substance)
|
||||
} catch {
|
||||
res.status(500).json({ error: 'HNS 물질 조회 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
110
backend/src/hns/hnsService.ts
Normal file
110
backend/src/hns/hnsService.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { wingPool } from '../db/wingDb.js'
|
||||
|
||||
interface HnsSearchParams {
|
||||
q?: string
|
||||
type?: 'abbreviation' | 'nameKr' | 'nameEn' | 'casNumber' | 'unNumber' | 'cargoCode'
|
||||
sebc?: string
|
||||
page?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export async function searchSubstances(params: HnsSearchParams) {
|
||||
const { q, type = 'nameKr', sebc, page = 1, limit = 50 } = params
|
||||
const conditions: string[] = ["USE_YN = 'Y'"]
|
||||
const values: (string | number)[] = []
|
||||
let paramIdx = 1
|
||||
|
||||
if (q && q.trim()) {
|
||||
const keyword = q.trim()
|
||||
switch (type) {
|
||||
case 'abbreviation':
|
||||
conditions.push(`ABBREVIATION ILIKE $${paramIdx}`)
|
||||
values.push(`%${keyword}%`)
|
||||
break
|
||||
case 'nameKr':
|
||||
conditions.push(`NM_KR ILIKE $${paramIdx}`)
|
||||
values.push(`%${keyword}%`)
|
||||
break
|
||||
case 'nameEn':
|
||||
conditions.push(`NM_EN ILIKE $${paramIdx}`)
|
||||
values.push(`%${keyword}%`)
|
||||
break
|
||||
case 'casNumber':
|
||||
conditions.push(`CAS_NO ILIKE $${paramIdx}`)
|
||||
values.push(`%${keyword}%`)
|
||||
break
|
||||
case 'unNumber':
|
||||
conditions.push(`UN_NO = $${paramIdx}`)
|
||||
values.push(keyword)
|
||||
break
|
||||
case 'cargoCode':
|
||||
conditions.push(`DATA->'cargoCodes' @> $${paramIdx}::jsonb`)
|
||||
values.push(JSON.stringify([{ code: keyword }]))
|
||||
break
|
||||
default:
|
||||
conditions.push(`(NM_KR ILIKE $${paramIdx} OR NM_EN ILIKE $${paramIdx} OR ABBREVIATION ILIKE $${paramIdx})`)
|
||||
values.push(`%${keyword}%`)
|
||||
}
|
||||
paramIdx++
|
||||
}
|
||||
|
||||
if (sebc && sebc.trim()) {
|
||||
conditions.push(`SEBC ILIKE $${paramIdx}`)
|
||||
values.push(`%${sebc.trim()}%`)
|
||||
paramIdx++
|
||||
}
|
||||
|
||||
const where = conditions.join(' AND ')
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
const countQuery = `SELECT COUNT(*) as total FROM HNS_SUBSTANCE WHERE ${where}`
|
||||
const dataQuery = `
|
||||
SELECT SBST_SN, ABBREVIATION, NM_KR, NM_EN, UN_NO, CAS_NO, SEBC, DATA
|
||||
FROM HNS_SUBSTANCE
|
||||
WHERE ${where}
|
||||
ORDER BY SBST_SN
|
||||
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
|
||||
`
|
||||
|
||||
const [countResult, dataResult] = await Promise.all([
|
||||
wingPool.query(countQuery, values),
|
||||
wingPool.query(dataQuery, [...values, limit, offset]),
|
||||
])
|
||||
|
||||
return {
|
||||
total: parseInt(countResult.rows[0].total, 10),
|
||||
page,
|
||||
limit,
|
||||
items: dataResult.rows.map(row => ({
|
||||
id: row.sbst_sn,
|
||||
abbreviation: row.abbreviation,
|
||||
nameKr: row.nm_kr,
|
||||
nameEn: row.nm_en,
|
||||
unNumber: row.un_no,
|
||||
casNumber: row.cas_no,
|
||||
sebc: row.sebc,
|
||||
...row.data,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSubstanceById(id: number) {
|
||||
const { rows } = await wingPool.query(
|
||||
`SELECT SBST_SN, ABBREVIATION, NM_KR, NM_EN, UN_NO, CAS_NO, SEBC, DATA
|
||||
FROM HNS_SUBSTANCE WHERE SBST_SN = $1`,
|
||||
[id]
|
||||
)
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const row = rows[0]
|
||||
return {
|
||||
id: row.sbst_sn,
|
||||
abbreviation: row.abbreviation,
|
||||
nameKr: row.nm_kr,
|
||||
nameEn: row.nm_en,
|
||||
unNumber: row.un_no,
|
||||
casNumber: row.cas_no,
|
||||
sebc: row.sebc,
|
||||
...row.data,
|
||||
}
|
||||
}
|
||||
197
backend/src/roles/permResolver.ts
Normal file
197
backend/src/roles/permResolver.ts
Normal file
@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 트리 구조 기반 권한 해석(Resolution) 유틸.
|
||||
*
|
||||
* 2차원 모델: 리소스 트리(상속) × 오퍼레이션(RCUD, 플랫)
|
||||
*
|
||||
* 규칙:
|
||||
* 1. 부모 리소스의 READ가 N → 자식의 모든 오퍼레이션 강제 N
|
||||
* 2. 해당 (RSRC_CD, OPER_CD) 명시적 레코드 있으면 → 그 값 사용
|
||||
* 3. 명시적 레코드 없으면 → 부모의 같은 OPER_CD 상속
|
||||
* 4. 최상위까지 없으면 → 기본 N (거부)
|
||||
*
|
||||
* 키 형식: "rsrcCode::operCd" (더블콜론으로 리소스와 오퍼레이션 구분)
|
||||
*/
|
||||
|
||||
export const OPERATIONS = ['READ', 'CREATE', 'UPDATE', 'DELETE'] as const
|
||||
export type OperationCode = (typeof OPERATIONS)[number]
|
||||
|
||||
export interface PermTreeNode {
|
||||
code: string
|
||||
parentCode: string | null
|
||||
name: string
|
||||
description: string | null
|
||||
icon: string | null
|
||||
level: number
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
/** 리소스::오퍼레이션 키 생성 */
|
||||
export function makePermKey(rsrcCode: string, operCd: string): string {
|
||||
return `${rsrcCode}::${operCd}`
|
||||
}
|
||||
|
||||
/** 키에서 리소스 코드와 오퍼레이션 코드 분리 */
|
||||
export function parsePermKey(key: string): { rsrcCode: string; operCd: string } {
|
||||
const idx = key.indexOf('::')
|
||||
return {
|
||||
rsrcCode: key.substring(0, idx),
|
||||
operCd: key.substring(idx + 2),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 트리 노드 + 역할별 명시적 권한 → granted된 "rsrc::oper" Set 반환.
|
||||
* 다중 역할: 역할별 resolve 후 OR (하나라도 Y면 Y).
|
||||
*/
|
||||
export function resolvePermissions(
|
||||
treeNodes: PermTreeNode[],
|
||||
explicitPermsPerRole: Map<number, Map<string, boolean>>,
|
||||
): Set<string> {
|
||||
const granted = new Set<string>()
|
||||
|
||||
const nodeMap = new Map<string, PermTreeNode>()
|
||||
for (const node of treeNodes) {
|
||||
nodeMap.set(node.code, node)
|
||||
}
|
||||
|
||||
for (const [, explicitPerms] of explicitPermsPerRole) {
|
||||
const roleResolved = resolveForSingleRole(treeNodes, nodeMap, explicitPerms)
|
||||
for (const key of roleResolved) {
|
||||
granted.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
return granted
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 역할에 대한 권한 해석.
|
||||
*/
|
||||
function resolveForSingleRole(
|
||||
treeNodes: PermTreeNode[],
|
||||
nodeMap: Map<string, PermTreeNode>,
|
||||
explicitPerms: Map<string, boolean>,
|
||||
): Set<string> {
|
||||
const effective = new Map<string, boolean>()
|
||||
|
||||
// 레벨 순(0→1→2→...)으로 처리하여 부모 → 자식 순서 보장
|
||||
const sorted = [...treeNodes].sort((a, b) => a.level - b.level || a.sortOrder - b.sortOrder)
|
||||
|
||||
for (const node of sorted) {
|
||||
// READ 먼저 resolve (CUD는 READ 결과에 의존)
|
||||
resolveNodeOper(node, 'READ', explicitPerms, effective)
|
||||
|
||||
// CUD resolve
|
||||
for (const oper of OPERATIONS) {
|
||||
if (oper === 'READ') continue
|
||||
resolveNodeOper(node, oper, explicitPerms, effective)
|
||||
}
|
||||
}
|
||||
|
||||
const granted = new Set<string>()
|
||||
for (const [key, value] of effective) {
|
||||
if (value) granted.add(key)
|
||||
}
|
||||
return granted
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 노드 × 오퍼레이션의 effective 값 계산.
|
||||
*/
|
||||
function resolveNodeOper(
|
||||
node: PermTreeNode,
|
||||
operCd: string,
|
||||
explicitPerms: Map<string, boolean>,
|
||||
effective: Map<string, boolean>,
|
||||
): void {
|
||||
const key = makePermKey(node.code, operCd)
|
||||
if (effective.has(key)) return
|
||||
|
||||
const explicit = explicitPerms.get(key)
|
||||
|
||||
if (node.parentCode === null) {
|
||||
// 최상위: 명시적 값 또는 기본 거부
|
||||
effective.set(key, explicit ?? false)
|
||||
return
|
||||
}
|
||||
|
||||
// 부모의 READ 확인 (접근 게이트)
|
||||
const parentReadKey = makePermKey(node.parentCode, 'READ')
|
||||
const parentReadEffective = effective.get(parentReadKey)
|
||||
|
||||
if (parentReadEffective === false) {
|
||||
// 부모 READ 차단 → 모든 오퍼레이션 강제 차단
|
||||
effective.set(key, false)
|
||||
return
|
||||
}
|
||||
|
||||
// 명시적 값 있으면 사용
|
||||
if (explicit !== undefined) {
|
||||
effective.set(key, explicit)
|
||||
return
|
||||
}
|
||||
|
||||
// 부모의 같은 오퍼레이션 상속
|
||||
const parentOperKey = makePermKey(node.parentCode, operCd)
|
||||
const parentOperEffective = effective.get(parentOperKey)
|
||||
effective.set(key, parentOperEffective ?? false)
|
||||
}
|
||||
|
||||
/**
|
||||
* resolved Set → Record<rsrcCode, operCd[]> 변환 (API 반환용).
|
||||
*/
|
||||
export function grantedSetToRecord(granted: Set<string>): Record<string, string[]> {
|
||||
const result: Record<string, string[]> = {}
|
||||
for (const key of granted) {
|
||||
const { rsrcCode, operCd } = parsePermKey(key)
|
||||
if (!result[rsrcCode]) result[rsrcCode] = []
|
||||
result[rsrcCode].push(operCd)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 플랫 노드 배열 → 트리 구조 변환 (프론트엔드 UI용).
|
||||
*/
|
||||
export interface PermTreeResponse {
|
||||
code: string
|
||||
parentCode: string | null
|
||||
name: string
|
||||
description: string | null
|
||||
icon: string | null
|
||||
level: number
|
||||
sortOrder: number
|
||||
children: PermTreeResponse[]
|
||||
}
|
||||
|
||||
export function buildPermTree(nodes: PermTreeNode[]): PermTreeResponse[] {
|
||||
const nodeMap = new Map<string, PermTreeResponse>()
|
||||
const roots: PermTreeResponse[] = []
|
||||
|
||||
const sorted = [...nodes].sort((a, b) => a.level - b.level || a.sortOrder - b.sortOrder)
|
||||
|
||||
for (const node of sorted) {
|
||||
const treeNode: PermTreeResponse = {
|
||||
code: node.code,
|
||||
parentCode: node.parentCode,
|
||||
name: node.name,
|
||||
description: node.description,
|
||||
icon: node.icon,
|
||||
level: node.level,
|
||||
sortOrder: node.sortOrder,
|
||||
children: [],
|
||||
}
|
||||
nodeMap.set(node.code, treeNode)
|
||||
|
||||
if (node.parentCode === null) {
|
||||
roots.push(treeNode)
|
||||
} else {
|
||||
const parent = nodeMap.get(node.parentCode)
|
||||
if (parent) {
|
||||
parent.children.push(treeNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return roots
|
||||
}
|
||||
@ -1,13 +1,24 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
||||
import { AuthError } from '../auth/authService.js'
|
||||
import { listRolesWithPermissions, createRole, updateRole, deleteRole, updatePermissions, updateRoleDefault } from './roleService.js'
|
||||
import { listRolesWithPermissions, createRole, updateRole, deleteRole, updatePermissions, updateRoleDefault, getPermTree } from './roleService.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireAuth)
|
||||
router.use(requireRole('ADMIN'))
|
||||
|
||||
// GET /api/roles/perm-tree — 권한 트리 구조 조회
|
||||
router.get('/perm-tree', async (_req, res) => {
|
||||
try {
|
||||
const tree = await getPermTree()
|
||||
res.json(tree)
|
||||
} catch (err) {
|
||||
console.error('[roles] 권한 트리 조회 오류:', err)
|
||||
res.status(500).json({ error: '권한 트리 조회 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/roles
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
@ -76,6 +87,7 @@ router.delete('/:id', async (req, res) => {
|
||||
})
|
||||
|
||||
// PUT /api/roles/:id/permissions
|
||||
// 요청: { permissions: [{ resourceCode, operationCode, granted }] }
|
||||
router.put('/:id/permissions', async (req, res) => {
|
||||
try {
|
||||
const roleSn = Number(req.params.id)
|
||||
@ -86,6 +98,13 @@ router.put('/:id/permissions', async (req, res) => {
|
||||
return
|
||||
}
|
||||
|
||||
for (const p of permissions) {
|
||||
if (!p.resourceCode || !p.operationCode || typeof p.granted !== 'boolean') {
|
||||
res.status(400).json({ error: '각 권한에는 resourceCode, operationCode, granted가 필요합니다.' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await updatePermissions(roleSn, permissions)
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
|
||||
@ -1,13 +1,34 @@
|
||||
import { authPool } from '../db/authDb.js'
|
||||
import { AuthError } from '../auth/authService.js'
|
||||
|
||||
const PERM_RESOURCE_CODES = [
|
||||
'prediction', 'hns', 'rescue', 'reports', 'aerial',
|
||||
'assets', 'scat', 'incidents', 'board', 'weather', 'admin',
|
||||
] as const
|
||||
import { type PermTreeNode, buildPermTree, type PermTreeResponse } from './permResolver.js'
|
||||
|
||||
const PROTECTED_ROLE_CODES = ['ADMIN']
|
||||
|
||||
/** AUTH_PERM_TREE에서 level 0 리소스 코드를 동적 조회 */
|
||||
async function getTopLevelResourceCodes(): Promise<string[]> {
|
||||
const result = await authPool.query(
|
||||
`SELECT RSRC_CD FROM AUTH_PERM_TREE WHERE RSRC_LEVEL = 0 AND USE_YN = 'Y' ORDER BY SORT_ORD`
|
||||
)
|
||||
return result.rows.map((r: { rsrc_cd: string }) => r.rsrc_cd)
|
||||
}
|
||||
|
||||
/** AUTH_PERM_TREE 전체 노드 조회 */
|
||||
export async function getPermTreeNodes(): Promise<PermTreeNode[]> {
|
||||
const result = await authPool.query(
|
||||
`SELECT RSRC_CD as code, PARENT_CD as "parentCode", RSRC_NM as name,
|
||||
RSRC_DESC as description, ICON as icon, RSRC_LEVEL as level, SORT_ORD as "sortOrder"
|
||||
FROM AUTH_PERM_TREE WHERE USE_YN = 'Y'
|
||||
ORDER BY RSRC_LEVEL, SORT_ORD`
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
/** 트리 구조로 변환하여 반환 (프론트엔드 UI용) */
|
||||
export async function getPermTree(): Promise<PermTreeResponse[]> {
|
||||
const nodes = await getPermTreeNodes()
|
||||
return buildPermTree(nodes)
|
||||
}
|
||||
|
||||
interface RoleWithPermissions {
|
||||
sn: number
|
||||
code: string
|
||||
@ -17,6 +38,7 @@ interface RoleWithPermissions {
|
||||
permissions: Array<{
|
||||
sn: number
|
||||
resourceCode: string
|
||||
operationCode: string
|
||||
granted: boolean
|
||||
}>
|
||||
}
|
||||
@ -42,8 +64,8 @@ export async function listRolesWithPermissions(): Promise<RoleWithPermissions[]>
|
||||
|
||||
for (const row of rolesResult.rows) {
|
||||
const permsResult = await authPool.query(
|
||||
`SELECT PERM_SN as sn, RSRC_CD as resource_code, GRANT_YN as granted
|
||||
FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD`,
|
||||
`SELECT PERM_SN as sn, RSRC_CD as resource_code, OPER_CD as operation_code, GRANT_YN as granted
|
||||
FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD, OPER_CD`,
|
||||
[row.sn]
|
||||
)
|
||||
|
||||
@ -53,9 +75,12 @@ export async function listRolesWithPermissions(): Promise<RoleWithPermissions[]>
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
isDefault: row.is_default === 'Y',
|
||||
permissions: permsResult.rows.map((p: { sn: number; resource_code: string; granted: string }) => ({
|
||||
permissions: permsResult.rows.map((p: {
|
||||
sn: number; resource_code: string; operation_code: string; granted: string
|
||||
}) => ({
|
||||
sn: p.sn,
|
||||
resourceCode: p.resource_code,
|
||||
operationCode: p.operation_code,
|
||||
granted: p.granted === 'Y',
|
||||
})),
|
||||
})
|
||||
@ -94,17 +119,20 @@ export async function createRole(input: CreateRoleInput): Promise<RoleWithPermis
|
||||
)
|
||||
const row = result.rows[0]
|
||||
|
||||
for (const rsrc of PERM_RESOURCE_CODES) {
|
||||
// 새 역할: level 0 리소스에 READ='N' 초기화
|
||||
const topLevelCodes = await getTopLevelResourceCodes()
|
||||
for (const rsrc of topLevelCodes) {
|
||||
await client.query(
|
||||
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES ($1, $2, $3)',
|
||||
[row.sn, rsrc, 'N']
|
||||
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES ($1, $2, $3, $4)',
|
||||
[row.sn, rsrc, 'READ', 'N']
|
||||
)
|
||||
}
|
||||
|
||||
await client.query('COMMIT')
|
||||
|
||||
const permsResult = await authPool.query(
|
||||
'SELECT PERM_SN as sn, RSRC_CD as resource_code, GRANT_YN as granted FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD',
|
||||
`SELECT PERM_SN as sn, RSRC_CD as resource_code, OPER_CD as operation_code, GRANT_YN as granted
|
||||
FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD, OPER_CD`,
|
||||
[row.sn]
|
||||
)
|
||||
|
||||
@ -114,9 +142,12 @@ export async function createRole(input: CreateRoleInput): Promise<RoleWithPermis
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
isDefault: row.is_default === 'Y',
|
||||
permissions: permsResult.rows.map((p: { sn: number; resource_code: string; granted: string }) => ({
|
||||
permissions: permsResult.rows.map((p: {
|
||||
sn: number; resource_code: string; operation_code: string; granted: string
|
||||
}) => ({
|
||||
sn: p.sn,
|
||||
resourceCode: p.resource_code,
|
||||
operationCode: p.operation_code,
|
||||
granted: p.granted === 'Y',
|
||||
})),
|
||||
}
|
||||
@ -177,23 +208,23 @@ export async function deleteRole(roleSn: number): Promise<void> {
|
||||
|
||||
export async function updatePermissions(
|
||||
roleSn: number,
|
||||
permissions: Array<{ resourceCode: string; granted: boolean }>
|
||||
permissions: Array<{ resourceCode: string; operationCode: string; granted: boolean }>
|
||||
): Promise<void> {
|
||||
for (const perm of permissions) {
|
||||
const existing = await authPool.query(
|
||||
'SELECT PERM_SN FROM AUTH_PERM WHERE ROLE_SN = $1 AND RSRC_CD = $2',
|
||||
[roleSn, perm.resourceCode]
|
||||
'SELECT PERM_SN FROM AUTH_PERM WHERE ROLE_SN = $1 AND RSRC_CD = $2 AND OPER_CD = $3',
|
||||
[roleSn, perm.resourceCode, perm.operationCode]
|
||||
)
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
await authPool.query(
|
||||
'UPDATE AUTH_PERM SET GRANT_YN = $1 WHERE ROLE_SN = $2 AND RSRC_CD = $3',
|
||||
[perm.granted ? 'Y' : 'N', roleSn, perm.resourceCode]
|
||||
'UPDATE AUTH_PERM SET GRANT_YN = $1 WHERE ROLE_SN = $2 AND RSRC_CD = $3 AND OPER_CD = $4',
|
||||
[perm.granted ? 'Y' : 'N', roleSn, perm.resourceCode, perm.operationCode]
|
||||
)
|
||||
} else {
|
||||
await authPool.query(
|
||||
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES ($1, $2, $3)',
|
||||
[roleSn, perm.resourceCode, perm.granted ? 'Y' : 'N']
|
||||
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES ($1, $2, $3, $4)',
|
||||
[roleSn, perm.resourceCode, perm.operationCode, perm.granted ? 'Y' : 'N']
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import express from 'express'
|
||||
import db from '../db/database.js'
|
||||
import { wingPool } from '../db/wingDb.js'
|
||||
import { enrichLayerWithMetadata } from '../utils/layerIcons.js'
|
||||
import {
|
||||
sanitizeParams,
|
||||
@ -19,14 +19,26 @@ interface Layer {
|
||||
clnm: string | null
|
||||
}
|
||||
|
||||
// DB 컬럼 → API 응답 컬럼 매핑 (프론트엔드 호환성 유지)
|
||||
const LAYER_COLUMNS = `
|
||||
LAYER_CD AS cmn_cd,
|
||||
UP_LAYER_CD AS up_cmn_cd,
|
||||
LAYER_FULL_NM AS cmn_cd_full_nm,
|
||||
LAYER_NM AS cmn_cd_nm,
|
||||
LAYER_LEVEL AS cmn_cd_level,
|
||||
WMS_LAYER_NM AS clnm
|
||||
`.trim()
|
||||
|
||||
// 모든 라우트에 파라미터 살균 적용
|
||||
router.use(sanitizeParams)
|
||||
|
||||
// 모든 레이어 조회
|
||||
router.get('/', (_req, res) => {
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const layers = db.prepare('SELECT * FROM layers ORDER BY cmn_cd').all() as Layer[]
|
||||
const enrichedLayers = layers.map(enrichLayerWithMetadata)
|
||||
const { rows } = await wingPool.query<Layer>(
|
||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' ORDER BY LAYER_CD`
|
||||
)
|
||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||
res.json(enrichedLayers)
|
||||
} catch {
|
||||
res.status(500).json({ error: '레이어 조회 실패' })
|
||||
@ -34,17 +46,19 @@ router.get('/', (_req, res) => {
|
||||
})
|
||||
|
||||
// 계층 구조로 변환된 레이어 트리 조회
|
||||
router.get('/tree/all', (_req, res) => {
|
||||
router.get('/tree/all', async (_req, res) => {
|
||||
try {
|
||||
const layers = db.prepare('SELECT * FROM layers ORDER BY cmn_cd').all() as Layer[]
|
||||
const enrichedLayers = layers.map(enrichLayerWithMetadata)
|
||||
const { rows } = await wingPool.query<Layer>(
|
||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' ORDER BY LAYER_CD`
|
||||
)
|
||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||
|
||||
const layerMap = new Map<string, any>()
|
||||
const layerMap = new Map<string, Layer & { children: Layer[] }>()
|
||||
enrichedLayers.forEach(layer => {
|
||||
layerMap.set(layer.cmn_cd, { ...layer, children: [] })
|
||||
})
|
||||
|
||||
const rootLayers: any[] = []
|
||||
const rootLayers: (Layer & { children: Layer[] })[] = []
|
||||
enrichedLayers.forEach(layer => {
|
||||
const layerNode = layerMap.get(layer.cmn_cd)!
|
||||
if (layer.up_cmn_cd === null) {
|
||||
@ -64,10 +78,12 @@ router.get('/tree/all', (_req, res) => {
|
||||
})
|
||||
|
||||
// WMS 레이어만 조회
|
||||
router.get('/wms/all', (_req, res) => {
|
||||
router.get('/wms/all', async (_req, res) => {
|
||||
try {
|
||||
const layers = db.prepare('SELECT * FROM layers WHERE clnm IS NOT NULL ORDER BY cmn_cd').all() as Layer[]
|
||||
const enrichedLayers = layers.map(enrichLayerWithMetadata)
|
||||
const { rows } = await wingPool.query<Layer>(
|
||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE WMS_LAYER_NM IS NOT NULL AND USE_YN = 'Y' ORDER BY LAYER_CD`
|
||||
)
|
||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||
res.json(enrichedLayers)
|
||||
} catch {
|
||||
res.status(500).json({ error: 'WMS 레이어 조회 실패' })
|
||||
@ -75,11 +91,10 @@ router.get('/wms/all', (_req, res) => {
|
||||
})
|
||||
|
||||
// 특정 레벨의 레이어만 조회
|
||||
router.get('/level/:level', (req, res) => {
|
||||
router.get('/level/:level', async (req, res) => {
|
||||
try {
|
||||
const level = parseInt(req.params.level, 10)
|
||||
|
||||
// 입력 검증: 레벨은 1~10 범위의 정수
|
||||
if (!isValidNumber(level, 1, 10)) {
|
||||
return res.status(400).json({
|
||||
error: '유효하지 않은 레벨값',
|
||||
@ -87,9 +102,11 @@ router.get('/level/:level', (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 파라미터화된 쿼리 사용 (SQL 인젝션 방지)
|
||||
const layers = db.prepare('SELECT * FROM layers WHERE cmn_cd_level = ? ORDER BY cmn_cd').all(level) as Layer[]
|
||||
const enrichedLayers = layers.map(enrichLayerWithMetadata)
|
||||
const { rows } = await wingPool.query<Layer>(
|
||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_LEVEL = $1 AND USE_YN = 'Y' ORDER BY LAYER_CD`,
|
||||
[level]
|
||||
)
|
||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||
res.json(enrichedLayers)
|
||||
} catch {
|
||||
res.status(500).json({ error: '레벨별 레이어 조회 실패' })
|
||||
@ -97,11 +114,10 @@ router.get('/level/:level', (req, res) => {
|
||||
})
|
||||
|
||||
// 특정 부모의 자식 레이어 조회
|
||||
router.get('/children/:parentId', (req, res) => {
|
||||
router.get('/children/:parentId', async (req, res) => {
|
||||
try {
|
||||
const parentId = req.params.parentId
|
||||
|
||||
// 입력 검증: 코드 형식 확인 (영숫자, 언더스코어, 하이픈만 허용)
|
||||
if (!parentId || !isValidStringLength(parentId, 50) || !/^[a-zA-Z0-9_-]+$/.test(parentId)) {
|
||||
return res.status(400).json({
|
||||
error: '유효하지 않은 부모 ID',
|
||||
@ -110,8 +126,11 @@ router.get('/children/:parentId', (req, res) => {
|
||||
}
|
||||
|
||||
const sanitizedId = sanitizeString(parentId)
|
||||
const layers = db.prepare('SELECT * FROM layers WHERE up_cmn_cd = ? ORDER BY cmn_cd').all(sanitizedId) as Layer[]
|
||||
const enrichedLayers = layers.map(enrichLayerWithMetadata)
|
||||
const { rows } = await wingPool.query<Layer>(
|
||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE UP_LAYER_CD = $1 AND USE_YN = 'Y' ORDER BY LAYER_CD`,
|
||||
[sanitizedId]
|
||||
)
|
||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||
res.json(enrichedLayers)
|
||||
} catch {
|
||||
res.status(500).json({ error: '자식 레이어 조회 실패' })
|
||||
@ -119,11 +138,10 @@ router.get('/children/:parentId', (req, res) => {
|
||||
})
|
||||
|
||||
// 특정 레이어 조회
|
||||
router.get('/:id', (req, res) => {
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const id = req.params.id
|
||||
|
||||
// 입력 검증: ID 형식 확인
|
||||
if (!id || !isValidStringLength(id, 50) || !/^[a-zA-Z0-9_-]+$/.test(id)) {
|
||||
return res.status(400).json({
|
||||
error: '유효하지 않은 레이어 ID',
|
||||
@ -132,11 +150,14 @@ router.get('/:id', (req, res) => {
|
||||
}
|
||||
|
||||
const sanitizedId = sanitizeString(id)
|
||||
const layer = db.prepare('SELECT * FROM layers WHERE cmn_cd = ?').get(sanitizedId) as Layer | undefined
|
||||
if (!layer) {
|
||||
const { rows } = await wingPool.query<Layer>(
|
||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_CD = $1`,
|
||||
[sanitizedId]
|
||||
)
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: '레이어를 찾을 수 없습니다' })
|
||||
}
|
||||
const enrichedLayer = enrichLayerWithMetadata(layer)
|
||||
const enrichedLayer = enrichLayerWithMetadata(rows[0])
|
||||
res.json(enrichedLayer)
|
||||
} catch {
|
||||
res.status(500).json({ error: '레이어 조회 실패' })
|
||||
|
||||
@ -4,8 +4,7 @@ import cors from 'cors'
|
||||
import helmet from 'helmet'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import { initDatabase } from './db/database.js'
|
||||
import { testAuthDbConnection } from './db/authDb.js'
|
||||
import { testWingDbConnection } from './db/wingDb.js'
|
||||
import layersRouter from './routes/layers.js'
|
||||
import simulationRouter from './routes/simulation.js'
|
||||
import authRouter from './auth/authRouter.js'
|
||||
@ -14,6 +13,8 @@ import roleRouter from './roles/roleRouter.js'
|
||||
import settingsRouter from './settings/settingsRouter.js'
|
||||
import menuRouter from './menus/menuRouter.js'
|
||||
import auditRouter from './audit/auditRouter.js'
|
||||
import boardRouter from './board/boardRouter.js'
|
||||
import hnsRouter from './hns/hnsRouter.js'
|
||||
import {
|
||||
sanitizeBody,
|
||||
sanitizeQuery,
|
||||
@ -48,6 +49,7 @@ app.use(helmet({
|
||||
}
|
||||
},
|
||||
crossOriginEmbedderPolicy: false, // API 서버이므로 비활성
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' }, // sendBeacon cross-origin 허용
|
||||
}))
|
||||
|
||||
// 2. 서버 정보 제거 (공격자에게 기술 스택 노출 방지)
|
||||
@ -113,11 +115,6 @@ app.use(express.urlencoded({ extended: false, limit: BODY_SIZE_LIMIT }))
|
||||
app.use(sanitizeBody)
|
||||
app.use(sanitizeQuery)
|
||||
|
||||
// ============================================================
|
||||
// 데이터베이스 초기화
|
||||
// ============================================================
|
||||
initDatabase()
|
||||
|
||||
// ============================================================
|
||||
// 라우트
|
||||
// ============================================================
|
||||
@ -140,8 +137,10 @@ app.use('/api/menus', menuRouter)
|
||||
app.use('/api/audit', auditRouter)
|
||||
|
||||
// API 라우트 — 업무
|
||||
app.use('/api/board', boardRouter)
|
||||
app.use('/api/layers', layersRouter)
|
||||
app.use('/api/simulation', simulationLimiter, simulationRouter)
|
||||
app.use('/api/hns', hnsRouter)
|
||||
|
||||
// 헬스 체크
|
||||
app.get('/health', (_req, res) => {
|
||||
@ -176,13 +175,14 @@ app.use((err: Error, _req: express.Request, res: express.Response, _next: expres
|
||||
// ============================================================
|
||||
app.listen(PORT, async () => {
|
||||
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
|
||||
const connected = await testAuthDbConnection()
|
||||
|
||||
// wing DB 연결 확인 (wing + auth 스키마 통합)
|
||||
const connected = await testWingDbConnection()
|
||||
if (connected) {
|
||||
// SETTING_VAL VARCHAR(500) → TEXT 마이그레이션 (메뉴 설정 JSON 확장 대응)
|
||||
try {
|
||||
const { authPool } = await import('./db/authDb.js')
|
||||
await authPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`)
|
||||
console.log('[migration] SETTING_VAL → TEXT 변환 완료')
|
||||
const { wingPool } = await import('./db/wingDb.js')
|
||||
await wingPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`)
|
||||
} catch {
|
||||
// 이미 TEXT이거나 권한 없으면 무시
|
||||
}
|
||||
|
||||
@ -13,5 +13,5 @@
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "src/db/seedHns.ts"]
|
||||
}
|
||||
|
||||
@ -134,18 +134,21 @@ CREATE TABLE AUTH_PERM (
|
||||
PERM_SN SERIAL NOT NULL,
|
||||
ROLE_SN INTEGER NOT NULL,
|
||||
RSRC_CD VARCHAR(50) NOT NULL,
|
||||
OPER_CD VARCHAR(20) NOT NULL,
|
||||
GRANT_YN CHAR(1) NOT NULL DEFAULT 'Y',
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT PK_AUTH_PERM PRIMARY KEY (PERM_SN),
|
||||
CONSTRAINT FK_AP_ROLE FOREIGN KEY (ROLE_SN) REFERENCES AUTH_ROLE(ROLE_SN) ON DELETE CASCADE,
|
||||
CONSTRAINT UK_AUTH_PERM UNIQUE (ROLE_SN, RSRC_CD),
|
||||
CONSTRAINT CK_AUTH_PERM_GRANT CHECK (GRANT_YN IN ('Y','N'))
|
||||
CONSTRAINT UK_AUTH_PERM UNIQUE (ROLE_SN, RSRC_CD, OPER_CD),
|
||||
CONSTRAINT CK_AUTH_PERM_GRANT CHECK (GRANT_YN IN ('Y','N')),
|
||||
CONSTRAINT CK_AUTH_PERM_OPER CHECK (OPER_CD IN ('READ','CREATE','UPDATE','DELETE','MANAGE','EXPORT'))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE AUTH_PERM IS '역할별권한';
|
||||
COMMENT ON COLUMN AUTH_PERM.PERM_SN IS '권한순번';
|
||||
COMMENT ON COLUMN AUTH_PERM.ROLE_SN IS '역할순번';
|
||||
COMMENT ON COLUMN AUTH_PERM.RSRC_CD IS '리소스코드 (탭 ID: prediction, hns, rescue 등)';
|
||||
COMMENT ON COLUMN AUTH_PERM.OPER_CD IS '오퍼레이션코드 (READ, CREATE, UPDATE, DELETE, MANAGE, EXPORT)';
|
||||
COMMENT ON COLUMN AUTH_PERM.GRANT_YN IS '부여여부 (Y:허용, N:거부)';
|
||||
COMMENT ON COLUMN AUTH_PERM.REG_DTM IS '등록일시';
|
||||
|
||||
@ -239,6 +242,7 @@ CREATE UNIQUE INDEX UK_AUTH_USER_OAUTH ON AUTH_USER(OAUTH_PROVIDER, OAUTH_SUB) W
|
||||
CREATE UNIQUE INDEX UK_AUTH_USER_EMAIL ON AUTH_USER(EMAIL) WHERE EMAIL IS NOT NULL;
|
||||
CREATE INDEX IDX_AUTH_PERM_ROLE ON AUTH_PERM (ROLE_SN);
|
||||
CREATE INDEX IDX_AUTH_PERM_RSRC ON AUTH_PERM (RSRC_CD);
|
||||
CREATE INDEX IDX_AUTH_PERM_OPER ON AUTH_PERM (OPER_CD);
|
||||
CREATE INDEX IDX_AUTH_LOGIN_USER ON AUTH_LOGIN_HIST (USER_ID);
|
||||
CREATE INDEX IDX_AUTH_LOGIN_DTM ON AUTH_LOGIN_HIST (LOGIN_DTM);
|
||||
CREATE INDEX IDX_AUDIT_LOG_USER ON AUTH_AUDIT_LOG (USER_ID);
|
||||
@ -257,36 +261,65 @@ INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN) VALUES
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 11. 초기 데이터: 역할별 권한 (탭 접근 매트릭스)
|
||||
-- 11. 초기 데이터: 역할별 권한 (리소스 × 오퍼레이션 매트릭스)
|
||||
-- OPER_CD: READ(조회), CREATE(생성), UPDATE(수정), DELETE(삭제)
|
||||
-- ============================================================
|
||||
|
||||
-- ADMIN (ROLE_SN=1): 모든 탭 접근
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
|
||||
(1, 'prediction', 'Y'), (1, 'hns', 'Y'), (1, 'rescue', 'Y'),
|
||||
(1, 'reports', 'Y'), (1, 'aerial', 'Y'), (1, 'assets', 'Y'),
|
||||
(1, 'scat', 'Y'), (1, 'incidents', 'Y'), (1, 'board', 'Y'),
|
||||
(1, 'weather', 'Y'), (1, 'admin', 'Y');
|
||||
-- ADMIN (ROLE_SN=1): 모든 탭 × 모든 오퍼레이션 허용
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||
(1, 'prediction', 'READ', 'Y'), (1, 'prediction', 'CREATE', 'Y'), (1, 'prediction', 'UPDATE', 'Y'), (1, 'prediction', 'DELETE', 'Y'),
|
||||
(1, 'hns', 'READ', 'Y'), (1, 'hns', 'CREATE', 'Y'), (1, 'hns', 'UPDATE', 'Y'), (1, 'hns', 'DELETE', 'Y'),
|
||||
(1, 'rescue', 'READ', 'Y'), (1, 'rescue', 'CREATE', 'Y'), (1, 'rescue', 'UPDATE', 'Y'), (1, 'rescue', 'DELETE', 'Y'),
|
||||
(1, 'reports', 'READ', 'Y'), (1, 'reports', 'CREATE', 'Y'), (1, 'reports', 'UPDATE', 'Y'), (1, 'reports', 'DELETE', 'Y'),
|
||||
(1, 'aerial', 'READ', 'Y'), (1, 'aerial', 'CREATE', 'Y'), (1, 'aerial', 'UPDATE', 'Y'), (1, 'aerial', 'DELETE', 'Y'),
|
||||
(1, 'assets', 'READ', 'Y'), (1, 'assets', 'CREATE', 'Y'), (1, 'assets', 'UPDATE', 'Y'), (1, 'assets', 'DELETE', 'Y'),
|
||||
(1, 'scat', 'READ', 'Y'), (1, 'scat', 'CREATE', 'Y'), (1, 'scat', 'UPDATE', 'Y'), (1, 'scat', 'DELETE', 'Y'),
|
||||
(1, 'incidents', 'READ', 'Y'), (1, 'incidents', 'CREATE', 'Y'), (1, 'incidents', 'UPDATE', 'Y'), (1, 'incidents', 'DELETE', 'Y'),
|
||||
(1, 'board', 'READ', 'Y'), (1, 'board', 'CREATE', 'Y'), (1, 'board', 'UPDATE', 'Y'), (1, 'board', 'DELETE', 'Y'),
|
||||
(1, 'weather', 'READ', 'Y'), (1, 'weather', 'CREATE', 'Y'), (1, 'weather', 'UPDATE', 'Y'), (1, 'weather', 'DELETE', 'Y'),
|
||||
(1, 'admin', 'READ', 'Y'), (1, 'admin', 'CREATE', 'Y'), (1, 'admin', 'UPDATE', 'Y'), (1, 'admin', 'DELETE', 'Y');
|
||||
|
||||
-- MANAGER (ROLE_SN=2): admin 탭 제외
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
|
||||
(2, 'prediction', 'Y'), (2, 'hns', 'Y'), (2, 'rescue', 'Y'),
|
||||
(2, 'reports', 'Y'), (2, 'aerial', 'Y'), (2, 'assets', 'Y'),
|
||||
(2, 'scat', 'Y'), (2, 'incidents', 'Y'), (2, 'board', 'Y'),
|
||||
(2, 'weather', 'Y'), (2, 'admin', 'N');
|
||||
-- MANAGER (ROLE_SN=2): admin 탭 제외, RCUD 허용
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||
(2, 'prediction', 'READ', 'Y'), (2, 'prediction', 'CREATE', 'Y'), (2, 'prediction', 'UPDATE', 'Y'), (2, 'prediction', 'DELETE', 'Y'),
|
||||
(2, 'hns', 'READ', 'Y'), (2, 'hns', 'CREATE', 'Y'), (2, 'hns', 'UPDATE', 'Y'), (2, 'hns', 'DELETE', 'Y'),
|
||||
(2, 'rescue', 'READ', 'Y'), (2, 'rescue', 'CREATE', 'Y'), (2, 'rescue', 'UPDATE', 'Y'), (2, 'rescue', 'DELETE', 'Y'),
|
||||
(2, 'reports', 'READ', 'Y'), (2, 'reports', 'CREATE', 'Y'), (2, 'reports', 'UPDATE', 'Y'), (2, 'reports', 'DELETE', 'Y'),
|
||||
(2, 'aerial', 'READ', 'Y'), (2, 'aerial', 'CREATE', 'Y'), (2, 'aerial', 'UPDATE', 'Y'), (2, 'aerial', 'DELETE', 'Y'),
|
||||
(2, 'assets', 'READ', 'Y'), (2, 'assets', 'CREATE', 'Y'), (2, 'assets', 'UPDATE', 'Y'), (2, 'assets', 'DELETE', 'Y'),
|
||||
(2, 'scat', 'READ', 'Y'), (2, 'scat', 'CREATE', 'Y'), (2, 'scat', 'UPDATE', 'Y'), (2, 'scat', 'DELETE', 'Y'),
|
||||
(2, 'incidents', 'READ', 'Y'), (2, 'incidents', 'CREATE', 'Y'), (2, 'incidents', 'UPDATE', 'Y'), (2, 'incidents', 'DELETE', 'Y'),
|
||||
(2, 'board', 'READ', 'Y'), (2, 'board', 'CREATE', 'Y'), (2, 'board', 'UPDATE', 'Y'), (2, 'board', 'DELETE', 'Y'),
|
||||
(2, 'weather', 'READ', 'Y'), (2, 'weather', 'CREATE', 'Y'), (2, 'weather', 'UPDATE', 'Y'), (2, 'weather', 'DELETE', 'Y'),
|
||||
(2, 'admin', 'READ', 'N');
|
||||
|
||||
-- USER (ROLE_SN=3): assets, admin 탭 제외
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
|
||||
(3, 'prediction', 'Y'), (3, 'hns', 'Y'), (3, 'rescue', 'Y'),
|
||||
(3, 'reports', 'Y'), (3, 'aerial', 'Y'), (3, 'assets', 'N'),
|
||||
(3, 'scat', 'Y'), (3, 'incidents', 'Y'), (3, 'board', 'Y'),
|
||||
(3, 'weather', 'Y'), (3, 'admin', 'N');
|
||||
-- USER (ROLE_SN=3): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||
(3, 'prediction', 'READ', 'Y'), (3, 'prediction', 'CREATE', 'Y'), (3, 'prediction', 'UPDATE', 'Y'),
|
||||
(3, 'hns', 'READ', 'Y'), (3, 'hns', 'CREATE', 'Y'), (3, 'hns', 'UPDATE', 'Y'),
|
||||
(3, 'rescue', 'READ', 'Y'), (3, 'rescue', 'CREATE', 'Y'), (3, 'rescue', 'UPDATE', 'Y'),
|
||||
(3, 'reports', 'READ', 'Y'), (3, 'reports', 'CREATE', 'Y'), (3, 'reports', 'UPDATE', 'Y'),
|
||||
(3, 'aerial', 'READ', 'Y'), (3, 'aerial', 'CREATE', 'Y'), (3, 'aerial', 'UPDATE', 'Y'),
|
||||
(3, 'assets', 'READ', 'N'),
|
||||
(3, 'scat', 'READ', 'Y'), (3, 'scat', 'CREATE', 'Y'), (3, 'scat', 'UPDATE', 'Y'),
|
||||
(3, 'incidents', 'READ', 'Y'), (3, 'incidents', 'CREATE', 'Y'), (3, 'incidents', 'UPDATE', 'Y'),
|
||||
(3, 'board', 'READ', 'Y'), (3, 'board', 'CREATE', 'Y'), (3, 'board', 'UPDATE', 'Y'),
|
||||
(3, 'weather', 'READ', 'Y'),
|
||||
(3, 'admin', 'READ', 'N');
|
||||
|
||||
-- VIEWER (ROLE_SN=4): reports, assets, scat, admin 제외
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
|
||||
(4, 'prediction', 'Y'), (4, 'hns', 'Y'), (4, 'rescue', 'Y'),
|
||||
(4, 'reports', 'N'), (4, 'aerial', 'Y'), (4, 'assets', 'N'),
|
||||
(4, 'scat', 'N'), (4, 'incidents', 'Y'), (4, 'board', 'Y'),
|
||||
(4, 'weather', 'Y'), (4, 'admin', 'N');
|
||||
-- VIEWER (ROLE_SN=4): 제한적 탭의 READ만 허용
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||
(4, 'prediction', 'READ', 'Y'),
|
||||
(4, 'hns', 'READ', 'Y'),
|
||||
(4, 'rescue', 'READ', 'Y'),
|
||||
(4, 'reports', 'READ', 'N'),
|
||||
(4, 'aerial', 'READ', 'Y'),
|
||||
(4, 'assets', 'READ', 'N'),
|
||||
(4, 'scat', 'READ', 'N'),
|
||||
(4, 'incidents', 'READ', 'Y'),
|
||||
(4, 'board', 'READ', 'Y'),
|
||||
(4, 'weather', 'READ', 'Y'),
|
||||
(4, 'admin', 'READ', 'N');
|
||||
|
||||
|
||||
-- ============================================================
|
||||
|
||||
36
database/migration/001_layer_table.sql
Normal file
36
database/migration/001_layer_table.sql
Normal file
@ -0,0 +1,36 @@
|
||||
-- ================================================================
|
||||
-- 001: LAYER 테이블 생성 (SQLite layers.db → PostgreSQL wing DB 마이그레이션)
|
||||
-- ================================================================
|
||||
-- 기존 SQLite layers 테이블의 데이터를 PostgreSQL wing DB로 이전
|
||||
-- 공공데이터베이스 표준화 관리 매뉴얼(2021.06) 네이밍 적용
|
||||
-- ================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS LAYER (
|
||||
LAYER_CD VARCHAR(50) NOT NULL, -- 레이어코드 (기존 cmn_cd)
|
||||
UP_LAYER_CD VARCHAR(50), -- 상위레이어코드 (기존 up_cmn_cd)
|
||||
LAYER_FULL_NM VARCHAR(200) NOT NULL, -- 레이어전체명 (기존 cmn_cd_full_nm)
|
||||
LAYER_NM VARCHAR(100) NOT NULL, -- 레이어명 (기존 cmn_cd_nm)
|
||||
LAYER_LEVEL INTEGER NOT NULL, -- 레이어레벨 (기존 cmn_cd_level)
|
||||
WMS_LAYER_NM VARCHAR(100), -- WMS레이어명 (기존 clnm)
|
||||
USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 사용여부
|
||||
SORT_ORD INTEGER DEFAULT 0, -- 정렬순서
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
||||
CONSTRAINT PK_LAYER PRIMARY KEY (LAYER_CD),
|
||||
CONSTRAINT FK_LAYER_UP FOREIGN KEY (UP_LAYER_CD) REFERENCES LAYER(LAYER_CD),
|
||||
CONSTRAINT CK_LAYER_USE_YN CHECK (USE_YN IN ('Y', 'N'))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE LAYER IS '레이어';
|
||||
COMMENT ON COLUMN LAYER.LAYER_CD IS '레이어코드 (예: LYR001001)';
|
||||
COMMENT ON COLUMN LAYER.UP_LAYER_CD IS '상위레이어코드 (상위 레이어 참조)';
|
||||
COMMENT ON COLUMN LAYER.LAYER_FULL_NM IS '레이어전체명 (계층 경로 포함 전체 명칭)';
|
||||
COMMENT ON COLUMN LAYER.LAYER_NM IS '레이어명 (표시용 짧은 명칭)';
|
||||
COMMENT ON COLUMN LAYER.LAYER_LEVEL IS '레이어레벨 (1:최상위, 2:중분류, 3:소분류 ...)';
|
||||
COMMENT ON COLUMN LAYER.WMS_LAYER_NM IS 'WMS레이어명 (GeoServer WMS 레이어 식별자)';
|
||||
COMMENT ON COLUMN LAYER.USE_YN IS '사용여부 (Y:사용, N:미사용)';
|
||||
COMMENT ON COLUMN LAYER.SORT_ORD IS '정렬순서';
|
||||
COMMENT ON COLUMN LAYER.REG_DTM IS '등록일시';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IDX_LAYER_UP ON LAYER(UP_LAYER_CD);
|
||||
CREATE INDEX IF NOT EXISTS IDX_LAYER_LEVEL ON LAYER(LAYER_LEVEL);
|
||||
CREATE INDEX IF NOT EXISTS IDX_LAYER_USE ON LAYER(USE_YN);
|
||||
46
database/migration/002_hns_substance.sql
Normal file
46
database/migration/002_hns_substance.sql
Normal file
@ -0,0 +1,46 @@
|
||||
-- ================================================================
|
||||
-- 002: HNS 물질정보 테이블 (프론트엔드 정적 데이터 → DB 이전)
|
||||
-- ================================================================
|
||||
-- 검색용 컬럼 + 상세 데이터 JSONB 구조
|
||||
-- pg_trgm 인덱스로 한글/영문 물질명 검색 지원
|
||||
-- ================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS HNS_SUBSTANCE (
|
||||
SBST_SN SERIAL NOT NULL, -- 물질순번
|
||||
ABBREVIATION VARCHAR(50), -- 약자/제품명
|
||||
NM_KR VARCHAR(200) NOT NULL, -- 국문명
|
||||
NM_EN VARCHAR(200), -- 영문명
|
||||
UN_NO VARCHAR(10), -- UN번호
|
||||
CAS_NO VARCHAR(20), -- CAS번호
|
||||
SEBC VARCHAR(50), -- SEBC 거동분류
|
||||
DATA JSONB NOT NULL, -- 전체 상세 데이터
|
||||
USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 사용여부
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
||||
CONSTRAINT PK_HNS_SUBSTANCE PRIMARY KEY (SBST_SN),
|
||||
CONSTRAINT CK_HNS_SBST_USE CHECK (USE_YN IN ('Y', 'N'))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE HNS_SUBSTANCE IS 'HNS물질정보 (1,316종)';
|
||||
COMMENT ON COLUMN HNS_SUBSTANCE.SBST_SN IS '물질순번';
|
||||
COMMENT ON COLUMN HNS_SUBSTANCE.ABBREVIATION IS '약자/제품명 (화물적부도 코드)';
|
||||
COMMENT ON COLUMN HNS_SUBSTANCE.NM_KR IS '국문명';
|
||||
COMMENT ON COLUMN HNS_SUBSTANCE.NM_EN IS '영문명';
|
||||
COMMENT ON COLUMN HNS_SUBSTANCE.UN_NO IS 'UN번호 (위험물 식별번호)';
|
||||
COMMENT ON COLUMN HNS_SUBSTANCE.CAS_NO IS 'CAS번호 (화학물질등록번호)';
|
||||
COMMENT ON COLUMN HNS_SUBSTANCE.SEBC IS 'SEBC 거동분류 (G/GD/E/ED/FE/FED/F/FD/D/S/SD)';
|
||||
COMMENT ON COLUMN HNS_SUBSTANCE.DATA IS '전체 상세 데이터 (JSONB)';
|
||||
COMMENT ON COLUMN HNS_SUBSTANCE.USE_YN IS '사용여부 (Y:사용, N:미사용)';
|
||||
COMMENT ON COLUMN HNS_SUBSTANCE.REG_DTM IS '등록일시';
|
||||
|
||||
-- 텍스트 검색 인덱스 (pg_trgm)
|
||||
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_NM_KR ON HNS_SUBSTANCE USING GIN(NM_KR gin_trgm_ops);
|
||||
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_NM_EN ON HNS_SUBSTANCE USING GIN(NM_EN gin_trgm_ops);
|
||||
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_ABBR ON HNS_SUBSTANCE USING GIN(ABBREVIATION gin_trgm_ops);
|
||||
|
||||
-- 코드 검색 인덱스
|
||||
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_UN ON HNS_SUBSTANCE(UN_NO);
|
||||
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_CAS ON HNS_SUBSTANCE(CAS_NO);
|
||||
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_SEBC ON HNS_SUBSTANCE(SEBC);
|
||||
|
||||
-- JSONB 내 cargoCodes 검색용 인덱스
|
||||
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_DATA ON HNS_SUBSTANCE USING GIN(DATA jsonb_path_ops);
|
||||
108
database/migration/003_perm_tree.sql
Normal file
108
database/migration/003_perm_tree.sql
Normal file
@ -0,0 +1,108 @@
|
||||
-- ============================================================
|
||||
-- AUTH_PERM_TREE: 트리 구조 기반 리소스(메뉴) 권한 정의
|
||||
-- 부모-자식 관계로 N-depth 서브탭 권한 제어 지원
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS AUTH_PERM_TREE (
|
||||
RSRC_CD VARCHAR(50) NOT NULL, -- 콜론 구분 경로: 'prediction', 'aerial:media'
|
||||
PARENT_CD VARCHAR(50), -- NULL이면 최상위 탭
|
||||
RSRC_NM VARCHAR(100) NOT NULL, -- 표시명
|
||||
RSRC_DESC VARCHAR(200), -- 설명 (NULL 허용)
|
||||
ICON VARCHAR(20), -- 아이콘 (NULL 허용, 선택 옵션)
|
||||
RSRC_LEVEL SMALLINT NOT NULL DEFAULT 0, -- depth (0=탭, 1=서브탭, 2+)
|
||||
SORT_ORD SMALLINT NOT NULL DEFAULT 0, -- 형제 노드 간 정렬
|
||||
USE_YN CHAR(1) NOT NULL DEFAULT 'Y',
|
||||
CONSTRAINT PK_PERM_TREE PRIMARY KEY (RSRC_CD),
|
||||
CONSTRAINT FK_PERM_TREE_PARENT FOREIGN KEY (PARENT_CD)
|
||||
REFERENCES AUTH_PERM_TREE(RSRC_CD)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IDX_PERM_TREE_PARENT ON AUTH_PERM_TREE(PARENT_CD);
|
||||
|
||||
-- ============================================================
|
||||
-- 초기 데이터
|
||||
-- ============================================================
|
||||
|
||||
-- Level 0: 메인 탭 (11개)
|
||||
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_DESC, RSRC_LEVEL, SORT_ORD) VALUES
|
||||
('prediction', NULL, '유출유 확산예측', '확산 예측 실행 및 결과 조회', 0, 1),
|
||||
('hns', NULL, 'HNS·대기확산', '대기확산 분석 실행 및 조회', 0, 2),
|
||||
('rescue', NULL, '긴급구난', '구난 예측 실행 및 조회', 0, 3),
|
||||
('reports', NULL, '보고자료', '사고 보고서 작성 및 조회', 0, 4),
|
||||
('aerial', NULL, '항공탐색', '항공 탐색 데이터 조회', 0, 5),
|
||||
('assets', NULL, '방제자산 관리', '방제 장비 및 자산 관리', 0, 6),
|
||||
('scat', NULL, '해안평가', 'SCAT 조사 실행 및 조회', 0, 7),
|
||||
('board', NULL, '게시판', '자료실 및 공지사항 조회', 0, 8),
|
||||
('weather', NULL, '기상정보', '기상 및 해상 정보 조회', 0, 9),
|
||||
('incidents', NULL, '통합조회', '사고 상세 정보 조회', 0, 10),
|
||||
('admin', NULL, '관리', '사용자 및 권한 관리', 0, 11)
|
||||
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||
|
||||
-- Level 1: prediction 하위
|
||||
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||
('prediction:analysis', 'prediction', '확산분석', 1, 1),
|
||||
('prediction:list', 'prediction', '분석 목록', 1, 2),
|
||||
('prediction:theory', 'prediction', '확산모델 이론', 1, 3),
|
||||
('prediction:boom-theory', 'prediction', '오일펜스 배치 이론', 1, 4)
|
||||
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||
|
||||
-- Level 1: hns 하위
|
||||
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||
('hns:analysis', 'hns', '대기확산 분석', 1, 1),
|
||||
('hns:list', 'hns', '분석 목록', 1, 2),
|
||||
('hns:scenario', 'hns', '시나리오 관리', 1, 3),
|
||||
('hns:manual', 'hns', 'HNS 대응매뉴얼', 1, 4),
|
||||
('hns:theory', 'hns', '확산모델 이론', 1, 5),
|
||||
('hns:substance', 'hns', 'HNS 물질정보', 1, 6)
|
||||
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||
|
||||
-- Level 1: rescue 하위
|
||||
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||
('rescue:rescue', 'rescue', '긴급구난예측', 1, 1),
|
||||
('rescue:list', 'rescue', '긴급구난 목록', 1, 2),
|
||||
('rescue:scenario', 'rescue', '시나리오 관리', 1, 3),
|
||||
('rescue:theory', 'rescue', '긴급구난모델 이론', 1, 4)
|
||||
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||
|
||||
-- Level 1: reports 하위
|
||||
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||
('reports:report-list', 'reports', '보고서 목록', 1, 1),
|
||||
('reports:template', 'reports', '표준보고서 템플릿', 1, 2),
|
||||
('reports:generate', 'reports', '보고서 생성', 1, 3)
|
||||
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||
|
||||
-- Level 1: aerial 하위
|
||||
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||
('aerial:media', 'aerial', '영상사진관리', 1, 1),
|
||||
('aerial:analysis', 'aerial', '유출유면적분석', 1, 2),
|
||||
('aerial:realtime', 'aerial', '실시간드론', 1, 3),
|
||||
('aerial:sensor', 'aerial', '오염/선박3D분석', 1, 4),
|
||||
('aerial:satellite', 'aerial', '위성요청', 1, 5),
|
||||
('aerial:cctv', 'aerial', 'CCTV 조회', 1, 6),
|
||||
('aerial:theory', 'aerial', '항공탐색 이론', 1, 7)
|
||||
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||
|
||||
-- Level 1: assets 하위
|
||||
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||
('assets:management', 'assets', '자산 관리', 1, 1),
|
||||
('assets:upload', 'assets', '자산 현행화', 1, 2),
|
||||
('assets:theory', 'assets', '방제자원 이론', 1, 3),
|
||||
('assets:insurance', 'assets', '선박 보험정보', 1, 4)
|
||||
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||
|
||||
-- Level 1: board 하위
|
||||
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||
('board:all', 'board', '전체', 1, 1),
|
||||
('board:notice', 'board', '공지사항', 1, 2),
|
||||
('board:data', 'board', '자료실', 1, 3),
|
||||
('board:qna', 'board', 'Q&A', 1, 4),
|
||||
('board:manual', 'board', '해경매뉴얼', 1, 5)
|
||||
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||
|
||||
-- Level 1: admin 하위
|
||||
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||
('admin:users', 'admin', '사용자 관리', 1, 1),
|
||||
('admin:permissions', 'admin', '권한 관리', 1, 2),
|
||||
('admin:menus', 'admin', '메뉴 관리', 1, 3),
|
||||
('admin:settings', 'admin', '시스템 설정', 1, 4)
|
||||
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||
55
database/migration/004_oper_cd.sql
Normal file
55
database/migration/004_oper_cd.sql
Normal file
@ -0,0 +1,55 @@
|
||||
-- ============================================================
|
||||
-- 마이그레이션 004: AUTH_PERM에 OPER_CD 컬럼 추가
|
||||
-- 리소스 단일 권한 → 리소스 × 오퍼레이션(RCUD) 2차원 권한 모델
|
||||
-- ============================================================
|
||||
|
||||
-- Step 1: OPER_CD 컬럼 추가 (기존 레코드는 'READ'로 설정)
|
||||
ALTER TABLE AUTH_PERM ADD COLUMN IF NOT EXISTS OPER_CD VARCHAR(20) NOT NULL DEFAULT 'READ';
|
||||
COMMENT ON COLUMN AUTH_PERM.OPER_CD IS '오퍼레이션코드 (READ, CREATE, UPDATE, DELETE, MANAGE, EXPORT)';
|
||||
|
||||
-- Step 2: UNIQUE 제약 변경 (ROLE_SN, RSRC_CD) → (ROLE_SN, RSRC_CD, OPER_CD)
|
||||
-- INSERT 전에 변경해야 CUD 레코드 삽입 시 충돌 없음
|
||||
ALTER TABLE AUTH_PERM DROP CONSTRAINT IF EXISTS UK_AUTH_PERM;
|
||||
ALTER TABLE AUTH_PERM ADD CONSTRAINT UK_AUTH_PERM UNIQUE (ROLE_SN, RSRC_CD, OPER_CD);
|
||||
|
||||
-- Step 3: 기존 GRANT_YN='Y' 레코드를 CREATE/UPDATE/DELETE로 확장
|
||||
-- (기존에 허용된 리소스는 RCUD 모두 허용하여 동작 보존)
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
|
||||
SELECT ROLE_SN, RSRC_CD, 'CREATE', GRANT_YN
|
||||
FROM AUTH_PERM WHERE OPER_CD = 'READ' AND GRANT_YN = 'Y'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
|
||||
SELECT ROLE_SN, RSRC_CD, 'UPDATE', GRANT_YN
|
||||
FROM AUTH_PERM WHERE OPER_CD = 'READ' AND GRANT_YN = 'Y'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
|
||||
SELECT ROLE_SN, RSRC_CD, 'DELETE', GRANT_YN
|
||||
FROM AUTH_PERM WHERE OPER_CD = 'READ' AND GRANT_YN = 'Y'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Step 3-1: VIEWER(조회 전용) 역할의 CUD 레코드 제거
|
||||
-- VIEWER는 READ만 허용, CUD 확장은 의미 없음
|
||||
DELETE FROM AUTH_PERM
|
||||
WHERE ROLE_SN = (SELECT ROLE_SN FROM AUTH_ROLE WHERE ROLE_CD = 'VIEWER')
|
||||
AND OPER_CD != 'READ';
|
||||
|
||||
-- Step 4: 기본값 제거 (신규 레코드는 반드시 OPER_CD 명시)
|
||||
ALTER TABLE AUTH_PERM ALTER COLUMN OPER_CD DROP DEFAULT;
|
||||
|
||||
-- Step 5: CHECK 제약 추가 (확장 가능: MANAGE, EXPORT 포함)
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE AUTH_PERM ADD CONSTRAINT CK_AUTH_PERM_OPER
|
||||
CHECK (OPER_CD IN ('READ','CREATE','UPDATE','DELETE','MANAGE','EXPORT'));
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- Step 6: 인덱스
|
||||
CREATE INDEX IF NOT EXISTS IDX_AUTH_PERM_OPER ON AUTH_PERM (OPER_CD);
|
||||
|
||||
-- 검증
|
||||
SELECT ROLE_SN, OPER_CD, COUNT(*), STRING_AGG(GRANT_YN, '') as grants
|
||||
FROM AUTH_PERM
|
||||
GROUP BY ROLE_SN, OPER_CD
|
||||
ORDER BY ROLE_SN, OPER_CD;
|
||||
45
database/migration/005_db_consolidation.sql
Normal file
45
database/migration/005_db_consolidation.sql
Normal file
@ -0,0 +1,45 @@
|
||||
-- ============================================================
|
||||
-- 마이그레이션 005: DB 통합 (wing + wing_auth → wing 단일 DB)
|
||||
--
|
||||
-- 스키마 구조:
|
||||
-- wing — 운영 데이터 (LAYER, BOARD_POST, HNS_SUBSTANCE 등)
|
||||
-- auth — 인증/인가 데이터 (AUTH_USER, AUTH_ROLE 등)
|
||||
-- public — PostGIS 시스템 테이블만 유지 (spatial_ref_sys)
|
||||
--
|
||||
-- 실행 순서:
|
||||
-- 1. 이 SQL을 wing DB에서 실행 (스키마 생성 + 테이블 이동)
|
||||
-- 2. wing_auth DB 덤프 → auth 스키마로 복원 (별도 쉘)
|
||||
-- 3. search_path 설정
|
||||
-- ============================================================
|
||||
|
||||
-- Step 1: 명시적 스키마 생성
|
||||
CREATE SCHEMA IF NOT EXISTS wing;
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
|
||||
-- Step 2: 기존 public 운영 테이블을 wing 스키마로 이동
|
||||
-- (PostGIS 시스템 테이블 spatial_ref_sys, topology는 public에 유지)
|
||||
ALTER TABLE IF EXISTS public.layer SET SCHEMA wing;
|
||||
ALTER TABLE IF EXISTS public.hns_substance SET SCHEMA wing;
|
||||
|
||||
-- Step 3: 기본 search_path 설정 (DB 레벨)
|
||||
-- wing 사용자가 스키마 접두사 없이 양쪽 테이블 접근 가능
|
||||
ALTER DATABASE wing SET search_path = wing, auth;
|
||||
|
||||
-- Step 4: wing 사용자에게 auth 스키마 권한 부여
|
||||
-- (wing_auth 데이터 복원 후 적용)
|
||||
GRANT USAGE ON SCHEMA auth TO wing;
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA auth TO wing;
|
||||
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA auth TO wing;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT ALL ON TABLES TO wing;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT ALL ON SEQUENCES TO wing;
|
||||
|
||||
-- Step 5: wing 스키마 기본 권한
|
||||
GRANT ALL PRIVILEGES ON SCHEMA wing TO wing;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA wing GRANT ALL ON TABLES TO wing;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA wing GRANT ALL ON SEQUENCES TO wing;
|
||||
|
||||
-- 검증
|
||||
SELECT schemaname, tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname IN ('wing', 'auth')
|
||||
ORDER BY schemaname, tablename;
|
||||
61
database/migration/006_board.sql
Normal file
61
database/migration/006_board.sql
Normal file
@ -0,0 +1,61 @@
|
||||
-- ============================================================
|
||||
-- 마이그레이션 006: 게시판 (BOARD_POST)
|
||||
-- wing 스키마에 생성, auth.AUTH_USER FK 참조
|
||||
-- ============================================================
|
||||
|
||||
-- Step 1: 게시판 테이블
|
||||
CREATE TABLE IF NOT EXISTS BOARD_POST (
|
||||
POST_SN SERIAL PRIMARY KEY,
|
||||
CATEGORY_CD VARCHAR(20) NOT NULL,
|
||||
TITLE VARCHAR(200) NOT NULL,
|
||||
CONTENT TEXT,
|
||||
AUTHOR_ID UUID NOT NULL,
|
||||
VIEW_CNT INTEGER NOT NULL DEFAULT 0,
|
||||
PINNED_YN CHAR(1) NOT NULL DEFAULT 'N',
|
||||
USE_YN CHAR(1) NOT NULL DEFAULT 'Y',
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
MDFCN_DTM TIMESTAMPTZ,
|
||||
|
||||
CONSTRAINT FK_BOARD_AUTHOR FOREIGN KEY (AUTHOR_ID)
|
||||
REFERENCES auth.AUTH_USER(USER_ID),
|
||||
CONSTRAINT CK_BOARD_CATEGORY
|
||||
CHECK (CATEGORY_CD IN ('NOTICE','DATA','QNA','MANUAL')),
|
||||
CONSTRAINT CK_BOARD_PINNED CHECK (PINNED_YN IN ('Y','N')),
|
||||
CONSTRAINT CK_BOARD_USE CHECK (USE_YN IN ('Y','N'))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE BOARD_POST IS '게시판 게시글';
|
||||
COMMENT ON COLUMN BOARD_POST.CATEGORY_CD IS '카테고리: NOTICE=공지, DATA=자료실, QNA=Q&A, MANUAL=해경매뉴얼';
|
||||
COMMENT ON COLUMN BOARD_POST.PINNED_YN IS '상단고정 여부';
|
||||
COMMENT ON COLUMN BOARD_POST.USE_YN IS '사용여부 (N=논리삭제)';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IDX_BOARD_CATEGORY ON BOARD_POST(CATEGORY_CD);
|
||||
CREATE INDEX IF NOT EXISTS IDX_BOARD_AUTHOR ON BOARD_POST(AUTHOR_ID);
|
||||
CREATE INDEX IF NOT EXISTS IDX_BOARD_REG_DTM ON BOARD_POST(REG_DTM DESC);
|
||||
|
||||
-- Step 2: 초기 데이터 (기존 프론트엔드 mockPosts 이전)
|
||||
-- admin 사용자 ID 조회
|
||||
DO $$
|
||||
DECLARE
|
||||
v_admin_id UUID;
|
||||
BEGIN
|
||||
SELECT USER_ID INTO v_admin_id FROM auth.AUTH_USER WHERE USER_ACNT = 'admin' LIMIT 1;
|
||||
|
||||
IF v_admin_id IS NOT NULL THEN
|
||||
INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID, VIEW_CNT, PINNED_YN, REG_DTM) VALUES
|
||||
('NOTICE', '시스템 업데이트 안내', '시스템 업데이트 관련 안내사항입니다.', v_admin_id, 245, 'Y', '2025-02-15'::timestamptz),
|
||||
('NOTICE', '2025년 방제 교육 일정 안내', '2025년도 방제 교육 일정을 안내합니다.', v_admin_id, 189, 'Y', '2025-02-14'::timestamptz),
|
||||
('DATA', '방제 매뉴얼 업데이트 (2025년 개정판)', '2025년 개정판 방제 매뉴얼입니다.', v_admin_id, 423, 'N', '2025-02-10'::timestamptz),
|
||||
('QNA', 'HNS 대기확산 분석 결과 해석 문의', 'HNS 분석 결과 해석 방법을 문의합니다.', v_admin_id, 156, 'N', '2025-02-08'::timestamptz),
|
||||
('DATA', '2024년 유류오염사고 통계 자료', '2024년도 유류오염사고 통계 자료를 공유합니다.', v_admin_id, 312, 'N', '2025-02-05'::timestamptz),
|
||||
('QNA', '유출유 확산 예측 알고리즘 선택 기준', '확산 예측 시 알고리즘 선택 기준을 문의합니다.', v_admin_id, 267, 'N', '2025-02-03'::timestamptz),
|
||||
('DATA', '해양오염 방제 장비 운용 가이드', '방제 장비 운용 가이드 문서입니다.', v_admin_id, 534, 'N', '2025-01-28'::timestamptz),
|
||||
('QNA', 'SCAT 조사 방법 관련 질문', 'SCAT 현장 조사 방법에 대해 질문합니다.', v_admin_id, 198, 'N', '2025-01-25'::timestamptz),
|
||||
('DATA', 'HNS 물질 안전보건자료 (MSDS) 모음', 'HNS 물질별 MSDS 자료 모음입니다.', v_admin_id, 645, 'N', '2025-01-20'::timestamptz),
|
||||
('QNA', '항공촬영 드론 운용 시 주의사항', '드론 운용 시 주의할 점을 문의합니다.', v_admin_id, 221, 'N', '2025-01-15'::timestamptz)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 검증
|
||||
SELECT POST_SN, CATEGORY_CD, TITLE, VIEW_CNT, PINNED_YN FROM BOARD_POST ORDER BY POST_SN;
|
||||
@ -10,21 +10,79 @@
|
||||
### 개요
|
||||
JWT 기반 세션 인증. HttpOnly 쿠키(`WING_SESSION`)로 토큰을 관리하며, 프론트엔드에서는 Zustand `authStore`로 상태를 관리합니다.
|
||||
|
||||
### 권한 모델: 리소스 × 오퍼레이션 (RBAC)
|
||||
|
||||
**2차원 권한 모델**: 리소스 트리(상속) × 오퍼레이션(RCUD, 플랫)
|
||||
|
||||
```
|
||||
AUTH_PERM 테이블: (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
|
||||
|
||||
리소스 트리 (AUTH_PERM_TREE) 오퍼레이션 (플랫)
|
||||
├── prediction READ = 조회/열람
|
||||
│ ├── prediction:analysis CREATE = 생성
|
||||
│ ├── prediction:list UPDATE = 수정
|
||||
│ └── prediction:theory DELETE = 삭제
|
||||
├── board
|
||||
│ ├── board:notice
|
||||
│ └── board:data
|
||||
└── admin
|
||||
├── admin:users
|
||||
└── admin:permissions
|
||||
```
|
||||
|
||||
#### 오퍼레이션 코드
|
||||
|
||||
| OPER_CD | 설명 | 비고 |
|
||||
|---------|------|------|
|
||||
| `READ` | 조회/열람 | 목록, 상세 조회 |
|
||||
| `CREATE` | 생성 | 새 데이터 등록 |
|
||||
| `UPDATE` | 수정 | 기존 데이터 변경 |
|
||||
| `DELETE` | 삭제 | 데이터 삭제 |
|
||||
| `MANAGE` | 관리 | 관리자 설정 (확장용) |
|
||||
| `EXPORT` | 내보내기 | 다운로드/출력 (확장용) |
|
||||
|
||||
#### 상속 규칙
|
||||
|
||||
1. 부모 리소스의 **READ**가 N → 자식의 **모든 오퍼레이션** 강제 N (접근 자체 차단)
|
||||
2. 해당 `(RSRC_CD, OPER_CD)` 명시적 레코드 있으면 → 그 값 사용
|
||||
3. 명시적 레코드 없으면 → 부모의 **같은 OPER_CD** 상속
|
||||
4. 최상위까지 없으면 → 기본 N (거부)
|
||||
|
||||
```
|
||||
예시: board (READ:Y, CREATE:Y, UPDATE:Y, DELETE:N)
|
||||
└── board:notice
|
||||
├── READ: 상속 Y (부모 READ Y)
|
||||
├── CREATE: 상속 Y (부모 CREATE Y)
|
||||
├── UPDATE: 명시적 N (override 가능)
|
||||
└── DELETE: 상속 N (부모 DELETE N)
|
||||
```
|
||||
|
||||
#### 키 구분자
|
||||
- 리소스 내부 경로: `:` (board:notice)
|
||||
- 리소스-오퍼레이션 결합 (내부용): `::` (board:notice::READ)
|
||||
|
||||
### 백엔드
|
||||
|
||||
#### 미들웨어 적용
|
||||
#### 미들웨어
|
||||
|
||||
```typescript
|
||||
// backend/src/auth/authMiddleware.ts
|
||||
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
||||
import { requireAuth, requireRole, requirePermission } from '../auth/authMiddleware.js'
|
||||
|
||||
// 인증만 필요한 라우트
|
||||
router.use(requireAuth)
|
||||
|
||||
// 특정 역할 필요
|
||||
// 역할 기반 (관리 API용)
|
||||
router.use(requireRole('ADMIN'))
|
||||
router.use(requireRole('ADMIN', 'MANAGER'))
|
||||
|
||||
// 리소스×오퍼레이션 기반 (일반 비즈니스 API용)
|
||||
router.post('/notice/list', requirePermission('board:notice', 'READ'), handler)
|
||||
router.post('/notice/create', requirePermission('board:notice', 'CREATE'), handler)
|
||||
router.post('/notice/update', requirePermission('board:notice', 'UPDATE'), handler)
|
||||
router.post('/notice/delete', requirePermission('board:notice', 'DELETE'), handler)
|
||||
```
|
||||
|
||||
`requirePermission`은 요청당 1회만 DB 조회하고 `req.resolvedPermissions`에 캐싱합니다.
|
||||
|
||||
#### JWT 페이로드 (req.user)
|
||||
`requireAuth` 통과 후 `req.user`에 담기는 정보:
|
||||
```typescript
|
||||
@ -36,25 +94,21 @@ interface JwtPayload {
|
||||
}
|
||||
```
|
||||
|
||||
#### 라우터 패턴
|
||||
#### 라우터 패턴 (CRUD 구조)
|
||||
```typescript
|
||||
// backend/src/[모듈]/[모듈]Router.ts
|
||||
import { Router } from 'express'
|
||||
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
||||
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireAuth)
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const userId = req.user!.sub
|
||||
// 비즈니스 로직...
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
console.error('[모듈] 오류:', err)
|
||||
res.status(500).json({ error: '처리 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
// 리소스별 CRUD 엔드포인트
|
||||
router.post('/list', requirePermission('module:sub', 'READ'), listHandler)
|
||||
router.post('/detail', requirePermission('module:sub', 'READ'), detailHandler)
|
||||
router.post('/create', requirePermission('module:sub', 'CREATE'), createHandler)
|
||||
router.post('/update', requirePermission('module:sub', 'UPDATE'), updateHandler)
|
||||
router.post('/delete', requirePermission('module:sub', 'DELETE'), deleteHandler)
|
||||
|
||||
export default router
|
||||
```
|
||||
@ -63,32 +117,36 @@ export default router
|
||||
|
||||
#### authStore (Zustand)
|
||||
```typescript
|
||||
// frontend/src/store/authStore.ts
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useAuthStore } from '@common/store/authStore'
|
||||
|
||||
// 컴포넌트 내에서 사용
|
||||
const { user, isAuthenticated, hasPermission, logout } = useAuthStore()
|
||||
|
||||
// 사용자 정보
|
||||
user?.id // UUID
|
||||
user?.name // 이름
|
||||
user?.roles // ['ADMIN', 'USER']
|
||||
user?.permissions // { 'prediction': ['READ','CREATE','UPDATE','DELETE'], ... }
|
||||
|
||||
// 권한 확인 (탭 ID 기준)
|
||||
hasPermission('prediction') // true/false
|
||||
hasPermission('admin') // true/false
|
||||
// 권한 확인 (리소스 × 오퍼레이션)
|
||||
hasPermission('prediction') // READ 확인 (기본값)
|
||||
hasPermission('prediction', 'READ') // 명시적 READ 확인
|
||||
hasPermission('board:notice', 'CREATE') // 공지사항 생성 권한
|
||||
hasPermission('board:notice', 'DELETE') // 공지사항 삭제 권한
|
||||
|
||||
// 하위 호환: operation 생략 시 'READ' 기본값
|
||||
hasPermission('admin') // === hasPermission('admin', 'READ')
|
||||
```
|
||||
|
||||
#### API 클라이언트
|
||||
```typescript
|
||||
// frontend/src/services/api.ts
|
||||
import { api } from './api'
|
||||
import { api } from '@common/services/api'
|
||||
|
||||
// withCredentials: true 설정으로 JWT 쿠키 자동 포함
|
||||
const response = await api.get('/your-endpoint')
|
||||
const response = await api.post('/your-endpoint', data)
|
||||
const response = await api.post('/your-endpoint/list', params)
|
||||
const response = await api.post('/your-endpoint/create', data)
|
||||
|
||||
// 401 응답 시 자동 로그아웃 처리 (인터셉터)
|
||||
// 403 응답 시 권한 부족 (requirePermission 미들웨어)
|
||||
```
|
||||
|
||||
---
|
||||
@ -103,13 +161,15 @@ const response = await api.post('/your-endpoint', data)
|
||||
|
||||
```typescript
|
||||
// frontend/src/App.tsx (자동 적용, 수정 불필요)
|
||||
import { API_BASE_URL } from '@common/services/api'
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return
|
||||
const blob = new Blob(
|
||||
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
|
||||
{ type: 'text/plain' }
|
||||
)
|
||||
navigator.sendBeacon('/api/audit/log', blob)
|
||||
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
|
||||
}, [activeMainTab, isAuthenticated])
|
||||
```
|
||||
|
||||
@ -117,12 +177,13 @@ useEffect(() => {
|
||||
특정 작업에 대해 명시적으로 감사 로그를 기록하려면:
|
||||
|
||||
```typescript
|
||||
// 프론트엔드에서 sendBeacon 사용
|
||||
import { API_BASE_URL } from '@common/services/api'
|
||||
|
||||
const blob = new Blob(
|
||||
[JSON.stringify({ action: 'ADMIN_ACTION', detail: '사용자 승인' })],
|
||||
{ type: 'text/plain' }
|
||||
)
|
||||
navigator.sendBeacon('/api/audit/log', blob)
|
||||
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
|
||||
```
|
||||
|
||||
### 감사 로그 테이블 구조 (AUTH_AUDIT_LOG)
|
||||
@ -275,30 +336,82 @@ const mutation = useMutation({
|
||||
|
||||
---
|
||||
|
||||
## 6. 백엔드 모듈 추가 절차
|
||||
## 6. 백엔드 API CRUD 규칙
|
||||
|
||||
> 상세 가이드 + 게시판 실전 튜토리얼: **[CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md)** 참조
|
||||
|
||||
### HTTP Method 정책 (보안 가이드 준수)
|
||||
- 보안 취약점 점검 가이드에 따라 **POST 메서드를 기본**으로 사용한다.
|
||||
- GET은 단순 조회 중 민감하지 않은 경우에만 허용 (필요 시 POST로 전환).
|
||||
- PUT, DELETE, PATCH 등 기타 메서드는 사용하지 않는다.
|
||||
|
||||
### 오퍼레이션 기반 권한 미들웨어
|
||||
OPER_CD는 HTTP Method가 아닌 **비즈니스 의미**로 결정한다.
|
||||
`requirePermission` 미들웨어에 명시적으로 오퍼레이션을 지정한다.
|
||||
|
||||
| URL 패턴 | OPER_CD | 미들웨어 |
|
||||
|----------|---------|----------|
|
||||
| `/resource/list` | READ | `requirePermission(resource, 'READ')` |
|
||||
| `/resource/detail` | READ | `requirePermission(resource, 'READ')` |
|
||||
| `/resource/create` | CREATE | `requirePermission(resource, 'CREATE')` |
|
||||
| `/resource/update` | UPDATE | `requirePermission(resource, 'UPDATE')` |
|
||||
| `/resource/delete` | DELETE | `requirePermission(resource, 'DELETE')` |
|
||||
|
||||
### 라우터 작성 예시
|
||||
|
||||
```typescript
|
||||
// backend/src/board/noticeRouter.ts
|
||||
import { Router } from 'express'
|
||||
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireAuth)
|
||||
|
||||
// 조회
|
||||
router.post('/list', requirePermission('board:notice', 'READ'), listHandler)
|
||||
router.post('/detail', requirePermission('board:notice', 'READ'), detailHandler)
|
||||
|
||||
// 생성/수정/삭제
|
||||
router.post('/create', requirePermission('board:notice', 'CREATE'), createHandler)
|
||||
router.post('/update', requirePermission('board:notice', 'UPDATE'), updateHandler)
|
||||
router.post('/delete', requirePermission('board:notice', 'DELETE'), deleteHandler)
|
||||
|
||||
export default router
|
||||
```
|
||||
|
||||
### 관리 API (예외)
|
||||
사용자/역할/설정 등 관리 API는 `requireRole('ADMIN')` 유지:
|
||||
```typescript
|
||||
router.use(requireAuth)
|
||||
router.use(requireRole('ADMIN'))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 백엔드 모듈 추가 절차
|
||||
|
||||
새 백엔드 모듈을 추가할 때:
|
||||
|
||||
1. `backend/src/[모듈명]/` 디렉토리 생성
|
||||
2. `[모듈명]Service.ts` — 비즈니스 로직 (DB 쿼리)
|
||||
3. `[모듈명]Router.ts` — Express 라우터 (입력 검증, 에러 처리)
|
||||
3. `[모듈명]Router.ts` — Express 라우터 (CRUD 엔드포인트 + requirePermission)
|
||||
4. `backend/src/server.ts`에 라우터 등록:
|
||||
```typescript
|
||||
import newRouter from './[모듈명]/[모듈명]Router.js'
|
||||
app.use('/api/[경로]', newRouter)
|
||||
```
|
||||
5. DB 테이블 필요 시 `database/auth_init.sql`에 DDL 추가
|
||||
6. 리소스 코드를 `AUTH_PERM_TREE`에 등록 (마이그레이션 SQL)
|
||||
|
||||
### DB 접근
|
||||
```typescript
|
||||
// PostgreSQL (인증 DB)
|
||||
import { authPool } from '../db/authDb.js'
|
||||
const result = await authPool.query('SELECT * FROM TABLE WHERE id = $1', [id])
|
||||
// PostgreSQL — wing DB (운영 데이터: 레이어, 사고, 예측 등)
|
||||
import { wingPool } from '../db/wingDb.js'
|
||||
const result = await wingPool.query('SELECT * FROM LAYER WHERE LAYER_CD = $1', [id])
|
||||
|
||||
// SQLite (레이어 DB)
|
||||
import { getDb } from '../db/database.js'
|
||||
const db = getDb()
|
||||
const rows = db.prepare('SELECT * FROM table').all()
|
||||
// PostgreSQL — wing_auth DB (인증 데이터: 사용자, 역할, 권한 등)
|
||||
import { authPool } from '../db/authDb.js'
|
||||
const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1', [id])
|
||||
```
|
||||
|
||||
---
|
||||
@ -307,20 +420,30 @@ const rows = db.prepare('SELECT * FROM table').all()
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── services/api.ts Axios 인스턴스 + 인터셉터
|
||||
├── services/authApi.ts 인증/사용자/역할/설정/메뉴/감사로그 API
|
||||
├── store/authStore.ts 인증 상태 (Zustand)
|
||||
├── store/menuStore.ts 메뉴 상태 (Zustand)
|
||||
├── common/
|
||||
│ ├── services/api.ts Axios 인스턴스 + API_BASE_URL + 인터셉터
|
||||
│ ├── services/authApi.ts 인증/사용자/역할/설정/메뉴/감사로그 API
|
||||
│ ├── store/authStore.ts 인증 상태 + hasPermission (Zustand)
|
||||
│ ├── store/menuStore.ts 메뉴 상태 (Zustand)
|
||||
│ └── hooks/ useSubMenu, useFeatureTracking 등
|
||||
├── tabs/ 탭별 패키지 (11개)
|
||||
└── App.tsx 탭 라우팅 + 감사 로그 자동 기록
|
||||
|
||||
backend/src/
|
||||
├── auth/ 인증 (JWT, OAuth, 미들웨어)
|
||||
├── auth/ 인증 (JWT, OAuth, 미들웨어, requirePermission)
|
||||
├── users/ 사용자 관리
|
||||
├── roles/ 역할/권한 관리
|
||||
├── roles/ 역할/권한 관리 (permResolver, roleService)
|
||||
├── settings/ 시스템 설정
|
||||
├── menus/ 메뉴 설정
|
||||
├── audit/ 감사 로그
|
||||
├── db/ DB 연결 (authDb, database)
|
||||
├── db/ DB 연결 (authDb, wingDb)
|
||||
├── middleware/ 보안 미들웨어
|
||||
└── server.ts Express 진입점 + 라우터 등록
|
||||
|
||||
database/
|
||||
├── auth_init.sql 인증 DB DDL + 초기 데이터
|
||||
├── init.sql 운영 DB DDL
|
||||
└── migration/ 마이그레이션 스크립트
|
||||
├── 003_perm_tree.sql 리소스 트리 (AUTH_PERM_TREE)
|
||||
└── 004_oper_cd.sql 오퍼레이션 코드 (OPER_CD) 추가
|
||||
```
|
||||
|
||||
1433
docs/CRUD-API-GUIDE.md
Normal file
1433
docs/CRUD-API-GUIDE.md
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -22,18 +22,19 @@ Frontend: menuStore.ts → TopBar.tsx (탭 렌더링)
|
||||
|
||||
| 순서 | 파일 | 작업 | 필수 |
|
||||
|------|------|------|------|
|
||||
| 1 | `frontend/src/components/views/XxxView.tsx` | 뷰 컴포넌트 생성 | O |
|
||||
| 2 | `frontend/src/App.tsx` | MainTab 타입 + import + renderView | O |
|
||||
| 3 | `backend/src/settings/settingsService.ts` | DEFAULT_MENU_CONFIG에 항목 추가 | O |
|
||||
| 4 | `database/auth_init.sql` | menu.config 초기 JSON에 추가 | O |
|
||||
| 5 | 관리자 UI | 메뉴 관리에서 활성화 | O |
|
||||
| 1 | `frontend/src/tabs/{탭명}/components/XxxView.tsx` | 뷰 컴포넌트 생성 | O |
|
||||
| 2 | `frontend/src/tabs/{탭명}/index.ts` | re-export 생성 | O |
|
||||
| 3 | `frontend/src/App.tsx` | MainTab 타입 + import + renderView | O |
|
||||
| 4 | `backend/src/settings/settingsService.ts` | DEFAULT_MENU_CONFIG에 항목 추가 | O |
|
||||
| 5 | `database/auth_init.sql` | menu.config 초기 JSON에 추가 | O |
|
||||
| 6 | 관리자 UI | 메뉴 관리에서 활성화 | O |
|
||||
|
||||
## Step 1: 뷰 컴포넌트 생성
|
||||
|
||||
`frontend/src/components/views/` 에 새 뷰 컴포넌트를 생성합니다.
|
||||
`frontend/src/tabs/{탭명}/components/` 에 새 뷰 컴포넌트를 생성합니다.
|
||||
|
||||
```tsx
|
||||
// frontend/src/components/views/MonitoringView.tsx
|
||||
// frontend/src/tabs/monitoring/components/MonitoringView.tsx
|
||||
|
||||
export function MonitoringView() {
|
||||
return (
|
||||
@ -47,7 +48,14 @@ export function MonitoringView() {
|
||||
}
|
||||
```
|
||||
|
||||
기존 뷰 컴포넌트(`OilSpillView`, `WeatherView` 등)의 레이아웃 패턴을 참고하세요.
|
||||
`index.ts`에서 re-export합니다:
|
||||
```tsx
|
||||
// frontend/src/tabs/monitoring/index.ts
|
||||
export { MonitoringView } from './components/MonitoringView'
|
||||
```
|
||||
|
||||
기존 탭(`@tabs/prediction`, `@tabs/weather` 등)의 레이아웃 패턴을 참고하세요.
|
||||
공통 모듈은 `@common/` alias로 import합니다.
|
||||
|
||||
## Step 2: App.tsx 탭 등록
|
||||
|
||||
@ -68,7 +76,7 @@ export type MainTab = 'prediction' | 'hns' | ... | 'monitoring' | 'admin'
|
||||
### 2-2. 뷰 컴포넌트 import
|
||||
|
||||
```tsx
|
||||
import { MonitoringView } from './components/views/MonitoringView'
|
||||
import { MonitoringView } from '@tabs/monitoring'
|
||||
```
|
||||
|
||||
### 2-3. renderView switch에 case 추가
|
||||
|
||||
@ -34,12 +34,12 @@ claude
|
||||
| 영역 | 기술 |
|
||||
|------|------|
|
||||
| Frontend | React 19, Vite 7, TypeScript 5.9, Tailwind CSS 3 |
|
||||
| Backend | Express 4, TypeScript, better-sqlite3 (레이어), pg (인증) |
|
||||
| Backend | Express 4, TypeScript, PostgreSQL (pg) |
|
||||
| 상태 관리 | Zustand (클라이언트), TanStack Query (서버) |
|
||||
| 지도 | Leaflet, OpenLayers |
|
||||
| 지도 | Leaflet + react-leaflet |
|
||||
| 실시간 | Socket.IO |
|
||||
| 인증 | JWT (HttpOnly Cookie), Google OAuth |
|
||||
| DB | PostgreSQL 16 + PostGIS (운영 DB 직접 연결), SQLite |
|
||||
| DB | PostgreSQL 16 + PostGIS (wing 운영DB + wing_auth 인증DB) |
|
||||
| CI/CD | Gitea Actions |
|
||||
|
||||
---
|
||||
@ -50,18 +50,21 @@ claude
|
||||
wing/
|
||||
├── frontend/ React 19 + Vite + TypeScript + Tailwind
|
||||
│ └── src/
|
||||
│ ├── App.tsx 메인 (탭 라우팅, 감사 로그 자동 기록)
|
||||
│ ├── components/ UI 컴포넌트
|
||||
│ │ ├── auth/ 로그인 페이지
|
||||
│ │ ├── views/ 각 탭별 페이지 뷰 (11개)
|
||||
│ │ ├── layout/ MainLayout, TopBar, LeftPanel, RightPanel
|
||||
│ │ ├── map/ 지도 관련
|
||||
│ │ └── ... analysis, board, incidents, weather 등
|
||||
│ ├── hooks/ 커스텀 훅
|
||||
│ ├── services/ API 서비스 (api, authApi, weatherApi 등)
|
||||
│ ├── store/ Zustand 상태 (authStore, menuStore)
|
||||
│ ├── types/ 타입 정의
|
||||
│ └── utils/ 유틸리티
|
||||
│ ├── App.tsx 메인 (탭 라우팅, 감사 로그)
|
||||
│ ├── common/ 공통 모듈 (@common/ alias)
|
||||
│ │ ├── components/ auth/, layer/, layout/, map/, ui/
|
||||
│ │ ├── hooks/ useLayers, useSubMenu
|
||||
│ │ ├── services/ api.ts, authApi.ts, layerService.ts
|
||||
│ │ ├── store/ authStore, menuStore (Zustand)
|
||||
│ │ ├── types/ backtrack, boomLine, hns, navigation
|
||||
│ │ └── utils/ coordinates, geo, sanitize
|
||||
│ └── tabs/ 탭 단위 패키지 (@tabs/ alias)
|
||||
│ ├── prediction/ 확산 예측 (OilSpillView, LeftPanel 등)
|
||||
│ ├── hns/ HNS 분석 (HNSView, HNSSubstanceView 등)
|
||||
│ ├── rescue/ 구조 시나리오
|
||||
│ ├── aerial/ 항공 방제
|
||||
│ ├── weather/ 해양 기상
|
||||
│ └── ... incidents, board, reports, assets, scat, admin
|
||||
├── backend/ Express + TypeScript
|
||||
│ └── src/
|
||||
│ ├── server.ts 진입점 + 라우터 등록
|
||||
@ -71,10 +74,11 @@ wing/
|
||||
│ ├── settings/ 시스템 설정
|
||||
│ ├── menus/ 메뉴 설정
|
||||
│ ├── audit/ 감사 로그
|
||||
│ ├── hns/ HNS 물질 검색 API
|
||||
│ ├── routes/ 레이어, 시뮬레이션
|
||||
│ ├── middleware/ 보안 (입력 살균, rate-limit)
|
||||
│ └── db/ DB 연결 (PostgreSQL, SQLite)
|
||||
├── database/ SQL 초기화 스크립트
|
||||
│ └── db/ DB 연결 (wingDb, authDb), seed
|
||||
├── database/ SQL 스크립트 + 마이그레이션
|
||||
├── docs/ 개발 문서
|
||||
├── .claude/ 팀 워크플로우 (rules, skills, scripts)
|
||||
└── .githooks/ Git hooks (pre-commit, commit-msg)
|
||||
|
||||
205
frontend/package-lock.json
generated
205
frontend/package-lock.json
generated
@ -19,7 +19,6 @@
|
||||
"emoji-mart": "^5.6.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.564.0",
|
||||
"ol": "^10.8.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
@ -1148,12 +1147,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@petamoriken/float16": {
|
||||
"version": "3.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz",
|
||||
"integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||
@ -1650,12 +1643,6 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/rbush": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz",
|
||||
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
@ -1966,16 +1953,6 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@zarrita/storage": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.4.tgz",
|
||||
"integrity": "sha512-qURfJAQcQGRfDQ4J9HaCjGaj3jlJKc66bnRk6G/IeLUsM7WKyG7Bzsuf1EZurSXyc0I4LVcu6HaeQQ4d3kZ16g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"reference-spec-reader": "^0.2.0",
|
||||
"unzipit": "1.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
@ -2483,12 +2460,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.286",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
|
||||
@ -2897,12 +2868,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
@ -3051,25 +3016,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/geotiff": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.3.tgz",
|
||||
"integrity": "sha512-yRoDQDYxWYiB421p0cbxJvdy79OlQW+rxDI9GDbIUeWCAh6YAZ0vlTKF448EAiEuuUpBsNaegd2flavF0p+kvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@petamoriken/float16": "^3.4.7",
|
||||
"lerc": "^3.0.0",
|
||||
"pako": "^2.0.4",
|
||||
"parse-headers": "^2.0.2",
|
||||
"quick-lru": "^6.1.1",
|
||||
"web-worker": "^1.5.0",
|
||||
"xml-utils": "^1.10.2",
|
||||
"zstddec": "^0.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.19"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@ -3410,12 +3356,6 @@
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/lerc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
|
||||
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@ -3633,15 +3573,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/numcodecs": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz",
|
||||
"integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@ -3662,24 +3593,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/ol": {
|
||||
"version": "10.8.0",
|
||||
"resolved": "https://registry.npmjs.org/ol/-/ol-10.8.0.tgz",
|
||||
"integrity": "sha512-kLk7jIlJvKyhVMAjORTXKjzlM6YIByZ1H/d0DBx3oq8nSPCG6/gbLr5RxukzPgwbhnAqh+xHNCmrvmFKhVMvoQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@types/rbush": "4.0.0",
|
||||
"earcut": "^3.0.0",
|
||||
"geotiff": "^3.0.2",
|
||||
"pbf": "4.0.1",
|
||||
"rbush": "^4.0.0",
|
||||
"zarrita": "^0.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/openlayers"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@ -3730,12 +3643,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@ -3749,12 +3656,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-headers": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz",
|
||||
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@ -3782,18 +3683,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@ -4007,12 +3896,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
||||
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@ -4050,33 +3933,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quick-lru": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
|
||||
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/rbush": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz",
|
||||
"integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"quickselect": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
@ -4158,12 +4014,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/reference-spec-reader": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz",
|
||||
"integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@ -4195,15 +4045,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
@ -4598,18 +4439,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unzipit": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/unzipit/-/unzipit-1.4.3.tgz",
|
||||
"integrity": "sha512-gsq2PdJIWWGhx5kcdWStvNWit9FVdTewm4SEG7gFskWs+XCVaULt9+BwuoBtJiRE8eo3L1IPAOrbByNLtLtIlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uzip-module": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@ -4658,12 +4487,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uzip-module": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/uzip-module/-/uzip-module-1.0.3.tgz",
|
||||
"integrity": "sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
@ -4739,12 +4562,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/web-worker": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
|
||||
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@ -4792,12 +4609,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-utils": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
|
||||
"integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
@ -4826,16 +4637,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zarrita": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.6.1.tgz",
|
||||
"integrity": "sha512-YOMTW8FT55Rz+vadTIZeOFZ/F2h4svKizyldvPtMYSxPgSNcRkOzkxCsWpIWlWzB1I/LmISmi0bEekOhLlI+Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zarrita/storage": "^0.1.4",
|
||||
"numcodecs": "^0.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
@ -4859,12 +4660,6 @@
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zstddec": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz",
|
||||
"integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==",
|
||||
"license": "MIT AND BSD-3-Clause"
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.11",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
|
||||
|
||||
@ -21,7 +21,6 @@
|
||||
"emoji-mart": "^5.6.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.564.0",
|
||||
"ol": "^10.8.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
|
||||
@ -1,23 +1,22 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { GoogleOAuthProvider } from '@react-oauth/google'
|
||||
import { MainLayout } from './components/layout/MainLayout'
|
||||
import { LoginPage } from './components/auth/LoginPage'
|
||||
import { registerMainTabSwitcher } from './hooks/useSubMenu'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { useMenuStore } from './store/menuStore'
|
||||
import { OilSpillView } from './components/views/OilSpillView'
|
||||
import { ReportsView } from './components/views/ReportsView'
|
||||
import { HNSView } from './components/views/HNSView'
|
||||
import { AerialView } from './components/views/AerialView'
|
||||
import { AssetsView } from './components/views/AssetsView'
|
||||
import { BoardView } from './components/views/BoardView'
|
||||
import { WeatherView } from './components/views/WeatherView'
|
||||
import { IncidentsView } from './components/views/IncidentsView'
|
||||
import { AdminView } from './components/views/AdminView'
|
||||
import { PreScatView } from './components/views/PreScatView'
|
||||
import { RescueView } from './components/views/RescueView'
|
||||
|
||||
export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin'
|
||||
import type { MainTab } from '@common/types/navigation'
|
||||
import { MainLayout } from '@common/components/layout/MainLayout'
|
||||
import { LoginPage } from '@common/components/auth/LoginPage'
|
||||
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu'
|
||||
import { useAuthStore } from '@common/store/authStore'
|
||||
import { useMenuStore } from '@common/store/menuStore'
|
||||
import { OilSpillView } from '@tabs/prediction'
|
||||
import { ReportsView } from '@tabs/reports'
|
||||
import { HNSView } from '@tabs/hns'
|
||||
import { AerialView } from '@tabs/aerial'
|
||||
import { AssetsView } from '@tabs/assets'
|
||||
import { BoardView } from '@tabs/board'
|
||||
import { WeatherView } from '@tabs/weather'
|
||||
import { IncidentsView } from '@tabs/incidents'
|
||||
import { AdminView } from '@tabs/admin'
|
||||
import { PreScatView } from '@tabs/scat'
|
||||
import { RescueView } from '@tabs/rescue'
|
||||
|
||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || ''
|
||||
|
||||
@ -47,7 +46,8 @@ function App() {
|
||||
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
|
||||
{ type: 'text/plain' }
|
||||
)
|
||||
navigator.sendBeacon('/api/audit/log', blob)
|
||||
const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
|
||||
navigator.sendBeacon(`${apiBase}/audit/log`, blob)
|
||||
}, [activeMainTab, isAuthenticated])
|
||||
|
||||
// 세션 확인 중 스플래시
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import type { Layer } from '../../data/layerDatabase'
|
||||
import type { Layer } from '@common/services/layerService'
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#ef4444','#f97316','#eab308','#22c55e','#06b6d4',
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { MainTab } from '../../App'
|
||||
import type { MainTab } from '../../types/navigation'
|
||||
import { TopBar } from './TopBar'
|
||||
import { SubMenuBar } from './SubMenuBar'
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { MainTab } from '../../App'
|
||||
import type { MainTab } from '../../types/navigation'
|
||||
import { useSubMenu } from '../../hooks/useSubMenu'
|
||||
|
||||
interface SubMenuBarProps {
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import type { MainTab } from '../../App'
|
||||
import type { MainTab } from '../../types/navigation'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useMenuStore } from '../../store/menuStore'
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { ReplayShip, CollisionEvent } from '../../types/backtrack'
|
||||
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||
|
||||
interface BacktrackReplayBarProps {
|
||||
isPlaying: boolean
|
||||
@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Polyline, CircleMarker, Circle, Marker, Popup } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import type { ReplayShip, CollisionEvent, ReplayPathPoint } from '../../types/backtrack'
|
||||
import type { ReplayShip, CollisionEvent, ReplayPathPoint } from '@common/types/backtrack'
|
||||
|
||||
interface BacktrackReplayOverlayProps {
|
||||
replayShips: ReplayShip[]
|
||||
@ -2,11 +2,11 @@ import { useState, useMemo, useEffect } from 'react'
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents, CircleMarker, Circle, Polyline } from 'react-leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import L from 'leaflet'
|
||||
import { layerDatabase } from '../../data/layerDatabase'
|
||||
import { decimalToDMS } from '../../utils/coordinates'
|
||||
import type { PredictionModel } from '../views/OilSpillView'
|
||||
import type { BoomLine, BoomLineCoord } from '../../types/boomLine'
|
||||
import type { ReplayShip, CollisionEvent } from '../../types/backtrack'
|
||||
import { layerDatabase } from '@common/services/layerService'
|
||||
import { decimalToDMS } from '@common/utils/coordinates'
|
||||
import type { PredictionModel } from '@tabs/prediction/components/OilSpillView'
|
||||
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
|
||||
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||
import { BacktrackReplayOverlay } from './BacktrackReplayOverlay'
|
||||
|
||||
// Fix Leaflet default icon issue
|
||||
72
frontend/src/common/constants/featureIds.ts
Normal file
72
frontend/src/common/constants/featureIds.ts
Normal file
@ -0,0 +1,72 @@
|
||||
/**
|
||||
* FEATURE_ID 레지스트리
|
||||
*
|
||||
* 서브탭 단위의 기능 식별자. AUTH_PERM.RSRC_CD 및 감사로그 ACTION_DTL과 동기화.
|
||||
* 형식: '{메인탭}:{서브탭}'
|
||||
*/
|
||||
export const FEATURE_IDS = {
|
||||
// prediction
|
||||
'prediction:analysis': '확산 분석',
|
||||
'prediction:list': '시뮬레이션 목록',
|
||||
'prediction:theory': '확산 이론',
|
||||
'prediction:boom-theory': '오일펜스 배치 이론',
|
||||
|
||||
// hns
|
||||
'hns:analysis': 'HNS 분석',
|
||||
'hns:list': 'HNS 시뮬레이션 목록',
|
||||
'hns:scenario': 'HNS 시나리오',
|
||||
'hns:manual': 'HNS 매뉴얼',
|
||||
'hns:theory': 'HNS 이론',
|
||||
'hns:substance': 'HNS 물질정보',
|
||||
|
||||
// rescue
|
||||
'rescue:rescue': '구난 메인',
|
||||
'rescue:list': '구난 목록',
|
||||
'rescue:scenario': '구난 시나리오',
|
||||
'rescue:theory': '구난 이론',
|
||||
|
||||
// aerial
|
||||
'aerial:media': '영상 관리',
|
||||
'aerial:analysis': '유출 면적 분석',
|
||||
'aerial:realtime': '실시간 드론',
|
||||
'aerial:sensor': '센서 분석',
|
||||
'aerial:satellite': '위성 요청',
|
||||
'aerial:cctv': 'CCTV 모니터링',
|
||||
'aerial:theory': '항공탐색 이론',
|
||||
|
||||
// reports
|
||||
'reports:report-list': '보고서 목록',
|
||||
'reports:template': '보고서 템플릿',
|
||||
'reports:generate': '보고서 생성',
|
||||
|
||||
// board
|
||||
'board:all': '전체 게시판',
|
||||
'board:notice': '공지사항',
|
||||
'board:data': '자료실',
|
||||
'board:qna': '질의응답',
|
||||
'board:manual': '매뉴얼',
|
||||
|
||||
// assets
|
||||
'assets:management': '자산 관리',
|
||||
'assets:upload': '자산 현행화',
|
||||
'assets:theory': '방제자원 이론',
|
||||
'assets:insurance': '선박 보험정보',
|
||||
|
||||
// scat
|
||||
'scat:survey': 'SCAT 조사',
|
||||
|
||||
// weather
|
||||
'weather:current': '현재 기상',
|
||||
'weather:forecast': '기상 예보',
|
||||
|
||||
// incidents
|
||||
'incidents:list': '사고 목록',
|
||||
|
||||
// admin
|
||||
'admin:users': '사용자 관리',
|
||||
'admin:permissions': '권한 매트릭스',
|
||||
'admin:menus': '메뉴 관리',
|
||||
'admin:settings': '시스템 설정',
|
||||
} as const;
|
||||
|
||||
export type FeatureId = keyof typeof FEATURE_IDS;
|
||||
24
frontend/src/common/hooks/useFeatureTracking.ts
Normal file
24
frontend/src/common/hooks/useFeatureTracking.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from '@common/store/authStore';
|
||||
import { API_BASE_URL } from '@common/services/api';
|
||||
|
||||
/**
|
||||
* 서브탭 진입 시 감사 로그를 기록하는 훅.
|
||||
* App.tsx의 탭 레벨 TAB_VIEW와 함께, 서브탭 레벨 SUBTAB_VIEW를 기록한다.
|
||||
*
|
||||
* N-depth 지원: 콜론 구분 경로 (예: 'aerial:media', 'admin:users', 'a:b:c:d')
|
||||
*
|
||||
* @param featureId - 콜론 구분 리소스 경로
|
||||
*/
|
||||
export function useFeatureTracking(featureId: string) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !featureId) return;
|
||||
const blob = new Blob(
|
||||
[JSON.stringify({ action: 'SUBTAB_VIEW', detail: featureId })],
|
||||
{ type: 'text/plain' },
|
||||
);
|
||||
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob);
|
||||
}, [featureId, isAuthenticated]);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchAllLayers, fetchLayerTree, fetchWMSLayers } from '../services/api'
|
||||
import type { Layer } from '../data/layerDatabase'
|
||||
import type { Layer } from '@common/services/layerService'
|
||||
|
||||
// 모든 레이어 조회 훅
|
||||
export function useLayers() {
|
||||
@ -1,5 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { MainTab } from '../App'
|
||||
import type { MainTab } from '../types/navigation'
|
||||
import { useAuthStore } from '@common/store/authStore'
|
||||
import { API_BASE_URL } from '@common/services/api'
|
||||
|
||||
interface SubMenuItem {
|
||||
id: string
|
||||
@ -91,6 +93,8 @@ function subscribe(listener: () => void) {
|
||||
|
||||
export function useSubMenu(mainTab: MainTab) {
|
||||
const [activeSubTab, setActiveSubTabLocal] = useState(subMenuState[mainTab])
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribe(() => {
|
||||
@ -103,10 +107,27 @@ export function useSubMenu(mainTab: MainTab) {
|
||||
setSubTab(mainTab, subTab)
|
||||
}
|
||||
|
||||
// 권한 기반 서브메뉴 필터링
|
||||
const rawConfig = subMenuConfigs[mainTab]
|
||||
const filteredConfig = rawConfig?.filter(item =>
|
||||
hasPermission(`${mainTab}:${item.id}`)
|
||||
) ?? null
|
||||
|
||||
// 서브탭 전환 시 자동 감사 로그 (N-depth 지원: 콜론 구분 경로)
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !activeSubTab) return
|
||||
const resourcePath = `${mainTab}:${activeSubTab}`
|
||||
const blob = new Blob(
|
||||
[JSON.stringify({ action: 'SUBTAB_VIEW', detail: resourcePath })],
|
||||
{ type: 'text/plain' },
|
||||
)
|
||||
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
|
||||
}, [mainTab, activeSubTab, isAuthenticated])
|
||||
|
||||
return {
|
||||
activeSubTab,
|
||||
setActiveSubTab,
|
||||
subMenuConfig: subMenuConfigs[mainTab]
|
||||
subMenuConfig: filteredConfig,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { BacktrackConditions, BacktrackVessel, ReplayShip, CollisionEvent } from '../types/backtrack'
|
||||
import type { BacktrackConditions, BacktrackVessel, ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||
|
||||
export const MOCK_CONDITIONS: BacktrackConditions = {
|
||||
estimatedSpillTime: '02-10 06:30',
|
||||
@ -1,6 +1,6 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
@ -7,7 +7,7 @@ export interface AuthUser {
|
||||
rank: string | null
|
||||
org: { sn: number; name: string; abbr: string } | null
|
||||
roles: string[]
|
||||
permissions: string[]
|
||||
permissions: Record<string, string[]>
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
@ -117,6 +117,7 @@ export interface RoleWithPermissions {
|
||||
permissions: Array<{
|
||||
sn: number
|
||||
resourceCode: string
|
||||
operationCode: string
|
||||
granted: boolean
|
||||
}>
|
||||
}
|
||||
@ -126,9 +127,26 @@ export async function fetchRoles(): Promise<RoleWithPermissions[]> {
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 권한 트리 구조 API
|
||||
export interface PermTreeNode {
|
||||
code: string
|
||||
parentCode: string | null
|
||||
name: string
|
||||
description: string | null
|
||||
icon: string | null
|
||||
level: number
|
||||
sortOrder: number
|
||||
children: PermTreeNode[]
|
||||
}
|
||||
|
||||
export async function fetchPermTree(): Promise<PermTreeNode[]> {
|
||||
const response = await api.get<PermTreeNode[]>('/roles/perm-tree')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function updatePermissionsApi(
|
||||
roleSn: number,
|
||||
permissions: Array<{ resourceCode: string; granted: boolean }>
|
||||
permissions: Array<{ resourceCode: string; operationCode: string; granted: boolean }>
|
||||
): Promise<void> {
|
||||
await api.put(`/roles/${roleSn}/permissions`, { permissions })
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
// 레이어 데이터베이스 - API에서 가져옴
|
||||
import { fetchAllLayers } from '../services/api'
|
||||
import { fetchAllLayers } from '@common/services/api'
|
||||
|
||||
export interface Layer {
|
||||
id: string
|
||||
@ -12,7 +12,7 @@ interface AuthState {
|
||||
googleLogin: (credential: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
checkSession: () => Promise<void>
|
||||
hasPermission: (resource: string) => boolean
|
||||
hasPermission: (resource: string, operation?: string) => boolean
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
@ -70,10 +70,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
hasPermission: (resource: string) => {
|
||||
hasPermission: (resource: string, operation?: string) => {
|
||||
const { user } = get()
|
||||
if (!user) return false
|
||||
return user.permissions.includes(resource)
|
||||
const ops = user.permissions[resource]
|
||||
if (!ops) return false
|
||||
return ops.includes(operation ?? 'READ')
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null, pendingMessage: null }),
|
||||
67
frontend/src/common/types/hns.ts
Normal file
67
frontend/src/common/types/hns.ts
Normal file
@ -0,0 +1,67 @@
|
||||
/* 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 }>
|
||||
}
|
||||
1
frontend/src/common/types/navigation.ts
Normal file
1
frontend/src/common/types/navigation.ts
Normal file
@ -0,0 +1 @@
|
||||
export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin';
|
||||
@ -1,247 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface BoardPost {
|
||||
id: number
|
||||
category: string
|
||||
title: string
|
||||
author: string
|
||||
date: string
|
||||
views: number
|
||||
isNotice?: boolean
|
||||
}
|
||||
|
||||
interface BoardListTableProps {
|
||||
onPostClick: (id: number) => void
|
||||
onWriteClick: () => void
|
||||
}
|
||||
|
||||
const mockPosts: BoardPost[] = [
|
||||
{
|
||||
id: 1,
|
||||
category: '공지사항',
|
||||
title: '시스템 업데이트 안내',
|
||||
author: '관리자',
|
||||
date: '2025-02-15',
|
||||
views: 245,
|
||||
isNotice: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category: '공지사항',
|
||||
title: '2025년 방제 교육 일정 안내',
|
||||
author: '관리자',
|
||||
date: '2025-02-14',
|
||||
views: 189,
|
||||
isNotice: true
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
category: '자료실',
|
||||
title: '방제 매뉴얼 업데이트 (2025년 개정판)',
|
||||
author: '김철수',
|
||||
date: '2025-02-10',
|
||||
views: 423
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
category: 'Q&A',
|
||||
title: 'HNS 대기확산 분석 결과 해석 문의',
|
||||
author: '이영희',
|
||||
date: '2025-02-08',
|
||||
views: 156
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
category: '자료실',
|
||||
title: '2024년 유류오염사고 통계 자료',
|
||||
author: '박민수',
|
||||
date: '2025-02-05',
|
||||
views: 312
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
category: 'Q&A',
|
||||
title: '유출유 확산 예측 알고리즘 선택 기준',
|
||||
author: '정수진',
|
||||
date: '2025-02-03',
|
||||
views: 267
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
category: '자료실',
|
||||
title: '해양오염 방제 장비 운용 가이드',
|
||||
author: '최동현',
|
||||
date: '2025-01-28',
|
||||
views: 534
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
category: 'Q&A',
|
||||
title: 'SCAT 조사 방법 관련 질문',
|
||||
author: '강지은',
|
||||
date: '2025-01-25',
|
||||
views: 198
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
category: '자료실',
|
||||
title: 'HNS 물질 안전보건자료 (MSDS) 모음',
|
||||
author: '윤성호',
|
||||
date: '2025-01-20',
|
||||
views: 645
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
category: 'Q&A',
|
||||
title: '항공촬영 드론 운용 시 주의사항',
|
||||
author: '송미래',
|
||||
date: '2025-01-15',
|
||||
views: 221
|
||||
}
|
||||
]
|
||||
|
||||
export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('전체')
|
||||
|
||||
const categories = ['전체', '공지사항', '자료실', 'Q&A']
|
||||
|
||||
const filteredPosts = mockPosts.filter((post) => {
|
||||
const matchesCategory = selectedCategory === '전체' || post.category === selectedCategory
|
||||
const matchesSearch =
|
||||
post.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
post.author.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
return matchesCategory && matchesSearch
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
{/* Header with Search and Write Button */}
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Category Filters */}
|
||||
<div className="flex gap-2">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`px-4 py-2 text-sm font-semibold rounded transition-all ${
|
||||
selectedCategory === category
|
||||
? 'bg-primary-cyan text-bg-0'
|
||||
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Search Input */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="제목, 작성자 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none w-64"
|
||||
/>
|
||||
|
||||
{/* Write Button */}
|
||||
<button
|
||||
onClick={onWriteClick}
|
||||
className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity flex items-center gap-2"
|
||||
>
|
||||
<span>✏️</span>
|
||||
<span>글쓰기</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Board List Table */}
|
||||
<div className="flex-1 overflow-auto px-8 py-6">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-border">
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-20">번호</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">분류</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2">제목</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">작성자</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">작성일</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-24">조회수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredPosts.map((post) => (
|
||||
<tr
|
||||
key={post.id}
|
||||
onClick={() => onPostClick(post.id)}
|
||||
className="border-b border-border hover:bg-bg-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-4 py-4 text-sm text-text-1">
|
||||
{post.isNotice ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400">
|
||||
공지
|
||||
</span>
|
||||
) : (
|
||||
post.id
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${
|
||||
post.category === '공지사항'
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: post.category === '자료실'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'bg-green-500/20 text-green-400'
|
||||
}`}
|
||||
>
|
||||
{post.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
post.isNotice ? 'font-semibold text-text-1' : 'text-text-1'
|
||||
} hover:text-primary-cyan transition-colors`}
|
||||
>
|
||||
{post.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-text-2">{post.author}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3">{post.date}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3">{post.views}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{filteredPosts.length === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-text-3 text-sm">검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-center gap-2 px-8 py-4 border-t border-border bg-bg-1">
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors">
|
||||
이전
|
||||
</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-primary-cyan text-bg-0 font-semibold">
|
||||
1
|
||||
</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors">
|
||||
2
|
||||
</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors">
|
||||
3
|
||||
</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors">
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
21
frontend/src/tabs/admin/components/AdminView.tsx
Executable file
21
frontend/src/tabs/admin/components/AdminView.tsx
Executable file
@ -0,0 +1,21 @@
|
||||
import { useSubMenu } from '@common/hooks/useSubMenu'
|
||||
import UsersPanel from './UsersPanel'
|
||||
import PermissionsPanel from './PermissionsPanel'
|
||||
import MenusPanel from './MenusPanel'
|
||||
import SettingsPanel from './SettingsPanel'
|
||||
|
||||
// ─── AdminView ────────────────────────────────────────────
|
||||
export function AdminView() {
|
||||
const { activeSubTab } = useSubMenu('admin')
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 overflow-hidden bg-bg-0">
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{activeSubTab === 'users' && <UsersPanel />}
|
||||
{activeSubTab === 'permissions' && <PermissionsPanel />}
|
||||
{activeSubTab === 'menus' && <MenusPanel />}
|
||||
{activeSubTab === 'settings' && <SettingsPanel />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
198
frontend/src/tabs/admin/components/MenusPanel.tsx
Normal file
198
frontend/src/tabs/admin/components/MenusPanel.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragOverlay,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import {
|
||||
fetchMenuConfig,
|
||||
updateMenuConfigApi,
|
||||
type MenuConfigItem,
|
||||
} from '@common/services/authApi'
|
||||
import { useMenuStore } from '@common/store/menuStore'
|
||||
import SortableMenuItem from './SortableMenuItem'
|
||||
|
||||
// ─── 메뉴 관리 패널 ─────────────────────────────────────────
|
||||
function MenusPanel() {
|
||||
const [menus, setMenus] = useState<MenuConfigItem[]>([])
|
||||
const [originalMenus, setOriginalMenus] = useState<MenuConfigItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [emojiPickerId, setEmojiPickerId] = useState<string | null>(null)
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
||||
const { setMenuConfig } = useMenuStore()
|
||||
|
||||
const hasChanges = JSON.stringify(menus) !== JSON.stringify(originalMenus)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
)
|
||||
|
||||
const loadMenus = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const config = await fetchMenuConfig()
|
||||
setMenus(config)
|
||||
setOriginalMenus(config)
|
||||
} catch (err) {
|
||||
console.error('메뉴 설정 조회 실패:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadMenus()
|
||||
}, [loadMenus])
|
||||
|
||||
useEffect(() => {
|
||||
if (!emojiPickerId) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target as Node)) {
|
||||
setEmojiPickerId(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [emojiPickerId])
|
||||
|
||||
const toggleMenu = (id: string) => {
|
||||
setMenus(prev => prev.map(m => m.id === id ? { ...m, enabled: !m.enabled } : m))
|
||||
}
|
||||
|
||||
const updateMenuField = (id: string, field: 'label' | 'icon', value: string) => {
|
||||
setMenus(prev => prev.map(m => m.id === id ? { ...m, [field]: value } : m))
|
||||
}
|
||||
|
||||
const handleEmojiSelect = (emoji: { native: string }) => {
|
||||
if (emojiPickerId) {
|
||||
updateMenuField(emojiPickerId, 'icon', emoji.native)
|
||||
setEmojiPickerId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const moveMenu = (idx: number, direction: -1 | 1) => {
|
||||
const targetIdx = idx + direction
|
||||
if (targetIdx < 0 || targetIdx >= menus.length) return
|
||||
setMenus(prev => {
|
||||
const arr = [...prev]
|
||||
;[arr[idx], arr[targetIdx]] = [arr[targetIdx], arr[idx]]
|
||||
return arr.map((m, i) => ({ ...m, order: i + 1 }))
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
setActiveId(null)
|
||||
if (!over || active.id === over.id) return
|
||||
setMenus(prev => {
|
||||
const oldIndex = prev.findIndex(m => m.id === active.id)
|
||||
const newIndex = prev.findIndex(m => m.id === over.id)
|
||||
const reordered = arrayMove(prev, oldIndex, newIndex)
|
||||
return reordered.map((m, i) => ({ ...m, order: i + 1 }))
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const updated = await updateMenuConfigApi(menus)
|
||||
setMenus(updated)
|
||||
setOriginalMenus(updated)
|
||||
setMenuConfig(updated)
|
||||
} catch (err) {
|
||||
console.error('메뉴 설정 저장 실패:', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-text-3 text-sm font-korean">메뉴 설정을 불러오는 중...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const activeMenu = activeId ? menus.find(m => m.id === activeId) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-text-1 font-korean">메뉴 관리</h1>
|
||||
<p className="text-xs text-text-3 mt-1 font-korean">메뉴 표시 여부, 순서, 라벨, 아이콘을 관리합니다</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean ${
|
||||
hasChanges && !saving
|
||||
? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||
: 'bg-bg-3 text-text-3 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{saving ? '저장 중...' : '변경사항 저장'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto px-6 py-4">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={(event) => setActiveId(event.active.id as string)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={menus.map(m => m.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col gap-2 max-w-[700px]">
|
||||
{menus.map((menu, idx) => (
|
||||
<SortableMenuItem
|
||||
key={menu.id}
|
||||
menu={menu}
|
||||
idx={idx}
|
||||
totalCount={menus.length}
|
||||
isEditing={editingId === menu.id}
|
||||
emojiPickerId={emojiPickerId}
|
||||
emojiPickerRef={emojiPickerRef}
|
||||
onToggle={toggleMenu}
|
||||
onMove={moveMenu}
|
||||
onEditStart={setEditingId}
|
||||
onEditEnd={() => { setEditingId(null); setEmojiPickerId(null) }}
|
||||
onEmojiPickerToggle={setEmojiPickerId}
|
||||
onLabelChange={(id, value) => updateMenuField(id, 'label', value)}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{activeMenu ? (
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-md border border-primary-cyan bg-bg-1 shadow-lg opacity-90 max-w-[700px]">
|
||||
<span className="text-text-3 text-xs">⠿</span>
|
||||
<span className="text-[16px]">{activeMenu.icon}</span>
|
||||
<span className="text-[13px] font-semibold text-text-1 font-korean">{activeMenu.label}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MenusPanel
|
||||
667
frontend/src/tabs/admin/components/PermissionsPanel.tsx
Normal file
667
frontend/src/tabs/admin/components/PermissionsPanel.tsx
Normal file
@ -0,0 +1,667 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
fetchRoles,
|
||||
fetchPermTree,
|
||||
updatePermissionsApi,
|
||||
createRoleApi,
|
||||
updateRoleApi,
|
||||
deleteRoleApi,
|
||||
updateRoleDefaultApi,
|
||||
type RoleWithPermissions,
|
||||
type PermTreeNode,
|
||||
} from '@common/services/authApi'
|
||||
import { getRoleColor } from './adminConstants'
|
||||
|
||||
// ─── 오퍼레이션 코드 ─────────────────────────────────
|
||||
const OPER_CODES = ['READ', 'CREATE', 'UPDATE', 'DELETE'] as const
|
||||
type OperCode = (typeof OPER_CODES)[number]
|
||||
const OPER_LABELS: Record<OperCode, string> = { READ: 'R', CREATE: 'C', UPDATE: 'U', DELETE: 'D' }
|
||||
const OPER_FULL_LABELS: Record<OperCode, string> = { READ: '조회', CREATE: '생성', UPDATE: '수정', DELETE: '삭제' }
|
||||
|
||||
// ─── 권한 상태 타입 ─────────────────────────────────────
|
||||
type PermState = 'explicit-granted' | 'inherited-granted' | 'explicit-denied' | 'forced-denied'
|
||||
|
||||
// ─── 키 유틸 ──────────────────────────────────────────
|
||||
function makeKey(rsrc: string, oper: string): string { return `${rsrc}::${oper}` }
|
||||
|
||||
// ─── 유틸: 플랫 노드 목록 추출 (트리 DFS) ─────────────
|
||||
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'
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ─── 체크박스 셀 컴포넌트 ────────────────────────────
|
||||
interface PermCellProps {
|
||||
state: PermState
|
||||
onToggle: () => void
|
||||
label?: string
|
||||
}
|
||||
|
||||
function PermCell({ state, onToggle, label }: PermCellProps) {
|
||||
const isDisabled = state === 'forced-denied'
|
||||
|
||||
const baseClasses = 'w-7 h-7 rounded border text-xs font-bold transition-all flex items-center justify-center'
|
||||
|
||||
let classes: string
|
||||
let icon: string
|
||||
|
||||
switch (state) {
|
||||
case 'explicit-granted':
|
||||
classes = `${baseClasses} bg-[rgba(6,182,212,0.2)] border-primary-cyan text-primary-cyan cursor-pointer hover:bg-[rgba(6,182,212,0.3)]`
|
||||
icon = '✓'
|
||||
break
|
||||
case 'inherited-granted':
|
||||
classes = `${baseClasses} bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] cursor-pointer hover:border-primary-cyan`
|
||||
icon = '✓'
|
||||
break
|
||||
case 'explicit-denied':
|
||||
classes = `${baseClasses} bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-red-400 cursor-pointer hover:border-red-400`
|
||||
icon = '—'
|
||||
break
|
||||
case 'forced-denied':
|
||||
classes = `${baseClasses} bg-bg-2 border-border text-text-3 opacity-40 cursor-not-allowed`
|
||||
icon = '—'
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={isDisabled ? undefined : onToggle}
|
||||
disabled={isDisabled}
|
||||
className={classes}
|
||||
title={
|
||||
state === 'explicit-granted' ? `${label ?? ''} 명시적 허용 (클릭: 거부로 전환)`
|
||||
: state === 'inherited-granted' ? `${label ?? ''} 부모 상속 허용 (클릭: 명시적 거부)`
|
||||
: state === 'explicit-denied' ? `${label ?? ''} 명시적 거부 (클릭: 허용으로 전환)`
|
||||
: `${label ?? ''} 부모 거부로 비활성`
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 트리 행 컴포넌트 ────────────────────────────────
|
||||
interface TreeRowProps {
|
||||
node: PermTreeNode
|
||||
stateMap: Map<string, PermState>
|
||||
expanded: Set<string>
|
||||
onToggleExpand: (code: string) => void
|
||||
onTogglePerm: (code: string, oper: OperCode, currentState: PermState) => void
|
||||
}
|
||||
|
||||
function TreeRow({ node, stateMap, expanded, onToggleExpand, onTogglePerm }: TreeRowProps) {
|
||||
const hasChildren = node.children.length > 0
|
||||
const isExpanded = expanded.has(node.code)
|
||||
const indent = node.level * 24
|
||||
|
||||
// 이 노드의 READ 상태 (CUD 비활성 판단용)
|
||||
const readState = stateMap.get(makeKey(node.code, 'READ')) ?? 'forced-denied'
|
||||
const readDenied = readState === 'explicit-denied' || readState === 'forced-denied'
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center" style={{ paddingLeft: indent }}>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={() => onToggleExpand(node.code)}
|
||||
className="w-5 h-5 flex items-center justify-center text-text-3 hover:text-text-1 transition-colors mr-1 flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
|
||||
className={`transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-5 mr-1 flex-shrink-0 text-center text-text-3 text-[10px]">
|
||||
{node.level > 0 ? '├' : ''}
|
||||
</span>
|
||||
)}
|
||||
{node.icon && <span className="mr-1.5 flex-shrink-0">{node.icon}</span>}
|
||||
<div className="min-w-0">
|
||||
<div className={`text-[12px] font-korean truncate ${node.level === 0 ? 'font-bold text-text-1' : 'font-medium text-text-2'}`}>
|
||||
{node.name}
|
||||
</div>
|
||||
{node.description && node.level === 0 && (
|
||||
<div className="text-[10px] text-text-3 font-korean truncate mt-0.5">{node.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{OPER_CODES.map(oper => {
|
||||
const key = makeKey(node.code, oper)
|
||||
const state = stateMap.get(key) ?? 'forced-denied'
|
||||
// READ 거부 시 CUD도 강제 거부
|
||||
const effectiveState = (oper !== 'READ' && readDenied) ? 'forced-denied' as PermState : state
|
||||
return (
|
||||
<td key={oper} className="px-2 py-2.5 text-center">
|
||||
<div className="flex justify-center">
|
||||
<PermCell
|
||||
state={effectiveState}
|
||||
label={OPER_FULL_LABELS[oper]}
|
||||
onToggle={() => onTogglePerm(node.code, oper, effectiveState)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
{hasChildren && isExpanded && node.children.map(child => (
|
||||
<TreeRow
|
||||
key={child.code}
|
||||
node={child}
|
||||
stateMap={stateMap}
|
||||
expanded={expanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onTogglePerm={onTogglePerm}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 메인 PermissionsPanel ──────────────────────────
|
||||
function PermissionsPanel() {
|
||||
const [roles, setRoles] = useState<RoleWithPermissions[]>([])
|
||||
const [permTree, setPermTree] = useState<PermTreeNode[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
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)
|
||||
|
||||
// 선택된 역할의 effective state 계산
|
||||
const currentStateMap = selectedRoleSn
|
||||
? buildEffectiveStates(flatNodes, rolePerms.get(selectedRoleSn) ?? new Map())
|
||||
: new Map<string, PermState>()
|
||||
|
||||
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)
|
||||
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)
|
||||
} 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-text-3 text-sm font-korean">불러오는 중...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-text-1 font-korean">사용자 권한 관리</h1>
|
||||
<p className="text-xs text-text-3 mt-1 font-korean">역할별 리소스 × CRUD 권한을 설정합니다</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { setShowCreateForm(true); setCreateError('') }}
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md border border-primary-cyan text-primary-cyan hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
|
||||
>
|
||||
+ 역할 추가
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean ${
|
||||
dirty ? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]' : 'bg-bg-3 text-text-3 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{saving ? '저장 중...' : '변경사항 저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 역할 탭 바 */}
|
||||
<div className="flex items-center gap-2 px-6 py-3 border-b border-border bg-bg-1 overflow-x-auto" style={{ flexShrink: 0 }}>
|
||||
{roles.map((role, idx) => {
|
||||
const color = getRoleColor(role.code, idx)
|
||||
const isSelected = selectedRoleSn === role.sn
|
||||
return (
|
||||
<div key={role.sn} className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setSelectedRoleSn(role.sn)}
|
||||
className={`px-3 py-1.5 text-xs font-semibold rounded-md transition-all font-korean ${
|
||||
isSelected
|
||||
? 'border-2 shadow-[0_0_8px_rgba(6,182,212,0.2)]'
|
||||
: 'border border-border text-text-3 hover:border-border'
|
||||
}`}
|
||||
style={isSelected ? { borderColor: color, color } : undefined}
|
||||
>
|
||||
{editingRoleSn === role.sn ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editRoleName}
|
||||
onChange={(e) => setEditRoleName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveRoleName(role.sn)
|
||||
if (e.key === 'Escape') setEditingRoleSn(null)
|
||||
}}
|
||||
onBlur={() => handleSaveRoleName(role.sn)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
className="w-20 px-1 py-0 text-[11px] font-semibold bg-bg-2 border border-primary-cyan rounded text-center text-text-1 focus:outline-none font-korean"
|
||||
/>
|
||||
) : (
|
||||
<span onDoubleClick={() => handleStartEditName(role)}>
|
||||
{role.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-1 text-[9px] font-mono opacity-50">{role.code}</span>
|
||||
{role.isDefault && <span className="ml-1 text-[9px] text-primary-cyan">기본</span>}
|
||||
</button>
|
||||
{isSelected && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => toggleDefault(role.sn)}
|
||||
className={`px-1.5 py-0.5 text-[9px] rounded transition-all font-korean ${
|
||||
role.isDefault
|
||||
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan'
|
||||
: 'text-text-3 hover:text-text-2'
|
||||
}`}
|
||||
title="신규 사용자 기본 역할 설정"
|
||||
>
|
||||
{role.isDefault ? '기본역할' : '기본설정'}
|
||||
</button>
|
||||
{role.code !== 'ADMIN' && (
|
||||
<button
|
||||
onClick={() => handleDeleteRole(role.sn, role.name)}
|
||||
className="w-5 h-5 flex items-center justify-center text-text-3 hover:text-red-400 transition-colors"
|
||||
title="역할 삭제"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="flex items-center gap-4 px-6 py-2 border-b border-border bg-bg-1 text-[10px] text-text-3 font-korean" style={{ flexShrink: 0 }}>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-4 h-4 rounded border bg-[rgba(6,182,212,0.2)] border-primary-cyan text-primary-cyan text-center text-[9px] leading-4">✓</span>
|
||||
명시적 허용
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-4 h-4 rounded border bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] text-center text-[9px] leading-4">✓</span>
|
||||
상속 허용
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-4 h-4 rounded border bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-red-400 text-center text-[9px] leading-4">—</span>
|
||||
명시적 거부
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-4 h-4 rounded border bg-bg-2 border-border text-text-3 opacity-40 text-center text-[9px] leading-4">—</span>
|
||||
강제 거부
|
||||
</span>
|
||||
<span className="ml-4 border-l border-border pl-4 text-text-3">
|
||||
R=조회 C=생성 U=수정 D=삭제
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* CRUD 매트릭스 테이블 */}
|
||||
{selectedRoleSn ? (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-1 sticky top-0 z-10">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean min-w-[240px]">기능</th>
|
||||
{OPER_CODES.map(oper => (
|
||||
<th key={oper} className="px-2 py-3 text-center w-16">
|
||||
<div className="text-[11px] font-semibold text-text-2">{OPER_LABELS[oper]}</div>
|
||||
<div className="text-[9px] text-text-3 font-korean">{OPER_FULL_LABELS[oper]}</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{permTree.map(rootNode => (
|
||||
<TreeRow
|
||||
key={rootNode.code}
|
||||
node={rootNode}
|
||||
stateMap={currentStateMap}
|
||||
expanded={expanded}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onTogglePerm={handleTogglePerm}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-text-3 text-sm font-korean">
|
||||
역할을 선택하세요
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 역할 생성 모달 */}
|
||||
{showCreateForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-[400px] bg-bg-1 rounded-lg border border-border shadow-2xl">
|
||||
<div className="px-5 py-4 border-b border-border">
|
||||
<h3 className="text-sm font-bold text-text-1 font-korean">새 역할 추가</h3>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
<div>
|
||||
<label className="text-[11px] text-text-3 font-korean block mb-1">역할 코드</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRoleCode}
|
||||
onChange={(e) => setNewRoleCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))}
|
||||
placeholder="CUSTOM_ROLE"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-text-3 mt-1 font-korean">영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-text-3 font-korean block mb-1">역할 이름</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRoleName}
|
||||
onChange={(e) => setNewRoleName(e.target.value)}
|
||||
placeholder="사용자 정의 역할"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-text-3 font-korean block mb-1">설명 (선택)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRoleDesc}
|
||||
onChange={(e) => setNewRoleDesc(e.target.value)}
|
||||
placeholder="역할에 대한 설명"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
/>
|
||||
</div>
|
||||
{createError && (
|
||||
<div className="px-3 py-2 text-[11px] text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)] rounded-md font-korean">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-3 border-t border-border flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
className="px-4 py-2 text-xs text-text-3 border border-border rounded-md hover:bg-bg-hover font-korean"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateRole}
|
||||
disabled={!newRoleCode || !newRoleName || creating}
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean disabled:opacity-50"
|
||||
>
|
||||
{creating ? '생성 중...' : '생성'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PermissionsPanel
|
||||
237
frontend/src/tabs/admin/components/SettingsPanel.tsx
Normal file
237
frontend/src/tabs/admin/components/SettingsPanel.tsx
Normal file
@ -0,0 +1,237 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
fetchRegistrationSettings,
|
||||
updateRegistrationSettingsApi,
|
||||
fetchOAuthSettings,
|
||||
updateOAuthSettingsApi,
|
||||
type RegistrationSettings,
|
||||
type OAuthSettings,
|
||||
} from '@common/services/authApi'
|
||||
|
||||
// ─── 시스템 설정 패널 ────────────────────────────────────────
|
||||
function SettingsPanel() {
|
||||
const [settings, setSettings] = useState<RegistrationSettings | null>(null)
|
||||
const [oauthSettings, setOauthSettings] = useState<OAuthSettings | null>(null)
|
||||
const [oauthDomainInput, setOauthDomainInput] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [savingOAuth, setSavingOAuth] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
}, [])
|
||||
|
||||
const loadSettings = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [regData, oauthData] = await Promise.all([
|
||||
fetchRegistrationSettings(),
|
||||
fetchOAuthSettings(),
|
||||
])
|
||||
setSettings(regData)
|
||||
setOauthSettings(oauthData)
|
||||
setOauthDomainInput(oauthData.autoApproveDomains)
|
||||
} catch (err) {
|
||||
console.error('설정 조회 실패:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (key: keyof RegistrationSettings) => {
|
||||
if (!settings) return
|
||||
const newValue = !settings[key]
|
||||
setSaving(true)
|
||||
try {
|
||||
const updated = await updateRegistrationSettingsApi({ [key]: newValue })
|
||||
setSettings(updated)
|
||||
} catch (err) {
|
||||
console.error('설정 변경 실패:', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">불러오는 중...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h1 className="text-lg font-bold text-text-1 font-korean">시스템 설정</h1>
|
||||
<p className="text-xs text-text-3 mt-1 font-korean">사용자 등록 및 권한 관련 시스템 설정을 관리합니다</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto px-6 py-6">
|
||||
<div className="max-w-[640px] flex flex-col gap-6">
|
||||
{/* 사용자 등록 설정 */}
|
||||
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-bold text-text-1 font-korean">사용자 등록 설정</h2>
|
||||
<p className="text-[11px] text-text-3 mt-0.5 font-korean">신규 사용자 등록 시 적용되는 정책을 설정합니다</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{/* 자동 승인 */}
|
||||
<div className="px-5 py-4 flex items-center justify-between">
|
||||
<div className="flex-1 mr-4">
|
||||
<div className="text-[13px] font-semibold text-text-1 font-korean">자동 승인</div>
|
||||
<p className="text-[11px] text-text-3 mt-1 font-korean leading-relaxed">
|
||||
활성화하면 신규 사용자가 등록 즉시 <span className="text-green-400 font-semibold">ACTIVE</span> 상태가 됩니다.
|
||||
비활성화하면 관리자 승인 전까지 <span className="text-yellow-400 font-semibold">PENDING</span> 상태로 대기합니다.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggle('autoApprove')}
|
||||
disabled={saving}
|
||||
className={`relative w-12 h-6 rounded-full transition-all flex-shrink-0 ${
|
||||
settings?.autoApprove ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
|
||||
} ${saving ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all ${
|
||||
settings?.autoApprove ? 'left-[26px]' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 기본 역할 자동 할당 */}
|
||||
<div className="px-5 py-4 flex items-center justify-between">
|
||||
<div className="flex-1 mr-4">
|
||||
<div className="text-[13px] font-semibold text-text-1 font-korean">기본 역할 자동 할당</div>
|
||||
<p className="text-[11px] text-text-3 mt-1 font-korean leading-relaxed">
|
||||
활성화하면 신규 사용자에게 <span className="text-primary-cyan font-semibold">기본 역할</span>이 자동으로 할당됩니다.
|
||||
기본 역할은 권한 관리 탭에서 설정할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggle('defaultRole')}
|
||||
disabled={saving}
|
||||
className={`relative w-12 h-6 rounded-full transition-all flex-shrink-0 ${
|
||||
settings?.defaultRole ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
|
||||
} ${saving ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all ${
|
||||
settings?.defaultRole ? 'left-[26px]' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OAuth 설정 */}
|
||||
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-bold text-text-1 font-korean">Google OAuth 설정</h2>
|
||||
<p className="text-[11px] text-text-3 mt-0.5 font-korean">Google 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다</p>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<div className="flex-1 mr-4 mb-3">
|
||||
<div className="text-[13px] font-semibold text-text-1 font-korean mb-1">자동 승인 도메인</div>
|
||||
<p className="text-[11px] text-text-3 font-korean leading-relaxed mb-3">
|
||||
지정된 도메인의 Google 계정은 가입 즉시 <span className="text-green-400 font-semibold">ACTIVE</span> 상태가 됩니다.
|
||||
미지정 도메인은 <span className="text-yellow-400 font-semibold">PENDING</span> 상태로 관리자 승인이 필요합니다.
|
||||
여러 도메인은 쉼표(,)로 구분합니다.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={oauthDomainInput}
|
||||
onChange={(e) => setOauthDomainInput(e.target.value)}
|
||||
placeholder="gcsc.co.kr, example.com"
|
||||
className="flex-1 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setSavingOAuth(true)
|
||||
try {
|
||||
const updated = await updateOAuthSettingsApi({ autoApproveDomains: oauthDomainInput.trim() })
|
||||
setOauthSettings(updated)
|
||||
setOauthDomainInput(updated.autoApproveDomains)
|
||||
} catch (err) {
|
||||
console.error('OAuth 설정 변경 실패:', err)
|
||||
} finally {
|
||||
setSavingOAuth(false)
|
||||
}
|
||||
}}
|
||||
disabled={savingOAuth || oauthDomainInput.trim() === (oauthSettings?.autoApproveDomains || '')}
|
||||
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean whitespace-nowrap ${
|
||||
oauthDomainInput.trim() !== (oauthSettings?.autoApproveDomains || '')
|
||||
? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||
: 'bg-bg-3 text-text-3 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{savingOAuth ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{oauthSettings?.autoApproveDomains && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
{oauthSettings.autoApproveDomains.split(',').map(d => d.trim()).filter(Boolean).map(domain => (
|
||||
<span
|
||||
key={domain}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-mono rounded-md"
|
||||
style={{ background: 'rgba(6,182,212,0.1)', color: 'var(--cyan)', border: '1px solid rgba(6,182,212,0.25)' }}
|
||||
>
|
||||
@{domain}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 현재 설정 상태 요약 */}
|
||||
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-bold text-text-1 font-korean">설정 상태 요약</h2>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<div className="flex flex-col gap-3 text-[12px] font-korean">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${settings?.autoApprove ? 'bg-green-400' : 'bg-yellow-400'}`} />
|
||||
<span className="text-text-2">
|
||||
신규 사용자 등록 시{' '}
|
||||
{settings?.autoApprove ? (
|
||||
<span className="text-green-400 font-semibold">즉시 활성화</span>
|
||||
) : (
|
||||
<span className="text-yellow-400 font-semibold">관리자 승인 필요</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${settings?.defaultRole ? 'bg-green-400' : 'bg-text-3'}`} />
|
||||
<span className="text-text-2">
|
||||
기본 역할 자동 할당{' '}
|
||||
{settings?.defaultRole ? (
|
||||
<span className="text-green-400 font-semibold">활성</span>
|
||||
) : (
|
||||
<span className="text-text-3 font-semibold">비활성</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${oauthSettings?.autoApproveDomains ? 'bg-blue-400' : 'bg-text-3'}`} />
|
||||
<span className="text-text-2">
|
||||
Google OAuth 자동 승인 도메인{' '}
|
||||
{oauthSettings?.autoApproveDomains ? (
|
||||
<span className="text-blue-400 font-semibold font-mono">{oauthSettings.autoApproveDomains}</span>
|
||||
) : (
|
||||
<span className="text-text-3 font-semibold">미설정</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPanel
|
||||
161
frontend/src/tabs/admin/components/SortableMenuItem.tsx
Normal file
161
frontend/src/tabs/admin/components/SortableMenuItem.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import data from '@emoji-mart/data'
|
||||
import EmojiPicker from '@emoji-mart/react'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { type MenuConfigItem } from '@common/services/authApi'
|
||||
|
||||
// ─── 메뉴 항목 (Sortable) ────────────────────────────────────
|
||||
export interface SortableMenuItemProps {
|
||||
menu: MenuConfigItem
|
||||
idx: number
|
||||
totalCount: number
|
||||
isEditing: boolean
|
||||
emojiPickerId: string | null
|
||||
emojiPickerRef: React.RefObject<HTMLDivElement | null>
|
||||
onToggle: (id: string) => void
|
||||
onMove: (idx: number, direction: -1 | 1) => void
|
||||
onEditStart: (id: string) => void
|
||||
onEditEnd: () => void
|
||||
onEmojiPickerToggle: (id: string | null) => void
|
||||
onLabelChange: (id: string, value: string) => void
|
||||
onEmojiSelect: (emoji: { native: string }) => void
|
||||
}
|
||||
|
||||
function SortableMenuItem({
|
||||
menu, idx, totalCount, isEditing, emojiPickerId, emojiPickerRef,
|
||||
onToggle, onMove, onEditStart, onEditEnd, onEmojiPickerToggle, onLabelChange, onEmojiSelect,
|
||||
}: SortableMenuItemProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: menu.id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
zIndex: isDragging ? 50 : undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-center justify-between px-4 py-3 rounded-md border transition-all ${
|
||||
menu.enabled
|
||||
? 'bg-bg-1 border-border'
|
||||
: 'bg-bg-0 border-border opacity-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing w-6 h-7 flex items-center justify-center text-text-3 hover:text-text-1 transition-all shrink-0"
|
||||
title="드래그하여 순서 변경"
|
||||
>
|
||||
<svg width="12" height="16" viewBox="0 0 12 16" fill="currentColor">
|
||||
<circle cx="3" cy="2" r="1.5" /><circle cx="9" cy="2" r="1.5" />
|
||||
<circle cx="3" cy="8" r="1.5" /><circle cx="9" cy="8" r="1.5" />
|
||||
<circle cx="3" cy="14" r="1.5" /><circle cx="9" cy="14" r="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-text-3 text-xs font-mono w-6 text-center shrink-0">{idx + 1}</span>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
onClick={() => onEmojiPickerToggle(emojiPickerId === menu.id ? null : menu.id)}
|
||||
className="w-10 h-10 text-[20px] bg-bg-2 border border-border rounded flex items-center justify-center hover:border-primary-cyan transition-all"
|
||||
title="아이콘 변경"
|
||||
>
|
||||
{menu.icon}
|
||||
</button>
|
||||
{emojiPickerId === menu.id && (
|
||||
<div ref={emojiPickerRef} className="absolute top-12 left-0 z-[300]">
|
||||
<EmojiPicker
|
||||
data={data}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
theme="dark"
|
||||
locale="kr"
|
||||
previewPosition="none"
|
||||
skinTonePosition="search"
|
||||
perLine={8}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={menu.label}
|
||||
onChange={(e) => onLabelChange(menu.id, e.target.value)}
|
||||
className="w-full h-8 text-[13px] font-semibold font-korean bg-bg-2 border border-border rounded px-2 text-text-1 focus:border-primary-cyan focus:outline-none"
|
||||
/>
|
||||
<div className="text-[10px] text-text-3 font-mono mt-0.5">{menu.id}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onEditEnd}
|
||||
className="shrink-0 px-2 py-1 text-[10px] font-semibold text-primary-cyan border border-primary-cyan rounded hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean"
|
||||
>
|
||||
완료
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[16px] shrink-0">{menu.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-[13px] font-semibold font-korean ${menu.enabled ? 'text-text-1' : 'text-text-3'}`}>
|
||||
{menu.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-text-3 font-mono">{menu.id}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onEditStart(menu.id)}
|
||||
className="shrink-0 w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-[11px] flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all"
|
||||
title="라벨/아이콘 편집"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 ml-3 shrink-0">
|
||||
<button
|
||||
onClick={() => onToggle(menu.id)}
|
||||
className={`relative w-10 h-5 rounded-full transition-all ${
|
||||
menu.enabled ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
|
||||
menu.enabled ? 'left-[22px]' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => onMove(idx, -1)}
|
||||
disabled={idx === 0}
|
||||
className="w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-xs flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onMove(idx, 1)}
|
||||
disabled={idx === totalCount - 1}
|
||||
className="w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-xs flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SortableMenuItem
|
||||
350
frontend/src/tabs/admin/components/UsersPanel.tsx
Normal file
350
frontend/src/tabs/admin/components/UsersPanel.tsx
Normal file
@ -0,0 +1,350 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
fetchUsers,
|
||||
fetchRoles,
|
||||
updateUserApi,
|
||||
approveUserApi,
|
||||
rejectUserApi,
|
||||
assignRolesApi,
|
||||
type UserListItem,
|
||||
type RoleWithPermissions,
|
||||
} from '@common/services/authApi'
|
||||
import { getRoleColor, statusLabels } from './adminConstants'
|
||||
|
||||
// ─── 사용자 관리 패널 ─────────────────────────────────────────
|
||||
function UsersPanel() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||
const [users, setUsers] = useState<UserListItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [allRoles, setAllRoles] = useState<RoleWithPermissions[]>([])
|
||||
const [roleEditUserId, setRoleEditUserId] = useState<string | null>(null)
|
||||
const [selectedRoleSns, setSelectedRoleSns] = useState<number[]>([])
|
||||
const roleDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchUsers(searchTerm || undefined, statusFilter || undefined)
|
||||
setUsers(data)
|
||||
} catch (err) {
|
||||
console.error('사용자 목록 조회 실패:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [searchTerm, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
}, [loadUsers])
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles().then(setAllRoles).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])
|
||||
|
||||
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 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',
|
||||
})
|
||||
}
|
||||
|
||||
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-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-text-1 font-korean">사용자 관리</h1>
|
||||
<p className="text-xs text-text-3 mt-1 font-korean">총 {users.length}명</p>
|
||||
</div>
|
||||
{pendingCount > 0 && (
|
||||
<span className="px-2.5 py-1 text-[10px] font-bold rounded-full bg-[rgba(250,204,21,0.15)] text-yellow-400 border border-[rgba(250,204,21,0.3)] animate-pulse font-korean">
|
||||
승인대기 {pendingCount}명
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan 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-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
/>
|
||||
<button className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan 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-text-3 text-sm font-korean">불러오는 중...</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-1">
|
||||
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">이름</th>
|
||||
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">계정</th>
|
||||
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">소속</th>
|
||||
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">역할</th>
|
||||
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">인증</th>
|
||||
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">상태</th>
|
||||
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">최근 로그인</th>
|
||||
<th className="px-6 py-3 text-right text-[11px] font-semibold text-text-3 font-korean">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => {
|
||||
const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE
|
||||
return (
|
||||
<tr key={user.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
||||
<td className="px-6 py-3 text-[12px] text-text-1 font-semibold font-korean">{user.name}</td>
|
||||
<td className="px-6 py-3 text-[12px] text-text-2 font-mono">{user.account}</td>
|
||||
<td className="px-6 py-3 text-[12px] text-text-2 font-korean">{user.orgAbbr || '-'}</td>
|
||||
<td className="px-6 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, idx) => {
|
||||
const color = getRoleColor(roleCode, idx)
|
||||
const roleName = allRoles.find(r => r.code === roleCode)?.name || roleCode
|
||||
return (
|
||||
<span
|
||||
key={roleCode}
|
||||
className="px-2 py-0.5 text-[10px] font-semibold rounded-md font-korean"
|
||||
style={{
|
||||
background: `${color}20`,
|
||||
color: color,
|
||||
border: `1px solid ${color}40`
|
||||
}}
|
||||
>
|
||||
{roleName}
|
||||
</span>
|
||||
)
|
||||
}) : (
|
||||
<span className="text-[10px] text-text-3 font-korean">역할 없음</span>
|
||||
)}
|
||||
<span className="text-[10px] text-text-3 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-1 border border-border rounded-lg shadow-lg min-w-[200px]"
|
||||
>
|
||||
<div className="text-[10px] text-text-3 font-korean font-semibold mb-1.5 px-1">역할 선택</div>
|
||||
{allRoles.map((role, idx) => {
|
||||
const color = getRoleColor(role.code, idx)
|
||||
return (
|
||||
<label key={role.sn} className="flex items-center gap-2 px-2 py-1.5 hover:bg-[rgba(255,255,255,0.04)] rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRoleSns.includes(role.sn)}
|
||||
onChange={() => toggleRoleSelection(role.sn)}
|
||||
style={{ accentColor: color }}
|
||||
/>
|
||||
<span className="text-xs font-korean" style={{ color }}>{role.name}</span>
|
||||
<span className="text-[10px] text-text-3 font-mono">{role.code}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
<div className="flex justify-end gap-2 mt-2 pt-2 border-t border-border">
|
||||
<button
|
||||
onClick={() => setRoleEditUserId(null)}
|
||||
className="px-3 py-1 text-[10px] text-text-3 border border-border rounded hover:bg-bg-hover font-korean"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSaveRoles(user.id)}
|
||||
disabled={selectedRoleSns.length === 0}
|
||||
className="px-3 py-1 text-[10px] font-semibold rounded bg-primary-cyan 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-6 py-3">
|
||||
{user.oauthProvider ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded-md font-mono"
|
||||
style={{ background: 'rgba(66,133,244,0.15)', color: '#4285F4', border: '1px solid rgba(66,133,244,0.3)' }}
|
||||
title={user.email || undefined}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 48 48"><path fill="#4285F4" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#34A853" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59A14.5 14.5 0 019.5 24c0-1.59.28-3.14.76-4.59l-7.98-6.19A23.99 23.99 0 000 24c0 3.77.9 7.35 2.56 10.54l7.97-5.95z"/><path fill="#EA4335" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 5.95C6.51 42.62 14.62 48 24 48z"/></svg>
|
||||
Google
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded-md font-korean"
|
||||
style={{ background: 'rgba(148,163,184,0.15)', color: 'var(--t3)', border: '1px solid rgba(148,163,184,0.2)' }}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
ID/PW
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<span className={`inline-flex items-center gap-1.5 text-[10px] 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-6 py-3 text-[11px] text-text-3 font-mono">{formatDate(user.lastLogin)}</td>
|
||||
<td className="px-6 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-[10px] font-semibold text-green-400 border border-green-400 rounded hover:bg-[rgba(74,222,128,0.1)] transition-all font-korean"
|
||||
>
|
||||
승인
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReject(user.id)}
|
||||
className="px-2 py-1 text-[10px] font-semibold text-red-400 border border-red-400 rounded hover:bg-[rgba(248,113,113,0.1)] transition-all font-korean"
|
||||
>
|
||||
거절
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{user.status === 'LOCKED' && (
|
||||
<button
|
||||
onClick={() => handleUnlock(user.id)}
|
||||
className="px-2 py-1 text-[10px] font-semibold text-yellow-400 border border-yellow-400 rounded hover:bg-[rgba(250,204,21,0.1)] transition-all font-korean"
|
||||
>
|
||||
잠금해제
|
||||
</button>
|
||||
)}
|
||||
{user.status === 'ACTIVE' && (
|
||||
<button
|
||||
onClick={() => handleDeactivate(user.id)}
|
||||
className="px-2 py-1 text-[10px] font-semibold text-text-3 border border-border rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
>
|
||||
비활성화
|
||||
</button>
|
||||
)}
|
||||
{(user.status === 'INACTIVE' || user.status === 'REJECTED') && (
|
||||
<button
|
||||
onClick={() => handleActivate(user.id)}
|
||||
className="px-2 py-1 text-[10px] font-semibold text-green-400 border border-green-400 rounded hover:bg-[rgba(74,222,128,0.1)] transition-all font-korean"
|
||||
>
|
||||
활성화
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsersPanel
|
||||
24
frontend/src/tabs/admin/components/adminConstants.ts
Normal file
24
frontend/src/tabs/admin/components/adminConstants.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export const DEFAULT_ROLE_COLORS: Record<string, string> = {
|
||||
ADMIN: 'var(--red)',
|
||||
MANAGER: 'var(--orange)',
|
||||
USER: 'var(--cyan)',
|
||||
VIEWER: 'var(--t3)',
|
||||
}
|
||||
|
||||
export const CUSTOM_ROLE_COLORS = [
|
||||
'#a78bfa', '#34d399', '#f472b6', '#fbbf24', '#60a5fa', '#2dd4bf',
|
||||
]
|
||||
|
||||
export function getRoleColor(code: string, index: number): string {
|
||||
return DEFAULT_ROLE_COLORS[code] || CUSTOM_ROLE_COLORS[index % CUSTOM_ROLE_COLORS.length]
|
||||
}
|
||||
|
||||
export const statusLabels: Record<string, { label: string; color: string; dot: string }> = {
|
||||
PENDING: { label: '승인대기', color: 'text-yellow-400', dot: 'bg-yellow-400' },
|
||||
ACTIVE: { label: '활성', color: 'text-green-400', dot: 'bg-green-400' },
|
||||
LOCKED: { label: '잠김', color: 'text-red-400', dot: 'bg-red-400' },
|
||||
INACTIVE: { label: '비활성', color: 'text-text-3', dot: 'bg-text-3' },
|
||||
REJECTED: { label: '거절됨', color: 'text-red-300', dot: 'bg-red-300' },
|
||||
}
|
||||
|
||||
// PERM_RESOURCES 제거됨 — GET /api/roles/perm-tree에서 동적 로드 (PermissionsPanel)
|
||||
1
frontend/src/tabs/admin/index.ts
Normal file
1
frontend/src/tabs/admin/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { AdminView } from './components/AdminView'
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { sanitizeHtml } from '../../utils/sanitize'
|
||||
import { sanitizeHtml } from '@common/utils/sanitize'
|
||||
|
||||
const panels = [
|
||||
{ id: 0, icon: '🌐', label: '개요' },
|
||||
54
frontend/src/tabs/aerial/components/AerialView.tsx
Executable file
54
frontend/src/tabs/aerial/components/AerialView.tsx
Executable file
@ -0,0 +1,54 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSubMenu } from '@common/hooks/useSubMenu'
|
||||
import { AerialTheoryView } from './AerialTheoryView'
|
||||
import { MediaManagement } from './MediaManagement'
|
||||
import { OilAreaAnalysis } from './OilAreaAnalysis'
|
||||
import { RealtimeDrone } from './RealtimeDrone'
|
||||
import { SensorAnalysis } from './SensorAnalysis'
|
||||
import { SatelliteRequest } from './SatelliteRequest'
|
||||
import { CctvView } from './CctvView'
|
||||
|
||||
type AerialTab = 'media' | 'analysis' | 'realtime' | 'sensor'
|
||||
|
||||
export function AerialView() {
|
||||
const { activeSubTab } = useSubMenu('aerial')
|
||||
const [activeTab, setActiveTab] = useState<AerialTab>('media')
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSubTab === 'media' || activeSubTab === 'analysis' || activeSubTab === 'realtime' || activeSubTab === 'sensor') {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setActiveTab(activeSubTab as AerialTab)
|
||||
}
|
||||
}, [activeSubTab])
|
||||
|
||||
if (activeSubTab === 'theory') {
|
||||
return <AerialTheoryView />
|
||||
}
|
||||
if (activeSubTab === 'satellite') {
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-bg-0">
|
||||
<div className="flex-1 overflow-auto"><SatelliteRequest /></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (activeSubTab === 'cctv') {
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-bg-0">
|
||||
<div className="flex-1 overflow-hidden px-6 py-5"><CctvView /></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-bg-0">
|
||||
<div className="flex-1 overflow-auto px-6 py-5">
|
||||
<div className="w-full h-full">
|
||||
{activeTab === 'media' && <MediaManagement />}
|
||||
{activeTab === 'analysis' && <OilAreaAnalysis />}
|
||||
{activeTab === 'realtime' && <RealtimeDrone />}
|
||||
{activeTab === 'sensor' && <SensorAnalysis />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
343
frontend/src/tabs/aerial/components/CctvView.tsx
Normal file
343
frontend/src/tabs/aerial/components/CctvView.tsx
Normal file
@ -0,0 +1,343 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface CctvCamera {
|
||||
id: number
|
||||
name: string
|
||||
region: '제주' | '남해' | '서해' | '동해'
|
||||
location: string
|
||||
coord: string
|
||||
status: 'live' | 'offline'
|
||||
ptz: boolean
|
||||
source: string
|
||||
}
|
||||
|
||||
const cctvCameras: CctvCamera[] = [
|
||||
{ id: 1, name: '서귀포항 동측', region: '제주', location: '제주 서귀포시 서귀동', coord: '33.24°N 126.57°E', status: 'live', ptz: true, source: 'TAGO' },
|
||||
{ id: 2, name: '제주항 입구', region: '제주', location: '제주 제주시 건입동', coord: '33.52°N 126.53°E', status: 'live', ptz: true, source: 'TAGO' },
|
||||
{ id: 3, name: '성산포항', region: '제주', location: '제주 서귀포시 성산읍', coord: '33.46°N 126.93°E', status: 'live', ptz: false, source: 'TAGO' },
|
||||
{ id: 4, name: '모슬포항', region: '제주', location: '제주 서귀포시 대정읍', coord: '33.21°N 126.25°E', status: 'live', ptz: false, source: 'KBS' },
|
||||
{ id: 5, name: '여수 신항', region: '남해', location: '전남 여수시 웅천동', coord: '34.73°N 127.68°E', status: 'live', ptz: true, source: 'TAGO' },
|
||||
{ id: 6, name: '통영항', region: '남해', location: '경남 통영시 항남동', coord: '34.84°N 128.43°E', status: 'live', ptz: true, source: 'TAGO' },
|
||||
{ id: 7, name: '부산 감천항', region: '남해', location: '부산 서구 암남동', coord: '35.08°N 129.01°E', status: 'live', ptz: false, source: 'KBS' },
|
||||
{ id: 8, name: '목포 내항', region: '서해', location: '전남 목포시 항동', coord: '34.79°N 126.38°E', status: 'live', ptz: true, source: 'TAGO' },
|
||||
{ id: 9, name: '군산 외항', region: '서해', location: '전북 군산시 소룡동', coord: '35.97°N 126.72°E', status: 'live', ptz: false, source: 'TAGO' },
|
||||
{ id: 10, name: '인천항 연안', region: '서해', location: '인천 중구 항동', coord: '37.45°N 126.60°E', status: 'offline', ptz: false, source: 'KBS' },
|
||||
{ id: 11, name: '동해항', region: '동해', location: '강원 동해시 송정동', coord: '37.52°N 129.12°E', status: 'live', ptz: true, source: 'TAGO' },
|
||||
{ id: 12, name: '포항 영일만', region: '동해', location: '경북 포항시 남구', coord: '36.02°N 129.38°E', status: 'live', ptz: false, source: 'TAGO' },
|
||||
]
|
||||
|
||||
const cctvFavorites = [
|
||||
{ name: '서귀포항 동측', reason: '유출 사고 인접' },
|
||||
{ name: '여수 신항', reason: '주요 방제 거점' },
|
||||
{ name: '목포 내항', reason: '서해 모니터링' },
|
||||
]
|
||||
|
||||
export function CctvView() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [regionFilter, setRegionFilter] = useState('전체')
|
||||
const [selectedCamera, setSelectedCamera] = useState<CctvCamera | null>(null)
|
||||
const [gridMode, setGridMode] = useState(1)
|
||||
const [activeCells, setActiveCells] = useState<CctvCamera[]>([])
|
||||
|
||||
const regions = ['전체', '제주', '남해', '서해', '동해']
|
||||
const regionIcons: Record<string, string> = { '전체': '', '제주': '🌊', '남해': '⚓', '서해': '🐟', '동해': '🌅' }
|
||||
|
||||
const filtered = cctvCameras.filter(c => {
|
||||
if (regionFilter !== '전체' && c.region !== regionFilter) return false
|
||||
if (searchTerm && !c.name.includes(searchTerm) && !c.location.includes(searchTerm)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const handleSelectCamera = (cam: CctvCamera) => {
|
||||
setSelectedCamera(cam)
|
||||
if (gridMode === 1) {
|
||||
setActiveCells([cam])
|
||||
} else {
|
||||
setActiveCells(prev => {
|
||||
if (prev.length < gridMode && !prev.find(c => c.id === cam.id)) return [...prev, cam]
|
||||
return prev
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const gridCols = gridMode === 1 ? 1 : gridMode === 4 ? 2 : 3
|
||||
const totalCells = gridMode
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
|
||||
{/* 왼쪽: 목록 패널 */}
|
||||
<div className="flex flex-col overflow-hidden bg-bg-1 border-r border-border" style={{ width: 290, minWidth: 290 }}>
|
||||
{/* 헤더 */}
|
||||
<div className="p-3 pb-2.5 border-b border-border shrink-0 bg-bg-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-xs font-bold text-text-1 font-korean flex items-center gap-1.5">
|
||||
<span className="w-[7px] h-[7px] rounded-full inline-block animate-pulse" style={{ background: 'var(--red)' }} />
|
||||
실시간 해안 CCTV
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[9px] text-text-3 font-korean">API 상태</span>
|
||||
<span className="w-[7px] h-[7px] rounded-full inline-block" style={{ background: 'var(--green)' }} />
|
||||
</div>
|
||||
</div>
|
||||
{/* 검색 */}
|
||||
<div className="flex items-center gap-2 bg-bg-0 border border-border rounded-md px-2.5 py-1.5 mb-2 focus-within:border-primary-cyan/50 transition-colors">
|
||||
<span className="text-text-3 text-[11px]">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="지점명 또는 지역 검색..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="flex-1 bg-transparent border-none text-text-1 text-[11px] font-korean outline-none"
|
||||
/>
|
||||
</div>
|
||||
{/* 지역 필터 */}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{regions.map(r => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRegionFilter(r)}
|
||||
className="px-2 py-0.5 rounded text-[9px] font-semibold cursor-pointer font-korean border transition-colors"
|
||||
style={regionFilter === r
|
||||
? { background: 'rgba(6,182,212,.15)', color: 'var(--cyan)', borderColor: 'rgba(6,182,212,.3)' }
|
||||
: { background: 'var(--bg3)', color: 'var(--t2)', borderColor: 'var(--bd)' }
|
||||
}
|
||||
>{regionIcons[r] ? `${regionIcons[r]} ` : ''}{r}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 바 */}
|
||||
<div className="flex items-center justify-between px-3.5 py-1 border-b border-border shrink-0 bg-bg-1">
|
||||
<div className="text-[9px] text-text-3 font-korean">출처: 국립해양조사원 · KBS 재난안전포털</div>
|
||||
<div className="text-[10px] text-text-2 font-korean"><b className="text-text-1">{filtered.length}</b>개</div>
|
||||
</div>
|
||||
|
||||
{/* 카메라 목록 */}
|
||||
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||
{filtered.map(cam => (
|
||||
<div
|
||||
key={cam.id}
|
||||
onClick={() => handleSelectCamera(cam)}
|
||||
className="flex items-center gap-2.5 px-3.5 py-2.5 border-b cursor-pointer transition-colors"
|
||||
style={{
|
||||
borderColor: 'rgba(255,255,255,.04)',
|
||||
background: selectedCamera?.id === cam.id ? 'rgba(6,182,212,.08)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<div className="w-8 h-8 rounded-md bg-bg-3 flex items-center justify-center text-sm">📹</div>
|
||||
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full border border-bg-1" style={{ background: cam.status === 'live' ? 'var(--green)' : 'var(--t3)' }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[11px] font-semibold text-text-1 font-korean truncate">{cam.name}</div>
|
||||
<div className="text-[9px] text-text-3 font-korean truncate">{cam.location}</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-0.5 shrink-0">
|
||||
{cam.status === 'live' ? (
|
||||
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: 'rgba(34,197,94,.12)', color: 'var(--green)' }}>LIVE</span>
|
||||
) : (
|
||||
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: 'rgba(255,255,255,.06)', color: 'var(--t3)' }}>OFF</span>
|
||||
)}
|
||||
{cam.ptz && <span className="text-[8px] text-text-3 font-mono">PTZ</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 가운데: 영상 뷰어 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0" style={{ background: '#04070f' }}>
|
||||
{/* 뷰어 툴바 */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-bg-2 shrink-0 gap-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="text-xs font-bold text-text-1 font-korean whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{selectedCamera ? `📹 ${selectedCamera.name}` : '📹 카메라를 선택하세요'}
|
||||
</div>
|
||||
{selectedCamera?.status === 'live' && (
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(239,68,68,.14)', border: '1px solid rgba(239,68,68,.35)', color: 'var(--red)' }}>
|
||||
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--red)' }} />LIVE
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{/* PTZ 컨트롤 */}
|
||||
{selectedCamera?.ptz && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-bg-3 border border-border rounded-[5px]">
|
||||
<span className="text-[9px] text-text-3 font-korean mr-1">PTZ</span>
|
||||
{['◀', '▲', '▼', '▶'].map((d, i) => (
|
||||
<button key={i} className="w-5 h-5 flex items-center justify-center bg-bg-0 border border-border rounded text-[9px] text-text-2 cursor-pointer hover:bg-bg-hover transition-colors">{d}</button>
|
||||
))}
|
||||
<div className="w-px h-4 bg-border mx-0.5" />
|
||||
{['+', '−'].map((z, i) => (
|
||||
<button key={i} className="w-5 h-5 flex items-center justify-center bg-bg-0 border border-border rounded text-[9px] text-text-2 cursor-pointer hover:bg-bg-hover transition-colors">{z}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 분할 모드 */}
|
||||
<div className="flex border border-border rounded-[5px] overflow-hidden">
|
||||
{[
|
||||
{ mode: 1, icon: '▣', label: '1화면' },
|
||||
{ mode: 4, icon: '⊞', label: '4분할' },
|
||||
{ mode: 9, icon: '⊟', label: '9분할' },
|
||||
].map(g => (
|
||||
<button
|
||||
key={g.mode}
|
||||
onClick={() => { setGridMode(g.mode); setActiveCells(prev => prev.slice(0, g.mode)) }}
|
||||
title={g.label}
|
||||
className="px-2 py-1 text-[11px] cursor-pointer border-none transition-colors"
|
||||
style={gridMode === g.mode
|
||||
? { background: 'rgba(6,182,212,.15)', color: 'var(--cyan)' }
|
||||
: { background: 'var(--bg3)', color: 'var(--t2)' }
|
||||
}
|
||||
>{g.icon}</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="px-2.5 py-1 bg-bg-3 border border-border rounded-[5px] text-text-2 text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-hover transition-colors">📷 캡처</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 영상 그리드 */}
|
||||
<div className="flex-1 gap-0.5 p-0.5 overflow-hidden relative" style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${gridCols}, 1fr)`,
|
||||
background: '#000',
|
||||
}}>
|
||||
{Array.from({ length: totalCells }).map((_, i) => {
|
||||
const cam = activeCells[i]
|
||||
return (
|
||||
<div key={i} className="relative flex items-center justify-center overflow-hidden" style={{ background: '#0a0e18', border: '1px solid rgba(255,255,255,.06)' }}>
|
||||
{cam ? (
|
||||
<>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-4xl opacity-20">📹</div>
|
||||
</div>
|
||||
<div className="absolute top-2 left-2 flex items-center gap-1.5">
|
||||
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(0,0,0,.7)', color: 'var(--t1)' }}>{cam.name}</span>
|
||||
<span className="text-[8px] font-bold px-1 py-0.5 rounded" style={{ background: 'rgba(239,68,68,.3)', color: '#f87171' }}>● REC</span>
|
||||
</div>
|
||||
<div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded" style={{ background: 'rgba(0,0,0,.7)', color: 'var(--t3)' }}>
|
||||
{cam.coord} · {cam.source}
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[11px] font-korean text-text-3 opacity-60">
|
||||
CCTV 스트리밍 영역
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-[10px] text-text-3 font-korean opacity-40">카메라를 선택하세요</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 하단 정보 바 */}
|
||||
<div className="flex items-center gap-3.5 px-4 py-1.5 border-t border-border bg-bg-2 shrink-0">
|
||||
<div className="text-[10px] text-text-3 font-korean">선택: <b className="text-text-1">{selectedCamera?.name ?? '–'}</b></div>
|
||||
<div className="text-[10px] text-text-3 font-korean">위치: <span className="text-text-2">{selectedCamera?.location ?? '–'}</span></div>
|
||||
<div className="text-[10px] text-text-3 font-korean">좌표: <span className="text-primary-cyan font-mono text-[9px]">{selectedCamera?.coord ?? '–'}</span></div>
|
||||
<div className="ml-auto text-[9px] text-text-3 font-korean">API: 국립해양조사원 TAGO 해양 CCTV</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 미니맵 + 정보 */}
|
||||
<div className="flex flex-col overflow-hidden bg-bg-1 border-l border-border" style={{ width: 232, minWidth: 232 }}>
|
||||
{/* 지도 헤더 */}
|
||||
<div className="px-3 py-2 border-b border-border text-[11px] font-bold text-text-1 font-korean bg-bg-2 shrink-0 flex items-center justify-between">
|
||||
<span>🗺 위치 지도</span>
|
||||
<span className="text-[9px] text-text-3 font-normal">클릭하여 선택</span>
|
||||
</div>
|
||||
{/* 미니맵 (placeholder) */}
|
||||
<div className="w-full bg-bg-3 flex items-center justify-center shrink-0 relative" style={{ height: 210 }}>
|
||||
<div className="text-[10px] text-text-3 font-korean opacity-50">지도 영역</div>
|
||||
{/* 간략 지도 표현 */}
|
||||
<div className="absolute inset-2 rounded-md border border-border/30 overflow-hidden" style={{ background: 'linear-gradient(180deg, rgba(6,182,212,.03), rgba(59,130,246,.05))' }}>
|
||||
{cctvCameras.filter(c => c.status === 'live').slice(0, 6).map((c, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-2 h-2 rounded-full cursor-pointer"
|
||||
style={{
|
||||
background: selectedCamera?.id === c.id ? 'var(--cyan)' : 'var(--green)',
|
||||
boxShadow: selectedCamera?.id === c.id ? '0 0 6px var(--cyan)' : 'none',
|
||||
top: `${20 + (i * 25) % 70}%`,
|
||||
left: `${15 + (i * 30) % 70}%`,
|
||||
}}
|
||||
title={c.name}
|
||||
onClick={() => handleSelectCamera(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카메라 정보 */}
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2.5 border-t border-border" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">📋 카메라 정보</div>
|
||||
{selectedCamera ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{[
|
||||
['카메라명', selectedCamera.name],
|
||||
['지역', selectedCamera.region],
|
||||
['위치', selectedCamera.location],
|
||||
['좌표', selectedCamera.coord],
|
||||
['상태', selectedCamera.status === 'live' ? '● 송출중' : '● 오프라인'],
|
||||
['PTZ', selectedCamera.ptz ? '지원' : '미지원'],
|
||||
['출처', selectedCamera.source],
|
||||
].map(([k, v], i) => (
|
||||
<div key={i} className="flex justify-between px-2 py-1 bg-bg-0 rounded text-[9px]">
|
||||
<span className="text-text-3 font-korean">{k}</span>
|
||||
<span className="font-mono text-text-1">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-text-3 font-korean">카메라를 선택하세요</div>
|
||||
)}
|
||||
|
||||
{/* 방제 즐겨찾기 */}
|
||||
<div className="mt-3 pt-2.5 border-t border-border">
|
||||
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">⭐ 방제 핵심 지점</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{cctvFavorites.map((fav, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-2 py-1.5 bg-bg-3 rounded-[5px] cursor-pointer hover:bg-bg-hover transition-colors"
|
||||
onClick={() => {
|
||||
const found = cctvCameras.find(c => c.name === fav.name)
|
||||
if (found) handleSelectCamera(found)
|
||||
}}
|
||||
>
|
||||
<span className="text-[9px]">⭐</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[9px] font-semibold text-text-1 font-korean truncate">{fav.name}</div>
|
||||
<div className="text-[8px] text-text-3 font-korean">{fav.reason}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API 연동 현황 */}
|
||||
<div className="mt-3 pt-2.5 border-t border-border">
|
||||
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">🔌 API 연동 현황</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{[
|
||||
{ name: '해양조사원 TAGO', status: '● 연결', color: 'var(--green)' },
|
||||
{ name: 'KBS 재난안전포털', status: '● 연결', color: 'var(--green)' },
|
||||
].map((api, i) => (
|
||||
<div key={i} className="flex items-center justify-between px-2 py-1 bg-bg-3 rounded-[5px]" style={{ border: '1px solid rgba(34,197,94,.2)' }}>
|
||||
<span className="text-[9px] text-text-2 font-korean">{api.name}</span>
|
||||
<span className="text-[9px] font-bold" style={{ color: api.color }}>{api.status}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-between px-2 py-1 bg-bg-3 rounded-[5px]" style={{ border: '1px solid rgba(59,130,246,.2)' }}>
|
||||
<span className="text-[9px] text-text-2 font-korean">갱신 주기</span>
|
||||
<span className="text-[9px] font-bold font-mono" style={{ color: 'var(--blue)' }}>1 fps</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-text-3 font-mono text-right mt-0.5">최종갱신: {new Date().toLocaleTimeString('ko-KR')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
335
frontend/src/tabs/aerial/components/MediaManagement.tsx
Normal file
335
frontend/src/tabs/aerial/components/MediaManagement.tsx
Normal file
@ -0,0 +1,335 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
|
||||
// ── Types & Mock Data ──
|
||||
|
||||
interface MediaFile {
|
||||
id: number
|
||||
incident: string
|
||||
location: string
|
||||
filename: string
|
||||
equipment: string
|
||||
equipType: 'drone' | 'plane' | 'satellite'
|
||||
mediaType: '사진' | '영상' | '적외선' | 'SAR' | '가시광' | '광학'
|
||||
datetime: string
|
||||
size: string
|
||||
resolution: string
|
||||
}
|
||||
|
||||
const mediaFiles: MediaFile[] = [
|
||||
{ id: 1, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_001.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:20', size: '12.4 MB', resolution: '5472×3648' },
|
||||
{ id: 2, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_002.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:21', size: '11.8 MB', resolution: '5472×3648' },
|
||||
{ id: 3, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_003.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:22', size: '13.1 MB', resolution: '5472×3648' },
|
||||
{ id: 4, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_004.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:23', size: '12.9 MB', resolution: '5472×3648' },
|
||||
{ id: 5, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_005.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:24', size: '11.5 MB', resolution: '5472×3648' },
|
||||
{ id: 6, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_006.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:25', size: '13.3 MB', resolution: '5472×3648' },
|
||||
{ id: 7, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론영상_01.mp4', equipment: 'DJI M300', equipType: 'drone', mediaType: '영상', datetime: '2026-01-18 15:30', size: '842 MB', resolution: '4K 30fps' },
|
||||
{ id: 8, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론영상_02.mp4', equipment: 'Mavic3', equipType: 'drone', mediaType: '영상', datetime: '2026-01-18 16:00', size: '624 MB', resolution: '4K 30fps' },
|
||||
{ id: 9, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공_광역_01.tif', equipment: 'CN-235', equipType: 'plane', mediaType: '적외선', datetime: '2026-01-18 14:00', size: '156 MB', resolution: '8192×6144' },
|
||||
{ id: 10, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공_광역_02.tif', equipment: 'CN-235', equipType: 'plane', mediaType: '가시광', datetime: '2026-01-18 14:10', size: '148 MB', resolution: '8192×6144' },
|
||||
{ id: 11, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공영상_01.mp4', equipment: 'B-512', equipType: 'plane', mediaType: '영상', datetime: '2026-01-18 14:30', size: '1.2 GB', resolution: 'FHD 60fps' },
|
||||
{ id: 12, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: 'Sentinel1_SAR_20260118.tif', equipment: 'Sentinel-1', equipType: 'satellite', mediaType: 'SAR', datetime: '2026-01-18 10:00', size: '420 MB', resolution: '10m/px' },
|
||||
{ id: 13, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: 'KompSat5_여수_20260118.tif', equipment: '다목적5호', equipType: 'satellite', mediaType: 'SAR', datetime: '2026-01-18 11:00', size: '380 MB', resolution: '1m/px' },
|
||||
{ id: 14, incident: '통영 해역 기름오염', location: '34.85°N, 128.43°E', filename: '통영_드론_001.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 09:30', size: '10.2 MB', resolution: '5472×3648' },
|
||||
{ id: 15, incident: '군산항 인근 오염', location: '35.97°N, 126.72°E', filename: '군산_항공촬영_01.tif', equipment: 'B-512', equipType: 'plane', mediaType: '가시광', datetime: '2026-01-18 13:00', size: '132 MB', resolution: '8192×6144' },
|
||||
]
|
||||
|
||||
const equipIcon = (t: string) => t === 'drone' ? '🛸' : t === 'plane' ? '✈' : '🛰'
|
||||
|
||||
const equipTagCls = (t: string) =>
|
||||
t === 'drone'
|
||||
? 'bg-[rgba(59,130,246,0.12)] text-primary-blue'
|
||||
: t === 'plane'
|
||||
? 'bg-[rgba(34,197,94,0.12)] text-status-green'
|
||||
: 'bg-[rgba(168,85,247,0.12)] text-primary-purple'
|
||||
|
||||
const mediaTagCls = (t: string) =>
|
||||
t === '영상'
|
||||
? 'bg-[rgba(239,68,68,0.12)] text-status-red'
|
||||
: 'bg-[rgba(234,179,8,0.12)] text-status-yellow'
|
||||
|
||||
const FilterBtn = ({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-2.5 py-1 text-[10px] font-semibold rounded font-korean transition-colors ${
|
||||
active
|
||||
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30'
|
||||
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function MediaManagement() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [equipFilter, setEquipFilter] = useState<string>('all')
|
||||
const [typeFilter, setTypeFilter] = useState<Set<string>>(new Set())
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [sortBy, setSortBy] = useState('latest')
|
||||
const [showUpload, setShowUpload] = useState(false)
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
setShowUpload(false)
|
||||
}
|
||||
}
|
||||
if (showUpload) document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [showUpload])
|
||||
|
||||
const filtered = mediaFiles.filter(f => {
|
||||
if (equipFilter !== 'all' && f.equipType !== equipFilter) return false
|
||||
if (typeFilter.size > 0) {
|
||||
const isPhoto = !['영상'].includes(f.mediaType)
|
||||
const isVideo = f.mediaType === '영상'
|
||||
if (typeFilter.has('photo') && !isPhoto) return false
|
||||
if (typeFilter.has('video') && !isVideo) return false
|
||||
}
|
||||
if (searchTerm && !f.filename.toLowerCase().includes(searchTerm.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
if (sortBy === 'name') return a.filename.localeCompare(b.filename)
|
||||
if (sortBy === 'size') return parseFloat(b.size) - parseFloat(a.size)
|
||||
return b.datetime.localeCompare(a.datetime)
|
||||
})
|
||||
|
||||
const toggleId = (id: number) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) { next.delete(id) } else { next.add(id) }
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selectedIds.size === sorted.length) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(sorted.map(f => f.id)))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleTypeFilter = (t: string) => {
|
||||
setTypeFilter(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(t)) { next.delete(t) } else { next.add(t) }
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const droneCount = mediaFiles.filter(f => f.equipType === 'drone').length
|
||||
const planeCount = mediaFiles.filter(f => f.equipType === 'plane').length
|
||||
const satCount = mediaFiles.filter(f => f.equipType === 'satellite').length
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Filters */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<span className="text-[11px] text-text-3 font-korean">촬영 장비:</span>
|
||||
<FilterBtn label="전체" active={equipFilter === 'all'} onClick={() => setEquipFilter('all')} />
|
||||
<FilterBtn label="🛸 드론" active={equipFilter === 'drone'} onClick={() => setEquipFilter('drone')} />
|
||||
<FilterBtn label="✈ 유인항공기" active={equipFilter === 'plane'} onClick={() => setEquipFilter('plane')} />
|
||||
<FilterBtn label="🛰 위성" active={equipFilter === 'satellite'} onClick={() => setEquipFilter('satellite')} />
|
||||
<span className="w-px h-4 bg-border mx-1" />
|
||||
<span className="text-[11px] text-text-3 font-korean">유형:</span>
|
||||
<FilterBtn label="📷 사진" active={typeFilter.has('photo')} onClick={() => toggleTypeFilter('photo')} />
|
||||
<FilterBtn label="🎬 영상" active={typeFilter.has('video')} onClick={() => toggleTypeFilter('video')} />
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="파일명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="px-3 py-1.5 bg-bg-0 border border-border rounded-sm text-text-1 font-korean text-[11px] outline-none w-40 focus:border-primary-cyan"
|
||||
/>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={e => setSortBy(e.target.value)}
|
||||
className="prd-i py-1.5 w-auto"
|
||||
>
|
||||
<option value="latest">최신순</option>
|
||||
<option value="name">이름순</option>
|
||||
<option value="size">크기순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="flex gap-2.5 mb-4">
|
||||
{[
|
||||
{ icon: '📸', value: String(mediaFiles.length), label: '총 파일', color: 'text-primary-cyan' },
|
||||
{ icon: '🛸', value: String(droneCount), label: '드론', color: 'text-text-1' },
|
||||
{ icon: '✈', value: String(planeCount), label: '유인항공기', color: 'text-text-1' },
|
||||
{ icon: '🛰', value: String(satCount), label: '위성', color: 'text-text-1' },
|
||||
{ icon: '💾', value: '3.8 GB', label: '총 용량', color: 'text-text-1' },
|
||||
].map((s, i) => (
|
||||
<div key={i} className="flex-1 flex items-center gap-2.5 px-4 py-3 bg-bg-3 border border-border rounded-sm">
|
||||
<span className="text-xl">{s.icon}</span>
|
||||
<div>
|
||||
<div className={`text-base font-bold font-mono ${s.color}`}>{s.value}</div>
|
||||
<div className="text-[10px] text-text-3 font-korean">{s.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* File Table */}
|
||||
<div className="flex-1 bg-bg-3 border border-border rounded-md overflow-hidden flex flex-col">
|
||||
<div className="overflow-auto flex-1">
|
||||
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: 36 }} />
|
||||
<col style={{ width: 36 }} />
|
||||
<col style={{ width: 120 }} />
|
||||
<col style={{ width: 130 }} />
|
||||
<col />
|
||||
<col style={{ width: 95 }} />
|
||||
<col style={{ width: 85 }} />
|
||||
<col style={{ width: 145 }} />
|
||||
<col style={{ width: 85 }} />
|
||||
<col style={{ width: 95 }} />
|
||||
<col style={{ width: 50 }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-2">
|
||||
<th className="px-2 py-2.5 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.size === sorted.length && sorted.length > 0}
|
||||
onChange={toggleAll}
|
||||
className="accent-primary-blue"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-1 py-2.5" />
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">사고명</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">위치</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean">파일명</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean">장비</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean">유형</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">촬영일시</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">용량</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">해상도</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 text-center">📥</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map(f => (
|
||||
<tr
|
||||
key={f.id}
|
||||
onClick={() => toggleId(f.id)}
|
||||
className={`border-b border-border/50 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${
|
||||
selectedIds.has(f.id) ? 'bg-[rgba(6,182,212,0.06)]' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-2 py-2 text-center" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(f.id)}
|
||||
onChange={() => toggleId(f.id)}
|
||||
className="accent-primary-blue"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-2 text-base">{equipIcon(f.equipType)}</td>
|
||||
<td className="px-2 py-2 text-[10px] font-semibold text-text-1 font-korean truncate">{f.incident}</td>
|
||||
<td className="px-2 py-2 text-[10px] text-primary-cyan font-mono truncate">{f.location}</td>
|
||||
<td className="px-2 py-2 text-[11px] font-semibold text-text-1 font-korean truncate">{f.filename}</td>
|
||||
<td className="px-2 py-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${equipTagCls(f.equipType)}`}>
|
||||
{f.equipment}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${mediaTagCls(f.mediaType)}`}>
|
||||
{f.mediaType === '영상' ? '🎬' : '📷'} {f.mediaType}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-[11px] font-mono">{f.datetime}</td>
|
||||
<td className="px-2 py-2 text-[11px] font-mono">{f.size}</td>
|
||||
<td className="px-2 py-2 text-[11px] font-mono">{f.resolution}</td>
|
||||
<td className="px-2 py-2 text-center" onClick={e => e.stopPropagation()}>
|
||||
<button className="px-2 py-1 text-[10px] rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/20 hover:bg-[rgba(6,182,212,0.2)] transition-colors">
|
||||
📥
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="flex justify-between items-center mt-4 pt-3.5 border-t border-border">
|
||||
<div className="text-[11px] text-text-3 font-korean">
|
||||
선택된 파일: <span className="text-primary-cyan font-semibold">{selectedIds.size}</span>건
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={toggleAll} className="px-3 py-1.5 text-[11px] font-semibold rounded bg-bg-3 border border-border text-text-2 hover:bg-bg-hover transition-colors font-korean">
|
||||
☑ 전체선택
|
||||
</button>
|
||||
<button className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/30 hover:bg-[rgba(6,182,212,0.2)] transition-colors font-korean">
|
||||
📥 선택 다운로드
|
||||
</button>
|
||||
<button className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(168,85,247,0.1)] text-primary-purple border border-primary-purple/30 hover:bg-[rgba(168,85,247,0.2)] transition-colors font-korean">
|
||||
🧩 유출유면적분석으로 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUpload && (
|
||||
<div className="fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center">
|
||||
<div ref={modalRef} className="bg-bg-1 border border-border rounded-md w-[480px] max-h-[80vh] overflow-y-auto p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<span className="text-base font-bold font-korean">📤 영상·사진 업로드</span>
|
||||
<button onClick={() => setShowUpload(false)} className="text-text-3 text-lg hover:text-text-1">✕</button>
|
||||
</div>
|
||||
<div className="border-2 border-dashed border-border-light rounded-md py-8 px-4 text-center mb-4 cursor-pointer hover:border-primary-cyan/40 transition-colors">
|
||||
<div className="text-3xl mb-2 opacity-50">📁</div>
|
||||
<div className="text-[13px] font-semibold mb-1 font-korean">파일을 드래그하거나 클릭하여 업로드</div>
|
||||
<div className="text-[11px] text-text-3 font-korean">JPG, TIFF, GeoTIFF, MP4, MOV 지원 · 최대 2GB</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">촬영 장비</label>
|
||||
<select className="prd-i w-full">
|
||||
<option>드론 (DJI M300 RTK)</option>
|
||||
<option>드론 (DJI Mavic 3E)</option>
|
||||
<option>유인항공기 (CN-235)</option>
|
||||
<option>유인항공기 (헬기 B-512)</option>
|
||||
<option>위성 (Sentinel-1)</option>
|
||||
<option>위성 (다목적위성5호)</option>
|
||||
<option>기타</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">연관 사고</label>
|
||||
<select className="prd-i w-full">
|
||||
<option>여수항 유류유출 (2026-01-18)</option>
|
||||
<option>통영 해역 기름오염 (2026-01-18)</option>
|
||||
<option>군산항 인근 오염 (2026-01-18)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">메모</label>
|
||||
<textarea
|
||||
className="prd-i w-full h-[60px] resize-y"
|
||||
placeholder="촬영 조건, 비고 등..."
|
||||
/>
|
||||
</div>
|
||||
<button className="w-full py-3 rounded-sm text-sm font-bold font-korean text-white border-none cursor-pointer" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>
|
||||
📤 업로드 실행
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
212
frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx
Normal file
212
frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx
Normal file
@ -0,0 +1,212 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
// ── Types & Mock Data ──
|
||||
|
||||
interface MosaicImage {
|
||||
id: string
|
||||
filename: string
|
||||
status: 'done' | 'processing' | 'waiting'
|
||||
hasOil: boolean
|
||||
}
|
||||
|
||||
const mosaicImages: MosaicImage[] = [
|
||||
{ id: 'T1', filename: '드론_001.jpg', status: 'done', hasOil: true },
|
||||
{ id: 'T2', filename: '드론_002.jpg', status: 'done', hasOil: true },
|
||||
{ id: 'T3', filename: '드론_003.jpg', status: 'done', hasOil: true },
|
||||
{ id: 'T4', filename: '드론_004.jpg', status: 'done', hasOil: true },
|
||||
{ id: 'T5', filename: '드론_005.jpg', status: 'processing', hasOil: false },
|
||||
{ id: 'T6', filename: '드론_006.jpg', status: 'waiting', hasOil: false },
|
||||
]
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function OilAreaAnalysis() {
|
||||
const [activeStep, setActiveStep] = useState(1)
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [analyzed, setAnalyzed] = useState(false)
|
||||
|
||||
const handleAnalyze = () => {
|
||||
setAnalyzing(true)
|
||||
setTimeout(() => {
|
||||
setAnalyzing(false)
|
||||
setAnalyzed(true)
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
const stepCls = (idx: number) => {
|
||||
if (idx < activeStep) return 'border-status-green text-status-green bg-[rgba(34,197,94,0.05)]'
|
||||
if (idx === activeStep) return 'border-primary-cyan text-primary-cyan bg-[rgba(6,182,212,0.05)]'
|
||||
return 'border-border text-text-3 bg-bg-3'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-5 h-full overflow-hidden">
|
||||
{/* Left Panel */}
|
||||
<div className="w-[340px] min-w-[340px] flex flex-col overflow-y-auto scrollbar-thin">
|
||||
<div className="text-sm font-bold mb-1 font-korean">🧩 유출유면적분석</div>
|
||||
<div className="text-[11px] text-text-3 mb-4 font-korean">단면 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
{['① 사진 선택', '② 정합·합성', '③ 면적 산정'].map((label, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setActiveStep(i)}
|
||||
className={`flex-1 py-2 rounded-sm border text-center text-[10px] font-semibold font-korean cursor-pointer transition-colors ${stepCls(i)}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Selected Images */}
|
||||
<div className="text-[11px] font-bold mb-2 font-korean">선택된 사진 (6장)</div>
|
||||
<div className="flex flex-col gap-1 mb-3.5">
|
||||
{['여수항_드론_001.jpg', '여수항_드론_002.jpg', '여수항_드론_003.jpg', '여수항_드론_004.jpg', '여수항_드론_005.jpg', '여수항_드론_006.jpg'].map((name, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-1.5 bg-bg-3 border border-border rounded-sm text-[11px] font-korean">
|
||||
<span>🛸</span>
|
||||
<span className="flex-1 truncate">{name}</span>
|
||||
<span className={`text-[9px] font-semibold ${
|
||||
i < 4 ? 'text-status-green' : i === 4 ? 'text-status-orange' : 'text-text-3'
|
||||
}`}>
|
||||
{i < 4 ? '✓ 정합' : i === 4 ? '⏳ 정합중' : '대기'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Analysis Parameters */}
|
||||
<div className="text-[11px] font-bold mb-2 font-korean">분석 파라미터</div>
|
||||
<div className="flex flex-col gap-1.5 mb-3.5">
|
||||
{[
|
||||
['촬영 고도', '120 m'],
|
||||
['GSD (지상해상도)', '3.2 cm/px'],
|
||||
['오버랩 비율', '80% / 70%'],
|
||||
['좌표계', 'EPSG:5186'],
|
||||
['유종 판별 기준', 'NDVI + NIR'],
|
||||
['유막 두께 추정', 'Bonn Agreement'],
|
||||
].map(([label, value], i) => (
|
||||
<div key={i} className="flex justify-between items-center text-[11px]">
|
||||
<span className="text-text-3 font-korean">{label}</span>
|
||||
<span className="font-mono font-semibold">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={analyzing}
|
||||
className={`w-full py-3 rounded-sm text-[13px] font-bold font-korean cursor-pointer border-none mb-2 transition-colors ${
|
||||
analyzed
|
||||
? 'bg-[rgba(34,197,94,0.15)] text-status-green border border-status-green'
|
||||
: 'text-white'
|
||||
}`}
|
||||
style={!analyzed ? { background: 'linear-gradient(135deg, var(--cyan), var(--blue))' } : undefined}
|
||||
>
|
||||
{analyzing ? '⏳ 분석중...' : analyzed ? '✅ 분석 완료!' : '🧩 면적분석 실행'}
|
||||
</button>
|
||||
<button className="w-full py-2.5 border border-border bg-bg-3 text-text-2 rounded-sm text-xs font-semibold font-korean cursor-pointer hover:bg-bg-hover transition-colors">
|
||||
📥 결과 다운로드 (GeoTIFF)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right Panel */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-xs font-bold font-korean">🗺 합성 영상 및 유막 탐지 결과</span>
|
||||
<div className="flex gap-1.5">
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[rgba(239,68,68,0.1)] text-status-red font-semibold font-korean">■ 유막 탐지</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[rgba(6,182,212,0.1)] text-primary-cyan font-semibold font-korean">□ 원본 타일</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[rgba(34,197,94,0.1)] text-status-green font-semibold font-korean">정합률 96.2%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Grid 3×2 */}
|
||||
<div className="grid grid-cols-3 gap-1.5 mb-3">
|
||||
{mosaicImages.map(img => (
|
||||
<div key={img.id} className="bg-bg-3 border border-border rounded-sm overflow-hidden cursor-pointer hover:border-border-light transition-colors">
|
||||
<div
|
||||
className="h-[100px] relative flex items-center justify-center overflow-hidden"
|
||||
style={{ background: 'linear-gradient(135deg, #0c1624, #1a1a2e)' }}
|
||||
>
|
||||
{img.hasOil && (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: 'rgba(239,68,68,0.15)',
|
||||
border: '1px solid rgba(239,68,68,0.35)',
|
||||
clipPath: 'polygon(20% 30%,60% 15%,85% 40%,70% 80%,30% 75%,10% 50%)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="text-lg font-bold text-white/[0.08] font-mono">{img.id}</div>
|
||||
<div className={`absolute top-1.5 right-1.5 px-1.5 py-0.5 rounded-md text-[9px] font-bold font-korean ${
|
||||
img.status === 'done' && img.hasOil ? 'bg-[rgba(239,68,68,0.2)] text-status-red' :
|
||||
img.status === 'processing' ? 'bg-[rgba(249,115,22,0.2)] text-status-orange' :
|
||||
'bg-[rgba(100,116,139,0.2)] text-text-3'
|
||||
}`}>
|
||||
{img.status === 'done' && img.hasOil ? '유막' : img.status === 'processing' ? '정합중' : '대기'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 py-1.5 flex justify-between items-center text-[10px] font-korean text-text-2">
|
||||
<span>{img.filename}</span>
|
||||
<span className={
|
||||
img.status === 'done' ? 'text-status-green' :
|
||||
img.status === 'processing' ? 'text-status-orange' :
|
||||
'text-text-3'
|
||||
}>
|
||||
{img.status === 'done' ? '✓' : img.status === 'processing' ? '⏳' : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Merged Result Preview */}
|
||||
<div className="relative h-[140px] bg-bg-0 border border-border rounded-sm overflow-hidden mb-3">
|
||||
<div className="absolute inset-0" style={{ background: 'radial-gradient(ellipse at 40% 50%, rgba(10,25,40,0.7), rgba(8,14,26,0.95))' }}>
|
||||
<div className="absolute border border-dashed rounded flex items-center justify-center text-[10px] font-korean" style={{ top: '15%', left: '10%', width: '65%', height: '70%', borderColor: 'rgba(6,182,212,0.3)', color: 'rgba(6,182,212,0.5)' }}>
|
||||
합성 영역 (3×2 그리드)
|
||||
</div>
|
||||
<div className="absolute" style={{ top: '22%', left: '18%', width: '35%', height: '40%', background: 'rgba(239,68,68,0.12)', border: '1.5px solid rgba(239,68,68,0.4)', borderRadius: '30% 50% 40% 60%' }} />
|
||||
<div className="absolute" style={{ top: '40%', left: '38%', width: '20%', height: '30%', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: '50% 30% 60% 40%' }} />
|
||||
</div>
|
||||
<div className="absolute bottom-1.5 left-2.5 text-[9px] text-text-3 font-mono">34.7312°N, 127.6845°E</div>
|
||||
<div className="absolute bottom-1.5 right-2.5 text-[9px] text-text-3 font-mono">축척 ≈ 1:2,500</div>
|
||||
</div>
|
||||
|
||||
{/* Analysis Results */}
|
||||
<div className="p-4 bg-bg-3 border border-border rounded-md">
|
||||
<div className="text-xs font-bold mb-2.5 font-korean">📊 유출유 분석 결과</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ value: '0.42 km²', label: '유막 면적', color: 'text-status-red' },
|
||||
{ value: '12.6 kL', label: '추정 유출량', color: 'text-status-orange' },
|
||||
{ value: '1.84 km²', label: '합성 영역 면적', color: 'text-primary-cyan' },
|
||||
].map((r, i) => (
|
||||
<div key={i} className="text-center py-2.5 px-2 bg-bg-0 border border-border rounded-sm">
|
||||
<div className={`text-lg font-bold font-mono ${r.color}`}>{r.value}</div>
|
||||
<div className="text-[9px] text-text-3 mt-0.5 font-korean">{r.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5 mt-2.5 text-[11px]">
|
||||
{[
|
||||
['두꺼운 유막 (>1mm)', '0.08 km²', 'text-status-red'],
|
||||
['얇은 유막 (<1mm)', '0.34 km²', 'text-status-orange'],
|
||||
['무지개 빛깔', '0.12 km²', 'text-status-yellow'],
|
||||
['Bonn 코드', 'Code 3~4', 'text-text-1'],
|
||||
].map(([label, value, color], i) => (
|
||||
<div key={i} className="flex justify-between px-2 py-1 bg-bg-0 rounded">
|
||||
<span className="text-text-3 font-korean">{label}</span>
|
||||
<span className={`font-semibold font-mono ${color}`}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
252
frontend/src/tabs/aerial/components/RealtimeDrone.tsx
Normal file
252
frontend/src/tabs/aerial/components/RealtimeDrone.tsx
Normal file
@ -0,0 +1,252 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface DroneInfo {
|
||||
id: string
|
||||
name: string
|
||||
status: 'active' | 'returning' | 'standby' | 'charging'
|
||||
battery: number
|
||||
altitude: number
|
||||
speed: number
|
||||
sensor: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const drones: DroneInfo[] = [
|
||||
{ id: 'D-01', name: 'DJI M300 #1', status: 'active', battery: 78, altitude: 150, speed: 12, sensor: '광학 4K', color: 'var(--blue)' },
|
||||
{ id: 'D-02', name: 'DJI M300 #2', status: 'active', battery: 65, altitude: 200, speed: 8, sensor: 'IR 열화상', color: 'var(--red)' },
|
||||
{ id: 'D-03', name: 'Mavic 3E', status: 'active', battery: 82, altitude: 120, speed: 15, sensor: '광학 4K', color: 'var(--purple)' },
|
||||
{ id: 'D-04', name: 'DJI M30T', status: 'active', battery: 45, altitude: 180, speed: 10, sensor: '다중센서', color: 'var(--green)' },
|
||||
{ id: 'D-05', name: 'DJI M300 #3', status: 'returning', battery: 12, altitude: 80, speed: 18, sensor: '광학 4K', color: 'var(--orange)' },
|
||||
{ id: 'D-06', name: 'Mavic 3E #2', status: 'charging', battery: 35, altitude: 0, speed: 0, sensor: '광학 4K', color: 'var(--t3)' },
|
||||
]
|
||||
|
||||
interface AlertItem {
|
||||
time: string
|
||||
type: 'warning' | 'info' | 'danger'
|
||||
message: string
|
||||
}
|
||||
|
||||
const alerts: AlertItem[] = [
|
||||
{ time: '15:42', type: 'danger', message: 'D-05 배터리 부족 — 자동 복귀' },
|
||||
{ time: '15:38', type: 'warning', message: '오염원 신규 탐지 (34.82°N)' },
|
||||
{ time: '15:35', type: 'info', message: 'D-01~D-03 다시점 융합 완료' },
|
||||
{ time: '15:30', type: 'warning', message: 'AIS OFF 선박 2척 추가 탐지' },
|
||||
{ time: '15:25', type: 'info', message: 'D-04 센서 데이터 수집 시작' },
|
||||
{ time: '15:20', type: 'danger', message: '유류오염 확산 속도 증가 감지' },
|
||||
{ time: '15:15', type: 'info', message: '3D 재구성 시작 (불명선박-B)' },
|
||||
]
|
||||
|
||||
export function RealtimeDrone() {
|
||||
const [reconProgress, setReconProgress] = useState(0)
|
||||
const [reconDone, setReconDone] = useState(false)
|
||||
const [selectedDrone, setSelectedDrone] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (reconDone) return
|
||||
const timer = setInterval(() => {
|
||||
setReconProgress(prev => {
|
||||
if (prev >= 100) {
|
||||
clearInterval(timer)
|
||||
setReconDone(true)
|
||||
return 100
|
||||
}
|
||||
return prev + 2
|
||||
})
|
||||
}, 300)
|
||||
return () => clearInterval(timer)
|
||||
}, [reconDone])
|
||||
|
||||
const statusLabel = (s: string) => {
|
||||
if (s === 'active') return { text: '비행중', cls: 'text-status-green' }
|
||||
if (s === 'returning') return { text: '복귀중', cls: 'text-status-orange' }
|
||||
if (s === 'charging') return { text: '충전중', cls: 'text-text-3' }
|
||||
return { text: '대기', cls: 'text-text-3' }
|
||||
}
|
||||
|
||||
const alertColor = (t: string) =>
|
||||
t === 'danger' ? 'border-l-status-red bg-[rgba(239,68,68,0.05)]'
|
||||
: t === 'warning' ? 'border-l-status-orange bg-[rgba(249,115,22,0.05)]'
|
||||
: 'border-l-primary-blue bg-[rgba(59,130,246,0.05)]'
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
|
||||
{/* Map Area */}
|
||||
<div className="flex-1 relative bg-bg-0 overflow-hidden">
|
||||
{/* Simulated map background */}
|
||||
<div className="absolute inset-0" style={{ background: 'radial-gradient(ellipse at 50% 50%, #0c1a2e, #060c18)' }}>
|
||||
{/* Grid lines */}
|
||||
<div className="absolute inset-0 opacity-[0.06]" style={{ backgroundImage: 'linear-gradient(rgba(6,182,212,0.3) 1px, transparent 1px), linear-gradient(90deg, rgba(6,182,212,0.3) 1px, transparent 1px)', backgroundSize: '60px 60px' }} />
|
||||
{/* Coastline hint */}
|
||||
<div className="absolute" style={{ top: '20%', left: '5%', width: '40%', height: '60%', border: '1px solid rgba(34,197,94,0.15)', borderRadius: '40% 60% 50% 30%' }} />
|
||||
{/* Drone position markers */}
|
||||
{drones.filter(d => d.status !== 'charging').map((d, i) => (
|
||||
<div
|
||||
key={d.id}
|
||||
className="absolute cursor-pointer"
|
||||
style={{ top: `${25 + i * 12}%`, left: `${30 + i * 10}%` }}
|
||||
onClick={() => setSelectedDrone(d.id)}
|
||||
>
|
||||
<div className="w-3 h-3 rounded-full animate-pulse-dot" style={{ background: d.color, boxShadow: `0 0 8px ${d.color}` }} />
|
||||
<div className="absolute -top-4 left-4 text-[8px] font-bold font-mono whitespace-nowrap" style={{ color: d.color }}>{d.id}</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Oil spill areas */}
|
||||
<div className="absolute" style={{ top: '35%', left: '45%', width: '120px', height: '80px', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: '40% 60% 50% 40%' }} />
|
||||
</div>
|
||||
|
||||
{/* Overlay Stats */}
|
||||
<div className="absolute top-2.5 left-2.5 flex gap-1.5 z-[2]">
|
||||
{[
|
||||
{ label: '탐지 객체', value: '847', unit: '건', color: 'text-primary-blue' },
|
||||
{ label: '식별 선박', value: '312', unit: '척', color: 'text-primary-cyan' },
|
||||
{ label: 'AIS OFF', value: '14', unit: '척', color: 'text-status-red' },
|
||||
{ label: '오염 탐지', value: '3', unit: '건', color: 'text-status-orange' },
|
||||
].map((s, i) => (
|
||||
<div key={i} className="bg-[rgba(15,21,36,0.9)] backdrop-blur-sm rounded-sm px-2.5 py-1.5 border border-border">
|
||||
<div className="text-[7px] text-text-3">{s.label}</div>
|
||||
<div>
|
||||
<span className={`font-mono font-bold text-base ${s.color}`}>{s.value}</span>
|
||||
<span className="text-[7px] text-text-3 ml-0.5">{s.unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 3D Reconstruction Progress */}
|
||||
<div className="absolute bottom-2.5 right-2.5 bg-[rgba(15,21,36,0.9)] rounded-sm px-3 py-2 border z-[3] min-w-[175px] cursor-pointer transition-colors hover:border-primary-cyan/40" style={{ borderColor: 'rgba(6,182,212,0.18)' }}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[9px] font-bold text-primary-cyan">🧊 3D 재구성</span>
|
||||
<span className="font-mono font-bold text-[13px] text-primary-cyan">{reconProgress}%</span>
|
||||
</div>
|
||||
<div className="w-full h-[3px] bg-white/[0.06] rounded-sm mb-1">
|
||||
<div className="h-full rounded-sm transition-all duration-500" style={{ width: `${reconProgress}%`, background: 'linear-gradient(90deg, var(--cyan), var(--blue))' }} />
|
||||
</div>
|
||||
{!reconDone ? (
|
||||
<div className="text-[7px] text-text-3">D-01~D-03 다각도 영상 융합중...</div>
|
||||
) : (
|
||||
<div className="text-[8px] font-bold text-status-green mt-0.5 animate-pulse-dot">✅ 완료 — 클릭하여 정밀분석</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live Feed Panel */}
|
||||
{selectedDrone && (() => {
|
||||
const drone = drones.find(d => d.id === selectedDrone)
|
||||
if (!drone) return null
|
||||
return (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-[rgba(15,21,36,0.95)] z-[5] border-t" style={{ borderColor: 'rgba(59,130,246,0.2)', height: 190 }}>
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border">
|
||||
<div className="text-[10px] font-bold flex items-center gap-1.5" style={{ color: drone.color }}>
|
||||
<div className="w-1.5 h-1.5 rounded-full animate-pulse-dot" style={{ background: drone.color }} />
|
||||
{drone.id} 실시간 영상
|
||||
</div>
|
||||
<button onClick={() => setSelectedDrone(null)} className="w-5 h-5 rounded bg-white/5 border border-border text-text-3 text-[11px] flex items-center justify-center cursor-pointer hover:text-text-1">✕</button>
|
||||
</div>
|
||||
<div className="grid h-[calc(100%-30px)]" style={{ gridTemplateColumns: '1fr 180px' }}>
|
||||
<div className="relative overflow-hidden" style={{ background: 'radial-gradient(ellipse at center, #0c1a2e, #060c18)' }}>
|
||||
{/* Simulated video feed */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-text-3/20 text-2xl font-mono">LIVE FEED</div>
|
||||
</div>
|
||||
{/* HUD overlay */}
|
||||
<div className="absolute top-1.5 left-2 z-[2]">
|
||||
<span className="text-[11px] font-bold" style={{ color: drone.color }}>{drone.id}</span>
|
||||
<span className="text-[7px] px-1 py-px rounded bg-white/[0.08] ml-1">{drone.sensor}</span>
|
||||
<div className="text-[7px] text-text-3 font-mono mt-0.5">34.82°N, 128.95°E</div>
|
||||
</div>
|
||||
<div className="absolute top-1.5 right-2 z-[2] flex items-center gap-1 text-[8px] font-bold text-status-red">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-status-red" />REC
|
||||
</div>
|
||||
<div className="absolute bottom-1 left-2 z-[2] text-[7px] text-text-3">
|
||||
ALT {drone.altitude}m · SPD {drone.speed}m/s · HDG 045°
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 overflow-auto text-[9px] border-l border-border">
|
||||
<div className="font-bold text-text-2 mb-1.5 font-korean">비행 정보</div>
|
||||
{[
|
||||
['드론 ID', drone.id],
|
||||
['기체', drone.name],
|
||||
['배터리', `${drone.battery}%`],
|
||||
['고도', `${drone.altitude}m`],
|
||||
['속도', `${drone.speed}m/s`],
|
||||
['센서', drone.sensor],
|
||||
['상태', statusLabel(drone.status).text],
|
||||
].map(([k, v], i) => (
|
||||
<div key={i} className="flex justify-between py-0.5">
|
||||
<span className="text-text-3 font-korean">{k}</span>
|
||||
<span className="font-mono font-semibold text-text-1">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar */}
|
||||
<div className="w-[260px] bg-[rgba(15,21,36,0.88)] border-l border-border flex flex-col overflow-auto">
|
||||
{/* Drone Swarm Status */}
|
||||
<div className="p-2.5 px-3 border-b border-border">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">군집 드론 현황 · 4/6 운용</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{drones.map(d => {
|
||||
const st = statusLabel(d.status)
|
||||
return (
|
||||
<div
|
||||
key={d.id}
|
||||
onClick={() => d.status !== 'charging' && setSelectedDrone(d.id)}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded-sm cursor-pointer transition-colors ${
|
||||
selectedDrone === d.id ? 'bg-[rgba(6,182,212,0.08)] border border-primary-cyan/20' : 'hover:bg-white/[0.02] border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: d.color }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[9px] font-bold" style={{ color: d.color }}>{d.id}</div>
|
||||
<div className="text-[7px] text-text-3 truncate">{d.name}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`text-[8px] font-semibold ${st.cls}`}>{st.text}</div>
|
||||
<div className="text-[7px] font-mono text-text-3">{d.battery}%</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi-Angle Analysis */}
|
||||
<div className="p-2.5 px-3 border-b border-border">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">다각화 분석</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{[
|
||||
{ icon: '🎯', label: '다시점 융합', value: '28건', sub: '360° 식별' },
|
||||
{ icon: '🧊', label: '3D 재구성', value: '12건', sub: '선박+오염원' },
|
||||
{ icon: '📡', label: '다센서 융합', value: '45건', sub: '광학+IR+SAR' },
|
||||
{ icon: '🛢️', label: '오염원 3D', value: '3건', sub: '유류+HNS' },
|
||||
].map((a, i) => (
|
||||
<div key={i} className="bg-white/[0.02] rounded-sm px-1.5 py-1.5 border border-white/[0.03]">
|
||||
<div className="text-[10px] mb-px">{a.icon}</div>
|
||||
<div className="text-[7px] text-text-3">{a.label}</div>
|
||||
<div className="text-xs font-bold font-mono text-primary-cyan my-px">{a.value}</div>
|
||||
<div className="text-[6px] text-text-3">{a.sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Real-time Alerts */}
|
||||
<div className="p-2.5 px-3 flex-1 overflow-auto">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">실시간 경보</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{alerts.map((a, i) => (
|
||||
<div key={i} className={`px-2 py-1.5 border-l-2 rounded-sm text-[9px] font-korean ${alertColor(a.type)}`}>
|
||||
<span className="font-mono text-text-3 mr-1.5">{a.time}</span>
|
||||
<span className="text-text-2">{a.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
787
frontend/src/tabs/aerial/components/SatelliteRequest.tsx
Normal file
787
frontend/src/tabs/aerial/components/SatelliteRequest.tsx
Normal file
@ -0,0 +1,787 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
|
||||
interface SatRequest {
|
||||
id: string
|
||||
zone: string
|
||||
zoneCoord: string
|
||||
zoneArea: string
|
||||
satellite: string
|
||||
requestDate: string
|
||||
expectedReceive: string
|
||||
resolution: string
|
||||
status: '촬영중' | '대기' | '완료'
|
||||
provider?: string
|
||||
purpose?: string
|
||||
requester?: string
|
||||
}
|
||||
|
||||
const satRequests: SatRequest[] = [
|
||||
{ id: 'SAT-004', zone: '제주 서귀포 해상 (유출 해역 중심)', zoneCoord: '33.24°N 126.50°E', zoneArea: '15km²', satellite: 'KOMPSAT-3A', requestDate: '02-20 08:14', expectedReceive: '02-20 14:30', resolution: '0.5m', status: '촬영중', provider: 'KARI', purpose: '유출유 확산 모니터링', requester: '방제과 김해양' },
|
||||
{ id: 'SAT-005', zone: '가파도 북쪽 해안선', zoneCoord: '33.17°N 126.27°E', zoneArea: '8km²', satellite: 'KOMPSAT-3', requestDate: '02-20 09:02', expectedReceive: '02-21 09:00', resolution: '1.0m', status: '대기', provider: 'KARI', purpose: '해안선 오염 확인', requester: '방제과 이민수' },
|
||||
{ id: 'SAT-006', zone: '마라도 주변 해역', zoneCoord: '33.11°N 126.27°E', zoneArea: '12km²', satellite: 'Sentinel-2', requestDate: '02-20 09:30', expectedReceive: '02-21 11:00', resolution: '10m', status: '대기', provider: 'ESA Copernicus', purpose: '수질 분석용 다분광 촬영', requester: '환경분석팀 박수진' },
|
||||
{ id: 'SAT-007', zone: '대정읍 해안 오염 확산 구역', zoneCoord: '33.21°N 126.10°E', zoneArea: '20km²', satellite: 'KOMPSAT-3A', requestDate: '02-20 10:05', expectedReceive: '02-22 08:00', resolution: '0.5m', status: '대기', provider: 'KARI', purpose: '확산 예측 모델 검증', requester: '방제과 김해양' },
|
||||
{ id: 'SAT-003', zone: '제주 남방 100해리 해상', zoneCoord: '33.00°N 126.50°E', zoneArea: '25km²', satellite: 'Sentinel-1', requestDate: '02-19 14:00', expectedReceive: '02-19 23:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: 'SAR 유막 탐지', requester: '환경분석팀 박수진' },
|
||||
{ id: 'SAT-002', zone: '여수 오동도 인근 해역', zoneCoord: '34.73°N 127.68°E', zoneArea: '18km²', satellite: 'KOMPSAT-3A', requestDate: '02-18 11:30', expectedReceive: '02-18 17:45', resolution: '0.5m', status: '완료', provider: 'KARI', purpose: '유출 초기 범위 확인', requester: '방제과 김해양' },
|
||||
{ id: 'SAT-001', zone: '통영 해역 남측', zoneCoord: '34.85°N 128.43°E', zoneArea: '30km²', satellite: 'Sentinel-1', requestDate: '02-17 09:00', expectedReceive: '02-17 21:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: '야간 SAR 유막 모니터링', requester: '환경분석팀 박수진' },
|
||||
]
|
||||
|
||||
const satellites = [
|
||||
{ name: 'KOMPSAT-3A', desc: '해상도 0.5m · 광학 / IR · 촬영 가능', status: '가용', statusColor: 'var(--green)', borderColor: 'rgba(34,197,94,.2)', pulse: true },
|
||||
{ name: 'KOMPSAT-3', desc: '해상도 1.0m · 광학 · 임무 중', status: '임무중', statusColor: 'var(--yellow)', borderColor: 'rgba(234,179,8,.2)', pulse: true },
|
||||
{ name: 'Sentinel-1 (ESA)', desc: '해상도 20m · SAR · 야간/우천 가능', status: '가용', statusColor: 'var(--green)', borderColor: 'var(--bd)', pulse: false },
|
||||
{ name: 'Sentinel-2 (ESA)', desc: '해상도 10m · 다분광 · 수질 분석 적합', status: '가용', statusColor: 'var(--green)', borderColor: 'var(--bd)', pulse: false },
|
||||
]
|
||||
|
||||
const passSchedules = [
|
||||
{ time: '14:10 – 14:24', desc: 'KOMPSAT-3A 패스 (제주 남방)', today: true },
|
||||
{ time: '16:55 – 17:08', desc: 'Sentinel-1 패스 (제주 전역)', today: true },
|
||||
{ time: '내일 09:12', desc: 'KOMPSAT-3 패스 (가파도~마라도)', today: false },
|
||||
{ time: '내일 10:40', desc: 'Sentinel-2 패스 (제주 서측)', today: false },
|
||||
]
|
||||
|
||||
// UP42 위성 카탈로그 데이터
|
||||
const up42Satellites = [
|
||||
{ id: 'mwl-hd15', name: 'Maxar WorldView Legion HD15', res: '0.3m', type: 'optical' as const, color: '#3b82f6', cloud: 15 },
|
||||
{ id: 'pneo-hd15', name: 'Pléiades Neo HD15', res: '0.3m', type: 'optical' as const, color: '#06b6d4', cloud: 10 },
|
||||
{ id: 'mwl', name: 'Maxar WorldView Legion', res: '0.5m', type: 'optical' as const, color: '#3b82f6', cloud: 20 },
|
||||
{ id: 'mwv3', name: 'Maxar WorldView-3', res: '0.5m', type: 'optical' as const, color: '#3b82f6', cloud: 20 },
|
||||
{ id: 'pneo', name: 'Pléiades Neo', res: '0.5m', type: 'optical' as const, color: '#06b6d4', cloud: 15 },
|
||||
{ id: 'bj3n', name: 'Beijing-3N', res: '0.5m', type: 'optical' as const, color: '#f97316', cloud: 20, delay: true },
|
||||
{ id: 'skysat', name: 'SkySat', res: '0.7m', type: 'optical' as const, color: '#22c55e', cloud: 15 },
|
||||
{ id: 'kmp3a', name: 'KOMPSAT-3A', res: '0.5m', type: 'optical' as const, color: '#a855f7', cloud: 10 },
|
||||
{ id: 'kmp3', name: 'KOMPSAT-3', res: '1.0m', type: 'optical' as const, color: '#a855f7', cloud: 15 },
|
||||
{ id: 'spot7', name: 'SPOT 7', res: '1.5m', type: 'optical' as const, color: '#eab308', cloud: 20 },
|
||||
{ id: 's2', name: 'Sentinel-2', res: '10m', type: 'optical' as const, color: '#ec4899', cloud: 20 },
|
||||
{ id: 's1', name: 'Sentinel-1 SAR', res: '20m', type: 'sar' as const, color: '#f59e0b', cloud: 0 },
|
||||
{ id: 'alos2', name: 'ALOS-2 PALSAR-2', res: '3m', type: 'sar' as const, color: '#f59e0b', cloud: 0 },
|
||||
{ id: 'rcm', name: 'RCM (Radarsat)', res: '3m', type: 'sar' as const, color: '#f59e0b', cloud: 0 },
|
||||
{ id: 'srtm', name: 'SRTM DEM', res: '30m', type: 'elevation' as const, color: '#64748b', cloud: 0 },
|
||||
{ id: 'cop-dem', name: 'Copernicus DEM', res: '10m', type: 'elevation' as const, color: '#64748b', cloud: 0 },
|
||||
]
|
||||
|
||||
const up42Passes = [
|
||||
{ sat: 'KOMPSAT-3A', time: '오늘 14:10–14:24', res: '0.5m', cloud: '≤10%', note: '최우선 추천', color: '#a855f7' },
|
||||
{ sat: 'Pléiades Neo', time: '오늘 14:38–14:52', res: '0.3m', cloud: '≤15%', note: '초고해상도', color: '#06b6d4' },
|
||||
{ sat: 'Sentinel-1 SAR', time: '오늘 16:55–17:08', res: '20m', cloud: '야간/우천 가능', note: 'SAR', color: '#f59e0b' },
|
||||
{ sat: 'KOMPSAT-3', time: '내일 09:12', res: '1.0m', cloud: '≤15%', note: '', color: '#a855f7' },
|
||||
{ sat: 'Maxar WV-3', time: '내일 13:20', res: '0.5m', cloud: '≤20%', note: '', color: '#3b82f6' },
|
||||
]
|
||||
|
||||
type SatModalPhase = 'none' | 'provider' | 'blacksky' | 'up42'
|
||||
|
||||
export function SatelliteRequest() {
|
||||
const [statusFilter, setStatusFilter] = useState('전체')
|
||||
const [modalPhase, setModalPhase] = useState<SatModalPhase>('none')
|
||||
const [selectedRequest, setSelectedRequest] = useState<SatRequest | null>(null)
|
||||
const [showMoreCompleted, setShowMoreCompleted] = useState(false)
|
||||
// UP42 sub-tab
|
||||
const [up42SubTab, setUp42SubTab] = useState<'optical' | 'sar' | 'elevation'>('optical')
|
||||
const [up42SelSat, setUp42SelSat] = useState<string | null>(null)
|
||||
const [up42SelPass, setUp42SelPass] = useState<number | null>(null)
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
setModalPhase('none')
|
||||
}
|
||||
}
|
||||
if (modalPhase !== 'none') document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [modalPhase])
|
||||
|
||||
const allRequests = showMoreCompleted ? satRequests : satRequests.filter(r => r.status !== '완료' || r.id === 'SAT-003')
|
||||
|
||||
const filtered = allRequests.filter(r => {
|
||||
if (statusFilter === '전체') return true
|
||||
if (statusFilter === '대기') return r.status === '대기'
|
||||
if (statusFilter === '진행') return r.status === '촬영중'
|
||||
if (statusFilter === '완료') return r.status === '완료'
|
||||
return true
|
||||
})
|
||||
|
||||
const statusBadge = (s: SatRequest['status']) => {
|
||||
if (s === '촬영중') return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(234,179,8,.15)', border: '1px solid rgba(234,179,8,.3)', color: 'var(--yellow)' }}>
|
||||
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--yellow)' }} />촬영중
|
||||
</span>
|
||||
)
|
||||
if (s === '대기') return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(59,130,246,.15)', border: '1px solid rgba(59,130,246,.3)', color: 'var(--blue)' }}>⏳ 대기</span>
|
||||
)
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(34,197,94,.1)', border: '1px solid rgba(34,197,94,.2)', color: 'var(--green)' }}>✅ 완료</span>
|
||||
)
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{ value: '3', label: '요청 대기', color: 'var(--blue)' },
|
||||
{ value: '1', label: '촬영 진행 중', color: 'var(--yellow)' },
|
||||
{ value: '7', label: '수신 완료', color: 'var(--green)' },
|
||||
{ value: '0.5m', label: '최고 해상도', color: 'var(--cyan)' },
|
||||
]
|
||||
|
||||
const filters = ['전체', '대기', '진행', '완료']
|
||||
|
||||
const up42Filtered = up42Satellites.filter(s => s.type === up42SubTab)
|
||||
|
||||
// ── 섹션 헤더 헬퍼 (BlackSky 폼) ──
|
||||
const sectionHeader = (num: number, label: string) => (
|
||||
<div className="text-[11px] font-bold font-korean mb-2.5 flex items-center gap-1.5" style={{ color: '#818cf8' }}>
|
||||
<div className="w-[18px] h-[18px] rounded-[5px] flex items-center justify-center text-[9px] font-bold" style={{ background: 'rgba(99,102,241,.12)', color: '#818cf8' }}>{num}</div>
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
|
||||
const bsInput = "w-full px-3 py-2 rounded-md text-[11px] font-korean outline-none box-border"
|
||||
const bsInputStyle = { border: '1px solid #21262d', background: '#161b22', color: '#e2e8f0' }
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto" style={{ padding: '20px 24px' }}>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-[10px] flex items-center justify-center text-xl" style={{ background: 'linear-gradient(135deg,rgba(59,130,246,.2),rgba(168,85,247,.2))', border: '1px solid rgba(59,130,246,.3)' }}>🛰</div>
|
||||
<div>
|
||||
<div className="text-base font-bold font-korean text-text-1">위성 촬영 요청</div>
|
||||
<div className="text-[11px] text-text-3 font-korean mt-0.5">위성 촬영 임무를 요청하고 수신 현황을 관리합니다</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setModalPhase('provider')} className="px-4 py-2.5 text-white border-none rounded-sm text-[13px] font-semibold cursor-pointer font-korean flex items-center gap-1.5" style={{ background: 'linear-gradient(135deg,var(--blue),var(--purple))' }}>🛰 새 요청</button>
|
||||
</div>
|
||||
|
||||
{/* 요약 통계 */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-5">
|
||||
{stats.map((s, i) => (
|
||||
<div key={i} className="bg-bg-2 border border-border rounded-md p-3.5 text-center">
|
||||
<div className="text-[22px] font-bold font-mono" style={{ color: s.color }}>{s.value}</div>
|
||||
<div className="text-[10px] text-text-3 mt-1 font-korean">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 요청 목록 */}
|
||||
<div className="bg-bg-2 border border-border rounded-md overflow-hidden mb-5">
|
||||
<div className="flex items-center justify-between px-4 py-3.5 border-b border-border">
|
||||
<div className="text-[13px] font-bold font-korean text-text-1">📋 위성 요청 목록</div>
|
||||
<div className="flex gap-1.5">
|
||||
{filters.map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setStatusFilter(f)}
|
||||
className="px-2.5 py-1 rounded text-[10px] font-semibold cursor-pointer font-korean border"
|
||||
style={statusFilter === f
|
||||
? { background: 'rgba(59,130,246,.15)', color: 'var(--blue)', borderColor: 'rgba(59,130,246,.3)' }
|
||||
: { background: 'var(--bg3)', color: 'var(--t2)', borderColor: 'var(--bd)' }
|
||||
}
|
||||
>{f}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 헤더 행 */}
|
||||
<div className="grid gap-0 px-4 py-2 bg-bg-3 border-b border-border" style={{ gridTemplateColumns: '60px 1fr 100px 100px 120px 80px 90px' }}>
|
||||
{['번호', '촬영 구역', '위성', '요청일시', '예상수신', '해상도', '상태'].map(h => (
|
||||
<div key={h} className="text-[9px] font-bold text-text-3 font-korean uppercase tracking-wider">{h}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 데이터 행 */}
|
||||
{filtered.map(r => (
|
||||
<div key={r.id}>
|
||||
<div
|
||||
onClick={() => setSelectedRequest(selectedRequest?.id === r.id ? null : r)}
|
||||
className="grid gap-0 px-4 py-3 border-b items-center cursor-pointer hover:bg-bg-hover/30 transition-colors"
|
||||
style={{
|
||||
gridTemplateColumns: '60px 1fr 100px 100px 120px 80px 90px',
|
||||
borderColor: 'rgba(255,255,255,.04)',
|
||||
background: selectedRequest?.id === r.id ? 'rgba(99,102,241,.06)' : r.status === '촬영중' ? 'rgba(234,179,8,.03)' : 'transparent',
|
||||
opacity: r.status === '완료' ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
<div className="text-[11px] font-mono text-text-2">{r.id}</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-text-1 font-korean">{r.zone}</div>
|
||||
<div className="text-[10px] text-text-3 font-mono mt-0.5">{r.zoneCoord} · {r.zoneArea}</div>
|
||||
</div>
|
||||
<div className="text-[11px] font-semibold text-text-1 font-korean">{r.satellite}</div>
|
||||
<div className="text-[10px] text-text-2 font-mono">{r.requestDate}</div>
|
||||
<div className="text-[10px] font-semibold font-mono" style={{ color: r.status === '촬영중' ? 'var(--yellow)' : 'var(--t2)' }}>{r.expectedReceive}</div>
|
||||
<div className="text-[11px] font-bold font-mono" style={{ color: r.status === '완료' ? 'var(--t3)' : 'var(--cyan)' }}>{r.resolution}</div>
|
||||
<div>{statusBadge(r.status)}</div>
|
||||
</div>
|
||||
{/* 상세 정보 패널 */}
|
||||
{selectedRequest?.id === r.id && (
|
||||
<div className="px-4 py-3 border-b" style={{ borderColor: 'rgba(255,255,255,.04)', background: 'rgba(99,102,241,.03)' }}>
|
||||
<div className="grid grid-cols-4 gap-3 mb-2">
|
||||
{[
|
||||
['제공자', r.provider || '-'],
|
||||
['요청 목적', r.purpose || '-'],
|
||||
['요청자', r.requester || '-'],
|
||||
['촬영 면적', r.zoneArea],
|
||||
].map(([k, v], i) => (
|
||||
<div key={i} className="px-2.5 py-2 bg-bg-0 rounded">
|
||||
<div className="text-[8px] font-bold text-text-3 font-korean mb-1 uppercase">{k}</div>
|
||||
<div className="text-[10px] font-semibold text-text-1 font-korean">{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors" style={{ background: 'rgba(6,182,212,.08)', borderColor: 'rgba(6,182,212,.2)', color: 'var(--cyan)' }}>📍 지도에서 보기</button>
|
||||
{r.status === '완료' && (
|
||||
<button className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors" style={{ background: 'rgba(34,197,94,.08)', borderColor: 'rgba(34,197,94,.2)', color: 'var(--green)' }}>📥 영상 다운로드</button>
|
||||
)}
|
||||
{r.status === '대기' && (
|
||||
<button className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors" style={{ background: 'rgba(239,68,68,.08)', borderColor: 'rgba(239,68,68,.2)', color: 'var(--red)' }}>✕ 요청 취소</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
onClick={() => setShowMoreCompleted(!showMoreCompleted)}
|
||||
className="text-center py-2.5 text-[10px] text-text-3 font-korean cursor-pointer hover:text-text-2 transition-colors"
|
||||
>
|
||||
{showMoreCompleted ? '▲ 완료 목록 접기' : '▼ 이전 완료 목록 더보기 (6건)'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 위성 궤도 정보 */}
|
||||
<div className="grid grid-cols-2 gap-3.5">
|
||||
{/* 가용 위성 현황 */}
|
||||
<div className="bg-bg-2 border border-border rounded-md p-4">
|
||||
<div className="text-xs font-bold text-text-1 font-korean mb-3">🛰 가용 위성 현황</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{satellites.map((sat, i) => (
|
||||
<div key={i} className="flex items-center gap-2.5 px-3 py-2 bg-bg-3 rounded-md" style={{ border: `1px solid ${sat.borderColor}` }}>
|
||||
<div className={`w-2 h-2 rounded-full shrink-0 ${sat.pulse ? 'animate-pulse' : ''}`} style={{ background: sat.statusColor }} />
|
||||
<div className="flex-1">
|
||||
<div className="text-[11px] font-semibold text-text-1 font-korean">{sat.name}</div>
|
||||
<div className="text-[9px] text-text-3 font-korean">{sat.desc}</div>
|
||||
</div>
|
||||
<div className="text-[10px] font-bold font-korean" style={{ color: sat.statusColor }}>{sat.status}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오늘 촬영 가능 시간 */}
|
||||
<div className="bg-bg-2 border border-border rounded-md p-4">
|
||||
<div className="text-xs font-bold text-text-1 font-korean mb-3">⏰ 오늘 촬영 가능 시간 (KST)</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{passSchedules.map((ps, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-2.5 py-[7px] rounded-[5px]"
|
||||
style={{
|
||||
background: ps.today ? 'rgba(34,197,94,.05)' : 'rgba(59,130,246,.05)',
|
||||
border: ps.today ? '1px solid rgba(34,197,94,.15)' : '1px solid rgba(59,130,246,.15)',
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] font-bold font-mono min-w-[90px]" style={{ color: ps.today ? 'var(--cyan)' : 'var(--blue)' }}>{ps.time}</span>
|
||||
<span className="text-[10px] text-text-1 font-korean">{ps.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ 모달: 제공자 선택 ═══ */}
|
||||
{modalPhase !== 'none' && (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center" style={{ background: 'rgba(5,8,18,.75)', backdropFilter: 'blur(8px)' }}>
|
||||
<div ref={modalRef}>
|
||||
|
||||
{/* ── 제공자 선택 ── */}
|
||||
{modalPhase === 'provider' && (
|
||||
<div className="border rounded-2xl w-[640px] overflow-hidden" style={{ background: 'var(--bg1)', borderColor: 'rgba(99,102,241,.3)', boxShadow: '0 24px 80px rgba(0,0,0,.6)' }}>
|
||||
{/* 헤더 */}
|
||||
<div className="px-7 pt-6 pb-4 relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: 'linear-gradient(90deg,#6366f1,#3b82f6,#06b6d4)' }} />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-xl" style={{ background: 'linear-gradient(135deg,rgba(99,102,241,.15),rgba(59,130,246,.08))' }}>🛰</div>
|
||||
<div>
|
||||
<div className="text-base font-bold text-text-1 font-korean">위성 촬영 요청 — 제공자 선택</div>
|
||||
<div className="text-[10px] text-text-3 font-korean mt-0.5">요청할 위성 서비스 제공자를 선택하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setModalPhase('none')} className="text-lg cursor-pointer text-text-3 p-1 bg-transparent border-none hover:text-text-1 transition-colors">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제공자 카드 */}
|
||||
<div className="px-7 pb-6 flex flex-col gap-3.5">
|
||||
{/* BlackSky (Maxar) */}
|
||||
<div
|
||||
onClick={() => setModalPhase('blacksky')}
|
||||
className="cursor-pointer bg-bg-2 border border-border rounded-xl p-5 relative overflow-hidden hover:border-[rgba(99,102,241,.5)] hover:bg-[rgba(99,102,241,.04)] transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-[42px] h-[42px] rounded-[10px] flex items-center justify-center border" style={{ background: 'linear-gradient(135deg,#1a1a2e,#16213e)', borderColor: 'rgba(99,102,241,.3)' }}>
|
||||
<span className="text-[11px] font-extrabold font-mono" style={{ color: '#818cf8', letterSpacing: '-.5px' }}>B<span style={{ color: '#a78bfa' }}>Sky</span></span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-text-1 font-korean">BlackSky</div>
|
||||
<div className="text-[9px] text-text-3 font-korean mt-px">Maxar Electro-Optical API</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="px-2 py-0.5 rounded-[10px] text-[8px] font-semibold" style={{ background: 'rgba(34,197,94,.1)', border: '1px solid rgba(34,197,94,.2)', color: '#22c55e' }}>API 연결됨</span>
|
||||
<span className="text-base text-text-3">→</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 mb-2.5">
|
||||
{[
|
||||
['유형', 'EO (광학)', '#818cf8'],
|
||||
['해상도', '~1m', 'var(--t1)'],
|
||||
['재방문', '≤1시간', 'var(--t1)'],
|
||||
['납기', '90분 이내', '#22c55e'],
|
||||
].map(([k, v, c], i) => (
|
||||
<div key={i} className="text-center p-1.5 bg-bg-0 rounded-md">
|
||||
<div className="text-[7px] text-text-3 font-korean mb-0.5">{k}</div>
|
||||
<div className="text-[10px] font-bold font-mono" style={{ color: c }}>{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-[9px] text-text-3 font-korean leading-relaxed">고빈도 소형위성 군집 기반 긴급 촬영. 해양 사고 현장 신속 모니터링에 최적화. Dawn-to-Dusk 촬영 가능.</div>
|
||||
<div className="mt-2 text-[8px] text-text-3 font-mono">API: <span style={{ color: '#818cf8' }}>eapi.maxar.com/e1so/rapidoc</span></div>
|
||||
</div>
|
||||
|
||||
{/* UP42 (EO + SAR) */}
|
||||
<div
|
||||
onClick={() => setModalPhase('up42')}
|
||||
className="cursor-pointer bg-bg-2 border border-border rounded-xl p-5 relative overflow-hidden hover:border-[rgba(59,130,246,.5)] hover:bg-[rgba(59,130,246,.04)] transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-[42px] h-[42px] rounded-[10px] flex items-center justify-center border" style={{ background: 'linear-gradient(135deg,#0a1628,#162a50)', borderColor: 'rgba(59,130,246,.3)' }}>
|
||||
<span className="text-[13px] font-extrabold font-mono" style={{ color: '#60a5fa', letterSpacing: '-.5px' }}>up<sup className="text-[7px] align-super">42</sup></span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-text-1 font-korean">UP42 — EO + SAR</div>
|
||||
<div className="text-[9px] text-text-3 font-korean mt-px">Optical · SAR · Elevation 통합 마켓플레이스</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="px-2 py-0.5 rounded-[10px] text-[8px] font-semibold" style={{ background: 'rgba(34,197,94,.1)', border: '1px solid rgba(34,197,94,.2)', color: '#22c55e' }}>API 연결됨</span>
|
||||
<span className="text-base text-text-3">→</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 mb-2.5">
|
||||
{[
|
||||
['유형', 'EO + SAR', '#60a5fa'],
|
||||
['해상도', '0.3~5m', 'var(--t1)'],
|
||||
['위성 수', '16+ 컬렉션', 'var(--t1)'],
|
||||
['야간/악천후', 'SAR 가능', '#22c55e'],
|
||||
].map(([k, v, c], i) => (
|
||||
<div key={i} className="text-center p-1.5 bg-bg-0 rounded-md">
|
||||
<div className="text-[7px] text-text-3 font-korean mb-0.5">{k}</div>
|
||||
<div className="text-[10px] font-bold font-mono" style={{ color: c }}>{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1.5 mb-2.5 flex-wrap">
|
||||
{['Pléiades Neo', 'SPOT 6/7'].map((t, i) => (
|
||||
<span key={i} className="px-1.5 py-px rounded text-[8px] font-korean" style={{ background: 'rgba(59,130,246,.08)', border: '1px solid rgba(59,130,246,.15)', color: '#60a5fa' }}>{t}</span>
|
||||
))}
|
||||
{['TerraSAR-X', 'Capella SAR', 'ICEYE'].map((t, i) => (
|
||||
<span key={i} className="px-1.5 py-px rounded text-[8px] font-korean" style={{ background: 'rgba(6,182,212,.08)', border: '1px solid rgba(6,182,212,.15)', color: 'var(--cyan)' }}>{t}</span>
|
||||
))}
|
||||
<span className="px-1.5 py-px rounded text-[8px] font-korean" style={{ background: 'rgba(139,148,158,.08)', border: '1px solid rgba(139,148,158,.15)', color: 'var(--t3)' }}>+11 more</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-text-3 font-korean leading-relaxed">광학(EO) + 합성개구레이더(SAR) 통합 마켓. 야간·악천후 시 SAR 활용. 다중 위성 소스 자동 최적 선택.</div>
|
||||
<div className="mt-2 text-[8px] text-text-3 font-mono">API: <span style={{ color: '#60a5fa' }}>up42.com</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 */}
|
||||
<div className="px-7 pb-5 flex items-center justify-between">
|
||||
<div className="text-[9px] text-text-3 font-korean leading-relaxed">💡 긴급 촬영: BlackSky 권장 (90분 납기) · 야간/악천후: UP42 SAR 권장</div>
|
||||
<button onClick={() => setModalPhase('none')} className="px-4 py-2 rounded-lg border text-[11px] font-semibold cursor-pointer font-korean" style={{ borderColor: 'var(--bd)', background: 'var(--bg3)', color: 'var(--t2)' }}>닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── BlackSky 긴급 촬영 요청 ── */}
|
||||
{modalPhase === 'blacksky' && (
|
||||
<div className="border rounded-[14px] w-[860px] max-h-[90vh] flex flex-col overflow-hidden" style={{ background: '#0d1117', borderColor: 'rgba(99,102,241,.3)', boxShadow: '0 24px 80px rgba(0,0,0,.7)' }}>
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b flex items-center justify-between shrink-0 relative" style={{ borderColor: '#21262d' }}>
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: 'linear-gradient(90deg,#6366f1,#818cf8,#a78bfa)' }} />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg flex items-center justify-center border" style={{ background: 'linear-gradient(135deg,#1a1a2e,#16213e)', borderColor: 'rgba(99,102,241,.3)' }}>
|
||||
<span className="text-[10px] font-extrabold font-mono" style={{ color: '#818cf8' }}>B<span style={{ color: '#a78bfa' }}>Sky</span></span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[15px] font-bold font-korean" style={{ color: '#e2e8f0' }}>BlackSky — 긴급 위성 촬영 요청</div>
|
||||
<div className="text-[9px] font-korean mt-0.5" style={{ color: '#64748b' }}>Maxar E1SO RapiDoc API · 고빈도 긴급 태스킹</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-3 py-1 rounded-md text-[9px] font-semibold font-korean" style={{ background: 'rgba(99,102,241,.1)', border: '1px solid rgba(99,102,241,.25)', color: '#818cf8' }}>API Docs ↗</span>
|
||||
<button onClick={() => setModalPhase('none')} className="text-lg cursor-pointer p-1 bg-transparent border-none" style={{ color: '#64748b' }}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-4" style={{ scrollbarWidth: 'thin', scrollbarColor: '#21262d transparent' }}>
|
||||
{/* API 상태 */}
|
||||
<div className="flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg" style={{ background: 'rgba(34,197,94,.06)', border: '1px solid rgba(34,197,94,.15)' }}>
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: '#22c55e', boxShadow: '0 0 6px rgba(34,197,94,.5)' }} />
|
||||
<span className="text-[10px] font-semibold font-korean" style={{ color: '#22c55e' }}>API Connected</span>
|
||||
<span className="text-[9px] font-mono" style={{ color: '#64748b' }}>eapi.maxar.com/e1so/rapidoc · Latency: 142ms</span>
|
||||
<span className="ml-auto text-[8px] font-mono" style={{ color: '#64748b' }}>Quota: 47/50 요청 잔여</span>
|
||||
</div>
|
||||
|
||||
{/* ① 태스킹 유형 */}
|
||||
<div>
|
||||
{sectionHeader(1, '태스킹 유형 · 우선순위')}
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>촬영 유형 <span style={{ color: '#f87171' }}>*</span></label>
|
||||
<select className={bsInput} style={bsInputStyle}>
|
||||
<option>긴급 태스킹 (Emergency)</option>
|
||||
<option>표준 태스킹 (Standard)</option>
|
||||
<option>아카이브 검색 (Archive)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>우선순위 <span style={{ color: '#f87171' }}>*</span></label>
|
||||
<select className={bsInput} style={bsInputStyle}>
|
||||
<option>P1 — 긴급 (90분 내)</option>
|
||||
<option>P2 — 높음 (6시간 내)</option>
|
||||
<option>P3 — 보통 (24시간 내)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>촬영 모드</label>
|
||||
<select className={bsInput} style={bsInputStyle}>
|
||||
<option>Single Collect</option>
|
||||
<option>Multi-pass Monitoring</option>
|
||||
<option>Continuous (매 패스)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ② AOI 지정 */}
|
||||
<div>
|
||||
{sectionHeader(2, '관심 영역 (AOI)')}
|
||||
<div className="grid grid-cols-3 gap-2.5 items-end">
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>중심 위도 <span style={{ color: '#f87171' }}>*</span></label>
|
||||
<input type="text" defaultValue="34.5832" className={bsInput} style={bsInputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>중심 경도 <span style={{ color: '#f87171' }}>*</span></label>
|
||||
<input type="text" defaultValue="128.4217" className={bsInput} style={bsInputStyle} />
|
||||
</div>
|
||||
<button className="px-3.5 py-2 rounded-md text-[10px] font-semibold cursor-pointer font-korean whitespace-nowrap" style={{ border: '1px solid rgba(99,102,241,.3)', background: 'rgba(99,102,241,.08)', color: '#818cf8' }}>📍 지도에서 AOI 그리기</button>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2.5">
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>AOI 반경 (km)</label>
|
||||
<input type="number" defaultValue={10} step={1} min={1} className={bsInput} style={bsInputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>최대 구름량 (%)</label>
|
||||
<input type="number" defaultValue={20} step={5} min={0} max={100} className={bsInput} style={bsInputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>최대 Off-nadir (°)</label>
|
||||
<input type="number" defaultValue={25} step={5} min={0} max={45} className={bsInput} style={bsInputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ③ 촬영 기간 */}
|
||||
<div>
|
||||
{sectionHeader(3, '촬영 기간 · 반복')}
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>촬영 시작 <span style={{ color: '#f87171' }}>*</span></label>
|
||||
<input type="datetime-local" defaultValue="2026-02-26T08:00" className={bsInput} style={bsInputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>촬영 종료 <span style={{ color: '#f87171' }}>*</span></label>
|
||||
<input type="datetime-local" defaultValue="2026-02-27T20:00" className={bsInput} style={bsInputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>반복 촬영</label>
|
||||
<select className={bsInput} style={bsInputStyle}>
|
||||
<option>1회 (단일)</option>
|
||||
<option>매 패스 (가용 시)</option>
|
||||
<option>매 6시간</option>
|
||||
<option>매 12시간</option>
|
||||
<option>매일 1회</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ④ 산출물 설정 */}
|
||||
<div>
|
||||
{sectionHeader(4, '산출물 설정')}
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>산출물 형식 <span style={{ color: '#f87171' }}>*</span></label>
|
||||
<select className={bsInput} style={bsInputStyle}>
|
||||
<option>Ortho-Rectified (정사보정)</option>
|
||||
<option>Pan-sharpened (팬샤프닝)</option>
|
||||
<option>Basic L1 (원본)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>파일 포맷</label>
|
||||
<select className={bsInput} style={bsInputStyle}>
|
||||
<option>GeoTIFF</option>
|
||||
<option>NITF</option>
|
||||
<option>JPEG2000</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-3">
|
||||
{[
|
||||
{ label: '유출유 탐지 분석 (자동)', checked: true },
|
||||
{ label: 'GIS 상황판 자동 오버레이', checked: true },
|
||||
{ label: '변화탐지 (Change Detection)', checked: false },
|
||||
{ label: '웹훅 알림', checked: false },
|
||||
].map((opt, i) => (
|
||||
<label key={i} className="flex items-center gap-1 text-[9px] cursor-pointer font-korean" style={{ color: '#94a3b8' }}>
|
||||
<input type="checkbox" defaultChecked={opt.checked} style={{ accentColor: '#818cf8', transform: 'scale(.85)' }} /> {opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ⑤ 연계 사고 · 비고 */}
|
||||
<div>
|
||||
{sectionHeader(5, '연계 사고 · 비고')}
|
||||
<div className="grid grid-cols-2 gap-2.5 mb-2">
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>연계 사고번호</label>
|
||||
<select className={bsInput} style={bsInputStyle}>
|
||||
<option>OIL-2024-0892 · M/V STELLAR DAISY</option>
|
||||
<option>HNS-2024-041 · 울산 온산항 톨루엔 유출</option>
|
||||
<option>RSC-2024-0127 · M/V SEA GUARDIAN</option>
|
||||
<option value="">연계 없음</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>요청자</label>
|
||||
<input type="text" placeholder="소속 / 이름" className={bsInput} style={bsInputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
placeholder="촬영 요청 목적, 특이사항, 관심 대상 등을 기록합니다..."
|
||||
className="w-full h-[50px] px-3 py-2.5 rounded-md text-[10px] font-korean outline-none resize-y leading-relaxed box-border"
|
||||
style={{ border: '1px solid #21262d', background: '#161b22', color: '#e2e8f0' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="px-6 py-3.5 border-t flex items-center gap-2 shrink-0" style={{ borderColor: '#21262d' }}>
|
||||
<div className="flex-1 text-[9px] font-korean leading-relaxed" style={{ color: '#64748b' }}>
|
||||
<span style={{ color: '#f87171' }}>*</span> 필수 항목 · 긴급 태스킹은 P1 우선순위로 90분 내 최초 영상 수신
|
||||
</div>
|
||||
<button onClick={() => setModalPhase('provider')} className="px-5 py-2.5 rounded-lg border text-xs font-semibold cursor-pointer font-korean" style={{ borderColor: '#21262d', background: '#161b22', color: '#94a3b8' }}>← 뒤로</button>
|
||||
<button onClick={() => setModalPhase('none')} className="px-7 py-2.5 rounded-lg border-none text-xs font-bold cursor-pointer font-korean text-white" style={{ background: 'linear-gradient(135deg,#6366f1,#818cf8)', boxShadow: '0 4px 16px rgba(99,102,241,.35)' }}>🛰 BlackSky 촬영 요청 제출</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── UP42 카탈로그 주문 ── */}
|
||||
{modalPhase === 'up42' && (
|
||||
<div className="border rounded-[14px] w-[920px] max-h-[90vh] flex flex-col overflow-hidden" style={{ background: '#0d1117', borderColor: 'rgba(59,130,246,.3)', boxShadow: '0 24px 80px rgba(0,0,0,.7)' }}>
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b flex items-center justify-between shrink-0 relative" style={{ borderColor: '#21262d' }}>
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: 'linear-gradient(90deg,#3b82f6,#06b6d4,#22c55e)' }} />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg flex items-center justify-center border" style={{ background: 'linear-gradient(135deg,#0a1628,#162a50)', borderColor: 'rgba(59,130,246,.3)' }}>
|
||||
<span className="text-[13px] font-extrabold font-mono" style={{ color: '#60a5fa', letterSpacing: '-.5px' }}>up<sup className="text-[7px] align-super">42</sup></span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[15px] font-bold font-korean" style={{ color: '#e2e8f0' }}>위성 촬영 요청 — 새 태스킹 주문</div>
|
||||
<div className="text-[9px] font-korean mt-0.5" style={{ color: '#64748b' }}>관심 지역(AOI)을 그리고 위성 패스를 선택하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-3 py-1 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(234,179,8,.1)', border: '1px solid rgba(234,179,8,.25)', color: '#eab308' }}>⚠ Beijing-3N 납기 지연 2.15–2.23</span>
|
||||
<button onClick={() => setModalPhase('none')} className="text-lg cursor-pointer p-1 bg-transparent border-none" style={{ color: '#64748b' }}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 (좌: 사이드바, 우: 지도+AOI) */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* 왼쪽: 위성 카탈로그 */}
|
||||
<div className="flex flex-col overflow-hidden border-r" style={{ width: 320, minWidth: 320, borderColor: '#21262d', background: '#0d1117' }}>
|
||||
{/* Optical / SAR / Elevation 탭 */}
|
||||
<div className="flex border-b shrink-0" style={{ borderColor: '#21262d' }}>
|
||||
{(['optical', 'sar', 'elevation'] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setUp42SubTab(t)}
|
||||
className="flex-1 py-2 text-[10px] font-bold cursor-pointer border-none font-korean transition-colors"
|
||||
style={up42SubTab === t
|
||||
? { background: 'rgba(59,130,246,.1)', color: '#60a5fa', borderBottom: '2px solid #3b82f6' }
|
||||
: { background: 'transparent', color: '#64748b' }
|
||||
}
|
||||
>{t === 'optical' ? 'Optical' : t === 'sar' ? 'SAR' : 'Elevation'}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 필터 바 */}
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b shrink-0" style={{ borderColor: '#21262d' }}>
|
||||
<span className="px-2 py-0.5 rounded text-[9px] font-semibold" style={{ background: 'rgba(59,130,246,.1)', color: '#60a5fa', border: '1px solid rgba(59,130,246,.2)' }}>Filters ✎</span>
|
||||
<span className="px-2 py-0.5 rounded text-[9px] font-semibold" style={{ background: 'rgba(99,102,241,.1)', color: '#818cf8', border: '1px solid rgba(99,102,241,.2)' }}>☁ 구름 ≤ 20% ✕</span>
|
||||
<span className="ml-auto text-[9px] font-mono" style={{ color: '#64748b' }}>↕ 해상도 우선</span>
|
||||
</div>
|
||||
|
||||
{/* 컬렉션 수 */}
|
||||
<div className="px-3 py-1.5 border-b text-[9px] font-korean shrink-0" style={{ borderColor: '#21262d', color: '#64748b' }}>
|
||||
이 지역에서 <b style={{ color: '#e2e8f0' }}>{up42Filtered.length}</b>개 컬렉션 사용 가능
|
||||
</div>
|
||||
|
||||
{/* 위성 목록 */}
|
||||
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: '#21262d transparent' }}>
|
||||
{up42Filtered.map(sat => (
|
||||
<div
|
||||
key={sat.id}
|
||||
onClick={() => setUp42SelSat(up42SelSat === sat.id ? null : sat.id)}
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 border-b cursor-pointer transition-colors"
|
||||
style={{
|
||||
borderColor: '#161b22',
|
||||
background: up42SelSat === sat.id ? 'rgba(59,130,246,.08)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<div className="w-1 h-8 rounded-full shrink-0" style={{ background: sat.color }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[11px] font-semibold truncate font-korean" style={{ color: '#e2e8f0' }}>{sat.name}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[9px] font-bold font-mono" style={{ color: sat.color }}>{sat.res}</span>
|
||||
{sat.cloud > 0 && <span className="text-[8px] font-mono" style={{ color: '#64748b' }}>☁ ≤{sat.cloud}%</span>}
|
||||
{'delay' in sat && sat.delay && <span className="text-[8px] font-bold" style={{ color: '#eab308' }}>⚠ 지연</span>}
|
||||
</div>
|
||||
</div>
|
||||
{up42SelSat === sat.id && <span className="text-[12px]" style={{ color: '#3b82f6' }}>✓</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 지도 + AOI + 패스 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
{/* 지도 영역 (placeholder) */}
|
||||
<div className="flex-1 relative" style={{ background: '#0a0e18' }}>
|
||||
{/* 검색바 */}
|
||||
<div className="absolute top-3 left-3 right-3 flex items-center gap-2 px-3 py-2 rounded-lg z-10" style={{ background: 'rgba(13,17,23,.9)', border: '1px solid #21262d', backdropFilter: 'blur(8px)' }}>
|
||||
<span style={{ color: '#8690a6', fontSize: 13 }}>🔍</span>
|
||||
<input type="text" placeholder="위치 또는 좌표 입력..." className="flex-1 bg-transparent border-none outline-none text-[11px] font-korean" style={{ color: '#e2e8f0' }} />
|
||||
</div>
|
||||
|
||||
{/* 지도 placeholder */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl mb-2 opacity-20">🗺</div>
|
||||
<div className="text-[11px] font-korean opacity-40" style={{ color: '#64748b' }}>지도 영역 — AOI를 그려 위성 패스를 확인하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AOI 도구 버튼 (오른쪽 사이드) */}
|
||||
<div className="absolute top-14 right-3 flex flex-col gap-1 p-1.5 rounded-lg z-10" style={{ background: 'rgba(13,17,23,.9)', border: '1px solid #21262d' }}>
|
||||
<div className="text-[7px] font-bold text-center mb-0.5" style={{ color: '#64748b' }}>ADD</div>
|
||||
{[
|
||||
{ icon: '⬜', title: '사각형 AOI' },
|
||||
{ icon: '🔷', title: '다각형 AOI' },
|
||||
{ icon: '⭕', title: '원형 AOI' },
|
||||
{ icon: '📁', title: '파일 업로드' },
|
||||
].map((t, i) => (
|
||||
<button key={i} className="w-7 h-7 flex items-center justify-center rounded text-sm cursor-pointer border-none" style={{ background: '#161b22', color: '#8690a6' }} title={t.title}>{t.icon}</button>
|
||||
))}
|
||||
<div className="h-px my-0.5" style={{ background: '#21262d' }} />
|
||||
<div className="text-[7px] font-bold text-center mb-0.5" style={{ color: '#64748b' }}>AOI</div>
|
||||
<button className="w-7 h-7 flex items-center justify-center rounded text-sm cursor-pointer border-none" style={{ background: '#161b22', color: '#8690a6' }} title="저장된 AOI">💾</button>
|
||||
<button className="w-7 h-7 flex items-center justify-center rounded text-sm cursor-pointer border-none" style={{ background: '#161b22', color: '#ef4444' }} title="AOI 삭제">🗑</button>
|
||||
</div>
|
||||
|
||||
{/* 줌 컨트롤 */}
|
||||
<div className="absolute bottom-3 right-3 flex flex-col rounded-md overflow-hidden z-10" style={{ border: '1px solid #21262d' }}>
|
||||
<button className="w-7 h-7 flex items-center justify-center text-sm cursor-pointer border-none" style={{ background: '#161b22', color: '#8690a6' }}>+</button>
|
||||
<button className="w-7 h-7 flex items-center justify-center text-sm cursor-pointer border-none border-t" style={{ background: '#161b22', color: '#8690a6', borderTopColor: '#21262d' }}>−</button>
|
||||
</div>
|
||||
|
||||
{/* 이 지역 검색 버튼 */}
|
||||
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 z-10">
|
||||
<button className="px-4 py-2 rounded-full text-[10px] font-semibold cursor-pointer font-korean" style={{ background: 'rgba(59,130,246,.9)', color: '#fff', border: 'none', boxShadow: '0 2px 12px rgba(59,130,246,.3)' }}>🔍 이 지역 검색</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 위성 패스 타임라인 */}
|
||||
<div className="border-t px-4 py-3 shrink-0" style={{ borderColor: '#21262d', background: 'rgba(13,17,23,.95)' }}>
|
||||
<div className="text-[10px] font-bold font-korean mb-2" style={{ color: '#e2e8f0' }}>🛰 오늘 가용 위성 패스 — 선택된 AOI 통과 예정</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{up42Passes.map((p, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setUp42SelPass(up42SelPass === i ? null : i)}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: up42SelPass === i ? 'rgba(59,130,246,.1)' : '#161b22',
|
||||
border: up42SelPass === i ? '1px solid rgba(59,130,246,.3)' : '1px solid #21262d',
|
||||
}}
|
||||
>
|
||||
<div className="w-1.5 h-5 rounded-full shrink-0" style={{ background: p.color }} />
|
||||
<div className="flex-1 flex items-center gap-3 min-w-0">
|
||||
<span className="text-[10px] font-bold font-korean min-w-[100px]" style={{ color: '#e2e8f0' }}>{p.sat}</span>
|
||||
<span className="text-[9px] font-bold font-mono min-w-[110px]" style={{ color: '#60a5fa' }}>{p.time}</span>
|
||||
<span className="text-[9px] font-mono" style={{ color: '#06b6d4' }}>{p.res}</span>
|
||||
<span className="text-[8px] font-mono" style={{ color: '#64748b' }}>{p.cloud}</span>
|
||||
</div>
|
||||
{p.note && (
|
||||
<span className="px-1.5 py-px rounded text-[8px] font-bold shrink-0" style={{
|
||||
background: p.note === '최우선 추천' ? 'rgba(34,197,94,.1)' : p.note === '초고해상도' ? 'rgba(6,182,212,.1)' : p.note === 'SAR' ? 'rgba(245,158,11,.1)' : 'rgba(99,102,241,.1)',
|
||||
color: p.note === '최우선 추천' ? '#22c55e' : p.note === '초고해상도' ? '#06b6d4' : p.note === 'SAR' ? '#f59e0b' : '#818cf8',
|
||||
}}>{p.note}</span>
|
||||
)}
|
||||
{up42SelPass === i && <span className="text-xs" style={{ color: '#3b82f6' }}>✓</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="px-6 py-3 border-t flex items-center justify-between shrink-0" style={{ borderColor: '#21262d' }}>
|
||||
<div className="text-[9px] font-korean" style={{ color: '#64748b' }}>원하는 위성을 찾지 못했나요? <span style={{ color: '#60a5fa', cursor: 'pointer' }}>태스킹 주문 생성</span> 또는 <span style={{ color: '#60a5fa', cursor: 'pointer' }}>자세히 보기 ↗</span></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-korean mr-1.5" style={{ color: '#8690a6' }}>
|
||||
선택: {up42SelSat ? up42Satellites.find(s => s.id === up42SelSat)?.name : '없음'}
|
||||
</span>
|
||||
<button onClick={() => setModalPhase('provider')} className="px-4 py-2 rounded-lg border text-[11px] font-semibold cursor-pointer font-korean" style={{ borderColor: '#21262d', background: '#161b22', color: '#94a3b8' }}>← 뒤로</button>
|
||||
<button
|
||||
onClick={() => setModalPhase('none')}
|
||||
className="px-6 py-2 rounded-lg border-none text-[11px] font-bold cursor-pointer font-korean text-white transition-opacity"
|
||||
style={{
|
||||
background: up42SelSat ? 'linear-gradient(135deg,#3b82f6,#06b6d4)' : '#21262d',
|
||||
opacity: up42SelSat ? 1 : 0.5,
|
||||
color: up42SelSat ? '#fff' : '#64748b',
|
||||
boxShadow: up42SelSat ? '0 4px 16px rgba(59,130,246,.35)' : 'none',
|
||||
}}
|
||||
>🛰 촬영 요청 제출</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
497
frontend/src/tabs/aerial/components/SensorAnalysis.tsx
Normal file
497
frontend/src/tabs/aerial/components/SensorAnalysis.tsx
Normal file
@ -0,0 +1,497 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ReconItem {
|
||||
id: string
|
||||
name: string
|
||||
type: 'vessel' | 'pollution'
|
||||
status: 'complete' | 'processing'
|
||||
points: string
|
||||
polygons: string
|
||||
coverage: string
|
||||
}
|
||||
|
||||
const reconItems: ReconItem[] = [
|
||||
{ id: 'V-001', name: '불명선박-A', type: 'vessel', status: 'complete', points: '980K', polygons: '38K', coverage: '97.1%' },
|
||||
{ id: 'V-002', name: '불명선박-B', type: 'vessel', status: 'complete', points: '1.2M', polygons: '48K', coverage: '98.4%' },
|
||||
{ id: 'V-003', name: '어선 #37', type: 'vessel', status: 'processing', points: '420K', polygons: '16K', coverage: '64.2%' },
|
||||
{ id: 'P-001', name: '유류오염-A', type: 'pollution', status: 'complete', points: '560K', polygons: '22K', coverage: '95.8%' },
|
||||
{ id: 'P-002', name: '유류오염-B', type: 'pollution', status: 'processing', points: '310K', polygons: '12K', coverage: '52.1%' },
|
||||
]
|
||||
|
||||
function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string }) {
|
||||
const isProcessing = status === 'processing'
|
||||
const isWire = viewMode === 'wire'
|
||||
const isPoint = viewMode === 'point'
|
||||
|
||||
const [vesselPoints] = useState(() =>
|
||||
Array.from({ length: 300 }, (_, i) => {
|
||||
const x = 35 + Math.random() * 355
|
||||
const y = 15 + Math.random() * 160
|
||||
const inHull = y > 60 && y < 175 && x > 35 && x < 390
|
||||
const inBridge = x > 260 && x < 330 && y > 25 && y < 60
|
||||
if (!inHull && !inBridge && Math.random() > 0.15) return null
|
||||
const alpha = 0.15 + Math.random() * 0.55
|
||||
const r = 0.8 + Math.random() * 0.8
|
||||
return { i, x, y, r, alpha }
|
||||
})
|
||||
)
|
||||
|
||||
// 선박 SVG 와이어프레임/솔리드 3D 투시
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center" style={{ perspective: '800px' }}>
|
||||
<div style={{ transform: 'rotateX(15deg) rotateY(-25deg) rotateZ(2deg)', transformStyle: 'preserve-3d', position: 'relative', width: '420px', height: '200px' }}>
|
||||
<svg viewBox="0 0 420 200" width="420" height="200" style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }}>
|
||||
{/* 수선 (waterline) */}
|
||||
<ellipse cx="210" cy="165" rx="200" ry="12" fill="none" stroke="rgba(6,182,212,0.15)" strokeWidth="0.5" strokeDasharray="4 2" />
|
||||
|
||||
{/* 선체 (hull) - 3D 효과 */}
|
||||
<path d="M 30 140 Q 40 170 100 175 L 320 175 Q 380 170 395 140 L 390 100 Q 385 85 370 80 L 50 80 Q 35 85 30 100 Z"
|
||||
fill={isWire || isPoint ? 'none' : 'rgba(6,182,212,0.08)'}
|
||||
stroke={isProcessing ? 'rgba(6,182,212,0.2)' : 'rgba(6,182,212,0.5)'}
|
||||
strokeWidth={isWire ? '0.8' : '1.2'} />
|
||||
|
||||
{/* 선체 하부 */}
|
||||
<path d="M 30 140 Q 20 155 60 168 L 100 175 M 395 140 Q 405 155 360 168 L 320 175"
|
||||
fill="none" stroke="rgba(6,182,212,0.3)" strokeWidth="0.7" />
|
||||
|
||||
{/* 갑판 (deck) */}
|
||||
<path d="M 50 80 Q 45 65 55 60 L 365 60 Q 375 65 370 80"
|
||||
fill={isWire || isPoint ? 'none' : 'rgba(6,182,212,0.05)'}
|
||||
stroke={isProcessing ? 'rgba(6,182,212,0.15)' : 'rgba(6,182,212,0.45)'}
|
||||
strokeWidth={isWire ? '0.8' : '1'} />
|
||||
|
||||
{/* 선교 (bridge) */}
|
||||
<rect x="260" y="25" width="70" height="35" rx="2"
|
||||
fill={isWire || isPoint ? 'none' : 'rgba(6,182,212,0.1)'}
|
||||
stroke={isProcessing ? 'rgba(6,182,212,0.15)' : 'rgba(6,182,212,0.5)'}
|
||||
strokeWidth={isWire ? '0.8' : '1'} />
|
||||
{/* 선교 창문 */}
|
||||
{!isPoint && <g stroke="rgba(6,182,212,0.3)" strokeWidth="0.5" fill="none">
|
||||
<rect x="268" y="30" width="10" height="6" rx="1" />
|
||||
<rect x="282" y="30" width="10" height="6" rx="1" />
|
||||
<rect x="296" y="30" width="10" height="6" rx="1" />
|
||||
<rect x="310" y="30" width="10" height="6" rx="1" />
|
||||
</g>}
|
||||
|
||||
{/* 마스트 */}
|
||||
<line x1="295" y1="25" x2="295" y2="8" stroke="rgba(6,182,212,0.4)" strokeWidth="1" />
|
||||
<line x1="288" y1="12" x2="302" y2="12" stroke="rgba(6,182,212,0.3)" strokeWidth="0.8" />
|
||||
|
||||
{/* 연통 (funnel) */}
|
||||
<rect x="235" y="38" width="18" height="22" rx="1"
|
||||
fill={isWire || isPoint ? 'none' : 'rgba(239,68,68,0.1)'}
|
||||
stroke={isProcessing ? 'rgba(239,68,68,0.15)' : 'rgba(239,68,68,0.4)'}
|
||||
strokeWidth={isWire ? '0.8' : '1'} />
|
||||
|
||||
{/* 화물 크레인 */}
|
||||
<g stroke={isProcessing ? 'rgba(249,115,22,0.15)' : 'rgba(249,115,22,0.4)'} strokeWidth="0.8" fill="none">
|
||||
<line x1="150" y1="60" x2="150" y2="20" />
|
||||
<line x1="150" y1="22" x2="120" y2="40" />
|
||||
<line x1="180" y1="60" x2="180" y2="25" />
|
||||
<line x1="180" y1="27" x2="155" y2="42" />
|
||||
</g>
|
||||
|
||||
{/* 선체 리브 (와이어프레임 / 포인트 모드) */}
|
||||
{(isWire || isPoint) && <g stroke="rgba(6,182,212,0.15)" strokeWidth="0.4">
|
||||
{[80, 120, 160, 200, 240, 280, 320, 360].map(x => (
|
||||
<line key={x} x1={x} y1="60" x2={x} y2="175" />
|
||||
))}
|
||||
{[80, 100, 120, 140, 160].map(y => (
|
||||
<line key={y} x1="30" y1={y} x2="395" y2={y} />
|
||||
))}
|
||||
</g>}
|
||||
|
||||
{/* 포인트 클라우드 모드 */}
|
||||
{isPoint && <g>
|
||||
{vesselPoints.map(p => p && (
|
||||
<circle key={p.i} cx={p.x} cy={p.y} r={p.r} fill={`rgba(6,182,212,${p.alpha})`} />
|
||||
))}
|
||||
</g>}
|
||||
|
||||
{/* 선수/선미 표시 */}
|
||||
<text x="395" y="95" fill="rgba(6,182,212,0.3)" fontSize="8" fontFamily="var(--fM)">선수</text>
|
||||
<text x="15" y="95" fill="rgba(6,182,212,0.3)" fontSize="8" fontFamily="var(--fM)">선미</text>
|
||||
|
||||
{/* 측정선 (3D 모드) */}
|
||||
{viewMode === '3d' && <>
|
||||
<line x1="30" y1="185" x2="395" y2="185" stroke="rgba(34,197,94,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
|
||||
<text x="200" y="195" fill="rgba(34,197,94,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="middle">84.7m</text>
|
||||
<line x1="405" y1="60" x2="405" y2="175" stroke="rgba(249,115,22,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
|
||||
<text x="415" y="120" fill="rgba(249,115,22,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="start" transform="rotate(90, 415, 120)">14.2m</text>
|
||||
</>}
|
||||
</svg>
|
||||
|
||||
{/* 처리중 오버레이 */}
|
||||
{isProcessing && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-primary-cyan/40 text-xs font-mono animate-pulse">재구성 처리중...</div>
|
||||
<div className="w-24 h-0.5 bg-bg-3 rounded-full mt-2 mx-auto overflow-hidden">
|
||||
<div className="h-full bg-primary-cyan/40 rounded-full" style={{ width: '64%', animation: 'pulse 2s infinite' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Pollution3DModel({ viewMode, status }: { viewMode: string; status: string }) {
|
||||
const isProcessing = status === 'processing'
|
||||
const isWire = viewMode === 'wire'
|
||||
const isPoint = viewMode === 'point'
|
||||
|
||||
const [pollutionPoints] = useState(() =>
|
||||
Array.from({ length: 400 }, (_, i) => {
|
||||
const cx = 190, cy = 145, rx = 130, ry = 75
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const r = Math.sqrt(Math.random())
|
||||
const x = cx + r * rx * Math.cos(angle)
|
||||
const y = cy + r * ry * Math.sin(angle)
|
||||
if (x < 40 || x > 340 || y < 50 || y > 230) return null
|
||||
const dist = Math.sqrt(((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2)
|
||||
const intensity = Math.max(0.1, 1 - dist)
|
||||
const color = dist < 0.4 ? `rgba(239,68,68,${intensity * 0.7})` : dist < 0.7 ? `rgba(249,115,22,${intensity * 0.5})` : `rgba(234,179,8,${intensity * 0.3})`
|
||||
const circleR = 0.6 + Math.random() * 1.2
|
||||
return { i, x, y, r: circleR, color }
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center" style={{ perspective: '800px' }}>
|
||||
<div style={{ transform: 'rotateX(40deg) rotateY(-10deg)', transformStyle: 'preserve-3d', position: 'relative', width: '380px', height: '260px' }}>
|
||||
<svg viewBox="0 0 380 260" width="380" height="260" style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }}>
|
||||
{/* 해수면 그리드 */}
|
||||
<g stroke="rgba(6,182,212,0.08)" strokeWidth="0.4">
|
||||
{Array.from({ length: 15 }, (_, i) => <line key={`h${i}`} x1="0" y1={i * 20} x2="380" y2={i * 20} />)}
|
||||
{Array.from({ length: 20 }, (_, i) => <line key={`v${i}`} x1={i * 20} y1="0" x2={i * 20} y2="260" />)}
|
||||
</g>
|
||||
|
||||
{/* 유막 메인 형태 - 불규칙 blob */}
|
||||
<path d="M 120 80 Q 80 90 70 120 Q 55 155 80 180 Q 100 205 140 210 Q 180 220 220 205 Q 270 195 300 170 Q 320 145 310 115 Q 300 85 270 75 Q 240 65 200 70 Q 160 68 120 80 Z"
|
||||
fill={isWire || isPoint ? 'none' : 'rgba(239,68,68,0.08)'}
|
||||
stroke={isProcessing ? 'rgba(239,68,68,0.15)' : 'rgba(239,68,68,0.45)'}
|
||||
strokeWidth={isWire ? '0.8' : '1.5'} />
|
||||
|
||||
{/* 유막 두께 등고선 */}
|
||||
<path d="M 155 100 Q 125 115 120 140 Q 115 165 135 180 Q 155 195 190 190 Q 230 185 255 165 Q 270 145 260 120 Q 250 100 225 95 Q 195 88 155 100 Z"
|
||||
fill={isWire || isPoint ? 'none' : 'rgba(249,115,22,0.08)'}
|
||||
stroke={isProcessing ? 'rgba(249,115,22,0.12)' : 'rgba(249,115,22,0.35)'}
|
||||
strokeWidth="0.8" strokeDasharray={isWire ? '4 2' : 'none'} />
|
||||
|
||||
{/* 유막 최고 두께 핵심 */}
|
||||
<path d="M 175 120 Q 160 130 165 150 Q 170 170 195 170 Q 220 168 230 150 Q 235 130 220 120 Q 205 110 175 120 Z"
|
||||
fill={isWire || isPoint ? 'none' : 'rgba(239,68,68,0.15)'}
|
||||
stroke={isProcessing ? 'rgba(239,68,68,0.15)' : 'rgba(239,68,68,0.5)'}
|
||||
strokeWidth="0.8" />
|
||||
|
||||
{/* 확산 방향 화살표 */}
|
||||
<g stroke="rgba(249,115,22,0.5)" strokeWidth="1" fill="rgba(249,115,22,0.5)">
|
||||
<line x1="250" y1="140" x2="330" y2="120" />
|
||||
<polygon points="330,120 322,115 324,123" />
|
||||
<text x="335" y="122" fill="rgba(249,115,22,0.6)" fontSize="8" fontFamily="var(--fM)">ESE 0.3km/h</text>
|
||||
</g>
|
||||
|
||||
{/* 와이어프레임 추가 등고선 */}
|
||||
{(isWire || isPoint) && <g stroke="rgba(239,68,68,0.12)" strokeWidth="0.3">
|
||||
<ellipse cx="190" cy="145" rx="140" ry="80" fill="none" />
|
||||
<ellipse cx="190" cy="145" rx="100" ry="55" fill="none" />
|
||||
<ellipse cx="190" cy="145" rx="60" ry="35" fill="none" />
|
||||
</g>}
|
||||
|
||||
{/* 포인트 클라우드 */}
|
||||
{isPoint && <g>
|
||||
{pollutionPoints.map(p => p && (
|
||||
<circle key={p.i} cx={p.x} cy={p.y} r={p.r} fill={p.color} />
|
||||
))}
|
||||
</g>}
|
||||
|
||||
{/* 두께 색상 범례 */}
|
||||
{viewMode === '3d' && <>
|
||||
<text x="165" y="148" fill="rgba(239,68,68,0.7)" fontSize="7" fontFamily="var(--fM)" textAnchor="middle">3.2mm</text>
|
||||
<text x="130" y="165" fill="rgba(249,115,22,0.5)" fontSize="7" fontFamily="var(--fM)" textAnchor="middle">1.5mm</text>
|
||||
<text x="95" y="130" fill="rgba(234,179,8,0.4)" fontSize="7" fontFamily="var(--fM)" textAnchor="middle">0.3mm</text>
|
||||
</>}
|
||||
|
||||
{/* 측정선 (3D 모드) */}
|
||||
{viewMode === '3d' && <>
|
||||
<line x1="55" y1="240" x2="320" y2="240" stroke="rgba(34,197,94,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
|
||||
<text x="187" y="252" fill="rgba(34,197,94,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="middle">1.24 km</text>
|
||||
<line x1="25" y1="80" x2="25" y2="210" stroke="rgba(59,130,246,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
|
||||
<text x="15" y="150" fill="rgba(59,130,246,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="middle" transform="rotate(-90, 15, 150)">0.68 km</text>
|
||||
</>}
|
||||
</svg>
|
||||
|
||||
{/* 두께 색상 범례 바 */}
|
||||
{viewMode === '3d' && !isProcessing && (
|
||||
<div className="absolute bottom-0 right-2 flex items-center gap-1" style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fM)' }}>
|
||||
<span>0mm</span>
|
||||
<div style={{ width: '60px', height: '4px', borderRadius: '2px', background: 'linear-gradient(90deg, rgba(234,179,8,0.6), rgba(249,115,22,0.7), rgba(239,68,68,0.8))' }} />
|
||||
<span>3.2mm</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isProcessing && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-status-red/40 text-xs font-mono animate-pulse">재구성 처리중...</div>
|
||||
<div className="w-24 h-0.5 bg-bg-3 rounded-full mt-2 mx-auto overflow-hidden">
|
||||
<div className="h-full bg-status-red/40 rounded-full" style={{ width: '52%', animation: 'pulse 2s infinite' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SensorAnalysis() {
|
||||
const [subTab, setSubTab] = useState<'vessel' | 'pollution'>('vessel')
|
||||
const [viewMode, setViewMode] = useState('3d')
|
||||
const [selectedItem, setSelectedItem] = useState<ReconItem>(reconItems[1])
|
||||
|
||||
const filteredItems = reconItems.filter(r => r.type === (subTab === 'vessel' ? 'vessel' : 'pollution'))
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
|
||||
{/* Left Panel */}
|
||||
<div className="w-[280px] bg-bg-1 border-r border-border flex flex-col overflow-auto">
|
||||
{/* 3D Reconstruction List */}
|
||||
<div className="p-2.5 px-3 border-b border-border">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">📋 3D 재구성 완료 목록</div>
|
||||
<div className="flex gap-1 mb-2">
|
||||
<button
|
||||
onClick={() => setSubTab('vessel')}
|
||||
className={`flex-1 py-1.5 text-center text-[9px] font-semibold rounded cursor-pointer border transition-colors font-korean ${
|
||||
subTab === 'vessel'
|
||||
? 'text-primary-cyan bg-[rgba(6,182,212,0.08)] border-primary-cyan/20'
|
||||
: 'text-text-3 bg-bg-0 border-border'
|
||||
}`}
|
||||
>
|
||||
🚢 선박
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSubTab('pollution')}
|
||||
className={`flex-1 py-1.5 text-center text-[9px] font-semibold rounded cursor-pointer border transition-colors font-korean ${
|
||||
subTab === 'pollution'
|
||||
? 'text-primary-cyan bg-[rgba(6,182,212,0.08)] border-primary-cyan/20'
|
||||
: 'text-text-3 bg-bg-0 border-border'
|
||||
}`}
|
||||
>
|
||||
🛢️ 오염원
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{filteredItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
className={`flex items-center gap-2 px-2 py-2 rounded-sm cursor-pointer transition-colors border ${
|
||||
selectedItem.id === item.id
|
||||
? 'bg-[rgba(6,182,212,0.08)] border-primary-cyan/20'
|
||||
: 'border-transparent hover:bg-white/[0.02]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] font-bold text-text-1 font-korean">{item.name}</div>
|
||||
<div className="text-[8px] text-text-3 font-mono">{item.id} · {item.points} pts</div>
|
||||
</div>
|
||||
<span className={`text-[8px] font-semibold ${item.status === 'complete' ? 'text-status-green' : 'text-status-orange'}`}>
|
||||
{item.status === 'complete' ? '✅ 완료' : '⏳ 처리중'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Images */}
|
||||
<div className="p-2.5 px-3 flex-1 min-h-0 flex flex-col">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">📹 촬영 원본</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{[
|
||||
{ label: 'D-01 정면', sensor: '광학', color: 'text-primary-blue' },
|
||||
{ label: 'D-02 좌현', sensor: 'IR', color: 'text-status-red' },
|
||||
{ label: 'D-03 우현', sensor: '광학', color: 'text-primary-purple' },
|
||||
{ label: 'D-02 상부', sensor: 'IR', color: 'text-status-red' },
|
||||
].map((src, i) => (
|
||||
<div key={i} className="relative rounded-sm bg-bg-0 border border-border overflow-hidden aspect-square">
|
||||
<div className="absolute inset-0 flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #0c1624, #1a1a2e)' }}>
|
||||
<div className="text-text-3/10 text-xs font-mono">{src.label.split(' ')[0]}</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1.5 py-1 flex justify-between text-[8px] text-text-3 font-korean">
|
||||
<span>{src.label}</span>
|
||||
<span className={src.color}>{src.sensor}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center Panel - 3D Canvas */}
|
||||
<div className="flex-1 relative bg-bg-0 border-x border-border flex items-center justify-center overflow-hidden">
|
||||
{/* Simulated 3D viewport */}
|
||||
<div className="absolute inset-0" style={{ background: 'radial-gradient(ellipse at 50% 50%, #0c1a2e, #060c18)' }}>
|
||||
{/* Grid floor */}
|
||||
<div className="absolute inset-0 opacity-[0.06]" style={{ backgroundImage: 'linear-gradient(rgba(6,182,212,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(6,182,212,0.5) 1px, transparent 1px)', backgroundSize: '40px 40px', transform: 'perspective(500px) rotateX(55deg)', transformOrigin: 'center 80%' }} />
|
||||
|
||||
{/* 3D Model Visualization */}
|
||||
{selectedItem.type === 'vessel' ? (
|
||||
<Vessel3DModel viewMode={viewMode} status={selectedItem.status} />
|
||||
) : (
|
||||
<Pollution3DModel viewMode={viewMode} status={selectedItem.status} />
|
||||
)}
|
||||
|
||||
{/* Axis indicator */}
|
||||
<div className="absolute bottom-16 left-4" style={{ fontSize: '9px', fontFamily: 'var(--fM)' }}>
|
||||
<div style={{ color: '#ef4444' }}>X →</div>
|
||||
<div style={{ color: '#22c55e' }}>Y ↑</div>
|
||||
<div style={{ color: '#3b82f6' }}>Z ⊙</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="absolute top-3 left-3 z-[2]">
|
||||
<div className="text-[10px] font-bold text-text-3 uppercase tracking-wider">3D Vessel Analysis</div>
|
||||
<div className="text-[13px] font-bold text-primary-cyan my-1 font-korean">{selectedItem.name} 정밀분석</div>
|
||||
<div className="text-[9px] text-text-3 font-mono">34.58°N, 129.30°E · {selectedItem.status === 'complete' ? '재구성 완료' : '처리중'}</div>
|
||||
</div>
|
||||
|
||||
{/* View Mode Buttons */}
|
||||
<div className="absolute top-3 right-3 flex gap-1 z-[2]">
|
||||
{[
|
||||
{ id: '3d', label: '3D모델' },
|
||||
{ id: 'point', label: '포인트클라우드' },
|
||||
{ id: 'wire', label: '와이어프레임' },
|
||||
].map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => setViewMode(m.id)}
|
||||
className={`px-2.5 py-1.5 text-[10px] font-semibold rounded-sm cursor-pointer border font-korean transition-colors ${
|
||||
viewMode === m.id
|
||||
? 'bg-[rgba(6,182,212,0.2)] border-primary-cyan/50 text-primary-cyan'
|
||||
: 'bg-black/40 border-primary-cyan/20 text-text-3 hover:bg-black/60 hover:border-primary-cyan/40'
|
||||
}`}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom Stats */}
|
||||
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-3 bg-black/50 backdrop-blur-lg px-4 py-2 rounded-md border z-[2]" style={{ borderColor: 'rgba(6,182,212,0.15)' }}>
|
||||
{[
|
||||
{ value: selectedItem.points, label: '포인트' },
|
||||
{ value: selectedItem.polygons, label: '폴리곤' },
|
||||
{ value: '3', label: '시점' },
|
||||
{ value: selectedItem.coverage, label: '커버리지' },
|
||||
{ value: '0.023m', label: 'RMS오차' },
|
||||
].map((s, i) => (
|
||||
<div key={i} className="text-center">
|
||||
<div className="font-mono font-bold text-sm text-primary-cyan">{s.value}</div>
|
||||
<div className="text-[8px] text-text-3 mt-0.5 font-korean">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Analysis Details */}
|
||||
<div className="w-[270px] bg-bg-1 border-l border-border flex flex-col overflow-auto">
|
||||
{/* Ship/Pollution Info */}
|
||||
<div className="p-2.5 px-3 border-b border-border">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-2 uppercase tracking-wider">📊 분석 정보</div>
|
||||
<div className="flex flex-col gap-1.5 text-[10px]">
|
||||
{(selectedItem.type === 'vessel' ? [
|
||||
['대상', selectedItem.name],
|
||||
['선종 추정', '일반화물선 (추정)'],
|
||||
['길이', '약 85m'],
|
||||
['폭', '약 14m'],
|
||||
['AIS 상태', 'OFF (미식별)'],
|
||||
['최초 탐지', '2026-01-18 14:20'],
|
||||
['촬영 시점', '3 시점 (정면/좌현/우현)'],
|
||||
['센서', '광학 4K + IR 열화상'],
|
||||
] : [
|
||||
['대상', selectedItem.name],
|
||||
['유형', '유류 오염'],
|
||||
['추정 면적', '0.42 km²'],
|
||||
['추정 유출량', '12.6 kL'],
|
||||
['유종', 'B-C유 (추정)'],
|
||||
['최초 탐지', '2026-01-18 13:50'],
|
||||
['확산 속도', '0.3 km/h (ESE 방향)'],
|
||||
]).map(([k, v], i) => (
|
||||
<div key={i} className="flex justify-between items-start">
|
||||
<span className="text-text-3 font-korean">{k}</span>
|
||||
<span className="font-mono font-semibold text-text-1 text-right ml-2">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Detection Results */}
|
||||
<div className="p-2.5 px-3 border-b border-border">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-2 uppercase tracking-wider">🤖 AI 탐지 결과</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{(selectedItem.type === 'vessel' ? [
|
||||
{ label: '선박 식별', confidence: 94, color: 'bg-status-green' },
|
||||
{ label: '선종 분류', confidence: 78, color: 'bg-status-yellow' },
|
||||
{ label: '손상 감지', confidence: 45, color: 'bg-status-orange' },
|
||||
{ label: '화물 분석', confidence: 62, color: 'bg-status-yellow' },
|
||||
] : [
|
||||
{ label: '유막 탐지', confidence: 97, color: 'bg-status-green' },
|
||||
{ label: '유종 분류', confidence: 85, color: 'bg-status-green' },
|
||||
{ label: '두께 추정', confidence: 72, color: 'bg-status-yellow' },
|
||||
{ label: '확산 예측', confidence: 68, color: 'bg-status-orange' },
|
||||
]).map((r, i) => (
|
||||
<div key={i}>
|
||||
<div className="flex justify-between text-[9px] mb-0.5">
|
||||
<span className="text-text-3 font-korean">{r.label}</span>
|
||||
<span className="font-mono font-semibold text-text-1">{r.confidence}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1 bg-bg-0 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${r.color}`} style={{ width: `${r.confidence}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comparison / Measurements */}
|
||||
<div className="p-2.5 px-3 border-b border-border">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-2 uppercase tracking-wider">📐 3D 측정값</div>
|
||||
<div className="flex flex-col gap-1 text-[10px]">
|
||||
{(selectedItem.type === 'vessel' ? [
|
||||
['전장 (LOA)', '84.7 m'],
|
||||
['형폭 (Breadth)', '14.2 m'],
|
||||
['건현 (Freeboard)', '3.8 m'],
|
||||
['흘수 (Draft)', '5.6 m (추정)'],
|
||||
['마스트 높이', '22.3 m'],
|
||||
] : [
|
||||
['유막 면적', '0.42 km²'],
|
||||
['최대 길이', '1.24 km'],
|
||||
['최대 폭', '0.68 km'],
|
||||
['평균 두께', '0.8 mm'],
|
||||
['최대 두께', '3.2 mm'],
|
||||
]).map(([k, v], i) => (
|
||||
<div key={i} className="flex justify-between px-2 py-1 bg-bg-0 rounded">
|
||||
<span className="text-text-3 font-korean">{k}</span>
|
||||
<span className="font-mono font-semibold text-primary-cyan">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="p-2.5 px-3">
|
||||
<button className="w-full py-2.5 rounded-sm text-xs font-bold font-korean text-white border-none cursor-pointer mb-2" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>
|
||||
📊 상세 보고서 생성
|
||||
</button>
|
||||
<button className="w-full py-2 border border-border bg-bg-3 text-text-2 rounded-sm text-[11px] font-semibold font-korean cursor-pointer hover:bg-bg-hover transition-colors">
|
||||
📥 3D 모델 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
frontend/src/tabs/aerial/index.ts
Normal file
1
frontend/src/tabs/aerial/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { AerialView } from './components/AerialView'
|
||||
332
frontend/src/tabs/assets/components/AssetManagement.tsx
Normal file
332
frontend/src/tabs/assets/components/AssetManagement.tsx
Normal file
@ -0,0 +1,332 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { AssetOrg } from './assetTypes'
|
||||
import { typeTagCls } from './assetTypes'
|
||||
import { organizations } from './assetMockData'
|
||||
import AssetMap from './AssetMap'
|
||||
|
||||
function AssetManagement() {
|
||||
const [viewMode, setViewMode] = useState<'list' | 'map'>('list')
|
||||
const [selectedOrg, setSelectedOrg] = useState<AssetOrg>(organizations[0])
|
||||
const [detailTab, setDetailTab] = useState<'equip' | 'material' | 'contact'>('equip')
|
||||
const [regionFilter, setRegionFilter] = useState('all')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [typeFilterVal, setTypeFilterVal] = useState('all')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const pageSize = 15
|
||||
|
||||
const filtered = organizations.filter(o => {
|
||||
if (regionFilter !== 'all' && !o.jurisdiction.includes(regionFilter)) return false
|
||||
if (typeFilterVal !== 'all' && o.type !== typeFilterVal) return false
|
||||
if (searchTerm && !o.name.includes(searchTerm) && !o.address.includes(searchTerm)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize))
|
||||
const safePage = Math.min(currentPage, totalPages)
|
||||
const paged = filtered.slice((safePage - 1) * pageSize, safePage * pageSize)
|
||||
|
||||
// 필터 변경 시 첫 페이지로
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { setCurrentPage(1) }, [regionFilter, typeFilterVal, searchTerm])
|
||||
|
||||
const regionShort = (j: string) => {
|
||||
if (j.includes('중부')) return '중부청'
|
||||
if (j.includes('서해')) return '서해청'
|
||||
if (j.includes('남해')) return '남해청'
|
||||
if (j.includes('동해')) return '동해청'
|
||||
if (j.includes('중앙')) return '중특단'
|
||||
return '제주청'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* View Switcher & Filters */}
|
||||
<div className="flex items-center justify-between mb-3 pb-3 border-b border-border">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`px-3 py-1.5 text-[11px] font-semibold rounded-sm font-korean transition-colors ${
|
||||
viewMode === 'list'
|
||||
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30'
|
||||
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
|
||||
}`}
|
||||
>
|
||||
📋 방제자산리스트
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('map')}
|
||||
className={`px-3 py-1.5 text-[11px] font-semibold rounded-sm font-korean transition-colors ${
|
||||
viewMode === 'map'
|
||||
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30'
|
||||
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
|
||||
}`}
|
||||
>
|
||||
🗺 지도 보기
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="기관명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="prd-i w-40 py-1.5 px-2.5"
|
||||
/>
|
||||
<select value={regionFilter} onChange={e => setRegionFilter(e.target.value)} className="prd-i w-[100px] py-1.5 px-2">
|
||||
<option value="all">전체 관할</option>
|
||||
<option value="남해">남해청</option>
|
||||
<option value="서해">서해청</option>
|
||||
<option value="중부">중부청</option>
|
||||
<option value="동해">동해청</option>
|
||||
<option value="제주">제주청</option>
|
||||
</select>
|
||||
<select value={typeFilterVal} onChange={e => setTypeFilterVal(e.target.value)} className="prd-i w-[100px] py-1.5 px-2">
|
||||
<option value="all">전체 유형</option>
|
||||
<option value="해경관할">해경관할</option>
|
||||
<option value="해경경찰서">해경경찰서</option>
|
||||
<option value="파출소">파출소</option>
|
||||
<option value="관련기관">관련기관</option>
|
||||
<option value="해양환경공단">해양환경공단</option>
|
||||
<option value="업체">업체</option>
|
||||
<option value="지자체">지자체</option>
|
||||
<option value="기름저장시설">기름저장시설</option>
|
||||
<option value="정유사">정유사</option>
|
||||
<option value="해군">해군</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === 'list' ? (
|
||||
/* ── LIST VIEW ── */
|
||||
<div className="flex-1 bg-bg-3 border border-border rounded-md overflow-hidden flex flex-col">
|
||||
<div className="flex-1">
|
||||
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '3.5%' }} />
|
||||
<col style={{ width: '7%' }} />
|
||||
<col style={{ width: '7%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
<col />
|
||||
<col style={{ width: '8%' }} />
|
||||
<col style={{ width: '7%' }} />
|
||||
<col style={{ width: '7%' }} />
|
||||
<col style={{ width: '5%' }} />
|
||||
<col style={{ width: '5%' }} />
|
||||
<col style={{ width: '5%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-0">
|
||||
{['번호', '유형', '관할청', '기관명', '주소', '방제선', '유회수기', '이송펌프', '방제차량', '살포장치', '총자산'].map((h, i) => (
|
||||
<th key={i} className={`px-2.5 py-2.5 text-[10px] font-bold text-text-2 font-korean border-b border-border ${[0,5,6,7,8,9,10].includes(i) ? 'text-center' : ''}`}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map((org, idx) => (
|
||||
<tr
|
||||
key={org.id}
|
||||
className={`border-b border-border/50 hover:bg-[rgba(255,255,255,0.02)] cursor-pointer transition-colors ${
|
||||
selectedOrg.id === org.id ? 'bg-[rgba(6,182,212,0.03)]' : ''
|
||||
}`}
|
||||
onClick={() => { setSelectedOrg(org); setViewMode('map') }}
|
||||
>
|
||||
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{(safePage - 1) * pageSize + idx + 1}</td>
|
||||
<td className="px-2.5 py-2">
|
||||
<span className={`text-[9px] px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}>{org.type}</span>
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-[10px] font-semibold font-korean">{regionShort(org.jurisdiction)}</td>
|
||||
<td className="px-2.5 py-2 text-[10px] font-semibold text-primary-cyan font-korean cursor-pointer truncate">{org.name}</td>
|
||||
<td className="px-2.5 py-2 text-[10px] text-text-3 font-korean truncate">{org.address}</td>
|
||||
<td className="px-2.5 py-2 text-center font-mono text-[10px] font-semibold">{org.vessel}척</td>
|
||||
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.skimmer}대</td>
|
||||
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.pump}대</td>
|
||||
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.vehicle}대</td>
|
||||
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.sprayer}대</td>
|
||||
<td className="px-2.5 py-2 text-center font-bold text-primary-cyan font-mono text-[10px]">{org.totalAssets}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-center gap-4 px-4 py-2.5 border-t border-border bg-bg-0">
|
||||
<span className="text-[10px] text-text-3 font-korean">
|
||||
전체 <span className="font-semibold text-text-2">{filtered.length}</span>건 중{' '}
|
||||
<span className="font-semibold text-text-2">{(safePage - 1) * pageSize + 1}-{Math.min(safePage * pageSize, filtered.length)}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={safePage <= 1}
|
||||
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||
>«</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={safePage <= 1}
|
||||
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||
>‹</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setCurrentPage(p)}
|
||||
className={`w-6 h-6 text-[10px] font-bold rounded transition-colors cursor-pointer ${
|
||||
p === safePage
|
||||
? 'bg-primary-cyan/20 text-primary-cyan border border-primary-cyan/40'
|
||||
: 'border border-border bg-bg-3 text-text-3 hover:bg-bg-hover'
|
||||
}`}
|
||||
>{p}</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={safePage >= totalPages}
|
||||
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||
>›</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={safePage >= totalPages}
|
||||
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||
>»</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── MAP VIEW ── */
|
||||
<div className="flex-1 flex overflow-hidden rounded-md border border-border">
|
||||
{/* Map */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<AssetMap
|
||||
organizations={filtered}
|
||||
selectedOrg={selectedOrg}
|
||||
onSelectOrg={setSelectedOrg}
|
||||
regionFilter={regionFilter}
|
||||
onRegionFilterChange={setRegionFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Detail Panel */}
|
||||
<aside className="w-[340px] min-w-[340px] bg-bg-1 border-l border-border flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="text-sm font-bold mb-1 font-korean">{selectedOrg.name}</div>
|
||||
<div className="text-[11px] text-text-2 font-semibold font-korean mb-1">{selectedOrg.type} · {regionShort(selectedOrg.jurisdiction)} · {selectedOrg.area}</div>
|
||||
<div className="text-[11px] text-text-3 font-korean">{selectedOrg.address}</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-tabs */}
|
||||
<div className="flex border-b border-border">
|
||||
{(['equip', 'material', 'contact'] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setDetailTab(t)}
|
||||
className={`flex-1 py-2.5 text-center text-[11px] font-semibold font-korean border-b-2 transition-colors cursor-pointer ${
|
||||
detailTab === t
|
||||
? 'text-primary-cyan border-primary-cyan'
|
||||
: 'text-text-3 border-transparent hover:text-text-2'
|
||||
}`}
|
||||
>
|
||||
{t === 'equip' ? '장비' : t === 'material' ? '자재' : '연락처'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3.5 scrollbar-thin">
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-3 gap-1.5 mb-3">
|
||||
{[
|
||||
{ value: `${selectedOrg.vessel}척`, label: '방제선' },
|
||||
{ value: `${selectedOrg.skimmer}대`, label: '유회수기' },
|
||||
{ value: String(selectedOrg.totalAssets), label: '총 자산' },
|
||||
].map((s, i) => (
|
||||
<div key={i} className="bg-bg-3 border border-border rounded-sm p-2.5 text-center">
|
||||
<div className="text-lg font-bold text-primary-cyan font-mono">{s.value}</div>
|
||||
<div className="text-[9px] text-text-3 mt-0.5 font-korean">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{detailTab === 'equip' && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{selectedOrg.equipment.length > 0 ? selectedOrg.equipment.map((cat, ci) => {
|
||||
const unitMap: Record<string, string> = {
|
||||
'방제선': '척', '유회수기': '대', '비치크리너': '대', '이송펌프': '대', '방제차량': '대',
|
||||
'해안운반차': '대', '고압세척기': '대', '저압세척기': '대', '동력분무기': '대', '유량계측기': '대',
|
||||
'방제창고': '개소', '발전기': '대', '현장지휘소': '개', '지원장비': '대', '장비부품': '개',
|
||||
'경비함정방제': '대', '살포장치': '대',
|
||||
}
|
||||
const unit = unitMap[cat.category] || '개'
|
||||
return (
|
||||
<div key={ci} className="flex items-center justify-between px-2.5 py-2 bg-bg-3 border border-border rounded-sm hover:bg-bg-hover transition-colors">
|
||||
<span className="text-[11px] font-semibold flex items-center gap-1.5 font-korean">
|
||||
{cat.icon} {cat.category}
|
||||
</span>
|
||||
<span className="text-[11px] font-bold font-mono"><span className="text-primary-cyan">{cat.count}</span><span className="text-text-3 font-normal ml-0.5">{unit}</span></span>
|
||||
</div>
|
||||
)
|
||||
}) : (
|
||||
<div className="text-center text-text-3 text-xs py-8 font-korean">상세 장비 데이터가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailTab === 'material' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{[
|
||||
['방제선', `${selectedOrg.vessel}척`],
|
||||
['유회수기', `${selectedOrg.skimmer}대`],
|
||||
['이송펌프', `${selectedOrg.pump}대`],
|
||||
['방제차량', `${selectedOrg.vehicle}대`],
|
||||
['살포장치', `${selectedOrg.sprayer}대`],
|
||||
['총 자산', `${selectedOrg.totalAssets}건`],
|
||||
].map(([k, v], i) => (
|
||||
<div key={i} className="flex justify-between px-2.5 py-2 bg-bg-0 rounded text-[11px]">
|
||||
<span className="text-text-3 font-korean">{k}</span>
|
||||
<span className="font-mono font-semibold text-text-1">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailTab === 'contact' && (
|
||||
<div className="bg-bg-3 border border-border rounded-sm p-3">
|
||||
{selectedOrg.contacts.length > 0 ? selectedOrg.contacts.map((c, i) => (
|
||||
<div key={i} className="flex flex-col gap-1 mb-3 last:mb-0">
|
||||
{[
|
||||
['기관/업체', c.name],
|
||||
['연락처', c.phone],
|
||||
].map(([k, v], j) => (
|
||||
<div key={j} className="flex justify-between py-1 text-[11px]">
|
||||
<span className="text-text-3 font-korean">{k}</span>
|
||||
<span className="font-mono text-text-1">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
{i < selectedOrg.contacts.length - 1 && <div className="border-t border-border my-1" />}
|
||||
</div>
|
||||
)) : (
|
||||
<div className="text-center text-text-3 text-xs py-4 font-korean">연락처 정보가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="p-3.5 border-t border-border flex gap-2">
|
||||
<button className="flex-1 py-2.5 rounded-sm text-xs font-semibold font-korean text-white border-none cursor-pointer" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>
|
||||
📥 다운로드
|
||||
</button>
|
||||
<button className="flex-1 py-2.5 rounded-sm text-xs font-semibold font-korean bg-bg-3 border border-border text-text-2 cursor-pointer hover:bg-bg-hover transition-colors">
|
||||
✏ 수정
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetManagement
|
||||
161
frontend/src/tabs/assets/components/AssetMap.tsx
Normal file
161
frontend/src/tabs/assets/components/AssetMap.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import type { AssetOrg } from './assetTypes'
|
||||
import { typeColor } from './assetTypes'
|
||||
|
||||
interface AssetMapProps {
|
||||
organizations: AssetOrg[]
|
||||
selectedOrg: AssetOrg
|
||||
onSelectOrg: (o: AssetOrg) => void
|
||||
regionFilter: string
|
||||
onRegionFilterChange: (v: string) => void
|
||||
}
|
||||
|
||||
function AssetMap({
|
||||
organizations: orgs,
|
||||
selectedOrg,
|
||||
onSelectOrg,
|
||||
regionFilter,
|
||||
onRegionFilterChange,
|
||||
}: AssetMapProps) {
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<L.Map | null>(null)
|
||||
const markersRef = useRef<L.LayerGroup | null>(null)
|
||||
|
||||
// Initialize map once
|
||||
useEffect(() => {
|
||||
if (!mapContainerRef.current || mapRef.current) return
|
||||
|
||||
const map = L.map(mapContainerRef.current, {
|
||||
center: [35.9, 127.8],
|
||||
zoom: 7,
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
})
|
||||
|
||||
// Dark-themed OpenStreetMap tiles
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19,
|
||||
}).addTo(map)
|
||||
|
||||
L.control.zoom({ position: 'topright' }).addTo(map)
|
||||
L.control.attribution({ position: 'bottomright' }).addAttribution(
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>'
|
||||
).addTo(map)
|
||||
|
||||
mapRef.current = map
|
||||
markersRef.current = L.layerGroup().addTo(map)
|
||||
|
||||
return () => {
|
||||
map.remove()
|
||||
mapRef.current = null
|
||||
markersRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update markers when orgs or selectedOrg changes
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || !markersRef.current) return
|
||||
|
||||
markersRef.current.clearLayers()
|
||||
|
||||
orgs.forEach(org => {
|
||||
const isSelected = selectedOrg.id === org.id
|
||||
const tc = typeColor(org.type)
|
||||
const radius = org.pinSize === 'hq' ? 14 : org.pinSize === 'lg' ? 10 : 7
|
||||
|
||||
const cm = L.circleMarker([org.lat, org.lng], {
|
||||
radius: isSelected ? radius + 4 : radius,
|
||||
fillColor: isSelected ? tc.selected : tc.bg,
|
||||
color: isSelected ? tc.selected : tc.border,
|
||||
weight: isSelected ? 3 : 2,
|
||||
fillOpacity: isSelected ? 0.9 : 0.7,
|
||||
})
|
||||
cm.bindTooltip(
|
||||
`<div style="text-align:center;font-family:'Noto Sans KR',sans-serif;">
|
||||
<div style="font-weight:700;font-size:11px;">${org.name}</div>
|
||||
<div style="font-size:10px;opacity:0.7;">${org.type} · 자산 ${org.totalAssets}건</div>
|
||||
</div>`,
|
||||
{ permanent: org.pinSize === 'hq' || isSelected, direction: 'top', offset: [0, -radius - 2], className: 'asset-map-tooltip' }
|
||||
)
|
||||
cm.on('click', () => onSelectOrg(org))
|
||||
|
||||
markersRef.current!.addLayer(cm)
|
||||
})
|
||||
}, [orgs, selectedOrg, onSelectOrg])
|
||||
|
||||
// Pan to selected org
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return
|
||||
mapRef.current.flyTo([selectedOrg.lat, selectedOrg.lng], 10, { duration: 0.8 })
|
||||
}, [selectedOrg])
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
<style>{`
|
||||
.asset-map-tooltip {
|
||||
background: rgba(15,21,36,0.92) !important;
|
||||
border: 1px solid rgba(30,42,66,0.8) !important;
|
||||
color: #e4e8f1 !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 4px 8px !important;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4) !important;
|
||||
}
|
||||
.asset-map-tooltip::before {
|
||||
border-top-color: rgba(15,21,36,0.92) !important;
|
||||
}
|
||||
`}</style>
|
||||
<div ref={mapContainerRef} className="w-full h-full" />
|
||||
|
||||
{/* Region filter overlay */}
|
||||
<div className="absolute top-3 left-3 z-[1000] flex gap-1">
|
||||
{[
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '남해', label: '남해청' },
|
||||
{ value: '서해', label: '서해청' },
|
||||
{ value: '중부', label: '중부청' },
|
||||
{ value: '동해', label: '동해청' },
|
||||
{ value: '제주', label: '제주청' },
|
||||
].map(r => (
|
||||
<button
|
||||
key={r.value}
|
||||
onClick={() => onRegionFilterChange(r.value)}
|
||||
className={`px-2.5 py-1.5 text-[10px] font-bold rounded font-korean transition-colors ${
|
||||
regionFilter === r.value
|
||||
? 'bg-primary-cyan/20 text-primary-cyan border border-primary-cyan/40'
|
||||
: 'bg-bg-0/80 text-text-2 border border-border hover:bg-bg-hover/80'
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend overlay */}
|
||||
<div className="absolute bottom-3 left-3 z-[1000] bg-bg-0/90 border border-border rounded-sm p-2.5 backdrop-blur-sm">
|
||||
<div className="text-[9px] text-text-3 font-bold mb-1.5 font-korean">범례</div>
|
||||
{[
|
||||
{ color: '#06b6d4', label: '해경관할' },
|
||||
{ color: '#3b82f6', label: '해경경찰서' },
|
||||
{ color: '#22c55e', label: '파출소' },
|
||||
{ color: '#a855f7', label: '관련기관' },
|
||||
{ color: '#14b8a6', label: '해양환경공단' },
|
||||
{ color: '#f59e0b', label: '업체' },
|
||||
{ color: '#ec4899', label: '지자체' },
|
||||
{ color: '#8b5cf6', label: '기름저장시설' },
|
||||
{ color: '#0d9488', label: '정유사' },
|
||||
{ color: '#64748b', label: '해군' },
|
||||
{ color: '#6b7280', label: '기타' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 mb-0.5 last:mb-0">
|
||||
<span className="w-2.5 h-2.5 rounded-full inline-block flex-shrink-0" style={{ background: item.color }} />
|
||||
<span className="text-[10px] text-text-2 font-korean">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetMap
|
||||
255
frontend/src/tabs/assets/components/AssetTheory.tsx
Normal file
255
frontend/src/tabs/assets/components/AssetTheory.tsx
Normal file
@ -0,0 +1,255 @@
|
||||
interface TheoryItem {
|
||||
title: string
|
||||
source: string
|
||||
desc: string
|
||||
tags?: { label: string; color: string }[]
|
||||
highlight?: boolean
|
||||
}
|
||||
|
||||
interface TheorySection {
|
||||
icon: string
|
||||
title: string
|
||||
color: string
|
||||
bgTint: string
|
||||
items: TheoryItem[]
|
||||
dividerAfter?: number
|
||||
dividerLabel?: string
|
||||
}
|
||||
|
||||
const THEORY_SECTIONS: TheorySection[] = [
|
||||
{
|
||||
icon: '🚢', title: '방제선 성능 기준', color: 'var(--blue)', bgTint: 'rgba(59,130,246,.08)',
|
||||
items: [
|
||||
{
|
||||
title: '해양경찰청 방제선 성능기준 고시',
|
||||
source: '해양경찰청 고시 제2022-11호 | 방제선·방제정 등급별 회수용량·속력·펌프사양 기준 정의',
|
||||
desc: '1~5등급 방제선 기준 · 회수능력(㎥/h) · 오일펜스 전장 탑재량 · WING 자산 등급 필터링 근거',
|
||||
},
|
||||
{
|
||||
title: 'IMO OPRC 1990 — 방제자원 비축 기준',
|
||||
source: 'International Convention on Oil Pollution Preparedness, Response and Co-operation | IMO, 1990',
|
||||
desc: '국가 방제역량 비축 최저 기준 · 항만별 Tier 1/2/3 대응자원 분류 · 국내 방제자원 DB 설계 기초',
|
||||
},
|
||||
{
|
||||
title: '해양오염방제업 등록기준 (해양환경관리법 시행규칙)',
|
||||
source: '해양수산부령 | 별표 9 — 방제업 종류별 방제선·기자재 보유기준',
|
||||
desc: '제1종·제2종 방제업 자산 보유기준 · 오일펜스 전장·회수기 용량 법적 최저기준 · WING 자산현황 적법성 검증 기준',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🪢', title: '오일펜스·흡착재 규격', color: 'var(--boom, #f59e0b)', bgTint: 'rgba(245,158,11,.08)',
|
||||
items: [
|
||||
{
|
||||
title: 'ASTM F625 — Standard Guide for Selecting Mechanical Oil Spill Equipment',
|
||||
source: 'ASTM International | 오일펜스·회수기·흡착재 성능시험·선정 기준 가이드',
|
||||
desc: '오일펜스 인장강도·부력기준 · 흡착포 흡수율(g/g) 측정법 · WING 자산 성능등급 분류 참조 기준',
|
||||
},
|
||||
{
|
||||
title: '기름오염방제시 오일펜스 사용지침 (ITOPF TIP 03 한국어판)',
|
||||
source: 'ITOPF | 해양경찰청·해양환경관리공단 번역, 2011',
|
||||
desc: '커튼형·펜스형·해안용 규격분류 · 유속별 운용한계(0.7~3.0 kt) · 힘 계산식 F=100·A·V² · 앵커 파지력 기준표',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '⚙️', title: '방제자원 배치·동원 이론', color: 'var(--purple)', bgTint: 'rgba(168,85,247,.08)',
|
||||
dividerAfter: 2, dividerLabel: '📐 최적화 수리모델 참고문헌',
|
||||
items: [
|
||||
{
|
||||
title: 'An Emergency Scheduling Model for Oil Containment Boom in Dynamically Changing Marine Oil Spills',
|
||||
source: 'Xu, Y. et al. | Ningbo Univ. | Systems 2025, 13, 716 · DOI: 10.3390/systems13080716',
|
||||
desc: 'IMOGWO 다목적 최적화 · 스케줄링 시간+경제·생태손실 동시 최소화 · 동적 오일필름 기반 방제정 라우팅',
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
title: 'Dynamic Resource Allocation to Support Oil Spill Response Planning',
|
||||
source: 'Garrett, R.A. et al. | Eur. J. Oper. Res. 257:272–286, 2017',
|
||||
desc: '불확실성 하 방제자원 동적 배분 최적화 · 시나리오별 비축량 산정 · WING 자산 우선순위 배치 알고리즘 이론 기반',
|
||||
},
|
||||
{
|
||||
title: '해양오염방제 국가긴급방제계획 (NOSCP)',
|
||||
source: '해양경찰청 | 국가긴급방제계획, 2023년판',
|
||||
desc: 'Tier 3급 대형사고 자원 동원체계 · 기관별 역할분담·지휘계통 · WING 방제자산 연계 법적 근거',
|
||||
},
|
||||
{
|
||||
title: 'A Mixed Integer Programming Approach to Improve Oil Spill Response Resource Allocation in the Canadian Arctic',
|
||||
source: 'Das, T., Goerlandt, F. & Pelot, R. | Multimodal Transportation Vol.3 No.1, 100110, 2023',
|
||||
desc: '혼합정수계획법으로 응급 방제자원 거점 위치 선택 + 자원 할당 동시 최적화. 비용·응답시간 트레이드오프 파레토 분석.',
|
||||
highlight: true,
|
||||
tags: [
|
||||
{ label: 'MIP 수리모델', color: 'var(--purple)' },
|
||||
{ label: '자원 위치 선택', color: 'var(--blue)' },
|
||||
{ label: '북극해 적용', color: 'var(--cyan)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '유전알고리즘을 이용하여 최적화된 방제자원 배치안의 분포도 분석',
|
||||
source: '김혜진, 김용혁 | 한국융합학회논문지 Vol.11 No.4, pp.11–16, 2020',
|
||||
desc: 'GA(유전알고리즘)로 방제자원 배치 최적화 및 시뮬레이션 분포도 분석. 국내 해역 실정에 맞는 자원 배치 패턴 도출.',
|
||||
highlight: true,
|
||||
tags: [
|
||||
{ label: 'GA 메타휴리스틱', color: 'var(--purple)' },
|
||||
{ label: '국내 연구', color: 'var(--green, #22c55e)' },
|
||||
{ label: '배치 분포도 분석', color: 'var(--boom, #f59e0b)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'A Two-Stage Stochastic Optimization Framework for Environmentally Sensitive Oil Spill Response Resource Allocation',
|
||||
source: 'Rahman, M.A., Kuhel, M.T. & Novoa, C. | arXiv preprint arXiv:2511.22218, 2025',
|
||||
desc: '확률적 MILP 2단계 프레임워크로 불확실성 포함 최적 자원 배치. 환경민감구역 가중치 반영.',
|
||||
highlight: true,
|
||||
tags: [
|
||||
{ label: '확률적 MILP', color: 'var(--purple)' },
|
||||
{ label: '2단계 최적화', color: 'var(--blue)' },
|
||||
{ label: '환경민감구역', color: 'var(--green, #22c55e)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Mixed-Integer Dynamic Optimization for Oil-Spill Response Planning with Integration of Dynamic Oil Weathering Model',
|
||||
source: 'You, F. & Leyffer, S. | Argonne National Laboratory Technical Note, 2008',
|
||||
desc: '동적 최적화(MINLP/MILP) 프레임워크로 오일스필 대응 스케줄링 + 오일 풍화·거동 물리모델 통합.',
|
||||
highlight: true,
|
||||
tags: [
|
||||
{ label: 'MINLP 동적 최적화', color: 'var(--purple)' },
|
||||
{ label: '오일 풍화 모델 통합', color: 'var(--boom, #f59e0b)' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🗄', title: '자산 현행화·데이터 관리', color: 'var(--green, #22c55e)', bgTint: 'rgba(34,197,94,.08)',
|
||||
items: [
|
||||
{
|
||||
title: '해양오염방제자원 현황관리 지침',
|
||||
source: '해양경찰청 예규 | 방제자원 등록·현행화·이력관리 절차 규정',
|
||||
desc: '분기별 자산 실사 기준 · 자산분류코드 체계 · WING 업로드 양식(xlsx) 필드 정의 근거',
|
||||
},
|
||||
{
|
||||
title: 'ISO 55000 — Asset Management: Overview, Principles and Terminology',
|
||||
source: 'International Organization for Standardization | ISO 55000:2014',
|
||||
desc: '자산 생애주기 관리 원칙 · 자산가치·상태 평가 프레임워크 · WING 자산 노후도·교체주기 산정 이론 기준',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const TAG_COLORS: Record<string, { bg: string; bd: string; fg: string }> = {
|
||||
'var(--purple)': { bg: 'rgba(168,85,247,0.08)', bd: 'rgba(168,85,247,0.2)', fg: '#a855f7' },
|
||||
'var(--blue)': { bg: 'rgba(59,130,246,0.08)', bd: 'rgba(59,130,246,0.2)', fg: '#3b82f6' },
|
||||
'var(--cyan)': { bg: 'rgba(6,182,212,0.08)', bd: 'rgba(6,182,212,0.2)', fg: '#06b6d4' },
|
||||
'var(--green, #22c55e)': { bg: 'rgba(34,197,94,0.08)', bd: 'rgba(34,197,94,0.2)', fg: '#22c55e' },
|
||||
'var(--boom, #f59e0b)': { bg: 'rgba(245,158,11,0.08)', bd: 'rgba(245,158,11,0.2)', fg: '#f59e0b' },
|
||||
}
|
||||
|
||||
function TheoryCard({ section }: { section: TheorySection }) {
|
||||
const badgeBg = section.bgTint.replace(/[\d.]+\)$/, '0.15)')
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--bg3)', border: '1px solid var(--bd)',
|
||||
borderRadius: 'var(--rM, 10px)', overflow: 'hidden',
|
||||
}}>
|
||||
{/* Section Header */}
|
||||
<div style={{
|
||||
padding: '12px 16px', background: section.bgTint,
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
}}>
|
||||
<span style={{ fontSize: '14px' }}>{section.icon}</span>
|
||||
<span style={{ fontSize: '12px', fontWeight: 700, color: section.color, fontFamily: 'var(--fK)' }}>
|
||||
{section.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div style={{ padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: '8px', fontSize: '9px', fontFamily: 'var(--fK)' }}>
|
||||
{section.items.map((item, i) => (
|
||||
<div key={i}>
|
||||
{/* Divider */}
|
||||
{section.dividerAfter !== undefined && i === section.dividerAfter + 1 && (
|
||||
<div style={{ borderTop: '1px dashed var(--bd)', margin: '4px 0 12px', paddingTop: '8px' }}>
|
||||
<div style={{ fontSize: '8px', fontWeight: 700, color: section.color, marginBottom: '6px', opacity: 0.7 }}>
|
||||
{section.dividerLabel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: '24px 1fr', gap: '8px',
|
||||
padding: '8px 10px', background: 'var(--bg0)', borderRadius: '6px',
|
||||
borderLeft: item.highlight ? `2px solid ${section.color}` : undefined,
|
||||
}}>
|
||||
{/* Number badge */}
|
||||
<div style={{
|
||||
width: '20px', height: '20px', borderRadius: '4px',
|
||||
background: badgeBg,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '9px', flexShrink: 0,
|
||||
fontWeight: item.highlight ? 700 : 400,
|
||||
color: item.highlight ? section.color : undefined,
|
||||
}}>
|
||||
{['①','②','③','④','⑤','⑥','⑦','⑧','⑨','⑩'][i]}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: 'var(--t1)', fontWeight: 700, marginBottom: '2px' }}>
|
||||
{item.title}
|
||||
</div>
|
||||
<div style={{ color: 'var(--t3)', lineHeight: '1.6' }}>
|
||||
{item.source}
|
||||
</div>
|
||||
{/* Tags */}
|
||||
{item.tags && (
|
||||
<div style={{ marginTop: '3px', display: 'flex', flexWrap: 'wrap', gap: '3px' }}>
|
||||
{item.tags.map((tag, ti) => {
|
||||
const tc = TAG_COLORS[tag.color] || { bg: 'rgba(107,114,128,0.08)', bd: 'rgba(107,114,128,0.2)', fg: '#6b7280' }
|
||||
return (
|
||||
<span key={ti} style={{
|
||||
padding: '1px 5px', borderRadius: '3px', fontSize: '8px',
|
||||
color: tc.fg, background: tc.bg, border: `1px solid ${tc.bd}`,
|
||||
}}>
|
||||
{tag.label}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: '2px', color: 'var(--t2)' }}>
|
||||
{item.desc}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AssetTheory() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0' }}>
|
||||
<div style={{ fontSize: '18px', fontWeight: 700, fontFamily: 'var(--fK)', marginBottom: '4px' }}>
|
||||
📚 방제자원 이론
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: '24px' }}>
|
||||
방제자산 운용 기준·성능 이론 및 관련 법령·고시 근거 문헌
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '18px', alignItems: 'start' }}>
|
||||
{/* Left column */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{THEORY_SECTIONS.slice(0, 2).map((sec) => (
|
||||
<TheoryCard key={sec.title} section={sec} />
|
||||
))}
|
||||
</div>
|
||||
{/* Right column */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{THEORY_SECTIONS.slice(2).map((sec) => (
|
||||
<TheoryCard key={sec.title} section={sec} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetTheory
|
||||
124
frontend/src/tabs/assets/components/AssetUpload.tsx
Normal file
124
frontend/src/tabs/assets/components/AssetUpload.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import { useState } from 'react'
|
||||
import { uploadHistory } from './assetMockData'
|
||||
|
||||
function AssetUpload() {
|
||||
const [uploadMode, setUploadMode] = useState<'add' | 'replace'>('add')
|
||||
const [uploaded, setUploaded] = useState(false)
|
||||
|
||||
const handleUpload = () => {
|
||||
setUploaded(true)
|
||||
setTimeout(() => setUploaded(false), 3000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-8 h-full overflow-auto">
|
||||
{/* Left - Upload */}
|
||||
<div className="flex-1 max-w-[580px]">
|
||||
<div className="text-[13px] font-bold mb-3.5 font-korean">📤 자산 데이터 업로드</div>
|
||||
|
||||
{/* Drop Zone */}
|
||||
<div className="border-2 border-dashed border-border-light rounded-md py-10 px-5 text-center mb-5 cursor-pointer hover:border-primary-cyan/40 transition-colors">
|
||||
<div className="text-4xl mb-2.5 opacity-50">📁</div>
|
||||
<div className="text-sm font-semibold mb-1.5 font-korean">파일을 드래그하거나 클릭하여 업로드</div>
|
||||
<div className="text-[11px] text-text-3 mb-4 font-korean">엑셀(.xlsx), CSV 파일 지원 · 최대 10MB</div>
|
||||
<button className="px-7 py-2.5 text-[13px] font-semibold rounded-sm text-white border-none cursor-pointer font-korean" style={{ background: 'linear-gradient(135deg, var(--blue), #2563eb)' }}>
|
||||
파일 선택
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Asset Classification */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">자산 분류</label>
|
||||
<select className="prd-i w-full">
|
||||
<option>장비자재</option>
|
||||
<option>방제선</option>
|
||||
<option>경비함정</option>
|
||||
<option>방제창고</option>
|
||||
<option>공단·지자체</option>
|
||||
<option>MPRS·행안부</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Jurisdiction */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">업로드 대상 관할</label>
|
||||
<select className="prd-i w-full">
|
||||
<option>남해청 - 여수서</option>
|
||||
<option>남해청 - 부산서</option>
|
||||
<option>남해청 - 울산서</option>
|
||||
<option>서해청 - 목포서</option>
|
||||
<option>중부청 - 인천서</option>
|
||||
<option>동해청 - 동해서</option>
|
||||
<option>제주청 - 제주서</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Upload Mode */}
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">업로드 방식</label>
|
||||
<div className="flex gap-4 text-xs text-text-2 font-korean">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" checked={uploadMode === 'add'} onChange={() => setUploadMode('add')} className="accent-primary-blue" />
|
||||
추가 (기존 + 신규)
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" checked={uploadMode === 'replace'} onChange={() => setUploadMode('replace')} className="accent-primary-blue" />
|
||||
전체 교체
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Button */}
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
className={`w-full py-3.5 rounded-sm text-sm font-bold font-korean border-none cursor-pointer transition-all ${
|
||||
uploaded
|
||||
? 'bg-[rgba(34,197,94,0.2)] text-status-green border border-status-green'
|
||||
: 'text-white'
|
||||
}`}
|
||||
style={!uploaded ? { background: 'linear-gradient(135deg, var(--blue), #2563eb)' } : undefined}
|
||||
>
|
||||
{uploaded ? '✅ 업로드 완료!' : '📤 업로드 실행'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right - Permission & History */}
|
||||
<div className="flex-1 max-w-[480px]">
|
||||
{/* Permission System */}
|
||||
<div className="text-[13px] font-bold mb-3.5 font-korean">🔐 수정 권한 체계</div>
|
||||
<div className="flex flex-col gap-2 mb-7">
|
||||
{[
|
||||
{ icon: '👑', role: '본청 관리자', desc: '전체 자산 조회·수정·삭제·업로드', color: 'text-status-red', bg: 'rgba(239,68,68,0.15)' },
|
||||
{ icon: '🏛', role: '지방청 담당자', desc: '소속 지방청 및 하위 해경서 자산 수정·업로드', color: 'text-status-orange', bg: 'rgba(249,115,22,0.15)' },
|
||||
{ icon: '⚓', role: '해경서 담당자', desc: '소속 해경서 자산 수정·업로드', color: 'text-primary-blue', bg: 'rgba(59,130,246,0.15)' },
|
||||
{ icon: '👤', role: '일반 사용자', desc: '조회·다운로드만 가능', color: 'text-text-2', bg: 'rgba(100,116,139,0.15)' },
|
||||
].map((p, i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-3.5 px-4 bg-bg-3 border border-border rounded-sm">
|
||||
<div className="w-9 h-9 rounded-full flex items-center justify-center text-base" style={{ background: p.bg }}>{p.icon}</div>
|
||||
<div>
|
||||
<div className={`text-xs font-bold font-korean ${p.color}`}>{p.role}</div>
|
||||
<div className="text-[10px] text-text-3 font-korean">{p.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Upload History */}
|
||||
<div className="text-[13px] font-bold mb-3.5 font-korean">📋 최근 업로드 이력</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{uploadHistory.map((h, i) => (
|
||||
<div key={i} className="flex justify-between items-center p-3.5 px-4 bg-bg-3 border border-border rounded-sm">
|
||||
<div>
|
||||
<div className="text-xs font-semibold font-korean">{h.filename}</div>
|
||||
<div className="text-[10px] text-text-3 mt-0.5 font-korean">{h.date} · {h.uploader} · {h.count}건</div>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-[rgba(34,197,94,0.15)] text-status-green">완료</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetUpload
|
||||
57
frontend/src/tabs/assets/components/AssetsView.tsx
Executable file
57
frontend/src/tabs/assets/components/AssetsView.tsx
Executable file
@ -0,0 +1,57 @@
|
||||
import { useState } from 'react'
|
||||
import type { AssetsTab } from './assetTypes'
|
||||
import AssetManagement from './AssetManagement'
|
||||
import AssetUpload from './AssetUpload'
|
||||
import AssetTheory from './AssetTheory'
|
||||
import ShipInsurance from './ShipInsurance'
|
||||
import { useFeatureTracking } from '@common/hooks/useFeatureTracking'
|
||||
|
||||
// ── Main AssetsView ──
|
||||
|
||||
export function AssetsView() {
|
||||
const [activeTab, setActiveTab] = useState<AssetsTab>('management')
|
||||
|
||||
// 내부 탭 전환 시 자동 감사 로그
|
||||
useFeatureTracking(`assets:${activeTab}`)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-bg-0">
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex items-center justify-between border-b border-border bg-bg-1" style={{ flexShrink: 0 }}>
|
||||
<div className="flex">
|
||||
{([
|
||||
{ id: 'management' as const, icon: '🗂', label: '자산 관리' },
|
||||
{ id: 'upload' as const, icon: '📤', label: '자산 현행화 (업로드)' },
|
||||
{ id: 'theory' as const, icon: '📚', label: '방제자원 이론' },
|
||||
{ id: 'insurance' as const, icon: '🛡', label: '선박 보험정보' },
|
||||
]).map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-5 py-3.5 text-xs font-semibold transition-all font-korean border-b-2 ${
|
||||
activeTab === tab.id
|
||||
? 'text-primary-cyan border-primary-cyan'
|
||||
: 'text-text-3 border-transparent hover:text-text-2'
|
||||
}`}
|
||||
>
|
||||
{tab.icon} {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-3.5 py-1.5 border rounded-full text-[11px] text-primary-blue font-korean mr-4" style={{ borderColor: 'rgba(59,130,246,0.3)' }}>
|
||||
👤 남해청_방제과 (수정 권한 ✅)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto px-6 py-5">
|
||||
<div className="w-full h-full">
|
||||
{activeTab === 'management' && <AssetManagement />}
|
||||
{activeTab === 'upload' && <AssetUpload />}
|
||||
{activeTab === 'theory' && <AssetTheory />}
|
||||
{activeTab === 'insurance' && <ShipInsurance />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
319
frontend/src/tabs/assets/components/ShipInsurance.tsx
Normal file
319
frontend/src/tabs/assets/components/ShipInsurance.tsx
Normal file
@ -0,0 +1,319 @@
|
||||
import { useState } from 'react'
|
||||
import type { InsuranceRow } from './assetTypes'
|
||||
import { insuranceDemoData } from './assetMockData'
|
||||
|
||||
function ShipInsurance() {
|
||||
const [apiConnected, setApiConnected] = useState(false)
|
||||
const [showConfig, setShowConfig] = useState(false)
|
||||
const [configEndpoint, setConfigEndpoint] = useState('https://api.haewoon.or.kr/v1/insurance')
|
||||
const [configApiKey, setConfigApiKey] = useState('')
|
||||
const [configKeyType, setConfigKeyType] = useState('mmsi')
|
||||
const [configRespType, setConfigRespType] = useState('json')
|
||||
const [searchType, setSearchType] = useState('mmsi')
|
||||
const [searchVal, setSearchVal] = useState('')
|
||||
const [insTypeFilter, setInsTypeFilter] = useState('전체')
|
||||
const [viewState, setViewState] = useState<'empty' | 'loading' | 'result'>('empty')
|
||||
const [resultData, setResultData] = useState<InsuranceRow[]>([])
|
||||
const [lastSync, setLastSync] = useState('—')
|
||||
|
||||
const placeholderMap: Record<string, string> = {
|
||||
mmsi: 'MMSI 번호 입력 (예: 440123456)',
|
||||
imo: 'IMO 번호 입력 (예: 9876543)',
|
||||
shipname: '선박명 입력 (예: 한라호)',
|
||||
callsign: '호출부호 입력 (예: HLXX1)',
|
||||
}
|
||||
|
||||
const getStatus = (expiry: string) => {
|
||||
const now = new Date()
|
||||
const exp = new Date(expiry)
|
||||
const daysLeft = Math.ceil((exp.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (exp < now) return 'expired' as const
|
||||
if (daysLeft <= 30) return 'soon' as const
|
||||
return 'valid' as const
|
||||
}
|
||||
|
||||
const handleSaveConfig = () => {
|
||||
if (!configApiKey) { alert('API Key를 입력하세요.'); return }
|
||||
setShowConfig(false)
|
||||
alert('API 설정이 저장되었습니다.')
|
||||
}
|
||||
|
||||
const handleTestConnect = async () => {
|
||||
await new Promise(r => setTimeout(r, 1200))
|
||||
alert('⚠ API Key가 설정되지 않았습니다.\n[API 설정] 버튼에서 한국해운조합 API Key를 먼저 등록하세요.')
|
||||
}
|
||||
|
||||
const loadDemoData = () => {
|
||||
setResultData(insuranceDemoData)
|
||||
setViewState('result')
|
||||
setApiConnected(false)
|
||||
setLastSync(new Date().toLocaleString('ko-KR'))
|
||||
}
|
||||
|
||||
const handleQuery = async () => {
|
||||
if (!searchVal.trim()) { alert('조회값을 입력하세요.'); return }
|
||||
setViewState('loading')
|
||||
await new Promise(r => setTimeout(r, 900))
|
||||
loadDemoData()
|
||||
}
|
||||
|
||||
const handleBatchQuery = async () => {
|
||||
setViewState('loading')
|
||||
await new Promise(r => setTimeout(r, 1400))
|
||||
loadDemoData()
|
||||
}
|
||||
|
||||
const handleFullSync = async () => {
|
||||
setLastSync('동기화 중...')
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
setLastSync(new Date().toLocaleString('ko-KR'))
|
||||
alert('전체 동기화는 API 연동 후 활성화됩니다.')
|
||||
}
|
||||
|
||||
// summary computation
|
||||
const validCount = resultData.filter(r => getStatus(r.expiry) !== 'expired').length
|
||||
const soonList = resultData.filter(r => getStatus(r.expiry) === 'soon')
|
||||
const expiredList = resultData.filter(r => getStatus(r.expiry) === 'expired')
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'auto' }}>
|
||||
|
||||
{/* ── 헤더 ── */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, fontFamily: 'var(--fK)' }}>🛡 선박 보험정보 조회</div>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '3px 10px', borderRadius: 10,
|
||||
fontSize: 10, fontWeight: 700, fontFamily: 'var(--fK)',
|
||||
background: apiConnected ? 'rgba(34,197,94,.12)' : 'rgba(239,68,68,.12)',
|
||||
color: apiConnected ? 'var(--green)' : 'var(--red)',
|
||||
border: `1px solid ${apiConnected ? 'rgba(34,197,94,.25)' : 'rgba(239,68,68,.25)'}`,
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: apiConnected ? 'var(--green)' : 'var(--red)', display: 'inline-block' }} />
|
||||
{apiConnected ? 'API 연결됨' : 'API 미연결'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>한국해운조합(KSA) Open API 연동 · 선박 P&I 보험 및 선주 책임보험 실시간 조회</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={handleTestConnect} style={{ padding: '8px 16px', background: 'rgba(6,182,212,.12)', color: 'var(--cyan)', border: '1px solid rgba(6,182,212,.3)', borderRadius: 'var(--rS)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔌 연결 테스트</button>
|
||||
<button onClick={() => setShowConfig(v => !v)} style={{ padding: '8px 16px', background: 'var(--bg3)', color: 'var(--t2)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)' }}>⚙ API 설정</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── API 설정 패널 ── */}
|
||||
{showConfig && (
|
||||
<div style={{ background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 'var(--rM)', padding: '20px 24px', marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, fontFamily: 'var(--fK)', marginBottom: 14, color: 'var(--cyan)' }}>⚙ 한국해운조합 API 연동 설정</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 5 }}>API Endpoint URL</label>
|
||||
<input type="text" value={configEndpoint} onChange={e => setConfigEndpoint(e.target.value)} placeholder="https://api.haewoon.or.kr/v1/..."
|
||||
style={{ width: '100%', padding: '9px 12px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', color: 'var(--t1)', fontFamily: 'var(--fM)', fontSize: 12, outline: 'none', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 5 }}>API Key</label>
|
||||
<input type="password" value={configApiKey} onChange={e => setConfigApiKey(e.target.value)} placeholder="발급받은 API Key 입력"
|
||||
style={{ width: '100%', padding: '9px 12px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', color: 'var(--t1)', fontFamily: 'var(--fM)', fontSize: 12, outline: 'none', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 5 }}>조회 기본값 — 조회 키 타입</label>
|
||||
<select value={configKeyType} onChange={e => setConfigKeyType(e.target.value)} className="prd-i" style={{ borderColor: 'var(--bd)', width: '100%' }}>
|
||||
<option value="mmsi">MMSI</option>
|
||||
<option value="imo">IMO 번호</option>
|
||||
<option value="shipname">선박명</option>
|
||||
<option value="callsign">호출부호</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 5 }}>응답 형식</label>
|
||||
<select value={configRespType} onChange={e => setConfigRespType(e.target.value)} className="prd-i" style={{ borderColor: 'var(--bd)', width: '100%' }}>
|
||||
<option value="json">JSON</option>
|
||||
<option value="xml">XML</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={handleSaveConfig} style={{ padding: '9px 20px', background: 'linear-gradient(135deg, var(--cyan), var(--blue))', color: '#fff', border: 'none', borderRadius: 'var(--rS)', fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'var(--fK)' }}>💾 저장</button>
|
||||
<button onClick={() => setShowConfig(false)} style={{ padding: '9px 16px', background: 'var(--bg0)', color: 'var(--t2)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', fontSize: 12, cursor: 'pointer', fontFamily: 'var(--fK)' }}>취소</button>
|
||||
</div>
|
||||
{/* API 연동 안내 */}
|
||||
<div style={{ marginTop: 16, padding: '12px 16px', background: 'rgba(6,182,212,.05)', border: '1px solid rgba(6,182,212,.15)', borderRadius: 'var(--rS)', fontSize: 10, color: 'var(--t3)', fontFamily: 'var(--fK)', lineHeight: 1.8 }}>
|
||||
<span style={{ color: 'var(--cyan)', fontWeight: 700 }}>📋 한국해운조합 API 발급 안내</span><br />
|
||||
• 한국해운조합 공공데이터포털 또는 해운조합 IT지원팀에 API 키 신청<br />
|
||||
• 해양경찰청 기관 계정으로 신청 시 전용 엔드포인트 및 키 발급<br />
|
||||
• 조회 가능 데이터: P&I 보험, 선주책임보험, 해상보험 가입 여부, 증권번호, 보험기간, 보상한도
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 검색 영역 ── */}
|
||||
<div style={{ background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 'var(--rM)', padding: '18px 20px', marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, fontFamily: 'var(--fK)', marginBottom: 12, color: 'var(--t2)' }}>🔍 보험정보 조회</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 10, fontWeight: 600, color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: 4 }}>조회 키 타입</label>
|
||||
<select value={searchType} onChange={e => setSearchType(e.target.value)} className="prd-i" style={{ borderColor: 'var(--bd)', minWidth: 120 }}>
|
||||
<option value="mmsi">MMSI</option>
|
||||
<option value="imo">IMO 번호</option>
|
||||
<option value="shipname">선박명</option>
|
||||
<option value="callsign">호출부호</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 220 }}>
|
||||
<label style={{ display: 'block', fontSize: 10, fontWeight: 600, color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: 4 }}>조회값</label>
|
||||
<input type="text" value={searchVal} onChange={e => setSearchVal(e.target.value)} placeholder={placeholderMap[searchType]}
|
||||
style={{ width: '100%', padding: '9px 14px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', color: 'var(--t1)', fontFamily: 'var(--fM)', fontSize: 13, outline: 'none', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 10, fontWeight: 600, color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: 4 }}>보험 종류</label>
|
||||
<select value={insTypeFilter} onChange={e => setInsTypeFilter(e.target.value)} className="prd-i" style={{ borderColor: 'var(--bd)', minWidth: 140 }}>
|
||||
<option>전체</option>
|
||||
<option>P&I 보험</option>
|
||||
<option>선주책임보험</option>
|
||||
<option>해상보험(선박)</option>
|
||||
<option>방제보증보험</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={handleQuery} style={{ padding: '9px 24px', background: 'linear-gradient(135deg, var(--cyan), var(--blue))', color: '#fff', border: 'none', borderRadius: 'var(--rS)', fontSize: 13, fontWeight: 700, cursor: 'pointer', fontFamily: 'var(--fK)', flexShrink: 0 }}>🔍 조회</button>
|
||||
<button onClick={handleBatchQuery} style={{ padding: '9px 18px', background: 'rgba(168,85,247,.12)', color: 'var(--purple)', border: '1px solid rgba(168,85,247,.3)', borderRadius: 'var(--rS)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)', flexShrink: 0 }}>📋 자산목록 일괄조회</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 결과 영역 ── */}
|
||||
|
||||
{/* 초기 안내 상태 */}
|
||||
{viewState === 'empty' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '60px 20px', background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 'var(--rM)' }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16, opacity: 0.3 }}>🛡</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 8 }}>한국해운조합 API 연동 대기 중</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--t3)', fontFamily: 'var(--fK)', textAlign: 'center', lineHeight: 1.8 }}>
|
||||
API 설정에서 한국해운조합 API Key를 등록하거나<br />
|
||||
MMSI·IMO·선박명으로 직접 조회하세요.<br />
|
||||
<span style={{ color: 'var(--cyan)' }}>자산목록 일괄조회</span> 시 등록된 방제자산 전체의 보험 현황을 한번에 확인할 수 있습니다.
|
||||
</div>
|
||||
<div style={{ marginTop: 20, display: 'flex', gap: 10 }}>
|
||||
<button onClick={() => setShowConfig(true)} style={{ padding: '10px 20px', background: 'rgba(6,182,212,.12)', color: 'var(--cyan)', border: '1px solid rgba(6,182,212,.3)', borderRadius: 'var(--rS)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)' }}>⚙ API 설정</button>
|
||||
<button onClick={loadDemoData} style={{ padding: '10px 20px', background: 'var(--bg0)', color: 'var(--t2)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)' }}>📊 샘플 데이터 보기</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 */}
|
||||
{viewState === 'loading' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 60, background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 'var(--rM)' }}>
|
||||
<div style={{ width: 36, height: 36, border: '3px solid var(--bd)', borderTopColor: 'var(--cyan)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', marginBottom: 14 }} />
|
||||
<div style={{ fontSize: 13, color: 'var(--t2)', fontFamily: 'var(--fK)' }}>한국해운조합 API 조회 중...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 결과 테이블 */}
|
||||
{viewState === 'result' && (
|
||||
<>
|
||||
{/* 요약 카드 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, marginBottom: 14 }}>
|
||||
{[
|
||||
{ label: '전체', val: resultData.length, color: 'var(--cyan)', bg: 'rgba(6,182,212,.08)' },
|
||||
{ label: '유효', val: validCount, color: 'var(--green)', bg: 'rgba(34,197,94,.08)' },
|
||||
{ label: '만료임박(30일)', val: soonList.length, color: 'var(--yellow)', bg: 'rgba(234,179,8,.08)' },
|
||||
{ label: '만료/미가입', val: resultData.length - validCount, color: 'var(--red)', bg: 'rgba(239,68,68,.08)' },
|
||||
].map((c, i) => (
|
||||
<div key={i} style={{ padding: '14px 16px', background: c.bg, border: `1px solid ${c.color}33`, borderRadius: 'var(--rS)', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: c.color, fontFamily: 'var(--fM)' }}>{c.val}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--t3)', fontFamily: 'var(--fK)', marginTop: 2 }}>{c.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div style={{ background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 'var(--rM)', overflow: 'hidden', marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: '1px solid var(--bd)' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, fontFamily: 'var(--fK)', color: 'var(--t1)' }}>조회 결과 <span style={{ color: 'var(--cyan)' }}>{resultData.length}</span>건</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button onClick={() => alert('엑셀 내보내기 기능은 실제 API 연동 후 활성화됩니다.')} style={{ padding: '5px 12px', background: 'rgba(34,197,94,.1)', color: 'var(--green)', border: '1px solid rgba(34,197,94,.25)', borderRadius: 'var(--rS)', fontSize: 11, fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)' }}>📥 엑셀 내보내기</button>
|
||||
<button onClick={handleQuery} style={{ padding: '5px 12px', background: 'var(--bg0)', color: 'var(--t2)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', fontSize: 11, cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔄 새로고침</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11, fontFamily: 'var(--fK)' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--bg0)' }}>
|
||||
{[
|
||||
{ label: '선박명', align: 'left' },
|
||||
{ label: 'MMSI', align: 'center' },
|
||||
{ label: 'IMO', align: 'center' },
|
||||
{ label: '보험종류', align: 'center' },
|
||||
{ label: '보험사', align: 'center' },
|
||||
{ label: '증권번호', align: 'center' },
|
||||
{ label: '보험기간', align: 'center' },
|
||||
{ label: '보상한도', align: 'right' },
|
||||
{ label: '상태', align: 'center' },
|
||||
].map((h, i) => (
|
||||
<th key={i} style={{ padding: '10px 14px', textAlign: h.align as 'left' | 'center' | 'right', fontWeight: 700, color: 'var(--t2)', borderBottom: '1px solid var(--bd)', whiteSpace: 'nowrap' }}>{h.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resultData.map((r, i) => {
|
||||
const st = getStatus(r.expiry)
|
||||
const isExp = st === 'expired'
|
||||
const isSoon = st === 'soon'
|
||||
return (
|
||||
<tr key={i} style={{ borderBottom: '1px solid var(--bd)', background: isExp ? 'rgba(239,68,68,.03)' : undefined }}>
|
||||
<td style={{ padding: '10px 14px', fontWeight: 600 }}>{r.shipName}</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'center', fontFamily: 'var(--fM)', fontSize: 11 }}>{r.mmsi || '—'}</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'center', fontFamily: 'var(--fM)', fontSize: 11 }}>{r.imo || '—'}</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'center' }}>{r.insType}</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'center' }}>{r.insurer}</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'center', fontFamily: 'var(--fM)', fontSize: 10, color: 'var(--t3)' }}>{r.policyNo}</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'center', fontFamily: 'var(--fM)', fontSize: 11, color: isExp ? 'var(--red)' : isSoon ? 'var(--yellow)' : undefined, fontWeight: isExp || isSoon ? 700 : undefined }}>{r.start} ~ {r.expiry}</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'right', fontWeight: 700, fontFamily: 'var(--fM)' }}>{r.limit}</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
padding: '3px 10px', borderRadius: 10, fontSize: 10, fontWeight: 600,
|
||||
background: isExp ? 'rgba(239,68,68,.15)' : isSoon ? 'rgba(234,179,8,.15)' : 'rgba(34,197,94,.15)',
|
||||
color: isExp ? 'var(--red)' : isSoon ? 'var(--yellow)' : 'var(--green)',
|
||||
}}>
|
||||
{isExp ? '만료' : isSoon ? '만료임박' : '유효'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 경고 */}
|
||||
{(expiredList.length > 0 || soonList.length > 0) && (
|
||||
<div style={{ padding: '12px 16px', background: 'rgba(234,179,8,.06)', border: '1px solid rgba(234,179,8,.25)', borderRadius: 'var(--rS)', fontSize: 12, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 12 }}>
|
||||
{expiredList.length > 0 && (
|
||||
<><span style={{ color: 'var(--red)', fontWeight: 700 }}>⛔ 만료 {expiredList.length}건:</span> {expiredList.map(r => r.shipName).join(', ')}<br /></>
|
||||
)}
|
||||
{soonList.length > 0 && (
|
||||
<><span style={{ color: 'var(--yellow)', fontWeight: 700 }}>⚠ 만료임박(30일) {soonList.length}건:</span> {soonList.map(r => r.shipName).join(', ')}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── API 연동 정보 푸터 ── */}
|
||||
<div style={{ marginTop: 16, padding: '12px 16px', background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--t3)', fontFamily: 'var(--fK)', lineHeight: 1.7 }}>
|
||||
<span style={{ color: 'var(--t2)', fontWeight: 700 }}>데이터 출처:</span> 한국해운조합(KSA) · haewoon.or.kr<br />
|
||||
<span style={{ color: 'var(--t2)', fontWeight: 700 }}>연동 방식:</span> REST API (JSON) · 실시간 조회 · 캐시 TTL 1시간
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>마지막 동기화:</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--t2)', fontFamily: 'var(--fM)' }}>{lastSync}</span>
|
||||
<button onClick={handleFullSync} style={{ padding: '4px 10px', background: 'var(--bg0)', color: 'var(--t2)', border: '1px solid var(--bd)', borderRadius: 4, fontSize: 10, cursor: 'pointer', fontFamily: 'var(--fK)' }}>전체 동기화</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShipInsurance
|
||||
755
frontend/src/tabs/assets/components/assetMockData.ts
Normal file
755
frontend/src/tabs/assets/components/assetMockData.ts
Normal file
@ -0,0 +1,755 @@
|
||||
import type { AssetOrg, InsuranceRow } from './assetTypes'
|
||||
|
||||
export const organizations: AssetOrg[] = [
|
||||
// ── 중부지방해양경찰청 ──
|
||||
{
|
||||
id: 1, type: '해경관할', jurisdiction: '중부지방해양경찰청', area: '인천', name: '인천해양경찰서',
|
||||
address: '인천광역시 중구 북성동1가 80-8', vessel: 19, skimmer: 30, pump: 18, vehicle: 2, sprayer: 15, totalAssets: 234, phone: '010-4779-4191',
|
||||
lat: 37.4563, lng: 126.5922, pinSize: 'hq',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 19 }, { category: '유회수기', icon: '⚙', count: 30 }, { category: '비치크리너', icon: '🏖', count: 2 },
|
||||
{ category: '이송펌프', icon: '🔧', count: 18 }, { category: '방제차량', icon: '🚛', count: 2 }, { category: '해안운반차', icon: '🚜', count: 1 },
|
||||
{ category: '고압세척기', icon: '💧', count: 26 }, { category: '저압세척기', icon: '🚿', count: 3 }, { category: '동력분무기', icon: '💨', count: 14 },
|
||||
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 19 }, { category: '발전기', icon: '⚡', count: 9 },
|
||||
{ category: '현장지휘소', icon: '🏕', count: 2 }, { category: '지원장비', icon: '🔩', count: 9 }, { category: '장비부품', icon: '🔗', count: 46 },
|
||||
{ category: '경비함정방제', icon: '⚓', count: 18 }, { category: '살포장치', icon: '🌊', count: 15 },
|
||||
],
|
||||
contacts: [{ role: '방제과장', name: '김○○', phone: '032-835-0001' }, { role: '방제담당', name: '이○○', phone: '032-835-0002' }],
|
||||
},
|
||||
{
|
||||
id: 2, type: '해경경찰서', jurisdiction: '중부지방해양경찰청', area: '평택', name: '평택해양경찰서',
|
||||
address: '평택시 만호리 706번지', vessel: 14, skimmer: 27, pump: 33, vehicle: 3, sprayer: 22, totalAssets: 193, phone: '010-9812-8102',
|
||||
lat: 36.9694, lng: 126.8300, pinSize: 'lg',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 14 }, { category: '유회수기', icon: '⚙', count: 27 }, { category: '비치크리너', icon: '🏖', count: 1 },
|
||||
{ category: '이송펌프', icon: '🔧', count: 33 }, { category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 },
|
||||
{ category: '고압세척기', icon: '💧', count: 12 }, { category: '저압세척기', icon: '🚿', count: 5 }, { category: '동력분무기', icon: '💨', count: 2 },
|
||||
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 35 }, { category: '발전기', icon: '⚡', count: 9 },
|
||||
{ category: '지원장비', icon: '🔩', count: 10 }, { category: '장비부품', icon: '🔗', count: 4 },
|
||||
{ category: '경비함정방제', icon: '⚓', count: 14 }, { category: '살포장치', icon: '🌊', count: 22 },
|
||||
],
|
||||
contacts: [{ role: '방제담당', name: '박○○', phone: '031-682-0001' }],
|
||||
},
|
||||
{
|
||||
id: 3, type: '해경경찰서', jurisdiction: '중부지방해양경찰청', area: '태안', name: '태안해양경찰서',
|
||||
address: '충남 태안군 근흥면 신진부두길2', vessel: 10, skimmer: 27, pump: 21, vehicle: 8, sprayer: 15, totalAssets: 185, phone: '010-2965-4423',
|
||||
lat: 36.7456, lng: 126.2978, pinSize: 'lg',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 10 }, { category: '유회수기', icon: '⚙', count: 27 }, { category: '비치크리너', icon: '🏖', count: 4 },
|
||||
{ category: '이송펌프', icon: '🔧', count: 21 }, { category: '방제차량', icon: '🚛', count: 8 }, { category: '해안운반차', icon: '🚜', count: 8 },
|
||||
{ category: '고압세척기', icon: '💧', count: 14 }, { category: '저압세척기', icon: '🚿', count: 8 }, { category: '동력분무기', icon: '💨', count: 6 },
|
||||
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 28 }, { category: '발전기', icon: '⚡', count: 11 },
|
||||
{ category: '지원장비', icon: '🔩', count: 16 }, { category: '경비함정방제', icon: '⚓', count: 8 }, { category: '살포장치', icon: '🌊', count: 15 },
|
||||
],
|
||||
contacts: [{ role: '방제담당', name: '최○○', phone: '041-674-0001' }],
|
||||
},
|
||||
{
|
||||
id: 4, type: '파출소', jurisdiction: '중부지방해양경찰청', area: '보령', name: '보령해양경찰서',
|
||||
address: '보령시 해안로 740', vessel: 3, skimmer: 8, pump: 5, vehicle: 3, sprayer: 11, totalAssets: 80, phone: '010-2940-6343',
|
||||
lat: 36.3335, lng: 126.5874, pinSize: 'md',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 3 }, { category: '유회수기', icon: '⚙', count: 8 }, { category: '이송펌프', icon: '🔧', count: 5 },
|
||||
{ category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 5 },
|
||||
{ category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '유량계측기', icon: '📏', count: 1 },
|
||||
{ category: '방제창고', icon: '🏭', count: 22 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '지원장비', icon: '🔩', count: 6 },
|
||||
{ category: '장비부품', icon: '🔗', count: 4 }, { category: '경비함정방제', icon: '⚓', count: 6 }, { category: '살포장치', icon: '🌊', count: 11 },
|
||||
],
|
||||
contacts: [{ role: '방제담당', name: '정○○', phone: '041-931-0001' }],
|
||||
},
|
||||
// ── 서해지방해양경찰청 ──
|
||||
{
|
||||
id: 5, type: '해경관할', jurisdiction: '서해지방해양경찰청', area: '여수', name: '여수해양경찰서',
|
||||
address: '광양시 항만9로 89', vessel: 55, skimmer: 92, pump: 63, vehicle: 12, sprayer: 47, totalAssets: 464, phone: '010-2785-2493',
|
||||
lat: 34.7407, lng: 127.7385, pinSize: 'hq',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 55 }, { category: '유회수기', icon: '⚙', count: 92 }, { category: '비치크리너', icon: '🏖', count: 5 },
|
||||
{ category: '이송펌프', icon: '🔧', count: 63 }, { category: '방제차량', icon: '🚛', count: 12 }, { category: '해안운반차', icon: '🚜', count: 4 },
|
||||
{ category: '고압세척기', icon: '💧', count: 48 }, { category: '저압세척기', icon: '🚿', count: 7 }, { category: '동력분무기', icon: '💨', count: 25 },
|
||||
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 37 }, { category: '발전기', icon: '⚡', count: 16 },
|
||||
{ category: '현장지휘소', icon: '🏕', count: 2 }, { category: '지원장비', icon: '🔩', count: 14 }, { category: '장비부품', icon: '🔗', count: 14 },
|
||||
{ category: '경비함정방제', icon: '⚓', count: 22 }, { category: '살포장치', icon: '🌊', count: 47 },
|
||||
],
|
||||
contacts: [{ role: '방제과장', name: '윤○○', phone: '061-660-0001' }, { role: '방제담당', name: '장○○', phone: '061-660-0002' }],
|
||||
},
|
||||
{
|
||||
id: 6, type: '해경경찰서', jurisdiction: '서해지방해양경찰청', area: '목포', name: '목포해양경찰서',
|
||||
address: '목포시 고하대로 597번길 99-64', vessel: 10, skimmer: 19, pump: 18, vehicle: 3, sprayer: 16, totalAssets: 169, phone: '010-9812-8439',
|
||||
lat: 34.7936, lng: 126.3839, pinSize: 'lg',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 10 }, { category: '유회수기', icon: '⚙', count: 19 }, { category: '이송펌프', icon: '🔧', count: 18 },
|
||||
{ category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 7 },
|
||||
{ category: '저압세척기', icon: '🚿', count: 4 }, { category: '동력분무기', icon: '💨', count: 2 }, { category: '유량계측기', icon: '📏', count: 1 },
|
||||
{ category: '방제창고', icon: '🏭', count: 21 }, { category: '발전기', icon: '⚡', count: 4 }, { category: '지원장비', icon: '🔩', count: 31 },
|
||||
{ category: '장비부품', icon: '🔗', count: 17 }, { category: '경비함정방제', icon: '⚓', count: 15 }, { category: '살포장치', icon: '🌊', count: 16 },
|
||||
],
|
||||
contacts: [{ role: '방제담당', name: '조○○', phone: '061-244-0001' }],
|
||||
},
|
||||
{
|
||||
id: 7, type: '해경경찰서', jurisdiction: '서해지방해양경찰청', area: '군산', name: '군산해양경찰서',
|
||||
address: '전북 군산시 오식도동 506', vessel: 6, skimmer: 22, pump: 12, vehicle: 3, sprayer: 17, totalAssets: 155, phone: '010-2618-3406',
|
||||
lat: 35.9900, lng: 126.7133, pinSize: 'lg',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 6 }, { category: '유회수기', icon: '⚙', count: 22 }, { category: '비치크리너', icon: '🏖', count: 2 },
|
||||
{ category: '이송펌프', icon: '🔧', count: 12 }, { category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 },
|
||||
{ category: '고압세척기', icon: '💧', count: 5 }, { category: '저압세척기', icon: '🚿', count: 4 }, { category: '동력분무기', icon: '💨', count: 2 },
|
||||
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 6 }, { category: '발전기', icon: '⚡', count: 5 },
|
||||
{ category: '현장지휘소', icon: '🏕', count: 3 }, { category: '지원장비', icon: '🔩', count: 11 }, { category: '장비부품', icon: '🔗', count: 50 },
|
||||
{ category: '경비함정방제', icon: '⚓', count: 5 }, { category: '살포장치', icon: '🌊', count: 17 },
|
||||
],
|
||||
contacts: [{ role: '방제담당', name: '한○○', phone: '063-462-0001' }],
|
||||
},
|
||||
{
|
||||
id: 8, type: '해경경찰서', jurisdiction: '서해지방해양경찰청', area: '완도', name: '완도해양경찰서',
|
||||
address: '완도군 완도읍 장보고대로 383', vessel: 3, skimmer: 9, pump: 7, vehicle: 3, sprayer: 11, totalAssets: 75, phone: '061-550-2183',
|
||||
lat: 34.3110, lng: 126.7550, pinSize: 'lg',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 3 }, { category: '유회수기', icon: '⚙', count: 9 }, { category: '이송펌프', icon: '🔧', count: 7 },
|
||||
{ category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 3 },
|
||||
{ category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '유량계측기', icon: '📏', count: 1 },
|
||||
{ category: '방제창고', icon: '🏭', count: 24 }, { category: '발전기', icon: '⚡', count: 2 },
|
||||
{ category: '경비함정방제', icon: '⚓', count: 8 }, { category: '살포장치', icon: '🌊', count: 11 },
|
||||
],
|
||||
contacts: [{ role: '방제담당', name: '이○○', phone: '061-550-0001' }],
|
||||
},
|
||||
{
|
||||
id: 9, type: '파출소', jurisdiction: '서해지방해양경찰청', area: '부안', name: '부안해양경찰서',
|
||||
address: '전북 군산시 오식도동 506', vessel: 2, skimmer: 8, pump: 7, vehicle: 2, sprayer: 7, totalAssets: 66, phone: '063-928-xxxx',
|
||||
lat: 35.7316, lng: 126.7328, pinSize: 'md',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 2 }, { category: '유회수기', icon: '⚙', count: 8 }, { category: '이송펌프', icon: '🔧', count: 7 },
|
||||
{ category: '방제차량', icon: '🚛', count: 2 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 2 },
|
||||
{ category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '유량계측기', icon: '📏', count: 1 },
|
||||
{ category: '방제창고', icon: '🏭', count: 15 }, { category: '발전기', icon: '⚡', count: 3 }, { category: '현장지휘소', icon: '🏕', count: 2 },
|
||||
{ category: '지원장비', icon: '🔩', count: 6 }, { category: '경비함정방제', icon: '⚓', count: 7 }, { category: '살포장치', icon: '🌊', count: 7 },
|
||||
],
|
||||
contacts: [{ role: '방제담당', name: '김○○', phone: '063-928-0001' }],
|
||||
},
|
||||
// ── 남해지방해양경찰청 ──
|
||||
{
|
||||
id: 10, type: '해경관할', jurisdiction: '남해지방해양경찰청', area: '부산', name: '부산해양경찰서',
|
||||
address: '부산시 영도구 해양로 293', vessel: 108, skimmer: 22, pump: 25, vehicle: 10, sprayer: 24, totalAssets: 313, phone: '010-2609-1456',
|
||||
lat: 35.0746, lng: 129.0686, pinSize: 'hq',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 108 }, { category: '유회수기', icon: '⚙', count: 22 }, { category: '이송펌프', icon: '🔧', count: 25 },
|
||||
{ category: '방제차량', icon: '🚛', count: 10 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 38 },
|
||||
{ category: '저압세척기', icon: '🚿', count: 8 }, { category: '동력분무기', icon: '💨', count: 6 }, { category: '유량계측기', icon: '📏', count: 1 },
|
||||
{ category: '방제창고', icon: '🏭', count: 21 }, { category: '발전기', icon: '⚡', count: 11 }, { category: '현장지휘소', icon: '🏕', count: 2 },
|
||||
{ category: '지원장비', icon: '🔩', count: 20 }, { category: '경비함정방제', icon: '⚓', count: 16 }, { category: '살포장치', icon: '🌊', count: 24 },
|
||||
],
|
||||
contacts: [{ role: '방제과장', name: '임○○', phone: '051-400-0001' }],
|
||||
},
|
||||
{
|
||||
id: 11, type: '해경관할', jurisdiction: '남해지방해양경찰청', area: '울산', name: '울산해양경찰서',
|
||||
address: '울산광역시 남구 장생포 고래로 166', vessel: 46, skimmer: 69, pump: 26, vehicle: 11, sprayer: 20, totalAssets: 311, phone: '010-9812-8210',
|
||||
lat: 35.5008, lng: 129.3824, pinSize: 'hq',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 46 }, { category: '유회수기', icon: '⚙', count: 69 }, { category: '비치크리너', icon: '🏖', count: 4 },
|
||||
{ category: '이송펌프', icon: '🔧', count: 26 }, { category: '방제차량', icon: '🚛', count: 11 }, { category: '해안운반차', icon: '🚜', count: 5 },
|
||||
{ category: '고압세척기', icon: '💧', count: 23 }, { category: '저압세척기', icon: '🚿', count: 6 }, { category: '동력분무기', icon: '💨', count: 6 },
|
||||
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 32 }, { category: '발전기', icon: '⚡', count: 7 },
|
||||
{ category: '현장지휘소', icon: '🏕', count: 1 }, { category: '지원장비', icon: '🔩', count: 40 },
|
||||
{ category: '경비함정방제', icon: '⚓', count: 14 }, { category: '살포장치', icon: '🌊', count: 20 },
|
||||
],
|
||||
contacts: [{ role: '방제과장', name: '강○○', phone: '052-228-0001' }],
|
||||
},
|
||||
{
|
||||
id: 12, type: '해경경찰서', jurisdiction: '남해지방해양경찰청', area: '창원', name: '창원해양경찰서',
|
||||
address: '창원시 마산합포구 신포동 1가', vessel: 12, skimmer: 25, pump: 14, vehicle: 10, sprayer: 10, totalAssets: 139, phone: '010-4634-7364',
|
||||
lat: 35.1796, lng: 128.5681, pinSize: 'lg',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 12 }, { category: '유회수기', icon: '⚙', count: 25 }, { category: '비치크리너', icon: '🏖', count: 2 },
|
||||
{ category: '이송펌프', icon: '🔧', count: 14 }, { category: '방제차량', icon: '🚛', count: 10 }, { category: '해안운반차', icon: '🚜', count: 1 },
|
||||
{ category: '고압세척기', icon: '💧', count: 7 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 },
|
||||
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 21 }, { category: '발전기', icon: '⚡', count: 4 },
|
||||
{ category: '현장지휘소', icon: '🏕', count: 2 }, { category: '지원장비', icon: '🔩', count: 20 },
|
||||
{ category: '경비함정방제', icon: '⚓', count: 7 }, { category: '살포장치', icon: '🌊', count: 10 },
|
||||
],
|
||||
contacts: [{ role: '방제담당', name: '송○○', phone: '055-220-0001' }],
|
||||
},
|
||||
{
|
||||
id: 13, type: '해경경찰서', jurisdiction: '남해지방해양경찰청', area: '통영', name: '통영해양경찰서',
|
||||
address: '통영시 광도면 죽림리 1564-4', vessel: 6, skimmer: 15, pump: 9, vehicle: 5, sprayer: 13, totalAssets: 104, phone: '010-9812-8495',
|
||||
lat: 34.8544, lng: 128.4331, pinSize: 'lg',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 6 }, { category: '유회수기', icon: '⚙', count: 15 }, { category: '비치크리너', icon: '🏖', count: 2 },
|
||||
{ category: '이송펌프', icon: '🔧', count: 9 }, { category: '방제차량', icon: '🚛', count: 5 }, { category: '해안운반차', icon: '🚜', count: 1 },
|
||||
{ category: '고압세척기', icon: '💧', count: 3 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 },
|
||||
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 18 }, { category: '발전기', icon: '⚡', count: 4 },
|
||||
{ category: '현장지휘소', icon: '🏕', count: 1 }, { category: '지원장비', icon: '🔩', count: 11 },
|
||||
{ category: '경비함정방제', icon: '⚓', count: 12 }, { category: '살포장치', icon: '🌊', count: 13 },
|
||||
],
|
||||
contacts: [{ role: '방제담당', name: '서○○', phone: '055-640-0001' }],
|
||||
},
|
||||
{
|
||||
id: 14, type: '해경경찰서', jurisdiction: '남해지방해양경찰청', area: '사천', name: '사천해양경찰서',
|
||||
address: '사천시 신항만길 1길 17', vessel: 2, skimmer: 9, pump: 6, vehicle: 2, sprayer: 7, totalAssets: 80, phone: '010-9812-8352',
|
||||
lat: 34.9310, lng: 128.0660, pinSize: 'lg',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 2 }, { category: '유회수기', icon: '⚙', count: 9 }, { category: '이송펌프', icon: '🔧', count: 6 },
|
||||
{ category: '방제차량', icon: '🚛', count: 2 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 2 },
|
||||
{ category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 4 }, { category: '유량계측기', icon: '📏', count: 1 },
|
||||
{ category: '방제창고', icon: '🏭', count: 31 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '현장지휘소', icon: '🏕', count: 2 },
|
||||
{ category: '지원장비', icon: '🔩', count: 1 }, { category: '경비함정방제', icon: '⚓', count: 8 }, { category: '살포장치', icon: '🌊', count: 7 },
|
||||
],
|
||||
contacts: [{ role: '방제담당', name: '박○○', phone: '055-830-0001' }],
|
||||
},
|
||||
// ── 동해지방해양경찰청 ──
|
||||
{
|
||||
id: 15, type: '해경경찰서', jurisdiction: '동해지방해양경찰청', area: '동해', name: '동해해양경찰서',
|
||||
address: '동해시 임항로 130', vessel: 6, skimmer: 23, pump: 11, vehicle: 6, sprayer: 14, totalAssets: 156, phone: '010-9812-8073',
|
||||
lat: 37.5247, lng: 129.1143, pinSize: 'lg',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 6 }, { category: '유회수기', icon: '⚙', count: 23 }, { category: '비치크리너', icon: '🏖', count: 1 },
|
||||
{ category: '이송펌프', icon: '🔧', count: 11 }, { category: '방제차량', icon: '🚛', count: 6 }, { category: '해안운반차', icon: '🚜', count: 3 },
|
||||
{ category: '고압세척기', icon: '💧', count: 5 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 5 },
|
||||
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 38 }, { category: '발전기', icon: '⚡', count: 2 },
|
||||
{ category: '현장지휘소', icon: '🏕', count: 1 }, { category: '지원장비', icon: '🔩', count: 20 }, { category: '장비부품', icon: '🔗', count: 10 },
|
||||
{ category: '경비함정방제', icon: '⚓', count: 8 }, { category: '살포장치', icon: '🌊', count: 14 },
|
||||
],
|
||||
contacts: [{ role: '방제담당', name: '남○○', phone: '033-530-0001' }],
|
||||
},
|
||||
{
|
||||
id: 16, type: '해경경찰서', jurisdiction: '동해지방해양경찰청', area: '포항', name: '포항해양경찰서',
|
||||
address: '포항시 남구 희망대로 1341', vessel: 10, skimmer: 13, pump: 21, vehicle: 4, sprayer: 21, totalAssets: 135, phone: '010-3108-2183',
|
||||
lat: 36.0190, lng: 129.3651, pinSize: 'lg',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 10 }, { category: '유회수기', icon: '⚙', count: 13 }, { category: '비치크리너', icon: '🏖', count: 1 },
|
||||
{ category: '이송펌프', icon: '🔧', count: 21 }, { category: '방제차량', icon: '🚛', count: 4 }, { category: '해안운반차', icon: '🚜', count: 1 },
|
||||
{ category: '고압세척기', icon: '💧', count: 7 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 3 },
|
||||
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 15 }, { category: '발전기', icon: '⚡', count: 5 },
|
||||
{ category: '현장지휘소', icon: '🏕', count: 1 }, { category: '지원장비', icon: '🔩', count: 20 },
|
||||
{ category: '경비함정방제', icon: '⚓', count: 10 }, { category: '살포장치', icon: '🌊', count: 21 },
|
||||
],
|
||||
contacts: [{ role: '방제담당', name: '오○○', phone: '054-244-0001' }],
|
||||
},
|
||||
{
|
||||
id: 17, type: '파출소', jurisdiction: '동해지방해양경찰청', area: '속초', name: '속초해양경찰서',
|
||||
address: '속초시 설악금강대교로 206', vessel: 2, skimmer: 6, pump: 4, vehicle: 1, sprayer: 17, totalAssets: 85, phone: '033-634-2186',
|
||||
lat: 38.2070, lng: 128.5918, pinSize: 'md',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 2 }, { category: '유회수기', icon: '⚙', count: 6 }, { category: '이송펌프', icon: '🔧', count: 4 },
|
||||
{ category: '방제차량', icon: '🚛', count: 1 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 2 },
|
||||
{ category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '유량계측기', icon: '📏', count: 1 },
|
||||
{ category: '방제창고', icon: '🏭', count: 16 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '지원장비', icon: '🔩', count: 11 },
|
||||
{ category: '장비부품', icon: '🔗', count: 11 }, { category: '경비함정방제', icon: '⚓', count: 8 }, { category: '살포장치', icon: '🌊', count: 17 },
|
||||
],
|
||||
contacts: [{ role: '방제담당', name: '양○○', phone: '033-633-0001' }],
|
||||
},
|
||||
{
|
||||
id: 18, type: '파출소', jurisdiction: '동해지방해양경찰청', area: '울진', name: '울진해양경찰서',
|
||||
address: '울진군 후포면 후포리 623-148', vessel: 2, skimmer: 6, pump: 4, vehicle: 1, sprayer: 8, totalAssets: 66, phone: '010-9812-8076',
|
||||
lat: 36.9932, lng: 129.4003, pinSize: 'md',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 2 }, { category: '유회수기', icon: '⚙', count: 6 }, { category: '이송펌프', icon: '🔧', count: 4 },
|
||||
{ category: '방제차량', icon: '🚛', count: 1 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 3 },
|
||||
{ category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '유량계측기', icon: '📏', count: 1 },
|
||||
{ category: '방제창고', icon: '🏭', count: 13 }, { category: '발전기', icon: '⚡', count: 4 }, { category: '현장지휘소', icon: '🏕', count: 2 },
|
||||
{ category: '지원장비', icon: '🔩', count: 4 }, { category: '장비부품', icon: '🔗', count: 4 },
|
||||
{ category: '경비함정방제', icon: '⚓', count: 10 }, { category: '살포장치', icon: '🌊', count: 8 },
|
||||
],
|
||||
contacts: [{ role: '방제담당', name: '배○○', phone: '054-782-0001' }],
|
||||
},
|
||||
// ── 제주지방해양경찰청 ──
|
||||
{
|
||||
id: 19, type: '해경경찰서', jurisdiction: '제주지방해양경찰청', area: '제주', name: '제주해양경찰서',
|
||||
address: '제주시 임항로 85', vessel: 4, skimmer: 21, pump: 17, vehicle: 3, sprayer: 16, totalAssets: 113, phone: '064-766-2691',
|
||||
lat: 33.5154, lng: 126.5268, pinSize: 'lg',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 4 }, { category: '유회수기', icon: '⚙', count: 21 }, { category: '비치크리너', icon: '🏖', count: 2 },
|
||||
{ category: '이송펌프', icon: '🔧', count: 17 }, { category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 },
|
||||
{ category: '고압세척기', icon: '💧', count: 5 }, { category: '저압세척기', icon: '🚿', count: 3 }, { category: '동력분무기', icon: '💨', count: 4 },
|
||||
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 24 }, { category: '발전기', icon: '⚡', count: 6 },
|
||||
{ category: '현장지휘소', icon: '🏕', count: 2 }, { category: '경비함정방제', icon: '⚓', count: 4 }, { category: '살포장치', icon: '🌊', count: 16 },
|
||||
],
|
||||
contacts: [{ role: '방제담당', name: '문○○', phone: '064-750-0001' }],
|
||||
},
|
||||
{
|
||||
id: 20, type: '해경경찰서', jurisdiction: '제주지방해양경찰청', area: '서귀포', name: '서귀포해양경찰서',
|
||||
address: '서귀포시 안덕면 화순해안로69', vessel: 3, skimmer: 9, pump: 15, vehicle: 3, sprayer: 14, totalAssets: 67, phone: '064-793-2186',
|
||||
lat: 33.2469, lng: 126.5600, pinSize: 'lg',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 3 }, { category: '유회수기', icon: '⚙', count: 9 }, { category: '비치크리너', icon: '🏖', count: 1 },
|
||||
{ category: '이송펌프', icon: '🔧', count: 15 }, { category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 },
|
||||
{ category: '고압세척기', icon: '💧', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '유량계측기', icon: '📏', count: 1 },
|
||||
{ category: '방제창고', icon: '🏭', count: 10 }, { category: '발전기', icon: '⚡', count: 3 },
|
||||
{ category: '경비함정방제', icon: '⚓', count: 4 }, { category: '살포장치', icon: '🌊', count: 14 },
|
||||
],
|
||||
contacts: [{ role: '방제담당', name: '고○○', phone: '064-730-0001' }],
|
||||
},
|
||||
// ── 중앙특수구조단 ──
|
||||
{
|
||||
id: 21, type: '관련기관', jurisdiction: '해양경찰청(중앙)', area: '중앙', name: '중앙특수구조단',
|
||||
address: '부산광역시 영도구 해양로 301', vessel: 1, skimmer: 0, pump: 5, vehicle: 2, sprayer: 0, totalAssets: 39, phone: '051-580-2044',
|
||||
lat: 35.0580, lng: 129.0590, pinSize: 'md',
|
||||
equipment: [
|
||||
{ category: '방제선', icon: '🚢', count: 1 }, { category: '이송펌프', icon: '🔧', count: 5 }, { category: '방제차량', icon: '🚛', count: 2 },
|
||||
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '지원장비', icon: '🔩', count: 27 },
|
||||
{ category: '경비함정방제', icon: '⚓', count: 1 },
|
||||
],
|
||||
contacts: [{ role: '구조단장', name: '김○○', phone: '051-580-2044' }],
|
||||
},
|
||||
// ── 기름저장시설 ──
|
||||
{
|
||||
id: 22, type: '기름저장시설', jurisdiction: '서해지방해양경찰청', area: '여수', name: '오일허브코리아여수㈜ 외 4개',
|
||||
address: '전남 여수시 신덕동 325', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '061-686-3611',
|
||||
lat: 34.745, lng: 127.745, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '오일허브코리아여수㈜', phone: '061-686-3611' }],
|
||||
},
|
||||
{
|
||||
id: 23, type: '기름저장시설', jurisdiction: '남해지방해양경찰청', area: '부산', name: 'SK에너지 외 2개',
|
||||
address: '부산시 영도구 해양로 1', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 3, phone: '051-643-3331',
|
||||
lat: 35.175, lng: 129.075, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '방제창고', icon: '🏭', count: 1 }],
|
||||
contacts: [{ role: '담당', name: 'HD현대오일뱅크㈜', phone: '051-643-3331' }],
|
||||
},
|
||||
{
|
||||
id: 24, type: '기름저장시설', jurisdiction: '남해지방해양경찰청', area: '울산', name: 'SK지오센트릭 외 5개',
|
||||
address: '울산광역시 남구 신여천로 2', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 9, phone: '052-208-2851',
|
||||
lat: 35.535, lng: 129.305, pinSize: 'md',
|
||||
equipment: [{ category: '동력분무기', icon: '💨', count: 4 }, { category: '방제창고', icon: '🏭', count: 5 }],
|
||||
contacts: [{ role: '담당', name: 'SK엔텀㈜', phone: '052-208-2851' }],
|
||||
},
|
||||
{
|
||||
id: 25, type: '기름저장시설', jurisdiction: '남해지방해양경찰청', area: '통영', name: '한국가스공사 통영기지본부',
|
||||
address: '통영시 광도면 안정로 770', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '055-640-6014',
|
||||
lat: 35.05, lng: 128.41, pinSize: 'md',
|
||||
equipment: [{ category: '방제창고', icon: '🏭', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '한국가스공사', phone: '055-640-6014' }],
|
||||
},
|
||||
{
|
||||
id: 26, type: '기름저장시설', jurisdiction: '동해지방해양경찰청', area: '동해', name: 'HD현대오일뱅크㈜ 외 4개',
|
||||
address: '강릉시 옥계면 동해대로 206', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 9, phone: '033-534-2093',
|
||||
lat: 37.52, lng: 129.11, pinSize: 'md',
|
||||
equipment: [{ category: '동력분무기', icon: '💨', count: 2 }, { category: '방제창고', icon: '🏭', count: 6 }, { category: '발전기', icon: '⚡', count: 1 }],
|
||||
contacts: [{ role: '담당', name: 'HD현대오일뱅크㈜', phone: '033-534-2093' }],
|
||||
},
|
||||
{
|
||||
id: 27, type: '기름저장시설', jurisdiction: '동해지방해양경찰청', area: '포항', name: '포스코케미칼 외 1개',
|
||||
address: '포항시 남구 동해안로 6262', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 2, phone: '054-290-8222',
|
||||
lat: 37.73, lng: 129.01, pinSize: 'md',
|
||||
equipment: [{ category: '동력분무기', icon: '💨', count: 1 }, { category: '살포장치', icon: '🌊', count: 1 }],
|
||||
contacts: [{ role: '담당', name: 'OCI(주)', phone: '054-290-8222' }],
|
||||
},
|
||||
{
|
||||
id: 28, type: '기름저장시설', jurisdiction: '서해지방해양경찰청', area: '목포', name: '흑산도내연발전소 외 2개',
|
||||
address: '전남 신안군 흑산일주로70', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 4, phone: '061-351-2342',
|
||||
lat: 35.04, lng: 126.58, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '발전기', icon: '⚡', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '안마도내연발전소', phone: '061-351-2342' }],
|
||||
},
|
||||
{
|
||||
id: 29, type: '기름저장시설', jurisdiction: '서해지방해양경찰청', area: '여수', name: '오일허브코리아여수㈜ 외 4개',
|
||||
address: '전남 여수시 신덕동 325', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 4, phone: '061-686-3611',
|
||||
lat: 34.75, lng: 127.735, pinSize: 'md',
|
||||
equipment: [{ category: '동력분무기', icon: '💨', count: 1 }, { category: '방제창고', icon: '🏭', count: 3 }],
|
||||
contacts: [{ role: '담당', name: '오일허브코리아여수㈜', phone: '061-686-3611' }],
|
||||
},
|
||||
{
|
||||
id: 30, type: '기름저장시설', jurisdiction: '중부지방해양경찰청', area: '인천', name: 'GS칼텍스㈜ 외 10개',
|
||||
address: '인천광역시 중구 월미로 182', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 13, phone: '010-8777-6922',
|
||||
lat: 37.45, lng: 126.505, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 7 }, { category: '동력분무기', icon: '💨', count: 6 }],
|
||||
contacts: [{ role: '담당', name: 'GS칼텍스㈜', phone: '010-8777-6922' }],
|
||||
},
|
||||
{
|
||||
id: 31, type: '기름저장시설', jurisdiction: '중부지방해양경찰청', area: '태안', name: 'HD현대케미칼 외 4개',
|
||||
address: '충남 서산시 대산읍 평신2로 26', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 4, phone: '041-924-1068',
|
||||
lat: 36.91, lng: 126.415, pinSize: 'md',
|
||||
equipment: [{ category: '해안운반차', icon: '🚜', count: 2 }, { category: '동력분무기', icon: '💨', count: 2 }],
|
||||
contacts: [{ role: '담당', name: 'HD현대케미칼', phone: '041-924-1068' }],
|
||||
},
|
||||
{
|
||||
id: 32, type: '기름저장시설', jurisdiction: '중부지방해양경찰청', area: '평택', name: '현대오일터미널(주) 외 4개',
|
||||
address: '평택시 포승읍 포승공단순환로 11', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 4, phone: '031-683-5101',
|
||||
lat: 36.985, lng: 126.835, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 2 }, { category: '발전기', icon: '⚡', count: 1 }, { category: '살포장치', icon: '🌊', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '(주)경동탱크터미널', phone: '031-683-5101' }],
|
||||
},
|
||||
// ── 기타 ──
|
||||
{
|
||||
id: 33, type: '기타', jurisdiction: '남해지방해양경찰청', area: '사천', name: '한국남동발전(주) 외 2개',
|
||||
address: '고성군 하이면 하이로1', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 8, phone: '070-4486-7474',
|
||||
lat: 34.965, lng: 128.56, pinSize: 'md',
|
||||
equipment: [{ category: '동력분무기', icon: '💨', count: 3 }, { category: '방제창고', icon: '🏭', count: 5 }],
|
||||
contacts: [{ role: '담당', name: '(주)고성그린파워', phone: '070-4486-7474' }],
|
||||
},
|
||||
{
|
||||
id: 34, type: '기타', jurisdiction: '남해지방해양경찰청', area: '울산', name: 'HD현대미포',
|
||||
address: '울산광역시 동구 방어진순환도로100', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '052-250-3551',
|
||||
lat: 35.53, lng: 129.315, pinSize: 'md',
|
||||
equipment: [{ category: '방제창고', icon: '🏭', count: 1 }],
|
||||
contacts: [{ role: '담당', name: 'HD현대미포', phone: '052-250-3551' }],
|
||||
},
|
||||
{
|
||||
id: 35, type: '기타', jurisdiction: '남해지방해양경찰청', area: '통영', name: '삼성중공업 외 1개',
|
||||
address: '거제시 장평3로 80', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 2, phone: '055-630-5373',
|
||||
lat: 35.05, lng: 128.41, pinSize: 'md',
|
||||
equipment: [{ category: '동력분무기', icon: '💨', count: 2 }],
|
||||
contacts: [{ role: '담당', name: '삼성중공업', phone: '055-630-5373' }],
|
||||
},
|
||||
{
|
||||
id: 36, type: '기타', jurisdiction: '동해지방해양경찰청', area: '동해', name: '한국남부발전㈜',
|
||||
address: '삼척시 원덕읍 삼척로 734', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 2, phone: '070-7713-5153',
|
||||
lat: 37.45, lng: 129.17, pinSize: 'md',
|
||||
equipment: [{ category: '방제창고', icon: '🏭', count: 2 }],
|
||||
contacts: [{ role: '담당', name: '한국남부발전㈜', phone: '070-7713-5153' }],
|
||||
},
|
||||
{
|
||||
id: 37, type: '기타', jurisdiction: '동해지방해양경찰청', area: '울진', name: '한울원전',
|
||||
address: '울진군 북면 울진북로 2040', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 4, phone: '054-785-4833',
|
||||
lat: 37.065, lng: 129.39, pinSize: 'md',
|
||||
equipment: [{ category: '방제창고', icon: '🏭', count: 2 }, { category: '지원장비', icon: '🔩', count: 2 }],
|
||||
contacts: [{ role: '담당', name: '한울원전', phone: '054-785-4833' }],
|
||||
},
|
||||
{
|
||||
id: 38, type: '기타', jurisdiction: '서해지방해양경찰청', area: '여수', name: '㈜HR-PORT 외 5개',
|
||||
address: '여수시 제철로', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 16, phone: '061-791-0358',
|
||||
lat: 34.755, lng: 127.73, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '동력분무기', icon: '💨', count: 7 }, { category: '방제창고', icon: '🏭', count: 3 }, { category: '발전기', icon: '⚡', count: 1 }, { category: '지원장비', icon: '🔩', count: 3 }],
|
||||
contacts: [{ role: '담당', name: '㈜ 한진', phone: '061-791-0358' }],
|
||||
},
|
||||
{
|
||||
id: 39, type: '기타', jurisdiction: '중부지방해양경찰청', area: '인천', name: '삼광조선공업㈜ 외 1개',
|
||||
address: '인천 동구 보세로42번길41', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 5, phone: '010-3321-2959',
|
||||
lat: 37.45, lng: 126.505, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 5 }],
|
||||
contacts: [{ role: '담당', name: '삼광조선공업㈜', phone: '010-3321-2959' }],
|
||||
},
|
||||
// ── 방제유창청소업체 ──
|
||||
{
|
||||
id: 40, type: '업체', jurisdiction: '중부지방해양경찰청', area: '인천', name: '방제유창청소업체(㈜클린포트)',
|
||||
address: '㈜클린포트', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 3, phone: '032-882-8279',
|
||||
lat: 37.45, lng: 126.505, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 2 }, { category: '발전기', icon: '⚡', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '㈜클린포트', phone: '032-882-8279' }],
|
||||
},
|
||||
{
|
||||
id: 41, type: '업체', jurisdiction: '남해지방해양경찰청', area: '부산', name: '방제유창청소업체(대용환경㈜ 외 38개)',
|
||||
address: '㈜태평양해양산업', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 51, phone: '051-242-0622',
|
||||
lat: 35.18, lng: 129.085, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 31 }, { category: '저압세척기', icon: '🚿', count: 5 }, { category: '동력분무기', icon: '💨', count: 3 }, { category: '방제창고', icon: '🏭', count: 5 }, { category: '발전기', icon: '⚡', count: 6 }, { category: '현장지휘소', icon: '🏕', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '(주)경원마린서비스', phone: '051-242-0622' }],
|
||||
},
|
||||
{
|
||||
id: 42, type: '업체', jurisdiction: '남해지방해양경찰청', area: '울산', name: '방제유창청소업체((주)한유마린서비스 외 8개)',
|
||||
address: '대상해운(주)', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 12, phone: '010-5499-7401',
|
||||
lat: 35.54, lng: 129.295, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 11 }, { category: '방제창고', icon: '🏭', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '(주)골든씨', phone: '010-5499-7401' }],
|
||||
},
|
||||
{
|
||||
id: 43, type: '업체', jurisdiction: '동해지방해양경찰청', area: '포항', name: '방제유창청소업체(블루씨 외 1개)',
|
||||
address: '(주)블루씨', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 3, phone: '054-278-8200',
|
||||
lat: 36.015, lng: 129.365, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 2 }, { category: '살포장치', icon: '🌊', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '(주)블루씨', phone: '054-278-8200' }],
|
||||
},
|
||||
{
|
||||
id: 44, type: '업체', jurisdiction: '서해지방해양경찰청', area: '목포', name: '방제유창청소업체(㈜한국해운 외 1개)',
|
||||
address: '㈜한국해운 목포지사', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '010-8615-4326',
|
||||
lat: 35.04, lng: 126.58, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '㈜아라', phone: '010-8615-4326' }],
|
||||
},
|
||||
{
|
||||
id: 45, type: '업체', jurisdiction: '서해지방해양경찰청', area: '여수', name: '방제유창청소업체(마로해운 외 11개)',
|
||||
address: '㈜우진실업', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 2, totalAssets: 54, phone: '061-654-9603',
|
||||
lat: 34.74, lng: 127.75, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 28 }, { category: '동력분무기', icon: '💨', count: 15 }, { category: '방제창고', icon: '🏭', count: 5 }, { category: '발전기', icon: '⚡', count: 4 }, { category: '살포장치', icon: '🌊', count: 2 }],
|
||||
contacts: [{ role: '담당', name: '(유)피케이엘', phone: '061-654-9603' }],
|
||||
},
|
||||
{
|
||||
id: 46, type: '업체', jurisdiction: '중부지방해양경찰청', area: '태안', name: '방제유창청소업체(우진해운㈜)',
|
||||
address: '우진해운㈜', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 6, phone: '010-4384-6817',
|
||||
lat: 36.905, lng: 126.42, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '발전기', icon: '⚡', count: 2 }],
|
||||
contacts: [{ role: '담당', name: '우진해운㈜', phone: '010-4384-6817' }],
|
||||
},
|
||||
{
|
||||
id: 47, type: '업체', jurisdiction: '중부지방해양경찰청', area: '평택', name: '방제유창청소업체((주)씨앤 외 3개)',
|
||||
address: '㈜씨앤', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 6, phone: '031-683-2389',
|
||||
lat: 36.99, lng: 126.825, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '발전기', icon: '⚡', count: 2 }],
|
||||
contacts: [{ role: '담당', name: '(주)소스코리아', phone: '031-683-2389' }],
|
||||
},
|
||||
{
|
||||
id: 48, type: '업체', jurisdiction: '남해지방해양경찰청', area: '부산', name: '방제유창청소업체(㈜지앤비마린서비스)',
|
||||
address: '㈜지앤비마린서비스', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '051-242-0622',
|
||||
lat: 35.185, lng: 129.07, pinSize: 'md',
|
||||
equipment: [{ category: '방제창고', icon: '🏭', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '(주)경원마린서비스', phone: '051-242-0622' }],
|
||||
},
|
||||
// ── 정유사 ──
|
||||
{
|
||||
id: 49, type: '정유사', jurisdiction: '남해지방해양경찰청', area: '울산', name: 'SK엔텀(주) 외 4개',
|
||||
address: '울산광역시 남구 고사동 110-64', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 5, phone: '052-231-2318',
|
||||
lat: 35.545, lng: 129.31, pinSize: 'md',
|
||||
equipment: [{ category: '방제창고', icon: '🏭', count: 5 }],
|
||||
contacts: [{ role: '담당', name: 'S-OIL㈜', phone: '052-231-2318' }],
|
||||
},
|
||||
{
|
||||
id: 50, type: '정유사', jurisdiction: '서해지방해양경찰청', area: '여수', name: 'GS칼텍스㈜',
|
||||
address: '여수시 낙포단지길 251', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 20, totalAssets: 27, phone: '061-680-2121',
|
||||
lat: 34.735, lng: 127.755, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '방제창고', icon: '🏭', count: 4 }, { category: '살포장치', icon: '🌊', count: 20 }],
|
||||
contacts: [{ role: '담당', name: 'GS칼텍스㈜', phone: '061-680-2121' }],
|
||||
},
|
||||
{
|
||||
id: 51, type: '정유사', jurisdiction: '중부지방해양경찰청', area: '태안', name: 'HD현대오일뱅크㈜',
|
||||
address: '서산시 대산읍 평신2로 182', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 2, phone: '010-2050-5291',
|
||||
lat: 36.915, lng: 126.41, pinSize: 'md',
|
||||
equipment: [{ category: '동력분무기', icon: '💨', count: 2 }],
|
||||
contacts: [{ role: '담당', name: 'HD현대오일뱅크㈜', phone: '010-2050-5291' }],
|
||||
},
|
||||
// ── 지자체 ──
|
||||
{
|
||||
id: 52, type: '지자체', jurisdiction: '남해지방해양경찰청', area: '부산', name: '부산광역시 외 8개',
|
||||
address: '부산광역시 동구 좌천동', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 12, phone: '051-607-4484',
|
||||
lat: 35.17, lng: 129.08, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '방제창고', icon: '🏭', count: 11 }],
|
||||
contacts: [{ role: '담당', name: '남구청', phone: '051-607-4484' }],
|
||||
},
|
||||
{
|
||||
id: 53, type: '지자체', jurisdiction: '남해지방해양경찰청', area: '사천', name: '사천시 외 3개',
|
||||
address: '사천시 신항로 3', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 6, phone: '055-670-2484',
|
||||
lat: 34.935, lng: 128.075, pinSize: 'md',
|
||||
equipment: [{ category: '방제창고', icon: '🏭', count: 6 }],
|
||||
contacts: [{ role: '담당', name: '고성군', phone: '055-670-2484' }],
|
||||
},
|
||||
{
|
||||
id: 54, type: '지자체', jurisdiction: '남해지방해양경찰청', area: '울산', name: '울산북구청 외 2개',
|
||||
address: '울산광역시 북구 구유동 654-2', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 4, phone: '051-709-4611',
|
||||
lat: 35.55, lng: 129.32, pinSize: 'md',
|
||||
equipment: [{ category: '방제창고', icon: '🏭', count: 4 }],
|
||||
contacts: [{ role: '담당', name: '부산기장군청', phone: '051-709-4611' }],
|
||||
},
|
||||
{
|
||||
id: 55, type: '지자체', jurisdiction: '남해지방해양경찰청', area: '창원', name: '창원 진해구 외 1개',
|
||||
address: '창원시 진해구 천자로 105', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 2, phone: '051-970-4482',
|
||||
lat: 35.055, lng: 128.645, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '방제창고', icon: '🏭', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '부산 강서구', phone: '051-970-4482' }],
|
||||
},
|
||||
{
|
||||
id: 56, type: '지자체', jurisdiction: '동해지방해양경찰청', area: '동해', name: '삼척시 외 1개',
|
||||
address: '삼척시 근덕면 덕산리 107-74', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 4, phone: '033-640-5284',
|
||||
lat: 37.45, lng: 129.16, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '방제창고', icon: '🏭', count: 3 }],
|
||||
contacts: [{ role: '담당', name: '강릉시', phone: '033-640-5284' }],
|
||||
},
|
||||
{
|
||||
id: 57, type: '지자체', jurisdiction: '동해지방해양경찰청', area: '울진', name: '영덕군',
|
||||
address: '남정면 장사리 74-1', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 3, phone: '054-730-6562',
|
||||
lat: 36.35, lng: 129.4, pinSize: 'md',
|
||||
equipment: [{ category: '방제창고', icon: '🏭', count: 3 }],
|
||||
contacts: [{ role: '담당', name: '영덕군', phone: '054-730-6562' }],
|
||||
},
|
||||
{
|
||||
id: 58, type: '지자체', jurisdiction: '서해지방해양경찰청', area: '목포', name: '영광군 외 1개',
|
||||
address: '영광군 염산면 향화로', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 5, phone: '061-270-3419',
|
||||
lat: 35.04, lng: 126.58, pinSize: 'md',
|
||||
equipment: [{ category: '방제창고', icon: '🏭', count: 5 }],
|
||||
contacts: [{ role: '담당', name: '목포시', phone: '061-270-3419' }],
|
||||
},
|
||||
{
|
||||
id: 59, type: '지자체', jurisdiction: '서해지방해양경찰청', area: '여수', name: '광양시 외 1개',
|
||||
address: '순천시 진상면 성지로 8', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 3, phone: '061-797-2791',
|
||||
lat: 34.76, lng: 127.725, pinSize: 'md',
|
||||
equipment: [{ category: '방제창고', icon: '🏭', count: 3 }],
|
||||
contacts: [{ role: '담당', name: '광양시', phone: '061-797-2791' }],
|
||||
},
|
||||
{
|
||||
id: 60, type: '지자체', jurisdiction: '중부지방해양경찰청', area: '인천', name: '옹진군청 외 4개',
|
||||
address: '인천광역시 옹진군 덕적면 진리 387', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 10, phone: '010-2740-9388',
|
||||
lat: 37.45, lng: 126.505, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 5 }, { category: '동력분무기', icon: '💨', count: 4 }, { category: '발전기', icon: '⚡', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '김포시청', phone: '010-2740-9388' }],
|
||||
},
|
||||
{
|
||||
id: 61, type: '지자체', jurisdiction: '중부지방해양경찰청', area: '태안', name: '태안군청',
|
||||
address: '충남 태안군 근흥면 신진도리 75-36', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '041-670-2877',
|
||||
lat: 36.745, lng: 126.305, pinSize: 'md',
|
||||
equipment: [{ category: '방제창고', icon: '🏭', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '태안군청', phone: '041-670-2877' }],
|
||||
},
|
||||
{
|
||||
id: 62, type: '지자체', jurisdiction: '중부지방해양경찰청', area: '평택', name: '안산시청 외 2개',
|
||||
address: '경기도 안산시 단원구 진두길 97', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 6, phone: '041-350-4292',
|
||||
lat: 37.32, lng: 126.83, pinSize: 'md',
|
||||
equipment: [{ category: '동력분무기', icon: '💨', count: 1 }, { category: '방제창고', icon: '🏭', count: 5 }],
|
||||
contacts: [{ role: '담당', name: '당진시청', phone: '041-350-4292' }],
|
||||
},
|
||||
// ── 하역시설 ──
|
||||
{
|
||||
id: 63, type: '기타', jurisdiction: '서해지방해양경찰청', area: '여수', name: '㈜HR-PORT 외 5개',
|
||||
address: '여수시 제철로', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '061-791-0358',
|
||||
lat: 34.748, lng: 127.74, pinSize: 'md',
|
||||
equipment: [{ category: '방제창고', icon: '🏭', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '㈜ 한진', phone: '061-791-0358' }],
|
||||
},
|
||||
// ── 해군 ──
|
||||
{
|
||||
id: 64, type: '해군', jurisdiction: '동해지방해양경찰청', area: '동해', name: '해군1함대사령부 외 1개',
|
||||
address: '동해시 대동로 430', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '033-539-7323',
|
||||
lat: 37.525, lng: 129.115, pinSize: 'md',
|
||||
equipment: [{ category: '방제창고', icon: '🏭', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '1함대 사령부', phone: '033-539-7323' }],
|
||||
},
|
||||
{
|
||||
id: 65, type: '해군', jurisdiction: '중부지방해양경찰청', area: '인천', name: '해병대 제9518부대',
|
||||
address: '인천광역시 옹진군', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 2, phone: '010-4801-3473',
|
||||
lat: 37.45, lng: 126.505, pinSize: 'md',
|
||||
equipment: [{ category: '발전기', icon: '⚡', count: 2 }],
|
||||
contacts: [{ role: '담당', name: '해병대 제9518부대', phone: '010-4801-3473' }],
|
||||
},
|
||||
// ── 해양환경공단 ──
|
||||
{
|
||||
id: 66, type: '해양환경공단', jurisdiction: '남해지방해양경찰청', area: '부산', name: '부산지사',
|
||||
address: '창원시 진해구 안골동', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 6, totalAssets: 14, phone: '051-466-3944',
|
||||
lat: 35.105, lng: 128.715, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '방제창고', icon: '🏭', count: 1 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '현장지휘소', icon: '🏕', count: 1 }, { category: '살포장치', icon: '🌊', count: 6 }],
|
||||
contacts: [{ role: '담당', name: '부산지사', phone: '051-466-3944' }],
|
||||
},
|
||||
{
|
||||
id: 67, type: '해양환경공단', jurisdiction: '남해지방해양경찰청', area: '사천', name: '마산지사',
|
||||
address: '사천시 신항만1길 23', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 9, phone: '010-3598-4202',
|
||||
lat: 34.925, lng: 128.065, pinSize: 'md',
|
||||
equipment: [{ category: '방제창고', icon: '🏭', count: 8 }, { category: '발전기', icon: '⚡', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '마산지사', phone: '010-3598-4202' }],
|
||||
},
|
||||
{
|
||||
id: 68, type: '해양환경공단', jurisdiction: '남해지방해양경찰청', area: '울산', name: '울산지사',
|
||||
address: '울산광역시 남구 장생포고래로 276번길 27', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 16, phone: '052-238-7718',
|
||||
lat: 35.538, lng: 129.3, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 6 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '방제창고', icon: '🏭', count: 4 }, { category: '발전기', icon: '⚡', count: 4 }, { category: '살포장치', icon: '🌊', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '울산지사', phone: '052-238-7718' }],
|
||||
},
|
||||
{
|
||||
id: 69, type: '해양환경공단', jurisdiction: '남해지방해양경찰청', area: '창원', name: '마산지사',
|
||||
address: '창원시 마산합포구 드림베이대로59', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 7, phone: '010-2265-3928',
|
||||
lat: 35.055, lng: 128.645, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 4 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '살포장치', icon: '🌊', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '마산지사', phone: '010-2265-3928' }],
|
||||
},
|
||||
{
|
||||
id: 70, type: '해양환경공단', jurisdiction: '남해지방해양경찰청', area: '통영', name: '마산지사',
|
||||
address: '거제시 장승로 112', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 8, phone: '010-2636-5313',
|
||||
lat: 35.05, lng: 128.41, pinSize: 'md',
|
||||
equipment: [{ category: '방제창고', icon: '🏭', count: 8 }],
|
||||
contacts: [{ role: '담당', name: '마산지사', phone: '010-2636-5313' }],
|
||||
},
|
||||
{
|
||||
id: 71, type: '해양환경공단', jurisdiction: '동해지방해양경찰청', area: '동해', name: '동해지사',
|
||||
address: '동해시 대동로 210', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 2, totalAssets: 17, phone: '010-7499-0257',
|
||||
lat: 37.515, lng: 129.105, pinSize: 'md',
|
||||
equipment: [{ category: '해안운반차', icon: '🚜', count: 2 }, { category: '고압세척기', icon: '💧', count: 2 }, { category: '동력분무기', icon: '💨', count: 2 }, { category: '방제창고', icon: '🏭', count: 8 }, { category: '발전기', icon: '⚡', count: 1 }, { category: '살포장치', icon: '🌊', count: 2 }],
|
||||
contacts: [{ role: '담당', name: '동해지사', phone: '010-7499-0257' }],
|
||||
},
|
||||
{
|
||||
id: 72, type: '해양환경공단', jurisdiction: '동해지방해양경찰청', area: '울진', name: '포항지사',
|
||||
address: '울진군 죽변면 죽변리 36-88', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 2, phone: '054-273-5595',
|
||||
lat: 37.06, lng: 129.42, pinSize: 'md',
|
||||
equipment: [{ category: '방제창고', icon: '🏭', count: 2 }],
|
||||
contacts: [{ role: '담당', name: '포항지사', phone: '054-273-5595' }],
|
||||
},
|
||||
{
|
||||
id: 73, type: '해양환경공단', jurisdiction: '동해지방해양경찰청', area: '포항', name: '포항지사',
|
||||
address: '포항시 북구 해안로 44-10', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 2, totalAssets: 8, phone: '054-273-5595',
|
||||
lat: 36.025, lng: 129.375, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '현장지휘소', icon: '🏕', count: 1 }, { category: '살포장치', icon: '🌊', count: 2 }],
|
||||
contacts: [{ role: '담당', name: '포항지사', phone: '054-273-5595' }],
|
||||
},
|
||||
{
|
||||
id: 74, type: '해양환경공단', jurisdiction: '서해지방해양경찰청', area: '군산', name: '군산지사',
|
||||
address: '군산시 임해로 452', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 4, totalAssets: 12, phone: '063-443-4813',
|
||||
lat: 35.975, lng: 126.715, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 2 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '발전기', icon: '⚡', count: 3 }, { category: '살포장치', icon: '🌊', count: 4 }],
|
||||
contacts: [{ role: '담당', name: '군산지사', phone: '063-443-4813' }],
|
||||
},
|
||||
{
|
||||
id: 75, type: '해양환경공단', jurisdiction: '서해지방해양경찰청', area: '목포', name: '목포지사',
|
||||
address: '전남 목포시 죽교동 683', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 10, phone: '061-242-9663',
|
||||
lat: 35.04, lng: 126.58, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '방제창고', icon: '🏭', count: 6 }, { category: '발전기', icon: '⚡', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '목포지사', phone: '061-242-9663' }],
|
||||
},
|
||||
{
|
||||
id: 76, type: '해양환경공단', jurisdiction: '서해지방해양경찰청', area: '여수', name: '여수지사',
|
||||
address: '여수시 덕충동', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 3, totalAssets: 12, phone: '061-654-6431',
|
||||
lat: 34.742, lng: 127.748, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 5 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '발전기', icon: '⚡', count: 3 }, { category: '살포장치', icon: '🌊', count: 3 }],
|
||||
contacts: [{ role: '담당', name: '여수지사', phone: '061-654-6431' }],
|
||||
},
|
||||
{
|
||||
id: 77, type: '해양환경공단', jurisdiction: '서해지방해양경찰청', area: '완도', name: '목포지사 완도사업소',
|
||||
address: '완도군 완도읍 해변공원로 20-1', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 3, phone: '061-242-9663',
|
||||
lat: 34.315, lng: 126.755, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '방제창고', icon: '🏭', count: 2 }],
|
||||
contacts: [{ role: '담당', name: '목포지사', phone: '061-242-9663' }],
|
||||
},
|
||||
{
|
||||
id: 78, type: '해양환경공단', jurisdiction: '제주지방해양경찰청', area: '서귀포', name: '제주지사(서귀포)',
|
||||
address: '서귀포시 칠십리로72번길 14', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 1, phone: '064-753-4356',
|
||||
lat: 33.245, lng: 126.565, pinSize: 'md',
|
||||
equipment: [{ category: '살포장치', icon: '🌊', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '제주지사', phone: '064-753-4356' }],
|
||||
},
|
||||
{
|
||||
id: 79, type: '해양환경공단', jurisdiction: '제주지방해양경찰청', area: '제주', name: '제주지사(제주)',
|
||||
address: '제주시 임항로97', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 3, totalAssets: 20, phone: '064-753-4356',
|
||||
lat: 33.517, lng: 126.528, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '동력분무기', icon: '💨', count: 2 }, { category: '방제창고', icon: '🏭', count: 10 }, { category: '발전기', icon: '⚡', count: 1 }, { category: '살포장치', icon: '🌊', count: 3 }],
|
||||
contacts: [{ role: '담당', name: '제주지사', phone: '064-753-4356' }],
|
||||
},
|
||||
{
|
||||
id: 80, type: '해양환경공단', jurisdiction: '중부지방해양경찰청', area: '보령', name: '대산지사(보령)',
|
||||
address: '보령시 해안로 740', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 5, phone: '041-664-9101',
|
||||
lat: 36.333, lng: 126.612, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '방제창고', icon: '🏭', count: 4 }],
|
||||
contacts: [{ role: '담당', name: '대산지사', phone: '041-664-9101' }],
|
||||
},
|
||||
{
|
||||
id: 81, type: '해양환경공단', jurisdiction: '중부지방해양경찰청', area: '인천', name: '인천지사',
|
||||
address: '인천광역시 중구 연안부두로 128번길 35', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 11, phone: '010-7133-2167',
|
||||
lat: 37.45, lng: 126.505, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 5 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '동력분무기', icon: '💨', count: 3 }, { category: '발전기', icon: '⚡', count: 2 }],
|
||||
contacts: [{ role: '담당', name: '인천지사', phone: '010-7133-2167' }],
|
||||
},
|
||||
{
|
||||
id: 82, type: '해양환경공단', jurisdiction: '중부지방해양경찰청', area: '태안', name: '대산지사(태안)',
|
||||
address: '서산시 대산읍 대죽1로 325', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 17, phone: '041-664-9101',
|
||||
lat: 36.908, lng: 126.413, pinSize: 'md',
|
||||
equipment: [{ category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 5 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '방제창고', icon: '🏭', count: 5 }, { category: '발전기', icon: '⚡', count: 3 }, { category: '살포장치', icon: '🌊', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '대산지사', phone: '041-664-9101' }],
|
||||
},
|
||||
{
|
||||
id: 83, type: '해양환경공단', jurisdiction: '중부지방해양경찰청', area: '평택', name: '평택지사',
|
||||
address: '당진시 송악읍 고대공단2길', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 13, phone: '031-683-7973',
|
||||
lat: 36.905, lng: 126.635, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '방제창고', icon: '🏭', count: 3 }, { category: '발전기', icon: '⚡', count: 4 }, { category: '살포장치', icon: '🌊', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '평택지사', phone: '031-683-7973' }],
|
||||
},
|
||||
// ── 수협 ──
|
||||
{
|
||||
id: 84, type: '기타', jurisdiction: '남해지방해양경찰청', area: '통영', name: '삼성중공업 외 1개',
|
||||
address: '거제시 장평3로 80', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '055-630-5373',
|
||||
lat: 35.05, lng: 128.41, pinSize: 'md',
|
||||
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }],
|
||||
contacts: [{ role: '담당', name: '삼성중공업', phone: '055-630-5373' }],
|
||||
},
|
||||
]
|
||||
|
||||
export const insuranceDemoData: InsuranceRow[] = [
|
||||
{ shipName: '유조선 한라호', mmsi: '440123456', imo: '9876001', insType: 'P&I 보험', insurer: '한국P&I클럽', policyNo: 'PI-2025-1234', start: '2025-07-01', expiry: '2026-06-30', limit: '50억' },
|
||||
{ shipName: '화학물질운반선 제주호', mmsi: '440345678', imo: '9876002', insType: '선주책임보험', insurer: '삼성화재', policyNo: 'SF-2025-9012', start: '2025-09-16', expiry: '2026-09-15', limit: '80억' },
|
||||
{ shipName: '방제선 OCEAN STAR', mmsi: '440123789', imo: '9876003', insType: 'P&I 보험', insurer: '한국P&I클럽', policyNo: 'PI-2025-3456', start: '2025-11-21', expiry: '2026-11-20', limit: '120억' },
|
||||
{ shipName: 'LNG운반선 부산호', mmsi: '440567890', imo: '9876004', insType: '해상보험', insurer: 'DB손해보험', policyNo: 'DB-2025-7890', start: '2025-08-02', expiry: '2026-08-01', limit: '200억' },
|
||||
{ shipName: '유조선 백두호', mmsi: '440789012', imo: '9876005', insType: 'P&I 보험', insurer: 'SK해운보험', policyNo: 'MH-2025-5678', start: '2025-01-01', expiry: '2025-12-31', limit: '30억' },
|
||||
]
|
||||
|
||||
export const uploadHistory = [
|
||||
{ filename: '여수서_장비자재_2601.xlsx', date: '2026-01-25 14:30', uploader: '남해청_방제과', count: 45 },
|
||||
{ filename: '인천서_오일펜스현황.xlsx', date: '2026-01-22 10:15', uploader: '중부청_방제과', count: 12 },
|
||||
{ filename: '전체_방제정_현황.xlsx', date: '2026-01-20 09:00', uploader: '본청_방제과', count: 18 },
|
||||
]
|
||||
65
frontend/src/tabs/assets/components/assetTypes.ts
Normal file
65
frontend/src/tabs/assets/components/assetTypes.ts
Normal file
@ -0,0 +1,65 @@
|
||||
export type AssetsTab = 'management' | 'upload' | 'theory' | 'insurance'
|
||||
|
||||
export interface AssetOrg {
|
||||
id: number
|
||||
type: string
|
||||
jurisdiction: string
|
||||
area: string
|
||||
name: string
|
||||
address: string
|
||||
vessel: number
|
||||
skimmer: number
|
||||
pump: number
|
||||
vehicle: number
|
||||
sprayer: number
|
||||
totalAssets: number
|
||||
phone: string
|
||||
lat: number
|
||||
lng: number
|
||||
pinSize: 'hq' | 'lg' | 'md'
|
||||
equipment: { category: string; icon: string; count: number }[]
|
||||
contacts: { role: string; name: string; phone: string }[]
|
||||
}
|
||||
|
||||
export interface InsuranceRow {
|
||||
shipName: string
|
||||
mmsi: string
|
||||
imo: string
|
||||
insType: string
|
||||
insurer: string
|
||||
policyNo: string
|
||||
start: string
|
||||
expiry: string
|
||||
limit: string
|
||||
}
|
||||
|
||||
export const typeTagCls = (type: string) => {
|
||||
if (type === '해경관할') return 'bg-[rgba(239,68,68,0.1)] text-status-red'
|
||||
if (type === '해경경찰서') return 'bg-[rgba(59,130,246,0.1)] text-primary-blue'
|
||||
if (type === '파출소') return 'bg-[rgba(34,197,94,0.1)] text-status-green'
|
||||
if (type === '관련기관') return 'bg-[rgba(168,85,247,0.1)] text-primary-purple'
|
||||
if (type === '해양환경공단') return 'bg-[rgba(6,182,212,0.1)] text-primary-cyan'
|
||||
if (type === '업체') return 'bg-[rgba(245,158,11,0.1)] text-status-orange'
|
||||
if (type === '지자체') return 'bg-[rgba(236,72,153,0.1)] text-[#ec4899]'
|
||||
if (type === '기름저장시설') return 'bg-[rgba(139,92,246,0.1)] text-[#8b5cf6]'
|
||||
if (type === '정유사') return 'bg-[rgba(20,184,166,0.1)] text-[#14b8a6]'
|
||||
if (type === '해군') return 'bg-[rgba(100,116,139,0.1)] text-[#64748b]'
|
||||
if (type === '기타') return 'bg-[rgba(107,114,128,0.1)] text-[#6b7280]'
|
||||
return 'bg-[rgba(156,163,175,0.1)] text-[#9ca3af]'
|
||||
}
|
||||
|
||||
export const typeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case '해경관할': return { bg: 'rgba(6,182,212,0.3)', border: '#06b6d4', selected: '#22d3ee' }
|
||||
case '해경경찰서': return { bg: 'rgba(59,130,246,0.3)', border: '#3b82f6', selected: '#60a5fa' }
|
||||
case '파출소': return { bg: 'rgba(34,197,94,0.3)', border: '#22c55e', selected: '#4ade80' }
|
||||
case '관련기관': return { bg: 'rgba(168,85,247,0.3)', border: '#a855f7', selected: '#c084fc' }
|
||||
case '해양환경공단': return { bg: 'rgba(20,184,166,0.3)', border: '#14b8a6', selected: '#2dd4bf' }
|
||||
case '업체': return { bg: 'rgba(245,158,11,0.3)', border: '#f59e0b', selected: '#fbbf24' }
|
||||
case '지자체': return { bg: 'rgba(236,72,153,0.3)', border: '#ec4899', selected: '#f472b6' }
|
||||
case '기름저장시설': return { bg: 'rgba(139,92,246,0.3)', border: '#8b5cf6', selected: '#a78bfa' }
|
||||
case '정유사': return { bg: 'rgba(13,148,136,0.3)', border: '#0d9488', selected: '#2dd4bf' }
|
||||
case '해군': return { bg: 'rgba(100,116,139,0.3)', border: '#64748b', selected: '#94a3b8' }
|
||||
default: return { bg: 'rgba(107,114,128,0.3)', border: '#6b7280', selected: '#9ca3af' }
|
||||
}
|
||||
}
|
||||
1
frontend/src/tabs/assets/index.ts
Normal file
1
frontend/src/tabs/assets/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { AssetsView } from './components/AssetsView'
|
||||
245
frontend/src/tabs/board/components/BoardListTable.tsx
Executable file
245
frontend/src/tabs/board/components/BoardListTable.tsx
Executable file
@ -0,0 +1,245 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuthStore } from '@common/store/authStore';
|
||||
import { fetchBoardPosts, type BoardPostItem } from '../services/boardApi';
|
||||
|
||||
// 카테고리 코드 ↔ 표시명 매핑
|
||||
const CATEGORY_MAP: Record<string, string> = {
|
||||
NOTICE: '공지사항',
|
||||
DATA: '자료실',
|
||||
QNA: 'Q&A',
|
||||
MANUAL: '해경매뉴얼',
|
||||
};
|
||||
|
||||
const CATEGORY_FILTER: { label: string; code: string | null }[] = [
|
||||
{ label: '전체', code: null },
|
||||
{ label: '공지사항', code: 'NOTICE' },
|
||||
{ label: '자료실', code: 'DATA' },
|
||||
{ label: 'Q&A', code: 'QNA' },
|
||||
];
|
||||
|
||||
const CATEGORY_STYLE: Record<string, string> = {
|
||||
NOTICE: 'bg-red-500/20 text-red-400',
|
||||
DATA: 'bg-blue-500/20 text-blue-400',
|
||||
QNA: 'bg-green-500/20 text-green-400',
|
||||
MANUAL: 'bg-yellow-500/20 text-yellow-400',
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
interface BoardListTableProps {
|
||||
onPostClick: (id: number) => void;
|
||||
onWriteClick: () => void;
|
||||
}
|
||||
|
||||
export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProps) {
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission);
|
||||
|
||||
const [posts, setPosts] = useState<BoardPostItem[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
|
||||
|
||||
// 카테고리별 서브리소스 권한 확인 (전체 선택 시 board CREATE)
|
||||
const canWrite = selectedCategory
|
||||
? hasPermission(`board:${selectedCategory.toLowerCase()}`, 'CREATE')
|
||||
: hasPermission('board', 'CREATE');
|
||||
|
||||
const loadPosts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchBoardPosts({
|
||||
categoryCd: selectedCategory || undefined,
|
||||
search: searchTerm || undefined,
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
});
|
||||
setPosts(result.items);
|
||||
setTotalCount(result.totalCount);
|
||||
} catch {
|
||||
setPosts([]);
|
||||
setTotalCount(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedCategory, searchTerm, page]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts();
|
||||
}, [loadPosts]);
|
||||
|
||||
const handleCategoryChange = (code: string | null) => {
|
||||
setSelectedCategory(code);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setSearchTerm(searchInput);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
};
|
||||
|
||||
const formatDate = (dtm: string) => {
|
||||
return new Date(dtm).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
{/* Header with Search and Write Button */}
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex gap-2">
|
||||
{CATEGORY_FILTER.map((cat) => (
|
||||
<button
|
||||
key={cat.label}
|
||||
onClick={() => handleCategoryChange(cat.code)}
|
||||
className={`px-4 py-2 text-sm font-semibold rounded transition-all ${
|
||||
selectedCategory === cat.code
|
||||
? 'bg-primary-cyan text-bg-0'
|
||||
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1'
|
||||
}`}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="제목, 작성자 검색..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none w-64"
|
||||
/>
|
||||
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={onWriteClick}
|
||||
className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity flex items-center gap-2"
|
||||
>
|
||||
<span>+</span>
|
||||
<span>글쓰기</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Board List Table */}
|
||||
<div className="flex-1 overflow-auto px-8 py-6">
|
||||
{loading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-text-3 text-sm">불러오는 중...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-border">
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-20">번호</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">분류</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2">제목</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">작성자</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">작성일</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-24">조회수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posts.map((post) => (
|
||||
<tr
|
||||
key={post.sn}
|
||||
onClick={() => onPostClick(post.sn)}
|
||||
className="border-b border-border hover:bg-bg-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-4 py-4 text-sm text-text-1">
|
||||
{post.pinnedYn === 'Y' ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400">
|
||||
공지
|
||||
</span>
|
||||
) : (
|
||||
post.sn
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${
|
||||
CATEGORY_STYLE[post.categoryCd] || 'bg-gray-500/20 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{CATEGORY_MAP[post.categoryCd] || post.categoryCd}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
post.pinnedYn === 'Y' ? 'font-semibold text-text-1' : 'text-text-1'
|
||||
} hover:text-primary-cyan transition-colors`}
|
||||
>
|
||||
{post.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-text-2">{post.authorName}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3">{formatDate(post.regDtm)}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3">{post.viewCnt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{posts.length === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-text-3 text-sm">검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 px-8 py-4 border-t border-border bg-bg-1">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors disabled:opacity-40"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`px-3 py-1.5 text-sm rounded ${
|
||||
page === p
|
||||
? 'bg-primary-cyan text-bg-0 font-semibold'
|
||||
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors disabled:opacity-40"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useSubMenu } from '../../hooks/useSubMenu'
|
||||
import { BoardWriteForm } from '../board/BoardWriteForm'
|
||||
import { BoardDetailView } from '../board/BoardDetailView'
|
||||
import { useSubMenu } from '@common/hooks/useSubMenu'
|
||||
import { BoardWriteForm } from './BoardWriteForm'
|
||||
import { BoardDetailView } from './BoardDetailView'
|
||||
|
||||
interface BoardPost {
|
||||
id: number
|
||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user