diff --git a/CLAUDE.md b/CLAUDE.md index 8695d31..42410c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,8 +7,9 @@ - **프로젝트 타입**: react-ts (모노레포) - **Frontend**: React 19 + Vite 7 + TypeScript 5.9 + Tailwind CSS 3 - **Backend**: Express 4 + PostgreSQL (pg) + TypeScript +- **DB**: PostgreSQL 16 + PostGIS (wing 운영DB + wing_auth 인증DB) - **상태관리**: Zustand (클라이언트), TanStack Query (서버) -- **지도**: Leaflet +- **지도**: Leaflet + react-leaflet - **실시간**: Socket.IO ## 빌드/실행 @@ -49,16 +50,27 @@ wing/ ├── frontend/ React 19 + Vite + TypeScript + Tailwind │ └── src/ │ ├── App.tsx 메인 (탭 라우팅, 감사 로그 자동 기록) -│ ├── components/ UI 컴포넌트 -│ │ ├── auth/ 로그인 페이지 -│ │ ├── views/ 탭별 페이지 뷰 (11개) -│ │ ├── layout/ MainLayout, TopBar, LeftPanel, RightPanel -│ │ └── ... analysis, board, incidents, map, weather 등 -│ ├── hooks/ 커스텀 훅 -│ ├── services/ API 서비스 (api, authApi, weatherApi 등) -│ ├── store/ Zustand (authStore, menuStore) -│ ├── types/ 타입 정의 -│ └── utils/ 유틸리티 +│ ├── common/ 공통 모듈 (@common/ alias) +│ │ ├── components/ auth/, layer/, layout/, map/, ui/ +│ │ ├── hooks/ useLayers, useSubMenu +│ │ ├── services/ api.ts, authApi.ts, layerService.ts +│ │ ├── store/ authStore, menuStore (Zustand) +│ │ ├── types/ backtrack, boomLine, hns, navigation +│ │ ├── utils/ coordinates, geo, sanitize +│ │ ├── data/ layerData.ts (UI 레이어 트리) +│ │ └── mock/ vesselMockData, backtrackMockData +│ └── tabs/ 탭 단위 패키지 (@tabs/ alias) +│ ├── prediction/ 확산 예측 (OilSpillView, LeftPanel 등) +│ ├── hns/ HNS 분석 (HNSView, HNSSubstanceView 등) +│ ├── rescue/ 구조 시나리오 +│ ├── aerial/ 항공 방제 +│ ├── weather/ 해양 기상 (오버레이, hooks, services) +│ ├── incidents/ 사건/사고 관리 +│ ├── board/ 게시판 +│ ├── reports/ 보고서 +│ ├── assets/ 자산 관리 +│ ├── scat/ Pre-SCAT 조사 +│ └── admin/ 관리자 (사용자/권한/메뉴/설정) ├── backend/ Express + TypeScript │ └── src/ │ ├── server.ts 진입점 + 라우터 등록 @@ -68,15 +80,23 @@ wing/ │ ├── settings/ 시스템 설정 │ ├── menus/ 메뉴 설정 │ ├── audit/ 감사 로그 +│ ├── hns/ HNS 물질 검색 API │ ├── routes/ 레이어, 시뮬레이션 │ ├── middleware/ 보안 (입력 살균, rate-limit) -│ └── db/ DB 연결 (PostgreSQL: wing, wing_auth) -├── database/ SQL 초기화 스크립트 -├── docs/ 개발 문서 (README, 가이드, 변경이력) +│ └── db/ DB 연결 (wingDb, authDb), seed +├── database/ SQL 스크립트 +│ ├── init.sql wing DB 초기 스키마 +│ ├── auth_init.sql wing_auth DB 초기 스키마 +│ └── migration/ 마이그레이션 (001_layer, 002_hns_substance) +├── docs/ 개발 문서 ├── .claude/ 팀 워크플로우 (rules, skills, scripts) └── .githooks/ Git hooks (pre-commit, commit-msg) ``` +### Path Alias +- `@common/*` → `src/common/*` (공통 모듈) +- `@tabs/*` → `src/tabs/*` (탭 패키지) + ## 팀 컨벤션 `.claude/rules/` 디렉토리 참조: - `team-policy.md` — 보안/품질 정책 diff --git a/README.md b/README.md index 4fac801..aab89dc 100644 --- a/README.md +++ b/README.md @@ -90,17 +90,26 @@ cd frontend && npm install && npm run dev # localhost:5173 wing/ ├── frontend/ React 19 + Vite + TypeScript + Tailwind │ └── src/ -│ ├── App.tsx 메인 (탭 라우팅, 감사 로그 자동 기록) -│ ├── components/ UI 컴포넌트 -│ │ ├── auth/ 로그인 페이지 -│ │ ├── views/ 각 탭별 페이지 뷰 (11개) -│ │ ├── layout/ MainLayout, TopBar, LeftPanel, RightPanel -│ │ └── ... analysis, board, incidents, map, weather 등 -│ ├── hooks/ 커스텀 훅 -│ ├── services/ API 서비스 (api, authApi, weatherApi 등) -│ ├── store/ Zustand 상태 (authStore, menuStore) -│ ├── types/ 타입 정의 -│ └── utils/ 유틸리티 +│ ├── App.tsx 메인 (탭 라우팅, 감사 로그) +│ ├── common/ 공통 모듈 (@common/ alias) +│ │ ├── components/ auth/, layer/, layout/, map/, ui/ +│ │ ├── hooks/ useLayers, useSubMenu +│ │ ├── services/ api.ts, authApi.ts, layerService.ts +│ │ ├── store/ authStore, menuStore (Zustand) +│ │ ├── types/ backtrack, boomLine, hns, navigation +│ │ └── utils/ coordinates, geo, sanitize +│ └── tabs/ 탭 단위 패키지 (@tabs/ alias) +│ ├── prediction/ 확산 예측 +│ ├── hns/ HNS 분석 +│ ├── rescue/ 구조 시나리오 +│ ├── aerial/ 항공 방제 +│ ├── weather/ 해양 기상 +│ ├── incidents/ 사건/사고 +│ ├── board/ 게시판 +│ ├── reports/ 보고서 +│ ├── assets/ 자산 관리 +│ ├── scat/ Pre-SCAT +│ └── admin/ 관리자 ├── backend/ Express + TypeScript │ └── src/ │ ├── server.ts 진입점 + 라우터 등록 @@ -110,10 +119,11 @@ wing/ │ ├── settings/ 시스템 설정 │ ├── menus/ 메뉴 설정 │ ├── audit/ 감사 로그 +│ ├── hns/ HNS 물질 검색 API │ ├── routes/ 레이어, 시뮬레이션 │ ├── middleware/ 보안 (입력 살균, rate-limit) -│ └── db/ DB 연결 (PostgreSQL, SQLite) -├── database/ SQL 초기화 스크립트 +│ └── db/ DB 연결 (wingDb, authDb), seed +├── database/ SQL 스크립트 + 마이그레이션 ├── docs/ 개발 문서 ├── .claude/ 팀 워크플로우 (rules, skills, scripts) └── .githooks/ Git hooks (pre-commit, commit-msg) @@ -128,10 +138,10 @@ wing/ | Frontend | React 19, Vite 7, TypeScript 5.9, Tailwind CSS 3 | | Backend | Express 4, TypeScript, PostgreSQL (pg) | | 상태 관리 | Zustand (클라이언트), TanStack Query (서버) | -| 지도 | Leaflet, OpenLayers | +| 지도 | Leaflet + react-leaflet | | 실시간 | Socket.IO | | 인증 | JWT (HttpOnly Cookie), Google OAuth | -| DB | PostgreSQL 16 + PostGIS (운영 DB 직접 연결), SQLite | +| DB | PostgreSQL 16 + PostGIS (wing 운영DB + wing_auth 인증DB) | | CI/CD | Gitea Actions | --- diff --git a/backend/src/db/seedHns.ts b/backend/src/db/seedHns.ts new file mode 100644 index 0000000..a89aee7 --- /dev/null +++ b/backend/src/db/seedHns.ts @@ -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) +}) diff --git a/backend/src/hns/hnsRouter.ts b/backend/src/hns/hnsRouter.ts new file mode 100644 index 0000000..68aff33 --- /dev/null +++ b/backend/src/hns/hnsRouter.ts @@ -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 diff --git a/backend/src/hns/hnsService.ts b/backend/src/hns/hnsService.ts new file mode 100644 index 0000000..5e5fe11 --- /dev/null +++ b/backend/src/hns/hnsService.ts @@ -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, + } +} diff --git a/backend/src/server.ts b/backend/src/server.ts index ac252f9..21bb1ba 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -14,6 +14,7 @@ import settingsRouter from './settings/settingsRouter.js' import menuRouter from './menus/menuRouter.js' import auditRouter from './audit/auditRouter.js' import boardRouter from './board/boardRouter.js' +import hnsRouter from './hns/hnsRouter.js' import { sanitizeBody, sanitizeQuery, @@ -139,6 +140,7 @@ app.use('/api/audit', auditRouter) app.use('/api/board', boardRouter) app.use('/api/layers', layersRouter) app.use('/api/simulation', simulationLimiter, simulationRouter) +app.use('/api/hns', hnsRouter) // 헬스 체크 app.get('/health', (_req, res) => { diff --git a/backend/tsconfig.json b/backend/tsconfig.json index e8c99b6..c11d906 100755 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -13,5 +13,5 @@ "resolveJsonModule": true }, "include": ["src/**/*"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "src/db/seedHns.ts"] } diff --git a/database/migration/002_hns_substance.sql b/database/migration/002_hns_substance.sql new file mode 100644 index 0000000..d60b5cc --- /dev/null +++ b/database/migration/002_hns_substance.sql @@ -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); diff --git a/docs/MENU-TAB-GUIDE.md b/docs/MENU-TAB-GUIDE.md index a9d68eb..e9bfb70 100644 --- a/docs/MENU-TAB-GUIDE.md +++ b/docs/MENU-TAB-GUIDE.md @@ -22,18 +22,19 @@ Frontend: menuStore.ts → TopBar.tsx (탭 렌더링) | 순서 | 파일 | 작업 | 필수 | |------|------|------|------| -| 1 | `frontend/src/components/views/XxxView.tsx` | 뷰 컴포넌트 생성 | O | -| 2 | `frontend/src/App.tsx` | MainTab 타입 + import + renderView | O | -| 3 | `backend/src/settings/settingsService.ts` | DEFAULT_MENU_CONFIG에 항목 추가 | O | -| 4 | `database/auth_init.sql` | menu.config 초기 JSON에 추가 | O | -| 5 | 관리자 UI | 메뉴 관리에서 활성화 | O | +| 1 | `frontend/src/tabs/{탭명}/components/XxxView.tsx` | 뷰 컴포넌트 생성 | O | +| 2 | `frontend/src/tabs/{탭명}/index.ts` | re-export 생성 | O | +| 3 | `frontend/src/App.tsx` | MainTab 타입 + import + renderView | O | +| 4 | `backend/src/settings/settingsService.ts` | DEFAULT_MENU_CONFIG에 항목 추가 | O | +| 5 | `database/auth_init.sql` | menu.config 초기 JSON에 추가 | O | +| 6 | 관리자 UI | 메뉴 관리에서 활성화 | O | ## Step 1: 뷰 컴포넌트 생성 -`frontend/src/components/views/` 에 새 뷰 컴포넌트를 생성합니다. +`frontend/src/tabs/{탭명}/components/` 에 새 뷰 컴포넌트를 생성합니다. ```tsx -// frontend/src/components/views/MonitoringView.tsx +// frontend/src/tabs/monitoring/components/MonitoringView.tsx export function MonitoringView() { return ( @@ -47,7 +48,14 @@ export function MonitoringView() { } ``` -기존 뷰 컴포넌트(`OilSpillView`, `WeatherView` 등)의 레이아웃 패턴을 참고하세요. +`index.ts`에서 re-export합니다: +```tsx +// frontend/src/tabs/monitoring/index.ts +export { MonitoringView } from './components/MonitoringView' +``` + +기존 탭(`@tabs/prediction`, `@tabs/weather` 등)의 레이아웃 패턴을 참고하세요. +공통 모듈은 `@common/` alias로 import합니다. ## Step 2: App.tsx 탭 등록 @@ -68,7 +76,7 @@ export type MainTab = 'prediction' | 'hns' | ... | 'monitoring' | 'admin' ### 2-2. 뷰 컴포넌트 import ```tsx -import { MonitoringView } from './components/views/MonitoringView' +import { MonitoringView } from '@tabs/monitoring' ``` ### 2-3. renderView switch에 case 추가 diff --git a/docs/README.md b/docs/README.md index 1606965..53f349e 100755 --- a/docs/README.md +++ b/docs/README.md @@ -36,10 +36,10 @@ claude | Frontend | React 19, Vite 7, TypeScript 5.9, Tailwind CSS 3 | | Backend | Express 4, TypeScript, PostgreSQL (pg) | | 상태 관리 | Zustand (클라이언트), TanStack Query (서버) | -| 지도 | Leaflet, OpenLayers | +| 지도 | Leaflet + react-leaflet | | 실시간 | Socket.IO | | 인증 | JWT (HttpOnly Cookie), Google OAuth | -| DB | PostgreSQL 16 + PostGIS (wing + wing_auth) | +| DB | PostgreSQL 16 + PostGIS (wing 운영DB + wing_auth 인증DB) | | CI/CD | Gitea Actions | --- @@ -50,18 +50,21 @@ claude wing/ ├── frontend/ React 19 + Vite + TypeScript + Tailwind │ └── src/ -│ ├── App.tsx 메인 (탭 라우팅, 감사 로그 자동 기록) -│ ├── components/ UI 컴포넌트 -│ │ ├── auth/ 로그인 페이지 -│ │ ├── views/ 각 탭별 페이지 뷰 (11개) -│ │ ├── layout/ MainLayout, TopBar, LeftPanel, RightPanel -│ │ ├── map/ 지도 관련 -│ │ └── ... analysis, board, incidents, weather 등 -│ ├── hooks/ 커스텀 훅 -│ ├── services/ API 서비스 (api, authApi, weatherApi 등) -│ ├── store/ Zustand 상태 (authStore, menuStore) -│ ├── types/ 타입 정의 -│ └── utils/ 유틸리티 +│ ├── App.tsx 메인 (탭 라우팅, 감사 로그) +│ ├── common/ 공통 모듈 (@common/ alias) +│ │ ├── components/ auth/, layer/, layout/, map/, ui/ +│ │ ├── hooks/ useLayers, useSubMenu +│ │ ├── services/ api.ts, authApi.ts, layerService.ts +│ │ ├── store/ authStore, menuStore (Zustand) +│ │ ├── types/ backtrack, boomLine, hns, navigation +│ │ └── utils/ coordinates, geo, sanitize +│ └── tabs/ 탭 단위 패키지 (@tabs/ alias) +│ ├── prediction/ 확산 예측 (OilSpillView, LeftPanel 등) +│ ├── hns/ HNS 분석 (HNSView, HNSSubstanceView 등) +│ ├── rescue/ 구조 시나리오 +│ ├── aerial/ 항공 방제 +│ ├── weather/ 해양 기상 +│ └── ... incidents, board, reports, assets, scat, admin ├── backend/ Express + TypeScript │ └── src/ │ ├── server.ts 진입점 + 라우터 등록 @@ -71,10 +74,11 @@ wing/ │ ├── settings/ 시스템 설정 │ ├── menus/ 메뉴 설정 │ ├── audit/ 감사 로그 +│ ├── hns/ HNS 물질 검색 API │ ├── routes/ 레이어, 시뮬레이션 │ ├── middleware/ 보안 (입력 살균, rate-limit) -│ └── db/ DB 연결 (PostgreSQL: wing, wing_auth) -├── database/ SQL 초기화 스크립트 +│ └── db/ DB 연결 (wingDb, authDb), seed +├── database/ SQL 스크립트 + 마이그레이션 ├── docs/ 개발 문서 ├── .claude/ 팀 워크플로우 (rules, skills, scripts) └── .githooks/ Git hooks (pre-commit, commit-msg) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 920ca3e..7059d68 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,6 @@ import { MainLayout } from '@common/components/layout/MainLayout' import { LoginPage } from '@common/components/auth/LoginPage' import { registerMainTabSwitcher } from '@common/hooks/useSubMenu' import { useAuthStore } from '@common/store/authStore' -import { API_BASE_URL } from '@common/services/api' import { useMenuStore } from '@common/store/menuStore' import { OilSpillView } from '@tabs/prediction' import { ReportsView } from '@tabs/reports' @@ -47,7 +46,8 @@ function App() { [JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })], { 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]) // 세션 확인 중 스플래시 diff --git a/frontend/src/common/components/layer/LayerTree.tsx b/frontend/src/common/components/layer/LayerTree.tsx index d02db78..b4f2195 100755 --- a/frontend/src/common/components/layer/LayerTree.tsx +++ b/frontend/src/common/components/layer/LayerTree.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react' -import type { Layer } from '../../../data/layerDatabase' +import type { Layer } from '@common/services/layerService' const PRESET_COLORS = [ '#ef4444','#f97316','#eab308','#22c55e','#06b6d4', diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 47c075a..fce41a6 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -2,7 +2,7 @@ import { useState, useMemo, useEffect } from 'react' import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents, CircleMarker, Circle, Polyline } from 'react-leaflet' import 'leaflet/dist/leaflet.css' import L from 'leaflet' -import { layerDatabase } from '../../../data/layerDatabase' +import { layerDatabase } from '@common/services/layerService' import { decimalToDMS } from '@common/utils/coordinates' import type { PredictionModel } from '@tabs/prediction/components/OilSpillView' import type { BoomLine, BoomLineCoord } from '@common/types/boomLine' diff --git a/frontend/src/data/layerData.ts b/frontend/src/common/data/layerData.ts similarity index 100% rename from frontend/src/data/layerData.ts rename to frontend/src/common/data/layerData.ts diff --git a/frontend/src/common/hooks/useLayers.ts b/frontend/src/common/hooks/useLayers.ts index ae7fdaf..62654a2 100755 --- a/frontend/src/common/hooks/useLayers.ts +++ b/frontend/src/common/hooks/useLayers.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query' import { fetchAllLayers, fetchLayerTree, fetchWMSLayers } from '../services/api' -import type { Layer } from '../../data/layerDatabase' +import type { Layer } from '@common/services/layerService' // 모든 레이어 조회 훅 export function useLayers() { diff --git a/frontend/src/data/backtrackMockData.ts b/frontend/src/common/mock/backtrackMockData.ts similarity index 100% rename from frontend/src/data/backtrackMockData.ts rename to frontend/src/common/mock/backtrackMockData.ts diff --git a/frontend/src/data/vesselMockData.ts b/frontend/src/common/mock/vesselMockData.ts similarity index 100% rename from frontend/src/data/vesselMockData.ts rename to frontend/src/common/mock/vesselMockData.ts diff --git a/frontend/src/data/layerDatabase.ts b/frontend/src/common/services/layerService.ts similarity index 100% rename from frontend/src/data/layerDatabase.ts rename to frontend/src/common/services/layerService.ts diff --git a/frontend/src/common/types/hns.ts b/frontend/src/common/types/hns.ts new file mode 100644 index 0000000..70348e0 --- /dev/null +++ b/frontend/src/common/types/hns.ts @@ -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 }> +} diff --git a/frontend/src/tabs/hns/components/HNSSubstanceView.tsx b/frontend/src/tabs/hns/components/HNSSubstanceView.tsx index e314597..0d6d5d0 100755 --- a/frontend/src/tabs/hns/components/HNSSubstanceView.tsx +++ b/frontend/src/tabs/hns/components/HNSSubstanceView.tsx @@ -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 { HNS_SEARCH_DB, type HNSSearchSubstance } from '../../../data/hnsSubstanceSearchData' +import { api } from '@common/services/api' +import type { HNSSearchSubstance } from '@common/types/hns' /* ═══ HNS 물질 데이터베이스 ═══ */ interface HNSSubstance { @@ -65,8 +66,60 @@ export function HNSSubstanceView() { const [hmsSelectedId, setHmsSelectedId] = useState(null) const [hmsDetailTab, setHmsDetailTab] = useState(0) const [hmsPage, setHmsPage] = useState(1) + const [hmsResults, setHmsResults] = useState([]) + const [hmsTotal, setHmsTotal] = useState(0) + const [hmsLoading, setHmsLoading] = useState(false) + const [hmsSelectedSubstance, setHmsSelectedSubstance] = useState(null) const contentRef = useRef(null) + // 검색 타입 매핑 (프론트엔드 → API) + const searchTypeMap: Record = { + abbr: 'abbreviation', korName: 'nameKr', engName: 'nameEn', cas: 'casNumber', un: 'unNumber', + } + + // HNS 물질 검색 API 호출 + const fetchHnsSubstances = useCallback(async () => { + setHmsLoading(true) + try { + const params: Record = { 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 = () => { if (!contentRef.current) return const clone = contentRef.current.cloneNode(true) as HTMLElement @@ -113,28 +166,10 @@ ${styles} return matchName && matchCas && matchSebc }) - /* Panel 3: HNS 통합 검색 필터 */ - 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]) - + /* Panel 3: HNS API 기반 검색 결과 */ const HMS_PER_PAGE = 10 - const hmsTotalPages = Math.max(1, Math.ceil(hmsFiltered.length / HMS_PER_PAGE)) - const hmsPageData = hmsFiltered.slice((hmsPage - 1) * HMS_PER_PAGE, hmsPage * HMS_PER_PAGE) - const hmsSelectedSubstance = hmsSelectedId !== null ? HNS_SEARCH_DB.find(s => s.id === hmsSelectedId) ?? null : null + const hmsTotalPages = Math.max(1, Math.ceil(hmsTotal / HMS_PER_PAGE)) + const hmsPageData = hmsResults const tabLabels = [ { icon: '📊', label: 'SEBC 거동분류' }, @@ -563,7 +598,7 @@ ${styles} {/* ── 검색 결과 테이블 ── */}
-
📋 검색 결과 — {hmsFiltered.length}건 조회
+
📋 검색 결과 — {hmsTotal}건 조회
@@ -583,7 +618,9 @@ ${styles} - {hmsPageData.length > 0 ? hmsPageData.map((s, idx) => { + {hmsLoading ? ( + 검색 중... + ) : hmsPageData.length > 0 ? hmsPageData.map((s: HNSSearchSubstance, idx: number) => { const isSel = hmsSelectedId === s.id return ( { setHmsSelectedId(isSel ? null : s.id); setHmsDetailTab(0) }} @@ -608,7 +645,7 @@ ${styles}
- 1,316종 등록 · Port-MIS 화물적부도 연동 · 해경청 물질정보집 · IBC CODE 692종 + {hmsTotal.toLocaleString()}종 등록 · Port-MIS 화물적부도 연동 · 해경청 물질정보집 · IBC CODE 692종
{Array.from({ length: Math.min(hmsTotalPages, 5) }, (_, i) => i + 1).map(p => ( diff --git a/frontend/src/tabs/incidents/components/IncidentsView.tsx b/frontend/src/tabs/incidents/components/IncidentsView.tsx index 9e3ab9f..0c4a17d 100755 --- a/frontend/src/tabs/incidents/components/IncidentsView.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsView.tsx @@ -5,7 +5,7 @@ import type { LatLngExpression } from 'leaflet' import 'leaflet/dist/leaflet.css' import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel' 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건) const mockIncidents: Incident[] = [ diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index b9f74ab..b46fe63 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -12,7 +12,7 @@ import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/u import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine' import type { BacktrackPhase, BacktrackVessel } 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' // eslint-disable-next-line react-refresh/only-export-components