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

Reviewed-on: #26
This commit is contained in:
htlee 2026-02-28 18:44:25 +09:00
커밋 d702f0574b
166개의 변경된 파일14666개의 추가작업 그리고 11407개의 파일을 삭제

2
.gitignore vendored
파일 보기

@ -29,7 +29,7 @@ backend/data/*.db-wal
# Large reference data (keep locally, do not commit) # Large reference data (keep locally, do not commit)
_reference/ _reference/
scat/ /scat/
참고용/ 참고용/
논문/ 논문/

파일 보기

@ -6,9 +6,10 @@
- **프로젝트 타입**: react-ts (모노레포) - **프로젝트 타입**: react-ts (모노레포)
- **Frontend**: React 19 + Vite 7 + TypeScript 5.9 + Tailwind CSS 3 - **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 (서버) - **상태관리**: Zustand (클라이언트), TanStack Query (서버)
- **지도**: Leaflet, OpenLayers - **지도**: Leaflet + react-leaflet
- **실시간**: Socket.IO - **실시간**: Socket.IO
## 빌드/실행 ## 빌드/실행
@ -49,16 +50,27 @@ wing/
├── frontend/ React 19 + Vite + TypeScript + Tailwind ├── frontend/ React 19 + Vite + TypeScript + Tailwind
│ └── src/ │ └── src/
│ ├── App.tsx 메인 (탭 라우팅, 감사 로그 자동 기록) │ ├── App.tsx 메인 (탭 라우팅, 감사 로그 자동 기록)
│ ├── components/ UI 컴포넌트 │ ├── common/ 공통 모듈 (@common/ alias)
│ │ ├── auth/ 로그인 페이지 │ │ ├── components/ auth/, layer/, layout/, map/, ui/
│ │ ├── views/ 탭별 페이지 뷰 (11개) │ │ ├── hooks/ useLayers, useSubMenu
│ │ ├── layout/ MainLayout, TopBar, LeftPanel, RightPanel │ │ ├── services/ api.ts, authApi.ts, layerService.ts
│ │ └── ... analysis, board, incidents, map, weather 등 │ │ ├── store/ authStore, menuStore (Zustand)
│ ├── hooks/ 커스텀 훅 │ │ ├── types/ backtrack, boomLine, hns, navigation
│ ├── services/ API 서비스 (api, authApi, weatherApi 등) │ │ ├── utils/ coordinates, geo, sanitize
│ ├── store/ Zustand (authStore, menuStore) │ │ ├── data/ layerData.ts (UI 레이어 트리)
│ ├── types/ 타입 정의 │ │ └── mock/ vesselMockData, backtrackMockData
│ └── utils/ 유틸리티 │ └── 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 ├── backend/ Express + TypeScript
│ └── src/ │ └── src/
│ ├── server.ts 진입점 + 라우터 등록 │ ├── server.ts 진입점 + 라우터 등록
@ -68,15 +80,23 @@ wing/
│ ├── settings/ 시스템 설정 │ ├── settings/ 시스템 설정
│ ├── menus/ 메뉴 설정 │ ├── menus/ 메뉴 설정
│ ├── audit/ 감사 로그 │ ├── audit/ 감사 로그
│ ├── hns/ HNS 물질 검색 API
│ ├── routes/ 레이어, 시뮬레이션 │ ├── routes/ 레이어, 시뮬레이션
│ ├── middleware/ 보안 (입력 살균, rate-limit) │ ├── middleware/ 보안 (입력 살균, rate-limit)
│ └── db/ DB 연결 (PostgreSQL, SQLite) │ └── db/ DB 연결 (wingDb, authDb), seed
├── database/ SQL 초기화 스크립트 ├── database/ SQL 스크립트
├── docs/ 개발 문서 (README, 가이드, 변경이력) │ ├── init.sql wing DB 초기 스키마
│ ├── auth_init.sql wing_auth DB 초기 스키마
│ └── migration/ 마이그레이션 (001_layer, 002_hns_substance)
├── docs/ 개발 문서
├── .claude/ 팀 워크플로우 (rules, skills, scripts) ├── .claude/ 팀 워크플로우 (rules, skills, scripts)
└── .githooks/ Git hooks (pre-commit, commit-msg) └── .githooks/ Git hooks (pre-commit, commit-msg)
``` ```
### Path Alias
- `@common/*``src/common/*` (공통 모듈)
- `@tabs/*``src/tabs/*` (탭 패키지)
## 팀 컨벤션 ## 팀 컨벤션
`.claude/rules/` 디렉토리 참조: `.claude/rules/` 디렉토리 참조:
- `team-policy.md` — 보안/품질 정책 - `team-policy.md` — 보안/품질 정책

파일 보기

@ -90,17 +90,26 @@ cd frontend && npm install && npm run dev # localhost:5173
wing/ wing/
├── frontend/ React 19 + Vite + TypeScript + Tailwind ├── frontend/ React 19 + Vite + TypeScript + Tailwind
│ └── src/ │ └── src/
│ ├── App.tsx 메인 (탭 라우팅, 감사 로그 자동 기록) │ ├── App.tsx 메인 (탭 라우팅, 감사 로그)
│ ├── components/ UI 컴포넌트 │ ├── common/ 공통 모듈 (@common/ alias)
│ │ ├── auth/ 로그인 페이지 │ │ ├── components/ auth/, layer/, layout/, map/, ui/
│ │ ├── views/ 각 탭별 페이지 뷰 (11개) │ │ ├── hooks/ useLayers, useSubMenu
│ │ ├── layout/ MainLayout, TopBar, LeftPanel, RightPanel │ │ ├── services/ api.ts, authApi.ts, layerService.ts
│ │ └── ... analysis, board, incidents, map, weather 등 │ │ ├── store/ authStore, menuStore (Zustand)
│ ├── hooks/ 커스텀 훅 │ │ ├── types/ backtrack, boomLine, hns, navigation
│ ├── services/ API 서비스 (api, authApi, weatherApi 등) │ │ └── utils/ coordinates, geo, sanitize
│ ├── store/ Zustand 상태 (authStore, menuStore) │ └── tabs/ 탭 단위 패키지 (@tabs/ alias)
│ ├── types/ 타입 정의 │ ├── prediction/ 확산 예측
│ └── utils/ 유틸리티 │ ├── hns/ HNS 분석
│ ├── rescue/ 구조 시나리오
│ ├── aerial/ 항공 방제
│ ├── weather/ 해양 기상
│ ├── incidents/ 사건/사고
│ ├── board/ 게시판
│ ├── reports/ 보고서
│ ├── assets/ 자산 관리
│ ├── scat/ Pre-SCAT
│ └── admin/ 관리자
├── backend/ Express + TypeScript ├── backend/ Express + TypeScript
│ └── src/ │ └── src/
│ ├── server.ts 진입점 + 라우터 등록 │ ├── server.ts 진입점 + 라우터 등록
@ -110,10 +119,11 @@ wing/
│ ├── settings/ 시스템 설정 │ ├── settings/ 시스템 설정
│ ├── menus/ 메뉴 설정 │ ├── menus/ 메뉴 설정
│ ├── audit/ 감사 로그 │ ├── audit/ 감사 로그
│ ├── hns/ HNS 물질 검색 API
│ ├── routes/ 레이어, 시뮬레이션 │ ├── routes/ 레이어, 시뮬레이션
│ ├── middleware/ 보안 (입력 살균, rate-limit) │ ├── middleware/ 보안 (입력 살균, rate-limit)
│ └── db/ DB 연결 (PostgreSQL, SQLite) │ └── db/ DB 연결 (wingDb, authDb), seed
├── database/ SQL 초기화 스크립트 ├── database/ SQL 스크립트 + 마이그레이션
├── docs/ 개발 문서 ├── docs/ 개발 문서
├── .claude/ 팀 워크플로우 (rules, skills, scripts) ├── .claude/ 팀 워크플로우 (rules, skills, scripts)
└── .githooks/ Git hooks (pre-commit, commit-msg) └── .githooks/ Git hooks (pre-commit, commit-msg)
@ -126,12 +136,12 @@ wing/
| 영역 | 기술 | | 영역 | 기술 |
|------|------| |------|------|
| Frontend | React 19, Vite 7, TypeScript 5.9, Tailwind CSS 3 | | 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 (서버) | | 상태 관리 | Zustand (클라이언트), TanStack Query (서버) |
| 지도 | Leaflet, OpenLayers | | 지도 | Leaflet + react-leaflet |
| 실시간 | Socket.IO | | 실시간 | Socket.IO |
| 인증 | JWT (HttpOnly Cookie), Google OAuth | | 인증 | JWT (HttpOnly Cookie), Google OAuth |
| DB | PostgreSQL 16 + PostGIS (운영 DB 직접 연결), SQLite | | DB | PostgreSQL 16 + PostGIS (wing 운영DB + wing_auth 인증DB) |
| CI/CD | Gitea Actions | | CI/CD | Gitea Actions |
--- ---

파일 보기

@ -9,7 +9,6 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-sqlite3": "^11.9.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
@ -22,7 +21,6 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.12",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
@ -513,16 +511,6 @@
"@types/node": "*" "@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": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@ -773,17 +761,6 @@
"node": ">= 18" "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": { "node_modules/bignumber.js": {
"version": "9.3.1", "version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
@ -793,26 +770,6 @@
"node": "*" "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": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@ -873,30 +830,6 @@
"balanced-match": "^1.0.0" "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": { "node_modules/buffer-equal-constant-time": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "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" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "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": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -1120,15 +1023,6 @@
"npm": "1.2.8000 || >= 1.4.16" "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": { "node_modules/dotenv": {
"version": "17.3.1", "version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
@ -1191,15 +1085,6 @@
"node": ">= 0.8" "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": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -1287,15 +1172,6 @@
"node": ">= 0.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": { "node_modules/express": {
"version": "4.22.1", "version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
@ -1404,12 +1280,6 @@
"node": "^12.20 || >= 14.13" "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": { "node_modules/finalhandler": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@ -1489,12 +1359,6 @@
"node": ">= 0.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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -1598,12 +1462,6 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" "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": { "node_modules/glob": {
"version": "10.5.0", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@ -1729,38 +1587,12 @@
"node": ">= 14" "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": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "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": { "node_modules/ip-address": {
"version": "10.0.1", "version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
@ -1978,18 +1810,6 @@
"node": ">= 0.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": { "node_modules/minimatch": {
"version": "9.0.9", "version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
@ -2005,15 +1825,6 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/minipass": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
@ -2023,24 +1834,12 @@
"node": ">=16 || 14 >=14.17" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "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": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@ -2050,18 +1849,6 @@
"node": ">= 0.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": { "node_modules/node-addon-api": {
"version": "8.5.0", "version": "8.5.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
@ -2153,15 +1940,6 @@
"node": ">= 0.8" "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": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "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": ">=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": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -2375,16 +2127,6 @@
"node": ">= 0.10" "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": { "node_modules/qs": {
"version": "6.14.2", "version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
@ -2436,35 +2178,6 @@
"node": ">=0.10.0" "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": { "node_modules/resolve-pkg-maps": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "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" "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": { "node_modules/split2": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@ -2756,15 +2424,6 @@
"node": ">= 0.8" "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": { "node_modules/string-width": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@ -2861,43 +2520,6 @@
"node": ">=8" "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": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@ -2927,18 +2549,6 @@
"fsevents": "~2.3.3" "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": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@ -2982,12 +2592,6 @@
"node": ">= 0.8" "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": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@ -3121,12 +2725,6 @@
"node": ">=8" "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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

파일 보기

@ -10,7 +10,6 @@
}, },
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-sqlite3": "^11.9.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
@ -23,7 +22,6 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.12",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",

파일 보기

@ -1,11 +1,13 @@
import type { Request, Response, NextFunction } from 'express' import type { Request, Response, NextFunction } from 'express'
import { verifyToken, getTokenFromCookie } from './jwtProvider.js' import { verifyToken, getTokenFromCookie } from './jwtProvider.js'
import type { JwtPayload } from './jwtProvider.js' import type { JwtPayload } from './jwtProvider.js'
import { getUserInfo } from './authService.js'
declare global { declare global {
namespace Express { namespace Express {
interface Request { interface Request {
user?: JwtPayload user?: JwtPayload
resolvedPermissions?: Record<string, string[]>
} }
} }
} }
@ -43,3 +45,43 @@ export function requireRole(...roles: string[]) {
next() 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 { authPool } from '../db/authDb.js'
import { signToken, setTokenCookie } from './jwtProvider.js' import { signToken, setTokenCookie } from './jwtProvider.js'
import type { Response } from 'express' 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 MAX_FAIL_COUNT = 5
const SALT_ROUNDS = 10 const SALT_ROUNDS = 10
@ -24,7 +26,7 @@ interface AuthUserInfo {
rank: string | null rank: string | null
org: { sn: number; name: string; abbr: string } | null org: { sn: number; name: string; abbr: string } | null
roles: string[] roles: string[]
permissions: string[] permissions: Record<string, string[]>
} }
export async function login( export async function login(
@ -127,9 +129,9 @@ export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
const row = userResult.rows[0] const row = userResult.rows[0]
// 역할 조회 // 역할 조회 (ROLE_SN + ROLE_CD)
const rolesResult = await authPool.query( 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 FROM AUTH_USER_ROLE ur
JOIN AUTH_ROLE r ON ur.ROLE_SN = r.ROLE_SN JOIN AUTH_ROLE r ON ur.ROLE_SN = r.ROLE_SN
WHERE ur.USER_ID = $1`, WHERE ur.USER_ID = $1`,
@ -137,17 +139,63 @@ export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
) )
const roles = rolesResult.rows.map((r: { role_cd: string }) => r.role_cd) 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 (리소스 × 오퍼레이션)
const permsResult = await authPool.query( let permissions: Record<string, string[]>
`SELECT DISTINCT p.RSRC_CD as rsrc_cd try {
FROM AUTH_PERM p const treeNodes = await getPermTreeNodes()
JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`,
[userId]
)
const permissions = permsResult.rows.map((p: { rsrc_cd: string }) => p.rsrc_cd) 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
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']
}
}
} 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 { return {
id: row.user_id, id: row.user_id,

파일 보기

@ -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

파일 보기

@ -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 export const authPool = wingPool
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 async function testAuthDbConnection(): Promise<boolean> { export async function testAuthDbConnection(): Promise<boolean> {
try { return testWingDbConnection()
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
}
} }
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,91 +1,106 @@
import 'dotenv/config'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import { dirname } from 'path' import { dirname } from 'path'
import db, { initDatabase } from './database.js' import { wingPool } from './wingDb.js'
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename) const __dirname = dirname(__filename)
async function seedDatabase() { async function seedDatabase() {
console.log('데이터베이스 초기화 중...') console.log('wing DB 레이어 시드 시작...')
initDatabase()
// 기존 데이터 삭제 const client = await wingPool.connect()
db.exec('DELETE FROM layers')
// CSV 파일 읽기 try {
const csvPath = path.join(__dirname, '../../../LayerList.csv') // CSV 파일 읽기
const csvContent = fs.readFileSync(csvPath, 'utf-8') 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[]) => { // CSV 파싱
for (const row of rows) { const lines = csvContent.split('\n')
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 파싱 (쉼표로 구분, 따옴표 처리) for (let i = 1; i < lines.length; i++) {
const values = [] const line = lines[i].trim()
let current = '' if (!line) continue
let inQuotes = false
for (let j = 0; j < line.length; j++) { const values: string[] = []
const char = line[j] let current = ''
let inQuotes = false
if (char === '"') {
inQuotes = !inQuotes for (let j = 0; j < line.length; j++) {
} else if (char === ',' && !inQuotes) { const char = line[j]
values.push(current.trim()) if (char === '"') {
current = '' inQuotes = !inQuotes
} else { } else if (char === ',' && !inQuotes) {
current += char values.push(current.trim())
current = ''
} else {
current += char
}
}
values.push(current.trim())
const row = values.map(v => {
if (v === 'NULL' || v === '') return null
return v.replace(/"/g, '')
})
if (row.length >= 6) {
rows.push([
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
])
} }
} }
values.push(current.trim())
// NULL 값 처리 console.log(`${rows.length}개의 레이어 데이터 삽입 중...`)
const row = values.map(v => {
if (v === 'NULL' || v === '') return null
return v.replace(/"/g, '')
})
if (row.length >= 6) { await client.query('BEGIN')
rows.push([
row[0], // cmn_cd // 기존 데이터 삭제
row[1], // up_cmn_cd await client.query('DELETE FROM LAYER')
row[2], // cmn_cd_full_nm
row[3], // cmn_cd_nm // FK 제약 때문에 상위 레이어(낮은 레벨)부터 삽입
parseInt(row[4] || '0'), // cmn_cd_level const sortedRows = rows.sort((a, b) => (a[4] as number) - (b[4] as number))
row[5], // clnm
]) 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 { 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()
} }
console.log(`${rows.length}개의 레이어 데이터 삽입 중...`)
insertMany(rows)
console.log('시드 완료!')
// 결과 확인
const count = db.prepare('SELECT COUNT(*) as count FROM layers').get() as { count: number }
console.log(`${count.count}개의 레이어가 저장되었습니다.`)
db.close()
} }
seedDatabase().catch(console.error) seedDatabase().catch((err) => {
console.error(err)
process.exit(1)
})

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
파일 보기

@ -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 }

파일 보기

@ -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

파일 보기

@ -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,
}
}

파일 보기

@ -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 { Router } from 'express'
import { requireAuth, requireRole } from '../auth/authMiddleware.js' import { requireAuth, requireRole } from '../auth/authMiddleware.js'
import { AuthError } from '../auth/authService.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() const router = Router()
router.use(requireAuth) router.use(requireAuth)
router.use(requireRole('ADMIN')) 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 // GET /api/roles
router.get('/', async (_req, res) => { router.get('/', async (_req, res) => {
try { try {
@ -76,6 +87,7 @@ router.delete('/:id', async (req, res) => {
}) })
// PUT /api/roles/:id/permissions // PUT /api/roles/:id/permissions
// 요청: { permissions: [{ resourceCode, operationCode, granted }] }
router.put('/:id/permissions', async (req, res) => { router.put('/:id/permissions', async (req, res) => {
try { try {
const roleSn = Number(req.params.id) const roleSn = Number(req.params.id)
@ -86,6 +98,13 @@ router.put('/:id/permissions', async (req, res) => {
return 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) await updatePermissions(roleSn, permissions)
res.json({ success: true }) res.json({ success: true })
} catch (err) { } catch (err) {

파일 보기

@ -1,13 +1,34 @@
import { authPool } from '../db/authDb.js' import { authPool } from '../db/authDb.js'
import { AuthError } from '../auth/authService.js' import { AuthError } from '../auth/authService.js'
import { type PermTreeNode, buildPermTree, type PermTreeResponse } from './permResolver.js'
const PERM_RESOURCE_CODES = [
'prediction', 'hns', 'rescue', 'reports', 'aerial',
'assets', 'scat', 'incidents', 'board', 'weather', 'admin',
] as const
const PROTECTED_ROLE_CODES = ['ADMIN'] 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 { interface RoleWithPermissions {
sn: number sn: number
code: string code: string
@ -17,6 +38,7 @@ interface RoleWithPermissions {
permissions: Array<{ permissions: Array<{
sn: number sn: number
resourceCode: string resourceCode: string
operationCode: string
granted: boolean granted: boolean
}> }>
} }
@ -42,8 +64,8 @@ export async function listRolesWithPermissions(): Promise<RoleWithPermissions[]>
for (const row of rolesResult.rows) { for (const row of rolesResult.rows) {
const permsResult = await authPool.query( const permsResult = await authPool.query(
`SELECT PERM_SN as sn, RSRC_CD as resource_code, GRANT_YN as granted `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`, FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD, OPER_CD`,
[row.sn] [row.sn]
) )
@ -53,9 +75,12 @@ export async function listRolesWithPermissions(): Promise<RoleWithPermissions[]>
name: row.name, name: row.name,
description: row.description, description: row.description,
isDefault: row.is_default === 'Y', 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, sn: p.sn,
resourceCode: p.resource_code, resourceCode: p.resource_code,
operationCode: p.operation_code,
granted: p.granted === 'Y', granted: p.granted === 'Y',
})), })),
}) })
@ -94,17 +119,20 @@ export async function createRole(input: CreateRoleInput): Promise<RoleWithPermis
) )
const row = result.rows[0] 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( await client.query(
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES ($1, $2, $3)', 'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES ($1, $2, $3, $4)',
[row.sn, rsrc, 'N'] [row.sn, rsrc, 'READ', 'N']
) )
} }
await client.query('COMMIT') await client.query('COMMIT')
const permsResult = await authPool.query( 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] [row.sn]
) )
@ -114,9 +142,12 @@ export async function createRole(input: CreateRoleInput): Promise<RoleWithPermis
name: row.name, name: row.name,
description: row.description, description: row.description,
isDefault: row.is_default === 'Y', 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, sn: p.sn,
resourceCode: p.resource_code, resourceCode: p.resource_code,
operationCode: p.operation_code,
granted: p.granted === 'Y', granted: p.granted === 'Y',
})), })),
} }
@ -177,23 +208,23 @@ export async function deleteRole(roleSn: number): Promise<void> {
export async function updatePermissions( export async function updatePermissions(
roleSn: number, roleSn: number,
permissions: Array<{ resourceCode: string; granted: boolean }> permissions: Array<{ resourceCode: string; operationCode: string; granted: boolean }>
): Promise<void> { ): Promise<void> {
for (const perm of permissions) { for (const perm of permissions) {
const existing = await authPool.query( const existing = await authPool.query(
'SELECT PERM_SN FROM AUTH_PERM WHERE ROLE_SN = $1 AND RSRC_CD = $2', 'SELECT PERM_SN FROM AUTH_PERM WHERE ROLE_SN = $1 AND RSRC_CD = $2 AND OPER_CD = $3',
[roleSn, perm.resourceCode] [roleSn, perm.resourceCode, perm.operationCode]
) )
if (existing.rows.length > 0) { if (existing.rows.length > 0) {
await authPool.query( await authPool.query(
'UPDATE AUTH_PERM SET GRANT_YN = $1 WHERE ROLE_SN = $2 AND RSRC_CD = $3', '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.granted ? 'Y' : 'N', roleSn, perm.resourceCode, perm.operationCode]
) )
} else { } else {
await authPool.query( await authPool.query(
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES ($1, $2, $3)', 'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES ($1, $2, $3, $4)',
[roleSn, perm.resourceCode, perm.granted ? 'Y' : 'N'] [roleSn, perm.resourceCode, perm.operationCode, perm.granted ? 'Y' : 'N']
) )
} }
} }

파일 보기

@ -1,5 +1,5 @@
import express from 'express' import express from 'express'
import db from '../db/database.js' import { wingPool } from '../db/wingDb.js'
import { enrichLayerWithMetadata } from '../utils/layerIcons.js' import { enrichLayerWithMetadata } from '../utils/layerIcons.js'
import { import {
sanitizeParams, sanitizeParams,
@ -19,14 +19,26 @@ interface Layer {
clnm: string | null 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.use(sanitizeParams)
// 모든 레이어 조회 // 모든 레이어 조회
router.get('/', (_req, res) => { router.get('/', async (_req, res) => {
try { try {
const layers = db.prepare('SELECT * FROM layers ORDER BY cmn_cd').all() as Layer[] const { rows } = await wingPool.query<Layer>(
const enrichedLayers = layers.map(enrichLayerWithMetadata) `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' ORDER BY LAYER_CD`
)
const enrichedLayers = rows.map(enrichLayerWithMetadata)
res.json(enrichedLayers) res.json(enrichedLayers)
} catch { } catch {
res.status(500).json({ error: '레이어 조회 실패' }) 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 { try {
const layers = db.prepare('SELECT * FROM layers ORDER BY cmn_cd').all() as Layer[] const { rows } = await wingPool.query<Layer>(
const enrichedLayers = layers.map(enrichLayerWithMetadata) `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 => { enrichedLayers.forEach(layer => {
layerMap.set(layer.cmn_cd, { ...layer, children: [] }) layerMap.set(layer.cmn_cd, { ...layer, children: [] })
}) })
const rootLayers: any[] = [] const rootLayers: (Layer & { children: Layer[] })[] = []
enrichedLayers.forEach(layer => { enrichedLayers.forEach(layer => {
const layerNode = layerMap.get(layer.cmn_cd)! const layerNode = layerMap.get(layer.cmn_cd)!
if (layer.up_cmn_cd === null) { if (layer.up_cmn_cd === null) {
@ -64,10 +78,12 @@ router.get('/tree/all', (_req, res) => {
}) })
// WMS 레이어만 조회 // WMS 레이어만 조회
router.get('/wms/all', (_req, res) => { router.get('/wms/all', async (_req, res) => {
try { try {
const layers = db.prepare('SELECT * FROM layers WHERE clnm IS NOT NULL ORDER BY cmn_cd').all() as Layer[] const { rows } = await wingPool.query<Layer>(
const enrichedLayers = layers.map(enrichLayerWithMetadata) `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) res.json(enrichedLayers)
} catch { } catch {
res.status(500).json({ error: 'WMS 레이어 조회 실패' }) 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 { try {
const level = parseInt(req.params.level, 10) const level = parseInt(req.params.level, 10)
// 입력 검증: 레벨은 1~10 범위의 정수
if (!isValidNumber(level, 1, 10)) { if (!isValidNumber(level, 1, 10)) {
return res.status(400).json({ return res.status(400).json({
error: '유효하지 않은 레벨값', error: '유효하지 않은 레벨값',
@ -87,9 +102,11 @@ router.get('/level/:level', (req, res) => {
}) })
} }
// 파라미터화된 쿼리 사용 (SQL 인젝션 방지) const { rows } = await wingPool.query<Layer>(
const layers = db.prepare('SELECT * FROM layers WHERE cmn_cd_level = ? ORDER BY cmn_cd').all(level) as Layer[] `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_LEVEL = $1 AND USE_YN = 'Y' ORDER BY LAYER_CD`,
const enrichedLayers = layers.map(enrichLayerWithMetadata) [level]
)
const enrichedLayers = rows.map(enrichLayerWithMetadata)
res.json(enrichedLayers) res.json(enrichedLayers)
} catch { } catch {
res.status(500).json({ error: '레벨별 레이어 조회 실패' }) 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 { try {
const parentId = req.params.parentId const parentId = req.params.parentId
// 입력 검증: 코드 형식 확인 (영숫자, 언더스코어, 하이픈만 허용)
if (!parentId || !isValidStringLength(parentId, 50) || !/^[a-zA-Z0-9_-]+$/.test(parentId)) { if (!parentId || !isValidStringLength(parentId, 50) || !/^[a-zA-Z0-9_-]+$/.test(parentId)) {
return res.status(400).json({ return res.status(400).json({
error: '유효하지 않은 부모 ID', error: '유효하지 않은 부모 ID',
@ -110,8 +126,11 @@ router.get('/children/:parentId', (req, res) => {
} }
const sanitizedId = sanitizeString(parentId) const sanitizedId = sanitizeString(parentId)
const layers = db.prepare('SELECT * FROM layers WHERE up_cmn_cd = ? ORDER BY cmn_cd').all(sanitizedId) as Layer[] const { rows } = await wingPool.query<Layer>(
const enrichedLayers = layers.map(enrichLayerWithMetadata) `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) res.json(enrichedLayers)
} catch { } catch {
res.status(500).json({ error: '자식 레이어 조회 실패' }) 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 { try {
const id = req.params.id const id = req.params.id
// 입력 검증: ID 형식 확인
if (!id || !isValidStringLength(id, 50) || !/^[a-zA-Z0-9_-]+$/.test(id)) { if (!id || !isValidStringLength(id, 50) || !/^[a-zA-Z0-9_-]+$/.test(id)) {
return res.status(400).json({ return res.status(400).json({
error: '유효하지 않은 레이어 ID', error: '유효하지 않은 레이어 ID',
@ -132,11 +150,14 @@ router.get('/:id', (req, res) => {
} }
const sanitizedId = sanitizeString(id) const sanitizedId = sanitizeString(id)
const layer = db.prepare('SELECT * FROM layers WHERE cmn_cd = ?').get(sanitizedId) as Layer | undefined const { rows } = await wingPool.query<Layer>(
if (!layer) { `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_CD = $1`,
[sanitizedId]
)
if (rows.length === 0) {
return res.status(404).json({ error: '레이어를 찾을 수 없습니다' }) return res.status(404).json({ error: '레이어를 찾을 수 없습니다' })
} }
const enrichedLayer = enrichLayerWithMetadata(layer) const enrichedLayer = enrichLayerWithMetadata(rows[0])
res.json(enrichedLayer) res.json(enrichedLayer)
} catch { } catch {
res.status(500).json({ error: '레이어 조회 실패' }) res.status(500).json({ error: '레이어 조회 실패' })

파일 보기

@ -4,8 +4,7 @@ import cors from 'cors'
import helmet from 'helmet' import helmet from 'helmet'
import rateLimit from 'express-rate-limit' import rateLimit from 'express-rate-limit'
import cookieParser from 'cookie-parser' import cookieParser from 'cookie-parser'
import { initDatabase } from './db/database.js' import { testWingDbConnection } from './db/wingDb.js'
import { testAuthDbConnection } from './db/authDb.js'
import layersRouter from './routes/layers.js' import layersRouter from './routes/layers.js'
import simulationRouter from './routes/simulation.js' import simulationRouter from './routes/simulation.js'
import authRouter from './auth/authRouter.js' import authRouter from './auth/authRouter.js'
@ -14,6 +13,8 @@ import roleRouter from './roles/roleRouter.js'
import settingsRouter from './settings/settingsRouter.js' import settingsRouter from './settings/settingsRouter.js'
import menuRouter from './menus/menuRouter.js' import menuRouter from './menus/menuRouter.js'
import auditRouter from './audit/auditRouter.js' import auditRouter from './audit/auditRouter.js'
import boardRouter from './board/boardRouter.js'
import hnsRouter from './hns/hnsRouter.js'
import { import {
sanitizeBody, sanitizeBody,
sanitizeQuery, sanitizeQuery,
@ -48,6 +49,7 @@ app.use(helmet({
} }
}, },
crossOriginEmbedderPolicy: false, // API 서버이므로 비활성 crossOriginEmbedderPolicy: false, // API 서버이므로 비활성
crossOriginResourcePolicy: { policy: 'cross-origin' }, // sendBeacon cross-origin 허용
})) }))
// 2. 서버 정보 제거 (공격자에게 기술 스택 노출 방지) // 2. 서버 정보 제거 (공격자에게 기술 스택 노출 방지)
@ -113,11 +115,6 @@ app.use(express.urlencoded({ extended: false, limit: BODY_SIZE_LIMIT }))
app.use(sanitizeBody) app.use(sanitizeBody)
app.use(sanitizeQuery) app.use(sanitizeQuery)
// ============================================================
// 데이터베이스 초기화
// ============================================================
initDatabase()
// ============================================================ // ============================================================
// 라우트 // 라우트
// ============================================================ // ============================================================
@ -140,8 +137,10 @@ app.use('/api/menus', menuRouter)
app.use('/api/audit', auditRouter) app.use('/api/audit', auditRouter)
// API 라우트 — 업무 // API 라우트 — 업무
app.use('/api/board', boardRouter)
app.use('/api/layers', layersRouter) app.use('/api/layers', layersRouter)
app.use('/api/simulation', simulationLimiter, simulationRouter) app.use('/api/simulation', simulationLimiter, simulationRouter)
app.use('/api/hns', hnsRouter)
// 헬스 체크 // 헬스 체크
app.get('/health', (_req, res) => { 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 () => { app.listen(PORT, async () => {
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`) console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
const connected = await testAuthDbConnection()
// wing DB 연결 확인 (wing + auth 스키마 통합)
const connected = await testWingDbConnection()
if (connected) { if (connected) {
// SETTING_VAL VARCHAR(500) → TEXT 마이그레이션 (메뉴 설정 JSON 확장 대응) // SETTING_VAL VARCHAR(500) → TEXT 마이그레이션 (메뉴 설정 JSON 확장 대응)
try { try {
const { authPool } = await import('./db/authDb.js') const { wingPool } = await import('./db/wingDb.js')
await authPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`) await wingPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`)
console.log('[migration] SETTING_VAL → TEXT 변환 완료')
} catch { } catch {
// 이미 TEXT이거나 권한 없으면 무시 // 이미 TEXT이거나 권한 없으면 무시
} }

파일 보기

@ -13,5 +13,5 @@
"resolveJsonModule": true "resolveJsonModule": true
}, },
"include": ["src/**/*"], "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, PERM_SN SERIAL NOT NULL,
ROLE_SN INTEGER NOT NULL, ROLE_SN INTEGER NOT NULL,
RSRC_CD VARCHAR(50) NOT NULL, RSRC_CD VARCHAR(50) NOT NULL,
OPER_CD VARCHAR(20) NOT NULL,
GRANT_YN CHAR(1) NOT NULL DEFAULT 'Y', GRANT_YN CHAR(1) NOT NULL DEFAULT 'Y',
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT PK_AUTH_PERM PRIMARY KEY (PERM_SN), 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 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 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_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 TABLE AUTH_PERM IS '역할별권한';
COMMENT ON COLUMN AUTH_PERM.PERM_SN IS '권한순번'; COMMENT ON COLUMN AUTH_PERM.PERM_SN IS '권한순번';
COMMENT ON COLUMN AUTH_PERM.ROLE_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.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.GRANT_YN IS '부여여부 (Y:허용, N:거부)';
COMMENT ON COLUMN AUTH_PERM.REG_DTM IS '등록일시'; 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 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_ROLE ON AUTH_PERM (ROLE_SN);
CREATE INDEX IDX_AUTH_PERM_RSRC ON AUTH_PERM (RSRC_CD); 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_USER ON AUTH_LOGIN_HIST (USER_ID);
CREATE INDEX IDX_AUTH_LOGIN_DTM ON AUTH_LOGIN_HIST (LOGIN_DTM); CREATE INDEX IDX_AUTH_LOGIN_DTM ON AUTH_LOGIN_HIST (LOGIN_DTM);
CREATE INDEX IDX_AUDIT_LOG_USER ON AUTH_AUDIT_LOG (USER_ID); 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): 모든 탭 접근 -- ADMIN (ROLE_SN=1): 모든 탭 × 모든 오퍼레이션 허용
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(1, 'prediction', 'Y'), (1, 'hns', 'Y'), (1, 'rescue', 'Y'), (1, 'prediction', 'READ', 'Y'), (1, 'prediction', 'CREATE', 'Y'), (1, 'prediction', 'UPDATE', 'Y'), (1, 'prediction', 'DELETE', 'Y'),
(1, 'reports', 'Y'), (1, 'aerial', 'Y'), (1, 'assets', 'Y'), (1, 'hns', 'READ', 'Y'), (1, 'hns', 'CREATE', 'Y'), (1, 'hns', 'UPDATE', 'Y'), (1, 'hns', 'DELETE', 'Y'),
(1, 'scat', 'Y'), (1, 'incidents', 'Y'), (1, 'board', 'Y'), (1, 'rescue', 'READ', 'Y'), (1, 'rescue', 'CREATE', 'Y'), (1, 'rescue', 'UPDATE', 'Y'), (1, 'rescue', 'DELETE', 'Y'),
(1, 'weather', 'Y'), (1, 'admin', '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 탭 제외 -- MANAGER (ROLE_SN=2): admin 탭 제외, RCUD 허용
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(2, 'prediction', 'Y'), (2, 'hns', 'Y'), (2, 'rescue', 'Y'), (2, 'prediction', 'READ', 'Y'), (2, 'prediction', 'CREATE', 'Y'), (2, 'prediction', 'UPDATE', 'Y'), (2, 'prediction', 'DELETE', 'Y'),
(2, 'reports', 'Y'), (2, 'aerial', 'Y'), (2, 'assets', 'Y'), (2, 'hns', 'READ', 'Y'), (2, 'hns', 'CREATE', 'Y'), (2, 'hns', 'UPDATE', 'Y'), (2, 'hns', 'DELETE', 'Y'),
(2, 'scat', 'Y'), (2, 'incidents', 'Y'), (2, 'board', 'Y'), (2, 'rescue', 'READ', 'Y'), (2, 'rescue', 'CREATE', 'Y'), (2, 'rescue', 'UPDATE', 'Y'), (2, 'rescue', 'DELETE', 'Y'),
(2, 'weather', 'Y'), (2, 'admin', 'N'); (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 탭 제외 -- USER (ROLE_SN=3): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(3, 'prediction', 'Y'), (3, 'hns', 'Y'), (3, 'rescue', 'Y'), (3, 'prediction', 'READ', 'Y'), (3, 'prediction', 'CREATE', 'Y'), (3, 'prediction', 'UPDATE', 'Y'),
(3, 'reports', 'Y'), (3, 'aerial', 'Y'), (3, 'assets', 'N'), (3, 'hns', 'READ', 'Y'), (3, 'hns', 'CREATE', 'Y'), (3, 'hns', 'UPDATE', 'Y'),
(3, 'scat', 'Y'), (3, 'incidents', 'Y'), (3, 'board', 'Y'), (3, 'rescue', 'READ', 'Y'), (3, 'rescue', 'CREATE', 'Y'), (3, 'rescue', 'UPDATE', 'Y'),
(3, 'weather', 'Y'), (3, 'admin', 'N'); (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 제외 -- VIEWER (ROLE_SN=4): 제한적 탭의 READ만 허용
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(4, 'prediction', 'Y'), (4, 'hns', 'Y'), (4, 'rescue', 'Y'), (4, 'prediction', 'READ', 'Y'),
(4, 'reports', 'N'), (4, 'aerial', 'Y'), (4, 'assets', 'N'), (4, 'hns', 'READ', 'Y'),
(4, 'scat', 'N'), (4, 'incidents', 'Y'), (4, 'board', 'Y'), (4, 'rescue', 'READ', 'Y'),
(4, 'weather', 'Y'), (4, 'admin', 'N'); (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');
-- ============================================================ -- ============================================================

파일 보기

@ -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);

파일 보기

@ -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);

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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`로 상태를 관리합니다. 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 ```typescript
// backend/src/auth/authMiddleware.ts import { requireAuth, requireRole, requirePermission } from '../auth/authMiddleware.js'
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
// 인증만 필요한 라우트 // 인증만 필요한 라우트
router.use(requireAuth) router.use(requireAuth)
// 특정 역할 필요 // 역할 기반 (관리 API용)
router.use(requireRole('ADMIN')) 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) #### JWT 페이로드 (req.user)
`requireAuth` 통과 후 `req.user`에 담기는 정보: `requireAuth` 통과 후 `req.user`에 담기는 정보:
```typescript ```typescript
@ -36,25 +94,21 @@ interface JwtPayload {
} }
``` ```
#### 라우터 패턴 #### 라우터 패턴 (CRUD 구조)
```typescript ```typescript
// backend/src/[모듈]/[모듈]Router.ts // backend/src/[모듈]/[모듈]Router.ts
import { Router } from 'express' import { Router } from 'express'
import { requireAuth, requireRole } from '../auth/authMiddleware.js' import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
const router = Router() const router = Router()
router.use(requireAuth) router.use(requireAuth)
router.get('/', async (req, res) => { // 리소스별 CRUD 엔드포인트
try { router.post('/list', requirePermission('module:sub', 'READ'), listHandler)
const userId = req.user!.sub router.post('/detail', requirePermission('module:sub', 'READ'), detailHandler)
// 비즈니스 로직... router.post('/create', requirePermission('module:sub', 'CREATE'), createHandler)
res.json(result) router.post('/update', requirePermission('module:sub', 'UPDATE'), updateHandler)
} catch (err) { router.post('/delete', requirePermission('module:sub', 'DELETE'), deleteHandler)
console.error('[모듈] 오류:', err)
res.status(500).json({ error: '처리 중 오류가 발생했습니다.' })
}
})
export default router export default router
``` ```
@ -63,32 +117,36 @@ export default router
#### authStore (Zustand) #### authStore (Zustand)
```typescript ```typescript
// frontend/src/store/authStore.ts import { useAuthStore } from '@common/store/authStore'
import { useAuthStore } from '../store/authStore'
// 컴포넌트 내에서 사용
const { user, isAuthenticated, hasPermission, logout } = useAuthStore() const { user, isAuthenticated, hasPermission, logout } = useAuthStore()
// 사용자 정보 // 사용자 정보
user?.id // UUID user?.id // UUID
user?.name // 이름 user?.name // 이름
user?.roles // ['ADMIN', 'USER'] user?.roles // ['ADMIN', 'USER']
user?.permissions // { 'prediction': ['READ','CREATE','UPDATE','DELETE'], ... }
// 권한 확인 (탭 ID 기준) // 권한 확인 (리소스 × 오퍼레이션)
hasPermission('prediction') // true/false hasPermission('prediction') // READ 확인 (기본값)
hasPermission('admin') // true/false hasPermission('prediction', 'READ') // 명시적 READ 확인
hasPermission('board:notice', 'CREATE') // 공지사항 생성 권한
hasPermission('board:notice', 'DELETE') // 공지사항 삭제 권한
// 하위 호환: operation 생략 시 'READ' 기본값
hasPermission('admin') // === hasPermission('admin', 'READ')
``` ```
#### API 클라이언트 #### API 클라이언트
```typescript ```typescript
// frontend/src/services/api.ts import { api } from '@common/services/api'
import { api } from './api'
// withCredentials: true 설정으로 JWT 쿠키 자동 포함 // withCredentials: true 설정으로 JWT 쿠키 자동 포함
const response = await api.get('/your-endpoint') const response = await api.post('/your-endpoint/list', params)
const response = await api.post('/your-endpoint', data) const response = await api.post('/your-endpoint/create', data)
// 401 응답 시 자동 로그아웃 처리 (인터셉터) // 401 응답 시 자동 로그아웃 처리 (인터셉터)
// 403 응답 시 권한 부족 (requirePermission 미들웨어)
``` ```
--- ---
@ -103,13 +161,15 @@ const response = await api.post('/your-endpoint', data)
```typescript ```typescript
// frontend/src/App.tsx (자동 적용, 수정 불필요) // frontend/src/App.tsx (자동 적용, 수정 불필요)
import { API_BASE_URL } from '@common/services/api'
useEffect(() => { useEffect(() => {
if (!isAuthenticated) return if (!isAuthenticated) return
const blob = new Blob( const blob = new Blob(
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })], [JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
{ type: 'text/plain' } { type: 'text/plain' }
) )
navigator.sendBeacon('/api/audit/log', blob) navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
}, [activeMainTab, isAuthenticated]) }, [activeMainTab, isAuthenticated])
``` ```
@ -117,12 +177,13 @@ useEffect(() => {
특정 작업에 대해 명시적으로 감사 로그를 기록하려면: 특정 작업에 대해 명시적으로 감사 로그를 기록하려면:
```typescript ```typescript
// 프론트엔드에서 sendBeacon 사용 import { API_BASE_URL } from '@common/services/api'
const blob = new Blob( const blob = new Blob(
[JSON.stringify({ action: 'ADMIN_ACTION', detail: '사용자 승인' })], [JSON.stringify({ action: 'ADMIN_ACTION', detail: '사용자 승인' })],
{ type: 'text/plain' } { type: 'text/plain' }
) )
navigator.sendBeacon('/api/audit/log', blob) navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
``` ```
### 감사 로그 테이블 구조 (AUTH_AUDIT_LOG) ### 감사 로그 테이블 구조 (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/[모듈명]/` 디렉토리 생성 1. `backend/src/[모듈명]/` 디렉토리 생성
2. `[모듈명]Service.ts` — 비즈니스 로직 (DB 쿼리) 2. `[모듈명]Service.ts` — 비즈니스 로직 (DB 쿼리)
3. `[모듈명]Router.ts` — Express 라우터 (입력 검증, 에러 처리) 3. `[모듈명]Router.ts` — Express 라우터 (CRUD 엔드포인트 + requirePermission)
4. `backend/src/server.ts`에 라우터 등록: 4. `backend/src/server.ts`에 라우터 등록:
```typescript ```typescript
import newRouter from './[모듈명]/[모듈명]Router.js' import newRouter from './[모듈명]/[모듈명]Router.js'
app.use('/api/[경로]', newRouter) app.use('/api/[경로]', newRouter)
``` ```
5. DB 테이블 필요 시 `database/auth_init.sql`에 DDL 추가 5. DB 테이블 필요 시 `database/auth_init.sql`에 DDL 추가
6. 리소스 코드를 `AUTH_PERM_TREE`에 등록 (마이그레이션 SQL)
### DB 접근 ### DB 접근
```typescript ```typescript
// PostgreSQL (인증 DB) // PostgreSQL — wing DB (운영 데이터: 레이어, 사고, 예측 등)
import { authPool } from '../db/authDb.js' import { wingPool } from '../db/wingDb.js'
const result = await authPool.query('SELECT * FROM TABLE WHERE id = $1', [id]) const result = await wingPool.query('SELECT * FROM LAYER WHERE LAYER_CD = $1', [id])
// SQLite (레이어 DB) // PostgreSQL — wing_auth DB (인증 데이터: 사용자, 역할, 권한 등)
import { getDb } from '../db/database.js' import { authPool } from '../db/authDb.js'
const db = getDb() const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1', [id])
const rows = db.prepare('SELECT * FROM table').all()
``` ```
--- ---
@ -307,20 +420,30 @@ const rows = db.prepare('SELECT * FROM table').all()
``` ```
frontend/src/ frontend/src/
├── services/api.ts Axios 인스턴스 + 인터셉터 ├── common/
├── services/authApi.ts 인증/사용자/역할/설정/메뉴/감사로그 API │ ├── services/api.ts Axios 인스턴스 + API_BASE_URL + 인터셉터
├── store/authStore.ts 인증 상태 (Zustand) │ ├── services/authApi.ts 인증/사용자/역할/설정/메뉴/감사로그 API
├── store/menuStore.ts 메뉴 상태 (Zustand) │ ├── store/authStore.ts 인증 상태 + hasPermission (Zustand)
└── App.tsx 탭 라우팅 + 감사 로그 자동 기록 │ ├── store/menuStore.ts 메뉴 상태 (Zustand)
│ └── hooks/ useSubMenu, useFeatureTracking 등
├── tabs/ 탭별 패키지 (11개)
└── App.tsx 탭 라우팅 + 감사 로그 자동 기록
backend/src/ backend/src/
├── auth/ 인증 (JWT, OAuth, 미들웨어) ├── auth/ 인증 (JWT, OAuth, 미들웨어, requirePermission)
├── users/ 사용자 관리 ├── users/ 사용자 관리
├── roles/ 역할/권한 관리 ├── roles/ 역할/권한 관리 (permResolver, roleService)
├── settings/ 시스템 설정 ├── settings/ 시스템 설정
├── menus/ 메뉴 설정 ├── menus/ 메뉴 설정
├── audit/ 감사 로그 ├── audit/ 감사 로그
├── db/ DB 연결 (authDb, database) ├── db/ DB 연결 (authDb, wingDb)
├── middleware/ 보안 미들웨어 ├── middleware/ 보안 미들웨어
└── server.ts Express 진입점 + 라우터 등록 └── 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

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -22,18 +22,19 @@ Frontend: menuStore.ts → TopBar.tsx (탭 렌더링)
| 순서 | 파일 | 작업 | 필수 | | 순서 | 파일 | 작업 | 필수 |
|------|------|------|------| |------|------|------|------|
| 1 | `frontend/src/components/views/XxxView.tsx` | 뷰 컴포넌트 생성 | O | | 1 | `frontend/src/tabs/{탭명}/components/XxxView.tsx` | 뷰 컴포넌트 생성 | O |
| 2 | `frontend/src/App.tsx` | MainTab 타입 + import + renderView | O | | 2 | `frontend/src/tabs/{탭명}/index.ts` | re-export 생성 | O |
| 3 | `backend/src/settings/settingsService.ts` | DEFAULT_MENU_CONFIG에 항목 추가 | O | | 3 | `frontend/src/App.tsx` | MainTab 타입 + import + renderView | O |
| 4 | `database/auth_init.sql` | menu.config 초기 JSON에 추가 | O | | 4 | `backend/src/settings/settingsService.ts` | DEFAULT_MENU_CONFIG에 항목 추가 | O |
| 5 | 관리자 UI | 메뉴 관리에서 활성화 | O | | 5 | `database/auth_init.sql` | menu.config 초기 JSON에 추가 | O |
| 6 | 관리자 UI | 메뉴 관리에서 활성화 | O |
## Step 1: 뷰 컴포넌트 생성 ## Step 1: 뷰 컴포넌트 생성
`frontend/src/components/views/` 에 새 뷰 컴포넌트를 생성합니다. `frontend/src/tabs/{탭명}/components/` 에 새 뷰 컴포넌트를 생성합니다.
```tsx ```tsx
// frontend/src/components/views/MonitoringView.tsx // frontend/src/tabs/monitoring/components/MonitoringView.tsx
export function MonitoringView() { export function MonitoringView() {
return ( 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 탭 등록 ## Step 2: App.tsx 탭 등록
@ -68,7 +76,7 @@ export type MainTab = 'prediction' | 'hns' | ... | 'monitoring' | 'admin'
### 2-2. 뷰 컴포넌트 import ### 2-2. 뷰 컴포넌트 import
```tsx ```tsx
import { MonitoringView } from './components/views/MonitoringView' import { MonitoringView } from '@tabs/monitoring'
``` ```
### 2-3. renderView switch에 case 추가 ### 2-3. renderView switch에 case 추가

파일 보기

@ -34,12 +34,12 @@ claude
| 영역 | 기술 | | 영역 | 기술 |
|------|------| |------|------|
| Frontend | React 19, Vite 7, TypeScript 5.9, Tailwind CSS 3 | | 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 (서버) | | 상태 관리 | Zustand (클라이언트), TanStack Query (서버) |
| 지도 | Leaflet, OpenLayers | | 지도 | Leaflet + react-leaflet |
| 실시간 | Socket.IO | | 실시간 | Socket.IO |
| 인증 | JWT (HttpOnly Cookie), Google OAuth | | 인증 | JWT (HttpOnly Cookie), Google OAuth |
| DB | PostgreSQL 16 + PostGIS (운영 DB 직접 연결), SQLite | | DB | PostgreSQL 16 + PostGIS (wing 운영DB + wing_auth 인증DB) |
| CI/CD | Gitea Actions | | CI/CD | Gitea Actions |
--- ---
@ -50,18 +50,21 @@ claude
wing/ wing/
├── frontend/ React 19 + Vite + TypeScript + Tailwind ├── frontend/ React 19 + Vite + TypeScript + Tailwind
│ └── src/ │ └── src/
│ ├── App.tsx 메인 (탭 라우팅, 감사 로그 자동 기록) │ ├── App.tsx 메인 (탭 라우팅, 감사 로그)
│ ├── components/ UI 컴포넌트 │ ├── common/ 공통 모듈 (@common/ alias)
│ │ ├── auth/ 로그인 페이지 │ │ ├── components/ auth/, layer/, layout/, map/, ui/
│ │ ├── views/ 각 탭별 페이지 뷰 (11개) │ │ ├── hooks/ useLayers, useSubMenu
│ │ ├── layout/ MainLayout, TopBar, LeftPanel, RightPanel │ │ ├── services/ api.ts, authApi.ts, layerService.ts
│ │ ├── map/ 지도 관련 │ │ ├── store/ authStore, menuStore (Zustand)
│ │ └── ... analysis, board, incidents, weather 등 │ │ ├── types/ backtrack, boomLine, hns, navigation
│ ├── hooks/ 커스텀 훅 │ │ └── utils/ coordinates, geo, sanitize
│ ├── services/ API 서비스 (api, authApi, weatherApi 등) │ └── tabs/ 탭 단위 패키지 (@tabs/ alias)
│ ├── store/ Zustand 상태 (authStore, menuStore) │ ├── prediction/ 확산 예측 (OilSpillView, LeftPanel 등)
│ ├── types/ 타입 정의 │ ├── hns/ HNS 분석 (HNSView, HNSSubstanceView 등)
│ └── utils/ 유틸리티 │ ├── rescue/ 구조 시나리오
│ ├── aerial/ 항공 방제
│ ├── weather/ 해양 기상
│ └── ... incidents, board, reports, assets, scat, admin
├── backend/ Express + TypeScript ├── backend/ Express + TypeScript
│ └── src/ │ └── src/
│ ├── server.ts 진입점 + 라우터 등록 │ ├── server.ts 진입점 + 라우터 등록
@ -71,10 +74,11 @@ wing/
│ ├── settings/ 시스템 설정 │ ├── settings/ 시스템 설정
│ ├── menus/ 메뉴 설정 │ ├── menus/ 메뉴 설정
│ ├── audit/ 감사 로그 │ ├── audit/ 감사 로그
│ ├── hns/ HNS 물질 검색 API
│ ├── routes/ 레이어, 시뮬레이션 │ ├── routes/ 레이어, 시뮬레이션
│ ├── middleware/ 보안 (입력 살균, rate-limit) │ ├── middleware/ 보안 (입력 살균, rate-limit)
│ └── db/ DB 연결 (PostgreSQL, SQLite) │ └── db/ DB 연결 (wingDb, authDb), seed
├── database/ SQL 초기화 스크립트 ├── database/ SQL 스크립트 + 마이그레이션
├── docs/ 개발 문서 ├── docs/ 개발 문서
├── .claude/ 팀 워크플로우 (rules, skills, scripts) ├── .claude/ 팀 워크플로우 (rules, skills, scripts)
└── .githooks/ Git hooks (pre-commit, commit-msg) └── .githooks/ Git hooks (pre-commit, commit-msg)

파일 보기

@ -19,7 +19,6 @@
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.564.0", "lucide-react": "^0.564.0",
"ol": "^10.8.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
@ -1148,12 +1147,6 @@
"node": ">= 8" "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": { "node_modules/@react-leaflet/core": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
@ -1650,12 +1643,6 @@
"undici-types": "~7.16.0" "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": { "node_modules/@types/react": {
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "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" "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": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@ -2483,12 +2460,6 @@
"node": ">= 0.4" "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": { "node_modules/electron-to-chromium": {
"version": "1.5.286", "version": "1.5.286",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", "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": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@ -3051,25 +3016,6 @@
"node": ">=6.9.0" "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": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@ -3410,12 +3356,6 @@
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause" "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": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -3633,15 +3573,6 @@
"node": ">=0.10.0" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -3662,24 +3593,6 @@
"node": ">= 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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -3730,12 +3643,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -3749,12 +3656,6 @@
"node": ">=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": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -3782,18 +3683,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -4007,12 +3896,6 @@
"node": ">= 0.8.0" "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": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@ -4050,33 +3933,6 @@
], ],
"license": "MIT" "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": { "node_modules/react": {
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@ -4158,12 +4014,6 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@ -4195,15 +4045,6 @@
"node": ">=4" "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": { "node_modules/reusify": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@ -4598,18 +4439,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@ -4658,12 +4487,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "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": { "node_modules/xmlhttprequest-ssl": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
@ -4826,16 +4637,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/zod": {
"version": "4.3.6", "version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
@ -4859,12 +4660,6 @@
"zod": "^3.25.0 || ^4.0.0" "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": { "node_modules/zustand": {
"version": "5.0.11", "version": "5.0.11",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",

파일 보기

@ -21,7 +21,6 @@
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.564.0", "lucide-react": "^0.564.0",
"ol": "^10.8.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",

파일 보기

@ -1,23 +1,22 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { GoogleOAuthProvider } from '@react-oauth/google' import { GoogleOAuthProvider } from '@react-oauth/google'
import { MainLayout } from './components/layout/MainLayout' import type { MainTab } from '@common/types/navigation'
import { LoginPage } from './components/auth/LoginPage' import { MainLayout } from '@common/components/layout/MainLayout'
import { registerMainTabSwitcher } from './hooks/useSubMenu' import { LoginPage } from '@common/components/auth/LoginPage'
import { useAuthStore } from './store/authStore' import { registerMainTabSwitcher } from '@common/hooks/useSubMenu'
import { useMenuStore } from './store/menuStore' import { useAuthStore } from '@common/store/authStore'
import { OilSpillView } from './components/views/OilSpillView' import { useMenuStore } from '@common/store/menuStore'
import { ReportsView } from './components/views/ReportsView' import { OilSpillView } from '@tabs/prediction'
import { HNSView } from './components/views/HNSView' import { ReportsView } from '@tabs/reports'
import { AerialView } from './components/views/AerialView' import { HNSView } from '@tabs/hns'
import { AssetsView } from './components/views/AssetsView' import { AerialView } from '@tabs/aerial'
import { BoardView } from './components/views/BoardView' import { AssetsView } from '@tabs/assets'
import { WeatherView } from './components/views/WeatherView' import { BoardView } from '@tabs/board'
import { IncidentsView } from './components/views/IncidentsView' import { WeatherView } from '@tabs/weather'
import { AdminView } from './components/views/AdminView' import { IncidentsView } from '@tabs/incidents'
import { PreScatView } from './components/views/PreScatView' import { AdminView } from '@tabs/admin'
import { RescueView } from './components/views/RescueView' import { PreScatView } from '@tabs/scat'
import { RescueView } from '@tabs/rescue'
export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin'
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || '' const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || ''
@ -47,7 +46,8 @@ function App() {
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })], [JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
{ type: 'text/plain' } { 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]) }, [activeMainTab, isAuthenticated])
// 세션 확인 중 스플래시 // 세션 확인 중 스플래시

파일 보기

@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import type { Layer } from '../../data/layerDatabase' import type { Layer } from '@common/services/layerService'
const PRESET_COLORS = [ const PRESET_COLORS = [
'#ef4444','#f97316','#eab308','#22c55e','#06b6d4', '#ef4444','#f97316','#eab308','#22c55e','#06b6d4',

파일 보기

@ -1,5 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { MainTab } from '../../App' import type { MainTab } from '../../types/navigation'
import { TopBar } from './TopBar' import { TopBar } from './TopBar'
import { SubMenuBar } from './SubMenuBar' import { SubMenuBar } from './SubMenuBar'

파일 보기

@ -1,4 +1,4 @@
import type { MainTab } from '../../App' import type { MainTab } from '../../types/navigation'
import { useSubMenu } from '../../hooks/useSubMenu' import { useSubMenu } from '../../hooks/useSubMenu'
interface SubMenuBarProps { interface SubMenuBarProps {

파일 보기

@ -1,5 +1,5 @@
import { useState, useRef, useEffect, useMemo } from 'react' import { useState, useRef, useEffect, useMemo } from 'react'
import type { MainTab } from '../../App' import type { MainTab } from '../../types/navigation'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { useMenuStore } from '../../store/menuStore' 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 { interface BacktrackReplayBarProps {
isPlaying: boolean isPlaying: boolean

파일 보기

@ -1,7 +1,7 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { Polyline, CircleMarker, Circle, Marker, Popup } from 'react-leaflet' import { Polyline, CircleMarker, Circle, Marker, Popup } from 'react-leaflet'
import L from 'leaflet' import L from 'leaflet'
import type { ReplayShip, CollisionEvent, ReplayPathPoint } from '../../types/backtrack' import type { ReplayShip, CollisionEvent, ReplayPathPoint } from '@common/types/backtrack'
interface BacktrackReplayOverlayProps { interface BacktrackReplayOverlayProps {
replayShips: ReplayShip[] 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 { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents, CircleMarker, Circle, Polyline } from 'react-leaflet'
import 'leaflet/dist/leaflet.css' import 'leaflet/dist/leaflet.css'
import L from 'leaflet' import L from 'leaflet'
import { layerDatabase } from '../../data/layerDatabase' import { layerDatabase } from '@common/services/layerService'
import { decimalToDMS } from '../../utils/coordinates' import { decimalToDMS } from '@common/utils/coordinates'
import type { PredictionModel } from '../views/OilSpillView' import type { PredictionModel } from '@tabs/prediction/components/OilSpillView'
import type { BoomLine, BoomLineCoord } from '../../types/boomLine' import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
import type { ReplayShip, CollisionEvent } from '../../types/backtrack' import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
import { BacktrackReplayOverlay } from './BacktrackReplayOverlay' import { BacktrackReplayOverlay } from './BacktrackReplayOverlay'
// Fix Leaflet default icon issue // Fix Leaflet default icon issue

파일 보기

@ -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;

파일 보기

@ -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 { useQuery } from '@tanstack/react-query'
import { fetchAllLayers, fetchLayerTree, fetchWMSLayers } from '../services/api' import { fetchAllLayers, fetchLayerTree, fetchWMSLayers } from '../services/api'
import type { Layer } from '../data/layerDatabase' import type { Layer } from '@common/services/layerService'
// 모든 레이어 조회 훅 // 모든 레이어 조회 훅
export function useLayers() { export function useLayers() {

파일 보기

@ -1,5 +1,7 @@
import { useState, useEffect } from 'react' 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 { interface SubMenuItem {
id: string id: string
@ -91,6 +93,8 @@ function subscribe(listener: () => void) {
export function useSubMenu(mainTab: MainTab) { export function useSubMenu(mainTab: MainTab) {
const [activeSubTab, setActiveSubTabLocal] = useState(subMenuState[mainTab]) const [activeSubTab, setActiveSubTabLocal] = useState(subMenuState[mainTab])
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const hasPermission = useAuthStore((s) => s.hasPermission)
useEffect(() => { useEffect(() => {
const unsubscribe = subscribe(() => { const unsubscribe = subscribe(() => {
@ -103,10 +107,27 @@ export function useSubMenu(mainTab: MainTab) {
setSubTab(mainTab, subTab) 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 { return {
activeSubTab, activeSubTab,
setActiveSubTab, 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 = { export const MOCK_CONDITIONS: BacktrackConditions = {
estimatedSpillTime: '02-10 06:30', estimatedSpillTime: '02-10 06:30',

파일 보기

@ -1,6 +1,6 @@
import axios from 'axios' 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({ export const api = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,

파일 보기

@ -7,7 +7,7 @@ export interface AuthUser {
rank: string | null rank: string | null
org: { sn: number; name: string; abbr: string } | null org: { sn: number; name: string; abbr: string } | null
roles: string[] roles: string[]
permissions: string[] permissions: Record<string, string[]>
} }
interface LoginResponse { interface LoginResponse {
@ -117,6 +117,7 @@ export interface RoleWithPermissions {
permissions: Array<{ permissions: Array<{
sn: number sn: number
resourceCode: string resourceCode: string
operationCode: string
granted: boolean granted: boolean
}> }>
} }
@ -126,9 +127,26 @@ export async function fetchRoles(): Promise<RoleWithPermissions[]> {
return response.data 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( export async function updatePermissionsApi(
roleSn: number, roleSn: number,
permissions: Array<{ resourceCode: string; granted: boolean }> permissions: Array<{ resourceCode: string; operationCode: string; granted: boolean }>
): Promise<void> { ): Promise<void> {
await api.put(`/roles/${roleSn}/permissions`, { permissions }) await api.put(`/roles/${roleSn}/permissions`, { permissions })
} }

파일 보기

@ -1,5 +1,5 @@
// 레이어 데이터베이스 - API에서 가져옴 // 레이어 데이터베이스 - API에서 가져옴
import { fetchAllLayers } from '../services/api' import { fetchAllLayers } from '@common/services/api'
export interface Layer { export interface Layer {
id: string id: string

파일 보기

@ -12,7 +12,7 @@ interface AuthState {
googleLogin: (credential: string) => Promise<void> googleLogin: (credential: string) => Promise<void>
logout: () => Promise<void> logout: () => Promise<void>
checkSession: () => Promise<void> checkSession: () => Promise<void>
hasPermission: (resource: string) => boolean hasPermission: (resource: string, operation?: string) => boolean
clearError: () => void clearError: () => void
} }
@ -70,10 +70,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
} }
}, },
hasPermission: (resource: string) => { hasPermission: (resource: string, operation?: string) => {
const { user } = get() const { user } = get()
if (!user) return false 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 }), clearError: () => set({ error: null, pendingMessage: null }),

파일 보기

@ -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 }>
}

파일 보기

@ -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

파일 보기

@ -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>
)
}

파일 보기

@ -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

파일 보기

@ -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

파일 보기

@ -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

파일 보기

@ -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

파일 보기

@ -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

파일 보기

@ -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)

파일 보기

@ -0,0 +1 @@
export { AdminView } from './components/AdminView'

파일 보기

@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { sanitizeHtml } from '../../utils/sanitize' import { sanitizeHtml } from '@common/utils/sanitize'
const panels = [ const panels = [
{ id: 0, icon: '🌐', label: '개요' }, { id: 0, icon: '🌐', label: '개요' },

파일 보기

@ -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>
)
}

파일 보기

@ -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>
)
}

파일 보기

@ -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>
)
}

파일 보기

@ -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>
)
}

파일 보기

@ -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>
)
}

파일 보기

@ -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:1014:24', res: '0.5m', cloud: '≤10%', note: '최우선 추천', color: '#a855f7' },
{ sat: 'Pléiades Neo', time: '오늘 14:3814:52', res: '0.3m', cloud: '≤15%', note: '초고해상도', color: '#06b6d4' },
{ sat: 'Sentinel-1 SAR', time: '오늘 16:5517: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.152.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>
)
}

파일 보기

@ -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>
)
}

파일 보기

@ -0,0 +1 @@
export { AerialView } from './components/AerialView'

파일 보기

@ -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

파일 보기

@ -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(
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <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

파일 보기

@ -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:272286, 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.1116, 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

파일 보기

@ -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

파일 보기

@ -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>
)
}

파일 보기

@ -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

파일 보기

@ -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 },
]

파일 보기

@ -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' }
}
}

파일 보기

@ -0,0 +1 @@
export { AssetsView } from './components/AssetsView'

파일 보기

@ -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 { useState } from 'react'
import { useSubMenu } from '../../hooks/useSubMenu' import { useSubMenu } from '@common/hooks/useSubMenu'
import { BoardWriteForm } from '../board/BoardWriteForm' import { BoardWriteForm } from './BoardWriteForm'
import { BoardDetailView } from '../board/BoardDetailView' import { BoardDetailView } from './BoardDetailView'
interface BoardPost { interface BoardPost {
id: number id: number

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