From a61864646fa293667291599c7a2fcf62515920e4 Mon Sep 17 00:00:00 2001 From: htlee Date: Sat, 28 Feb 2026 14:00:50 +0900 Subject: [PATCH 1/8] =?UTF-8?q?refactor(frontend):=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20common/=20=EB=B6=84=EB=A6=AC=20+=20OpenLay?= =?UTF-8?q?ers=20=EC=A0=9C=EA=B1=B0=20+=20path=20alias=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OpenLayers(ol) 패키지 제거 (미사용, import 0건) - common/ 디렉토리 생성: components, hooks, services, store, types, utils - 17개 공통 파일을 common/으로 이동 (git mv, blame 이력 보존) - MainTab 타입을 App.tsx에서 common/types/navigation.ts로 분리 - tsconfig path alias (@common/*, @tabs/*) + vite resolve.alias 설정 - 42개 import 경로를 @common/ alias 또는 상대경로로 수정 Co-Authored-By: Claude Opus 4.6 --- frontend/package-lock.json | 205 ------------------ frontend/package.json | 1 - frontend/src/App.tsx | 13 +- .../components/auth/LoginPage.tsx | 0 .../components/layer/LayerTree.tsx | 2 +- .../components/layout/MainLayout.tsx | 2 +- .../components/layout/SubMenuBar.tsx | 2 +- .../{ => common}/components/layout/TopBar.tsx | 2 +- .../{ => common}/components/ui/ComboBox.tsx | 0 frontend/src/{ => common}/hooks/useLayers.ts | 2 +- frontend/src/{ => common}/hooks/useSubMenu.ts | 2 +- frontend/src/{ => common}/services/api.ts | 0 frontend/src/{ => common}/services/authApi.ts | 0 frontend/src/{ => common}/store/authStore.ts | 0 frontend/src/{ => common}/store/menuStore.ts | 0 frontend/src/{ => common}/types/backtrack.ts | 0 frontend/src/{ => common}/types/boomLine.ts | 0 frontend/src/common/types/navigation.ts | 1 + .../src/{ => common}/utils/coordinates.ts | 0 frontend/src/{ => common}/utils/geo.ts | 0 frontend/src/{ => common}/utils/sanitize.ts | 0 .../components/analysis/AerialTheoryView.tsx | 2 +- .../components/analysis/BacktrackModal.tsx | 2 +- .../components/analysis/HNSSubstanceView.tsx | 2 +- .../src/components/analysis/HNSTheoryView.tsx | 2 +- .../analysis/OilSpillTheoryView.tsx | 2 +- .../components/analysis/RescueTheoryView.tsx | 2 +- .../src/components/board/BoardWriteForm.tsx | 2 +- .../src/components/layout/HNSLeftPanel.tsx | 2 +- frontend/src/components/layout/LeftPanel.tsx | 12 +- .../src/components/map/BacktrackReplayBar.tsx | 2 +- .../components/map/BacktrackReplayOverlay.tsx | 2 +- frontend/src/components/map/MapView.tsx | 6 +- frontend/src/components/views/AdminView.tsx | 6 +- frontend/src/components/views/AerialView.tsx | 2 +- frontend/src/components/views/BoardView.tsx | 2 +- frontend/src/components/views/HNSView.tsx | 2 +- .../src/components/views/OilSpillView.tsx | 8 +- frontend/src/components/views/ReportsView.tsx | 4 +- frontend/src/components/views/RescueView.tsx | 2 +- frontend/src/data/backtrackMockData.ts | 2 +- frontend/src/data/layerDatabase.ts | 2 +- frontend/tsconfig.app.json | 9 +- frontend/vite.config.ts | 7 + 44 files changed, 62 insertions(+), 254 deletions(-) rename frontend/src/{ => common}/components/auth/LoginPage.tsx (100%) rename frontend/src/{ => common}/components/layer/LayerTree.tsx (99%) rename frontend/src/{ => common}/components/layout/MainLayout.tsx (93%) rename frontend/src/{ => common}/components/layout/SubMenuBar.tsx (95%) rename frontend/src/{ => common}/components/layout/TopBar.tsx (99%) rename frontend/src/{ => common}/components/ui/ComboBox.tsx (100%) rename frontend/src/{ => common}/hooks/useLayers.ts (93%) rename frontend/src/{ => common}/hooks/useSubMenu.ts (98%) rename frontend/src/{ => common}/services/api.ts (100%) rename frontend/src/{ => common}/services/authApi.ts (100%) rename frontend/src/{ => common}/store/authStore.ts (100%) rename frontend/src/{ => common}/store/menuStore.ts (100%) rename frontend/src/{ => common}/types/backtrack.ts (100%) rename frontend/src/{ => common}/types/boomLine.ts (100%) create mode 100644 frontend/src/common/types/navigation.ts rename frontend/src/{ => common}/utils/coordinates.ts (100%) rename frontend/src/{ => common}/utils/geo.ts (100%) rename frontend/src/{ => common}/utils/sanitize.ts (100%) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e5e0d34..bc22c88 100755 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,7 +19,6 @@ "emoji-mart": "^5.6.0", "leaflet": "^1.9.4", "lucide-react": "^0.564.0", - "ol": "^10.8.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-leaflet": "^5.0.0", @@ -1148,12 +1147,6 @@ "node": ">= 8" } }, - "node_modules/@petamoriken/float16": { - "version": "3.9.3", - "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", - "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", - "license": "MIT" - }, "node_modules/@react-leaflet/core": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", @@ -1650,12 +1643,6 @@ "undici-types": "~7.16.0" } }, - "node_modules/@types/rbush": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz", - "integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==", - "license": "MIT" - }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -1966,16 +1953,6 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/@zarrita/storage": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.4.tgz", - "integrity": "sha512-qURfJAQcQGRfDQ4J9HaCjGaj3jlJKc66bnRk6G/IeLUsM7WKyG7Bzsuf1EZurSXyc0I4LVcu6HaeQQ4d3kZ16g==", - "license": "MIT", - "dependencies": { - "reference-spec-reader": "^0.2.0", - "unzipit": "1.4.3" - } - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2483,12 +2460,6 @@ "node": ">= 0.4" } }, - "node_modules/earcut": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", - "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", - "license": "ISC" - }, "node_modules/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -2897,12 +2868,6 @@ } } }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "license": "MIT" - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3051,25 +3016,6 @@ "node": ">=6.9.0" } }, - "node_modules/geotiff": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.3.tgz", - "integrity": "sha512-yRoDQDYxWYiB421p0cbxJvdy79OlQW+rxDI9GDbIUeWCAh6YAZ0vlTKF448EAiEuuUpBsNaegd2flavF0p+kvw==", - "license": "MIT", - "dependencies": { - "@petamoriken/float16": "^3.4.7", - "lerc": "^3.0.0", - "pako": "^2.0.4", - "parse-headers": "^2.0.2", - "quick-lru": "^6.1.1", - "web-worker": "^1.5.0", - "xml-utils": "^1.10.2", - "zstddec": "^0.2.0" - }, - "engines": { - "node": ">=10.19" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3410,12 +3356,6 @@ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "license": "BSD-2-Clause" }, - "node_modules/lerc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", - "integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==", - "license": "Apache-2.0" - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3633,15 +3573,6 @@ "node": ">=0.10.0" } }, - "node_modules/numcodecs": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz", - "integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==", - "license": "MIT", - "dependencies": { - "fflate": "^0.8.0" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3662,24 +3593,6 @@ "node": ">= 6" } }, - "node_modules/ol": { - "version": "10.8.0", - "resolved": "https://registry.npmjs.org/ol/-/ol-10.8.0.tgz", - "integrity": "sha512-kLk7jIlJvKyhVMAjORTXKjzlM6YIByZ1H/d0DBx3oq8nSPCG6/gbLr5RxukzPgwbhnAqh+xHNCmrvmFKhVMvoQ==", - "license": "BSD-2-Clause", - "dependencies": { - "@types/rbush": "4.0.0", - "earcut": "^3.0.0", - "geotiff": "^3.0.2", - "pbf": "4.0.1", - "rbush": "^4.0.0", - "zarrita": "^0.6.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/openlayers" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3730,12 +3643,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pako": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", - "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", - "license": "(MIT AND Zlib)" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3749,12 +3656,6 @@ "node": ">=6" } }, - "node_modules/parse-headers": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", - "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", - "license": "MIT" - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3782,18 +3683,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pbf": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", - "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", - "license": "BSD-3-Clause", - "dependencies": { - "resolve-protobuf-schema": "^2.1.0" - }, - "bin": { - "pbf": "bin/pbf" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4007,12 +3896,6 @@ "node": ">= 0.8.0" } }, - "node_modules/protocol-buffers-schema": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", - "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", - "license": "MIT" - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4050,33 +3933,6 @@ ], "license": "MIT" }, - "node_modules/quick-lru": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", - "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/quickselect": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", - "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", - "license": "ISC" - }, - "node_modules/rbush": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", - "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", - "license": "MIT", - "dependencies": { - "quickselect": "^3.0.0" - } - }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -4158,12 +4014,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/reference-spec-reader": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz", - "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==", - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -4195,15 +4045,6 @@ "node": ">=4" } }, - "node_modules/resolve-protobuf-schema": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", - "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", - "license": "MIT", - "dependencies": { - "protocol-buffers-schema": "^3.3.1" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -4598,18 +4439,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unzipit": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-1.4.3.tgz", - "integrity": "sha512-gsq2PdJIWWGhx5kcdWStvNWit9FVdTewm4SEG7gFskWs+XCVaULt9+BwuoBtJiRE8eo3L1IPAOrbByNLtLtIlg==", - "license": "MIT", - "dependencies": { - "uzip-module": "^1.0.2" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4658,12 +4487,6 @@ "dev": true, "license": "MIT" }, - "node_modules/uzip-module": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/uzip-module/-/uzip-module-1.0.3.tgz", - "integrity": "sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA==", - "license": "MIT" - }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -4739,12 +4562,6 @@ } } }, - "node_modules/web-worker": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", - "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", - "license": "Apache-2.0" - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4792,12 +4609,6 @@ } } }, - "node_modules/xml-utils": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", - "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==", - "license": "CC0-1.0" - }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", @@ -4826,16 +4637,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zarrita": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.6.1.tgz", - "integrity": "sha512-YOMTW8FT55Rz+vadTIZeOFZ/F2h4svKizyldvPtMYSxPgSNcRkOzkxCsWpIWlWzB1I/LmISmi0bEekOhLlI+Zw==", - "license": "MIT", - "dependencies": { - "@zarrita/storage": "^0.1.4", - "numcodecs": "^0.3.2" - } - }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", @@ -4859,12 +4660,6 @@ "zod": "^3.25.0 || ^4.0.0" } }, - "node_modules/zstddec": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz", - "integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==", - "license": "MIT AND BSD-3-Clause" - }, "node_modules/zustand": { "version": "5.0.11", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", diff --git a/frontend/package.json b/frontend/package.json index c659318..aa8625f 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,7 +21,6 @@ "emoji-mart": "^5.6.0", "leaflet": "^1.9.4", "lucide-react": "^0.564.0", - "ol": "^10.8.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-leaflet": "^5.0.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index de541a9..3805afc 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,11 @@ import { useState, useEffect } from 'react' import { GoogleOAuthProvider } from '@react-oauth/google' -import { MainLayout } from './components/layout/MainLayout' -import { LoginPage } from './components/auth/LoginPage' -import { registerMainTabSwitcher } from './hooks/useSubMenu' -import { useAuthStore } from './store/authStore' -import { useMenuStore } from './store/menuStore' +import type { MainTab } from '@common/types/navigation' +import { MainLayout } from '@common/components/layout/MainLayout' +import { LoginPage } from '@common/components/auth/LoginPage' +import { registerMainTabSwitcher } from '@common/hooks/useSubMenu' +import { useAuthStore } from '@common/store/authStore' +import { useMenuStore } from '@common/store/menuStore' import { OilSpillView } from './components/views/OilSpillView' import { ReportsView } from './components/views/ReportsView' import { HNSView } from './components/views/HNSView' @@ -17,8 +18,6 @@ import { AdminView } from './components/views/AdminView' import { PreScatView } from './components/views/PreScatView' import { RescueView } from './components/views/RescueView' -export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin' - const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || '' function App() { diff --git a/frontend/src/components/auth/LoginPage.tsx b/frontend/src/common/components/auth/LoginPage.tsx similarity index 100% rename from frontend/src/components/auth/LoginPage.tsx rename to frontend/src/common/components/auth/LoginPage.tsx diff --git a/frontend/src/components/layer/LayerTree.tsx b/frontend/src/common/components/layer/LayerTree.tsx similarity index 99% rename from frontend/src/components/layer/LayerTree.tsx rename to frontend/src/common/components/layer/LayerTree.tsx index df2a736..d02db78 100755 --- a/frontend/src/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 '../../../data/layerDatabase' const PRESET_COLORS = [ '#ef4444','#f97316','#eab308','#22c55e','#06b6d4', diff --git a/frontend/src/components/layout/MainLayout.tsx b/frontend/src/common/components/layout/MainLayout.tsx similarity index 93% rename from frontend/src/components/layout/MainLayout.tsx rename to frontend/src/common/components/layout/MainLayout.tsx index 68f7c50..adddd5f 100755 --- a/frontend/src/components/layout/MainLayout.tsx +++ b/frontend/src/common/components/layout/MainLayout.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import type { MainTab } from '../../App' +import type { MainTab } from '../../types/navigation' import { TopBar } from './TopBar' import { SubMenuBar } from './SubMenuBar' diff --git a/frontend/src/components/layout/SubMenuBar.tsx b/frontend/src/common/components/layout/SubMenuBar.tsx similarity index 95% rename from frontend/src/components/layout/SubMenuBar.tsx rename to frontend/src/common/components/layout/SubMenuBar.tsx index 9abd458..98a39da 100755 --- a/frontend/src/components/layout/SubMenuBar.tsx +++ b/frontend/src/common/components/layout/SubMenuBar.tsx @@ -1,4 +1,4 @@ -import type { MainTab } from '../../App' +import type { MainTab } from '../../types/navigation' import { useSubMenu } from '../../hooks/useSubMenu' interface SubMenuBarProps { diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/common/components/layout/TopBar.tsx similarity index 99% rename from frontend/src/components/layout/TopBar.tsx rename to frontend/src/common/components/layout/TopBar.tsx index c5f4a8d..3133575 100755 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/common/components/layout/TopBar.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect, useMemo } from 'react' -import type { MainTab } from '../../App' +import type { MainTab } from '../../types/navigation' import { useAuthStore } from '../../store/authStore' import { useMenuStore } from '../../store/menuStore' diff --git a/frontend/src/components/ui/ComboBox.tsx b/frontend/src/common/components/ui/ComboBox.tsx similarity index 100% rename from frontend/src/components/ui/ComboBox.tsx rename to frontend/src/common/components/ui/ComboBox.tsx diff --git a/frontend/src/hooks/useLayers.ts b/frontend/src/common/hooks/useLayers.ts similarity index 93% rename from frontend/src/hooks/useLayers.ts rename to frontend/src/common/hooks/useLayers.ts index 6c0ceb7..ae7fdaf 100755 --- a/frontend/src/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 '../../data/layerDatabase' // 모든 레이어 조회 훅 export function useLayers() { diff --git a/frontend/src/hooks/useSubMenu.ts b/frontend/src/common/hooks/useSubMenu.ts similarity index 98% rename from frontend/src/hooks/useSubMenu.ts rename to frontend/src/common/hooks/useSubMenu.ts index ce3b04a..9dac4b4 100755 --- a/frontend/src/hooks/useSubMenu.ts +++ b/frontend/src/common/hooks/useSubMenu.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import type { MainTab } from '../App' +import type { MainTab } from '../types/navigation' interface SubMenuItem { id: string diff --git a/frontend/src/services/api.ts b/frontend/src/common/services/api.ts similarity index 100% rename from frontend/src/services/api.ts rename to frontend/src/common/services/api.ts diff --git a/frontend/src/services/authApi.ts b/frontend/src/common/services/authApi.ts similarity index 100% rename from frontend/src/services/authApi.ts rename to frontend/src/common/services/authApi.ts diff --git a/frontend/src/store/authStore.ts b/frontend/src/common/store/authStore.ts similarity index 100% rename from frontend/src/store/authStore.ts rename to frontend/src/common/store/authStore.ts diff --git a/frontend/src/store/menuStore.ts b/frontend/src/common/store/menuStore.ts similarity index 100% rename from frontend/src/store/menuStore.ts rename to frontend/src/common/store/menuStore.ts diff --git a/frontend/src/types/backtrack.ts b/frontend/src/common/types/backtrack.ts similarity index 100% rename from frontend/src/types/backtrack.ts rename to frontend/src/common/types/backtrack.ts diff --git a/frontend/src/types/boomLine.ts b/frontend/src/common/types/boomLine.ts similarity index 100% rename from frontend/src/types/boomLine.ts rename to frontend/src/common/types/boomLine.ts diff --git a/frontend/src/common/types/navigation.ts b/frontend/src/common/types/navigation.ts new file mode 100644 index 0000000..a3c3c57 --- /dev/null +++ b/frontend/src/common/types/navigation.ts @@ -0,0 +1 @@ +export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin'; diff --git a/frontend/src/utils/coordinates.ts b/frontend/src/common/utils/coordinates.ts similarity index 100% rename from frontend/src/utils/coordinates.ts rename to frontend/src/common/utils/coordinates.ts diff --git a/frontend/src/utils/geo.ts b/frontend/src/common/utils/geo.ts similarity index 100% rename from frontend/src/utils/geo.ts rename to frontend/src/common/utils/geo.ts diff --git a/frontend/src/utils/sanitize.ts b/frontend/src/common/utils/sanitize.ts similarity index 100% rename from frontend/src/utils/sanitize.ts rename to frontend/src/common/utils/sanitize.ts diff --git a/frontend/src/components/analysis/AerialTheoryView.tsx b/frontend/src/components/analysis/AerialTheoryView.tsx index c61bdea..733724b 100755 --- a/frontend/src/components/analysis/AerialTheoryView.tsx +++ b/frontend/src/components/analysis/AerialTheoryView.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { sanitizeHtml } from '../../utils/sanitize' +import { sanitizeHtml } from '@common/utils/sanitize' const panels = [ { id: 0, icon: '🌐', label: '개요' }, diff --git a/frontend/src/components/analysis/BacktrackModal.tsx b/frontend/src/components/analysis/BacktrackModal.tsx index a646cf7..86bf5ab 100755 --- a/frontend/src/components/analysis/BacktrackModal.tsx +++ b/frontend/src/components/analysis/BacktrackModal.tsx @@ -1,5 +1,5 @@ import { useRef, useEffect } from 'react' -import type { BacktrackPhase, BacktrackVessel, BacktrackConditions } from '../../types/backtrack' +import type { BacktrackPhase, BacktrackVessel, BacktrackConditions } from '@common/types/backtrack' interface BacktrackModalProps { isOpen: boolean diff --git a/frontend/src/components/analysis/HNSSubstanceView.tsx b/frontend/src/components/analysis/HNSSubstanceView.tsx index 6f9f41f..1008239 100755 --- a/frontend/src/components/analysis/HNSSubstanceView.tsx +++ b/frontend/src/components/analysis/HNSSubstanceView.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useMemo } from 'react' -import { sanitizeHtml } from '../../utils/sanitize' +import { sanitizeHtml } from '@common/utils/sanitize' import { HNS_SEARCH_DB, type HNSSearchSubstance } from '../../data/hnsSubstanceSearchData' /* ═══ HNS 물질 데이터베이스 ═══ */ diff --git a/frontend/src/components/analysis/HNSTheoryView.tsx b/frontend/src/components/analysis/HNSTheoryView.tsx index 013ce02..39c33bf 100755 --- a/frontend/src/components/analysis/HNSTheoryView.tsx +++ b/frontend/src/components/analysis/HNSTheoryView.tsx @@ -1,5 +1,5 @@ import { useState, useRef } from 'react' -import { sanitizeHtml } from '../../utils/sanitize' +import { sanitizeHtml } from '@common/utils/sanitize' const theoryTabs = [ { icon: '🔬', name: '시스템 개요' }, diff --git a/frontend/src/components/analysis/OilSpillTheoryView.tsx b/frontend/src/components/analysis/OilSpillTheoryView.tsx index 12ce2e0..76a1fa4 100755 --- a/frontend/src/components/analysis/OilSpillTheoryView.tsx +++ b/frontend/src/components/analysis/OilSpillTheoryView.tsx @@ -1,5 +1,5 @@ import { useState, useRef } from 'react' -import { sanitizeHtml } from '../../utils/sanitize' +import { sanitizeHtml } from '@common/utils/sanitize' const theoryTabs: { id: number; icon: string; name: string; nameColor?: string }[] = [ { id: 0, icon: '🌊', name: '시스템 개요' }, diff --git a/frontend/src/components/analysis/RescueTheoryView.tsx b/frontend/src/components/analysis/RescueTheoryView.tsx index fe1a3ed..722dbfd 100755 --- a/frontend/src/components/analysis/RescueTheoryView.tsx +++ b/frontend/src/components/analysis/RescueTheoryView.tsx @@ -1,4 +1,4 @@ -import { sanitizeHtml } from '../../utils/sanitize' +import { sanitizeHtml } from '@common/utils/sanitize' export function RescueTheoryView() { const contentHtml = ` diff --git a/frontend/src/components/board/BoardWriteForm.tsx b/frontend/src/components/board/BoardWriteForm.tsx index 8d077db..e5c0103 100755 --- a/frontend/src/components/board/BoardWriteForm.tsx +++ b/frontend/src/components/board/BoardWriteForm.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { sanitizeInput } from '../../utils/sanitize' +import { sanitizeInput } from '@common/utils/sanitize' interface BoardPost { id?: number diff --git a/frontend/src/components/layout/HNSLeftPanel.tsx b/frontend/src/components/layout/HNSLeftPanel.tsx index 6da3479..af529f1 100755 --- a/frontend/src/components/layout/HNSLeftPanel.tsx +++ b/frontend/src/components/layout/HNSLeftPanel.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { ComboBox } from '../ui/ComboBox' +import { ComboBox } from '@common/components/ui/ComboBox' interface HNSLeftPanelProps { activeSubTab: 'analysis' | 'list' diff --git a/frontend/src/components/layout/LeftPanel.tsx b/frontend/src/components/layout/LeftPanel.tsx index e9eb1c1..0122881 100755 --- a/frontend/src/components/layout/LeftPanel.tsx +++ b/frontend/src/components/layout/LeftPanel.tsx @@ -1,15 +1,15 @@ import { useState, useMemo } from 'react' -import { LayerTree } from '../layer/LayerTree' -import { useLayerTree } from '../../hooks/useLayers' +import { LayerTree } from '@common/components/layer/LayerTree' +import { useLayerTree } from '@common/hooks/useLayers' import { layerData } from '../../data/layerData' import type { LayerNode } from '../../data/layerData' import type { Layer } from '../../data/layerDatabase' -import { decimalToDMS } from '../../utils/coordinates' -import { ComboBox } from '../ui/ComboBox' +import { decimalToDMS } from '@common/utils/coordinates' +import { ComboBox } from '@common/components/ui/ComboBox' import { ALL_MODELS } from '../views/OilSpillView' import type { PredictionModel } from '../views/OilSpillView' -import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '../../types/boomLine' -import { generateAIBoomLines, runContainmentAnalysis, computePolylineLength, computeBearing } from '../../utils/geo' +import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine' +import { generateAIBoomLines, runContainmentAnalysis, computePolylineLength, computeBearing } from '@common/utils/geo' import type { Analysis } from '../analysis/AnalysisListTable' interface LeftPanelProps { diff --git a/frontend/src/components/map/BacktrackReplayBar.tsx b/frontend/src/components/map/BacktrackReplayBar.tsx index ec46ddd..fdab7fe 100755 --- a/frontend/src/components/map/BacktrackReplayBar.tsx +++ b/frontend/src/components/map/BacktrackReplayBar.tsx @@ -1,4 +1,4 @@ -import type { ReplayShip, CollisionEvent } from '../../types/backtrack' +import type { ReplayShip, CollisionEvent } from '@common/types/backtrack' interface BacktrackReplayBarProps { isPlaying: boolean diff --git a/frontend/src/components/map/BacktrackReplayOverlay.tsx b/frontend/src/components/map/BacktrackReplayOverlay.tsx index 503d088..c2a6c51 100755 --- a/frontend/src/components/map/BacktrackReplayOverlay.tsx +++ b/frontend/src/components/map/BacktrackReplayOverlay.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { Polyline, CircleMarker, Circle, Marker, Popup } from 'react-leaflet' import L from 'leaflet' -import type { ReplayShip, CollisionEvent, ReplayPathPoint } from '../../types/backtrack' +import type { ReplayShip, CollisionEvent, ReplayPathPoint } from '@common/types/backtrack' interface BacktrackReplayOverlayProps { replayShips: ReplayShip[] diff --git a/frontend/src/components/map/MapView.tsx b/frontend/src/components/map/MapView.tsx index 0d81357..6fc8738 100755 --- a/frontend/src/components/map/MapView.tsx +++ b/frontend/src/components/map/MapView.tsx @@ -3,10 +3,10 @@ import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents, CircleMar import 'leaflet/dist/leaflet.css' import L from 'leaflet' import { layerDatabase } from '../../data/layerDatabase' -import { decimalToDMS } from '../../utils/coordinates' +import { decimalToDMS } from '@common/utils/coordinates' import type { PredictionModel } from '../views/OilSpillView' -import type { BoomLine, BoomLineCoord } from '../../types/boomLine' -import type { ReplayShip, CollisionEvent } from '../../types/backtrack' +import type { BoomLine, BoomLineCoord } from '@common/types/boomLine' +import type { ReplayShip, CollisionEvent } from '@common/types/backtrack' import { BacktrackReplayOverlay } from './BacktrackReplayOverlay' // Fix Leaflet default icon issue diff --git a/frontend/src/components/views/AdminView.tsx b/frontend/src/components/views/AdminView.tsx index a0d7bc6..06b0f67 100755 --- a/frontend/src/components/views/AdminView.tsx +++ b/frontend/src/components/views/AdminView.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react' -import { useSubMenu } from '../../hooks/useSubMenu' +import { useSubMenu } from '@common/hooks/useSubMenu' import data from '@emoji-mart/data' import EmojiPicker from '@emoji-mart/react' import { @@ -43,8 +43,8 @@ import { type RegistrationSettings, type OAuthSettings, type MenuConfigItem, -} from '../../services/authApi' -import { useMenuStore } from '../../store/menuStore' +} from '@common/services/authApi' +import { useMenuStore } from '@common/store/menuStore' const DEFAULT_ROLE_COLORS: Record = { ADMIN: 'var(--red)', diff --git a/frontend/src/components/views/AerialView.tsx b/frontend/src/components/views/AerialView.tsx index 249cb70..0ecfbaf 100755 --- a/frontend/src/components/views/AerialView.tsx +++ b/frontend/src/components/views/AerialView.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react' -import { useSubMenu } from '../../hooks/useSubMenu' +import { useSubMenu } from '@common/hooks/useSubMenu' import { AerialTheoryView } from '../analysis/AerialTheoryView' type AerialTab = 'media' | 'analysis' | 'realtime' | 'sensor' diff --git a/frontend/src/components/views/BoardView.tsx b/frontend/src/components/views/BoardView.tsx index 9f29312..9258ffd 100755 --- a/frontend/src/components/views/BoardView.tsx +++ b/frontend/src/components/views/BoardView.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { useSubMenu } from '../../hooks/useSubMenu' +import { useSubMenu } from '@common/hooks/useSubMenu' import { BoardWriteForm } from '../board/BoardWriteForm' import { BoardDetailView } from '../board/BoardDetailView' diff --git a/frontend/src/components/views/HNSView.tsx b/frontend/src/components/views/HNSView.tsx index 8d77bb2..d13ab75 100755 --- a/frontend/src/components/views/HNSView.tsx +++ b/frontend/src/components/views/HNSView.tsx @@ -7,7 +7,7 @@ import { HNSTheoryView } from '../analysis/HNSTheoryView' import { HNSSubstanceView } from '../analysis/HNSSubstanceView' import { HNSScenarioView } from '../analysis/HNSScenarioView' import { HNSRecalcModal } from '../analysis/HNSRecalcModal' -import { useSubMenu, navigateToTab, setReportGenCategory } from '../../hooks/useSubMenu' +import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/useSubMenu' /* ─── HNS 매뉴얼 뷰어 컴포넌트 ─── */ function HNSManualViewer() { diff --git a/frontend/src/components/views/OilSpillView.tsx b/frontend/src/components/views/OilSpillView.tsx index cea74a0..afb5a8d 100755 --- a/frontend/src/components/views/OilSpillView.tsx +++ b/frontend/src/components/views/OilSpillView.tsx @@ -8,10 +8,10 @@ import { BoomDeploymentTheoryView } from '../analysis/BoomDeploymentTheoryView' import { BacktrackModal } from '../analysis/BacktrackModal' import { RecalcModal } from '../analysis/RecalcModal' import { BacktrackReplayBar } from '../map/BacktrackReplayBar' -import { useSubMenu, navigateToTab, setReportGenCategory } from '../../hooks/useSubMenu' -import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '../../types/boomLine' -import type { BacktrackPhase, BacktrackVessel } from '../../types/backtrack' -import { TOTAL_REPLAY_FRAMES } from '../../types/backtrack' +import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/useSubMenu' +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' export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift' diff --git a/frontend/src/components/views/ReportsView.tsx b/frontend/src/components/views/ReportsView.tsx index 7222f85..c6e6446 100755 --- a/frontend/src/components/views/ReportsView.tsx +++ b/frontend/src/components/views/ReportsView.tsx @@ -9,8 +9,8 @@ import { type ReportType, type Jurisdiction, } from '../reports/OilSpillReportTemplate' -import { sanitizeHtml } from '../../utils/sanitize' -import { useSubMenu, consumeReportGenCategory } from '../../hooks/useSubMenu' +import { sanitizeHtml } from '@common/utils/sanitize' +import { useSubMenu, consumeReportGenCategory } from '@common/hooks/useSubMenu' // ─── Report Export Helpers ────────────────────────────── function generateReportHTML( diff --git a/frontend/src/components/views/RescueView.tsx b/frontend/src/components/views/RescueView.tsx index a5142f0..764dfdf 100755 --- a/frontend/src/components/views/RescueView.tsx +++ b/frontend/src/components/views/RescueView.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { useSubMenu } from '../../hooks/useSubMenu' +import { useSubMenu } from '@common/hooks/useSubMenu' import { RescueTheoryView } from '../analysis/RescueTheoryView' import { RescueScenarioView } from '../analysis/RescueScenarioView' diff --git a/frontend/src/data/backtrackMockData.ts b/frontend/src/data/backtrackMockData.ts index b494e7e..668cd3f 100755 --- a/frontend/src/data/backtrackMockData.ts +++ b/frontend/src/data/backtrackMockData.ts @@ -1,4 +1,4 @@ -import type { BacktrackConditions, BacktrackVessel, ReplayShip, CollisionEvent } from '../types/backtrack' +import type { BacktrackConditions, BacktrackVessel, ReplayShip, CollisionEvent } from '@common/types/backtrack' export const MOCK_CONDITIONS: BacktrackConditions = { estimatedSpillTime: '02-10 06:30', diff --git a/frontend/src/data/layerDatabase.ts b/frontend/src/data/layerDatabase.ts index 503dbd1..848c0e5 100755 --- a/frontend/src/data/layerDatabase.ts +++ b/frontend/src/data/layerDatabase.ts @@ -1,5 +1,5 @@ // 레이어 데이터베이스 - API에서 가져옴 -import { fetchAllLayers } from '../services/api' +import { fetchAllLayers } from '@common/services/api' export interface Layer { id: string diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index a9b5a59..016b36c 100755 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -22,7 +22,14 @@ "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + + /* Path aliases */ + "baseUrl": ".", + "paths": { + "@common/*": ["src/common/*"], + "@tabs/*": ["src/tabs/*"] + } }, "include": ["src"] } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 8b0f57b..ccf4196 100755 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,14 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import path from 'path' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + resolve: { + alias: { + '@common': path.resolve(__dirname, 'src/common'), + '@tabs': path.resolve(__dirname, 'src/tabs'), + }, + }, }) -- 2.45.2 From f099ff29b124e6a9d85ba368a72a2f47bde3b716 Mon Sep 17 00:00:00 2001 From: htlee Date: Sat, 28 Feb 2026 14:08:34 +0900 Subject: [PATCH 2/8] =?UTF-8?q?refactor(frontend):=20=ED=83=AD=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20(tabs/)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 11개 탭 디렉토리 생성: tabs/{prediction,hns,rescue,weather,incidents,aerial,board,reports,assets,scat,admin}/ - 51개 컴포넌트를 역할 기반(views/, analysis/, layout/) → 탭 기반(tabs/) 구조로 이동 - weather 탭에 전용 hooks/, services/ 포함 - incidents 탭에 전용 services/ 포함 - 공통 지도 컴포넌트(MapView, BacktrackReplay)를 common/components/map/으로 이동 - 각 탭에 index.ts 생성하여 View 컴포넌트 re-export - App.tsx import를 @tabs/ alias 사용으로 변경 - 전체 import 경로 수정 (탭 내부 상대경로, 외부 @common/ alias) Co-Authored-By: Claude Opus 4.6 --- frontend/src/App.tsx | 22 +++++++++---------- .../components/map/BacktrackReplayBar.tsx | 0 .../components/map/BacktrackReplayOverlay.tsx | 0 .../{ => common}/components/map/MapView.tsx | 4 ++-- .../admin/components}/AdminView.tsx | 0 frontend/src/tabs/admin/index.ts | 1 + .../aerial/components}/AerialTheoryView.tsx | 0 .../aerial/components}/AerialView.tsx | 2 +- frontend/src/tabs/aerial/index.ts | 1 + .../assets/components}/AssetsView.tsx | 0 frontend/src/tabs/assets/index.ts | 1 + .../board/components}/BoardDetailView.tsx | 0 .../board/components}/BoardListTable.tsx | 0 .../board/components}/BoardView.tsx | 4 ++-- .../board/components}/BoardWriteForm.tsx | 0 frontend/src/tabs/board/index.ts | 1 + .../hns/components}/HNSAnalysisListTable.tsx | 0 .../hns/components}/HNSLeftPanel.tsx | 0 .../hns/components}/HNSRecalcModal.tsx | 0 .../hns/components}/HNSRightPanel.tsx | 0 .../hns/components}/HNSScenarioView.tsx | 0 .../hns/components}/HNSSubstanceView.tsx | 2 +- .../hns/components}/HNSTheoryView.tsx | 0 .../views => tabs/hns/components}/HNSView.tsx | 16 +++++++------- frontend/src/tabs/hns/index.ts | 1 + .../incidents/components}/IncidentTable.tsx | 0 .../components}/IncidentsLeftPanel.tsx | 0 .../components}/IncidentsRightPanel.tsx | 0 .../incidents/components}/IncidentsView.tsx | 6 ++--- .../incidents/components}/MediaModal.tsx | 0 frontend/src/tabs/incidents/index.ts | 1 + .../incidents}/services/vesselService.ts | 0 .../components}/AnalysisListTable.tsx | 0 .../prediction/components}/BacktrackModal.tsx | 0 .../components}/BoomDeploymentTheoryView.tsx | 0 .../prediction/components}/LeftPanel.tsx | 12 +++++----- .../components}/OilSpillTheoryView.tsx | 0 .../prediction/components}/OilSpillView.tsx | 20 ++++++++--------- .../prediction/components}/RecalcModal.tsx | 2 +- .../prediction/components}/RightPanel.tsx | 0 frontend/src/tabs/prediction/index.ts | 1 + .../components}/OilSpillReportTemplate.tsx | 0 .../reports/components}/ReportsView.tsx | 2 +- frontend/src/tabs/reports/index.ts | 1 + .../rescue/components}/RescueScenarioView.tsx | 0 .../rescue/components}/RescueTheoryView.tsx | 0 .../rescue/components}/RescueView.tsx | 4 ++-- frontend/src/tabs/rescue/index.ts | 1 + .../scat/components}/PreScatView.tsx | 0 .../weather/components}/OceanCurrentLayer.tsx | 0 .../components}/OceanForecastOverlay.tsx | 2 +- .../components}/WaterTemperatureLayer.tsx | 0 .../weather/components}/WeatherMapOverlay.tsx | 0 .../weather/components}/WeatherRightPanel.tsx | 0 .../weather/components}/WeatherView.tsx | 16 +++++++------- .../weather/components}/WindParticleLayer.tsx | 0 .../weather}/hooks/useOceanForecast.ts | 0 .../weather}/hooks/useWeatherData.ts | 0 frontend/src/tabs/weather/index.ts | 1 + .../{ => tabs/weather}/services/khoaApi.ts | 0 .../{ => tabs/weather}/services/weatherApi.ts | 0 .../weather}/services/weatherService.ts | 0 62 files changed, 67 insertions(+), 57 deletions(-) rename frontend/src/{ => common}/components/map/BacktrackReplayBar.tsx (100%) rename frontend/src/{ => common}/components/map/BacktrackReplayOverlay.tsx (100%) rename frontend/src/{ => common}/components/map/MapView.tsx (99%) rename frontend/src/{components/views => tabs/admin/components}/AdminView.tsx (100%) create mode 100644 frontend/src/tabs/admin/index.ts rename frontend/src/{components/analysis => tabs/aerial/components}/AerialTheoryView.tsx (100%) rename frontend/src/{components/views => tabs/aerial/components}/AerialView.tsx (99%) create mode 100644 frontend/src/tabs/aerial/index.ts rename frontend/src/{components/views => tabs/assets/components}/AssetsView.tsx (100%) create mode 100644 frontend/src/tabs/assets/index.ts rename frontend/src/{components/board => tabs/board/components}/BoardDetailView.tsx (100%) rename frontend/src/{components/board => tabs/board/components}/BoardListTable.tsx (100%) rename frontend/src/{components/views => tabs/board/components}/BoardView.tsx (99%) rename frontend/src/{components/board => tabs/board/components}/BoardWriteForm.tsx (100%) create mode 100644 frontend/src/tabs/board/index.ts rename frontend/src/{components/analysis => tabs/hns/components}/HNSAnalysisListTable.tsx (100%) rename frontend/src/{components/layout => tabs/hns/components}/HNSLeftPanel.tsx (100%) rename frontend/src/{components/analysis => tabs/hns/components}/HNSRecalcModal.tsx (100%) rename frontend/src/{components/layout => tabs/hns/components}/HNSRightPanel.tsx (100%) rename frontend/src/{components/analysis => tabs/hns/components}/HNSScenarioView.tsx (100%) rename frontend/src/{components/analysis => tabs/hns/components}/HNSSubstanceView.tsx (99%) rename frontend/src/{components/analysis => tabs/hns/components}/HNSTheoryView.tsx (100%) rename frontend/src/{components/views => tabs/hns/components}/HNSView.tsx (98%) create mode 100644 frontend/src/tabs/hns/index.ts rename frontend/src/{components/incidents => tabs/incidents/components}/IncidentTable.tsx (100%) rename frontend/src/{components/incidents => tabs/incidents/components}/IncidentsLeftPanel.tsx (100%) rename frontend/src/{components/incidents => tabs/incidents/components}/IncidentsRightPanel.tsx (100%) rename frontend/src/{components/views => tabs/incidents/components}/IncidentsView.tsx (99%) rename frontend/src/{components/incidents => tabs/incidents/components}/MediaModal.tsx (100%) create mode 100644 frontend/src/tabs/incidents/index.ts rename frontend/src/{ => tabs/incidents}/services/vesselService.ts (100%) rename frontend/src/{components/analysis => tabs/prediction/components}/AnalysisListTable.tsx (100%) rename frontend/src/{components/analysis => tabs/prediction/components}/BacktrackModal.tsx (100%) rename frontend/src/{components/analysis => tabs/prediction/components}/BoomDeploymentTheoryView.tsx (100%) rename frontend/src/{components/layout => tabs/prediction/components}/LeftPanel.tsx (99%) rename frontend/src/{components/analysis => tabs/prediction/components}/OilSpillTheoryView.tsx (100%) rename frontend/src/{components/views => tabs/prediction/components}/OilSpillView.tsx (97%) rename frontend/src/{components/analysis => tabs/prediction/components}/RecalcModal.tsx (99%) rename frontend/src/{components/layout => tabs/prediction/components}/RightPanel.tsx (100%) create mode 100644 frontend/src/tabs/prediction/index.ts rename frontend/src/{components/reports => tabs/reports/components}/OilSpillReportTemplate.tsx (100%) rename frontend/src/{components/views => tabs/reports/components}/ReportsView.tsx (99%) create mode 100644 frontend/src/tabs/reports/index.ts rename frontend/src/{components/analysis => tabs/rescue/components}/RescueScenarioView.tsx (100%) rename frontend/src/{components/analysis => tabs/rescue/components}/RescueTheoryView.tsx (100%) rename frontend/src/{components/views => tabs/rescue/components}/RescueView.tsx (99%) create mode 100644 frontend/src/tabs/rescue/index.ts rename frontend/src/{components/views => tabs/scat/components}/PreScatView.tsx (100%) rename frontend/src/{components/weather => tabs/weather/components}/OceanCurrentLayer.tsx (100%) rename frontend/src/{components/weather => tabs/weather/components}/OceanForecastOverlay.tsx (95%) rename frontend/src/{components/weather => tabs/weather/components}/WaterTemperatureLayer.tsx (100%) rename frontend/src/{components/weather => tabs/weather/components}/WeatherMapOverlay.tsx (100%) rename frontend/src/{components/weather => tabs/weather/components}/WeatherRightPanel.tsx (100%) rename frontend/src/{components/views => tabs/weather/components}/WeatherView.tsx (97%) rename frontend/src/{components/weather => tabs/weather/components}/WindParticleLayer.tsx (100%) rename frontend/src/{ => tabs/weather}/hooks/useOceanForecast.ts (100%) rename frontend/src/{ => tabs/weather}/hooks/useWeatherData.ts (100%) create mode 100644 frontend/src/tabs/weather/index.ts rename frontend/src/{ => tabs/weather}/services/khoaApi.ts (100%) rename frontend/src/{ => tabs/weather}/services/weatherApi.ts (100%) rename frontend/src/{ => tabs/weather}/services/weatherService.ts (100%) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3805afc..bc5b47b 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,17 +6,17 @@ import { LoginPage } from '@common/components/auth/LoginPage' import { registerMainTabSwitcher } from '@common/hooks/useSubMenu' import { useAuthStore } from '@common/store/authStore' import { useMenuStore } from '@common/store/menuStore' -import { OilSpillView } from './components/views/OilSpillView' -import { ReportsView } from './components/views/ReportsView' -import { HNSView } from './components/views/HNSView' -import { AerialView } from './components/views/AerialView' -import { AssetsView } from './components/views/AssetsView' -import { BoardView } from './components/views/BoardView' -import { WeatherView } from './components/views/WeatherView' -import { IncidentsView } from './components/views/IncidentsView' -import { AdminView } from './components/views/AdminView' -import { PreScatView } from './components/views/PreScatView' -import { RescueView } from './components/views/RescueView' +import { OilSpillView } from '@tabs/prediction' +import { ReportsView } from '@tabs/reports' +import { HNSView } from '@tabs/hns' +import { AerialView } from '@tabs/aerial' +import { AssetsView } from '@tabs/assets' +import { BoardView } from '@tabs/board' +import { WeatherView } from '@tabs/weather' +import { IncidentsView } from '@tabs/incidents' +import { AdminView } from '@tabs/admin' +import { PreScatView } from '@tabs/scat' +import { RescueView } from '@tabs/rescue' const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || '' diff --git a/frontend/src/components/map/BacktrackReplayBar.tsx b/frontend/src/common/components/map/BacktrackReplayBar.tsx similarity index 100% rename from frontend/src/components/map/BacktrackReplayBar.tsx rename to frontend/src/common/components/map/BacktrackReplayBar.tsx diff --git a/frontend/src/components/map/BacktrackReplayOverlay.tsx b/frontend/src/common/components/map/BacktrackReplayOverlay.tsx similarity index 100% rename from frontend/src/components/map/BacktrackReplayOverlay.tsx rename to frontend/src/common/components/map/BacktrackReplayOverlay.tsx diff --git a/frontend/src/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx similarity index 99% rename from frontend/src/components/map/MapView.tsx rename to frontend/src/common/components/map/MapView.tsx index 6fc8738..47c075a 100755 --- a/frontend/src/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -2,9 +2,9 @@ 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 '../../../data/layerDatabase' 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 '@common/types/boomLine' import type { ReplayShip, CollisionEvent } from '@common/types/backtrack' import { BacktrackReplayOverlay } from './BacktrackReplayOverlay' diff --git a/frontend/src/components/views/AdminView.tsx b/frontend/src/tabs/admin/components/AdminView.tsx similarity index 100% rename from frontend/src/components/views/AdminView.tsx rename to frontend/src/tabs/admin/components/AdminView.tsx diff --git a/frontend/src/tabs/admin/index.ts b/frontend/src/tabs/admin/index.ts new file mode 100644 index 0000000..a4a410b --- /dev/null +++ b/frontend/src/tabs/admin/index.ts @@ -0,0 +1 @@ +export { AdminView } from './components/AdminView' diff --git a/frontend/src/components/analysis/AerialTheoryView.tsx b/frontend/src/tabs/aerial/components/AerialTheoryView.tsx similarity index 100% rename from frontend/src/components/analysis/AerialTheoryView.tsx rename to frontend/src/tabs/aerial/components/AerialTheoryView.tsx diff --git a/frontend/src/components/views/AerialView.tsx b/frontend/src/tabs/aerial/components/AerialView.tsx similarity index 99% rename from frontend/src/components/views/AerialView.tsx rename to frontend/src/tabs/aerial/components/AerialView.tsx index 0ecfbaf..321850f 100755 --- a/frontend/src/components/views/AerialView.tsx +++ b/frontend/src/tabs/aerial/components/AerialView.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect } from 'react' import { useSubMenu } from '@common/hooks/useSubMenu' -import { AerialTheoryView } from '../analysis/AerialTheoryView' +import { AerialTheoryView } from './AerialTheoryView' type AerialTab = 'media' | 'analysis' | 'realtime' | 'sensor' diff --git a/frontend/src/tabs/aerial/index.ts b/frontend/src/tabs/aerial/index.ts new file mode 100644 index 0000000..013f9e4 --- /dev/null +++ b/frontend/src/tabs/aerial/index.ts @@ -0,0 +1 @@ +export { AerialView } from './components/AerialView' diff --git a/frontend/src/components/views/AssetsView.tsx b/frontend/src/tabs/assets/components/AssetsView.tsx similarity index 100% rename from frontend/src/components/views/AssetsView.tsx rename to frontend/src/tabs/assets/components/AssetsView.tsx diff --git a/frontend/src/tabs/assets/index.ts b/frontend/src/tabs/assets/index.ts new file mode 100644 index 0000000..2b2f64f --- /dev/null +++ b/frontend/src/tabs/assets/index.ts @@ -0,0 +1 @@ +export { AssetsView } from './components/AssetsView' diff --git a/frontend/src/components/board/BoardDetailView.tsx b/frontend/src/tabs/board/components/BoardDetailView.tsx similarity index 100% rename from frontend/src/components/board/BoardDetailView.tsx rename to frontend/src/tabs/board/components/BoardDetailView.tsx diff --git a/frontend/src/components/board/BoardListTable.tsx b/frontend/src/tabs/board/components/BoardListTable.tsx similarity index 100% rename from frontend/src/components/board/BoardListTable.tsx rename to frontend/src/tabs/board/components/BoardListTable.tsx diff --git a/frontend/src/components/views/BoardView.tsx b/frontend/src/tabs/board/components/BoardView.tsx similarity index 99% rename from frontend/src/components/views/BoardView.tsx rename to frontend/src/tabs/board/components/BoardView.tsx index 9258ffd..d9a996e 100755 --- a/frontend/src/components/views/BoardView.tsx +++ b/frontend/src/tabs/board/components/BoardView.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useSubMenu } from '@common/hooks/useSubMenu' -import { BoardWriteForm } from '../board/BoardWriteForm' -import { BoardDetailView } from '../board/BoardDetailView' +import { BoardWriteForm } from './BoardWriteForm' +import { BoardDetailView } from './BoardDetailView' interface BoardPost { id: number diff --git a/frontend/src/components/board/BoardWriteForm.tsx b/frontend/src/tabs/board/components/BoardWriteForm.tsx similarity index 100% rename from frontend/src/components/board/BoardWriteForm.tsx rename to frontend/src/tabs/board/components/BoardWriteForm.tsx diff --git a/frontend/src/tabs/board/index.ts b/frontend/src/tabs/board/index.ts new file mode 100644 index 0000000..31edec6 --- /dev/null +++ b/frontend/src/tabs/board/index.ts @@ -0,0 +1 @@ +export { BoardView } from './components/BoardView' diff --git a/frontend/src/components/analysis/HNSAnalysisListTable.tsx b/frontend/src/tabs/hns/components/HNSAnalysisListTable.tsx similarity index 100% rename from frontend/src/components/analysis/HNSAnalysisListTable.tsx rename to frontend/src/tabs/hns/components/HNSAnalysisListTable.tsx diff --git a/frontend/src/components/layout/HNSLeftPanel.tsx b/frontend/src/tabs/hns/components/HNSLeftPanel.tsx similarity index 100% rename from frontend/src/components/layout/HNSLeftPanel.tsx rename to frontend/src/tabs/hns/components/HNSLeftPanel.tsx diff --git a/frontend/src/components/analysis/HNSRecalcModal.tsx b/frontend/src/tabs/hns/components/HNSRecalcModal.tsx similarity index 100% rename from frontend/src/components/analysis/HNSRecalcModal.tsx rename to frontend/src/tabs/hns/components/HNSRecalcModal.tsx diff --git a/frontend/src/components/layout/HNSRightPanel.tsx b/frontend/src/tabs/hns/components/HNSRightPanel.tsx similarity index 100% rename from frontend/src/components/layout/HNSRightPanel.tsx rename to frontend/src/tabs/hns/components/HNSRightPanel.tsx diff --git a/frontend/src/components/analysis/HNSScenarioView.tsx b/frontend/src/tabs/hns/components/HNSScenarioView.tsx similarity index 100% rename from frontend/src/components/analysis/HNSScenarioView.tsx rename to frontend/src/tabs/hns/components/HNSScenarioView.tsx diff --git a/frontend/src/components/analysis/HNSSubstanceView.tsx b/frontend/src/tabs/hns/components/HNSSubstanceView.tsx similarity index 99% rename from frontend/src/components/analysis/HNSSubstanceView.tsx rename to frontend/src/tabs/hns/components/HNSSubstanceView.tsx index 1008239..e314597 100755 --- a/frontend/src/components/analysis/HNSSubstanceView.tsx +++ b/frontend/src/tabs/hns/components/HNSSubstanceView.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useMemo } from 'react' import { sanitizeHtml } from '@common/utils/sanitize' -import { HNS_SEARCH_DB, type HNSSearchSubstance } from '../../data/hnsSubstanceSearchData' +import { HNS_SEARCH_DB, type HNSSearchSubstance } from '../../../data/hnsSubstanceSearchData' /* ═══ HNS 물질 데이터베이스 ═══ */ interface HNSSubstance { diff --git a/frontend/src/components/analysis/HNSTheoryView.tsx b/frontend/src/tabs/hns/components/HNSTheoryView.tsx similarity index 100% rename from frontend/src/components/analysis/HNSTheoryView.tsx rename to frontend/src/tabs/hns/components/HNSTheoryView.tsx diff --git a/frontend/src/components/views/HNSView.tsx b/frontend/src/tabs/hns/components/HNSView.tsx similarity index 98% rename from frontend/src/components/views/HNSView.tsx rename to frontend/src/tabs/hns/components/HNSView.tsx index d13ab75..572c28d 100755 --- a/frontend/src/components/views/HNSView.tsx +++ b/frontend/src/tabs/hns/components/HNSView.tsx @@ -1,12 +1,12 @@ import { useState } from 'react' -import { HNSLeftPanel } from '../layout/HNSLeftPanel' -import { HNSRightPanel } from '../layout/HNSRightPanel' -import { MapView } from '../map/MapView' -import { HNSAnalysisListTable } from '../analysis/HNSAnalysisListTable' -import { HNSTheoryView } from '../analysis/HNSTheoryView' -import { HNSSubstanceView } from '../analysis/HNSSubstanceView' -import { HNSScenarioView } from '../analysis/HNSScenarioView' -import { HNSRecalcModal } from '../analysis/HNSRecalcModal' +import { HNSLeftPanel } from './HNSLeftPanel' +import { HNSRightPanel } from './HNSRightPanel' +import { MapView } from '@common/components/map/MapView' +import { HNSAnalysisListTable } from './HNSAnalysisListTable' +import { HNSTheoryView } from './HNSTheoryView' +import { HNSSubstanceView } from './HNSSubstanceView' +import { HNSScenarioView } from './HNSScenarioView' +import { HNSRecalcModal } from './HNSRecalcModal' import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/useSubMenu' /* ─── HNS 매뉴얼 뷰어 컴포넌트 ─── */ diff --git a/frontend/src/tabs/hns/index.ts b/frontend/src/tabs/hns/index.ts new file mode 100644 index 0000000..a413011 --- /dev/null +++ b/frontend/src/tabs/hns/index.ts @@ -0,0 +1 @@ +export { HNSView } from './components/HNSView' diff --git a/frontend/src/components/incidents/IncidentTable.tsx b/frontend/src/tabs/incidents/components/IncidentTable.tsx similarity index 100% rename from frontend/src/components/incidents/IncidentTable.tsx rename to frontend/src/tabs/incidents/components/IncidentTable.tsx diff --git a/frontend/src/components/incidents/IncidentsLeftPanel.tsx b/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx similarity index 100% rename from frontend/src/components/incidents/IncidentsLeftPanel.tsx rename to frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx diff --git a/frontend/src/components/incidents/IncidentsRightPanel.tsx b/frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx similarity index 100% rename from frontend/src/components/incidents/IncidentsRightPanel.tsx rename to frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx diff --git a/frontend/src/components/views/IncidentsView.tsx b/frontend/src/tabs/incidents/components/IncidentsView.tsx similarity index 99% rename from frontend/src/components/views/IncidentsView.tsx rename to frontend/src/tabs/incidents/components/IncidentsView.tsx index 71fbca4..9e3ab9f 100755 --- a/frontend/src/components/views/IncidentsView.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsView.tsx @@ -3,9 +3,9 @@ import { MapContainer, TileLayer, CircleMarker, Popup, Marker } from 'react-leaf import L from 'leaflet' import type { LatLngExpression } from 'leaflet' import 'leaflet/dist/leaflet.css' -import { IncidentsLeftPanel, type Incident } from '../incidents/IncidentsLeftPanel' -import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from '../incidents/IncidentsRightPanel' -import { mockVessels, VESSEL_LEGEND, type Vessel } from '../../data/vesselMockData' +import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel' +import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel' +import { mockVessels, VESSEL_LEGEND, type Vessel } from '../../../data/vesselMockData' // Mock incident data (HTML 참고 6건) const mockIncidents: Incident[] = [ diff --git a/frontend/src/components/incidents/MediaModal.tsx b/frontend/src/tabs/incidents/components/MediaModal.tsx similarity index 100% rename from frontend/src/components/incidents/MediaModal.tsx rename to frontend/src/tabs/incidents/components/MediaModal.tsx diff --git a/frontend/src/tabs/incidents/index.ts b/frontend/src/tabs/incidents/index.ts new file mode 100644 index 0000000..9a15c9b --- /dev/null +++ b/frontend/src/tabs/incidents/index.ts @@ -0,0 +1 @@ +export { IncidentsView } from './components/IncidentsView' diff --git a/frontend/src/services/vesselService.ts b/frontend/src/tabs/incidents/services/vesselService.ts similarity index 100% rename from frontend/src/services/vesselService.ts rename to frontend/src/tabs/incidents/services/vesselService.ts diff --git a/frontend/src/components/analysis/AnalysisListTable.tsx b/frontend/src/tabs/prediction/components/AnalysisListTable.tsx similarity index 100% rename from frontend/src/components/analysis/AnalysisListTable.tsx rename to frontend/src/tabs/prediction/components/AnalysisListTable.tsx diff --git a/frontend/src/components/analysis/BacktrackModal.tsx b/frontend/src/tabs/prediction/components/BacktrackModal.tsx similarity index 100% rename from frontend/src/components/analysis/BacktrackModal.tsx rename to frontend/src/tabs/prediction/components/BacktrackModal.tsx diff --git a/frontend/src/components/analysis/BoomDeploymentTheoryView.tsx b/frontend/src/tabs/prediction/components/BoomDeploymentTheoryView.tsx similarity index 100% rename from frontend/src/components/analysis/BoomDeploymentTheoryView.tsx rename to frontend/src/tabs/prediction/components/BoomDeploymentTheoryView.tsx diff --git a/frontend/src/components/layout/LeftPanel.tsx b/frontend/src/tabs/prediction/components/LeftPanel.tsx similarity index 99% rename from frontend/src/components/layout/LeftPanel.tsx rename to frontend/src/tabs/prediction/components/LeftPanel.tsx index 0122881..a3540a3 100755 --- a/frontend/src/components/layout/LeftPanel.tsx +++ b/frontend/src/tabs/prediction/components/LeftPanel.tsx @@ -1,16 +1,16 @@ import { useState, useMemo } from 'react' import { LayerTree } from '@common/components/layer/LayerTree' import { useLayerTree } from '@common/hooks/useLayers' -import { layerData } from '../../data/layerData' -import type { LayerNode } from '../../data/layerData' -import type { Layer } from '../../data/layerDatabase' +import { layerData } from '../../../data/layerData' +import type { LayerNode } from '../../../data/layerData' +import type { Layer } from '../../../data/layerDatabase' import { decimalToDMS } from '@common/utils/coordinates' import { ComboBox } from '@common/components/ui/ComboBox' -import { ALL_MODELS } from '../views/OilSpillView' -import type { PredictionModel } from '../views/OilSpillView' +import { ALL_MODELS } from './OilSpillView' +import type { PredictionModel } from './OilSpillView' import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine' import { generateAIBoomLines, runContainmentAnalysis, computePolylineLength, computeBearing } from '@common/utils/geo' -import type { Analysis } from '../analysis/AnalysisListTable' +import type { Analysis } from './AnalysisListTable' interface LeftPanelProps { selectedAnalysis?: Analysis | null diff --git a/frontend/src/components/analysis/OilSpillTheoryView.tsx b/frontend/src/tabs/prediction/components/OilSpillTheoryView.tsx similarity index 100% rename from frontend/src/components/analysis/OilSpillTheoryView.tsx rename to frontend/src/tabs/prediction/components/OilSpillTheoryView.tsx diff --git a/frontend/src/components/views/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx similarity index 97% rename from frontend/src/components/views/OilSpillView.tsx rename to frontend/src/tabs/prediction/components/OilSpillView.tsx index afb5a8d..b9f74ab 100755 --- a/frontend/src/components/views/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -1,18 +1,18 @@ import { useState, useEffect } from 'react' -import { LeftPanel } from '../layout/LeftPanel' -import { RightPanel } from '../layout/RightPanel' -import { MapView } from '../map/MapView' -import { AnalysisListTable, type Analysis } from '../analysis/AnalysisListTable' -import { OilSpillTheoryView } from '../analysis/OilSpillTheoryView' -import { BoomDeploymentTheoryView } from '../analysis/BoomDeploymentTheoryView' -import { BacktrackModal } from '../analysis/BacktrackModal' -import { RecalcModal } from '../analysis/RecalcModal' -import { BacktrackReplayBar } from '../map/BacktrackReplayBar' +import { LeftPanel } from './LeftPanel' +import { RightPanel } from './RightPanel' +import { MapView } from '@common/components/map/MapView' +import { AnalysisListTable, type Analysis } from './AnalysisListTable' +import { OilSpillTheoryView } from './OilSpillTheoryView' +import { BoomDeploymentTheoryView } from './BoomDeploymentTheoryView' +import { BacktrackModal } from './BacktrackModal' +import { RecalcModal } from './RecalcModal' +import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar' import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/useSubMenu' 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 '../../../data/backtrackMockData' export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift' // eslint-disable-next-line react-refresh/only-export-components diff --git a/frontend/src/components/analysis/RecalcModal.tsx b/frontend/src/tabs/prediction/components/RecalcModal.tsx similarity index 99% rename from frontend/src/components/analysis/RecalcModal.tsx rename to frontend/src/tabs/prediction/components/RecalcModal.tsx index 664484e..6e033d4 100755 --- a/frontend/src/components/analysis/RecalcModal.tsx +++ b/frontend/src/tabs/prediction/components/RecalcModal.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react' -import type { PredictionModel } from '../views/OilSpillView' +import type { PredictionModel } from './OilSpillView' interface RecalcModalProps { isOpen: boolean diff --git a/frontend/src/components/layout/RightPanel.tsx b/frontend/src/tabs/prediction/components/RightPanel.tsx similarity index 100% rename from frontend/src/components/layout/RightPanel.tsx rename to frontend/src/tabs/prediction/components/RightPanel.tsx diff --git a/frontend/src/tabs/prediction/index.ts b/frontend/src/tabs/prediction/index.ts new file mode 100644 index 0000000..bba2193 --- /dev/null +++ b/frontend/src/tabs/prediction/index.ts @@ -0,0 +1 @@ +export { OilSpillView } from './components/OilSpillView' diff --git a/frontend/src/components/reports/OilSpillReportTemplate.tsx b/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx similarity index 100% rename from frontend/src/components/reports/OilSpillReportTemplate.tsx rename to frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx diff --git a/frontend/src/components/views/ReportsView.tsx b/frontend/src/tabs/reports/components/ReportsView.tsx similarity index 99% rename from frontend/src/components/views/ReportsView.tsx rename to frontend/src/tabs/reports/components/ReportsView.tsx index c6e6446..3941bdd 100755 --- a/frontend/src/components/views/ReportsView.tsx +++ b/frontend/src/tabs/reports/components/ReportsView.tsx @@ -8,7 +8,7 @@ import { type OilSpillReportData, type ReportType, type Jurisdiction, -} from '../reports/OilSpillReportTemplate' +} from './OilSpillReportTemplate' import { sanitizeHtml } from '@common/utils/sanitize' import { useSubMenu, consumeReportGenCategory } from '@common/hooks/useSubMenu' diff --git a/frontend/src/tabs/reports/index.ts b/frontend/src/tabs/reports/index.ts new file mode 100644 index 0000000..5ba33c4 --- /dev/null +++ b/frontend/src/tabs/reports/index.ts @@ -0,0 +1 @@ +export { ReportsView } from './components/ReportsView' diff --git a/frontend/src/components/analysis/RescueScenarioView.tsx b/frontend/src/tabs/rescue/components/RescueScenarioView.tsx similarity index 100% rename from frontend/src/components/analysis/RescueScenarioView.tsx rename to frontend/src/tabs/rescue/components/RescueScenarioView.tsx diff --git a/frontend/src/components/analysis/RescueTheoryView.tsx b/frontend/src/tabs/rescue/components/RescueTheoryView.tsx similarity index 100% rename from frontend/src/components/analysis/RescueTheoryView.tsx rename to frontend/src/tabs/rescue/components/RescueTheoryView.tsx diff --git a/frontend/src/components/views/RescueView.tsx b/frontend/src/tabs/rescue/components/RescueView.tsx similarity index 99% rename from frontend/src/components/views/RescueView.tsx rename to frontend/src/tabs/rescue/components/RescueView.tsx index 764dfdf..2f890f2 100755 --- a/frontend/src/components/views/RescueView.tsx +++ b/frontend/src/tabs/rescue/components/RescueView.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { useSubMenu } from '@common/hooks/useSubMenu' -import { RescueTheoryView } from '../analysis/RescueTheoryView' -import { RescueScenarioView } from '../analysis/RescueScenarioView' +import { RescueTheoryView } from './RescueTheoryView' +import { RescueScenarioView } from './RescueScenarioView' /* ─── Types ─── */ type AccidentType = 'collision' | 'grounding' | 'turning' | 'capsizing' | 'sharpTurn' | 'flooding' | 'sinking' diff --git a/frontend/src/tabs/rescue/index.ts b/frontend/src/tabs/rescue/index.ts new file mode 100644 index 0000000..adfd9eb --- /dev/null +++ b/frontend/src/tabs/rescue/index.ts @@ -0,0 +1 @@ +export { RescueView } from './components/RescueView' diff --git a/frontend/src/components/views/PreScatView.tsx b/frontend/src/tabs/scat/components/PreScatView.tsx similarity index 100% rename from frontend/src/components/views/PreScatView.tsx rename to frontend/src/tabs/scat/components/PreScatView.tsx diff --git a/frontend/src/components/weather/OceanCurrentLayer.tsx b/frontend/src/tabs/weather/components/OceanCurrentLayer.tsx similarity index 100% rename from frontend/src/components/weather/OceanCurrentLayer.tsx rename to frontend/src/tabs/weather/components/OceanCurrentLayer.tsx diff --git a/frontend/src/components/weather/OceanForecastOverlay.tsx b/frontend/src/tabs/weather/components/OceanForecastOverlay.tsx similarity index 95% rename from frontend/src/components/weather/OceanForecastOverlay.tsx rename to frontend/src/tabs/weather/components/OceanForecastOverlay.tsx index e34b530..c4a7585 100755 --- a/frontend/src/components/weather/OceanForecastOverlay.tsx +++ b/frontend/src/tabs/weather/components/OceanForecastOverlay.tsx @@ -1,7 +1,7 @@ import { ImageOverlay, useMap } from 'react-leaflet' import { LatLngBounds } from 'leaflet' import { useEffect, useState } from 'react' -import type { OceanForecastData } from '../../services/khoaApi' +import type { OceanForecastData } from '../services/khoaApi' interface OceanForecastOverlayProps { forecast: OceanForecastData | null diff --git a/frontend/src/components/weather/WaterTemperatureLayer.tsx b/frontend/src/tabs/weather/components/WaterTemperatureLayer.tsx similarity index 100% rename from frontend/src/components/weather/WaterTemperatureLayer.tsx rename to frontend/src/tabs/weather/components/WaterTemperatureLayer.tsx diff --git a/frontend/src/components/weather/WeatherMapOverlay.tsx b/frontend/src/tabs/weather/components/WeatherMapOverlay.tsx similarity index 100% rename from frontend/src/components/weather/WeatherMapOverlay.tsx rename to frontend/src/tabs/weather/components/WeatherMapOverlay.tsx diff --git a/frontend/src/components/weather/WeatherRightPanel.tsx b/frontend/src/tabs/weather/components/WeatherRightPanel.tsx similarity index 100% rename from frontend/src/components/weather/WeatherRightPanel.tsx rename to frontend/src/tabs/weather/components/WeatherRightPanel.tsx diff --git a/frontend/src/components/views/WeatherView.tsx b/frontend/src/tabs/weather/components/WeatherView.tsx similarity index 97% rename from frontend/src/components/views/WeatherView.tsx rename to frontend/src/tabs/weather/components/WeatherView.tsx index 811f7b5..c9b8339 100755 --- a/frontend/src/components/views/WeatherView.tsx +++ b/frontend/src/tabs/weather/components/WeatherView.tsx @@ -2,14 +2,14 @@ import { useState, useEffect } from 'react' import { MapContainer, TileLayer, useMapEvents } from 'react-leaflet' import type { LatLngExpression } from 'leaflet' import 'leaflet/dist/leaflet.css' -import { WeatherRightPanel } from '../weather/WeatherRightPanel' -import { WeatherMapOverlay } from '../weather/WeatherMapOverlay' -import { OceanForecastOverlay } from '../weather/OceanForecastOverlay' -import { OceanCurrentLayer } from '../weather/OceanCurrentLayer' -import { WaterTemperatureLayer } from '../weather/WaterTemperatureLayer' -import { WindParticleLayer } from '../weather/WindParticleLayer' -import { useWeatherData } from '../../hooks/useWeatherData' -import { useOceanForecast } from '../../hooks/useOceanForecast' +import { WeatherRightPanel } from './WeatherRightPanel' +import { WeatherMapOverlay } from './WeatherMapOverlay' +import { OceanForecastOverlay } from './OceanForecastOverlay' +import { OceanCurrentLayer } from './OceanCurrentLayer' +import { WaterTemperatureLayer } from './WaterTemperatureLayer' +import { WindParticleLayer } from './WindParticleLayer' +import { useWeatherData } from '../hooks/useWeatherData' +import { useOceanForecast } from '../hooks/useOceanForecast' type TimeOffset = '0' | '3' | '6' | '9' diff --git a/frontend/src/components/weather/WindParticleLayer.tsx b/frontend/src/tabs/weather/components/WindParticleLayer.tsx similarity index 100% rename from frontend/src/components/weather/WindParticleLayer.tsx rename to frontend/src/tabs/weather/components/WindParticleLayer.tsx diff --git a/frontend/src/hooks/useOceanForecast.ts b/frontend/src/tabs/weather/hooks/useOceanForecast.ts similarity index 100% rename from frontend/src/hooks/useOceanForecast.ts rename to frontend/src/tabs/weather/hooks/useOceanForecast.ts diff --git a/frontend/src/hooks/useWeatherData.ts b/frontend/src/tabs/weather/hooks/useWeatherData.ts similarity index 100% rename from frontend/src/hooks/useWeatherData.ts rename to frontend/src/tabs/weather/hooks/useWeatherData.ts diff --git a/frontend/src/tabs/weather/index.ts b/frontend/src/tabs/weather/index.ts new file mode 100644 index 0000000..e4dd76d --- /dev/null +++ b/frontend/src/tabs/weather/index.ts @@ -0,0 +1 @@ +export { WeatherView } from './components/WeatherView' diff --git a/frontend/src/services/khoaApi.ts b/frontend/src/tabs/weather/services/khoaApi.ts similarity index 100% rename from frontend/src/services/khoaApi.ts rename to frontend/src/tabs/weather/services/khoaApi.ts diff --git a/frontend/src/services/weatherApi.ts b/frontend/src/tabs/weather/services/weatherApi.ts similarity index 100% rename from frontend/src/services/weatherApi.ts rename to frontend/src/tabs/weather/services/weatherApi.ts diff --git a/frontend/src/services/weatherService.ts b/frontend/src/tabs/weather/services/weatherService.ts similarity index 100% rename from frontend/src/services/weatherService.ts rename to frontend/src/tabs/weather/services/weatherService.ts -- 2.45.2 From 199d5310db0db87ad9ed7cb2d1f8695c07be1c9c Mon Sep 17 00:00:00 2001 From: htlee Date: Sat, 28 Feb 2026 14:18:00 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor(backend):=20SQLite=20=E2=86=92=20P?= =?UTF-8?q?ostgreSQL=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20+=20wing=20DB=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - better-sqlite3 제거, wingDb.ts (PostgreSQL wing DB Pool) 추가 - layers 라우터: 동기(better-sqlite3) → 비동기(pg) 전환 - LAYER 테이블 마이그레이션 SQL 생성 (database/migration/001_layer_table.sql) - seed 스크립트 PostgreSQL 전환 - 문서 업데이트: CLAUDE.md, README.md, docs/README.md, COMMON-GUIDE.md Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 6 +- README.md | 2 +- backend/package-lock.json | 402 ------------------------- backend/package.json | 2 - backend/src/db/database.ts | 30 -- backend/src/db/seed.ts | 151 +++++----- backend/src/db/wingDb.ts | 33 ++ backend/src/routes/layers.ts | 73 +++-- backend/src/server.ts | 12 +- database/migration/001_layer_table.sql | 36 +++ docs/COMMON-GUIDE.md | 13 +- docs/README.md | 6 +- 12 files changed, 218 insertions(+), 548 deletions(-) delete mode 100755 backend/src/db/database.ts create mode 100644 backend/src/db/wingDb.ts create mode 100644 database/migration/001_layer_table.sql diff --git a/CLAUDE.md b/CLAUDE.md index 29f527d..8695d31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,9 +6,9 @@ - **프로젝트 타입**: react-ts (모노레포) - **Frontend**: React 19 + Vite 7 + TypeScript 5.9 + Tailwind CSS 3 -- **Backend**: Express 4 + better-sqlite3 + TypeScript +- **Backend**: Express 4 + PostgreSQL (pg) + TypeScript - **상태관리**: Zustand (클라이언트), TanStack Query (서버) -- **지도**: Leaflet, OpenLayers +- **지도**: Leaflet - **실시간**: Socket.IO ## 빌드/실행 @@ -70,7 +70,7 @@ wing/ │ ├── audit/ 감사 로그 │ ├── routes/ 레이어, 시뮬레이션 │ ├── middleware/ 보안 (입력 살균, rate-limit) -│ └── db/ DB 연결 (PostgreSQL, SQLite) +│ └── db/ DB 연결 (PostgreSQL: wing, wing_auth) ├── database/ SQL 초기화 스크립트 ├── docs/ 개발 문서 (README, 가이드, 변경이력) ├── .claude/ 팀 워크플로우 (rules, skills, scripts) diff --git a/README.md b/README.md index 7832a3e..4fac801 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ wing/ | 영역 | 기술 | |------|------| | Frontend | React 19, Vite 7, TypeScript 5.9, Tailwind CSS 3 | -| Backend | Express 4, TypeScript, better-sqlite3 (레이어), pg (인증) | +| Backend | Express 4, TypeScript, PostgreSQL (pg) | | 상태 관리 | Zustand (클라이언트), TanStack Query (서버) | | 지도 | Leaflet, OpenLayers | | 실시간 | Socket.IO | diff --git a/backend/package-lock.json b/backend/package-lock.json index 761c796..a3d5138 100755 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,7 +9,6 @@ "version": "1.0.0", "dependencies": { "bcrypt": "^6.0.0", - "better-sqlite3": "^11.9.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.3.1", @@ -22,7 +21,6 @@ }, "devDependencies": { "@types/bcrypt": "^6.0.0", - "@types/better-sqlite3": "^7.6.12", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", @@ -513,16 +511,6 @@ "@types/node": "*" } }, - "node_modules/@types/better-sqlite3": { - "version": "7.6.13", - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", - "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -773,17 +761,6 @@ "node": ">= 18" } }, - "node_modules/better-sqlite3": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", - "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - } - }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -793,26 +770,6 @@ "node": "*" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -873,30 +830,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -941,12 +874,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1077,30 +1004,6 @@ } } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1120,15 +1023,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -1191,15 +1085,6 @@ "node": ">= 0.8" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1287,15 +1172,6 @@ "node": ">= 0.6" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -1404,12 +1280,6 @@ "node": "^12.20 || >= 14.13" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -1489,12 +1359,6 @@ "node": ">= 0.6" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1598,12 +1462,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -1729,38 +1587,12 @@ "node": ">= 14" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/ip-address": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", @@ -1978,18 +1810,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -2005,15 +1825,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -2023,24 +1834,12 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -2050,18 +1849,6 @@ "node": ">= 0.6" } }, - "node_modules/node-abi": { - "version": "3.87.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", - "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-addon-api": { "version": "8.5.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", @@ -2153,15 +1940,6 @@ "node": ">= 0.8" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -2336,32 +2114,6 @@ "node": ">=0.10.0" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2375,16 +2127,6 @@ "node": ">= 0.10" } }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -2436,35 +2178,6 @@ "node": ">=0.10.0" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -2693,51 +2406,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -2756,15 +2424,6 @@ "node": ">= 0.8" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -2861,43 +2520,6 @@ "node": ">=8" } }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2927,18 +2549,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -2982,12 +2592,6 @@ "node": ">= 0.8" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -3121,12 +2725,6 @@ "node": ">=8" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 36aaee1..1e578cb 100755 --- a/backend/package.json +++ b/backend/package.json @@ -10,7 +10,6 @@ }, "dependencies": { "bcrypt": "^6.0.0", - "better-sqlite3": "^11.9.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.3.1", @@ -23,7 +22,6 @@ }, "devDependencies": { "@types/bcrypt": "^6.0.0", - "@types/better-sqlite3": "^7.6.12", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", diff --git a/backend/src/db/database.ts b/backend/src/db/database.ts deleted file mode 100755 index de1109c..0000000 --- a/backend/src/db/database.ts +++ /dev/null @@ -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 diff --git a/backend/src/db/seed.ts b/backend/src/db/seed.ts index 1a27bf1..1beff4f 100755 --- a/backend/src/db/seed.ts +++ b/backend/src/db/seed.ts @@ -1,91 +1,106 @@ +import 'dotenv/config' import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' import { dirname } from 'path' -import db, { initDatabase } from './database.js' +import { wingPool } from './wingDb.js' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) async function seedDatabase() { - console.log('데이터베이스 초기화 중...') - initDatabase() + console.log('wing DB 레이어 시드 시작...') - // 기존 데이터 삭제 - db.exec('DELETE FROM layers') + const client = await wingPool.connect() - // CSV 파일 읽기 - const csvPath = path.join(__dirname, '../../../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 (?, ?, ?, ?, ?, ?) - `) + try { + // CSV 파일 읽기 + const csvPath = path.join(__dirname, '../../../_reference/LayerList.csv') + const csvContent = fs.readFileSync(csvPath, 'utf-8') - const insertMany = db.transaction((rows: any[]) => { - for (const row of rows) { - insert.run(row) - } - }) + // CSV 파싱 + const lines = csvContent.split('\n') - const rows = [] - - for (let i = 1; i < lines.length; i++) { - const line = lines[i].trim() - if (!line) continue + const rows: (string | number | null)[][] = [] - // CSV 파싱 (쉼표로 구분, 따옴표 처리) - const values = [] - let current = '' - let inQuotes = false + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim() + if (!line) continue - for (let j = 0; j < line.length; j++) { - const char = line[j] - - if (char === '"') { - inQuotes = !inQuotes - } else if (char === ',' && !inQuotes) { - values.push(current.trim()) - current = '' - } else { - current += char + const values: string[] = [] + let current = '' + let inQuotes = false + + for (let j = 0; j < line.length; j++) { + const char = line[j] + if (char === '"') { + inQuotes = !inQuotes + } else if (char === ',' && !inQuotes) { + 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 값 처리 - const row = values.map(v => { - if (v === 'NULL' || v === '') return null - return v.replace(/"/g, '') - }) + console.log(`${rows.length}개의 레이어 데이터 삽입 중...`) - if (row.length >= 6) { - rows.push([ - row[0], // cmn_cd - row[1], // up_cmn_cd - row[2], // cmn_cd_full_nm - row[3], // cmn_cd_nm - parseInt(row[4] || '0'), // cmn_cd_level - row[5], // clnm - ]) + await client.query('BEGIN') + + // 기존 데이터 삭제 + await client.query('DELETE FROM LAYER') + + // FK 제약 때문에 상위 레이어(낮은 레벨)부터 삽입 + const sortedRows = rows.sort((a, b) => (a[4] as number) - (b[4] as number)) + + for (const row of sortedRows) { + await client.query( + `INSERT INTO LAYER (LAYER_CD, UP_LAYER_CD, LAYER_FULL_NM, LAYER_NM, LAYER_LEVEL, WMS_LAYER_NM) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (LAYER_CD) DO UPDATE SET + UP_LAYER_CD = EXCLUDED.UP_LAYER_CD, + LAYER_FULL_NM = EXCLUDED.LAYER_FULL_NM, + LAYER_NM = EXCLUDED.LAYER_NM, + LAYER_LEVEL = EXCLUDED.LAYER_LEVEL, + WMS_LAYER_NM = EXCLUDED.WMS_LAYER_NM`, + row + ) } + + await client.query('COMMIT') + + // 결과 확인 + const { 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) +}) diff --git a/backend/src/db/wingDb.ts b/backend/src/db/wingDb.ts new file mode 100644 index 0000000..83648a7 --- /dev/null +++ b/backend/src/db/wingDb.ts @@ -0,0 +1,33 @@ +import pg from 'pg' + +const { Pool } = pg + +const wingPool = new Pool({ + host: process.env.WING_DB_HOST || 'localhost', + port: Number(process.env.WING_DB_PORT) || 5432, + database: process.env.WING_DB_NAME || 'wing', + user: process.env.WING_DB_USER || 'wing', + password: process.env.WING_DB_PASSWORD || 'Wing2026', + max: 10, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, +}) + +wingPool.on('error', (err) => { + console.error('[wingDb] 예기치 않은 연결 오류:', err.message) +}) + +export async function testWingDbConnection(): Promise { + try { + const client = await wingPool.connect() + await client.query('SELECT 1') + client.release() + console.log('[wingDb] wing 데이터베이스 연결 성공') + return true + } catch (err) { + console.warn('[wingDb] wing 데이터베이스 연결 실패:', (err as Error).message) + return false + } +} + +export { wingPool } diff --git a/backend/src/routes/layers.ts b/backend/src/routes/layers.ts index ee50daf..d5ad1cb 100755 --- a/backend/src/routes/layers.ts +++ b/backend/src/routes/layers.ts @@ -1,5 +1,5 @@ import express from 'express' -import db from '../db/database.js' +import { wingPool } from '../db/wingDb.js' import { enrichLayerWithMetadata } from '../utils/layerIcons.js' import { sanitizeParams, @@ -19,14 +19,26 @@ interface Layer { clnm: string | null } +// DB 컬럼 → API 응답 컬럼 매핑 (프론트엔드 호환성 유지) +const LAYER_COLUMNS = ` + LAYER_CD AS cmn_cd, + UP_LAYER_CD AS up_cmn_cd, + LAYER_FULL_NM AS cmn_cd_full_nm, + LAYER_NM AS cmn_cd_nm, + LAYER_LEVEL AS cmn_cd_level, + WMS_LAYER_NM AS clnm +`.trim() + // 모든 라우트에 파라미터 살균 적용 router.use(sanitizeParams) // 모든 레이어 조회 -router.get('/', (_req, res) => { +router.get('/', async (_req, res) => { try { - const layers = db.prepare('SELECT * FROM layers ORDER BY cmn_cd').all() as Layer[] - const enrichedLayers = layers.map(enrichLayerWithMetadata) + const { rows } = await wingPool.query( + `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' ORDER BY LAYER_CD` + ) + const enrichedLayers = rows.map(enrichLayerWithMetadata) res.json(enrichedLayers) } catch { res.status(500).json({ error: '레이어 조회 실패' }) @@ -34,17 +46,19 @@ router.get('/', (_req, res) => { }) // 계층 구조로 변환된 레이어 트리 조회 -router.get('/tree/all', (_req, res) => { +router.get('/tree/all', async (_req, res) => { try { - const layers = db.prepare('SELECT * FROM layers ORDER BY cmn_cd').all() as Layer[] - const enrichedLayers = layers.map(enrichLayerWithMetadata) + const { rows } = await wingPool.query( + `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' ORDER BY LAYER_CD` + ) + const enrichedLayers = rows.map(enrichLayerWithMetadata) - const layerMap = new Map() + const layerMap = new Map() enrichedLayers.forEach(layer => { layerMap.set(layer.cmn_cd, { ...layer, children: [] }) }) - const rootLayers: any[] = [] + const rootLayers: (Layer & { children: Layer[] })[] = [] enrichedLayers.forEach(layer => { const layerNode = layerMap.get(layer.cmn_cd)! if (layer.up_cmn_cd === null) { @@ -64,10 +78,12 @@ router.get('/tree/all', (_req, res) => { }) // WMS 레이어만 조회 -router.get('/wms/all', (_req, res) => { +router.get('/wms/all', async (_req, res) => { try { - const layers = db.prepare('SELECT * FROM layers WHERE clnm IS NOT NULL ORDER BY cmn_cd').all() as Layer[] - const enrichedLayers = layers.map(enrichLayerWithMetadata) + const { rows } = await wingPool.query( + `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE WMS_LAYER_NM IS NOT NULL AND USE_YN = 'Y' ORDER BY LAYER_CD` + ) + const enrichedLayers = rows.map(enrichLayerWithMetadata) res.json(enrichedLayers) } catch { res.status(500).json({ error: 'WMS 레이어 조회 실패' }) @@ -75,11 +91,10 @@ router.get('/wms/all', (_req, res) => { }) // 특정 레벨의 레이어만 조회 -router.get('/level/:level', (req, res) => { +router.get('/level/:level', async (req, res) => { try { const level = parseInt(req.params.level, 10) - // 입력 검증: 레벨은 1~10 범위의 정수 if (!isValidNumber(level, 1, 10)) { return res.status(400).json({ error: '유효하지 않은 레벨값', @@ -87,9 +102,11 @@ router.get('/level/:level', (req, res) => { }) } - // 파라미터화된 쿼리 사용 (SQL 인젝션 방지) - const layers = db.prepare('SELECT * FROM layers WHERE cmn_cd_level = ? ORDER BY cmn_cd').all(level) as Layer[] - const enrichedLayers = layers.map(enrichLayerWithMetadata) + const { rows } = await wingPool.query( + `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_LEVEL = $1 AND USE_YN = 'Y' ORDER BY LAYER_CD`, + [level] + ) + const enrichedLayers = rows.map(enrichLayerWithMetadata) res.json(enrichedLayers) } catch { res.status(500).json({ error: '레벨별 레이어 조회 실패' }) @@ -97,11 +114,10 @@ router.get('/level/:level', (req, res) => { }) // 특정 부모의 자식 레이어 조회 -router.get('/children/:parentId', (req, res) => { +router.get('/children/:parentId', async (req, res) => { try { const parentId = req.params.parentId - // 입력 검증: 코드 형식 확인 (영숫자, 언더스코어, 하이픈만 허용) if (!parentId || !isValidStringLength(parentId, 50) || !/^[a-zA-Z0-9_-]+$/.test(parentId)) { return res.status(400).json({ error: '유효하지 않은 부모 ID', @@ -110,8 +126,11 @@ router.get('/children/:parentId', (req, res) => { } const sanitizedId = sanitizeString(parentId) - const layers = db.prepare('SELECT * FROM layers WHERE up_cmn_cd = ? ORDER BY cmn_cd').all(sanitizedId) as Layer[] - const enrichedLayers = layers.map(enrichLayerWithMetadata) + const { rows } = await wingPool.query( + `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE UP_LAYER_CD = $1 AND USE_YN = 'Y' ORDER BY LAYER_CD`, + [sanitizedId] + ) + const enrichedLayers = rows.map(enrichLayerWithMetadata) res.json(enrichedLayers) } catch { res.status(500).json({ error: '자식 레이어 조회 실패' }) @@ -119,11 +138,10 @@ router.get('/children/:parentId', (req, res) => { }) // 특정 레이어 조회 -router.get('/:id', (req, res) => { +router.get('/:id', async (req, res) => { try { const id = req.params.id - // 입력 검증: ID 형식 확인 if (!id || !isValidStringLength(id, 50) || !/^[a-zA-Z0-9_-]+$/.test(id)) { return res.status(400).json({ error: '유효하지 않은 레이어 ID', @@ -132,11 +150,14 @@ router.get('/:id', (req, res) => { } const sanitizedId = sanitizeString(id) - const layer = db.prepare('SELECT * FROM layers WHERE cmn_cd = ?').get(sanitizedId) as Layer | undefined - if (!layer) { + const { rows } = await wingPool.query( + `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_CD = $1`, + [sanitizedId] + ) + if (rows.length === 0) { return res.status(404).json({ error: '레이어를 찾을 수 없습니다' }) } - const enrichedLayer = enrichLayerWithMetadata(layer) + const enrichedLayer = enrichLayerWithMetadata(rows[0]) res.json(enrichedLayer) } catch { res.status(500).json({ error: '레이어 조회 실패' }) diff --git a/backend/src/server.ts b/backend/src/server.ts index 8d312c9..d46dbdc 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -4,8 +4,8 @@ import cors from 'cors' import helmet from 'helmet' import rateLimit from 'express-rate-limit' import cookieParser from 'cookie-parser' -import { initDatabase } from './db/database.js' import { testAuthDbConnection } from './db/authDb.js' +import { testWingDbConnection } from './db/wingDb.js' import layersRouter from './routes/layers.js' import simulationRouter from './routes/simulation.js' import authRouter from './auth/authRouter.js' @@ -113,11 +113,6 @@ app.use(express.urlencoded({ extended: false, limit: BODY_SIZE_LIMIT })) app.use(sanitizeBody) app.use(sanitizeQuery) -// ============================================================ -// 데이터베이스 초기화 -// ============================================================ -initDatabase() - // ============================================================ // 라우트 // ============================================================ @@ -176,6 +171,11 @@ app.use((err: Error, _req: express.Request, res: express.Response, _next: expres // ============================================================ app.listen(PORT, async () => { console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`) + + // wing DB (운영 데이터) 연결 확인 + await testWingDbConnection() + + // wing_auth DB (인증 데이터) 연결 확인 const connected = await testAuthDbConnection() if (connected) { // SETTING_VAL VARCHAR(500) → TEXT 마이그레이션 (메뉴 설정 JSON 확장 대응) diff --git a/database/migration/001_layer_table.sql b/database/migration/001_layer_table.sql new file mode 100644 index 0000000..1d43b13 --- /dev/null +++ b/database/migration/001_layer_table.sql @@ -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); diff --git a/docs/COMMON-GUIDE.md b/docs/COMMON-GUIDE.md index e4f2b25..b77b5a9 100644 --- a/docs/COMMON-GUIDE.md +++ b/docs/COMMON-GUIDE.md @@ -291,14 +291,13 @@ const mutation = useMutation({ ### DB 접근 ```typescript -// PostgreSQL (인증 DB) -import { authPool } from '../db/authDb.js' -const result = await authPool.query('SELECT * FROM TABLE WHERE id = $1', [id]) +// PostgreSQL — wing DB (운영 데이터: 레이어, 사고, 예측 등) +import { wingPool } from '../db/wingDb.js' +const result = await wingPool.query('SELECT * FROM LAYER WHERE LAYER_CD = $1', [id]) -// SQLite (레이어 DB) -import { getDb } from '../db/database.js' -const db = getDb() -const rows = db.prepare('SELECT * FROM table').all() +// PostgreSQL — wing_auth DB (인증 데이터: 사용자, 역할, 권한 등) +import { authPool } from '../db/authDb.js' +const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1', [id]) ``` --- diff --git a/docs/README.md b/docs/README.md index f077b30..1606965 100755 --- a/docs/README.md +++ b/docs/README.md @@ -34,12 +34,12 @@ claude | 영역 | 기술 | |------|------| | Frontend | React 19, Vite 7, TypeScript 5.9, Tailwind CSS 3 | -| Backend | Express 4, TypeScript, better-sqlite3 (레이어), pg (인증) | +| Backend | Express 4, TypeScript, PostgreSQL (pg) | | 상태 관리 | Zustand (클라이언트), TanStack Query (서버) | | 지도 | Leaflet, OpenLayers | | 실시간 | Socket.IO | | 인증 | JWT (HttpOnly Cookie), Google OAuth | -| DB | PostgreSQL 16 + PostGIS (운영 DB 직접 연결), SQLite | +| DB | PostgreSQL 16 + PostGIS (wing + wing_auth) | | CI/CD | Gitea Actions | --- @@ -73,7 +73,7 @@ wing/ │ ├── audit/ 감사 로그 │ ├── routes/ 레이어, 시뮬레이션 │ ├── middleware/ 보안 (입력 살균, rate-limit) -│ └── db/ DB 연결 (PostgreSQL, SQLite) +│ └── db/ DB 연결 (PostgreSQL: wing, wing_auth) ├── database/ SQL 초기화 스크립트 ├── docs/ 개발 문서 ├── .claude/ 팀 워크플로우 (rules, skills, scripts) -- 2.45.2 From 63645e9f855e162a9a10b610404d8ddc3e8e9837 Mon Sep 17 00:00:00 2001 From: htlee Date: Sat, 28 Feb 2026 14:52:46 +0900 Subject: [PATCH 4/8] =?UTF-8?q?refactor(phase4):=20HNS=20=EB=AC=BC?= =?UTF-8?q?=EC=A7=88=EC=A0=95=EB=B3=B4=20DB=20=EC=9D=B4=EC=A0=84=20+=20?= =?UTF-8?q?=EC=A0=95=EC=A0=81=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HNS_SUBSTANCE 테이블 마이그레이션 SQL 추가 (002_hns_substance.sql) - HNS 검색/상세 API 구현 (hnsRouter, hnsService) - HNS 시드 스크립트 추가 (seedHns.ts, 20종 물질 데이터) - 프론트엔드 HNSSubstanceView: 정적 HNS_SEARCH_DB → API 호출 전환 - HNSSearchSubstance 타입 common/types/hns.ts로 분리 - Mock 데이터 이동: data/ → common/mock/ (vesselMockData, backtrackMockData) - layerDatabase.ts → common/services/layerService.ts 이동 - layerData.ts → common/data/layerData.ts 이동 - scat/index.ts 누락 수정 + .gitignore scat 규칙 수정 Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 +- backend/src/db/seedHns.ts | 63 ++++++++++ backend/src/hns/hnsRouter.ts | 53 +++++++++ backend/src/hns/hnsService.ts | 110 ++++++++++++++++++ backend/src/server.ts | 2 + backend/tsconfig.json | 2 +- database/migration/002_hns_substance.sql | 46 ++++++++ .../src/common/components/layer/LayerTree.tsx | 2 +- .../src/common/components/map/MapView.tsx | 2 +- frontend/src/{ => common}/data/layerData.ts | 0 frontend/src/common/hooks/useLayers.ts | 2 +- .../mock}/backtrackMockData.ts | 0 .../{data => common/mock}/vesselMockData.ts | 0 .../services/layerService.ts} | 0 frontend/src/common/types/hns.ts | 67 +++++++++++ .../tabs/hns/components/HNSSubstanceView.tsx | 89 +++++++++----- .../incidents/components/IncidentsView.tsx | 2 +- .../tabs/prediction/components/LeftPanel.tsx | 6 +- .../prediction/components/OilSpillView.tsx | 2 +- frontend/src/tabs/scat/index.ts | 1 + 20 files changed, 415 insertions(+), 36 deletions(-) create mode 100644 backend/src/db/seedHns.ts create mode 100644 backend/src/hns/hnsRouter.ts create mode 100644 backend/src/hns/hnsService.ts create mode 100644 database/migration/002_hns_substance.sql rename frontend/src/{ => common}/data/layerData.ts (100%) rename frontend/src/{data => common/mock}/backtrackMockData.ts (100%) rename frontend/src/{data => common/mock}/vesselMockData.ts (100%) rename frontend/src/{data/layerDatabase.ts => common/services/layerService.ts} (100%) create mode 100644 frontend/src/common/types/hns.ts create mode 100644 frontend/src/tabs/scat/index.ts diff --git a/.gitignore b/.gitignore index 43468a6..a3652b2 100755 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,7 @@ backend/data/*.db-wal # Large reference data (keep locally, do not commit) _reference/ -scat/ +/scat/ 참고용/ 논문/ 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 d46dbdc..3fbc79e 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -14,6 +14,7 @@ import roleRouter from './roles/roleRouter.js' import settingsRouter from './settings/settingsRouter.js' import menuRouter from './menus/menuRouter.js' import auditRouter from './audit/auditRouter.js' +import hnsRouter from './hns/hnsRouter.js' import { sanitizeBody, sanitizeQuery, @@ -137,6 +138,7 @@ app.use('/api/audit', auditRouter) // API 라우트 — 업무 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/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/LeftPanel.tsx b/frontend/src/tabs/prediction/components/LeftPanel.tsx index a3540a3..a915a45 100755 --- a/frontend/src/tabs/prediction/components/LeftPanel.tsx +++ b/frontend/src/tabs/prediction/components/LeftPanel.tsx @@ -1,9 +1,9 @@ import { useState, useMemo } from 'react' import { LayerTree } from '@common/components/layer/LayerTree' import { useLayerTree } from '@common/hooks/useLayers' -import { layerData } from '../../../data/layerData' -import type { LayerNode } from '../../../data/layerData' -import type { Layer } from '../../../data/layerDatabase' +import { layerData } from '@common/data/layerData' +import type { LayerNode } from '@common/data/layerData' +import type { Layer } from '@common/services/layerService' import { decimalToDMS } from '@common/utils/coordinates' import { ComboBox } from '@common/components/ui/ComboBox' import { ALL_MODELS } from './OilSpillView' 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 diff --git a/frontend/src/tabs/scat/index.ts b/frontend/src/tabs/scat/index.ts new file mode 100644 index 0000000..b238189 --- /dev/null +++ b/frontend/src/tabs/scat/index.ts @@ -0,0 +1 @@ +export { PreScatView } from './components/PreScatView' -- 2.45.2 From 54aeb2f124ab99ccbe949d58798e69fd8d5e5338 Mon Sep 17 00:00:00 2001 From: htlee Date: Sat, 28 Feb 2026 15:24:29 +0900 Subject: [PATCH 5/8] =?UTF-8?q?docs:=20Phase=201~4=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81=20=EB=B0=98=EC=98=81=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=B5=9C=EC=8B=A0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md: 프로젝트 구조 (common/, tabs/, path alias), DB 스택, 지도 라이브러리 - README.md: 프로젝트 구조 트리, 기술 스택 테이블 (OpenLayers·SQLite 제거) - docs/README.md: 기술 스택, 프로젝트 구조 트리 - docs/MENU-TAB-GUIDE.md: 새 탭 추가 경로 (tabs/ + @common/ alias) - App.tsx: audit/log sendBeacon URL 수정 (Phase 4 버그 수정 포함) Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 48 ++++++++++++++++++++++++++++++------------ README.md | 40 ++++++++++++++++++++++------------- docs/MENU-TAB-GUIDE.md | 26 +++++++++++++++-------- docs/README.md | 36 +++++++++++++++++-------------- frontend/src/App.tsx | 3 ++- 5 files changed, 98 insertions(+), 55 deletions(-) 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/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 bc5b47b..7059d68 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -46,7 +46,8 @@ function App() { [JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })], { type: 'text/plain' } ) - navigator.sendBeacon('/api/audit/log', blob) + const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:3001/api' + navigator.sendBeacon(`${apiBase}/audit/log`, blob) }, [activeMainTab, isAuthenticated]) // 세션 확인 중 스플래시 -- 2.45.2 From c727afd1baca62ceeced6a19892cecfdc2c56f6b Mon Sep 17 00:00:00 2001 From: htlee Date: Sat, 28 Feb 2026 16:19:22 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor(frontend):=20=EB=8C=80=ED=98=95=20?= =?UTF-8?q?View=20=EC=84=9C=EB=B8=8C=ED=83=AD=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EB=B6=84=ED=95=A0=20+=20FEATURE=5FID=20=EC=B2=B4=EA=B3=84=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6개 대형 View(AerialView, AssetsView, ReportsView, PreScatView, AdminView, LeftPanel)를 서브탭 단위로 분할하여 모듈 경계를 명확히 함. - AerialView (2,526줄 → 8파일): MediaManagement, OilAreaAnalysis, RealtimeDrone 등 - AssetsView (2,047줄 → 8파일): AssetManagement, AssetMap, ShipInsurance 등 - ReportsView (1,596줄 → 5파일): TemplateFormEditor, ReportGenerator 등 - PreScatView (1,390줄 → 7파일): ScatLeftPanel, ScatMap, ScatPopup 등 - AdminView (1,306줄 → 7파일): UsersPanel, PermissionsPanel, MenusPanel 등 - LeftPanel (1,237줄 → 5파일): PredictionInputSection, InfoLayerSection, OilBoomSection 등 FEATURE_ID 레지스트리(common/constants/featureIds.ts) 및 감사로그 서브탭 추적 훅(useFeatureTracking) 추가. .gitignore의 scat/ → /scat/ 수정 (scat 탭 파일 추적 누락 수정) Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 +- frontend/src/common/constants/featureIds.ts | 72 + .../src/common/hooks/useFeatureTracking.ts | 22 + .../src/tabs/admin/components/AdminView.tsx | 1293 +-------- .../src/tabs/admin/components/MenusPanel.tsx | 198 ++ .../admin/components/PermissionsPanel.tsx | 330 +++ .../tabs/admin/components/SettingsPanel.tsx | 237 ++ .../admin/components/SortableMenuItem.tsx | 161 ++ .../src/tabs/admin/components/UsersPanel.tsx | 350 +++ .../tabs/admin/components/adminConstants.ts | 36 + .../src/tabs/aerial/components/AerialView.tsx | 2498 +---------------- .../src/tabs/aerial/components/CctvView.tsx | 343 +++ .../aerial/components/MediaManagement.tsx | 335 +++ .../aerial/components/OilAreaAnalysis.tsx | 212 ++ .../tabs/aerial/components/RealtimeDrone.tsx | 252 ++ .../aerial/components/SatelliteRequest.tsx | 787 ++++++ .../tabs/aerial/components/SensorAnalysis.tsx | 497 ++++ .../assets/components/AssetManagement.tsx | 332 +++ .../src/tabs/assets/components/AssetMap.tsx | 161 ++ .../tabs/assets/components/AssetTheory.tsx | 255 ++ .../tabs/assets/components/AssetUpload.tsx | 124 + .../src/tabs/assets/components/AssetsView.tsx | 2014 +------------ .../tabs/assets/components/ShipInsurance.tsx | 319 +++ .../tabs/assets/components/assetMockData.ts | 755 +++++ .../src/tabs/assets/components/assetTypes.ts | 65 + .../components/InfoLayerSection.tsx | 196 ++ .../tabs/prediction/components/LeftPanel.tsx | 1149 +------- .../prediction/components/OilBoomSection.tsx | 548 ++++ .../components/PredictionInputSection.tsx | 426 +++ .../prediction/components/leftPanelTypes.ts | 49 + .../reports/components/ReportGenerator.tsx | 532 ++++ .../tabs/reports/components/ReportsView.tsx | 1233 +------- .../reports/components/TemplateFormEditor.tsx | 301 ++ .../tabs/reports/components/reportTypes.ts | 331 +++ .../tabs/reports/components/reportUtils.ts | 89 + .../src/tabs/scat/components/PreScatView.tsx | 1316 +-------- .../tabs/scat/components/ScatLeftPanel.tsx | 155 + frontend/src/tabs/scat/components/ScatMap.tsx | 276 ++ .../src/tabs/scat/components/ScatPopup.tsx | 326 +++ .../src/tabs/scat/components/ScatTimeline.tsx | 144 + .../src/tabs/scat/components/scatConstants.ts | 387 +++ .../src/tabs/scat/components/scatTypes.ts | 37 + frontend/src/tabs/scat/index.ts | 1 + 43 files changed, 9743 insertions(+), 9403 deletions(-) create mode 100644 frontend/src/common/constants/featureIds.ts create mode 100644 frontend/src/common/hooks/useFeatureTracking.ts create mode 100644 frontend/src/tabs/admin/components/MenusPanel.tsx create mode 100644 frontend/src/tabs/admin/components/PermissionsPanel.tsx create mode 100644 frontend/src/tabs/admin/components/SettingsPanel.tsx create mode 100644 frontend/src/tabs/admin/components/SortableMenuItem.tsx create mode 100644 frontend/src/tabs/admin/components/UsersPanel.tsx create mode 100644 frontend/src/tabs/admin/components/adminConstants.ts create mode 100644 frontend/src/tabs/aerial/components/CctvView.tsx create mode 100644 frontend/src/tabs/aerial/components/MediaManagement.tsx create mode 100644 frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx create mode 100644 frontend/src/tabs/aerial/components/RealtimeDrone.tsx create mode 100644 frontend/src/tabs/aerial/components/SatelliteRequest.tsx create mode 100644 frontend/src/tabs/aerial/components/SensorAnalysis.tsx create mode 100644 frontend/src/tabs/assets/components/AssetManagement.tsx create mode 100644 frontend/src/tabs/assets/components/AssetMap.tsx create mode 100644 frontend/src/tabs/assets/components/AssetTheory.tsx create mode 100644 frontend/src/tabs/assets/components/AssetUpload.tsx create mode 100644 frontend/src/tabs/assets/components/ShipInsurance.tsx create mode 100644 frontend/src/tabs/assets/components/assetMockData.ts create mode 100644 frontend/src/tabs/assets/components/assetTypes.ts create mode 100644 frontend/src/tabs/prediction/components/InfoLayerSection.tsx create mode 100644 frontend/src/tabs/prediction/components/OilBoomSection.tsx create mode 100644 frontend/src/tabs/prediction/components/PredictionInputSection.tsx create mode 100644 frontend/src/tabs/prediction/components/leftPanelTypes.ts create mode 100644 frontend/src/tabs/reports/components/ReportGenerator.tsx create mode 100644 frontend/src/tabs/reports/components/TemplateFormEditor.tsx create mode 100644 frontend/src/tabs/reports/components/reportTypes.ts create mode 100644 frontend/src/tabs/reports/components/reportUtils.ts create mode 100644 frontend/src/tabs/scat/components/ScatLeftPanel.tsx create mode 100644 frontend/src/tabs/scat/components/ScatMap.tsx create mode 100644 frontend/src/tabs/scat/components/ScatPopup.tsx create mode 100644 frontend/src/tabs/scat/components/ScatTimeline.tsx create mode 100644 frontend/src/tabs/scat/components/scatConstants.ts create mode 100644 frontend/src/tabs/scat/components/scatTypes.ts create mode 100644 frontend/src/tabs/scat/index.ts diff --git a/.gitignore b/.gitignore index 43468a6..a3652b2 100755 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,7 @@ backend/data/*.db-wal # Large reference data (keep locally, do not commit) _reference/ -scat/ +/scat/ 참고용/ 논문/ diff --git a/frontend/src/common/constants/featureIds.ts b/frontend/src/common/constants/featureIds.ts new file mode 100644 index 0000000..9fee631 --- /dev/null +++ b/frontend/src/common/constants/featureIds.ts @@ -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; diff --git a/frontend/src/common/hooks/useFeatureTracking.ts b/frontend/src/common/hooks/useFeatureTracking.ts new file mode 100644 index 0000000..b12b8a9 --- /dev/null +++ b/frontend/src/common/hooks/useFeatureTracking.ts @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; +import { useAuthStore } from '@common/store/authStore'; +import type { FeatureId } from '@common/constants/featureIds'; + +/** + * 서브탭 진입 시 감사 로그를 기록하는 훅. + * App.tsx의 탭 레벨 TAB_VIEW와 함께, 서브탭 레벨 SUBTAB_VIEW를 기록한다. + * + * @param featureId - FEATURE_ID (예: 'aerial:media', 'admin:users') + */ +export function useFeatureTracking(featureId: FeatureId) { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + + useEffect(() => { + if (!isAuthenticated) return; + const blob = new Blob( + [JSON.stringify({ action: 'SUBTAB_VIEW', detail: featureId })], + { type: 'text/plain' }, + ); + navigator.sendBeacon('/api/audit/log', blob); + }, [featureId, isAuthenticated]); +} diff --git a/frontend/src/tabs/admin/components/AdminView.tsx b/frontend/src/tabs/admin/components/AdminView.tsx index 06b0f67..3633998 100755 --- a/frontend/src/tabs/admin/components/AdminView.tsx +++ b/frontend/src/tabs/admin/components/AdminView.tsx @@ -1,1293 +1,8 @@ -import { useState, useEffect, useCallback, useRef } from 'react' import { useSubMenu } from '@common/hooks/useSubMenu' -import data from '@emoji-mart/data' -import EmojiPicker from '@emoji-mart/react' -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - DragOverlay, - type DragEndEvent, -} from '@dnd-kit/core' -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable' -import { CSS } from '@dnd-kit/utilities' -import { - fetchUsers, - fetchRoles, - updatePermissionsApi, - updateUserApi, - updateRoleDefaultApi, - approveUserApi, - rejectUserApi, - assignRolesApi, - createRoleApi, - updateRoleApi, - deleteRoleApi, - fetchRegistrationSettings, - updateRegistrationSettingsApi, - fetchOAuthSettings, - updateOAuthSettingsApi, - fetchMenuConfig, - updateMenuConfigApi, - type UserListItem, - type RoleWithPermissions, - type RegistrationSettings, - type OAuthSettings, - type MenuConfigItem, -} from '@common/services/authApi' -import { useMenuStore } from '@common/store/menuStore' - -const DEFAULT_ROLE_COLORS: Record = { - ADMIN: 'var(--red)', - MANAGER: 'var(--orange)', - USER: 'var(--cyan)', - VIEWER: 'var(--t3)', -} - -const CUSTOM_ROLE_COLORS = [ - '#a78bfa', '#34d399', '#f472b6', '#fbbf24', '#60a5fa', '#2dd4bf', -] - -function getRoleColor(code: string, index: number): string { - return DEFAULT_ROLE_COLORS[code] || CUSTOM_ROLE_COLORS[index % CUSTOM_ROLE_COLORS.length] -} - -const statusLabels: Record = { - 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' }, -} - - -// ─── 사용자 관리 패널 ───────────────────────────────────────── -function UsersPanel() { - const [searchTerm, setSearchTerm] = useState('') - const [statusFilter, setStatusFilter] = useState('') - const [users, setUsers] = useState([]) - const [loading, setLoading] = useState(true) - const [allRoles, setAllRoles] = useState([]) - const [roleEditUserId, setRoleEditUserId] = useState(null) - const [selectedRoleSns, setSelectedRoleSns] = useState([]) - const roleDropdownRef = useRef(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 ( -
-
-
-
-

사용자 관리

-

총 {users.length}명

-
- {pendingCount > 0 && ( - - 승인대기 {pendingCount}명 - - )} -
-
- - 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" - /> - -
-
- -
- {loading ? ( -
불러오는 중...
- ) : ( - - - - - - - - - - - - - - - {users.map((user) => { - const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE - return ( - - - - - - - - - - - ) - })} - -
이름계정소속역할인증상태최근 로그인관리
{user.name}{user.account}{user.orgAbbr || '-'} -
-
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 ( - - {roleName} - - ) - }) : ( - 역할 없음 - )} - - - -
- {roleEditUserId === user.id && ( -
-
역할 선택
- {allRoles.map((role, idx) => { - const color = getRoleColor(role.code, idx) - return ( - - ) - })} -
- - -
-
- )} -
-
- {user.oauthProvider ? ( - - - Google - - ) : ( - - - ID/PW - - )} - - - - {statusInfo.label} - - {formatDate(user.lastLogin)} -
- {user.status === 'PENDING' && ( - <> - - - - )} - {user.status === 'LOCKED' && ( - - )} - {user.status === 'ACTIVE' && ( - - )} - {(user.status === 'INACTIVE' || user.status === 'REJECTED') && ( - - )} -
-
- )} -
-
- ) -} - -// ─── 권한 관리 패널 ───────────────────────────────────────── -const PERM_RESOURCES = [ - { id: 'prediction', label: '유출유 확산예측', desc: '확산 예측 실행 및 결과 조회' }, - { id: 'hns', label: 'HNS·대기확산', desc: '대기확산 분석 실행 및 조회' }, - { id: 'rescue', label: '긴급구난', desc: '구난 예측 실행 및 조회' }, - { id: 'reports', label: '보고자료', desc: '보고자료 생성 및 관리' }, - { id: 'aerial', label: '항공탐색', desc: '항공탐색 계획 및 결과 조회' }, - { id: 'assets', label: '방제자산 관리', desc: '방제자산 등록 및 관리' }, - { id: 'scat', label: '해안평가', desc: '해안 SCAT 조사 접근' }, - { id: 'incidents', label: '사고조회', desc: '사고 정보 등록 및 조회' }, - { id: 'board', label: '게시판', desc: '게시판 접근' }, - { id: 'weather', label: '기상정보', desc: '기상 정보 조회' }, - { id: 'admin', label: '관리자 설정', desc: '시스템 관리 기능 접근' }, -] - -function PermissionsPanel() { - const [roles, setRoles] = useState([]) - 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(null) - const [editRoleName, setEditRoleName] = useState('') - - useEffect(() => { - loadRoles() - }, []) - - const loadRoles = async () => { - setLoading(true) - try { - const data = await fetchRoles() - setRoles(data) - setDirty(false) - } catch (err) { - console.error('역할 목록 조회 실패:', err) - } finally { - setLoading(false) - } - } - - const getPermGranted = (roleSn: number, resourceCode: string): boolean => { - const role = roles.find(r => r.sn === roleSn) - if (!role) return false - const perm = role.permissions.find(p => p.resourceCode === resourceCode) - return perm?.granted ?? false - } - - const togglePerm = (roleSn: number, resourceCode: string) => { - setRoles(prev => prev.map(role => { - if (role.sn !== roleSn) return role - const perms = role.permissions.map(p => - p.resourceCode === resourceCode ? { ...p, granted: !p.granted } : p - ) - if (!perms.find(p => p.resourceCode === resourceCode)) { - perms.push({ sn: 0, resourceCode, granted: true }) - } - return { ...role, permissions: perms } - })) - setDirty(true) - } - - 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) - } - } - - const handleSave = async () => { - setSaving(true) - try { - for (const role of roles) { - const permissions = PERM_RESOURCES.map(r => ({ - resourceCode: r.id, - granted: getPermGranted(role.sn, r.id), - })) - await updatePermissionsApi(role.sn, permissions) - } - 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 loadRoles() - 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) - await loadRoles() - } 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) - } - } - - if (loading) { - return
불러오는 중...
- } - - return ( -
-
-
-

사용자 권한 관리

-

역할별 메뉴 접근 권한을 설정합니다

-
-
- - -
-
- -
- - - - - {roles.map((role, idx) => { - const color = getRoleColor(role.code, idx) - return ( - - ) - })} - - - - {PERM_RESOURCES.map((perm) => ( - - - {roles.map(role => ( - - ))} - - ))} - -
기능 -
- {editingRoleSn === role.sn ? ( - setEditRoleName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleSaveRoleName(role.sn) - if (e.key === 'Escape') setEditingRoleSn(null) - }} - onBlur={() => handleSaveRoleName(role.sn)} - autoFocus - className="w-20 px-1 py-0.5 text-[11px] font-semibold bg-bg-2 border border-primary-cyan rounded text-center text-text-1 focus:outline-none font-korean" - /> - ) : ( - handleStartEditName(role)} - title="클릭하여 이름 수정" - > - {role.name} - - )} - {role.code !== 'ADMIN' && ( - - )} -
-
{role.code}
- -
-
{perm.label}
-
{perm.desc}
-
- -
-
- - {/* 역할 생성 모달 */} - {showCreateForm && ( -
-
-
-

새 역할 추가

-
-
-
- - 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" - /> -

영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)

-
-
- - 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" - /> -
-
- - 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" - /> -
- {createError && ( -
- {createError} -
- )} -
-
- - -
-
-
- )} -
- ) -} - -// ─── 메뉴 항목 (Sortable) ──────────────────────────────────── -interface SortableMenuItemProps { - menu: MenuConfigItem - idx: number - totalCount: number - isEditing: boolean - emojiPickerId: string | null - emojiPickerRef: React.RefObject - 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 ( -
-
- - {idx + 1} - {isEditing ? ( - <> -
- - {emojiPickerId === menu.id && ( -
- -
- )} -
-
- 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" - /> -
{menu.id}
-
- - - ) : ( - <> - {menu.icon} -
-
- {menu.label} -
-
{menu.id}
-
- - - )} -
-
- -
- - -
-
-
- ) -} - -// ─── 메뉴 관리 패널 ───────────────────────────────────────── -function MenusPanel() { - const [menus, setMenus] = useState([]) - const [originalMenus, setOriginalMenus] = useState([]) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [editingId, setEditingId] = useState(null) - const [emojiPickerId, setEmojiPickerId] = useState(null) - const [activeId, setActiveId] = useState(null) - const emojiPickerRef = useRef(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 ( -
-
메뉴 설정을 불러오는 중...
-
- ) - } - - const activeMenu = activeId ? menus.find(m => m.id === activeId) : null - - return ( -
-
-
-

메뉴 관리

-

메뉴 표시 여부, 순서, 라벨, 아이콘을 관리합니다

-
- -
- -
- setActiveId(event.active.id as string)} - onDragEnd={handleDragEnd} - > - m.id)} strategy={verticalListSortingStrategy}> -
- {menus.map((menu, idx) => ( - { setEditingId(null); setEmojiPickerId(null) }} - onEmojiPickerToggle={setEmojiPickerId} - onLabelChange={(id, value) => updateMenuField(id, 'label', value)} - onEmojiSelect={handleEmojiSelect} - /> - ))} -
-
- - {activeMenu ? ( -
- - {activeMenu.icon} - {activeMenu.label} -
- ) : null} -
-
-
-
- ) -} - -// ─── 시스템 설정 패널 ──────────────────────────────────────── -function SettingsPanel() { - const [settings, setSettings] = useState(null) - const [oauthSettings, setOauthSettings] = useState(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
불러오는 중...
- } - - return ( -
-
-

시스템 설정

-

사용자 등록 및 권한 관련 시스템 설정을 관리합니다

-
- -
-
- {/* 사용자 등록 설정 */} -
-
-

사용자 등록 설정

-

신규 사용자 등록 시 적용되는 정책을 설정합니다

-
- -
- {/* 자동 승인 */} -
-
-
자동 승인
-

- 활성화하면 신규 사용자가 등록 즉시 ACTIVE 상태가 됩니다. - 비활성화하면 관리자 승인 전까지 PENDING 상태로 대기합니다. -

-
- -
- - {/* 기본 역할 자동 할당 */} -
-
-
기본 역할 자동 할당
-

- 활성화하면 신규 사용자에게 기본 역할이 자동으로 할당됩니다. - 기본 역할은 권한 관리 탭에서 설정할 수 있습니다. -

-
- -
-
-
- - {/* OAuth 설정 */} -
-
-

Google OAuth 설정

-

Google 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다

-
-
-
-
자동 승인 도메인
-

- 지정된 도메인의 Google 계정은 가입 즉시 ACTIVE 상태가 됩니다. - 미지정 도메인은 PENDING 상태로 관리자 승인이 필요합니다. - 여러 도메인은 쉼표(,)로 구분합니다. -

-
- 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" - /> - -
-
- {oauthSettings?.autoApproveDomains && ( -
- {oauthSettings.autoApproveDomains.split(',').map(d => d.trim()).filter(Boolean).map(domain => ( - - @{domain} - - ))} -
- )} -
-
- - {/* 현재 설정 상태 요약 */} -
-
-

설정 상태 요약

-
-
-
-
- - - 신규 사용자 등록 시{' '} - {settings?.autoApprove ? ( - 즉시 활성화 - ) : ( - 관리자 승인 필요 - )} - -
-
- - - 기본 역할 자동 할당{' '} - {settings?.defaultRole ? ( - 활성 - ) : ( - 비활성 - )} - -
-
- - - Google OAuth 자동 승인 도메인{' '} - {oauthSettings?.autoApproveDomains ? ( - {oauthSettings.autoApproveDomains} - ) : ( - 미설정 - )} - -
-
-
-
-
-
-
- ) -} +import UsersPanel from './UsersPanel' +import PermissionsPanel from './PermissionsPanel' +import MenusPanel from './MenusPanel' +import SettingsPanel from './SettingsPanel' // ─── AdminView ──────────────────────────────────────────── export function AdminView() { diff --git a/frontend/src/tabs/admin/components/MenusPanel.tsx b/frontend/src/tabs/admin/components/MenusPanel.tsx new file mode 100644 index 0000000..80a4ffd --- /dev/null +++ b/frontend/src/tabs/admin/components/MenusPanel.tsx @@ -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([]) + const [originalMenus, setOriginalMenus] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [editingId, setEditingId] = useState(null) + const [emojiPickerId, setEmojiPickerId] = useState(null) + const [activeId, setActiveId] = useState(null) + const emojiPickerRef = useRef(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 ( +
+
메뉴 설정을 불러오는 중...
+
+ ) + } + + const activeMenu = activeId ? menus.find(m => m.id === activeId) : null + + return ( +
+
+
+

메뉴 관리

+

메뉴 표시 여부, 순서, 라벨, 아이콘을 관리합니다

+
+ +
+ +
+ setActiveId(event.active.id as string)} + onDragEnd={handleDragEnd} + > + m.id)} strategy={verticalListSortingStrategy}> +
+ {menus.map((menu, idx) => ( + { setEditingId(null); setEmojiPickerId(null) }} + onEmojiPickerToggle={setEmojiPickerId} + onLabelChange={(id, value) => updateMenuField(id, 'label', value)} + onEmojiSelect={handleEmojiSelect} + /> + ))} +
+
+ + {activeMenu ? ( +
+ + {activeMenu.icon} + {activeMenu.label} +
+ ) : null} +
+
+
+
+ ) +} + +export default MenusPanel diff --git a/frontend/src/tabs/admin/components/PermissionsPanel.tsx b/frontend/src/tabs/admin/components/PermissionsPanel.tsx new file mode 100644 index 0000000..01ea644 --- /dev/null +++ b/frontend/src/tabs/admin/components/PermissionsPanel.tsx @@ -0,0 +1,330 @@ +import { useState, useEffect } from 'react' +import { + fetchRoles, + updatePermissionsApi, + createRoleApi, + updateRoleApi, + deleteRoleApi, + updateRoleDefaultApi, + type RoleWithPermissions, +} from '@common/services/authApi' +import { getRoleColor, PERM_RESOURCES } from './adminConstants' + +// ─── 권한 관리 패널 ───────────────────────────────────────── +function PermissionsPanel() { + const [roles, setRoles] = useState([]) + 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(null) + const [editRoleName, setEditRoleName] = useState('') + + useEffect(() => { + loadRoles() + }, []) + + const loadRoles = async () => { + setLoading(true) + try { + const data = await fetchRoles() + setRoles(data) + setDirty(false) + } catch (err) { + console.error('역할 목록 조회 실패:', err) + } finally { + setLoading(false) + } + } + + const getPermGranted = (roleSn: number, resourceCode: string): boolean => { + const role = roles.find(r => r.sn === roleSn) + if (!role) return false + const perm = role.permissions.find(p => p.resourceCode === resourceCode) + return perm?.granted ?? false + } + + const togglePerm = (roleSn: number, resourceCode: string) => { + setRoles(prev => prev.map(role => { + if (role.sn !== roleSn) return role + const perms = role.permissions.map(p => + p.resourceCode === resourceCode ? { ...p, granted: !p.granted } : p + ) + if (!perms.find(p => p.resourceCode === resourceCode)) { + perms.push({ sn: 0, resourceCode, granted: true }) + } + return { ...role, permissions: perms } + })) + setDirty(true) + } + + 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) + } + } + + const handleSave = async () => { + setSaving(true) + try { + for (const role of roles) { + const permissions = PERM_RESOURCES.map(r => ({ + resourceCode: r.id, + granted: getPermGranted(role.sn, r.id), + })) + await updatePermissionsApi(role.sn, permissions) + } + 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 loadRoles() + 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) + await loadRoles() + } 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) + } + } + + if (loading) { + return
불러오는 중...
+ } + + return ( +
+
+
+

사용자 권한 관리

+

역할별 메뉴 접근 권한을 설정합니다

+
+
+ + +
+
+ +
+ + + + + {roles.map((role, idx) => { + const color = getRoleColor(role.code, idx) + return ( + + ) + })} + + + + {PERM_RESOURCES.map((perm) => ( + + + {roles.map(role => ( + + ))} + + ))} + +
기능 +
+ {editingRoleSn === role.sn ? ( + setEditRoleName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveRoleName(role.sn) + if (e.key === 'Escape') setEditingRoleSn(null) + }} + onBlur={() => handleSaveRoleName(role.sn)} + autoFocus + className="w-20 px-1 py-0.5 text-[11px] font-semibold bg-bg-2 border border-primary-cyan rounded text-center text-text-1 focus:outline-none font-korean" + /> + ) : ( + handleStartEditName(role)} + title="클릭하여 이름 수정" + > + {role.name} + + )} + {role.code !== 'ADMIN' && ( + + )} +
+
{role.code}
+ +
+
{perm.label}
+
{perm.desc}
+
+ +
+
+ + {/* 역할 생성 모달 */} + {showCreateForm && ( +
+
+
+

새 역할 추가

+
+
+
+ + 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" + /> +

영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)

+
+
+ + 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" + /> +
+
+ + 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" + /> +
+ {createError && ( +
+ {createError} +
+ )} +
+
+ + +
+
+
+ )} +
+ ) +} + +export default PermissionsPanel diff --git a/frontend/src/tabs/admin/components/SettingsPanel.tsx b/frontend/src/tabs/admin/components/SettingsPanel.tsx new file mode 100644 index 0000000..fd30cc9 --- /dev/null +++ b/frontend/src/tabs/admin/components/SettingsPanel.tsx @@ -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(null) + const [oauthSettings, setOauthSettings] = useState(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
불러오는 중...
+ } + + return ( +
+
+

시스템 설정

+

사용자 등록 및 권한 관련 시스템 설정을 관리합니다

+
+ +
+
+ {/* 사용자 등록 설정 */} +
+
+

사용자 등록 설정

+

신규 사용자 등록 시 적용되는 정책을 설정합니다

+
+ +
+ {/* 자동 승인 */} +
+
+
자동 승인
+

+ 활성화하면 신규 사용자가 등록 즉시 ACTIVE 상태가 됩니다. + 비활성화하면 관리자 승인 전까지 PENDING 상태로 대기합니다. +

+
+ +
+ + {/* 기본 역할 자동 할당 */} +
+
+
기본 역할 자동 할당
+

+ 활성화하면 신규 사용자에게 기본 역할이 자동으로 할당됩니다. + 기본 역할은 권한 관리 탭에서 설정할 수 있습니다. +

+
+ +
+
+
+ + {/* OAuth 설정 */} +
+
+

Google OAuth 설정

+

Google 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다

+
+
+
+
자동 승인 도메인
+

+ 지정된 도메인의 Google 계정은 가입 즉시 ACTIVE 상태가 됩니다. + 미지정 도메인은 PENDING 상태로 관리자 승인이 필요합니다. + 여러 도메인은 쉼표(,)로 구분합니다. +

+
+ 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" + /> + +
+
+ {oauthSettings?.autoApproveDomains && ( +
+ {oauthSettings.autoApproveDomains.split(',').map(d => d.trim()).filter(Boolean).map(domain => ( + + @{domain} + + ))} +
+ )} +
+
+ + {/* 현재 설정 상태 요약 */} +
+
+

설정 상태 요약

+
+
+
+
+ + + 신규 사용자 등록 시{' '} + {settings?.autoApprove ? ( + 즉시 활성화 + ) : ( + 관리자 승인 필요 + )} + +
+
+ + + 기본 역할 자동 할당{' '} + {settings?.defaultRole ? ( + 활성 + ) : ( + 비활성 + )} + +
+
+ + + Google OAuth 자동 승인 도메인{' '} + {oauthSettings?.autoApproveDomains ? ( + {oauthSettings.autoApproveDomains} + ) : ( + 미설정 + )} + +
+
+
+
+
+
+
+ ) +} + +export default SettingsPanel diff --git a/frontend/src/tabs/admin/components/SortableMenuItem.tsx b/frontend/src/tabs/admin/components/SortableMenuItem.tsx new file mode 100644 index 0000000..ed5e6f1 --- /dev/null +++ b/frontend/src/tabs/admin/components/SortableMenuItem.tsx @@ -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 + 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 ( +
+
+ + {idx + 1} + {isEditing ? ( + <> +
+ + {emojiPickerId === menu.id && ( +
+ +
+ )} +
+
+ 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" + /> +
{menu.id}
+
+ + + ) : ( + <> + {menu.icon} +
+
+ {menu.label} +
+
{menu.id}
+
+ + + )} +
+
+ +
+ + +
+
+
+ ) +} + +export default SortableMenuItem diff --git a/frontend/src/tabs/admin/components/UsersPanel.tsx b/frontend/src/tabs/admin/components/UsersPanel.tsx new file mode 100644 index 0000000..417c58b --- /dev/null +++ b/frontend/src/tabs/admin/components/UsersPanel.tsx @@ -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('') + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [allRoles, setAllRoles] = useState([]) + const [roleEditUserId, setRoleEditUserId] = useState(null) + const [selectedRoleSns, setSelectedRoleSns] = useState([]) + const roleDropdownRef = useRef(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 ( +
+
+
+
+

사용자 관리

+

총 {users.length}명

+
+ {pendingCount > 0 && ( + + 승인대기 {pendingCount}명 + + )} +
+
+ + 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" + /> + +
+
+ +
+ {loading ? ( +
불러오는 중...
+ ) : ( + + + + + + + + + + + + + + + {users.map((user) => { + const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE + return ( + + + + + + + + + + + ) + })} + +
이름계정소속역할인증상태최근 로그인관리
{user.name}{user.account}{user.orgAbbr || '-'} +
+
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 ( + + {roleName} + + ) + }) : ( + 역할 없음 + )} + + + +
+ {roleEditUserId === user.id && ( +
+
역할 선택
+ {allRoles.map((role, idx) => { + const color = getRoleColor(role.code, idx) + return ( + + ) + })} +
+ + +
+
+ )} +
+
+ {user.oauthProvider ? ( + + + Google + + ) : ( + + + ID/PW + + )} + + + + {statusInfo.label} + + {formatDate(user.lastLogin)} +
+ {user.status === 'PENDING' && ( + <> + + + + )} + {user.status === 'LOCKED' && ( + + )} + {user.status === 'ACTIVE' && ( + + )} + {(user.status === 'INACTIVE' || user.status === 'REJECTED') && ( + + )} +
+
+ )} +
+
+ ) +} + +export default UsersPanel diff --git a/frontend/src/tabs/admin/components/adminConstants.ts b/frontend/src/tabs/admin/components/adminConstants.ts new file mode 100644 index 0000000..b29f8b4 --- /dev/null +++ b/frontend/src/tabs/admin/components/adminConstants.ts @@ -0,0 +1,36 @@ +export const DEFAULT_ROLE_COLORS: Record = { + 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 = { + 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' }, +} + +export const PERM_RESOURCES = [ + { id: 'prediction', label: '유출유 확산예측', desc: '확산 예측 실행 및 결과 조회' }, + { id: 'hns', label: 'HNS·대기확산', desc: '대기확산 분석 실행 및 조회' }, + { id: 'rescue', label: '긴급구난', desc: '구난 예측 실행 및 조회' }, + { id: 'reports', label: '보고자료', desc: '보고자료 생성 및 관리' }, + { id: 'aerial', label: '항공탐색', desc: '항공탐색 계획 및 결과 조회' }, + { id: 'assets', label: '방제자산 관리', desc: '방제자산 등록 및 관리' }, + { id: 'scat', label: '해안평가', desc: '해안 SCAT 조사 접근' }, + { id: 'incidents', label: '사고조회', desc: '사고 정보 등록 및 조회' }, + { id: 'board', label: '게시판', desc: '게시판 접근' }, + { id: 'weather', label: '기상정보', desc: '기상 정보 조회' }, + { id: 'admin', label: '관리자 설정', desc: '시스템 관리 기능 접근' }, +] diff --git a/frontend/src/tabs/aerial/components/AerialView.tsx b/frontend/src/tabs/aerial/components/AerialView.tsx index 321850f..26a171c 100755 --- a/frontend/src/tabs/aerial/components/AerialView.tsx +++ b/frontend/src/tabs/aerial/components/AerialView.tsx @@ -1,2445 +1,19 @@ -import { useState, useRef, useEffect } from 'react' +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' -// ── 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' - -// ── Tab 0: 영상·사진 관리 ── - -const FilterBtn = ({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) => ( - -) - -function MediaManagementTab() { - const [selectedIds, setSelectedIds] = useState>(new Set()) - const [equipFilter, setEquipFilter] = useState('all') - const [typeFilter, setTypeFilter] = useState>(new Set()) - const [searchTerm, setSearchTerm] = useState('') - const [sortBy, setSortBy] = useState('latest') - const [showUpload, setShowUpload] = useState(false) - const modalRef = useRef(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 ( -
- {/* Filters */} -
-
- 촬영 장비: - setEquipFilter('all')} /> - setEquipFilter('drone')} /> - setEquipFilter('plane')} /> - setEquipFilter('satellite')} /> - - 유형: - toggleTypeFilter('photo')} /> - toggleTypeFilter('video')} /> -
-
- 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" - /> - -
-
- - {/* Summary Stats */} -
- {[ - { 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) => ( -
- {s.icon} -
-
{s.value}
-
{s.label}
-
-
- ))} -
- - {/* File Table */} -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {sorted.map(f => ( - 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)]' : '' - }`} - > - - - - - - - - - - - - - ))} - -
- 0} - onChange={toggleAll} - className="accent-primary-blue" - /> - - 사고명위치파일명장비유형촬영일시용량해상도📥
e.stopPropagation()}> - toggleId(f.id)} - className="accent-primary-blue" - /> - {equipIcon(f.equipType)}{f.incident}{f.location}{f.filename} - - {f.equipment} - - - - {f.mediaType === '영상' ? '🎬' : '📷'} {f.mediaType} - - {f.datetime}{f.size}{f.resolution} e.stopPropagation()}> - -
-
-
- - {/* Bottom Actions */} -
-
- 선택된 파일: {selectedIds.size}건 -
-
- - - -
-
- - {/* Upload Modal */} - {showUpload && ( -
-
-
- 📤 영상·사진 업로드 - -
-
-
📁
-
파일을 드래그하거나 클릭하여 업로드
-
JPG, TIFF, GeoTIFF, MP4, MOV 지원 · 최대 2GB
-
-
- - -
-
- - -
-
- -