chore: develop 브랜치 머지 충돌 해결 (MR#23 HNS + MR#24 문서)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
커밋
6e0a412729
48
CLAUDE.md
48
CLAUDE.md
@ -7,8 +7,9 @@
|
|||||||
- **프로젝트 타입**: 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 + PostgreSQL (pg) + TypeScript
|
- **Backend**: Express 4 + PostgreSQL (pg) + TypeScript
|
||||||
|
- **DB**: PostgreSQL 16 + PostGIS (wing 운영DB + wing_auth 인증DB)
|
||||||
- **상태관리**: Zustand (클라이언트), TanStack Query (서버)
|
- **상태관리**: Zustand (클라이언트), TanStack Query (서버)
|
||||||
- **지도**: Leaflet
|
- **지도**: 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: wing, wing_auth)
|
│ └── 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` — 보안/품질 정책
|
||||||
|
|||||||
40
README.md
40
README.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)
|
||||||
@ -128,10 +138,10 @@ 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, PostgreSQL (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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
63
backend/src/db/seedHns.ts
Normal file
63
backend/src/db/seedHns.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
|
import { wingPool } from './wingDb.js'
|
||||||
|
|
||||||
|
// 프론트엔드 정적 데이터를 직접 import (tsx로 실행)
|
||||||
|
import { HNS_SEARCH_DB } from '../../../frontend/src/data/hnsSubstanceSearchData.js'
|
||||||
|
|
||||||
|
async function seedHnsSubstances() {
|
||||||
|
console.log('HNS 물질정보 시드 시작...')
|
||||||
|
console.log(`총 ${HNS_SEARCH_DB.length}종 물질 데이터 삽입 예정`)
|
||||||
|
|
||||||
|
const client = await wingPool.connect()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
|
||||||
|
// 기존 데이터 삭제
|
||||||
|
await client.query('DELETE FROM HNS_SUBSTANCE')
|
||||||
|
|
||||||
|
let inserted = 0
|
||||||
|
|
||||||
|
for (const s of HNS_SEARCH_DB) {
|
||||||
|
// 검색용 컬럼 추출, 나머지는 DATA JSONB로 저장
|
||||||
|
const { abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, ...detailData } = s
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO HNS_SUBSTANCE (SBST_SN, ABBREVIATION, NM_KR, NM_EN, UN_NO, CAS_NO, SEBC, DATA)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
ON CONFLICT (SBST_SN) DO UPDATE SET
|
||||||
|
ABBREVIATION = EXCLUDED.ABBREVIATION,
|
||||||
|
NM_KR = EXCLUDED.NM_KR,
|
||||||
|
NM_EN = EXCLUDED.NM_EN,
|
||||||
|
UN_NO = EXCLUDED.UN_NO,
|
||||||
|
CAS_NO = EXCLUDED.CAS_NO,
|
||||||
|
SEBC = EXCLUDED.SEBC,
|
||||||
|
DATA = EXCLUDED.DATA`,
|
||||||
|
[s.id, abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, JSON.stringify(detailData)]
|
||||||
|
)
|
||||||
|
|
||||||
|
inserted++
|
||||||
|
if (inserted % 100 === 0) {
|
||||||
|
console.log(` ${inserted}/${HNS_SEARCH_DB.length}건 삽입 완료...`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT')
|
||||||
|
|
||||||
|
// 결과 확인
|
||||||
|
const { rows } = await client.query('SELECT COUNT(*) as count FROM HNS_SUBSTANCE')
|
||||||
|
console.log(`시드 완료! 총 ${rows[0].count}종의 HNS 물질이 저장되었습니다.`)
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK')
|
||||||
|
console.error('HNS 시드 실패:', err)
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
client.release()
|
||||||
|
await wingPool.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedHnsSubstances().catch((err) => {
|
||||||
|
console.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
53
backend/src/hns/hnsRouter.ts
Normal file
53
backend/src/hns/hnsRouter.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import { searchSubstances, getSubstanceById } from './hnsService.js'
|
||||||
|
import { isValidNumber } from '../middleware/security.js'
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
// HNS 물질 검색
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const q = req.query.q as string | undefined
|
||||||
|
const type = req.query.type as string | undefined
|
||||||
|
const sebc = req.query.sebc as string | undefined
|
||||||
|
const page = parseInt(req.query.page as string, 10) || 1
|
||||||
|
const limit = parseInt(req.query.limit as string, 10) || 50
|
||||||
|
|
||||||
|
if (!isValidNumber(page, 1, 10000) || !isValidNumber(limit, 1, 100)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: '유효하지 않은 페이지네이션',
|
||||||
|
message: 'page는 1~10000, limit은 1~100 범위여야 합니다.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const validTypes = ['abbreviation', 'nameKr', 'nameEn', 'casNumber', 'unNumber', 'cargoCode']
|
||||||
|
const searchType = type && validTypes.includes(type)
|
||||||
|
? type as 'abbreviation' | 'nameKr' | 'nameEn' | 'casNumber' | 'unNumber' | 'cargoCode'
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const result = await searchSubstances({ q, type: searchType, sebc, page, limit })
|
||||||
|
res.json(result)
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'HNS 물질 검색 실패' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// HNS 물질 상세 조회
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10)
|
||||||
|
if (!isValidNumber(id, 1, 999999)) {
|
||||||
|
return res.status(400).json({ error: '유효하지 않은 물질 ID' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const substance = await getSubstanceById(id)
|
||||||
|
if (!substance) {
|
||||||
|
return res.status(404).json({ error: '물질을 찾을 수 없습니다' })
|
||||||
|
}
|
||||||
|
res.json(substance)
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'HNS 물질 조회 실패' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
110
backend/src/hns/hnsService.ts
Normal file
110
backend/src/hns/hnsService.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { wingPool } from '../db/wingDb.js'
|
||||||
|
|
||||||
|
interface HnsSearchParams {
|
||||||
|
q?: string
|
||||||
|
type?: 'abbreviation' | 'nameKr' | 'nameEn' | 'casNumber' | 'unNumber' | 'cargoCode'
|
||||||
|
sebc?: string
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchSubstances(params: HnsSearchParams) {
|
||||||
|
const { q, type = 'nameKr', sebc, page = 1, limit = 50 } = params
|
||||||
|
const conditions: string[] = ["USE_YN = 'Y'"]
|
||||||
|
const values: (string | number)[] = []
|
||||||
|
let paramIdx = 1
|
||||||
|
|
||||||
|
if (q && q.trim()) {
|
||||||
|
const keyword = q.trim()
|
||||||
|
switch (type) {
|
||||||
|
case 'abbreviation':
|
||||||
|
conditions.push(`ABBREVIATION ILIKE $${paramIdx}`)
|
||||||
|
values.push(`%${keyword}%`)
|
||||||
|
break
|
||||||
|
case 'nameKr':
|
||||||
|
conditions.push(`NM_KR ILIKE $${paramIdx}`)
|
||||||
|
values.push(`%${keyword}%`)
|
||||||
|
break
|
||||||
|
case 'nameEn':
|
||||||
|
conditions.push(`NM_EN ILIKE $${paramIdx}`)
|
||||||
|
values.push(`%${keyword}%`)
|
||||||
|
break
|
||||||
|
case 'casNumber':
|
||||||
|
conditions.push(`CAS_NO ILIKE $${paramIdx}`)
|
||||||
|
values.push(`%${keyword}%`)
|
||||||
|
break
|
||||||
|
case 'unNumber':
|
||||||
|
conditions.push(`UN_NO = $${paramIdx}`)
|
||||||
|
values.push(keyword)
|
||||||
|
break
|
||||||
|
case 'cargoCode':
|
||||||
|
conditions.push(`DATA->'cargoCodes' @> $${paramIdx}::jsonb`)
|
||||||
|
values.push(JSON.stringify([{ code: keyword }]))
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
conditions.push(`(NM_KR ILIKE $${paramIdx} OR NM_EN ILIKE $${paramIdx} OR ABBREVIATION ILIKE $${paramIdx})`)
|
||||||
|
values.push(`%${keyword}%`)
|
||||||
|
}
|
||||||
|
paramIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sebc && sebc.trim()) {
|
||||||
|
conditions.push(`SEBC ILIKE $${paramIdx}`)
|
||||||
|
values.push(`%${sebc.trim()}%`)
|
||||||
|
paramIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = conditions.join(' AND ')
|
||||||
|
const offset = (page - 1) * limit
|
||||||
|
|
||||||
|
const countQuery = `SELECT COUNT(*) as total FROM HNS_SUBSTANCE WHERE ${where}`
|
||||||
|
const dataQuery = `
|
||||||
|
SELECT SBST_SN, ABBREVIATION, NM_KR, NM_EN, UN_NO, CAS_NO, SEBC, DATA
|
||||||
|
FROM HNS_SUBSTANCE
|
||||||
|
WHERE ${where}
|
||||||
|
ORDER BY SBST_SN
|
||||||
|
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
|
||||||
|
`
|
||||||
|
|
||||||
|
const [countResult, dataResult] = await Promise.all([
|
||||||
|
wingPool.query(countQuery, values),
|
||||||
|
wingPool.query(dataQuery, [...values, limit, offset]),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: parseInt(countResult.rows[0].total, 10),
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
items: dataResult.rows.map(row => ({
|
||||||
|
id: row.sbst_sn,
|
||||||
|
abbreviation: row.abbreviation,
|
||||||
|
nameKr: row.nm_kr,
|
||||||
|
nameEn: row.nm_en,
|
||||||
|
unNumber: row.un_no,
|
||||||
|
casNumber: row.cas_no,
|
||||||
|
sebc: row.sebc,
|
||||||
|
...row.data,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSubstanceById(id: number) {
|
||||||
|
const { rows } = await wingPool.query(
|
||||||
|
`SELECT SBST_SN, ABBREVIATION, NM_KR, NM_EN, UN_NO, CAS_NO, SEBC, DATA
|
||||||
|
FROM HNS_SUBSTANCE WHERE SBST_SN = $1`,
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
return {
|
||||||
|
id: row.sbst_sn,
|
||||||
|
abbreviation: row.abbreviation,
|
||||||
|
nameKr: row.nm_kr,
|
||||||
|
nameEn: row.nm_en,
|
||||||
|
unNumber: row.un_no,
|
||||||
|
casNumber: row.cas_no,
|
||||||
|
sebc: row.sebc,
|
||||||
|
...row.data,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@ 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 boardRouter from './board/boardRouter.js'
|
||||||
|
import hnsRouter from './hns/hnsRouter.js'
|
||||||
import {
|
import {
|
||||||
sanitizeBody,
|
sanitizeBody,
|
||||||
sanitizeQuery,
|
sanitizeQuery,
|
||||||
@ -139,6 +140,7 @@ app.use('/api/audit', auditRouter)
|
|||||||
app.use('/api/board', boardRouter)
|
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) => {
|
||||||
|
|||||||
@ -13,5 +13,5 @@
|
|||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "src/db/seedHns.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
46
database/migration/002_hns_substance.sql
Normal file
46
database/migration/002_hns_substance.sql
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
-- ================================================================
|
||||||
|
-- 002: HNS 물질정보 테이블 (프론트엔드 정적 데이터 → DB 이전)
|
||||||
|
-- ================================================================
|
||||||
|
-- 검색용 컬럼 + 상세 데이터 JSONB 구조
|
||||||
|
-- pg_trgm 인덱스로 한글/영문 물질명 검색 지원
|
||||||
|
-- ================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS HNS_SUBSTANCE (
|
||||||
|
SBST_SN SERIAL NOT NULL, -- 물질순번
|
||||||
|
ABBREVIATION VARCHAR(50), -- 약자/제품명
|
||||||
|
NM_KR VARCHAR(200) NOT NULL, -- 국문명
|
||||||
|
NM_EN VARCHAR(200), -- 영문명
|
||||||
|
UN_NO VARCHAR(10), -- UN번호
|
||||||
|
CAS_NO VARCHAR(20), -- CAS번호
|
||||||
|
SEBC VARCHAR(50), -- SEBC 거동분류
|
||||||
|
DATA JSONB NOT NULL, -- 전체 상세 데이터
|
||||||
|
USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 사용여부
|
||||||
|
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
||||||
|
CONSTRAINT PK_HNS_SUBSTANCE PRIMARY KEY (SBST_SN),
|
||||||
|
CONSTRAINT CK_HNS_SBST_USE CHECK (USE_YN IN ('Y', 'N'))
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE HNS_SUBSTANCE IS 'HNS물질정보 (1,316종)';
|
||||||
|
COMMENT ON COLUMN HNS_SUBSTANCE.SBST_SN IS '물질순번';
|
||||||
|
COMMENT ON COLUMN HNS_SUBSTANCE.ABBREVIATION IS '약자/제품명 (화물적부도 코드)';
|
||||||
|
COMMENT ON COLUMN HNS_SUBSTANCE.NM_KR IS '국문명';
|
||||||
|
COMMENT ON COLUMN HNS_SUBSTANCE.NM_EN IS '영문명';
|
||||||
|
COMMENT ON COLUMN HNS_SUBSTANCE.UN_NO IS 'UN번호 (위험물 식별번호)';
|
||||||
|
COMMENT ON COLUMN HNS_SUBSTANCE.CAS_NO IS 'CAS번호 (화학물질등록번호)';
|
||||||
|
COMMENT ON COLUMN HNS_SUBSTANCE.SEBC IS 'SEBC 거동분류 (G/GD/E/ED/FE/FED/F/FD/D/S/SD)';
|
||||||
|
COMMENT ON COLUMN HNS_SUBSTANCE.DATA IS '전체 상세 데이터 (JSONB)';
|
||||||
|
COMMENT ON COLUMN HNS_SUBSTANCE.USE_YN IS '사용여부 (Y:사용, N:미사용)';
|
||||||
|
COMMENT ON COLUMN HNS_SUBSTANCE.REG_DTM IS '등록일시';
|
||||||
|
|
||||||
|
-- 텍스트 검색 인덱스 (pg_trgm)
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_NM_KR ON HNS_SUBSTANCE USING GIN(NM_KR gin_trgm_ops);
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_NM_EN ON HNS_SUBSTANCE USING GIN(NM_EN gin_trgm_ops);
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_ABBR ON HNS_SUBSTANCE USING GIN(ABBREVIATION gin_trgm_ops);
|
||||||
|
|
||||||
|
-- 코드 검색 인덱스
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_UN ON HNS_SUBSTANCE(UN_NO);
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_CAS ON HNS_SUBSTANCE(CAS_NO);
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_SEBC ON HNS_SUBSTANCE(SEBC);
|
||||||
|
|
||||||
|
-- JSONB 내 cargoCodes 검색용 인덱스
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_DATA ON HNS_SUBSTANCE USING GIN(DATA jsonb_path_ops);
|
||||||
@ -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 추가
|
||||||
|
|||||||
@ -36,10 +36,10 @@ 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, PostgreSQL (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 (wing + wing_auth) |
|
| 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: wing, wing_auth)
|
│ └── 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)
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { MainLayout } from '@common/components/layout/MainLayout'
|
|||||||
import { LoginPage } from '@common/components/auth/LoginPage'
|
import { LoginPage } from '@common/components/auth/LoginPage'
|
||||||
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu'
|
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu'
|
||||||
import { useAuthStore } from '@common/store/authStore'
|
import { useAuthStore } from '@common/store/authStore'
|
||||||
import { API_BASE_URL } from '@common/services/api'
|
|
||||||
import { useMenuStore } from '@common/store/menuStore'
|
import { useMenuStore } from '@common/store/menuStore'
|
||||||
import { OilSpillView } from '@tabs/prediction'
|
import { OilSpillView } from '@tabs/prediction'
|
||||||
import { ReportsView } from '@tabs/reports'
|
import { ReportsView } from '@tabs/reports'
|
||||||
@ -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_BASE_URL}/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',
|
||||||
|
|||||||
@ -2,7 +2,7 @@ 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 '@common/utils/coordinates'
|
import { decimalToDMS } from '@common/utils/coordinates'
|
||||||
import type { PredictionModel } from '@tabs/prediction/components/OilSpillView'
|
import type { PredictionModel } from '@tabs/prediction/components/OilSpillView'
|
||||||
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
|
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
67
frontend/src/common/types/hns.ts
Normal file
67
frontend/src/common/types/hns.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
/* HNS 물질 검색 데이터 타입 */
|
||||||
|
|
||||||
|
export interface HNSSearchSubstance {
|
||||||
|
id: number
|
||||||
|
abbreviation: string // 약자/제품명 (화물적부도 코드)
|
||||||
|
nameKr: string // 국문명
|
||||||
|
nameEn: string // 영문명
|
||||||
|
synonymsEn: string // 영문 동의어
|
||||||
|
synonymsKr: string // 국문 동의어/용도
|
||||||
|
unNumber: string // UN번호
|
||||||
|
casNumber: string // CAS번호
|
||||||
|
transportMethod: string // 운송방법
|
||||||
|
sebc: string // SEBC 거동분류
|
||||||
|
/* 물리·화학적 특성 */
|
||||||
|
usage: string
|
||||||
|
state: string
|
||||||
|
color: string
|
||||||
|
odor: string
|
||||||
|
flashPoint: string
|
||||||
|
autoIgnition: string
|
||||||
|
boilingPoint: string
|
||||||
|
density: string // 비중 (물=1)
|
||||||
|
solubility: string
|
||||||
|
vaporPressure: string
|
||||||
|
vaporDensity: string // 증기밀도 (공기=1)
|
||||||
|
explosionRange: string // 폭발범위
|
||||||
|
/* 위험등급·농도기준 */
|
||||||
|
nfpa: { health: number; fire: number; reactivity: number; special: string }
|
||||||
|
hazardClass: string
|
||||||
|
ergNumber: string
|
||||||
|
idlh: string
|
||||||
|
aegl2: string
|
||||||
|
erpg2: string
|
||||||
|
/* 방제거리 */
|
||||||
|
responseDistanceFire: string
|
||||||
|
responseDistanceSpillDay: string
|
||||||
|
responseDistanceSpillNight: string
|
||||||
|
marineResponse: string
|
||||||
|
/* PPE */
|
||||||
|
ppeClose: string
|
||||||
|
ppeFar: string
|
||||||
|
/* MSDS 요약 */
|
||||||
|
msds: {
|
||||||
|
hazard: string
|
||||||
|
firstAid: string
|
||||||
|
fireFighting: string
|
||||||
|
spillResponse: string
|
||||||
|
exposure: string
|
||||||
|
regulation: string
|
||||||
|
}
|
||||||
|
/* IBC CODE */
|
||||||
|
ibcHazard: string
|
||||||
|
ibcShipType: string
|
||||||
|
ibcTankType: string
|
||||||
|
ibcDetection: string
|
||||||
|
ibcFireFighting: string
|
||||||
|
ibcMinRequirement: string
|
||||||
|
/* EmS */
|
||||||
|
emsCode: string
|
||||||
|
emsFire: string
|
||||||
|
emsSpill: string
|
||||||
|
emsFirstAid: string
|
||||||
|
/* 화물적부도 코드 */
|
||||||
|
cargoCodes: Array<{ code: string; name: string; company: string; source: string }>
|
||||||
|
/* 항구별 반입 */
|
||||||
|
portFrequency: Array<{ port: string; portCode: string; lastImport: string; frequency: string }>
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useRef, useMemo } from 'react'
|
import React, { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import { sanitizeHtml } from '@common/utils/sanitize'
|
import { sanitizeHtml } from '@common/utils/sanitize'
|
||||||
import { HNS_SEARCH_DB, type HNSSearchSubstance } from '../../../data/hnsSubstanceSearchData'
|
import { api } from '@common/services/api'
|
||||||
|
import type { HNSSearchSubstance } from '@common/types/hns'
|
||||||
|
|
||||||
/* ═══ HNS 물질 데이터베이스 ═══ */
|
/* ═══ HNS 물질 데이터베이스 ═══ */
|
||||||
interface HNSSubstance {
|
interface HNSSubstance {
|
||||||
@ -65,8 +66,60 @@ export function HNSSubstanceView() {
|
|||||||
const [hmsSelectedId, setHmsSelectedId] = useState<number | null>(null)
|
const [hmsSelectedId, setHmsSelectedId] = useState<number | null>(null)
|
||||||
const [hmsDetailTab, setHmsDetailTab] = useState(0)
|
const [hmsDetailTab, setHmsDetailTab] = useState(0)
|
||||||
const [hmsPage, setHmsPage] = useState(1)
|
const [hmsPage, setHmsPage] = useState(1)
|
||||||
|
const [hmsResults, setHmsResults] = useState<HNSSearchSubstance[]>([])
|
||||||
|
const [hmsTotal, setHmsTotal] = useState(0)
|
||||||
|
const [hmsLoading, setHmsLoading] = useState(false)
|
||||||
|
const [hmsSelectedSubstance, setHmsSelectedSubstance] = useState<HNSSearchSubstance | null>(null)
|
||||||
const contentRef = useRef<HTMLDivElement>(null)
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// 검색 타입 매핑 (프론트엔드 → API)
|
||||||
|
const searchTypeMap: Record<string, string> = {
|
||||||
|
abbr: 'abbreviation', korName: 'nameKr', engName: 'nameEn', cas: 'casNumber', un: 'unNumber',
|
||||||
|
}
|
||||||
|
|
||||||
|
// HNS 물질 검색 API 호출
|
||||||
|
const fetchHnsSubstances = useCallback(async () => {
|
||||||
|
setHmsLoading(true)
|
||||||
|
try {
|
||||||
|
const params: Record<string, string | number> = { page: hmsPage, limit: 10 }
|
||||||
|
if (hmsSearchInput.trim()) {
|
||||||
|
params.q = hmsSearchInput.trim()
|
||||||
|
params.type = searchTypeMap[hmsSearchType] || 'abbreviation'
|
||||||
|
}
|
||||||
|
if (hmsFilterSebc !== '전체 거동분류') {
|
||||||
|
params.sebc = hmsFilterSebc.split(' ')[0]
|
||||||
|
}
|
||||||
|
const { data } = await api.get('/hns', { params })
|
||||||
|
setHmsResults(data.items)
|
||||||
|
setHmsTotal(data.total)
|
||||||
|
} catch {
|
||||||
|
setHmsResults([])
|
||||||
|
setHmsTotal(0)
|
||||||
|
} finally {
|
||||||
|
setHmsLoading(false)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hmsSearchInput, hmsSearchType, hmsFilterSebc, hmsPage])
|
||||||
|
|
||||||
|
// 검색 조건 변경 시 API 호출 (디바운스)
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(fetchHnsSubstances, 300)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [fetchHnsSubstances])
|
||||||
|
|
||||||
|
// 물질 선택 시 상세 정보 조회
|
||||||
|
useEffect(() => {
|
||||||
|
if (hmsSelectedId === null) {
|
||||||
|
setHmsSelectedSubstance(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.get(`/hns/${hmsSelectedId}`).then(({ data }) => {
|
||||||
|
setHmsSelectedSubstance(data)
|
||||||
|
}).catch(() => {
|
||||||
|
setHmsSelectedSubstance(null)
|
||||||
|
})
|
||||||
|
}, [hmsSelectedId])
|
||||||
|
|
||||||
const handleExportPDF = () => {
|
const handleExportPDF = () => {
|
||||||
if (!contentRef.current) return
|
if (!contentRef.current) return
|
||||||
const clone = contentRef.current.cloneNode(true) as HTMLElement
|
const clone = contentRef.current.cloneNode(true) as HTMLElement
|
||||||
@ -113,28 +166,10 @@ ${styles}
|
|||||||
return matchName && matchCas && matchSebc
|
return matchName && matchCas && matchSebc
|
||||||
})
|
})
|
||||||
|
|
||||||
/* Panel 3: HNS 통합 검색 필터 */
|
/* Panel 3: HNS API 기반 검색 결과 */
|
||||||
const hmsFiltered = useMemo(() => {
|
|
||||||
const q = hmsSearchInput.toLowerCase().replace(/[\s\-./]/g, '')
|
|
||||||
return HNS_SEARCH_DB.filter(s => {
|
|
||||||
// SEBC 필터
|
|
||||||
if (hmsFilterSebc !== '전체 거동분류' && !s.sebc.startsWith(hmsFilterSebc.split(' ')[0])) return false
|
|
||||||
if (!q) return true
|
|
||||||
switch (hmsSearchType) {
|
|
||||||
case 'abbr': return s.abbreviation.toLowerCase().replace(/[\s\-./]/g, '').includes(q) || s.cargoCodes.some(c => c.code.toLowerCase().replace(/[\s\-./]/g, '').includes(q))
|
|
||||||
case 'korName': return s.nameKr.includes(hmsSearchInput) || s.synonymsKr.includes(hmsSearchInput)
|
|
||||||
case 'engName': return s.nameEn.toLowerCase().includes(q) || s.synonymsEn.toLowerCase().includes(q)
|
|
||||||
case 'cas': return s.casNumber.replace(/-/g, '').includes(q.replace(/-/g, ''))
|
|
||||||
case 'un': return s.unNumber.includes(hmsSearchInput)
|
|
||||||
default: return true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [hmsSearchInput, hmsSearchType, hmsFilterSebc])
|
|
||||||
|
|
||||||
const HMS_PER_PAGE = 10
|
const HMS_PER_PAGE = 10
|
||||||
const hmsTotalPages = Math.max(1, Math.ceil(hmsFiltered.length / HMS_PER_PAGE))
|
const hmsTotalPages = Math.max(1, Math.ceil(hmsTotal / HMS_PER_PAGE))
|
||||||
const hmsPageData = hmsFiltered.slice((hmsPage - 1) * HMS_PER_PAGE, hmsPage * HMS_PER_PAGE)
|
const hmsPageData = hmsResults
|
||||||
const hmsSelectedSubstance = hmsSelectedId !== null ? HNS_SEARCH_DB.find(s => s.id === hmsSelectedId) ?? null : null
|
|
||||||
|
|
||||||
const tabLabels = [
|
const tabLabels = [
|
||||||
{ icon: '📊', label: 'SEBC 거동분류' },
|
{ icon: '📊', label: 'SEBC 거동분류' },
|
||||||
@ -563,7 +598,7 @@ ${styles}
|
|||||||
{/* ── 검색 결과 테이블 ── */}
|
{/* ── 검색 결과 테이블 ── */}
|
||||||
<div style={{ background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 10, padding: 16, marginBottom: 16 }}>
|
<div style={{ background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 10, padding: 16, marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)' }}>📋 검색 결과 <span style={{ fontSize: 9, fontWeight: 400, color: 'var(--t3)' }}>— {hmsFiltered.length}건 조회</span></div>
|
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)' }}>📋 검색 결과 <span style={{ fontSize: 9, fontWeight: 400, color: 'var(--t3)' }}>— {hmsTotal}건 조회</span></div>
|
||||||
<select value={hmsFilterSebc} onChange={e => { setHmsFilterSebc(e.target.value); setHmsPage(1) }} style={{ padding: '3px 8px', borderRadius: 4, border: '1px solid var(--bd)', background: 'var(--bg0)', color: 'var(--t2)', fontSize: 9, fontFamily: 'var(--fK)', outline: 'none' }}>
|
<select value={hmsFilterSebc} onChange={e => { setHmsFilterSebc(e.target.value); setHmsPage(1) }} style={{ padding: '3px 8px', borderRadius: 4, border: '1px solid var(--bd)', background: 'var(--bg0)', color: 'var(--t2)', fontSize: 9, fontFamily: 'var(--fK)', outline: 'none' }}>
|
||||||
<option>전체 거동분류</option><option>G (Gas)</option><option>E (Evaporator)</option><option>F (Floater)</option><option>D (Dissolver)</option><option>S (Sinker)</option>
|
<option>전체 거동분류</option><option>G (Gas)</option><option>E (Evaporator)</option><option>F (Floater)</option><option>D (Dissolver)</option><option>S (Sinker)</option>
|
||||||
</select>
|
</select>
|
||||||
@ -583,7 +618,9 @@ ${styles}
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{hmsPageData.length > 0 ? hmsPageData.map((s, idx) => {
|
{hmsLoading ? (
|
||||||
|
<tr><td colSpan={8} style={{ padding: '20px 8px', textAlign: 'center', color: 'var(--t3)' }}>검색 중...</td></tr>
|
||||||
|
) : hmsPageData.length > 0 ? hmsPageData.map((s: HNSSearchSubstance, idx: number) => {
|
||||||
const isSel = hmsSelectedId === s.id
|
const isSel = hmsSelectedId === s.id
|
||||||
return (
|
return (
|
||||||
<tr key={s.id} onClick={() => { setHmsSelectedId(isSel ? null : s.id); setHmsDetailTab(0) }}
|
<tr key={s.id} onClick={() => { setHmsSelectedId(isSel ? null : s.id); setHmsDetailTab(0) }}
|
||||||
@ -608,7 +645,7 @@ ${styles}
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 10, display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 9, fontFamily: 'var(--fK)', color: 'var(--t3)' }}>
|
<div style={{ marginTop: 10, display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 9, fontFamily: 'var(--fK)', color: 'var(--t3)' }}>
|
||||||
<span>총 <b style={{ color: 'var(--orange)' }}>1,316</b>종 등록 · Port-MIS 화물적부도 연동 · 해경청 물질정보집 · IBC CODE 692종</span>
|
<span>총 <b style={{ color: 'var(--orange)' }}>{hmsTotal.toLocaleString()}</b>종 등록 · Port-MIS 화물적부도 연동 · 해경청 물질정보집 · IBC CODE 692종</span>
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
<button onClick={() => setHmsPage(p => Math.max(1, p - 1))} disabled={hmsPage <= 1} style={{ padding: '4px 10px', borderRadius: 4, border: '1px solid var(--bd)', background: 'var(--bg0)', color: 'var(--t2)', fontSize: 9, cursor: 'pointer', opacity: hmsPage <= 1 ? 0.4 : 1 }}>◀</button>
|
<button onClick={() => setHmsPage(p => Math.max(1, p - 1))} disabled={hmsPage <= 1} style={{ padding: '4px 10px', borderRadius: 4, border: '1px solid var(--bd)', background: 'var(--bg0)', color: 'var(--t2)', fontSize: 9, cursor: 'pointer', opacity: hmsPage <= 1 ? 0.4 : 1 }}>◀</button>
|
||||||
{Array.from({ length: Math.min(hmsTotalPages, 5) }, (_, i) => i + 1).map(p => (
|
{Array.from({ length: Math.min(hmsTotalPages, 5) }, (_, i) => i + 1).map(p => (
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { LatLngExpression } from 'leaflet'
|
|||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel'
|
import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel'
|
||||||
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel'
|
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel'
|
||||||
import { mockVessels, VESSEL_LEGEND, type Vessel } from '../../../data/vesselMockData'
|
import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData'
|
||||||
|
|
||||||
// Mock incident data (HTML 참고 6건)
|
// Mock incident data (HTML 참고 6건)
|
||||||
const mockIncidents: Incident[] = [
|
const mockIncidents: Incident[] = [
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/u
|
|||||||
import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine'
|
import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine'
|
||||||
import type { BacktrackPhase, BacktrackVessel } from '@common/types/backtrack'
|
import type { BacktrackPhase, BacktrackVessel } from '@common/types/backtrack'
|
||||||
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
|
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
|
||||||
import { MOCK_CONDITIONS, MOCK_VESSELS, MOCK_REPLAY_SHIPS, MOCK_COLLISION } from '../../../data/backtrackMockData'
|
import { MOCK_CONDITIONS, MOCK_VESSELS, MOCK_REPLAY_SHIPS, MOCK_COLLISION } from '@common/mock/backtrackMockData'
|
||||||
|
|
||||||
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
|
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user