diff --git a/.claude/settings.json b/.claude/settings.json index 441dc35..6c2b037 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,7 +5,30 @@ }, "permissions": { "allow": [ - "Bash(*)" + "Bash(*)", + "Bash(npm run *)", + "Bash(npm install *)", + "Bash(npm test *)", + "Bash(npx *)", + "Bash(node *)", + "Bash(git status)", + "Bash(git diff *)", + "Bash(git log *)", + "Bash(git branch *)", + "Bash(git checkout *)", + "Bash(git add *)", + "Bash(git commit *)", + "Bash(git pull *)", + "Bash(git fetch *)", + "Bash(git merge *)", + "Bash(git stash *)", + "Bash(git remote *)", + "Bash(git config *)", + "Bash(git rev-parse *)", + "Bash(git show *)", + "Bash(git tag *)", + "Bash(curl -s *)", + "Bash(fnm *)" ], "deny": [ "Bash(git push --force*)", @@ -61,5 +84,8 @@ ] } ] + }, + "enabledPlugins": { + "frontend-design@claude-plugins-official": true } -} +} \ No newline at end of file diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index 03381a9..87e0806 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,7 +1,7 @@ { "applied_global_version": "1.6.1", - "applied_date": "2026-03-31", + "applied_date": "2026-04-16", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true -} +} \ No newline at end of file diff --git a/.github/java-upgrade/hooks/scripts/recordToolUse.ps1 b/.github/java-upgrade/hooks/scripts/recordToolUse.ps1 new file mode 100644 index 0000000..2d24329 --- /dev/null +++ b/.github/java-upgrade/hooks/scripts/recordToolUse.ps1 @@ -0,0 +1,17 @@ +# Records run_in_terminal and appmod-* tool calls as JSONL for the extension to process. + +$raw = [Console]::In.ReadToEnd() + +if ($raw -notmatch '"tool_name"\s*:\s*"([^"]+)"') { exit 0 } +$toolName = $Matches[1] + +if ($toolName -ne 'run_in_terminal' -and $toolName -notlike 'appmod-*') { exit 0 } + +if ($raw -notmatch '"session_id"\s*:\s*"([^"]+)"') { exit 0 } +$sessionId = $Matches[1] + +$hooksDir = '.github\java-upgrade\hooks' +if (-not (Test-Path $hooksDir)) { New-Item -ItemType Directory -Path $hooksDir -Force | Out-Null } + +$line = ($raw -replace '[\r\n]+', ' ').Trim() + "`n" +[System.IO.File]::AppendAllText("$hooksDir\$sessionId.json", $line, [System.Text.UTF8Encoding]::new($false)) diff --git a/.github/java-upgrade/hooks/scripts/recordToolUse.sh b/.github/java-upgrade/hooks/scripts/recordToolUse.sh new file mode 100644 index 0000000..36b2043 --- /dev/null +++ b/.github/java-upgrade/hooks/scripts/recordToolUse.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Records run_in_terminal and appmod-* tool calls as JSONL for the extension to process. + +INPUT=$(cat) + +TOOL_NAME="${INPUT#*\"tool_name\":\"}" +TOOL_NAME="${TOOL_NAME%%\"*}" + +case "$TOOL_NAME" in + run_in_terminal|appmod-*) ;; + *) exit 0 ;; +esac + +case "$INPUT" in + *'"session_id":"'*) ;; + *) exit 0 ;; +esac + +SESSION_ID="${INPUT#*\"session_id\":\"}" +SESSION_ID="${SESSION_ID%%\"*}" +[ -z "$SESSION_ID" ] && exit 0 + +HOOKS_DIR=".github/java-upgrade/hooks" +mkdir -p "$HOOKS_DIR" + +LINE=$(printf '%s' "$INPUT" | tr -d '\r\n') +printf '%s\n' "$LINE" >> "$HOOKS_DIR/${SESSION_ID}.json" diff --git a/.gitignore b/.gitignore index a7b64e4..42533eb 100755 --- a/.gitignore +++ b/.gitignore @@ -102,4 +102,7 @@ frontend/public/hns-manual/images/ # mcp -.mcp.json \ No newline at end of file +.mcp.json + +# python +.venv \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 3cbe76c..eb09ccd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,7 @@ wing/ │ │ ├── types/ backtrack, boomLine, hns, navigation │ │ ├── utils/ coordinates, geo, sanitize, cn.ts │ │ └── data/ layerData.ts (UI 레이어 트리) -│ └── tabs/ 탭 단위 패키지 (@tabs/ alias) +│ └── components/ 탭 단위 패키지 (@components/ alias) │ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐) │ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산) │ ├── rescue/ 구조 시나리오 @@ -96,7 +96,7 @@ wing/ ### Path Alias - `@common/*` -> `src/common/*` (공통 모듈) -- `@tabs/*` -> `src/tabs/*` (탭 패키지) +- `@components/*` -> `src/components/*` (탭 패키지) ## 팀 컨벤션 @@ -107,6 +107,8 @@ wing/ - `naming.md` -- 네이밍 규칙 - `testing.md` -- 테스트 규칙 - `subagent-policy.md` -- 서브에이전트 활용 정책 +- `design-system.md` -- AI 에이전트 UI 디자인 시스템 규칙 (영문, 실사용) +- `design-system-ko.md` -- 디자인 시스템 규칙 (한국어 참고용) ## 개발 문서 (docs/) diff --git a/README.md b/README.md index c709935..e334e7b 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ cd backend && npm run db:seed # DB 초기 데이터 ## 프로젝트 구조 -Path Alias: `@common/*` -> `src/common/*`, `@tabs/*` -> `src/tabs/*` +Path Alias: `@common/*` -> `src/common/*`, `@components/*` -> `src/components/*` ``` wing/ @@ -95,7 +95,7 @@ wing/ │ │ ├── types/ backtrack, boomLine, hns, navigation │ │ ├── utils/ coordinates, geo, sanitize, cn.ts │ │ └── data/ layerData.ts (UI 레이어 트리) -│ └── tabs/ 탭 단위 패키지 (@tabs/ alias) +│ └── tabs/ 탭 단위 패키지 (@components/ alias) │ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐) │ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산) │ ├── rescue/ 구조 시나리오 diff --git a/backend/src/aerial/aerialRouter.ts b/backend/src/aerial/aerialRouter.ts index 7f892e7..65d5f34 100644 --- a/backend/src/aerial/aerialRouter.ts +++ b/backend/src/aerial/aerialRouter.ts @@ -1,6 +1,8 @@ import express from 'express'; +import { mkdirSync, existsSync } from 'fs'; import multer from 'multer'; import path from 'path'; +import { randomUUID } from 'crypto'; import { listMedia, createMedia, @@ -25,6 +27,29 @@ import { requireAuth, requirePermission } from '../auth/authMiddleware.js'; const router = express.Router(); const stitchUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } }); +const mediaUpload = multer({ + storage: multer.diskStorage({ + destination: (_req, _file, cb) => { + const dir = path.resolve('uploads', 'aerial'); + mkdirSync(dir, { recursive: true }); + cb(null, dir); + }, + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, `${randomUUID()}${ext}`); + }, + }), + limits: { fileSize: 2 * 1024 * 1024 * 1024 }, // 2GB + fileFilter: (_req, file, cb) => { + const allowed = /\.(jpe?g|png|tiff?|geotiff|mp4|mov)$/i; + if (allowed.test(path.extname(file.originalname))) { + cb(null, true); + } else { + cb(new Error('허용되지 않는 파일 형식입니다.')); + } + }, +}); + // ============================================================ // AERIAL_MEDIA 라우트 // ============================================================ @@ -73,6 +98,96 @@ router.post('/media', requireAuth, requirePermission('aerial', 'CREATE'), async } }); +// POST /api/aerial/media/upload — 파일 업로드 + 메타 등록 +router.post('/media/upload', requireAuth, requirePermission('aerial', 'CREATE'), mediaUpload.single('file'), async (req, res) => { + try { + const file = req.file; + if (!file) { + res.status(400).json({ error: '파일이 필요합니다.' }); + return; + } + const { equipTpCd, equipNm, mediaTpCd, acdntSn, memo } = req.body as { + equipTpCd?: string; + equipNm?: string; + mediaTpCd?: string; + acdntSn?: string; + memo?: string; + }; + + const isVideo = file.mimetype.startsWith('video/'); + const detectedMediaType = mediaTpCd ?? (isVideo ? '영상' : '사진'); + const fileSzMb = (file.size / (1024 * 1024)).toFixed(2) + ' MB'; + + const result = await createMedia({ + fileNm: file.filename, + orgnlNm: file.originalname, + filePath: file.path, + equipTpCd: equipTpCd ?? 'drone', + equipNm: equipNm ?? '기타', + mediaTpCd: detectedMediaType, + fileSz: fileSzMb, + acdntSn: acdntSn ? parseInt(acdntSn, 10) : undefined, + locDc: memo ?? undefined, + }); + + res.status(201).json(result); + } catch (err) { + console.error('[aerial] 미디어 업로드 오류:', err); + res.status(500).json({ error: '미디어 업로드 실패' }); + } +}); + +// GET /api/aerial/media/:sn/view — 원본 이미지 뷰어용 (inline 표시) +router.get('/media/:sn/view', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => { + try { + const sn = parseInt(req.params['sn'] as string, 10); + if (!isValidNumber(sn, 1, 999999)) { + res.status(400).json({ error: '유효하지 않은 미디어 번호' }); + return; + } + + const media = await getMediaBySn(sn); + if (!media) { + res.status(404).json({ error: '미디어를 찾을 수 없습니다.' }); + return; + } + + // 로컬 업로드 파일이면 직접 서빙 + if (media.filePath) { + const absPath = path.resolve(media.filePath); + if (existsSync(absPath)) { + const ext = path.extname(absPath).toLowerCase(); + const mimeMap: Record = { + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', + '.tif': 'image/tiff', '.tiff': 'image/tiff', + '.mp4': 'video/mp4', '.mov': 'video/quicktime', + }; + res.setHeader('Content-Type', mimeMap[ext] ?? 'application/octet-stream'); + res.setHeader('Content-Disposition', 'inline'); + res.setHeader('Cache-Control', 'private, max-age=300'); + res.sendFile(absPath); + return; + } + } + + const fileId = media.fileNm.substring(0, 36); + const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!UUID_PATTERN.test(fileId) || !media.equipNm) { + res.status(404).json({ error: '표시 가능한 이미지가 없습니다.' }); + return; + } + + const buffer = await fetchOriginalImage(media.equipNm, fileId); + res.setHeader('Content-Type', 'image/jpeg'); + res.setHeader('Content-Disposition', 'inline'); + res.setHeader('Cache-Control', 'private, max-age=300'); + res.send(buffer); + } catch (err) { + console.error('[aerial] 이미지 뷰어 오류:', err); + res.status(502).json({ error: '이미지 조회 실패' }); + } +}); + // GET /api/aerial/media/:sn/download — 원본 이미지 다운로드 router.get('/media/:sn/download', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => { try { diff --git a/backend/src/aerial/aerialService.ts b/backend/src/aerial/aerialService.ts index c3ec980..f8c907c 100644 --- a/backend/src/aerial/aerialService.ts +++ b/backend/src/aerial/aerialService.ts @@ -368,7 +368,7 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis // OIL INFERENCE (GPU 서버 프록시) // ============================================================ -const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001'; +const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://211.208.115.83:5001'; const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090'; const INFERENCE_TIMEOUT_MS = 10_000; diff --git a/backend/src/gsc/gscAccidentsRouter.ts b/backend/src/gsc/gscAccidentsRouter.ts new file mode 100644 index 0000000..4ba3cbb --- /dev/null +++ b/backend/src/gsc/gscAccidentsRouter.ts @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import { requireAuth } from '../auth/authMiddleware.js'; +import { listGscAccidents } from './gscAccidentsService.js'; + +const router = Router(); + +// ============================================================ +// GET /api/gsc/accidents — 외부 수집 사고 목록 (최신 20건) +// ============================================================ +router.get('/', requireAuth, async (_req, res) => { + try { + const accidents = await listGscAccidents(20); + res.json(accidents); + } catch (err) { + console.error('[gsc] 사고 목록 조회 오류:', err); + res.status(500).json({ error: '사고 목록 조회 중 오류가 발생했습니다.' }); + } +}); + +export default router; diff --git a/backend/src/gsc/gscAccidentsService.ts b/backend/src/gsc/gscAccidentsService.ts new file mode 100644 index 0000000..4365173 --- /dev/null +++ b/backend/src/gsc/gscAccidentsService.ts @@ -0,0 +1,44 @@ +import { wingPool } from '../db/wingDb.js'; + +export interface GscAccidentListItem { + acdntSn: number; + acdntMngNo: string; + pollNm: string; + pollDate: string | null; + lat: number | null; + lon: number | null; +} + +export async function listGscAccidents(limit = 20): Promise { + const sql = ` + SELECT + ACDNT_SN AS "acdntSn", + ACDNT_CD AS "acdntMngNo", + ACDNT_NM AS "pollNm", + to_char(OCCRN_DTM, 'YYYY-MM-DD"T"HH24:MI') AS "pollDate", + LAT AS "lat", + LNG AS "lon" + FROM wing.ACDNT + WHERE ACDNT_NM IS NOT NULL + ORDER BY OCCRN_DTM DESC NULLS LAST + LIMIT $1 + `; + + const result = await wingPool.query<{ + acdntSn: number; + acdntMngNo: string; + pollNm: string; + pollDate: string | null; + lat: string | null; + lon: string | null; + }>(sql, [limit]); + + return result.rows.map((row) => ({ + acdntSn: row.acdntSn, + acdntMngNo: row.acdntMngNo, + pollNm: row.pollNm, + pollDate: row.pollDate, + lat: row.lat != null ? Number(row.lat) : null, + lon: row.lon != null ? Number(row.lon) : null, + })); +} diff --git a/backend/src/hns/hnsRouter.ts b/backend/src/hns/hnsRouter.ts index 828ed7a..7985d72 100644 --- a/backend/src/hns/hnsRouter.ts +++ b/backend/src/hns/hnsRouter.ts @@ -12,11 +12,13 @@ const router = express.Router() // GET /api/hns/analyses — 분석 목록 router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (req, res) => { try { - const { status, substance, search } = req.query + const { status, substance, search, acdntSn } = req.query + const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined const items = await listAnalyses({ status: status as string | undefined, substance: substance as string | undefined, search: search as string | undefined, + acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined, }) res.json(items) } catch (err) { @@ -48,13 +50,15 @@ router.get('/analyses/:sn', requireAuth, requirePermission('hns', 'READ'), async // POST /api/hns/analyses — 분석 생성 router.post('/analyses', requireAuth, requirePermission('hns', 'CREATE'), async (req, res) => { try { - const { anlysNm, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm } = req.body + const { anlysNm, acdntSn, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm } = req.body if (!anlysNm) { res.status(400).json({ error: '분석명은 필수입니다.' }) return } + const acdntSnNum = acdntSn != null ? parseInt(String(acdntSn), 10) : undefined const result = await createAnalysis({ - anlysNm, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm, + anlysNm, acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined, + acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm, }) res.status(201).json(result) } catch (err) { diff --git a/backend/src/hns/hnsService.ts b/backend/src/hns/hnsService.ts index 10a001c..ff10c2e 100644 --- a/backend/src/hns/hnsService.ts +++ b/backend/src/hns/hnsService.ts @@ -94,6 +94,7 @@ export async function searchSubstances(params: HnsSearchParams) { interface HnsAnalysisItem { hnsAnlysSn: number + acdntSn: number | null anlysNm: string acdntDtm: string | null locNm: string | null @@ -118,11 +119,13 @@ interface ListAnalysesInput { status?: string substance?: string search?: string + acdntSn?: number } function rowToAnalysis(r: Record): HnsAnalysisItem { return { hnsAnlysSn: r.hns_anlys_sn as number, + acdntSn: (r.acdnt_sn as number) ?? null, anlysNm: r.anlys_nm as string, acdntDtm: r.acdnt_dtm as string | null, locNm: r.loc_nm as string | null, @@ -146,7 +149,7 @@ function rowToAnalysis(r: Record): HnsAnalysisItem { export async function listAnalyses(input: ListAnalysesInput): Promise { const conditions: string[] = ["USE_YN = 'Y'"] - const params: string[] = [] + const params: (string | number)[] = [] let idx = 1 if (input.status) { @@ -162,9 +165,13 @@ export async function listAnalyses(input: ListAnalysesInput): Promise { const { rows } = await wingPool.query( - `SELECT HNS_ANLYS_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT, + `SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT, SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD, WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD, EXEC_STTS_CD, RISK_CD, ANALYST_NM, @@ -194,6 +201,7 @@ export async function getAnalysis(sn: number): Promise { export async function createAnalysis(input: { anlysNm: string + acdntSn?: number acdntDtm?: string locNm?: string lon?: number @@ -213,21 +221,21 @@ export async function createAnalysis(input: { }): Promise<{ hnsAnlysSn: number }> { const { rows } = await wingPool.query( `INSERT INTO HNS_ANALYSIS ( - ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT, + ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT, GEOM, LOC_DC, SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD, WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD, ANALYST_NM, EXEC_STTS_CD ) VALUES ( - $1, $2, $3, $4::numeric, $5::numeric, - CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($4::double precision, $5::double precision), 4326) END, - CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN $4::text || ' + ' || $5::text END, - $6, $7, $8, $9, $10, $11, - $12, $13, $14, $15, $16, - $17, 'PENDING' + $1, $2, $3, $4, $5::numeric, $6::numeric, + CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($5::double precision, $6::double precision), 4326) END, + CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN $5::text || ' + ' || $6::text END, + $7, $8, $9, $10, $11, $12, + $13, $14, $15, $16, $17, + $18, 'PENDING' ) RETURNING HNS_ANLYS_SN`, [ - input.anlysNm, input.acdntDtm || null, input.locNm || null, input.lon || null, input.lat || null, + input.acdntSn || null, input.anlysNm, input.acdntDtm || null, input.locNm || null, input.lon || null, input.lat || null, input.sbstNm || null, input.spilQty || null, input.spilUnitCd || 'KL', input.fcstHr || null, input.algoCd || null, input.critMdlCd || null, input.windSpd || null, input.windDir || null, input.temp || null, input.humid || null, input.atmStblCd || null, diff --git a/backend/src/incidents/incidentsService.ts b/backend/src/incidents/incidentsService.ts index a02534b..34b1ba8 100644 --- a/backend/src/incidents/incidentsService.ts +++ b/backend/src/incidents/incidentsService.ts @@ -25,6 +25,8 @@ interface IncidentListItem { spilUnitCd: string | null; fcstHr: number | null; hasPredCompleted: boolean; + hasHnsCompleted: boolean; + hasRescueCompleted: boolean; mediaCnt: number; hasImgAnalysis: boolean; } @@ -118,6 +120,18 @@ export async function listIncidents(filters: { SELECT 1 FROM wing.PRED_EXEC pe WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED' ) AS has_pred_completed, + EXISTS ( + SELECT 1 FROM wing.HNS_ANALYSIS h + WHERE h.ACDNT_SN = a.ACDNT_SN + AND h.EXEC_STTS_CD = 'COMPLETED' + AND h.USE_YN = 'Y' + ) AS has_hns_completed, + EXISTS ( + SELECT 1 FROM wing.RESCUE_OPS r + WHERE r.ACDNT_SN = a.ACDNT_SN + AND r.STTS_CD = 'RESOLVED' + AND r.USE_YN = 'Y' + ) AS has_rescue_completed, COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0) + COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt FROM wing.ACDNT a @@ -157,6 +171,8 @@ export async function listIncidents(filters: { spilUnitCd: (r.spil_unit_cd as string) ?? null, fcstHr: (r.fcst_hr as number) ?? null, hasPredCompleted: r.has_pred_completed as boolean, + hasHnsCompleted: r.has_hns_completed as boolean, + hasRescueCompleted: r.has_rescue_completed as boolean, mediaCnt: Number(r.media_cnt), hasImgAnalysis: (r.has_img_analysis as boolean) ?? false, })); @@ -177,6 +193,18 @@ export async function getIncident(acdntSn: number): Promise { + try { + const acdntSn = parseInt(req.params.acdntSn as string, 10); + if (!isValidNumber(acdntSn, 1, 999999)) { + res.status(400).json({ error: '유효하지 않은 사고 번호' }); + return; + } + const predRunSn = req.query.predRunSn ? parseInt(req.query.predRunSn as string, 10) : undefined; + const result = await getOilSpillSummary(acdntSn, predRunSn); + if (!result) { + res.json({ primary: null, byModel: {} }); + return; + } + res.json(result); + } catch (err) { + console.error('[prediction] oil-summary 조회 오류:', err); + res.status(500).json({ error: 'oil-summary 조회 실패' }); + } +}); + // GET /api/prediction/analyses/:acdntSn/sensitive-resources — 예측 영역 내 민감자원 집계 router.get('/analyses/:acdntSn/sensitive-resources', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => { try { diff --git a/backend/src/prediction/predictionService.ts b/backend/src/prediction/predictionService.ts index e5ec6cb..fb26a14 100644 --- a/backend/src/prediction/predictionService.ts +++ b/backend/src/prediction/predictionService.ts @@ -1,6 +1,16 @@ import { wingPool } from '../db/wingDb.js'; import { runBacktrackAnalysis } from './backtrackAnalysisService.js'; +function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371; + const dLat = (lat2 - lat1) * Math.PI / 180; + const dLon = (lon2 - lon1) * Math.PI / 180; + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * + Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + interface PredictionAnalysis { acdntSn: number; acdntNm: string; @@ -812,3 +822,116 @@ export async function listBoomLines(acdntSn: number): Promise { regDtm: String(r['reg_dtm'] ?? ''), })); } + +// ── 유출유 확산 요약 (통합조회 분할 패널용) ────────────── +export interface OilSpillSummary { + model: string; + forecastDurationHr: number | null; + maxSpreadDistanceKm: number | null; + coastArrivalTimeHr: number | null; + affectedCoastlineKm: number | null; + weatheringRatePct: number | null; + remainingVolumeKl: number | null; +} + +export interface OilSpillSummaryResponse { + primary: OilSpillSummary; + byModel: Record; +} + +export async function getOilSpillSummary(acdntSn: number, predRunSn?: number): Promise { + const baseSql = ` + SELECT pe.ALGO_CD, pe.RSLT_DATA, + sd.FCST_HR, + ST_Y(a.LOC_GEOM) AS spil_lat, + ST_X(a.LOC_GEOM) AS spil_lon + FROM wing.PRED_EXEC pe + LEFT JOIN wing.SPIL_DATA sd ON sd.ACDNT_SN = pe.ACDNT_SN + LEFT JOIN wing.ACDNT a ON a.ACDNT_SN = pe.ACDNT_SN + WHERE pe.ACDNT_SN = $1 + AND pe.ALGO_CD IN ('OPENDRIFT', 'POSEIDON') + AND pe.EXEC_STTS_CD = 'COMPLETED' + AND pe.RSLT_DATA IS NOT NULL + `; + const sql = predRunSn != null + ? baseSql + ' AND pe.PRED_RUN_SN = $2 ORDER BY pe.CMPL_DTM DESC' + : baseSql + ' ORDER BY pe.CMPL_DTM DESC'; + const params = predRunSn != null ? [acdntSn, predRunSn] : [acdntSn]; + const { rows } = await wingPool.query(sql, params); + if (rows.length === 0) return null; + + const byModel: Record = {}; + + // OpenDrift 우선, 없으면 POSEIDON + const opendriftRow = (rows as Array>).find((r) => r['algo_cd'] === 'OPENDRIFT'); + const poseidonRow = (rows as Array>).find((r) => r['algo_cd'] === 'POSEIDON'); + const primaryRow = opendriftRow ?? poseidonRow ?? null; + + for (const row of rows as Array>) { + const rsltData = row['rslt_data'] as TrajectoryTimeStep[] | null; + if (!rsltData || rsltData.length === 0) continue; + + const algoCd = String(row['algo_cd'] ?? ''); + const modelName = ALGO_CD_TO_MODEL[algoCd] ?? algoCd; + const fcstHr = row['fcst_hr'] != null ? Number(row['fcst_hr']) : null; + const spilLat = row['spil_lat'] != null ? Number(row['spil_lat']) : null; + const spilLon = row['spil_lon'] != null ? Number(row['spil_lon']) : null; + const totalSteps = rsltData.length; + const lastStep = rsltData[totalSteps - 1]; + + // 최대 확산거리 — 사고 위치 또는 첫 파티클 위치를 원점으로 사용 + let maxDist: number | null = null; + const originLat = spilLat ?? rsltData[0]?.particles[0]?.lat ?? null; + const originLon = spilLon ?? rsltData[0]?.particles[0]?.lon ?? null; + if (originLat != null && originLon != null) { + let maxVal = 0; + for (const step of rsltData) { + for (const p of step.particles) { + const d = haversineKm(originLat, originLon, p.lat, p.lon); + if (d > maxVal) maxVal = d; + } + } + maxDist = maxVal; + } + + // 해안 도달 시간 (stranded===1 최초 등장 step) + let coastArrivalHr: number | null = null; + for (let i = 0; i < totalSteps; i++) { + if (rsltData[i].particles.some((p) => p.stranded === 1)) { + coastArrivalHr = fcstHr != null && totalSteps > 1 + ? parseFloat(((i / (totalSteps - 1)) * fcstHr).toFixed(1)) + : i; + break; + } + } + + // 풍화율 + const totalVol = lastStep.remaining_volume_m3 + lastStep.weathered_volume_m3 + lastStep.beached_volume_m3; + const weatheringPct = totalVol > 0 + ? parseFloat(((lastStep.weathered_volume_m3 / totalVol) * 100).toFixed(1)) + : null; + + byModel[modelName] = { + model: modelName, + forecastDurationHr: fcstHr, + maxSpreadDistanceKm: maxDist != null ? parseFloat(maxDist.toFixed(1)) : null, + coastArrivalTimeHr: coastArrivalHr, + affectedCoastlineKm: lastStep.pollution_coast_length_m != null + ? parseFloat((lastStep.pollution_coast_length_m / 1000).toFixed(1)) + : null, + weatheringRatePct: weatheringPct, + remainingVolumeKl: lastStep.remaining_volume_m3 != null + ? parseFloat(lastStep.remaining_volume_m3.toFixed(1)) + : null, + }; + } + + if (!primaryRow) return null; + const primaryAlgo = String(primaryRow['algo_cd'] ?? ''); + const primaryModel = ALGO_CD_TO_MODEL[primaryAlgo] ?? primaryAlgo; + + return { + primary: byModel[primaryModel] ?? Object.values(byModel)[0], + byModel, + }; +} diff --git a/backend/src/rescue/rescueRouter.ts b/backend/src/rescue/rescueRouter.ts index ba984d8..a61a487 100644 --- a/backend/src/rescue/rescueRouter.ts +++ b/backend/src/rescue/rescueRouter.ts @@ -10,11 +10,13 @@ const router = express.Router(); // ============================================================ router.get('/ops', requireAuth, requirePermission('rescue', 'READ'), async (req, res) => { try { - const { sttsCd, acdntTpCd, search } = req.query; + const { sttsCd, acdntTpCd, search, acdntSn } = req.query; + const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined; const items = await listOps({ sttsCd: sttsCd as string | undefined, acdntTpCd: acdntTpCd as string | undefined, search: search as string | undefined, + acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined, }); res.json(items); } catch (err) { diff --git a/backend/src/rescue/rescueService.ts b/backend/src/rescue/rescueService.ts index 7ca4cb2..19ccdcd 100644 --- a/backend/src/rescue/rescueService.ts +++ b/backend/src/rescue/rescueService.ts @@ -59,6 +59,7 @@ interface ListOpsInput { sttsCd?: string; acdntTpCd?: string; search?: string; + acdntSn?: number; } // ============================================================ @@ -82,6 +83,10 @@ export async function listOps(input?: ListOpsInput): Promise { @@ -210,6 +215,9 @@ app.use((err: Error, _req: express.Request, res: express.Response, _next: expres app.listen(PORT, async () => { console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`) + // 선박 신호 스케줄러 시작 (한국 전 해역 1분 폴링) + startVesselScheduler() + // wing DB 연결 확인 (wing + auth 스키마 통합) const connected = await testWingDbConnection() if (connected) { diff --git a/backend/src/vessels/vesselRouter.ts b/backend/src/vessels/vesselRouter.ts new file mode 100644 index 0000000..601c970 --- /dev/null +++ b/backend/src/vessels/vesselRouter.ts @@ -0,0 +1,33 @@ +import { Router } from 'express'; +import { getVesselsInBounds, getCacheStatus } from './vesselService.js'; +import type { BoundingBox } from './vesselTypes.js'; + +const vesselRouter = Router(); + +// POST /api/vessels/in-area +// 현재 뷰포트 bbox 안의 선박 목록 반환 (메모리 캐시에서 필터링) +vesselRouter.post('/in-area', (req, res) => { + const { bounds } = req.body as { bounds?: BoundingBox }; + + if ( + !bounds || + typeof bounds.minLon !== 'number' || + typeof bounds.minLat !== 'number' || + typeof bounds.maxLon !== 'number' || + typeof bounds.maxLat !== 'number' + ) { + res.status(400).json({ error: '유효한 bounds 정보가 필요합니다.' }); + return; + } + + const vessels = getVesselsInBounds(bounds); + res.json(vessels); +}); + +// GET /api/vessels/status — 캐시 상태 확인 (디버그용) +vesselRouter.get('/status', (_req, res) => { + const status = getCacheStatus(); + res.json(status); +}); + +export default vesselRouter; diff --git a/backend/src/vessels/vesselScheduler.ts b/backend/src/vessels/vesselScheduler.ts new file mode 100644 index 0000000..fbcd2c1 --- /dev/null +++ b/backend/src/vessels/vesselScheduler.ts @@ -0,0 +1,96 @@ +import { updateVesselCache } from './vesselService.js'; +import type { VesselPosition } from './vesselTypes.js'; + +const VESSEL_TRACK_API_URL = + process.env.VESSEL_TRACK_API_URL ?? 'https://guide.gc-si.dev/signal-batch'; +const POLL_INTERVAL_MS = 60_000; + +// 개별 쿠키 환경변수를 조합하여 Cookie 헤더 문자열 생성 +function buildVesselCookie(): string { + const entries: [string, string | undefined][] = [ + ['apt.uid', process.env.VESSEL_COOKIE_APT_UID], + ['g_state', process.env.VESSEL_COOKIE_G_STATE], + ['gc_proxy_auth', process.env.VESSEL_COOKIE_GC_PROXY_AUTH], + ['GC_SESSION', process.env.VESSEL_COOKIE_GC_SESSION], + // 기존 단일 쿠키 변수 폴백 (레거시 지원) + ]; + const parts = entries + .filter(([, v]) => v) + .map(([k, v]) => `${k}=${v}`); + + // 기존 VESSEL_TRACK_COOKIE 폴백 (단일 문자열로 설정된 경우) + if (parts.length === 0 && process.env.VESSEL_TRACK_COOKIE) { + return process.env.VESSEL_TRACK_COOKIE; + } + return parts.join('; '); +} + +// 한국 전 해역 고정 폴리곤 (124~132°E, 32~38°N) +const KOREA_WATERS_POLYGON = [ + [120, 31], + [132, 31], + [132, 41], + [120, 41], + [120, 31], +]; + +let intervalId: ReturnType | null = null; + +async function pollVesselSignals(): Promise { + const url = `${VESSEL_TRACK_API_URL}/api/v1/vessels/recent-positions-detail`; + const body = { + minutes: 5, + coordinates: KOREA_WATERS_POLYGON, + polygonFilter: true, + }; + + const cookie = buildVesselCookie(); + const requestHeaders: Record = { + 'Content-Type': 'application/json', + ...(cookie ? { Cookie: cookie } : {}), + }; + + try { + const res = await fetch(url, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30_000), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + console.error(`[vesselScheduler] 선박 신호 API 오류: ${res.status}`, text.substring(0, 200)); + return; + } + + const contentType = res.headers.get('content-type') ?? ''; + if (!contentType.includes('application/json')) { + const text = await res.text().catch(() => ''); + console.error('[vesselScheduler] 선박 신호 API가 JSON이 아닌 응답 반환:', text); + return; + } + + const data = (await res.json()) as VesselPosition[]; + updateVesselCache(data); + } catch (err) { + console.error('[vesselScheduler] 선박 신호 폴링 실패:', err); + } +} + +export function startVesselScheduler(): void { + if (intervalId !== null) return; + + // 서버 시작 시 즉시 1회 실행 후 주기적 폴링 + pollVesselSignals(); + intervalId = setInterval(pollVesselSignals, POLL_INTERVAL_MS); + console.log('[vesselScheduler] 선박 신호 스케줄러 시작 (1분 간격)'); +} + +export function stopVesselScheduler(): void { + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + console.log('[vesselScheduler] 선박 신호 스케줄러 중지'); + } +} diff --git a/backend/src/vessels/vesselService.ts b/backend/src/vessels/vesselService.ts new file mode 100644 index 0000000..5b2640c --- /dev/null +++ b/backend/src/vessels/vesselService.ts @@ -0,0 +1,55 @@ +import type { VesselPosition, BoundingBox } from './vesselTypes.js'; + +const VESSEL_TTL_MS = 10 * 60 * 1000; // 10분 + +const cachedVessels = new Map(); +let lastUpdated: Date | null = null; + +// lastUpdate가 TTL을 초과한 선박을 캐시에서 제거. +// lastUpdate 파싱이 불가능한 경우 보수적으로 유지한다. +function evictStale(): void { + const now = Date.now(); + for (const [mmsi, vessel] of cachedVessels) { + const ts = Date.parse(vessel.lastUpdate); + if (Number.isNaN(ts)) continue; + if (now - ts > VESSEL_TTL_MS) { + cachedVessels.delete(mmsi); + } + } +} + +export function updateVesselCache(vessels: VesselPosition[]): void { + for (const vessel of vessels) { + if (!vessel.mmsi) continue; + cachedVessels.set(vessel.mmsi, vessel); + } + evictStale(); + lastUpdated = new Date(); +} + +export function getVesselsInBounds(bounds: BoundingBox): VesselPosition[] { + const result: VesselPosition[] = []; + for (const v of cachedVessels.values()) { + if ( + v.lon >= bounds.minLon && + v.lon <= bounds.maxLon && + v.lat >= bounds.minLat && + v.lat <= bounds.maxLat + ) { + result.push(v); + } + } + return result; +} + +export function getCacheStatus(): { + count: number; + bangjeCount: number; + lastUpdated: Date | null; +} { + let bangjeCount = 0; + for (const v of cachedVessels.values()) { + if (v.shipNm && v.shipNm.toUpperCase().includes('BANGJE')) bangjeCount++; + } + return { count: cachedVessels.size, bangjeCount, lastUpdated }; +} diff --git a/backend/src/vessels/vesselTypes.ts b/backend/src/vessels/vesselTypes.ts new file mode 100644 index 0000000..8b5ed00 --- /dev/null +++ b/backend/src/vessels/vesselTypes.ts @@ -0,0 +1,26 @@ +export interface VesselPosition { + mmsi: string; + imo?: number; + lon: number; + lat: number; + sog?: number; + cog?: number; + heading?: number; + shipNm?: string; + shipTy?: string; + shipKindCode?: string; + nationalCode?: string; + lastUpdate: string; + status?: string; + destination?: string; + length?: number; + width?: number; + draught?: number; +} + +export interface BoundingBox { + minLon: number; + minLat: number; + maxLon: number; + maxLat: number; +} diff --git a/database/migration/032_sync_gsc_accidents_to_wing.sql b/database/migration/032_sync_gsc_accidents_to_wing.sql new file mode 100644 index 0000000..3e1de38 --- /dev/null +++ b/database/migration/032_sync_gsc_accidents_to_wing.sql @@ -0,0 +1,118 @@ +-- ============================================================ +-- 032: gsc.tgs_acdnt_info → wing.ACDNT 동기화 (2026-04-10 이후) +-- ------------------------------------------------------------ +-- 목적 +-- 3개 예측 탭(유출유확산예측 / HNS 대기확산 / 긴급구난)의 사고 +-- 선택 셀렉트박스에 노출되는 gsc 사고 레코드를 wing.ACDNT에 +-- 이관하여 wing 운영 로직과 동일한 사고 마스터를 공유한다. +-- +-- 필터 정책 (backend/src/gsc/gscAccidentsService.ts 의 listGscAccidents 와 동일) +-- - acdnt_asort_code IN (12개 코드) +-- - acdnt_title IS NOT NULL +-- - 좌표(tgs_acdnt_lc.la, lo) 존재 +-- - rcept_dt >= '2026-04-10' (본 이관 추가 조건) +-- +-- ACDNT_CD 생성 규칙 +-- 'INC-YYYY-NNNN' (YYYY = rcept_dt 의 연도, NNNN = 해당 연도 내 순번 4자리) +-- 기존 wing.ACDNT 에 이미 부여된 'INC-YYYY-NNNN' 중 같은 연도의 최대 순번을 +-- 구해 이어서 증가시킨다. +-- +-- 중복 방지 +-- (ACDNT_NM = acdnt_title, OCCRN_DTM = rcept_dt) 조합이 이미 존재하면 제외. +-- acdnt_mng_no 를 별도 컬럼으로 보관하지 않으므로 이 조합을 자연 키로 사용. +-- +-- ACDNT_TP_CD +-- gsc.tcm_code.code_nm 으로 치환 (JOIN: tcm_code.code = acdnt_asort_code) +-- 매핑 누락 시 원본 코드값으로 폴백. +-- +-- 사전 확인 쿼리 (실행 전 참고) +-- SELECT COUNT(DISTINCT a.acdnt_mng_no) +-- FROM gsc.tgs_acdnt_info a JOIN gsc.tgs_acdnt_lc b USING (acdnt_mng_no) +-- WHERE a.acdnt_asort_code = ANY(ARRAY[ +-- '055001001','055001002','055001003','055001004','055001005','055001006', +-- '055003001','055003002','055003003','055003004','055003005','055004003' +-- ]::varchar[]) +-- AND a.acdnt_title IS NOT NULL +-- AND a.rcept_dt >= '2026-04-10'; +-- ============================================================ + +WITH src AS ( + SELECT DISTINCT ON (a.acdnt_mng_no) + a.acdnt_mng_no, + a.acdnt_title, + a.acdnt_asort_code, + a.rcept_dt, + b.la, + b.lo + FROM gsc.tgs_acdnt_info AS a + JOIN gsc.tgs_acdnt_lc AS b ON a.acdnt_mng_no = b.acdnt_mng_no + WHERE a.acdnt_asort_code = ANY(ARRAY[ + '055001001','055001002','055001003','055001004','055001005','055001006', + '055003001','055003002','055003003','055003004','055003005','055004003' + ]::varchar[]) + AND a.acdnt_title IS NOT NULL + AND a.rcept_dt >= '2026-04-10'::timestamptz + AND b.la IS NOT NULL AND b.lo IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM wing.ACDNT w + WHERE w.ACDNT_NM = a.acdnt_title + AND w.OCCRN_DTM = a.rcept_dt + ) + ORDER BY a.acdnt_mng_no, b.acdnt_lc_sn ASC +), +numbered AS ( + SELECT + src.*, + EXTRACT(YEAR FROM src.rcept_dt)::int AS yr, + ROW_NUMBER() OVER ( + PARTITION BY EXTRACT(YEAR FROM src.rcept_dt) + ORDER BY src.rcept_dt ASC, src.acdnt_mng_no ASC + ) AS rn_in_year + FROM src +), +year_max AS ( + SELECT + (split_part(ACDNT_CD, '-', 2))::int AS yr, + MAX((split_part(ACDNT_CD, '-', 3))::int) AS max_seq + FROM wing.ACDNT + WHERE ACDNT_CD ~ '^INC-[0-9]{4}-[0-9]+$' + GROUP BY split_part(ACDNT_CD, '-', 2) +) +INSERT INTO wing.ACDNT ( + ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, ACDNT_STTS_CD, + LAT, LNG, LOC_GEOM, LOC_DC, OCCRN_DTM, REG_DTM, MDFCN_DTM +) +SELECT + 'INC-' || lpad(n.yr::text, 4, '0') || '-' || + lpad((COALESCE(ym.max_seq, 0) + n.rn_in_year)::text, 4, '0') AS ACDNT_CD, + n.acdnt_title AS ACDNT_NM, + COALESCE(c.code_nm, n.acdnt_asort_code) AS ACDNT_TP_CD, + 'ACTIVE' AS ACDNT_STTS_CD, + n.la::numeric AS LAT, + n.lo::numeric AS LNG, + ST_SetSRID(ST_MakePoint(n.lo::float8, n.la::float8), 4326) AS LOC_GEOM, + NULL AS LOC_DC, + n.rcept_dt AS OCCRN_DTM, + NOW(), NOW() +FROM numbered n +LEFT JOIN year_max ym ON ym.yr = n.yr +LEFT JOIN gsc.tcm_code c ON c.code = n.acdnt_asort_code +ORDER BY n.rcept_dt ASC, n.acdnt_mng_no ASC; + +-- ============================================================ +-- 사후 검증 (필요 시 주석 해제 실행) +-- SELECT COUNT(*) FROM wing.ACDNT WHERE OCCRN_DTM >= '2026-04-10'; +-- +-- SELECT ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, ST_AsText(LOC_GEOM), OCCRN_DTM +-- FROM wing.ACDNT +-- WHERE OCCRN_DTM >= '2026-04-10' +-- ORDER BY ACDNT_CD DESC +-- LIMIT 20; +-- +-- SELECT ACDNT_TP_CD, COUNT(*) +-- FROM wing.ACDNT +-- WHERE OCCRN_DTM >= '2026-04-10' +-- GROUP BY 1 +-- ORDER BY 2 DESC; +-- ============================================================ diff --git a/docs/COMMON-GUIDE.md b/docs/COMMON-GUIDE.md index 656f795..660504f 100644 --- a/docs/COMMON-GUIDE.md +++ b/docs/COMMON-GUIDE.md @@ -378,7 +378,7 @@ PUT, DELETE, PATCH 등 기타 메서드는 사용하지 않는다. 각 탭은 `tabs/{탭명}/services/{탭명}Api.ts`에 API 함수를 정의한다. ```typescript -// frontend/src/tabs/board/services/boardApi.ts +// frontend/src/components/board/services/boardApi.ts import { api } from '@common/services/api'; // 인터페이스 정의 @@ -490,7 +490,7 @@ interface MenuConfigItem { ```typescript // frontend/src/common/store/newStore.ts (공통) 또는 -// frontend/src/tabs/{탭}/store/newStore.ts (탭 전용) +// frontend/src/components/{탭}/store/newStore.ts (탭 전용) import { create } from 'zustand'; interface MyState { @@ -514,7 +514,7 @@ export const useMyStore = create((set) => ({ ```typescript import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { fetchBoardPosts, createBoardPost } from '@tabs/board/services/boardApi'; +import { fetchBoardPosts, createBoardPost } from '@components/board/services/boardApi'; // 조회 (캐싱 + 자동 리페치) const { data, isLoading, error } = useQuery({ @@ -1491,13 +1491,13 @@ const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1' ### 파일 위치 ``` -frontend/src/tabs/{탭명}/services/{탭명}Api.ts +frontend/src/components/{탭명}/services/{탭명}Api.ts ``` ### 작성 패턴 ```typescript -// frontend/src/tabs/{탭명}/services/{탭명}Api.ts +// frontend/src/components/{탭명}/services/{탭명}Api.ts import { api } from '@common/services/api'; // ============================================================ diff --git a/docs/CRUD-API-GUIDE.md b/docs/CRUD-API-GUIDE.md index 7212063..050d50e 100644 --- a/docs/CRUD-API-GUIDE.md +++ b/docs/CRUD-API-GUIDE.md @@ -736,13 +736,13 @@ ON CONFLICT DO NOTHING; ### 파일 위치 ``` -frontend/src/tabs/{탭명}/services/{tabName}Api.ts +frontend/src/components/{탭명}/services/{tabName}Api.ts ``` ### 기본 구조 ```ts -// frontend/src/tabs/{탭명}/services/{tabName}Api.ts +// frontend/src/components/{탭명}/services/{tabName}Api.ts import { api } from '@common/services/api'; @@ -1376,7 +1376,7 @@ export default router; ### 4단계: 프론트엔드 API 서비스 ```ts -// frontend/src/tabs/assets/services/equipmentApi.ts +// frontend/src/components/assets/services/equipmentApi.ts import { api } from '@common/services/api'; diff --git a/docs/DEVELOPMENT-GUIDE.md b/docs/DEVELOPMENT-GUIDE.md index 429d75e..ff14f54 100644 --- a/docs/DEVELOPMENT-GUIDE.md +++ b/docs/DEVELOPMENT-GUIDE.md @@ -163,11 +163,11 @@ Frontend에서 두 가지 경로 별칭을 사용한다: | Alias | 실제 경로 | 용도 | |-------|----------|------| | `@common/*` | `src/common/*` | 공통 모듈 (컴포넌트, 훅, 서비스, 스토어) | -| `@tabs/*` | `src/tabs/*` | 탭별 패키지 (11개 탭) | +| `@components/*` | `src/components/*` | 탭별 패키지 (11개 탭) | ```tsx import { useAuth } from '@common/hooks/useAuth'; -import OilSpillView from '@tabs/prediction/components/OilSpillView'; +import OilSpillView from '@components/prediction/components/OilSpillView'; ``` --- @@ -495,7 +495,7 @@ pre-commit: [backend] 타입 체크 성공 git status # 스테이징 (파일 지정) -git add frontend/src/tabs/incidents/components/IncidentDetailView.tsx +git add frontend/src/components/incidents/components/IncidentDetailView.tsx git add backend/src/incidents/incidentService.ts # 커밋 (pre-commit + commit-msg 검증 자동 실행) @@ -540,7 +540,7 @@ curl -X POST "https://gitea.gc-si.dev/api/v1/repos/gc/wing-ops/pulls" \ - 변경 내용을 1~3줄로 요약 ## 변경 파일 -- `frontend/src/tabs/incidents/components/IncidentDetailView.tsx` (신규) +- `frontend/src/components/incidents/components/IncidentDetailView.tsx` (신규) - `backend/src/incidents/incidentService.ts` (수정) ## Test plan @@ -754,8 +754,8 @@ chmod +x .githooks/pre-commit .githooks/commit-msg | `database/migration/017_incident_detail.sql` | DB 마이그레이션 (필요 시) | | `backend/src/incidents/incidentService.ts` | 상세 조회 함수 추가 | | `backend/src/incidents/incidentRouter.ts` | `GET /api/incidents/:id` 라우트 | -| `frontend/src/tabs/incidents/services/incidentsApi.ts` | API 호출 함수 | -| `frontend/src/tabs/incidents/components/IncidentDetailView.tsx` | 상세 뷰 컴포넌트 | +| `frontend/src/components/incidents/services/incidentsApi.ts` | API 호출 함수 | +| `frontend/src/components/incidents/components/IncidentDetailView.tsx` | 상세 뷰 컴포넌트 | #### Step 2. 브랜치 생성 @@ -797,7 +797,7 @@ router.get('/:id', requireAuth, async (req, res) => { **Frontend - API:** ```typescript -// frontend/src/tabs/incidents/services/incidentsApi.ts +// frontend/src/components/incidents/services/incidentsApi.ts export async function fetchIncidentById(id: number) { const { data } = await api.get(`/incidents/${id}`); return data; @@ -807,7 +807,7 @@ export async function fetchIncidentById(id: number) { **Frontend - Component:** ```tsx -// frontend/src/tabs/incidents/components/IncidentDetailView.tsx +// frontend/src/components/incidents/components/IncidentDetailView.tsx const IncidentDetailView = ({ incidentId }: IncidentDetailViewProps) => { const { data, isLoading } = useQuery({ queryKey: ['incident', incidentId], @@ -829,7 +829,7 @@ cd ../backend && npx tsc --noEmit #### Step 5. 커밋 & 푸시 ```bash -git add backend/src/incidents/ frontend/src/tabs/incidents/ +git add backend/src/incidents/ frontend/src/components/incidents/ git commit -m "feat(incidents): 사고 상세 조회 페이지 추가" # pre-commit: TypeScript OK, ESLint OK # commit-msg: Conventional Commits OK diff --git a/docs/MENU-TAB-GUIDE.md b/docs/MENU-TAB-GUIDE.md index 141a5d5..93071ea 100644 --- a/docs/MENU-TAB-GUIDE.md +++ b/docs/MENU-TAB-GUIDE.md @@ -31,9 +31,9 @@ board 탭을 기준 템플릿으로 사용하며, 각 단계별 실제 코드 | 단계 | 파일 | 작업 | |------|------|------| -| **Step 1** | `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` | 뷰 컴포넌트 생성 | -| | `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` | API 서비스 생성 | -| | `frontend/src/tabs/{탭명}/index.ts` | re-export | +| **Step 1** | `frontend/src/components/{탭명}/components/{TabName}View.tsx` | 뷰 컴포넌트 생성 | +| | `frontend/src/components/{탭명}/services/{tabName}Api.ts` | API 서비스 생성 | +| | `frontend/src/components/{탭명}/index.ts` | re-export | | **Step 2** | `frontend/src/common/types/navigation.ts` | MainTab 타입 추가 | | | `frontend/src/App.tsx` | import + renderView case 추가 | | | `frontend/src/common/hooks/useSubMenu.ts` | 서브메뉴 설정 (서브탭이 있는 경우) | @@ -52,7 +52,7 @@ board 탭을 기준 템플릿으로 사용하며, 각 단계별 실제 코드 ### 1-1. 디렉토리 구조 ``` -frontend/src/tabs/{탭명}/ +frontend/src/components/{탭명}/ components/ {TabName}View.tsx # 메인 뷰 컴포넌트 services/ @@ -65,7 +65,7 @@ frontend/src/tabs/{탭명}/ 서브탭이 **없는** 간단한 탭: ```tsx -// frontend/src/tabs/monitoring/components/MonitoringView.tsx +// frontend/src/components/monitoring/components/MonitoringView.tsx export function MonitoringView() { return ( @@ -91,7 +91,7 @@ export function MonitoringView() { 서브탭이 **있는** 탭 (board 패턴): ```tsx -// frontend/src/tabs/monitoring/components/MonitoringView.tsx +// frontend/src/components/monitoring/components/MonitoringView.tsx import { useSubMenu } from '@common/hooks/useSubMenu'; @@ -122,7 +122,7 @@ export function MonitoringView() { ### 1-3. API 서비스 (보일러플레이트) ```ts -// frontend/src/tabs/monitoring/services/monitoringApi.ts +// frontend/src/components/monitoring/services/monitoringApi.ts import { api } from '@common/services/api'; @@ -180,7 +180,7 @@ export async function createMonitoring(input: CreateMonitoringInput): Promise<{ ### 1-4. index.ts (re-export) ```ts -// frontend/src/tabs/monitoring/index.ts +// frontend/src/components/monitoring/index.ts export { MonitoringView } from './components/MonitoringView'; ``` @@ -209,7 +209,7 @@ export type MainTab = 'prediction' | 'hns' | 'rescue' | ... | 'monitoring' | 'ad // frontend/src/App.tsx // 1. import 추가 -import { MonitoringView } from '@tabs/monitoring'; +import { MonitoringView } from '@components/monitoring'; // 2. renderView switch에 case 추가 const renderView = () => { @@ -577,13 +577,13 @@ CREATE INDEX IF NOT EXISTS IDX_MONITORING_REG_DTM ON MONITORING(REG_DTM DESC); ### 1단계: 프론트엔드 파일 생성 ```bash -mkdir -p frontend/src/tabs/monitoring/components -mkdir -p frontend/src/tabs/monitoring/services +mkdir -p frontend/src/components/monitoring/components +mkdir -p frontend/src/components/monitoring/services ``` -- `frontend/src/tabs/monitoring/components/MonitoringView.tsx` 생성 -- `frontend/src/tabs/monitoring/services/monitoringApi.ts` 생성 -- `frontend/src/tabs/monitoring/index.ts` 생성 +- `frontend/src/components/monitoring/components/MonitoringView.tsx` 생성 +- `frontend/src/components/monitoring/services/monitoringApi.ts` 생성 +- `frontend/src/components/monitoring/index.ts` 생성 ### 2단계: 프론트엔드 기존 파일 수정 @@ -592,7 +592,7 @@ mkdir -p frontend/src/tabs/monitoring/services + export type MainTab = '...' | 'monitoring' | 'admin'; --- frontend/src/App.tsx -+ import { MonitoringView } from '@tabs/monitoring'; ++ import { MonitoringView } from '@components/monitoring'; // renderView switch 내: + case 'monitoring': + return ; @@ -644,9 +644,9 @@ cd backend && npx tsc --noEmit # 백엔드 컴파일 검증 ## 체크리스트 ### 프론트엔드 -- [ ] `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` 생성 -- [ ] `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` 생성 -- [ ] `frontend/src/tabs/{탭명}/index.ts` re-export 생성 +- [ ] `frontend/src/components/{탭명}/components/{TabName}View.tsx` 생성 +- [ ] `frontend/src/components/{탭명}/services/{tabName}Api.ts` 생성 +- [ ] `frontend/src/components/{탭명}/index.ts` re-export 생성 - [ ] `navigation.ts` MainTab 타입에 새 ID 추가 - [ ] `App.tsx` import + renderView switch case 추가 - [ ] `useSubMenu.ts` subMenuConfigs + subMenuState 추가 (서브탭 있는 경우) diff --git a/docs/MOCK-TO-API-GUIDE.md b/docs/MOCK-TO-API-GUIDE.md index 171e23a..6ceb996 100644 --- a/docs/MOCK-TO-API-GUIDE.md +++ b/docs/MOCK-TO-API-GUIDE.md @@ -49,7 +49,7 @@ git checkout -b feature/{탭명}-crud ```bash # 탭 디렉토리 내 mock 데이터 검색 grep -rn "mock\|Mock\|MOCK\|sample\|initial\|hardcod\|localStorage" \ - frontend/src/tabs/{탭명}/ + frontend/src/components/{탭명}/ # 공통 디렉토리에서 해당 탭 관련 데이터 확인 (반드시!) grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/ @@ -302,7 +302,7 @@ app.use('/api/{탭명}', newtabRouter); **1) API 서비스 파일 생성:** -파일 위치: `frontend/src/tabs/{탭명}/services/{탭명}Api.ts` +파일 위치: `frontend/src/components/{탭명}/services/{탭명}Api.ts` ```typescript import { api } from '@common/services/api'; @@ -476,7 +476,7 @@ CRUD 전체 흐름(생성 -> 조회 -> 수정 -> 삭제)을 확인하고 테스 ```bash # 해당 탭 디렉토리에서 mock 잔여 검색 -grep -rn "mock\|Mock\|MOCK\|localStorage" frontend/src/tabs/{탭명}/ +grep -rn "mock\|Mock\|MOCK\|localStorage" frontend/src/components/{탭명}/ # 공통 mock/data 디렉토리에서 해당 탭 관련 검색 grep -rn "{탭명}" frontend/src/common/mock/ @@ -497,7 +497,7 @@ git status git add database/migration/017_{탭명}.sql git add backend/src/{탭명}/ git add backend/src/server.ts -git add frontend/src/tabs/{탭명}/ +git add frontend/src/components/{탭명}/ # 커밋 (Conventional Commits, 한국어) git commit -m "feat({탭명}): mock 데이터를 PostgreSQL + REST API로 전환" @@ -602,7 +602,7 @@ AUTH_USER 주요 컬럼 참조: ```bash # 불충분 -- 탭 디렉토리만 검색 -grep -rn "mock" frontend/src/tabs/{탭명}/ +grep -rn "mock" frontend/src/components/{탭명}/ # 반드시 공통 디렉토리도 검색 grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/ @@ -780,8 +780,8 @@ export async function fetchCategories(): Promise { - [ ] 프론트 타입 체크 통과: `cd frontend && npx tsc --noEmit` - [ ] ESLint 통과: `cd frontend && npx eslint .` - [ ] CRUD 테스트: curl로 생성/조회/수정/삭제 정상 동작 확인 -- [ ] Mock 잔여 0건: `grep -rn "mock\|Mock" frontend/src/tabs/{탭명}/` (UI 상수 제외) -- [ ] PUT/DELETE 사용 0건: `grep -rn "api\.put\|api\.delete" frontend/src/tabs/{탭명}/` +- [ ] Mock 잔여 0건: `grep -rn "mock\|Mock" frontend/src/components/{탭명}/` (UI 상수 제외) +- [ ] PUT/DELETE 사용 0건: `grep -rn "api\.put\|api\.delete" frontend/src/components/{탭명}/` - [ ] 라우터 등록 확인: `server.ts`에 `app.use('/api/{탭명}', ...)` 추가됨 - [ ] 마이그레이션 실행 확인: psql로 테이블 생성 및 검증 SELECT 통과 - [ ] 커밋 + 푸시 + MR 생성 diff --git a/docs/README.md b/docs/README.md index dea0ae3..79fffc3 100755 --- a/docs/README.md +++ b/docs/README.md @@ -66,7 +66,7 @@ wing/ │ │ ├── utils/ cn, coordinates, geo, sanitize │ │ ├── styles/ base.css, components.css, wing.css (@layer) │ │ └── constants/ featureIds.ts (FEATURE_ID 상수 체계) -│ └── tabs/ @tabs/ alias (11개 탭) +│ └── tabs/ @components/ alias (11개 탭) │ ├── prediction/ 유류 확산 예측 │ ├── hns/ HNS 분석 │ ├── rescue/ 구조 시나리오 @@ -103,7 +103,7 @@ wing/ | Alias | 경로 | |-------|------| | `@common/*` | `src/common/*` | -| `@tabs/*` | `src/tabs/*` | +| `@components/*` | `src/components/*` | --- diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index d30a684..bc4decd 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -20,6 +20,16 @@ ## [2026-04-15] +### 추가 +- 확산예측·HNS 대기확산·긴급구난: GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (사고명·발생시각·위경도 자동 입력 + 지도 이동) +- 실시간 선박 신호 지도 표출: 한국 해역 1분 주기 폴링 스케줄러, 호버 툴팁·클릭 팝업·상세 모달 제공 (확산예측·HNS·긴급구난·사건사고 탭 연동) + +### 변경 +- MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용 +- aerial 이미지 분석 API 기본 URL 변경 + +## [2026-04-14] + ### 추가 - 디자인 시스템: HNS·사건사고·확산예측·SCAT·기상 탭 디자인 시스템 토큰 전면 적용 - 관리자: 비식별화조치 메뉴 및 패널 추가 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8f1fd1d..771a0bf 100755 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1945,9 +1945,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", "cpu": [ "arm" ], @@ -1959,9 +1959,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", "cpu": [ "arm64" ], @@ -1973,9 +1973,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", "cpu": [ "arm64" ], @@ -1987,9 +1987,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", "cpu": [ "x64" ], @@ -2001,9 +2001,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", "cpu": [ "arm64" ], @@ -2015,9 +2015,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", "cpu": [ "x64" ], @@ -2029,9 +2029,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", "cpu": [ "arm" ], @@ -2043,9 +2043,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", "cpu": [ "arm" ], @@ -2057,9 +2057,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", "cpu": [ "arm64" ], @@ -2071,9 +2071,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", "cpu": [ "arm64" ], @@ -2085,9 +2085,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", "cpu": [ "loong64" ], @@ -2099,9 +2099,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", "cpu": [ "loong64" ], @@ -2113,9 +2113,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", "cpu": [ "ppc64" ], @@ -2127,9 +2127,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", "cpu": [ "ppc64" ], @@ -2141,9 +2141,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", "cpu": [ "riscv64" ], @@ -2155,9 +2155,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", "cpu": [ "riscv64" ], @@ -2169,9 +2169,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", "cpu": [ "s390x" ], @@ -2183,9 +2183,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", "cpu": [ "x64" ], @@ -2197,9 +2197,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", "cpu": [ "x64" ], @@ -2211,9 +2211,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", "cpu": [ "x64" ], @@ -2225,9 +2225,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", "cpu": [ "arm64" ], @@ -2239,9 +2239,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", "cpu": [ "arm64" ], @@ -2253,9 +2253,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", "cpu": [ "ia32" ], @@ -2267,9 +2267,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", "cpu": [ "x64" ], @@ -2281,9 +2281,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", "cpu": [ "x64" ], @@ -2711,9 +2711,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -2721,13 +2721,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2873,9 +2873,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2927,9 +2927,9 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -3015,14 +3015,14 @@ } }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/balanced-match": { @@ -3077,9 +3077,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -3946,9 +3946,9 @@ "license": "MIT" }, "node_modules/fast-xml-parser": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz", - "integrity": "sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==", + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz", + "integrity": "sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==", "funding": [ { "type": "github", @@ -4055,16 +4055,16 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -4904,9 +4904,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -4938,9 +4938,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -5169,9 +5169,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -5409,10 +5409,13 @@ "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", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -5569,9 +5572,9 @@ } }, "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -5633,9 +5636,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", "dependencies": { @@ -5649,31 +5652,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" } }, @@ -5801,9 +5804,9 @@ } }, "node_modules/socket.io-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", - "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", @@ -6285,9 +6288,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 69a512d..46ae0f8 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,25 +1,25 @@ import { useState, useEffect } from 'react'; import { Routes, Route } from 'react-router-dom'; import { GoogleOAuthProvider } from '@react-oauth/google'; -import type { MainTab } from '@common/types/navigation'; -import { MainLayout } from '@common/components/layout/MainLayout'; -import { LoginPage } from '@common/components/auth/LoginPage'; +import type { MainTab } from '@/types/navigation'; +import { MainLayout } from '@components/common/layout/MainLayout'; +import { LoginPage } from '@components/common/auth/LoginPage'; import { registerMainTabSwitcher } from '@common/hooks/useSubMenu'; import { useAuthStore } from '@common/store/authStore'; import { useMenuStore } from '@common/store/menuStore'; import { useMapStore } from '@common/store/mapStore'; import { API_BASE_URL } from '@common/services/api'; -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 { ScatView } from '@tabs/scat'; -import { RescueView } from '@tabs/rescue'; +import { OilSpillView } from '@components/prediction'; +import { ReportsView } from '@components/reports'; +import { HNSView } from '@components/hns'; +import { AerialView } from '@components/aerial'; +import { AssetsView } from '@components/assets'; +import { BoardView } from '@components/board'; +import { WeatherView } from '@components/weather'; +import { IncidentsView } from '@components/incidents'; +import { AdminView } from '@components/admin'; +import { ScatView } from '@components/scat'; +import { RescueView } from '@components/rescue'; import { DesignPage } from '@/pages/design/DesignPage'; const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || ''; diff --git a/frontend/src/common/components/ui/UserManualPopup.tsx b/frontend/src/common/components/ui/UserManualPopup.tsx deleted file mode 100644 index bcbe904..0000000 --- a/frontend/src/common/components/ui/UserManualPopup.tsx +++ /dev/null @@ -1,1623 +0,0 @@ -import { useState } from 'react'; - -interface UserManualPopupProps { - isOpen: boolean; - onClose: () => void; -} - -interface InputItem { - label: string; - type: string; - required: boolean; - desc: string; -} - -interface ScreenItem { - id: string; - name: string; - menuPath: string; - imageIndex: number; - overview: string; - description?: string; - procedure?: string[]; - inputs?: InputItem[]; - notes?: string[]; -} - -interface Chapter { - id: string; - number: string; - title: string; - subtitle: string; - screens: ScreenItem[]; -} - -const CHAPTERS: Chapter[] = [ - { - id: 'ch01', - number: '01', - title: '유출유 확산예측', - subtitle: 'Oil Spill Dispersion Prediction', - screens: [ - { - id: '001', - name: '직접입력', - menuPath: '유출유확산예측 > 유출유확산분석', - imageIndex: 1, - overview: - '해양 유출유 사고 발생 시 오염원 위치와 유출 조건을 직접 입력하여 확산 범위를 예측하는 주요 분석 화면이다. KOSPS·POSEIDON·OpenDrift·앙상블의 4종 수치 모델을 선택적으로 적용할 수 있다. 실시간 기상·해양 데이터와 연계하여 즉시 예측 결과를 지도에 표출한다.', - description: - "화면 좌측에 예측정보 입력 패널(사고명, 위치, 유종, 유출량, 예측 시간)이 위치한다. 중앙 지도에서 클릭하여 사고 발생 위치를 직접 지정하거나 위·경도 좌표를 수동 입력할 수 있다. 하단 타임라인 슬라이더로 시간 경과에 따른 확산 경과를 재생할 수 있다. 우측 '분석 요약' 패널에서 예측 면적, 이동거리, 이동 방향, 풍화 비율 등을 확인할 수 있다.", - procedure: [ - "상단 메뉴에서 '유출유확산예측 > 유출유확산분석'을 클릭하여 화면을 이동한다.", - '좌측 패널에서 사고명, 날짜, 유종, 유출량, 예측 시간을 입력한다.', - '지도를 클릭하거나 좌표 입력란에 위·경도를 직접 입력하여 사고 위치를 지정한다.', - '적용할 확산 모델(KOSPS·POSEIDON·OpenDrift·앙상블) 체크박스를 선택한다.', - "'확산예측 실행' 버튼을 클릭하여 예측을 시작한다.", - '하단 타임라인 재생 버튼으로 시간별 확산 결과를 확인한다.', - ], - inputs: [ - { label: '사고명', type: '텍스트', required: true, desc: '사고 식별 명칭' }, - { label: '날짜/시간', type: '날짜+시간', required: true, desc: '사고 발생 일시' }, - { label: '위도/경도', type: '숫자', required: true, desc: '지도 클릭 자동 입력 가능' }, - { label: '유종', type: '드롭다운', required: true, desc: '벙커C유·경유·연료유 등' }, - { label: '유출량', type: '숫자 kL', required: true, desc: '유출 유류 총량' }, - { label: '예측 시간', type: '숫자 h', required: true, desc: '확산 예측 기간' }, - { - label: '적용 모델', - type: '체크박스', - required: true, - desc: 'KOSPS·POSEIDON·OpenDrift·앙상블 중 선택', - }, - ], - notes: [ - "좌표 입력 후 반드시 '적용' 버튼을 클릭해야 지도에 반영된다.", - '앙상블 모델 선택 시 3개 모델이 동시 실행되어 계산 시간이 증가할 수 있다.', - '유출량이 0 이하이거나 예측 시간이 입력되지 않으면 실행 버튼이 비활성화된다.', - ], - }, - { - id: '002', - name: '모델종류별 확산예측 실행', - menuPath: '유출유확산예측 > 유출유확산분석', - imageIndex: 2, - overview: - 'KOSPS·POSEIDON·OpenDrift 3개 모델의 예측 결과를 동시에 지도에 표출하여 모델 간 비교 분석을 지원한다. 모델별 입자 궤적(파란점·빨간점·하늘점)이 중첩 표시되어 확산 경향의 편차를 한눈에 파악할 수 있다.', - description: - "지도에 모델별 색상 구분 입자가 동시에 표시되며, 각 모델의 확산 방향과 범위 차이를 시각적으로 비교할 수 있다. 우측 '분석 요약' 패널에 예측 면적(km2), 이동거리(km), 방향, 이동속도(cm/s)가 표출된다. 풍화 상태 막대그래프(유출량·해면연소·자연분산·수중분산·잔류 비율)를 제공한다. '다각형 분석수행' 버튼으로 특정 구역 내 오염 면적을 별도 산정할 수 있다.", - procedure: [ - '확산분석 화면에서 적용 모델 체크박스를 2개 이상 선택한다.', - "'확산예측 실행' 버튼을 클릭한다.", - '지도에서 각 모델의 입자 분포와 확산 범위를 비교한다.', - "우측 '분석 요약' 탭에서 모델별 정량 지표를 확인한다.", - '하단 타임라인으로 시간 경과별 확산 변화를 재생한다.', - ], - notes: [ - '여러 모델을 동시에 선택하면 계산 자원 소모가 증가하여 결과 표출까지 수 분이 소요될 수 있다.', - '모델별 예측 결과에 차이가 있을 경우, 앙상블 결과 또는 현장 데이터와 교차 검토하여 활용한다.', - ], - }, - { - id: '003', - name: '사고정보 조회', - menuPath: '유출유확산예측 > 유출유확산분석', - imageIndex: 3, - overview: - '시스템에 등록된 실제 사고 정보를 불러와 확산분석에 자동 연동하는 기능이다. 사고코드·선박명·유종·유출량·발생 좌표 등이 자동으로 예측정보 입력란에 채워진다.', - description: - "좌측 '사고정보' 패널에 진행 중인 사고 목록이 표시된다. 사고를 선택하면 지도 중심이 해당 사고 발생 위치로 자동 이동하고 위치 핀이 표시된다. 사고 상태는 '진행중(빨간 배지)'과 '종료(회색 배지)'로 구분된다.", - procedure: [ - "좌측 '사고정보' 패널의 펼침 버튼을 클릭하여 패널을 연다.", - '목록에서 분석할 사고를 클릭한다.', - '사고 정보가 예측정보 입력란에 자동으로 입력되었는지 확인한다.', - '필요 시 유출량·예측 시간 등 추가 정보를 수정한다.', - "'확산예측 실행'을 클릭하여 분석을 시작한다.", - ], - notes: [ - "'진행중' 상태의 사고만 실시간 확산예측과 연동된다.", - '사고 데이터는 관계 기관으로부터 등록된 정보를 기반으로 하며, 입력 오류 시 관리자에게 문의한다.', - ], - }, - { - id: '004', - name: '영향 민감자원 레이어 중첩 표시', - menuPath: '유출유확산예측 > 유출유확산분석', - imageIndex: 4, - overview: - '유출유 확산 예측 결과와 해양 민감자원(환경생태·수산자원·관광지 등) 레이어를 지도에 동시 표출하는 기능이다. 잠재적 피해 자원을 사전에 파악하여 방제 우선순위 설정에 활용한다.', - description: - "좌측 하단 '정보 레이어' 패널에서 환경생태·사회경제·민감도평가·해경관할구역 등 대분류 토글을 제공한다. 각 항목 우측에 해당 레이어 데이터 건수가 표시된다. '전체 켜기/끄기' 버튼으로 모든 레이어를 일괄 제어할 수 있다.", - procedure: [ - "좌측 하단 '정보 레이어' 패널을 열고 원하는 레이어 항목을 활성화한다.", - '확산예측 실행 후 지도에 표출된 오염 범위와 레이어 분포를 비교한다.', - '오염 영향이 우려되는 자원 레이어를 클릭하여 상세 정보를 확인한다.', - '필요한 레이어 조합을 선택하여 보고서용 지도 캡처에 활용한다.', - ], - notes: [ - '레이어 수가 많을 경우 지도 로딩 속도가 느려질 수 있으므로 필요한 레이어만 선택적으로 활성화한다.', - ], - }, - { - id: '005', - name: 'AI자동추천 배치안 적용', - menuPath: '유출유확산예측 > 유출유확산분석 > 오일펜스 배치 가이드', - imageIndex: 5, - overview: - '확산예측 결과를 기반으로 AI(NSGA-II 알고리즘)가 최적 오일펜스 배치안을 자동으로 생성하는 기능이다. 최대 3개 방어선의 위치·방향·총 길이·차단율을 자동 계산하여 지도에 표출한다.', - description: - "'오일펜스 배치 가이드' 패널 상단 'AI자동 추천' 탭에서 배치 결과를 확인한다. 1차~3차 방어선별 배치 위치(좌표), 방향, 오일펜스 길이, 예상 차단율이 표시된다.", - procedure: [ - "확산예측 실행 완료 후 좌측 '오일펜스 배치 가이드' 패널을 연다.", - "'AI자동 추천' 탭을 선택하여 추천 배치안을 확인한다.", - '방어선별 차단율 및 오일펜스 총 길이를 검토한다.', - "'추천 배치안 적용하기' 버튼을 클릭하여 지도에 표출한다.", - '현장 여건(조류·지형·자원 현황)을 고려하여 최종 배치안을 확정한다.', - ], - inputs: [ - { label: '배치 수', type: '숫자', required: false, desc: '투입할 오일펜스 방어선 수' }, - { label: '최소 차단율', type: '숫자 %', required: false, desc: '목표 차단율' }, - ], - notes: [ - 'AI 추천 기능은 확산예측이 완료된 후에 활성화된다.', - '추천 배치안은 수치 모델 기반 결과로, 현장 조류 변동·수심·접근 가능성 등을 반드시 현장 담당자가 최종 확인해야 한다.', - ], - }, - { - id: '006', - name: '이미지업로드', - menuPath: '유출유확산예측 > 유출유확산분석', - imageIndex: 6, - overview: - '위성·드론·항공기에서 촬영한 이미지를 업로드하여 실제 오염 현황과 모델 예측 결과를 비교 분석하는 기능이다.', - description: - "예측정보 입력 패널 상단 '이미지 업로드' 탭을 선택하면 파일 업로드 영역이 활성화된다. 업로드된 이미지는 지도에 자동으로 오버레이 표출된다.", - procedure: [ - "예측정보 입력 패널에서 '이미지 업로드' 탭을 선택한다.", - "'파일 선택' 버튼을 클릭하거나 파일을 드래그하여 업로드한다.", - '업로드된 이미지가 지도에 올바르게 표출되는지 위치를 확인한다.', - '확산예측 결과와 이미지를 동시에 표출하여 비교한다.', - ], - inputs: [ - { - label: '업로드 파일', - type: '파일 PNG/JPG', - required: false, - desc: '위성·드론·항공기 촬영 이미지', - }, - ], - notes: [ - '지원 파일 형식은 PNG, JPG이다.', - '업로드한 이미지의 좌표 기준(GCP)이 없을 경우 지도 상 위치 정확도가 낮을 수 있다.', - ], - }, - { - id: '007', - name: '분석 목록', - menuPath: '유출유확산예측', - imageIndex: 7, - overview: - '완료 또는 진행 중인 유출유 확산예측 분석 이력을 목록 형식으로 조회하는 화면이다. 사고명·날짜·유종·유출량·모델 상태 등을 한눈에 파악할 수 있다.', - description: - "목록에는 번호·사고명·사고일시·예측시간·유종·유출량·모델별 상태(KOSPS·POSEIDON·OPENDRIFT)·담당자 등이 표시된다. 모델 상태는 '완료(녹색 배지)'와 '대기(회색 배지)'로 구분된다.", - procedure: [ - "상단 메뉴에서 '유출유확산예측'을 클릭하여 분석 목록 화면으로 이동한다.", - '검색창에 사고명을 입력하거나 페이지를 이동하여 원하는 분석을 찾는다.', - '사고명 링크를 클릭하여 해당 분석 결과 상세 화면으로 이동한다.', - "신규 분석이 필요한 경우 우측 상단 '+ 새 분석' 버튼을 클릭한다.", - ], - notes: [ - '분석 결과 삭제 시 복구가 불가하므로, 삭제 전 보고서 출력 또는 데이터 저장 여부를 확인한다.', - ], - }, - { - id: '008', - name: '분석 조회(test001)', - menuPath: '유출유확산예측 > 분석 목록', - imageIndex: 8, - overview: - '분석 목록에서 특정 분석 건을 선택하면 해당 확산분석 결과 화면으로 이동하여 상세 내용을 조회한다. 이전 분석 결과를 재검토하거나 조건 변경 후 재분석할 수 있다.', - procedure: [ - '분석 목록에서 조회할 사고명 링크를 클릭한다.', - '로드된 분석 정보를 확인한다.', - "필요 시 입력 조건을 수정하고 '확산예측 실행'을 다시 클릭하여 재분석한다.", - '결과를 보고서로 출력하거나 저장한다.', - ], - notes: [ - '기존 분석에서 모델 조건 변경 후 재실행 시 이전 결과가 덮어써질 수 있으므로, 원본 결과를 먼저 저장한다.', - ], - }, - { - id: '009', - name: '유출유확산모델 이론 - 시스템 개요', - menuPath: '유출유확산예측 > 유출유확산모델 이론', - imageIndex: 9, - overview: - 'Wing 시스템에 탑재된 유출유 확산 수치 모델의 개요와 운용 체계를 안내하는 이론 화면이다. KOSPS·POSEIDON·OpenDrift 3종 모델의 특징·비교·데이터 흐름을 확인할 수 있다.', - procedure: [ - "상단 메뉴에서 '유출유확산예측 > 유출유확산모델 이론'을 클릭한다.", - "'시스템 개요' 탭을 선택하여 전체 모델 체계를 확인한다.", - '각 탭(KOSPS·POSEIDON·OpenDrift 등)을 클릭하여 상세 이론을 열람한다.', - ], - }, - { - id: '010', - name: '유출유확산모델 이론 - KOSPS', - menuPath: '유출유확산예측 > 유출유확산모델 이론', - imageIndex: 10, - overview: - '한국해양과학기술원(KIOST)이 개발한 KOSPS 모델의 상세 이론을 안내한다. 국내 연안 특성에 최적화된 조류예측(CHARRY) 및 풍류 경험식 적용 원리를 설명한다.', - notes: [ - 'KOSPS는 국내 연안 중심으로 검증된 모델로, 원해·심해 적용 시 정확도 제한이 있을 수 있다.', - ], - }, - { - id: '011', - name: '유출유확산모델 이론 - POSEIDON', - menuPath: '유출유확산예측 > 유출유확산모델 이론', - imageIndex: 11, - overview: - '입자추적 최적화 예측 시스템 POSEIDON의 이론 및 MOHID 3D 해양순환모델 적용 원리를 안내한다. GA·DE·HS·PSO 등 4종 최적화 알고리즘을 통한 파라미터 자동 최적화 방법을 설명한다.', - }, - { - id: '012', - name: '유출유확산모델 이론 - OpenDrift', - menuPath: '유출유확산예측 > 유출유확산모델 이론', - imageIndex: 12, - overview: - '노르웨이 MET Norway가 개발한 오픈소스 라그랑지안 확산 프레임워크 OpenDrift의 이론을 안내한다. NEMO·ROMS·HYCOM·Copernicus CMEMS 등 다양한 해양 예보 모델과 연동 가능한 범용성을 설명한다.', - }, - { - id: '013', - name: '유출유확산모델 이론 - 입자추적법', - menuPath: '유출유확산예측 > 유출유확산모델 이론', - imageIndex: 13, - overview: - '유출유 수치 모델에 적용되는 라그랑지안 입자추적법(Lagrangian Particle Tracking Method)의 수학적 이론과 수식을 안내한다.', - }, - { - id: '014', - name: '유출유확산모델 이론 - 풍화 프로세스', - menuPath: '유출유확산예측 > 유출유확산모델 이론', - imageIndex: 14, - overview: - '유출유가 시간 경과에 따라 겪는 풍화(Weathering) 프로세스(증발·유화·자연분산 등)의 이론과 타임라인을 안내한다.', - notes: [ - '풍화 속도는 유종·기온·해수온·파랑 조건에 따라 크게 달라질 수 있으므로 참고용으로 활용한다.', - ], - }, - { - id: '015', - name: '유출유확산모델 이론 - 해양환경 입력', - menuPath: '유출유확산예측 > 유출유확산모델 이론', - imageIndex: 15, - overview: - '유출유 확산 수치 모델에 사용되는 해양환경 입력 데이터(기상·해류·조류·해수면 온도) 체계를 안내한다. KMA RDAPS·ECMWF·NIFS ROMS·HYCOM·TPXO9 등 데이터 소스 설명.', - }, - { - id: '016', - name: '유출유확산모델 이론 - 모델 검증', - menuPath: '유출유확산예측 > 유출유확산모델 이론', - imageIndex: 16, - overview: - '유출유 확산 모델의 정확도 검증 사례(2007 허베이스피리트, 2014 무이산 등)와 RMSE·Skill Score 통계 지표를 안내한다.', - }, - { - id: '017', - name: '유출유확산모델 이론 - 앙상블', - menuPath: '유출유확산예측 > 유출유확산모델 이론', - imageIndex: 17, - overview: - 'KOSPS·POSEIDON·OpenDrift 3종 모델의 결과를 통합하는 앙상블 예측 방법론과 가중평균 알고리즘을 안내한다. 최악 시나리오(Worst Case) 산출 방법도 설명한다.', - }, - { - id: '018', - name: '유출유확산모델 이론 - 발전 방향', - menuPath: '유출유확산예측 > 유출유확산모델 이론', - imageIndex: 18, - overview: '현재 유출유 확산 모델의 한계와 향후 개선 방향(4단계 로드맵)을 안내한다.', - }, - { - id: '019', - name: '오일펜스 배치 알고리즘 이론 - 개요', - menuPath: '유출유확산예측 > 오일펜스 배치 알고리즘 이론', - imageIndex: 19, - overview: - '오일펜스 최적 배치 알고리즘의 전체 개요와 최적화 목표(차단 면적 최대화·도달시간 최소화·자원 효율화)를 안내한다.', - }, - { - id: '020', - name: '오일펜스 배치 알고리즘 이론 - 배치 이론', - menuPath: '유출유확산예측 > 오일펜스 배치 알고리즘 이론', - imageIndex: 20, - overview: - '차단 효율 함수(E(theta, U))와 V형·U형·J형 배치 형태별 이론적 차단 원리를 안내한다. 다단계 차단선 배치 시 총 차단 효율 산출 공식도 포함한다.', - }, - { - id: '021', - name: '오일펜스 배치 알고리즘 이론 - 최적화 알고리즘', - menuPath: '유출유확산예측 > 오일펜스 배치 알고리즘 이론', - imageIndex: 21, - overview: - 'NSGA-II(Non-dominated Sorting Genetic Algorithm II) 기반 다목적 최적화 알고리즘의 원리와 보조 알고리즘(PSO·Greedy) 비교를 안내한다.', - }, - { - id: '022', - name: '오일펜스 배치 알고리즘 이론 - 유체역학 모델', - menuPath: '유출유확산예측 > 오일펜스 배치 알고리즘 이론', - imageIndex: 22, - overview: '오일펜스 차단 성능 평가에 사용되는 유체역학 모델의 이론을 안내한다.', - }, - { - id: '023', - name: '오일펜스 배치 알고리즘 이론 - 현장 적용', - menuPath: '유출유확산예측 > 오일펜스 배치 알고리즘 이론', - imageIndex: 23, - overview: - '오일펜스 배치 알고리즘의 실제 사고 현장 적용 사례와 현장 운용 시 고려사항을 안내한다.', - }, - { - id: '024', - name: '오일펜스 배치 알고리즘 이론 - 참고문헌', - menuPath: '유출유확산예측 > 오일펜스 배치 알고리즘 이론', - imageIndex: 24, - overview: - '오일펜스 배치 알고리즘 이론에 사용된 학술 논문·기술 보고서·국제 기준 등 참고문헌 목록을 안내한다.', - }, - ], - }, - { - id: 'ch02', - number: '02', - title: 'HNS·대기확산', - subtitle: 'HNS Atmospheric Dispersion', - screens: [ - { - id: '025', - name: '대기확산예측 실행', - menuPath: 'HNS대기확산 > 대기확산분석', - imageIndex: 25, - overview: - 'HNS(위험유해물질) 해상 유출 시 대기 확산 범위, 위험 농도 구역, 영향 인구를 예측하는 핵심 분석 화면이다. ALOHA(EPA) 또는 이문진박사모델 두 가지 알고리즘 중 선택하여 가우시안 Plume/Puff 모델을 적용한다.', - description: - "화면 좌측에 사고 기본정보·물질 및 유출 조건·기상조건 입력 패널이 위치한다. 중앙 지도에 AEGL-1~3 위험 구역이 색상별 Plume 형태로 표출된다. 우측 '예측 결과' 패널에 최대 농도(ppm), AEGL-1 영향 면적, 위험 등급 등이 표시된다.", - procedure: [ - "상단 메뉴에서 'HNS대기확산 > 대기확산분석'을 클릭한다.", - '사고 기본정보(사고명·날짜·시간·위치)를 입력한다.', - 'HNS 물질 종류 및 누출 방식·유출량을 선택·입력한다.', - '기상조건(풍향·풍속·기온·대기안정도·예측 시간)을 입력한다.', - '적용 알고리즘(ALOHA/이문진박사모델)을 선택한다.', - "'확산예측 실행' 버튼을 클릭한다.", - '지도의 위험 구역 Plume과 우측 예측 결과 패널을 확인한다.', - ], - inputs: [ - { label: '사고명', type: '텍스트', required: true, desc: '사고 식별 명칭' }, - { label: '사고 일시', type: '날짜+시간', required: true, desc: '사고 발생 일시' }, - { label: '위도/경도', type: '숫자', required: true, desc: '사고 발생 지점 좌표' }, - { - label: 'HNS 물질', - type: '드롭다운', - required: true, - desc: 'AEGL/ERPG 기준값 자동 로드', - }, - { label: '누출 방식', type: '라디오', required: true, desc: '순간 유출 또는 지속 유출' }, - { label: '유출량', type: '숫자', required: true, desc: 'ton 또는 g/s 단위' }, - { label: '풍향', type: '숫자', required: true, desc: '0~360도' }, - { label: '풍속', type: '숫자 m/s', required: true, desc: '최소 0.5 m/s 이상' }, - { label: '기온', type: '숫자', required: true, desc: '현재 기온' }, - { label: '대기안정도', type: '선택 A~F', required: true, desc: 'Pasquill-Gifford 분류' }, - { label: '예측 시간', type: '숫자 h', required: true, desc: '확산 예측 기간' }, - { - label: '적용 알고리즘', - type: '라디오', - required: true, - desc: 'ALOHA 또는 이문진박사모델', - }, - ], - notes: [ - '풍속은 최소 0.5 m/s 이상 입력해야 하며 0 입력 시 오류 발생.', - 'HNS 물질 선택 후 AEGL/ERPGs 기준값이 자동 로드되었는지 반드시 확인.', - ], - }, - { - id: '026', - name: 'HNS 분석 목록', - menuPath: 'HNS대기확산', - imageIndex: 26, - overview: - '완료된 HNS 대기확산 분석 이력을 목록 형식으로 조회하는 화면이다. AEGL-1~3 거리·위험 등급·피해반경·담당자 등 주요 결과 정보를 한눈에 파악할 수 있다.', - procedure: [ - "상단 메뉴에서 'HNS대기확산'을 클릭하여 분석 목록으로 이동한다.", - '검색창에서 분석명을 검색하거나 목록을 스크롤하여 원하는 분석을 찾는다.', - '분석명 링크를 클릭하여 해당 분석 결과 지도 화면으로 이동한다.', - ], - }, - { - id: '027', - name: '시나리오 상세', - menuPath: 'HNS대기확산 > 시나리오 관리', - imageIndex: 27, - overview: - '단일 HNS 사고에 대해 여러 기상·유출 조건 시나리오(S-01~S-05 등)를 생성·비교 관리하는 화면이다. 시나리오별 위험도·위험 구역·대응 권고사항을 한눈에 비교할 수 있다.', - description: - '시나리오 목록 카드에 시나리오명·위험도 배지(CRITICAL/HIGH/MEDIUM/RESOLVED)·최대농도·IDLH 거리·ERPG-2 거리·영향인구 요약이 표시된다.', - procedure: [ - "'HNS대기확산 > 시나리오 관리'를 클릭하여 이동한다.", - '원하는 사고 건을 선택하면 시나리오 목록이 표시된다.', - '시나리오 카드를 클릭하여 상세 위험 구역과 대응 권고사항을 확인한다.', - "'비교 차트' 탭으로 이동하여 시나리오 간 시간별 변화를 비교한다.", - ], - }, - { - id: '028', - name: '비교 차트', - menuPath: 'HNS대기확산 > 시나리오 관리', - imageIndex: 28, - overview: - '생성된 여러 HNS 시나리오의 최대 지표면 농도·위험 반경·영향 인구를 시간별 변화 차트로 비교하는 화면이다.', - description: - '상단은 시나리오별 최대 지표면 농도(ppm) 시간별 변화 그래프. 중단에 위험 반경(km)과 영향 인구(명) 변화 차트. 하단에 피대농도·IDLH 거리·ERPG-2 반경·영향인구·풍향/풍속·위험등급 항목별 비교표가 제공된다.', - }, - { - id: '029', - name: '확산범위 오버레이', - menuPath: 'HNS대기확산 > 시나리오 관리', - imageIndex: 29, - overview: - 'HNS 대기확산 시나리오의 시간대별(T+0h~T+4h) 확산 범위를 지도에 오버레이 표출하는 화면이다.', - procedure: [ - "시나리오 관리 화면에서 '확산범위 오버레이' 탭을 클릭한다.", - '지도에서 시간대별 확산 범위를 확인한다.', - '슬라이더를 이동하여 원하는 시간대의 확산 상태를 조회한다.', - ], - }, - { - id: '030', - name: '신규 시나리오', - menuPath: 'HNS대기확산 > 시나리오 관리', - imageIndex: 30, - overview: - '기상·유출 조건을 변경하여 새로운 HNS 대기확산 시나리오를 생성하는 입력 모달 화면이다.', - procedure: [ - "시나리오 관리 화면 우측 상단 '신규 시나리오' 버튼을 클릭한다.", - '시나리오명과 기준 시각을 입력한다.', - 'HNS 물질·누출 구분·유출량을 입력한다.', - '기상조건(풍향·풍속·기온·대기안정도)을 입력한다.', - "'시나리오 생성 및 예측 실행'을 클릭한다.", - ], - inputs: [ - { label: '시나리오명', type: '텍스트', required: true, desc: '시나리오 식별 명칭' }, - { label: 'HNS 물질', type: '드롭다운', required: true, desc: '유출 물질 선택' }, - { label: '누출 구분', type: '라디오', required: true, desc: '순간/지속' }, - { label: '유출량', type: '숫자', required: true, desc: 'ton 또는 g/s' }, - { label: '풍향/풍속', type: '숫자', required: true, desc: '각도와 m/s' }, - { label: '기온', type: '숫자', required: true, desc: '섭씨' }, - { label: '대기안정도', type: '선택 A~F', required: true, desc: 'Pasquill-Gifford' }, - ], - }, - { - id: '031', - name: 'HNS 대응매뉴얼', - menuPath: 'HNS대기확산', - imageIndex: 31, - overview: - '해양 HNS 사고 대응 절차를 정리한 Marine HNS Response Manual(Bonn Agreement·HELCOM·REMPEC 기반)을 시스템 내에서 직접 열람하는 화면이다. 8개 챕터. SEBC 거동 분류 5유형 카드.', - procedure: [ - "상단 메뉴에서 'HNS대기확산 > HNS 대응매뉴얼'을 클릭한다.", - '원하는 챕터 카드를 클릭하여 세부 내용을 확인한다.', - '하단 SEBC 분류 카드를 클릭하여 거동 유형별 대응 방법을 확인한다.', - ], - }, - { - id: '032', - name: 'HNS 확산모델 이론 - 시스템 개요', - menuPath: 'HNS대기확산 > 확산모델 이론', - imageIndex: 32, - overview: - 'Wing에 탑재된 HNS 대기확산 모델(WRF-Chem·Gaussian Plume/Puff·ROMS 연동)의 전체 체계와 처리 흐름을 안내한다.', - }, - { - id: '033', - name: 'HNS 확산모델 이론 - 가우시안 모델', - menuPath: 'HNS대기확산 > 확산모델 이론', - imageIndex: 33, - overview: - 'HNS 대기확산 예측에 적용되는 Gaussian Plume·Puff 모델의 수학적 이론과 Pasquill-Gifford 대기안정도 분류 체계를 안내한다.', - }, - { - id: '034', - name: 'HNS 확산모델 이론 - 물질별 시나리오', - menuPath: 'HNS대기확산 > 확산모델 이론', - imageIndex: 34, - overview: - '주요 HNS 물질(NH3·CH3OH·H2·LNG 등)의 물질 특성 및 대기확산 시나리오 적용 기준을 안내한다.', - }, - { - id: '035', - name: 'HNS 확산모델 이론 - 해양환경 보정', - menuPath: 'HNS대기확산 > 확산모델 이론', - imageIndex: 35, - overview: - '해양 환경 특성(해풍·육풍 순환·해수면 거칠기·SST 영향·MABL 구조)에 따른 대기확산 모델 보정 인자를 안내한다.', - }, - { - id: '036', - name: 'HNS 확산모델 이론 - 모델 검증', - menuPath: 'HNS대기확산 > 확산모델 이론', - imageIndex: 36, - overview: - 'HNS 대기확산 모델의 실측값 대비 예측 정확도 검증 결과(ALOHA·이문진박사모델·실측값 3종 비교)를 안내한다.', - }, - { - id: '037', - name: 'HNS 확산모델 이론 - 실시간 비교', - menuPath: 'HNS대기확산 > 확산모델 이론', - imageIndex: 37, - overview: - '동일 조건에서 ALOHA와 이문진박사모델의 예측 결과를 실시간으로 비교하는 시뮬레이션 화면이다.', - }, - { - id: '038', - name: 'HNS 확산모델 이론 - WRF-Chem 발전', - menuPath: 'HNS대기확산 > 확산모델 이론', - imageIndex: 38, - overview: - 'WRF-Chem 기상-화학 연계 모델 및 ROMS 해양확산 수치모의 검증 결과와 고도화 방향을 안내한다.', - }, - { - id: '039', - name: 'HNS물질정보 - SEBC 거동분류', - menuPath: 'HNS대기확산 > HNS물질정보', - imageIndex: 39, - overview: - 'HNS 물질의 해양 거동 특성을 SEBC 기준으로 분류한 정보를 제공한다. G(기체)·E(증발)·F(부유)·D(용해)·S(침강) 5가지 기본 유형과 복합 거동 유형을 안내한다.', - }, - { - id: '040', - name: 'HNS물질정보 - 주요 물질 특성', - menuPath: 'HNS대기확산 > HNS물질정보', - imageIndex: 40, - overview: - 'NH3·CH4·H2·페놀·톨루엔 등 주요 HNS 물질의 상세 물리화학적 특성을 카드 형식으로 제공하는 화면이다.', - }, - { - id: '041', - name: 'HNS물질정보 - 위험도 기준', - menuPath: 'HNS대기확산 > HNS물질정보', - imageIndex: 41, - overview: - 'HNS 물질의 위험도 평가에 사용되는 AEGL·ERPG·IDLH·TWA 등 주요 독성 기준값 체계를 안내한다.', - }, - { - id: '042', - name: 'HNS물질정보 - 물질 상세검색', - menuPath: 'HNS대기확산 > HNS물질정보', - imageIndex: 42, - overview: - 'HNS 6,500여 종의 물질 데이터베이스에서 CAS 번호·물질명·거동 분류 조건으로 물질을 검색하는 화면이다.', - inputs: [ - { label: '검색어', type: '텍스트', required: true, desc: 'CAS 번호 또는 물질명' }, - { label: '거동 분류', type: '체크박스', required: false, desc: 'SEBC 유형 필터' }, - ], - notes: ['CAS 번호 입력 시 하이픈(-) 포함 정확한 형식으로 입력해야 검색된다.'], - }, - ], - }, - { - id: 'ch03', - number: '03', - title: '긴급구난', - subtitle: 'Emergency Rescue', - screens: [ - { - id: '043', - name: '긴급구난예측', - menuPath: '긴급구난', - imageIndex: 43, - overview: - '해상 조난자·표류 선박·표류 물체의 위치를 예측하는 긴급구난(SAR) 분석 화면이다. 해류·바람·파랑 데이터 기반 라그랑지안 표류 궤적 시뮬레이션을 수행한다.', - description: - '화면 좌측에 사고 발생 위치·일시·표류체 종류·예측 시간·기상조건 입력 패널이 위치한다. 중앙 지도에 표류 예측 궤적과 탐색 구역(최우선·일반)이 표출된다.', - procedure: [ - "상단 메뉴에서 '긴급구난'을 클릭한다.", - '사고 발생 위치(위경도)와 일시를 입력한다.', - '표류체 종류(선박/사람/컨테이너 등)를 선택한다.', - '예측 시간과 기상 조건을 입력한다.', - "'예측 실행' 버튼을 클릭하여 표류 궤적과 탐색 구역을 확인한다.", - ], - inputs: [ - { label: '사고 위치', type: '숫자', required: true, desc: '위·경도' }, - { label: '사고 일시', type: '날짜+시간', required: true, desc: '발생 일시' }, - { - label: '표류체 종류', - type: '드롭다운', - required: true, - desc: '선박·사람·컨테이너·구명정 등', - }, - { label: '예측 시간', type: '숫자 h', required: true, desc: '표류 예측 기간' }, - { label: '기상조건', type: '숫자', required: false, desc: '미입력 시 실시간 자동 적용' }, - ], - notes: [ - '표류체 종류 선택이 부적절한 경우 예측 정확도가 낮아질 수 있다.', - '신속한 대응을 위해 입력 완료 즉시 실행하고 기상 변화 시 재분석을 수행한다.', - ], - }, - { - id: '044', - name: '긴급구난 목록', - menuPath: '긴급구난', - imageIndex: 44, - overview: '완료 또는 진행 중인 긴급구난 예측 분석 이력을 목록 형식으로 조회하는 화면이다.', - procedure: [ - "상단 메뉴에서 '긴급구난'을 클릭하면 목록 화면이 표시된다.", - '원하는 분석을 검색하거나 목록에서 선택한다.', - '사고명 링크를 클릭하여 상세 분석 결과로 이동한다.', - ], - }, - { - id: '045', - name: '시나리오 상세', - menuPath: '긴급구난 > 시나리오 관리', - imageIndex: 45, - overview: - '긴급구난 사고에 대해 기상·해양 조건을 달리 설정한 복수 시나리오를 생성·비교 관리하는 화면이다.', - procedure: [ - "'긴급구난 > 시나리오 관리'를 클릭하여 이동한다.", - "생성된 시나리오 카드를 확인하거나 '신규 시나리오' 버튼으로 추가 생성한다.", - '시나리오 카드를 클릭하여 상세 내용을 확인한다.', - ], - }, - { - id: '046', - name: '비교 차트', - menuPath: '긴급구난 > 시나리오 관리', - imageIndex: 46, - overview: - '긴급구난 시나리오별 탐색 면적·표류 속도·최대 이동거리를 시간별 변화 차트로 비교하는 화면이다.', - }, - { - id: '047', - name: '지도 오버레이', - menuPath: '긴급구난 > 시나리오 관리', - imageIndex: 47, - overview: - '긴급구난 시나리오의 시간대별 표류 예측 궤적 및 탐색 구역을 지도에 오버레이 표출하는 화면이다.', - }, - { - id: '048', - name: '긴급구난모델 이론', - menuPath: '긴급구난', - imageIndex: 48, - overview: - '긴급구난 표류 예측에 적용되는 라그랑지안 표류 모델·탐색 구역(Search Area) 산출 방법·IMO SAR 기준 적용 이론을 안내한다.', - }, - ], - }, - { - id: 'ch04', - number: '04', - title: '보고자료', - subtitle: 'Reports', - screens: [ - { - id: '049', - name: '보고서 목록', - menuPath: '보고자료', - imageIndex: 49, - overview: - '시스템에서 생성된 모든 사고 대응 보고서를 목록 형식으로 관리하는 화면이다. 보고서명·사고명·생성일시·유형·작성자·상태가 표시된다.', - procedure: [ - "상단 메뉴에서 '보고자료'를 클릭하여 보고서 목록으로 이동한다.", - '검색창에서 원하는 보고서를 검색하거나 목록에서 선택한다.', - '보고서명을 클릭하여 미리보기하거나 PDF를 다운로드한다.', - ], - }, - { - id: '050', - name: '초기보고서', - menuPath: '보고자료 > 표준보고서 템플릿', - imageIndex: 50, - overview: - '사고 발생 초기 지휘부 보고를 위한 표준 보고서 템플릿을 제공한다. 확산예측 결과와 사고 기본정보가 자동 연동된다.', - procedure: [ - "보고자료 메뉴에서 '표준보고서 템플릿'을 클릭한다.", - "좌측 사이드바에서 '초기보고서'를 선택한다.", - '자동 입력된 항목을 검토하고 필요 내용을 추가 입력한다.', - "'저장' 또는 'PDF 출력' 버튼을 활용한다.", - ], - }, - { - id: '051', - name: '지휘부 보고', - menuPath: '보고자료 > 표준보고서 템플릿', - imageIndex: 51, - overview: - '사고 지휘부 보고용 표준 보고서 템플릿. 방제 대응 현황·자원 투입 현황·향후 계획이 포함된다.', - }, - { - id: '052', - name: '예측보고서', - menuPath: '보고자료 > 표준보고서 템플릿', - imageIndex: 52, - overview: - '확산예측 결과를 포함한 예측보고서 템플릿. 모델 종류·예측 시간·주요 결과가 자동 연동된다.', - }, - { - id: '053', - name: '종합보고서', - menuPath: '보고자료 > 표준보고서 템플릿', - imageIndex: 53, - overview: - '사고 종료 후 방제 대응 전 과정을 정리하는 종합보고서. 사고개요·대응경과·방제실적·환경영향평가·교훈이 포함된다.', - }, - { - id: '054', - name: '유출유 보고', - menuPath: '보고자료 > 표준보고서 템플릿', - imageIndex: 54, - overview: - '유출유 사고 전용 표준 보고서 템플릿. 확산예측·오일펜스 배치·풍화 상태가 자동 연동된다.', - }, - { - id: '055', - name: '유출유 확산예측 보고서 생성', - menuPath: '보고자료 > 보고서 생성', - imageIndex: 55, - overview: '유출유 확산예측 분석 결과를 기반으로 보고서를 자동 생성하는 화면이다.', - procedure: [ - "보고자료 메뉴에서 '보고서 생성'을 클릭한다.", - "'유출유 확산예측' 유형을 선택한다.", - '연동할 분석 결과를 드롭다운에서 선택한다.', - '보고 대상을 선택한다.', - "'보고서 생성' 버튼을 클릭한다.", - ], - inputs: [ - { label: '분석 결과', type: '드롭다운', required: true, desc: '연동할 분석 건 선택' }, - { label: '보고 대상', type: '체크박스', required: true, desc: '지휘부·현장·외부 기관' }, - ], - }, - { - id: '056', - name: 'HNS 대기확산 보고서 생성', - menuPath: '보고자료 > 보고서 생성', - imageIndex: 56, - overview: 'HNS 대기확산 분석 결과를 기반으로 보고서를 자동 생성하는 화면이다.', - }, - { - id: '057', - name: '긴급구난 보고서 생성', - menuPath: '보고자료 > 보고서 생성', - imageIndex: 57, - overview: - '긴급구난 예측 분석 결과를 기반으로 구조 현장 지휘부용 보고서를 자동 생성하는 화면이다.', - }, - ], - }, - { - id: 'ch05', - number: '05', - title: '항공탐색', - subtitle: 'Aerial Surveillance', - screens: [ - { - id: '058', - name: '영상사진관리', - menuPath: '항공탐색', - imageIndex: 58, - overview: - '드론·항공기·위성에서 수집된 영상 및 사진을 통합 관리하는 화면이다. 촬영 일시·위치·기기 유형별 분류 조회와 유출유 면적분석 연계를 지원한다.', - procedure: [ - "상단 메뉴에서 '항공탐색 > 영상사진관리'를 클릭한다.", - '목록에서 원하는 영상/사진을 선택하거나 지도 마커를 클릭하여 확인한다.', - "신규 파일 업로드 시 '파일 업로드' 버튼을 클릭한다.", - ], - inputs: [ - { label: '업로드 파일', type: '파일', required: false, desc: 'JPG·PNG·GeoTIFF·KMZ' }, - { label: '촬영 일시', type: '날짜+시간', required: false, desc: '촬영 시점' }, - { label: '장비 유형', type: '드롭다운', required: false, desc: '드론·항공기·위성·CCTV' }, - ], - notes: ['50MB 초과 파일은 업로드 전 압축하거나 관리자에게 문의한다.'], - }, - { - id: '059', - name: '유출유면적분석', - menuPath: '항공탐색', - imageIndex: 59, - overview: - '항공·위성 이미지를 기반으로 유출유 오염 면적을 AI 자동 산출하는 화면이다. 자동 추출된 오염 경계선을 수동 편집하여 정밀 면적을 산정할 수 있다.', - procedure: [ - "영상사진관리에서 분석 대상 이미지를 선택하고 '면적 분석' 버튼을 클릭한다.", - "'AI 자동 분석 실행' 버튼을 클릭하여 오염 경계선을 추출한다.", - '폴리곤 경계선을 검토하고 필요 시 편집 도구로 수동 조정한다.', - '최종 면적·둘레·중심 좌표를 확인하고 저장한다.', - ], - }, - { - id: '060', - name: '실시간드론', - menuPath: '항공탐색', - imageIndex: 60, - overview: - '현장 드론에서 실시간 전송되는 영상 스트리밍을 시스템 내에서 직접 모니터링하는 화면이다.', - procedure: [ - "상단 메뉴에서 '항공탐색 > 실시간 드론'을 클릭한다.", - '연결된 드론 목록에서 모니터링할 드론을 선택한다.', - '실시간 영상을 확인하고 필요 시 스냅샷을 저장한다.', - ], - notes: ['드론 스트리밍은 네트워크 연결 상태에 따라 화질 및 지연이 달라질 수 있다.'], - }, - { - id: '061', - name: '오염선박 3D분석', - menuPath: '항공탐색', - imageIndex: 61, - overview: - '항공·위성 이미지를 기반으로 오염 선박의 3D 모델을 생성하고 오염 분포를 입체적으로 분석하는 화면이다.', - }, - { - id: '062', - name: '위성요청', - menuPath: '항공탐색', - imageIndex: 62, - overview: 'SAR·광학 위성 촬영 요청 및 수신 결과를 관리하는 화면이다.', - inputs: [ - { label: '요청 위치', type: '숫자', required: true, desc: '위·경도' }, - { label: '촬영 희망 일시', type: '날짜+시간', required: true, desc: '촬영 필요 일시' }, - { label: '위성 종류', type: '라디오', required: true, desc: 'SAR 또는 광학' }, - { label: '해상도', type: '드롭다운', required: false, desc: '요청 이미지 해상도' }, - ], - notes: [ - '위성 촬영 가능 여부는 궤도 조건에 따라 결정되며 요청이 항상 수용되지 않을 수 있다.', - ], - }, - { - id: '063', - name: 'CCTV 조회', - menuPath: '항공탐색', - imageIndex: 63, - overview: - '해안·항만 CCTV의 실시간 영상을 조회하는 화면이다. 사고 인근 CCTV를 지도에서 선택하여 스트리밍으로 확인할 수 있다.', - procedure: [ - "상단 메뉴에서 '항공탐색 > CCTV조회'를 클릭한다.", - '지도에서 확인할 CCTV 마커를 클릭한다.', - '실시간 영상을 확인하고 필요 시 스냅샷을 저장한다.', - ], - }, - { - id: '064', - name: '항공탐색 이론 - 개요', - menuPath: '항공탐색 > 항공탐색 이론', - imageIndex: 64, - overview: - 'Wing 항공탐색 기능에 적용되는 원격탐사·ESI 방제지도·면적 산정·확산예측 연계 이론의 전체 개요를 안내한다.', - }, - { - id: '065', - name: '항공탐색 이론 - 탐지 장비', - menuPath: '항공탐색 > 항공탐색 이론', - imageIndex: 65, - overview: - '해양 오염 항공 탐지에 사용되는 주요 장비(SAR·적외선 카메라·UV 센서·광학 카메라)의 특성과 적용 원리를 안내한다.', - }, - { - id: '066', - name: '항공탐색 이론 - 원격탐사', - menuPath: '항공탐색 > 항공탐색 이론', - imageIndex: 66, - overview: - '위성 및 항공 원격탐사(Remote Sensing)의 기본 원리와 해양 오염 탐지 적용 방법을 안내한다.', - }, - { - id: '067', - name: '항공탐색 이론 - ESI 방제지도', - menuPath: '항공탐색 > 항공탐색 이론', - imageIndex: 67, - overview: - '환경민감지수(ESI) 방제지도의 해안선 민감도 등급 분류 체계와 방제 우선순위 결정 방법을 안내한다.', - }, - { - id: '068', - name: '항공탐색 이론 - 면적 산정', - menuPath: '항공탐색 > 항공탐색 이론', - imageIndex: 68, - overview: - '항공·위성 이미지 기반 유출유 면적 산정에 적용되는 AI 알고리즘(객체 탐지·영상 분할)의 원리를 안내한다.', - }, - { - id: '069', - name: '항공탐색 이론 - 확산예측 연계', - menuPath: '항공탐색 > 항공탐색 이론', - imageIndex: 69, - overview: - '항공 탐색 결과(실측 면적·위치)와 유출유 확산예측 모델 보정 연계 방법을 안내한다.', - }, - { - id: '070', - name: '항공탐색 이론 - 논문 특허', - menuPath: '항공탐색 > 항공탐색 이론', - imageIndex: 70, - overview: 'Wing 항공탐색 기능의 기반이 되는 관련 논문과 특허 목록을 안내한다.', - }, - ], - }, - { - id: 'ch06', - number: '06', - title: '게시판', - subtitle: 'Bulletin Board', - screens: [ - { - id: '071', - name: '전체 게시판', - menuPath: '게시판', - imageIndex: 71, - overview: '공지사항·자료실·Q&A·해경매뉴얼 게시물을 통합 조회하는 전체 게시판 화면이다.', - procedure: [ - "상단 메뉴에서 '게시판'을 클릭하여 전체 게시판으로 이동한다.", - '유형 필터 탭을 선택하거나 검색창에 키워드를 입력한다.', - '원하는 게시물 제목을 클릭하여 상세 내용을 확인한다.', - ], - }, - { - id: '072', - name: '공지사항', - menuPath: '게시판', - imageIndex: 72, - overview: - '시스템 공지·업데이트·장애 안내 등 운영 공지사항을 게시하는 화면이다. 관리자만 등록·수정 가능.', - }, - { - id: '073', - name: '자료실', - menuPath: '게시판', - imageIndex: 73, - overview: '매뉴얼·지침서·참고자료 등 업무 관련 문서 파일을 공유하는 게시판 화면이다.', - }, - { - id: '074', - name: 'QNA', - menuPath: '게시판', - imageIndex: 74, - overview: '시스템 사용 관련 질문을 등록하고 답변을 확인하는 Q&A 게시판 화면이다.', - inputs: [ - { label: '제목', type: '텍스트', required: true, desc: '질문 제목' }, - { - label: '카테고리', - type: '드롭다운', - required: true, - desc: '기능문의·오류신고·개선요청', - }, - { label: '내용', type: '텍스트', required: true, desc: '질문 내용' }, - { label: '첨부 파일', type: '파일', required: false, desc: '스크린샷 등' }, - ], - }, - { - id: '075', - name: '해경 매뉴얼', - menuPath: '게시판', - imageIndex: 75, - overview: - '해양경찰청 방제·대응 매뉴얼을 시스템 내에서 바로 조회할 수 있는 화면이다. 관리자만 등록·수정 가능.', - }, - ], - }, - { - id: 'ch07', - number: '07', - title: '기상정보', - subtitle: 'Weather', - screens: [ - { - id: '076', - name: '기상정보', - menuPath: '기상정보', - imageIndex: 76, - overview: - '현재 사고 지역 주변의 실시간 기상 정보(풍향·풍속·기온·파고·시정 등)를 조회하는 화면이다. 기상 데이터는 확산예측 모델 입력으로 자동 연동된다.', - description: - '사고 위치 기준 반경 내 기상 관측소 목록과 최신 관측값이 표시된다. 시계열 그래프로 과거 24시간 기상 변화를 확인할 수 있다.', - procedure: [ - "상단 메뉴에서 '기상정보'를 클릭하여 이동한다.", - '지도에서 관측소 마커를 클릭하거나 목록에서 관측소를 선택한다.', - '최신 기상값과 시계열 그래프를 확인한다.', - ], - notes: [ - '극한 기상 조건에서는 확산예측 정확도가 낮아질 수 있으므로 현장 기상 관측값과 병행 확인한다.', - ], - }, - ], - }, - { - id: 'ch08', - number: '08', - title: '통합조회', - subtitle: 'Integrated Search', - screens: [ - { - id: '077', - name: '통합조회', - menuPath: '통합조회', - imageIndex: 77, - overview: - 'Wing 시스템 전체 분석 이력(유출유·HNS·긴급구난·보고서)을 통합 조회하는 화면이다. 사고명·날짜·분석 유형·담당자 등 복합 조건으로 검색하고 결과를 지도와 목록으로 표출한다.', - description: - '화면 좌측에 날짜 범위·분석 유형·담당자·사고 지역 복합 필터 패널이 위치한다. 중앙 지도에 검색 결과 사고지점 마커가 표출된다. Excel·CSV 내보내기를 지원한다.', - procedure: [ - "상단 메뉴에서 '통합조회'를 클릭하여 이동한다.", - '날짜 범위·분석 유형 등 필터 조건을 설정한다.', - "'검색' 버튼을 클릭하여 결과를 조회한다.", - '지도 마커 또는 목록 항목을 클릭하여 해당 분석 결과 상세 화면으로 이동한다.', - 'Excel·CSV 내보내기 버튼으로 이력 자료를 저장한다.', - ], - inputs: [ - { label: '날짜 범위', type: '날짜', required: false, desc: '시작·종료 날짜' }, - { - label: '분석 유형', - type: '체크박스', - required: false, - desc: '유출유·HNS·긴급구난·보고서', - }, - { label: '담당자', type: '텍스트', required: false, desc: '이름 필터' }, - { label: '사고 지역', type: '텍스트', required: false, desc: '지역명 필터' }, - ], - }, - ], - }, -]; - -const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => { - const [selectedChapterId, setSelectedChapterId] = useState('ch01'); - const [expandedScreenIds, setExpandedScreenIds] = useState>(new Set()); - const [lightboxSrc, setLightboxSrc] = useState(null); - - if (!isOpen) return null; - - const selectedChapter = CHAPTERS.find((ch) => ch.id === selectedChapterId) ?? CHAPTERS[0]; - - const toggleScreen = (screenId: string) => { - setExpandedScreenIds((prev) => { - const next = new Set(prev); - if (next.has(screenId)) { - next.delete(screenId); - } else { - next.add(screenId); - } - return next; - }); - }; - - const expandAll = () => { - setExpandedScreenIds(new Set(selectedChapter.screens.map((s) => s.id))); - }; - - const collapseAll = () => { - setExpandedScreenIds(new Set()); - }; - - const allExpanded = - selectedChapter.screens.length > 0 && - selectedChapter.screens.every((s) => expandedScreenIds.has(s.id)); - - return ( - <> -
{ - if (e.target === e.currentTarget) onClose(); - }} - > -
- {/* Header */} -
-
- - Wing 사용자 매뉴얼 - - - v0.5 - -
- -
- - {/* Body */} -
- {/* Left Sidebar */} -
- {CHAPTERS.map((chapter) => { - const isActive = chapter.id === selectedChapterId; - return ( - - ); - })} -
- - {/* Right Content */} -
- {/* Chapter heading */} -
-
-
- - CH {selectedChapter.number} - -

- {selectedChapter.title} -

- - {selectedChapter.subtitle} - -
-
- - {selectedChapter.screens.length}개 화면 - - -
-
-
- - {/* Screen cards */} -
- {selectedChapter.screens.map((screen) => { - const isExpanded = expandedScreenIds.has(screen.id); - const imageSrc = `/manual/image${screen.imageIndex}.png`; - return ( -
- {/* Screen header (toggle) */} - - - {/* Screen detail (expanded) */} - {isExpanded && ( -
- {/* Screenshot image */} -
- {screen.name} setLightboxSrc(imageSrc)} - style={{ - width: '100%', - borderRadius: '6px', - border: '1px solid #1e2a45', - cursor: 'zoom-in', - display: 'block', - }} - /> -

- 이미지를 클릭하면 크게 볼 수 있다 -

-
- - {/* Menu path breadcrumb */} -
- {screen.menuPath} -
- - {/* Overview */} -
-

- {screen.overview} -

-
- - {/* Description */} - {screen.description && ( -
-
- 화면 설명 -
-

- {screen.description} -

-
- )} - - {/* Procedure */} - {screen.procedure && screen.procedure.length > 0 && ( -
-
- 사용 절차 -
-
    - {screen.procedure.map((step, idx) => ( -
  1. - - {idx + 1} - - - {step} - -
  2. - ))} -
-
- )} - - {/* Inputs */} - {screen.inputs && screen.inputs.length > 0 && ( -
-
- 입력 항목 -
-
- - - - - - - - - - - {screen.inputs.map((input, idx) => ( - - - - - - - ))} - -
- 항목 - - 유형 - - 필수 - - 설명 -
- {input.label} - - {input.type} - - {input.required ? ( - - 필수 - - ) : ( - - 선택 - - )} - - {input.desc} -
-
-
- )} - - {/* Notes */} - {screen.notes && screen.notes.length > 0 && ( -
-
- 유의사항 -
-
    - {screen.notes.map((note, idx) => ( -
  • - - - {note} - -
  • - ))} -
-
- )} -
- )} -
- ); - })} -
-
-
-
-
- - {/* Lightbox */} - {lightboxSrc !== null && ( -
setLightboxSrc(null)} - > -
e.stopPropagation()} - > - 확대 이미지 - -
-
- )} - - ); -}; - -export default UserManualPopup; diff --git a/frontend/src/common/data/chapters.json b/frontend/src/common/data/chapters.json new file mode 100644 index 0000000..eec27cb --- /dev/null +++ b/frontend/src/common/data/chapters.json @@ -0,0 +1,1187 @@ +[ + { + "id": "ch01", + "number": "01", + "title": "유출유 확산예측", + "subtitle": "Oil Spill Dispersion Prediction", + "screens": [ + { + "id": "001", + "name": "직접입력", + "menuPath": "유출유확산예측 > 유출유확산분석", + "imageIndex": 1, + "overview": "해양 유출유 사고 발생 시 오염원 위치와 유출 조건을 직접 입력하여 확산 범위를 예측하는 주요 분석 화면이다. KOSPS·POSEIDON·OpenDrift·앙상블의 4종 수치 모델을 선택적으로 적용할 수 있다. 실시간 기상·해양 데이터와 연계하여 즉시 예측 결과를 지도에 표출한다.", + "description": "화면 좌측에 예측정보 입력 패널(사고명, 위치, 유종, 유출량, 예측 시간)이 위치한다. 중앙 지도에서 클릭하여 사고 발생 위치를 직접 지정하거나 위·경도 좌표를 수동 입력할 수 있다. 하단 타임라인 슬라이더로 시간 경과에 따른 확산 경과를 재생할 수 있다. 우측 '분석 요약' 패널에서 예측 면적, 이동거리, 이동 방향, 풍화 비율 등을 확인할 수 있다.", + "procedure": [ + "상단 메뉴에서 '유출유확산예측 > 유출유확산분석'을 클릭하여 화면을 이동한다.", + "좌측 패널에서 사고명, 날짜, 유종, 유출량, 예측 시간을 입력한다.", + "지도를 클릭하거나 좌표 입력란에 위·경도를 직접 입력하여 사고 위치를 지정한다.", + "적용할 확산 모델(KOSPS·POSEIDON·OpenDrift·앙상블) 체크박스를 선택한다.", + "'확산예측 실행' 버튼을 클릭하여 예측을 시작한다.", + "하단 타임라인 재생 버튼으로 시간별 확산 결과를 확인한다." + ], + "inputs": [ + { + "label": "사고명", + "type": "텍스트", + "required": true, + "desc": "사고 식별 명칭" + }, + { + "label": "날짜/시간", + "type": "날짜+시간", + "required": true, + "desc": "사고 발생 일시" + }, + { + "label": "위도/경도", + "type": "숫자", + "required": true, + "desc": "지도 클릭 자동 입력 가능" + }, + { + "label": "유종", + "type": "드롭다운", + "required": true, + "desc": "벙커C유·경유·연료유 등" + }, + { + "label": "유출량", + "type": "숫자 kL", + "required": true, + "desc": "유출 유류 총량" + }, + { + "label": "예측 시간", + "type": "숫자 h", + "required": true, + "desc": "확산 예측 기간" + }, + { + "label": "적용 모델", + "type": "체크박스", + "required": true, + "desc": "KOSPS·POSEIDON·OpenDrift·앙상블 중 선택" + } + ], + "notes": [ + "좌표 입력 후 반드시 '적용' 버튼을 클릭해야 지도에 반영된다.", + "앙상블 모델 선택 시 3개 모델이 동시 실행되어 계산 시간이 증가할 수 있다.", + "유출량이 0 이하이거나 예측 시간이 입력되지 않으면 실행 버튼이 비활성화된다." + ] + }, + { + "id": "002", + "name": "모델종류별 확산예측 실행", + "menuPath": "유출유확산예측 > 유출유확산분석", + "imageIndex": 2, + "overview": "KOSPS·POSEIDON·OpenDrift 3개 모델의 예측 결과를 동시에 지도에 표출하여 모델 간 비교 분석을 지원한다. 모델별 입자 궤적(파란점·빨간점·하늘점)이 중첩 표시되어 확산 경향의 편차를 한눈에 파악할 수 있다.", + "description": "지도에 모델별 색상 구분 입자가 동시에 표시되며, 각 모델의 확산 방향과 범위 차이를 시각적으로 비교할 수 있다. 우측 '분석 요약' 패널에 예측 면적(km2), 이동거리(km), 방향, 이동속도(cm/s)가 표출된다. 풍화 상태 막대그래프(유출량·해면연소·자연분산·수중분산·잔류 비율)를 제공한다. '다각형 분석수행' 버튼으로 특정 구역 내 오염 면적을 별도 산정할 수 있다.", + "procedure": [ + "확산분석 화면에서 적용 모델 체크박스를 2개 이상 선택한다.", + "'확산예측 실행' 버튼을 클릭한다.", + "지도에서 각 모델의 입자 분포와 확산 범위를 비교한다.", + "우측 '분석 요약' 탭에서 모델별 정량 지표를 확인한다.", + "하단 타임라인으로 시간 경과별 확산 변화를 재생한다." + ], + "notes": [ + "여러 모델을 동시에 선택하면 계산 자원 소모가 증가하여 결과 표출까지 수 분이 소요될 수 있다.", + "모델별 예측 결과에 차이가 있을 경우, 앙상블 결과 또는 현장 데이터와 교차 검토하여 활용한다." + ] + }, + { + "id": "003", + "name": "사고정보 조회", + "menuPath": "유출유확산예측 > 유출유확산분석", + "imageIndex": 3, + "overview": "시스템에 등록된 실제 사고 정보를 불러와 확산분석에 자동 연동하는 기능이다. 사고코드·선박명·유종·유출량·발생 좌표 등이 자동으로 예측정보 입력란에 채워진다.", + "description": "좌측 '사고정보' 패널에 진행 중인 사고 목록이 표시된다. 사고를 선택하면 지도 중심이 해당 사고 발생 위치로 자동 이동하고 위치 핀이 표시된다. 사고 상태는 '진행중(빨간 배지)'과 '종료(회색 배지)'로 구분된다.", + "procedure": [ + "좌측 '사고정보' 패널의 펼침 버튼을 클릭하여 패널을 연다.", + "목록에서 분석할 사고를 클릭한다.", + "사고 정보가 예측정보 입력란에 자동으로 입력되었는지 확인한다.", + "필요 시 유출량·예측 시간 등 추가 정보를 수정한다.", + "'확산예측 실행'을 클릭하여 분석을 시작한다." + ], + "notes": [ + "'진행중' 상태의 사고만 실시간 확산예측과 연동된다.", + "사고 데이터는 관계 기관으로부터 등록된 정보를 기반으로 하며, 입력 오류 시 관리자에게 문의한다." + ] + }, + { + "id": "004", + "name": "영향 민감자원 레이어 중첩 표시", + "menuPath": "유출유확산예측 > 유출유확산분석", + "imageIndex": 4, + "overview": "유출유 확산 예측 결과와 해양 민감자원(환경생태·수산자원·관광지 등) 레이어를 지도에 동시 표출하는 기능이다. 잠재적 피해 자원을 사전에 파악하여 방제 우선순위 설정에 활용한다.", + "description": "좌측 하단 '정보 레이어' 패널에서 환경생태·사회경제·민감도평가·해경관할구역 등 대분류 토글을 제공한다. 각 항목 우측에 해당 레이어 데이터 건수가 표시된다. '전체 켜기/끄기' 버튼으로 모든 레이어를 일괄 제어할 수 있다.", + "procedure": [ + "좌측 하단 '정보 레이어' 패널을 열고 원하는 레이어 항목을 활성화한다.", + "확산예측 실행 후 지도에 표출된 오염 범위와 레이어 분포를 비교한다.", + "오염 영향이 우려되는 자원 레이어를 클릭하여 상세 정보를 확인한다.", + "필요한 레이어 조합을 선택하여 보고서용 지도 캡처에 활용한다." + ], + "notes": [ + "레이어 수가 많을 경우 지도 로딩 속도가 느려질 수 있으므로 필요한 레이어만 선택적으로 활성화한다." + ] + }, + { + "id": "005", + "name": "AI자동추천 배치안 적용", + "menuPath": "유출유확산예측 > 유출유확산분석 > 오일펜스 배치 가이드", + "imageIndex": 5, + "overview": "확산예측 결과를 기반으로 AI(NSGA-II 알고리즘)가 최적 오일펜스 배치안을 자동으로 생성하는 기능이다. 최대 3개 방어선의 위치·방향·총 길이·차단율을 자동 계산하여 지도에 표출한다.", + "description": "'오일펜스 배치 가이드' 패널 상단 'AI자동 추천' 탭에서 배치 결과를 확인한다. 1차~3차 방어선별 배치 위치(좌표), 방향, 오일펜스 길이, 예상 차단율이 표시된다.", + "procedure": [ + "확산예측 실행 완료 후 좌측 '오일펜스 배치 가이드' 패널을 연다.", + "'AI자동 추천' 탭을 선택하여 추천 배치안을 확인한다.", + "방어선별 차단율 및 오일펜스 총 길이를 검토한다.", + "'추천 배치안 적용하기' 버튼을 클릭하여 지도에 표출한다.", + "현장 여건(조류·지형·자원 현황)을 고려하여 최종 배치안을 확정한다." + ], + "inputs": [ + { + "label": "배치 수", + "type": "숫자", + "required": false, + "desc": "투입할 오일펜스 방어선 수" + }, + { + "label": "최소 차단율", + "type": "숫자 %", + "required": false, + "desc": "목표 차단율" + } + ], + "notes": [ + "AI 추천 기능은 확산예측이 완료된 후에 활성화된다.", + "추천 배치안은 수치 모델 기반 결과로, 현장 조류 변동·수심·접근 가능성 등을 반드시 현장 담당자가 최종 확인해야 한다." + ] + }, + { + "id": "006", + "name": "이미지업로드", + "menuPath": "유출유확산예측 > 유출유확산분석", + "imageIndex": 6, + "overview": "위성·드론·항공기에서 촬영한 이미지를 업로드하여 실제 오염 현황과 모델 예측 결과를 비교 분석하는 기능이다.", + "description": "예측정보 입력 패널 상단 '이미지 업로드' 탭을 선택하면 파일 업로드 영역이 활성화된다. 업로드된 이미지는 지도에 자동으로 오버레이 표출된다.", + "procedure": [ + "예측정보 입력 패널에서 '이미지 업로드' 탭을 선택한다.", + "'파일 선택' 버튼을 클릭하거나 파일을 드래그하여 업로드한다.", + "업로드된 이미지가 지도에 올바르게 표출되는지 위치를 확인한다.", + "확산예측 결과와 이미지를 동시에 표출하여 비교한다." + ], + "inputs": [ + { + "label": "업로드 파일", + "type": "파일 PNG/JPG", + "required": false, + "desc": "위성·드론·항공기 촬영 이미지" + } + ], + "notes": [ + "지원 파일 형식은 PNG, JPG이다.", + "업로드한 이미지의 좌표 기준(GCP)이 없을 경우 지도 상 위치 정확도가 낮을 수 있다." + ] + }, + { + "id": "007", + "name": "분석 목록", + "menuPath": "유출유확산예측", + "imageIndex": 7, + "overview": "완료 또는 진행 중인 유출유 확산예측 분석 이력을 목록 형식으로 조회하는 화면이다. 사고명·날짜·유종·유출량·모델 상태 등을 한눈에 파악할 수 있다.", + "description": "목록에는 번호·사고명·사고일시·예측시간·유종·유출량·모델별 상태(KOSPS·POSEIDON·OPENDRIFT)·담당자 등이 표시된다. 모델 상태는 '완료(녹색 배지)'와 '대기(회색 배지)'로 구분된다.", + "procedure": [ + "상단 메뉴에서 '유출유확산예측'을 클릭하여 분석 목록 화면으로 이동한다.", + "검색창에 사고명을 입력하거나 페이지를 이동하여 원하는 분석을 찾는다.", + "사고명 링크를 클릭하여 해당 분석 결과 상세 화면으로 이동한다.", + "신규 분석이 필요한 경우 우측 상단 '+ 새 분석' 버튼을 클릭한다." + ], + "notes": [ + "분석 결과 삭제 시 복구가 불가하므로, 삭제 전 보고서 출력 또는 데이터 저장 여부를 확인한다." + ] + }, + { + "id": "008", + "name": "분석 조회(test001)", + "menuPath": "유출유확산예측 > 분석 목록", + "imageIndex": 8, + "overview": "분석 목록에서 특정 분석 건을 선택하면 해당 확산분석 결과 화면으로 이동하여 상세 내용을 조회한다. 이전 분석 결과를 재검토하거나 조건 변경 후 재분석할 수 있다.", + "procedure": [ + "분석 목록에서 조회할 사고명 링크를 클릭한다.", + "로드된 분석 정보를 확인한다.", + "필요 시 입력 조건을 수정하고 '확산예측 실행'을 다시 클릭하여 재분석한다.", + "결과를 보고서로 출력하거나 저장한다." + ], + "notes": [ + "기존 분석에서 모델 조건 변경 후 재실행 시 이전 결과가 덮어써질 수 있으므로, 원본 결과를 먼저 저장한다." + ] + }, + { + "id": "009", + "name": "유출유확산모델 이론 - 시스템 개요", + "menuPath": "유출유확산예측 > 유출유확산모델 이론", + "imageIndex": 9, + "overview": "Wing 시스템에 탑재된 유출유 확산 수치 모델의 개요와 운용 체계를 안내하는 이론 화면이다. KOSPS·POSEIDON·OpenDrift 3종 모델의 특징·비교·데이터 흐름을 확인할 수 있다.", + "procedure": [ + "상단 메뉴에서 '유출유확산예측 > 유출유확산모델 이론'을 클릭한다.", + "'시스템 개요' 탭을 선택하여 전체 모델 체계를 확인한다.", + "각 탭(KOSPS·POSEIDON·OpenDrift 등)을 클릭하여 상세 이론을 열람한다." + ] + }, + { + "id": "010", + "name": "유출유확산모델 이론 - KOSPS", + "menuPath": "유출유확산예측 > 유출유확산모델 이론", + "imageIndex": 10, + "overview": "한국해양과학기술원(KIOST)이 개발한 KOSPS 모델의 상세 이론을 안내한다. 국내 연안 특성에 최적화된 조류예측(CHARRY) 및 풍류 경험식 적용 원리를 설명한다.", + "notes": [ + "KOSPS는 국내 연안 중심으로 검증된 모델로, 원해·심해 적용 시 정확도 제한이 있을 수 있다." + ] + }, + { + "id": "011", + "name": "유출유확산모델 이론 - POSEIDON", + "menuPath": "유출유확산예측 > 유출유확산모델 이론", + "imageIndex": 11, + "overview": "입자추적 최적화 예측 시스템 POSEIDON의 이론 및 MOHID 3D 해양순환모델 적용 원리를 안내한다. GA·DE·HS·PSO 등 4종 최적화 알고리즘을 통한 파라미터 자동 최적화 방법을 설명한다." + }, + { + "id": "012", + "name": "유출유확산모델 이론 - OpenDrift", + "menuPath": "유출유확산예측 > 유출유확산모델 이론", + "imageIndex": 12, + "overview": "노르웨이 MET Norway가 개발한 오픈소스 라그랑지안 확산 프레임워크 OpenDrift의 이론을 안내한다. NEMO·ROMS·HYCOM·Copernicus CMEMS 등 다양한 해양 예보 모델과 연동 가능한 범용성을 설명한다." + }, + { + "id": "013", + "name": "유출유확산모델 이론 - 입자추적법", + "menuPath": "유출유확산예측 > 유출유확산모델 이론", + "imageIndex": 13, + "overview": "유출유 수치 모델에 적용되는 라그랑지안 입자추적법(Lagrangian Particle Tracking Method)의 수학적 이론과 수식을 안내한다." + }, + { + "id": "014", + "name": "유출유확산모델 이론 - 풍화 프로세스", + "menuPath": "유출유확산예측 > 유출유확산모델 이론", + "imageIndex": 14, + "overview": "유출유가 시간 경과에 따라 겪는 풍화(Weathering) 프로세스(증발·유화·자연분산 등)의 이론과 타임라인을 안내한다.", + "notes": [ + "풍화 속도는 유종·기온·해수온·파랑 조건에 따라 크게 달라질 수 있으므로 참고용으로 활용한다." + ] + }, + { + "id": "015", + "name": "유출유확산모델 이론 - 해양환경 입력", + "menuPath": "유출유확산예측 > 유출유확산모델 이론", + "imageIndex": 15, + "overview": "유출유 확산 수치 모델에 사용되는 해양환경 입력 데이터(기상·해류·조류·해수면 온도) 체계를 안내한다. KMA RDAPS·ECMWF·NIFS ROMS·HYCOM·TPXO9 등 데이터 소스 설명." + }, + { + "id": "016", + "name": "유출유확산모델 이론 - 모델 검증", + "menuPath": "유출유확산예측 > 유출유확산모델 이론", + "imageIndex": 16, + "overview": "유출유 확산 모델의 정확도 검증 사례(2007 허베이스피리트, 2014 무이산 등)와 RMSE·Skill Score 통계 지표를 안내한다." + }, + { + "id": "017", + "name": "유출유확산모델 이론 - 앙상블", + "menuPath": "유출유확산예측 > 유출유확산모델 이론", + "imageIndex": 17, + "overview": "KOSPS·POSEIDON·OpenDrift 3종 모델의 결과를 통합하는 앙상블 예측 방법론과 가중평균 알고리즘을 안내한다. 최악 시나리오(Worst Case) 산출 방법도 설명한다." + }, + { + "id": "018", + "name": "유출유확산모델 이론 - 발전 방향", + "menuPath": "유출유확산예측 > 유출유확산모델 이론", + "imageIndex": 18, + "overview": "현재 유출유 확산 모델의 한계와 향후 개선 방향(4단계 로드맵)을 안내한다." + }, + { + "id": "019", + "name": "오일펜스 배치 알고리즘 이론 - 개요", + "menuPath": "유출유확산예측 > 오일펜스 배치 알고리즘 이론", + "imageIndex": 19, + "overview": "오일펜스 최적 배치 알고리즘의 전체 개요와 최적화 목표(차단 면적 최대화·도달시간 최소화·자원 효율화)를 안내한다." + }, + { + "id": "020", + "name": "오일펜스 배치 알고리즘 이론 - 배치 이론", + "menuPath": "유출유확산예측 > 오일펜스 배치 알고리즘 이론", + "imageIndex": 20, + "overview": "차단 효율 함수(E(theta, U))와 V형·U형·J형 배치 형태별 이론적 차단 원리를 안내한다. 다단계 차단선 배치 시 총 차단 효율 산출 공식도 포함한다." + }, + { + "id": "021", + "name": "오일펜스 배치 알고리즘 이론 - 최적화 알고리즘", + "menuPath": "유출유확산예측 > 오일펜스 배치 알고리즘 이론", + "imageIndex": 21, + "overview": "NSGA-II(Non-dominated Sorting Genetic Algorithm II) 기반 다목적 최적화 알고리즘의 원리와 보조 알고리즘(PSO·Greedy) 비교를 안내한다." + }, + { + "id": "022", + "name": "오일펜스 배치 알고리즘 이론 - 유체역학 모델", + "menuPath": "유출유확산예측 > 오일펜스 배치 알고리즘 이론", + "imageIndex": 22, + "overview": "오일펜스 차단 성능 평가에 사용되는 유체역학 모델의 이론을 안내한다." + }, + { + "id": "023", + "name": "오일펜스 배치 알고리즘 이론 - 현장 적용", + "menuPath": "유출유확산예측 > 오일펜스 배치 알고리즘 이론", + "imageIndex": 23, + "overview": "오일펜스 배치 알고리즘의 실제 사고 현장 적용 사례와 현장 운용 시 고려사항을 안내한다." + }, + { + "id": "024", + "name": "오일펜스 배치 알고리즘 이론 - 참고문헌", + "menuPath": "유출유확산예측 > 오일펜스 배치 알고리즘 이론", + "imageIndex": 24, + "overview": "오일펜스 배치 알고리즘 이론에 사용된 학술 논문·기술 보고서·국제 기준 등 참고문헌 목록을 안내한다." + } + ] + }, + { + "id": "ch02", + "number": "02", + "title": "HNS·대기확산", + "subtitle": "HNS Atmospheric Dispersion", + "screens": [ + { + "id": "025", + "name": "대기확산예측 실행", + "menuPath": "HNS대기확산 > 대기확산분석", + "imageIndex": 25, + "overview": "HNS(위험유해물질) 해상 유출 시 대기 확산 범위, 위험 농도 구역, 영향 인구를 예측하는 핵심 분석 화면이다. ALOHA(EPA) 또는 이문진박사모델 두 가지 알고리즘 중 선택하여 가우시안 Plume/Puff 모델을 적용한다.", + "description": "화면 좌측에 사고 기본정보·물질 및 유출 조건·기상조건 입력 패널이 위치한다. 중앙 지도에 AEGL-1~3 위험 구역이 색상별 Plume 형태로 표출된다. 우측 '예측 결과' 패널에 최대 농도(ppm), AEGL-1 영향 면적, 위험 등급 등이 표시된다.", + "procedure": [ + "상단 메뉴에서 'HNS대기확산 > 대기확산분석'을 클릭한다.", + "사고 기본정보(사고명·날짜·시간·위치)를 입력한다.", + "HNS 물질 종류 및 누출 방식·유출량을 선택·입력한다.", + "기상조건(풍향·풍속·기온·대기안정도·예측 시간)을 입력한다.", + "적용 알고리즘(ALOHA/이문진박사모델)을 선택한다.", + "'확산예측 실행' 버튼을 클릭한다.", + "지도의 위험 구역 Plume과 우측 예측 결과 패널을 확인한다." + ], + "inputs": [ + { + "label": "사고명", + "type": "텍스트", + "required": true, + "desc": "사고 식별 명칭" + }, + { + "label": "사고 일시", + "type": "날짜+시간", + "required": true, + "desc": "사고 발생 일시" + }, + { + "label": "위도/경도", + "type": "숫자", + "required": true, + "desc": "사고 발생 지점 좌표" + }, + { + "label": "HNS 물질", + "type": "드롭다운", + "required": true, + "desc": "AEGL/ERPG 기준값 자동 로드" + }, + { + "label": "누출 방식", + "type": "라디오", + "required": true, + "desc": "순간 유출 또는 지속 유출" + }, + { + "label": "유출량", + "type": "숫자", + "required": true, + "desc": "ton 또는 g/s 단위" + }, + { + "label": "풍향", + "type": "숫자", + "required": true, + "desc": "0~360도" + }, + { + "label": "풍속", + "type": "숫자 m/s", + "required": true, + "desc": "최소 0.5 m/s 이상" + }, + { + "label": "기온", + "type": "숫자", + "required": true, + "desc": "현재 기온" + }, + { + "label": "대기안정도", + "type": "선택 A~F", + "required": true, + "desc": "Pasquill-Gifford 분류" + }, + { + "label": "예측 시간", + "type": "숫자 h", + "required": true, + "desc": "확산 예측 기간" + }, + { + "label": "적용 알고리즘", + "type": "라디오", + "required": true, + "desc": "ALOHA 또는 이문진박사모델" + } + ], + "notes": [ + "풍속은 최소 0.5 m/s 이상 입력해야 하며 0 입력 시 오류 발생.", + "HNS 물질 선택 후 AEGL/ERPGs 기준값이 자동 로드되었는지 반드시 확인." + ] + }, + { + "id": "026", + "name": "HNS 분석 목록", + "menuPath": "HNS대기확산", + "imageIndex": 26, + "overview": "완료된 HNS 대기확산 분석 이력을 목록 형식으로 조회하는 화면이다. AEGL-1~3 거리·위험 등급·피해반경·담당자 등 주요 결과 정보를 한눈에 파악할 수 있다.", + "procedure": [ + "상단 메뉴에서 'HNS대기확산'을 클릭하여 분석 목록으로 이동한다.", + "검색창에서 분석명을 검색하거나 목록을 스크롤하여 원하는 분석을 찾는다.", + "분석명 링크를 클릭하여 해당 분석 결과 지도 화면으로 이동한다." + ] + }, + { + "id": "027", + "name": "시나리오 상세", + "menuPath": "HNS대기확산 > 시나리오 관리", + "imageIndex": 27, + "overview": "단일 HNS 사고에 대해 여러 기상·유출 조건 시나리오(S-01~S-05 등)를 생성·비교 관리하는 화면이다. 시나리오별 위험도·위험 구역·대응 권고사항을 한눈에 비교할 수 있다.", + "description": "시나리오 목록 카드에 시나리오명·위험도 배지(CRITICAL/HIGH/MEDIUM/RESOLVED)·최대농도·IDLH 거리·ERPG-2 거리·영향인구 요약이 표시된다.", + "procedure": [ + "'HNS대기확산 > 시나리오 관리'를 클릭하여 이동한다.", + "원하는 사고 건을 선택하면 시나리오 목록이 표시된다.", + "시나리오 카드를 클릭하여 상세 위험 구역과 대응 권고사항을 확인한다.", + "'비교 차트' 탭으로 이동하여 시나리오 간 시간별 변화를 비교한다." + ] + }, + { + "id": "028", + "name": "비교 차트", + "menuPath": "HNS대기확산 > 시나리오 관리", + "imageIndex": 28, + "overview": "생성된 여러 HNS 시나리오의 최대 지표면 농도·위험 반경·영향 인구를 시간별 변화 차트로 비교하는 화면이다.", + "description": "상단은 시나리오별 최대 지표면 농도(ppm) 시간별 변화 그래프. 중단에 위험 반경(km)과 영향 인구(명) 변화 차트. 하단에 피대농도·IDLH 거리·ERPG-2 반경·영향인구·풍향/풍속·위험등급 항목별 비교표가 제공된다." + }, + { + "id": "029", + "name": "확산범위 오버레이", + "menuPath": "HNS대기확산 > 시나리오 관리", + "imageIndex": 29, + "overview": "HNS 대기확산 시나리오의 시간대별(T+0h~T+4h) 확산 범위를 지도에 오버레이 표출하는 화면이다.", + "procedure": [ + "시나리오 관리 화면에서 '확산범위 오버레이' 탭을 클릭한다.", + "지도에서 시간대별 확산 범위를 확인한다.", + "슬라이더를 이동하여 원하는 시간대의 확산 상태를 조회한다." + ] + }, + { + "id": "030", + "name": "신규 시나리오", + "menuPath": "HNS대기확산 > 시나리오 관리", + "imageIndex": 30, + "overview": "기상·유출 조건을 변경하여 새로운 HNS 대기확산 시나리오를 생성하는 입력 모달 화면이다.", + "procedure": [ + "시나리오 관리 화면 우측 상단 '신규 시나리오' 버튼을 클릭한다.", + "시나리오명과 기준 시각을 입력한다.", + "HNS 물질·누출 구분·유출량을 입력한다.", + "기상조건(풍향·풍속·기온·대기안정도)을 입력한다.", + "'시나리오 생성 및 예측 실행'을 클릭한다." + ], + "inputs": [ + { + "label": "시나리오명", + "type": "텍스트", + "required": true, + "desc": "시나리오 식별 명칭" + }, + { + "label": "HNS 물질", + "type": "드롭다운", + "required": true, + "desc": "유출 물질 선택" + }, + { + "label": "누출 구분", + "type": "라디오", + "required": true, + "desc": "순간/지속" + }, + { + "label": "유출량", + "type": "숫자", + "required": true, + "desc": "ton 또는 g/s" + }, + { + "label": "풍향/풍속", + "type": "숫자", + "required": true, + "desc": "각도와 m/s" + }, + { + "label": "기온", + "type": "숫자", + "required": true, + "desc": "섭씨" + }, + { + "label": "대기안정도", + "type": "선택 A~F", + "required": true, + "desc": "Pasquill-Gifford" + } + ] + }, + { + "id": "031", + "name": "HNS 대응매뉴얼", + "menuPath": "HNS대기확산", + "imageIndex": 31, + "overview": "해양 HNS 사고 대응 절차를 정리한 Marine HNS Response Manual(Bonn Agreement·HELCOM·REMPEC 기반)을 시스템 내에서 직접 열람하는 화면이다. 8개 챕터. SEBC 거동 분류 5유형 카드.", + "procedure": [ + "상단 메뉴에서 'HNS대기확산 > HNS 대응매뉴얼'을 클릭한다.", + "원하는 챕터 카드를 클릭하여 세부 내용을 확인한다.", + "하단 SEBC 분류 카드를 클릭하여 거동 유형별 대응 방법을 확인한다." + ] + }, + { + "id": "032", + "name": "HNS 확산모델 이론 - 시스템 개요", + "menuPath": "HNS대기확산 > 확산모델 이론", + "imageIndex": 32, + "overview": "Wing에 탑재된 HNS 대기확산 모델(WRF-Chem·Gaussian Plume/Puff·ROMS 연동)의 전체 체계와 처리 흐름을 안내한다." + }, + { + "id": "033", + "name": "HNS 확산모델 이론 - 가우시안 모델", + "menuPath": "HNS대기확산 > 확산모델 이론", + "imageIndex": 33, + "overview": "HNS 대기확산 예측에 적용되는 Gaussian Plume·Puff 모델의 수학적 이론과 Pasquill-Gifford 대기안정도 분류 체계를 안내한다." + }, + { + "id": "034", + "name": "HNS 확산모델 이론 - 물질별 시나리오", + "menuPath": "HNS대기확산 > 확산모델 이론", + "imageIndex": 34, + "overview": "주요 HNS 물질(NH3·CH3OH·H2·LNG 등)의 물질 특성 및 대기확산 시나리오 적용 기준을 안내한다." + }, + { + "id": "035", + "name": "HNS 확산모델 이론 - 해양환경 보정", + "menuPath": "HNS대기확산 > 확산모델 이론", + "imageIndex": 35, + "overview": "해양 환경 특성(해풍·육풍 순환·해수면 거칠기·SST 영향·MABL 구조)에 따른 대기확산 모델 보정 인자를 안내한다." + }, + { + "id": "036", + "name": "HNS 확산모델 이론 - 모델 검증", + "menuPath": "HNS대기확산 > 확산모델 이론", + "imageIndex": 36, + "overview": "HNS 대기확산 모델의 실측값 대비 예측 정확도 검증 결과(ALOHA·이문진박사모델·실측값 3종 비교)를 안내한다." + }, + { + "id": "037", + "name": "HNS 확산모델 이론 - 실시간 비교", + "menuPath": "HNS대기확산 > 확산모델 이론", + "imageIndex": 37, + "overview": "동일 조건에서 ALOHA와 이문진박사모델의 예측 결과를 실시간으로 비교하는 시뮬레이션 화면이다." + }, + { + "id": "038", + "name": "HNS 확산모델 이론 - WRF-Chem 발전", + "menuPath": "HNS대기확산 > 확산모델 이론", + "imageIndex": 38, + "overview": "WRF-Chem 기상-화학 연계 모델 및 ROMS 해양확산 수치모의 검증 결과와 고도화 방향을 안내한다." + }, + { + "id": "039", + "name": "HNS물질정보 - SEBC 거동분류", + "menuPath": "HNS대기확산 > HNS물질정보", + "imageIndex": 39, + "overview": "HNS 물질의 해양 거동 특성을 SEBC 기준으로 분류한 정보를 제공한다. G(기체)·E(증발)·F(부유)·D(용해)·S(침강) 5가지 기본 유형과 복합 거동 유형을 안내한다." + }, + { + "id": "040", + "name": "HNS물질정보 - 주요 물질 특성", + "menuPath": "HNS대기확산 > HNS물질정보", + "imageIndex": 40, + "overview": "NH3·CH4·H2·페놀·톨루엔 등 주요 HNS 물질의 상세 물리화학적 특성을 카드 형식으로 제공하는 화면이다." + }, + { + "id": "041", + "name": "HNS물질정보 - 위험도 기준", + "menuPath": "HNS대기확산 > HNS물질정보", + "imageIndex": 41, + "overview": "HNS 물질의 위험도 평가에 사용되는 AEGL·ERPG·IDLH·TWA 등 주요 독성 기준값 체계를 안내한다." + }, + { + "id": "042", + "name": "HNS물질정보 - 물질 상세검색", + "menuPath": "HNS대기확산 > HNS물질정보", + "imageIndex": 42, + "overview": "HNS 6,500여 종의 물질 데이터베이스에서 CAS 번호·물질명·거동 분류 조건으로 물질을 검색하는 화면이다.", + "inputs": [ + { + "label": "검색어", + "type": "텍스트", + "required": true, + "desc": "CAS 번호 또는 물질명" + }, + { + "label": "거동 분류", + "type": "체크박스", + "required": false, + "desc": "SEBC 유형 필터" + } + ], + "notes": [ + "CAS 번호 입력 시 하이픈(-) 포함 정확한 형식으로 입력해야 검색된다." + ] + } + ] + }, + { + "id": "ch03", + "number": "03", + "title": "긴급구난", + "subtitle": "Emergency Rescue", + "screens": [ + { + "id": "043", + "name": "긴급구난예측", + "menuPath": "긴급구난", + "imageIndex": 43, + "overview": "해상 조난자·표류 선박·표류 물체의 위치를 예측하는 긴급구난(SAR) 분석 화면이다. 해류·바람·파랑 데이터 기반 라그랑지안 표류 궤적 시뮬레이션을 수행한다.", + "description": "화면 좌측에 사고 발생 위치·일시·표류체 종류·예측 시간·기상조건 입력 패널이 위치한다. 중앙 지도에 표류 예측 궤적과 탐색 구역(최우선·일반)이 표출된다.", + "procedure": [ + "상단 메뉴에서 '긴급구난'을 클릭한다.", + "사고 발생 위치(위경도)와 일시를 입력한다.", + "표류체 종류(선박/사람/컨테이너 등)를 선택한다.", + "예측 시간과 기상 조건을 입력한다.", + "'예측 실행' 버튼을 클릭하여 표류 궤적과 탐색 구역을 확인한다." + ], + "inputs": [ + { + "label": "사고 위치", + "type": "숫자", + "required": true, + "desc": "위·경도" + }, + { + "label": "사고 일시", + "type": "날짜+시간", + "required": true, + "desc": "발생 일시" + }, + { + "label": "표류체 종류", + "type": "드롭다운", + "required": true, + "desc": "선박·사람·컨테이너·구명정 등" + }, + { + "label": "예측 시간", + "type": "숫자 h", + "required": true, + "desc": "표류 예측 기간" + }, + { + "label": "기상조건", + "type": "숫자", + "required": false, + "desc": "미입력 시 실시간 자동 적용" + } + ], + "notes": [ + "표류체 종류 선택이 부적절한 경우 예측 정확도가 낮아질 수 있다.", + "신속한 대응을 위해 입력 완료 즉시 실행하고 기상 변화 시 재분석을 수행한다." + ] + }, + { + "id": "044", + "name": "긴급구난 목록", + "menuPath": "긴급구난", + "imageIndex": 44, + "overview": "완료 또는 진행 중인 긴급구난 예측 분석 이력을 목록 형식으로 조회하는 화면이다.", + "procedure": [ + "상단 메뉴에서 '긴급구난'을 클릭하면 목록 화면이 표시된다.", + "원하는 분석을 검색하거나 목록에서 선택한다.", + "사고명 링크를 클릭하여 상세 분석 결과로 이동한다." + ] + }, + { + "id": "045", + "name": "시나리오 상세", + "menuPath": "긴급구난 > 시나리오 관리", + "imageIndex": 45, + "overview": "긴급구난 사고에 대해 기상·해양 조건을 달리 설정한 복수 시나리오를 생성·비교 관리하는 화면이다.", + "procedure": [ + "'긴급구난 > 시나리오 관리'를 클릭하여 이동한다.", + "생성된 시나리오 카드를 확인하거나 '신규 시나리오' 버튼으로 추가 생성한다.", + "시나리오 카드를 클릭하여 상세 내용을 확인한다." + ] + }, + { + "id": "046", + "name": "비교 차트", + "menuPath": "긴급구난 > 시나리오 관리", + "imageIndex": 46, + "overview": "긴급구난 시나리오별 탐색 면적·표류 속도·최대 이동거리를 시간별 변화 차트로 비교하는 화면이다." + }, + { + "id": "047", + "name": "지도 오버레이", + "menuPath": "긴급구난 > 시나리오 관리", + "imageIndex": 47, + "overview": "긴급구난 시나리오의 시간대별 표류 예측 궤적 및 탐색 구역을 지도에 오버레이 표출하는 화면이다." + }, + { + "id": "048", + "name": "긴급구난모델 이론", + "menuPath": "긴급구난", + "imageIndex": 48, + "overview": "긴급구난 표류 예측에 적용되는 라그랑지안 표류 모델·탐색 구역(Search Area) 산출 방법·IMO SAR 기준 적용 이론을 안내한다." + } + ] + }, + { + "id": "ch04", + "number": "04", + "title": "보고자료", + "subtitle": "Reports", + "screens": [ + { + "id": "049", + "name": "보고서 목록", + "menuPath": "보고자료", + "imageIndex": 49, + "overview": "시스템에서 생성된 모든 사고 대응 보고서를 목록 형식으로 관리하는 화면이다. 보고서명·사고명·생성일시·유형·작성자·상태가 표시된다.", + "procedure": [ + "상단 메뉴에서 '보고자료'를 클릭하여 보고서 목록으로 이동한다.", + "검색창에서 원하는 보고서를 검색하거나 목록에서 선택한다.", + "보고서명을 클릭하여 미리보기하거나 PDF를 다운로드한다." + ] + }, + { + "id": "050", + "name": "초기보고서", + "menuPath": "보고자료 > 표준보고서 템플릿", + "imageIndex": 50, + "overview": "사고 발생 초기 지휘부 보고를 위한 표준 보고서 템플릿을 제공한다. 확산예측 결과와 사고 기본정보가 자동 연동된다.", + "procedure": [ + "보고자료 메뉴에서 '표준보고서 템플릿'을 클릭한다.", + "좌측 사이드바에서 '초기보고서'를 선택한다.", + "자동 입력된 항목을 검토하고 필요 내용을 추가 입력한다.", + "'저장' 또는 'PDF 출력' 버튼을 활용한다." + ] + }, + { + "id": "051", + "name": "지휘부 보고", + "menuPath": "보고자료 > 표준보고서 템플릿", + "imageIndex": 51, + "overview": "사고 지휘부 보고용 표준 보고서 템플릿. 방제 대응 현황·자원 투입 현황·향후 계획이 포함된다." + }, + { + "id": "052", + "name": "예측보고서", + "menuPath": "보고자료 > 표준보고서 템플릿", + "imageIndex": 52, + "overview": "확산예측 결과를 포함한 예측보고서 템플릿. 모델 종류·예측 시간·주요 결과가 자동 연동된다." + }, + { + "id": "053", + "name": "종합보고서", + "menuPath": "보고자료 > 표준보고서 템플릿", + "imageIndex": 53, + "overview": "사고 종료 후 방제 대응 전 과정을 정리하는 종합보고서. 사고개요·대응경과·방제실적·환경영향평가·교훈이 포함된다." + }, + { + "id": "054", + "name": "유출유 보고", + "menuPath": "보고자료 > 표준보고서 템플릿", + "imageIndex": 54, + "overview": "유출유 사고 전용 표준 보고서 템플릿. 확산예측·오일펜스 배치·풍화 상태가 자동 연동된다." + }, + { + "id": "055", + "name": "유출유 확산예측 보고서 생성", + "menuPath": "보고자료 > 보고서 생성", + "imageIndex": 55, + "overview": "유출유 확산예측 분석 결과를 기반으로 보고서를 자동 생성하는 화면이다.", + "procedure": [ + "보고자료 메뉴에서 '보고서 생성'을 클릭한다.", + "'유출유 확산예측' 유형을 선택한다.", + "연동할 분석 결과를 드롭다운에서 선택한다.", + "보고 대상을 선택한다.", + "'보고서 생성' 버튼을 클릭한다." + ], + "inputs": [ + { + "label": "분석 결과", + "type": "드롭다운", + "required": true, + "desc": "연동할 분석 건 선택" + }, + { + "label": "보고 대상", + "type": "체크박스", + "required": true, + "desc": "지휘부·현장·외부 기관" + } + ] + }, + { + "id": "056", + "name": "HNS 대기확산 보고서 생성", + "menuPath": "보고자료 > 보고서 생성", + "imageIndex": 56, + "overview": "HNS 대기확산 분석 결과를 기반으로 보고서를 자동 생성하는 화면이다." + }, + { + "id": "057", + "name": "긴급구난 보고서 생성", + "menuPath": "보고자료 > 보고서 생성", + "imageIndex": 57, + "overview": "긴급구난 예측 분석 결과를 기반으로 구조 현장 지휘부용 보고서를 자동 생성하는 화면이다." + } + ] + }, + { + "id": "ch05", + "number": "05", + "title": "항공탐색", + "subtitle": "Aerial Surveillance", + "screens": [ + { + "id": "058", + "name": "영상사진관리", + "menuPath": "항공탐색", + "imageIndex": 58, + "overview": "드론·항공기·위성에서 수집된 영상 및 사진을 통합 관리하는 화면이다. 촬영 일시·위치·기기 유형별 분류 조회와 유출유 면적분석 연계를 지원한다.", + "procedure": [ + "상단 메뉴에서 '항공탐색 > 영상사진관리'를 클릭한다.", + "목록에서 원하는 영상/사진을 선택하거나 지도 마커를 클릭하여 확인한다.", + "신규 파일 업로드 시 '파일 업로드' 버튼을 클릭한다." + ], + "inputs": [ + { + "label": "업로드 파일", + "type": "파일", + "required": false, + "desc": "JPG·PNG·GeoTIFF·KMZ" + }, + { + "label": "촬영 일시", + "type": "날짜+시간", + "required": false, + "desc": "촬영 시점" + }, + { + "label": "장비 유형", + "type": "드롭다운", + "required": false, + "desc": "드론·항공기·위성·CCTV" + } + ], + "notes": [ + "50MB 초과 파일은 업로드 전 압축하거나 관리자에게 문의한다." + ] + }, + { + "id": "059", + "name": "유출유면적분석", + "menuPath": "항공탐색", + "imageIndex": 59, + "overview": "항공·위성 이미지를 기반으로 유출유 오염 면적을 AI 자동 산출하는 화면이다. 자동 추출된 오염 경계선을 수동 편집하여 정밀 면적을 산정할 수 있다.", + "procedure": [ + "영상사진관리에서 분석 대상 이미지를 선택하고 '면적 분석' 버튼을 클릭한다.", + "'AI 자동 분석 실행' 버튼을 클릭하여 오염 경계선을 추출한다.", + "폴리곤 경계선을 검토하고 필요 시 편집 도구로 수동 조정한다.", + "최종 면적·둘레·중심 좌표를 확인하고 저장한다." + ] + }, + { + "id": "060", + "name": "실시간드론", + "menuPath": "항공탐색", + "imageIndex": 60, + "overview": "현장 드론에서 실시간 전송되는 영상 스트리밍을 시스템 내에서 직접 모니터링하는 화면이다.", + "procedure": [ + "상단 메뉴에서 '항공탐색 > 실시간 드론'을 클릭한다.", + "연결된 드론 목록에서 모니터링할 드론을 선택한다.", + "실시간 영상을 확인하고 필요 시 스냅샷을 저장한다." + ], + "notes": [ + "드론 스트리밍은 네트워크 연결 상태에 따라 화질 및 지연이 달라질 수 있다." + ] + }, + { + "id": "061", + "name": "오염선박 3D분석", + "menuPath": "항공탐색", + "imageIndex": 61, + "overview": "항공·위성 이미지를 기반으로 오염 선박의 3D 모델을 생성하고 오염 분포를 입체적으로 분석하는 화면이다." + }, + { + "id": "062", + "name": "위성요청", + "menuPath": "항공탐색", + "imageIndex": 62, + "overview": "SAR·광학 위성 촬영 요청 및 수신 결과를 관리하는 화면이다.", + "inputs": [ + { + "label": "요청 위치", + "type": "숫자", + "required": true, + "desc": "위·경도" + }, + { + "label": "촬영 희망 일시", + "type": "날짜+시간", + "required": true, + "desc": "촬영 필요 일시" + }, + { + "label": "위성 종류", + "type": "라디오", + "required": true, + "desc": "SAR 또는 광학" + }, + { + "label": "해상도", + "type": "드롭다운", + "required": false, + "desc": "요청 이미지 해상도" + } + ], + "notes": [ + "위성 촬영 가능 여부는 궤도 조건에 따라 결정되며 요청이 항상 수용되지 않을 수 있다." + ] + }, + { + "id": "063", + "name": "CCTV 조회", + "menuPath": "항공탐색", + "imageIndex": 63, + "overview": "해안·항만 CCTV의 실시간 영상을 조회하는 화면이다. 사고 인근 CCTV를 지도에서 선택하여 스트리밍으로 확인할 수 있다.", + "procedure": [ + "상단 메뉴에서 '항공탐색 > CCTV조회'를 클릭한다.", + "지도에서 확인할 CCTV 마커를 클릭한다.", + "실시간 영상을 확인하고 필요 시 스냅샷을 저장한다." + ] + }, + { + "id": "064", + "name": "항공탐색 이론 - 개요", + "menuPath": "항공탐색 > 항공탐색 이론", + "imageIndex": 64, + "overview": "Wing 항공탐색 기능에 적용되는 원격탐사·ESI 방제지도·면적 산정·확산예측 연계 이론의 전체 개요를 안내한다." + }, + { + "id": "065", + "name": "항공탐색 이론 - 탐지 장비", + "menuPath": "항공탐색 > 항공탐색 이론", + "imageIndex": 65, + "overview": "해양 오염 항공 탐지에 사용되는 주요 장비(SAR·적외선 카메라·UV 센서·광학 카메라)의 특성과 적용 원리를 안내한다." + }, + { + "id": "066", + "name": "항공탐색 이론 - 원격탐사", + "menuPath": "항공탐색 > 항공탐색 이론", + "imageIndex": 66, + "overview": "위성 및 항공 원격탐사(Remote Sensing)의 기본 원리와 해양 오염 탐지 적용 방법을 안내한다." + }, + { + "id": "067", + "name": "항공탐색 이론 - ESI 방제지도", + "menuPath": "항공탐색 > 항공탐색 이론", + "imageIndex": 67, + "overview": "환경민감지수(ESI) 방제지도의 해안선 민감도 등급 분류 체계와 방제 우선순위 결정 방법을 안내한다." + }, + { + "id": "068", + "name": "항공탐색 이론 - 면적 산정", + "menuPath": "항공탐색 > 항공탐색 이론", + "imageIndex": 68, + "overview": "항공·위성 이미지 기반 유출유 면적 산정에 적용되는 AI 알고리즘(객체 탐지·영상 분할)의 원리를 안내한다." + }, + { + "id": "069", + "name": "항공탐색 이론 - 확산예측 연계", + "menuPath": "항공탐색 > 항공탐색 이론", + "imageIndex": 69, + "overview": "항공 탐색 결과(실측 면적·위치)와 유출유 확산예측 모델 보정 연계 방법을 안내한다." + }, + { + "id": "070", + "name": "항공탐색 이론 - 논문 특허", + "menuPath": "항공탐색 > 항공탐색 이론", + "imageIndex": 70, + "overview": "Wing 항공탐색 기능의 기반이 되는 관련 논문과 특허 목록을 안내한다." + } + ] + }, + { + "id": "ch06", + "number": "06", + "title": "게시판", + "subtitle": "Bulletin Board", + "screens": [ + { + "id": "071", + "name": "전체 게시판", + "menuPath": "게시판", + "imageIndex": 71, + "overview": "공지사항·자료실·Q&A·해경매뉴얼 게시물을 통합 조회하는 전체 게시판 화면이다.", + "procedure": [ + "상단 메뉴에서 '게시판'을 클릭하여 전체 게시판으로 이동한다.", + "유형 필터 탭을 선택하거나 검색창에 키워드를 입력한다.", + "원하는 게시물 제목을 클릭하여 상세 내용을 확인한다." + ] + }, + { + "id": "072", + "name": "공지사항", + "menuPath": "게시판", + "imageIndex": 72, + "overview": "시스템 공지·업데이트·장애 안내 등 운영 공지사항을 게시하는 화면이다. 관리자만 등록·수정 가능." + }, + { + "id": "073", + "name": "자료실", + "menuPath": "게시판", + "imageIndex": 73, + "overview": "매뉴얼·지침서·참고자료 등 업무 관련 문서 파일을 공유하는 게시판 화면이다." + }, + { + "id": "074", + "name": "QNA", + "menuPath": "게시판", + "imageIndex": 74, + "overview": "시스템 사용 관련 질문을 등록하고 답변을 확인하는 Q&A 게시판 화면이다.", + "inputs": [ + { + "label": "제목", + "type": "텍스트", + "required": true, + "desc": "질문 제목" + }, + { + "label": "카테고리", + "type": "드롭다운", + "required": true, + "desc": "기능문의·오류신고·개선요청" + }, + { + "label": "내용", + "type": "텍스트", + "required": true, + "desc": "질문 내용" + }, + { + "label": "첨부 파일", + "type": "파일", + "required": false, + "desc": "스크린샷 등" + } + ] + }, + { + "id": "075", + "name": "해경 매뉴얼", + "menuPath": "게시판", + "imageIndex": 75, + "overview": "해양경찰청 방제·대응 매뉴얼을 시스템 내에서 바로 조회할 수 있는 화면이다. 관리자만 등록·수정 가능." + } + ] + }, + { + "id": "ch07", + "number": "07", + "title": "기상정보", + "subtitle": "Weather", + "screens": [ + { + "id": "076", + "name": "기상정보", + "menuPath": "기상정보", + "imageIndex": 76, + "overview": "현재 사고 지역 주변의 실시간 기상 정보(풍향·풍속·기온·파고·시정 등)를 조회하는 화면이다. 기상 데이터는 확산예측 모델 입력으로 자동 연동된다.", + "description": "사고 위치 기준 반경 내 기상 관측소 목록과 최신 관측값이 표시된다. 시계열 그래프로 과거 24시간 기상 변화를 확인할 수 있다.", + "procedure": [ + "상단 메뉴에서 '기상정보'를 클릭하여 이동한다.", + "지도에서 관측소 마커를 클릭하거나 목록에서 관측소를 선택한다.", + "최신 기상값과 시계열 그래프를 확인한다." + ], + "notes": [ + "극한 기상 조건에서는 확산예측 정확도가 낮아질 수 있으므로 현장 기상 관측값과 병행 확인한다." + ] + } + ] + }, + { + "id": "ch08", + "number": "08", + "title": "통합조회", + "subtitle": "Integrated Search", + "screens": [ + { + "id": "077", + "name": "통합조회", + "menuPath": "통합조회", + "imageIndex": 77, + "overview": "Wing 시스템 전체 분석 이력(유출유·HNS·긴급구난·보고서)을 통합 조회하는 화면이다. 사고명·날짜·분석 유형·담당자 등 복합 조건으로 검색하고 결과를 지도와 목록으로 표출한다.", + "description": "화면 좌측에 날짜 범위·분석 유형·담당자·사고 지역 복합 필터 패널이 위치한다. 중앙 지도에 검색 결과 사고지점 마커가 표출된다. Excel·CSV 내보내기를 지원한다.", + "procedure": [ + "상단 메뉴에서 '통합조회'를 클릭하여 이동한다.", + "날짜 범위·분석 유형 등 필터 조건을 설정한다.", + "'검색' 버튼을 클릭하여 결과를 조회한다.", + "지도 마커 또는 목록 항목을 클릭하여 해당 분석 결과 상세 화면으로 이동한다.", + "Excel·CSV 내보내기 버튼으로 이력 자료를 저장한다." + ], + "inputs": [ + { + "label": "날짜 범위", + "type": "날짜", + "required": false, + "desc": "시작·종료 날짜" + }, + { + "label": "분석 유형", + "type": "체크박스", + "required": false, + "desc": "유출유·HNS·긴급구난·보고서" + }, + { + "label": "담당자", + "type": "텍스트", + "required": false, + "desc": "이름 필터" + }, + { + "label": "사고 지역", + "type": "텍스트", + "required": false, + "desc": "지역명 필터" + } + ] + } + ] + } +] diff --git a/frontend/src/common/data/manualChapters.ts b/frontend/src/common/data/manualChapters.ts new file mode 100644 index 0000000..cde842a --- /dev/null +++ b/frontend/src/common/data/manualChapters.ts @@ -0,0 +1,30 @@ +import chaptersJson from './chapters.json'; + +export interface InputItem { + label: string; + type: string; + required: boolean; + desc: string; +} + +export interface ScreenItem { + id: string; + name: string; + menuPath: string; + imageIndex: number; + overview: string; + description?: string; + procedure?: string[]; + inputs?: InputItem[]; + notes?: string[]; +} + +export interface Chapter { + id: string; + number: string; + title: string; + subtitle: string; + screens: ScreenItem[]; +} + +export const CHAPTERS = chaptersJson as Chapter[]; diff --git a/frontend/src/common/hooks/useBaseMapStyle.ts b/frontend/src/common/hooks/useBaseMapStyle.ts index d067173..512eecf 100644 --- a/frontend/src/common/hooks/useBaseMapStyle.ts +++ b/frontend/src/common/hooks/useBaseMapStyle.ts @@ -1,6 +1,6 @@ import type { StyleSpecification } from 'maplibre-gl'; import { useMapStore } from '@common/store/mapStore'; -import { LIGHT_STYLE, SATELLITE_3D_STYLE, ENC_EMPTY_STYLE } from '@common/components/map/mapStyles'; +import { LIGHT_STYLE, SATELLITE_3D_STYLE, ENC_EMPTY_STYLE } from '@components/common/map/mapStyles'; export function useBaseMapStyle(): StyleSpecification { const mapToggles = useMapStore((s) => s.mapToggles); diff --git a/frontend/src/common/hooks/useSubMenu.ts b/frontend/src/common/hooks/useSubMenu.ts index b2c9225..f97c814 100755 --- a/frontend/src/common/hooks/useSubMenu.ts +++ b/frontend/src/common/hooks/useSubMenu.ts @@ -1,5 +1,5 @@ import { useEffect, useSyncExternalStore } from 'react'; -import type { MainTab } from '../types/navigation'; +import type { MainTab } from '@/types/navigation'; import { useAuthStore } from '@common/store/authStore'; import { API_BASE_URL } from '@common/services/api'; @@ -61,6 +61,7 @@ const subMenuConfigs: Record = { { id: 'manual', label: '해경매뉴얼', icon: '📘' }, ], weather: null, + monitor: null, admin: null, // 관리자 화면은 자체 사이드바 사용 (AdminSidebar.tsx) }; @@ -76,6 +77,7 @@ const subMenuState: Record = { incidents: '', board: 'all', weather: '', + monitor: '', admin: 'users', }; diff --git a/frontend/src/common/hooks/useVesselSignals.ts b/frontend/src/common/hooks/useVesselSignals.ts new file mode 100644 index 0000000..709b7ac --- /dev/null +++ b/frontend/src/common/hooks/useVesselSignals.ts @@ -0,0 +1,79 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { + createVesselSignalClient, + type VesselSignalClient, +} from '@common/services/vesselSignalClient'; +import { + getInitialVesselSnapshot, + isVesselInitEnabled, +} from '@common/services/vesselApi'; +import type { VesselPosition, MapBounds } from '@/types/vessel'; + +/** + * 선박 신호 실시간 수신 훅 + * + * 개발환경(VITE_VESSEL_SIGNAL_MODE=polling): + * - 60초마다 백엔드 REST API(/api/vessels/in-area)를 현재 뷰포트 bbox로 호출 + * + * 운영환경(VITE_VESSEL_SIGNAL_MODE=websocket): + * - 운영 WebSocket 서버(VITE_VESSEL_WS_URL)에 직접 연결하여 실시간 수신 + * - 수신된 전체 데이터를 현재 뷰포트 bbox로 프론트에서 필터링 + * + * @param mapBounds MapView의 onBoundsChange로 전달받은 현재 뷰포트 bbox + * @returns 현재 뷰포트 내 선박 목록 + */ +export function useVesselSignals(mapBounds: MapBounds | null): VesselPosition[] { + const [vessels, setVessels] = useState([]); + const boundsRef = useRef(mapBounds); + const clientRef = useRef(null); + + useEffect(() => { + boundsRef.current = mapBounds; + }, [mapBounds]); + + const getViewportBounds = useCallback(() => boundsRef.current, []); + + useEffect(() => { + const client = createVesselSignalClient(); + clientRef.current = client; + + // 운영 환경: 로그인/새로고침 직후 최근 10분치 스냅샷을 먼저 1회 로드. + // 이후 WebSocket 수신이 시작되면 최신 신호로 갱신된다. + // VITE_VESSEL_INIT_ENABLED=true 일 때만 활성화(기본 비활성). + if (isVesselInitEnabled()) { + getInitialVesselSnapshot() + .then((initial) => { + const bounds = boundsRef.current; + const filtered = bounds + ? initial.filter( + (v) => + v.lon >= bounds.minLon && + v.lon <= bounds.maxLon && + v.lat >= bounds.minLat && + v.lat <= bounds.maxLat, + ) + : initial; + // WS 첫 메시지가 먼저 도착해 이미 채워졌다면 덮어쓰지 않음 + setVessels((prev) => (prev.length === 0 ? filtered : prev)); + }) + .catch((e) => console.warn('[useVesselSignals] 초기 스냅샷 실패', e)); + } + + client.start(setVessels, getViewportBounds); + return () => { + client.stop(); + clientRef.current = null; + }; + }, [getViewportBounds]); + + // mapBounds가 바뀔 때마다(최초 채워질 때 + 이후 뷰포트 이동/줌마다) 즉시 1회 새로고침. + // MapView의 onBoundsChange는 moveend/zoomend에서만 호출되므로 드래그 중 스팸은 없다. + // 이후에도 60초 인터벌 폴링은 백그라운드에서 계속 동작. + useEffect(() => { + if (mapBounds && clientRef.current) { + clientRef.current.refresh(); + } + }, [mapBounds]); + + return vessels; +} diff --git a/frontend/src/common/mock/vesselMockData.ts b/frontend/src/common/mock/vesselMockData.ts index 48d5604..9c9de86 100755 --- a/frontend/src/common/mock/vesselMockData.ts +++ b/frontend/src/common/mock/vesselMockData.ts @@ -1,613 +1,4 @@ -export interface Vessel { - mmsi: number; - imo: string; - name: string; - typS: string; - flag: string; - status: string; - speed: number; - heading: number; - lat: number; - lng: number; - draft: number; - depart: string; - arrive: string; - etd: string; - eta: string; - gt: string; - dwt: string; - loa: string; - beam: string; - built: string; - yard: string; - callSign: string; - cls: string; - cargo: string; - color: string; - markerType: string; -} - -export const VESSEL_TYPE_COLORS: Record = { - Tanker: '#ef4444', - Chemical: '#ef4444', - Cargo: '#22c55e', - Bulk: '#22c55e', - Container: '#3b82f6', - Passenger: '#a855f7', - Fishing: '#f97316', - Tug: '#06b6d4', - Navy: '#6b7280', - Sailing: '#fbbf24', -}; - -export const VESSEL_LEGEND = [ - { type: 'Tanker', color: '#ef4444' }, - { type: 'Cargo', color: '#22c55e' }, - { type: 'Container', color: '#3b82f6' }, - { type: 'Fishing', color: '#f97316' }, - { type: 'Passenger', color: '#a855f7' }, - { type: 'Tug', color: '#06b6d4' }, -]; - -export const mockVessels: Vessel[] = [ - { - mmsi: 440123456, - imo: '9812345', - name: 'HANKUK CHEMI', - typS: 'Tanker', - flag: '🇰🇷', - status: '항해중', - speed: 8.2, - heading: 330, - lat: 34.6, - lng: 127.5, - draft: 5.8, - depart: '여수항', - arrive: '부산항', - etd: '2026-02-25 08:00', - eta: '2026-02-25 18:30', - gt: '29,246', - dwt: '49,999', - loa: '183.0m', - beam: '32.2m', - built: '2018', - yard: '현대미포조선', - callSign: 'HLKC', - cls: '한국선급(KR)', - cargo: 'BUNKER-C · 1,200kL · IMO Class 3', - color: '#ef4444', - markerType: 'tanker', - }, - { - mmsi: 440234567, - imo: '9823456', - name: 'DONG-A GLAUCOS', - typS: 'Cargo', - flag: '🇰🇷', - status: '항해중', - speed: 11.4, - heading: 245, - lat: 34.78, - lng: 127.8, - draft: 7.2, - depart: '울산항', - arrive: '광양항', - etd: '2026-02-25 06:30', - eta: '2026-02-25 16:00', - gt: '12,450', - dwt: '18,800', - loa: '144.0m', - beam: '22.6m', - built: '2015', - yard: 'STX조선', - callSign: 'HLDG', - cls: '한국선급(KR)', - cargo: '철강재 · 4,500t', - color: '#22c55e', - markerType: 'cargo', - }, - { - mmsi: 440345678, - imo: '9834567', - name: 'HMM ALGECIRAS', - typS: 'Container', - flag: '🇰🇷', - status: '항해중', - speed: 18.5, - heading: 195, - lat: 35.0, - lng: 128.8, - draft: 14.5, - depart: '부산항', - arrive: '싱가포르', - etd: '2026-02-25 04:00', - eta: '2026-03-02 08:00', - gt: '228,283', - dwt: '223,092', - loa: '399.9m', - beam: '61.0m', - built: '2020', - yard: '대우조선해양', - callSign: 'HLHM', - cls: "Lloyd's Register", - cargo: '컨테이너 · 16,420 TEU', - color: '#3b82f6', - markerType: 'container', - }, - { - mmsi: 355678901, - imo: '9756789', - name: 'STELLAR DAISY', - typS: 'Tanker', - flag: '🇵🇦', - status: '⚠ 사고(좌초)', - speed: 0.0, - heading: 0, - lat: 34.72, - lng: 127.72, - draft: 8.1, - depart: '여수항', - arrive: '—', - etd: '2026-01-18 12:00', - eta: '—', - gt: '35,120', - dwt: '58,000', - loa: '190.0m', - beam: '34.0m', - built: '2012', - yard: 'CSBC Taiwan', - callSign: '3FZA7', - cls: 'NK', - cargo: 'BUNKER-C · 150kL 유출 · ⚠ 사고선박', - color: '#ef4444', - markerType: 'tanker', - }, - { - mmsi: 440456789, - imo: '—', - name: '제72 금양호', - typS: 'Fishing', - flag: '🇰🇷', - status: '조업중', - speed: 4.1, - heading: 120, - lat: 34.55, - lng: 127.35, - draft: 2.1, - depart: '여수 국동항', - arrive: '여수 국동항', - etd: '2026-02-25 04:30', - eta: '2026-02-25 18:00', - gt: '78', - dwt: '—', - loa: '24.5m', - beam: '6.2m', - built: '2008', - yard: '통영조선', - callSign: '—', - cls: '한국선급', - cargo: '어획물', - color: '#f97316', - markerType: 'fishing', - }, - { - mmsi: 440567890, - imo: '9867890', - name: 'PAN OCEAN GLORY', - typS: 'Bulk', - flag: '🇰🇷', - status: '항해중', - speed: 12.8, - heading: 170, - lat: 35.6, - lng: 126.4, - draft: 10.3, - depart: '군산항', - arrive: '포항항', - etd: '2026-02-25 07:00', - eta: '2026-02-26 04:00', - gt: '43,800', - dwt: '76,500', - loa: '229.0m', - beam: '32.3m', - built: '2019', - yard: '현대삼호중공업', - callSign: 'HLPO', - cls: '한국선급(KR)', - cargo: '석탄 · 65,000t', - color: '#22c55e', - markerType: 'cargo', - }, - { - mmsi: 440678901, - imo: '—', - name: '여수예인1호', - typS: 'Tug', - flag: '🇰🇷', - status: '방제지원', - speed: 6.3, - heading: 355, - lat: 34.68, - lng: 127.6, - draft: 3.2, - depart: '여수항', - arrive: '사고현장', - etd: '2026-01-18 16:30', - eta: '—', - gt: '280', - dwt: '—', - loa: '32.0m', - beam: '9.5m', - built: '2016', - yard: '삼성중공업', - callSign: 'HLYT', - cls: '한국선급', - cargo: '방제장비 · 오일붐 500m', - color: '#06b6d4', - markerType: 'tug', - }, - { - mmsi: 235012345, - imo: '9456789', - name: 'QUEEN MARY', - typS: 'Passenger', - flag: '🇬🇧', - status: '항해중', - speed: 15.2, - heading: 10, - lat: 33.8, - lng: 127.0, - draft: 8.5, - depart: '상하이', - arrive: '부산항', - etd: '2026-02-24 18:00', - eta: '2026-02-26 06:00', - gt: '148,528', - dwt: '18,000', - loa: '345.0m', - beam: '41.0m', - built: '2004', - yard: "Chantiers de l'Atlantique", - callSign: 'GBQM2', - cls: "Lloyd's Register", - cargo: '승객 2,620명', - color: '#a855f7', - markerType: 'passenger', - }, - { - mmsi: 353012345, - imo: '9811000', - name: 'EVER GIVEN', - typS: 'Container', - flag: '🇹🇼', - status: '항해중', - speed: 14.7, - heading: 220, - lat: 35.2, - lng: 129.2, - draft: 15.7, - depart: '부산항', - arrive: '카오슝', - etd: '2026-02-25 02:00', - eta: '2026-02-28 14:00', - gt: '220,940', - dwt: '199,629', - loa: '400.0m', - beam: '59.0m', - built: '2018', - yard: '今治造船', - callSign: 'BIXE9', - cls: 'ABS', - cargo: '컨테이너 · 14,800 TEU', - color: '#3b82f6', - markerType: 'container', - }, - { - mmsi: 440789012, - imo: '—', - name: '제85 대성호', - typS: 'Fishing', - flag: '🇰🇷', - status: '조업중', - speed: 3.8, - heading: 85, - lat: 34.4, - lng: 126.3, - draft: 1.8, - depart: '목포항', - arrive: '목포항', - etd: '2026-02-25 03:00', - eta: '2026-02-25 17:00', - gt: '65', - dwt: '—', - loa: '22.0m', - beam: '5.8m', - built: '2010', - yard: '목포조선', - callSign: '—', - cls: '한국선급', - cargo: '어획물', - color: '#f97316', - markerType: 'fishing', - }, - { - mmsi: 440890123, - imo: '9878901', - name: 'SK INNOVATION', - typS: 'Chemical', - flag: '🇰🇷', - status: '항해중', - speed: 9.6, - heading: 340, - lat: 35.8, - lng: 126.6, - draft: 6.5, - depart: '대산항', - arrive: '여수항', - etd: '2026-02-25 10:00', - eta: '2026-02-26 02:00', - gt: '11,200', - dwt: '16,800', - loa: '132.0m', - beam: '20.4m', - built: '2020', - yard: '현대미포조선', - callSign: 'HLSK', - cls: '한국선급(KR)', - cargo: '톨루엔 · 8,500kL · IMO Class 3', - color: '#ef4444', - markerType: 'tanker', - }, - { - mmsi: 440901234, - imo: '9889012', - name: 'KOREA EXPRESS', - typS: 'Cargo', - flag: '🇰🇷', - status: '항해중', - speed: 10.1, - heading: 190, - lat: 36.2, - lng: 128.5, - draft: 6.8, - depart: '동해항', - arrive: '포항항', - etd: '2026-02-25 09:00', - eta: '2026-02-25 15:00', - gt: '8,500', - dwt: '12,000', - loa: '118.0m', - beam: '18.2m', - built: '2014', - yard: '대한조선', - callSign: 'HLKE', - cls: '한국선급', - cargo: '일반화물', - color: '#22c55e', - markerType: 'cargo', - }, - { - mmsi: 440012345, - imo: '—', - name: 'ROKS SEJONG', - typS: 'Navy', - flag: '🇰🇷', - status: '작전중', - speed: 16.0, - heading: 270, - lat: 35.3, - lng: 129.5, - draft: 6.3, - depart: '부산 해군기지', - arrive: '—', - etd: '—', - eta: '—', - gt: '7,600', - dwt: '—', - loa: '165.9m', - beam: '21.4m', - built: '2008', - yard: '현대중공업', - callSign: 'HLNS', - cls: '군용', - cargo: '군사작전', - color: '#6b7280', - markerType: 'military', - }, - { - mmsi: 440023456, - imo: '—', - name: '군산예인3호', - typS: 'Tug', - flag: '🇰🇷', - status: '대기중', - speed: 5.5, - heading: 140, - lat: 35.9, - lng: 126.9, - draft: 2.8, - depart: '군산항', - arrive: '군산항', - etd: '—', - eta: '—', - gt: '180', - dwt: '—', - loa: '28.0m', - beam: '8.2m', - built: '2019', - yard: '통영조선', - callSign: 'HLGS', - cls: '한국선급', - cargo: '—', - color: '#06b6d4', - markerType: 'tug', - }, - { - mmsi: 440034567, - imo: '—', - name: 'JEJU WIND', - typS: 'Sailing', - flag: '🇰🇷', - status: '항해중', - speed: 6.8, - heading: 290, - lat: 33.35, - lng: 126.65, - draft: 2.5, - depart: '제주항', - arrive: '제주항', - etd: '2026-02-25 10:00', - eta: '2026-02-25 16:00', - gt: '45', - dwt: '—', - loa: '18.0m', - beam: '5.0m', - built: '2022', - yard: '제주요트', - callSign: '—', - cls: '—', - cargo: '—', - color: '#fbbf24', - markerType: 'sail', - }, - { - mmsi: 440045678, - imo: '—', - name: '제33 삼양호', - typS: 'Fishing', - flag: '🇰🇷', - status: '조업중', - speed: 2.4, - heading: 55, - lat: 35.1, - lng: 127.4, - draft: 1.6, - depart: '통영항', - arrive: '통영항', - etd: '2026-02-25 05:00', - eta: '2026-02-25 19:00', - gt: '52', - dwt: '—', - loa: '20.0m', - beam: '5.4m', - built: '2006', - yard: '거제조선', - callSign: '—', - cls: '한국선급', - cargo: '어획물', - color: '#f97316', - markerType: 'fishing', - }, - { - mmsi: 255012345, - imo: '9703291', - name: 'MSC OSCAR', - typS: 'Container', - flag: '🇨🇭', - status: '항해중', - speed: 17.3, - heading: 355, - lat: 34.1, - lng: 128.1, - draft: 14.0, - depart: '카오슝', - arrive: '부산항', - etd: '2026-02-23 08:00', - eta: '2026-02-25 22:00', - gt: '197,362', - dwt: '199,272', - loa: '395.4m', - beam: '59.0m', - built: '2015', - yard: '대우조선해양', - callSign: '9HA4713', - cls: 'DNV', - cargo: '컨테이너 · 18,200 TEU', - color: '#3b82f6', - markerType: 'container', - }, - { - mmsi: 440056789, - imo: '9890567', - name: 'SAEHAN PIONEER', - typS: 'Tanker', - flag: '🇰🇷', - status: '항해중', - speed: 7.9, - heading: 310, - lat: 34.9, - lng: 127.1, - draft: 5.2, - depart: '여수항', - arrive: '대산항', - etd: '2026-02-25 11:00', - eta: '2026-02-26 08:00', - gt: '8,900', - dwt: '14,200', - loa: '120.0m', - beam: '18.0m', - built: '2017', - yard: '현대미포조선', - callSign: 'HLSP', - cls: '한국선급(KR)', - cargo: '경유 · 10,000kL', - color: '#ef4444', - markerType: 'tanker', - }, - { - mmsi: 440067890, - imo: '9891678', - name: 'DONGHAE STAR', - typS: 'Cargo', - flag: '🇰🇷', - status: '항해중', - speed: 11.0, - heading: 155, - lat: 37.55, - lng: 129.3, - draft: 6.0, - depart: '속초항', - arrive: '동해항', - etd: '2026-02-25 12:00', - eta: '2026-02-25 16:30', - gt: '6,200', - dwt: '8,500', - loa: '105.0m', - beam: '16.5m', - built: '2013', - yard: '대한조선', - callSign: 'HLDS', - cls: '한국선급', - cargo: '일반화물 · 목재', - color: '#22c55e', - markerType: 'cargo', - }, - { - mmsi: 440078901, - imo: '—', - name: '제18 한라호', - typS: 'Fishing', - flag: '🇰🇷', - status: '귀항중', - speed: 3.2, - heading: 70, - lat: 33.3, - lng: 126.3, - draft: 1.9, - depart: '서귀포항', - arrive: '서귀포항', - etd: '2026-02-25 04:00', - eta: '2026-02-25 15:00', - gt: '58', - dwt: '—', - loa: '21.0m', - beam: '5.6m', - built: '2011', - yard: '제주조선', - callSign: '—', - cls: '한국선급', - cargo: '어획물 · 갈치/고등어', - color: '#f97316', - markerType: 'fishing', - }, -]; +// Deprecated: Mock 선박 데이터는 제거되었습니다. +// 실제 선박 신호는 @common/hooks/useVesselSignals + @components/common/map/VesselLayer 를 사용합니다. +// 범례는 @components/common/map/VesselLayer 의 VESSEL_LEGEND 를 import 하세요. +export {}; diff --git a/frontend/src/common/services/vesselApi.ts b/frontend/src/common/services/vesselApi.ts new file mode 100644 index 0000000..00c9ce5 --- /dev/null +++ b/frontend/src/common/services/vesselApi.ts @@ -0,0 +1,35 @@ +import { api } from './api'; +import type { VesselPosition, MapBounds } from '@/types/vessel'; + +export async function getVesselsInArea(bounds: MapBounds): Promise { + const res = await api.post('/vessels/in-area', { bounds }); + return res.data; +} + +/** + * 로그인/새로고침 직후 1회 호출하는 초기 스냅샷 API. + * 운영 환경의 별도 REST 서버가 현재 시각 기준 최근 10분치 선박 신호를 반환한다. + * URL은 VITE_VESSEL_INIT_API_URL 로 주입(운영에서 실제 URL로 교체). + */ +export async function getInitialVesselSnapshot(): Promise { + const url = import.meta.env.VITE_VESSEL_INIT_API_URL as string | undefined; + if (!url) return []; + const res = await fetch(url, { method: 'GET' }); + if (!res.ok) throw new Error(`vessel init snapshot ${res.status}`); + return (await res.json()) as VesselPosition[]; +} + +export function isVesselInitEnabled(): boolean { + return import.meta.env.VITE_VESSEL_INIT_ENABLED === 'true'; +} + +export interface VesselCacheStatus { + count: number; + bangjeCount: number; + lastUpdated: string | null; +} + +export async function getVesselCacheStatus(): Promise { + const res = await api.get('/vessels/status'); + return res.data; +} diff --git a/frontend/src/common/services/vesselSignalClient.ts b/frontend/src/common/services/vesselSignalClient.ts new file mode 100644 index 0000000..8a47fe2 --- /dev/null +++ b/frontend/src/common/services/vesselSignalClient.ts @@ -0,0 +1,125 @@ +import type { VesselPosition, MapBounds } from '@/types/vessel'; +import { getVesselsInArea } from './vesselApi'; + +export interface VesselSignalClient { + start( + onVessels: (vessels: VesselPosition[]) => void, + getViewportBounds: () => MapBounds | null, + ): void; + stop(): void; + /** + * 즉시 1회 새로고침. 폴링 모드에선 현재 bbox로 REST 호출, + * WebSocket 모드에선 no-op(서버 push에 의존). + */ + refresh(): void; +} + +// 개발환경: setInterval(60s) → 백엔드 REST API 호출 +class PollingVesselClient implements VesselSignalClient { + private intervalId: ReturnType | null = null; + private onVessels: ((vessels: VesselPosition[]) => void) | null = null; + private getViewportBounds: (() => MapBounds | null) | null = null; + + private async poll(): Promise { + const bounds = this.getViewportBounds?.(); + if (!bounds || !this.onVessels) return; + try { + const vessels = await getVesselsInArea(bounds); + this.onVessels(vessels); + } catch { + // 폴링 실패 시 무시 (다음 인터벌에 재시도) + } + } + + start( + onVessels: (vessels: VesselPosition[]) => void, + getViewportBounds: () => MapBounds | null, + ): void { + this.onVessels = onVessels; + this.getViewportBounds = getViewportBounds; + + // 즉시 1회 실행 후 60초 간격으로 반복 + this.poll(); + this.intervalId = setInterval(() => this.poll(), 60_000); + } + + stop(): void { + if (this.intervalId !== null) { + clearInterval(this.intervalId); + this.intervalId = null; + } + this.onVessels = null; + this.getViewportBounds = null; + } + + refresh(): void { + this.poll(); + } +} + +// 운영환경: 실시간 WebSocket 서버에 직접 연결 +class DirectWebSocketVesselClient implements VesselSignalClient { + private ws: WebSocket | null = null; + private readonly wsUrl: string; + + constructor(wsUrl: string) { + this.wsUrl = wsUrl; + } + + start( + onVessels: (vessels: VesselPosition[]) => void, + getViewportBounds: () => MapBounds | null, + ): void { + this.ws = new WebSocket(this.wsUrl); + + this.ws.onmessage = (event) => { + try { + const allVessels = JSON.parse(event.data as string) as VesselPosition[]; + const bounds = getViewportBounds(); + + if (!bounds) { + onVessels(allVessels); + return; + } + + const filtered = allVessels.filter( + (v) => + v.lon >= bounds.minLon && + v.lon <= bounds.maxLon && + v.lat >= bounds.minLat && + v.lat <= bounds.maxLat, + ); + onVessels(filtered); + } catch { + // 파싱 실패 무시 + } + }; + + this.ws.onerror = () => { + console.error('[vesselSignalClient] WebSocket 연결 오류'); + }; + + this.ws.onclose = () => { + console.warn('[vesselSignalClient] WebSocket 연결 종료'); + }; + } + + stop(): void { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + refresh(): void { + // 운영 WS 모드에선 서버 push에 의존하므로 별도 새로고침 동작 없음 + } +} + +export function createVesselSignalClient(): VesselSignalClient { + if (import.meta.env.VITE_VESSEL_SIGNAL_MODE === 'websocket') { + const wsUrl = import.meta.env.VITE_VESSEL_WS_URL as string; + return new DirectWebSocketVesselClient(wsUrl); + } + return new PollingVesselClient(); +} diff --git a/frontend/src/common/store/weatherSnapshotStore.ts b/frontend/src/common/store/weatherSnapshotStore.ts index 79847dd..c6e6711 100644 --- a/frontend/src/common/store/weatherSnapshotStore.ts +++ b/frontend/src/common/store/weatherSnapshotStore.ts @@ -1,44 +1,7 @@ import { create } from 'zustand'; +import type { WeatherSnapshot } from '@interfaces/weather/WeatherInterface'; -export interface WeatherSnapshot { - stationName: string; - capturedAt: string; - wind: { - speed: number; - direction: number; - directionLabel: string; - speed_1k: number; - speed_3k: number; - }; - wave: { - height: number; - maxHeight: number; - period: number; - direction: string; - }; - temperature: { - current: number; - feelsLike: number; - }; - pressure: number; - visibility: number; - salinity: number; - astronomy?: { - sunrise: string; - sunset: string; - moonrise: string; - moonset: string; - moonPhase: string; - tidalRange: number; - }; - alert?: string; - forecast?: Array<{ - time: string; - icon: string; - temperature: number; - windSpeed: number; - }>; -} +export type { WeatherSnapshot }; interface WeatherSnapshotStore { snapshot: WeatherSnapshot | null; diff --git a/frontend/src/common/styles/components.css b/frontend/src/common/styles/components.css index 15aabeb..c8a810f 100644 --- a/frontend/src/common/styles/components.css +++ b/frontend/src/common/styles/components.css @@ -325,7 +325,7 @@ gap: 4px; padding: 5px 4px; border-radius: 5px; - font-size: 0.6875rem; + font-size: 0.75rem; font-weight: 600; font-family: var(--font-korean); cursor: pointer; @@ -360,7 +360,7 @@ width: 100%; padding: 10px; border-radius: 6px; - font-size: 12px; + font-size: 0.8125rem; font-weight: 700; cursor: pointer; border: none; @@ -386,7 +386,7 @@ border: 1px solid rgba(6, 182, 212, 0.2); border-radius: 6px; color: var(--color-accent); - font-size: 0.6875rem; + font-size: 0.75rem; font-weight: 600; cursor: pointer; white-space: nowrap; @@ -411,7 +411,7 @@ align-items: center; justify-content: center; border-radius: var(--radius-sm); - font-size: 12px; + font-size: 0.8125rem; font-weight: 600; transition: all 0.15s; background: rgba(15, 21, 36, 0.75); @@ -450,7 +450,7 @@ border-radius: 6px; padding: 5px 14px; font-family: var(--font-mono); - font-size: 0.6875rem; + font-size: 0.75rem; color: rgba(255, 255, 255, 0.7); font-weight: 400; z-index: 20; @@ -491,7 +491,7 @@ } .wii-value { - font-size: 0.6875rem; + font-size: 0.75rem; font-weight: 400; color: #ffffff; font-family: var(--font-mono); @@ -538,7 +538,7 @@ align-items: center; justify-content: center; cursor: pointer; - font-size: 14px; + font-size: 1rem; transition: 0.2s; } @@ -621,7 +621,7 @@ position: absolute; top: -18px; transform: translateX(-50%); - font-size: 12px; + font-size: 0.8125rem; cursor: pointer; filter: drop-shadow(0 0 4px rgba(245, 158, 11, 0.5)); } @@ -672,7 +672,7 @@ } .tlct { - font-size: 14px; + font-size: 1rem; font-weight: 600; color: var(--color-accent); font-family: var(--font-mono); @@ -841,7 +841,7 @@ } .layer-icon { - font-size: 14px; + font-size: 1rem; flex-shrink: 0; } @@ -903,10 +903,10 @@ background: var(--bg-base); border: 1px solid var(--stroke-default); border-radius: 4px; - color: var(--color-accent); + color: var(--color-default); font-family: var(--font-mono); font-size: 0.75rem; - font-weight: 600; + font-weight: 400; text-align: right; outline: none; transition: border-color 0.2s; @@ -1115,7 +1115,7 @@ cursor: pointer; border-radius: var(--radius-sm); transition: background 0.15s; - font-size: 12px; + font-size: 0.8125rem; font-weight: 700; color: var(--fg-default); font-family: var(--font-korean); @@ -1345,7 +1345,7 @@ } .lyr-ccustom label { - font-size: 0.6875rem; + font-size: 0.75rem; color: var(--fg-disabled); font-family: var(--font-korean); } @@ -1369,7 +1369,7 @@ border-radius: var(--radius-sm); } .lyr-style-label { - font-size: 0.6875rem; + font-size: 0.75rem; font-weight: 700; color: var(--fg-disabled); font-family: var(--font-korean); @@ -1411,7 +1411,7 @@ cursor: pointer; } .lyr-style-val { - font-size: 0.6875rem; + font-size: 0.75rem; color: var(--fg-disabled); font-family: var(--font-mono); min-width: 28px; diff --git a/frontend/src/common/types/hns.ts b/frontend/src/common/types/hns.ts deleted file mode 100644 index 72392b1..0000000 --- a/frontend/src/common/types/hns.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* 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/common/utils/geo.ts b/frontend/src/common/utils/geo.ts index 424987e..22b21d1 100755 --- a/frontend/src/common/utils/geo.ts +++ b/frontend/src/common/utils/geo.ts @@ -3,7 +3,7 @@ import type { BoomLineCoord, AlgorithmSettings, ContainmentResult, -} from '../types/boomLine'; +} from '@/types/boomLine'; const DEG2RAD = Math.PI / 180; const RAD2DEG = 180 / Math.PI; diff --git a/frontend/src/common/utils/imageAnalysisSignal.ts b/frontend/src/common/utils/imageAnalysisSignal.ts index 84569f4..6c99530 100644 --- a/frontend/src/common/utils/imageAnalysisSignal.ts +++ b/frontend/src/common/utils/imageAnalysisSignal.ts @@ -1,4 +1,4 @@ -import type { ImageAnalyzeResult } from '@tabs/prediction/services/predictionApi'; +import type { ImageAnalyzeResult } from '@interfaces/prediction/PredictionInterface'; /** * 항공탐색(유출유면적분석) → 유출유 확산예측 탭 간 데이터 전달용 모듈 레벨 시그널. diff --git a/frontend/src/tabs/admin/components/AdminPlaceholder.tsx b/frontend/src/components/admin/components/AdminPlaceholder.tsx similarity index 100% rename from frontend/src/tabs/admin/components/AdminPlaceholder.tsx rename to frontend/src/components/admin/components/AdminPlaceholder.tsx diff --git a/frontend/src/tabs/admin/components/AdminSidebar.tsx b/frontend/src/components/admin/components/AdminSidebar.tsx similarity index 100% rename from frontend/src/tabs/admin/components/AdminSidebar.tsx rename to frontend/src/components/admin/components/AdminSidebar.tsx diff --git a/frontend/src/tabs/admin/components/AdminView.tsx b/frontend/src/components/admin/components/AdminView.tsx old mode 100755 new mode 100644 similarity index 96% rename from frontend/src/tabs/admin/components/AdminView.tsx rename to frontend/src/components/admin/components/AdminView.tsx index 574dd66..6703374 --- a/frontend/src/tabs/admin/components/AdminView.tsx +++ b/frontend/src/components/admin/components/AdminView.tsx @@ -69,7 +69,9 @@ export function AdminView() { return (
-
{renderContent()}
+
+ {renderContent()} +
); } diff --git a/frontend/src/tabs/admin/components/AssetUploadPanel.tsx b/frontend/src/components/admin/components/AssetUploadPanel.tsx similarity index 96% rename from frontend/src/tabs/admin/components/AssetUploadPanel.tsx rename to frontend/src/components/admin/components/AssetUploadPanel.tsx index 0112a4c..3c67304 100644 --- a/frontend/src/tabs/admin/components/AssetUploadPanel.tsx +++ b/frontend/src/components/admin/components/AssetUploadPanel.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; -import { fetchUploadLogs } from '@tabs/assets/services/assetsApi'; -import type { UploadLogItem } from '@tabs/assets/services/assetsApi'; +import { fetchUploadLogs } from '@components/assets/services/assetsApi'; +import type { UploadLogItem } from '@interfaces/assets/AssetsInterface'; const ASSET_CATEGORIES = [ '전체', @@ -20,29 +20,29 @@ const PERM_ITEMS = [ icon: '👑', role: '시스템관리자', desc: '전체 자산 업로드/삭제 가능', - bg: 'rgba(245,158,11,0.15)', - color: 'text-yellow-400', + bg: 'rgba(6,182,212,0.12)', + color: 'text-color-accent', }, { icon: '🔧', role: '운영관리자', desc: '관할청 내 자산 업로드 가능', - bg: 'rgba(6,182,212,0.15)', + bg: 'rgba(6,182,212,0.08)', color: 'text-color-accent', }, { icon: '👁', role: '조회자', desc: '현황 조회만 가능', - bg: 'rgba(148,163,184,0.15)', + bg: 'rgba(6,182,212,0.08)', color: 'text-fg-sub', }, { icon: '🚫', role: '게스트', desc: '접근 불가', - bg: 'rgba(239,68,68,0.1)', - color: 'text-red-400', + bg: 'rgba(6,182,212,0.08)', + color: 'text-fg-sub', }, ]; @@ -102,7 +102,7 @@ function AssetUploadPanel() {
{/* 헤더 */}
-

자산 현행화

+

자산 현행화

자산 데이터를 업로드하여 현행화합니다

@@ -130,7 +130,7 @@ function AssetUploadPanel() { className={`rounded-lg border-2 border-dashed py-8 text-center cursor-pointer transition-colors ${ dragging ? 'border-color-accent bg-[rgba(6,182,212,0.05)]' - : 'border-stroke hover:border-[rgba(6,182,212,0.5)] bg-bg-elevated' + : 'border-stroke hover:border-[rgba(6,182,212,0.3)] bg-bg-elevated' }`} >
📁
diff --git a/frontend/src/tabs/admin/components/BoardMgmtPanel.tsx b/frontend/src/components/admin/components/BoardMgmtPanel.tsx similarity index 89% rename from frontend/src/tabs/admin/components/BoardMgmtPanel.tsx rename to frontend/src/components/admin/components/BoardMgmtPanel.tsx index 7a77c9a..a05dd37 100644 --- a/frontend/src/tabs/admin/components/BoardMgmtPanel.tsx +++ b/frontend/src/components/admin/components/BoardMgmtPanel.tsx @@ -1,10 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; -import { - fetchBoardPosts, - adminDeleteBoardPost, - type BoardPostItem, - type BoardListResponse, -} from '@tabs/board/services/boardApi'; +import { fetchBoardPosts, adminDeleteBoardPost } from '@components/board/services/boardApi'; +import type { BoardPostItem, BoardListResponse } from '@interfaces/board/BoardInterface'; // ─── 상수 ────────────────────────────────────────────────── const PAGE_SIZE = 20; @@ -118,13 +114,13 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP return (
{/* 헤더 */} -
+

게시판 관리

총 {data?.totalCount ?? 0}건
{/* 카테고리 탭 + 검색 */} -
+
{CATEGORY_TABS.map((tab) => ( @@ -158,11 +154,11 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
{/* 액션 바 */} -
+
@@ -172,7 +168,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
- + + @@ -275,17 +271,17 @@ function PostRow({ post, checked, onToggle }: PostRowProps) { {CATEGORY_LABELS[post.categoryCd] ?? post.categoryCd} diff --git a/frontend/src/tabs/admin/components/CleanupEquipPanel.tsx b/frontend/src/components/admin/components/CleanupEquipPanel.tsx similarity index 95% rename from frontend/src/tabs/admin/components/CleanupEquipPanel.tsx rename to frontend/src/components/admin/components/CleanupEquipPanel.tsx index 0f60686..8157fb7 100644 --- a/frontend/src/tabs/admin/components/CleanupEquipPanel.tsx +++ b/frontend/src/components/admin/components/CleanupEquipPanel.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useMemo } from 'react'; -import { fetchOrganizations } from '@tabs/assets/services/assetsApi'; -import type { AssetOrgCompat } from '@tabs/assets/services/assetsApi'; -import { typeTagCls } from '@tabs/assets/components/assetTypes'; +import { fetchOrganizations } from '@components/assets/services/assetsApi'; +import type { AssetOrgCompat } from '@interfaces/assets/AssetsInterface'; +import { typeTagCls } from '@components/assets/components/assetTypes'; +/* eslint-disable react-refresh/only-export-components */ const PAGE_SIZE = 20; @@ -98,7 +99,7 @@ function CleanupEquipPanel() { {/* 헤더 */}
-

방제장비 현황

+

방제장비 현황

총 {filtered.length}개 기관

@@ -341,16 +342,11 @@ function CleanupEquipPanel() { diff --git a/frontend/src/tabs/admin/components/CollectHrPanel.tsx b/frontend/src/components/admin/components/CollectHrPanel.tsx similarity index 94% rename from frontend/src/tabs/admin/components/CollectHrPanel.tsx rename to frontend/src/components/admin/components/CollectHrPanel.tsx index 5cd324e..a0e1214 100644 --- a/frontend/src/tabs/admin/components/CollectHrPanel.tsx +++ b/frontend/src/components/admin/components/CollectHrPanel.tsx @@ -176,9 +176,9 @@ function getCollectStatus(item: HrCollectItem): { label: string; color: string } return { label: '비활성', color: 'text-t3 bg-bg-elevated' }; } if (item.etaClctList.length > 0) { - return { label: '완료', color: 'text-emerald-400 bg-emerald-500/10' }; + return { label: '완료', color: 'text-color-success bg-[rgba(34,197,94,0.08)]' }; } - return { label: '대기', color: 'text-yellow-400 bg-yellow-500/10' }; + return { label: '대기', color: 'text-color-caution bg-[rgba(234,179,8,0.08)]' }; } // ─── cron 표현식 → 읽기 쉬운 형태 ───────────────────────── @@ -217,7 +217,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean }) {HEADERS.map((h) => (
@@ -227,7 +227,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean }) {loading && rows.length === 0 ? Array.from({ length: 5 }).map((_, i) => ( - + {HEADERS.map((_, j) => ( @@ -134,7 +134,7 @@ function ForecastTable({ rows, loading }: { rows: NumericalDataStatus[]; loading {loading && rows.length === 0 ? Array.from({ length: 6 }).map((_, i) => ( - + {TABLE_HEADERS.map((_, j) => ( )) : rows.map((row) => ( - + @@ -192,7 +192,7 @@ export default function MonitorForecastPanel() { return (
{/* 헤더 */} -
+

수치예측자료 모니터링

{lastUpdate && ( @@ -229,14 +229,14 @@ export default function MonitorForecastPanel() {
{/* 탭 */} -
+
{TABS.map((tab) => (
{/* 상태 표시줄 */} -
+
{!loading && totalCount > 0 && ( 모델 {totalCount}개 diff --git a/frontend/src/tabs/admin/components/MonitorRealtimePanel.tsx b/frontend/src/components/admin/components/MonitorRealtimePanel.tsx similarity index 88% rename from frontend/src/tabs/admin/components/MonitorRealtimePanel.tsx rename to frontend/src/components/admin/components/MonitorRealtimePanel.tsx index 28d99f1..080fd26 100644 --- a/frontend/src/tabs/admin/components/MonitorRealtimePanel.tsx +++ b/frontend/src/components/admin/components/MonitorRealtimePanel.tsx @@ -1,18 +1,17 @@ import { useState, useEffect, useCallback } from 'react'; -import { - getRecentObservation, - OBS_STATION_CODES, - type RecentObservation, -} from '@tabs/weather/services/khoaApi'; +import { getRecentObservation, OBS_STATION_CODES } from '@components/weather/services/khoaApi'; import { getUltraShortForecast, getMarineForecast, convertToGridCoords, getCurrentBaseDateTime, MARINE_REGIONS, - type WeatherForecastData, - type MarineWeatherData, -} from '@tabs/weather/services/weatherApi'; +} from '@components/weather/services/weatherApi'; +import type { + RecentObservation, + WeatherForecastData, + MarineWeatherData, +} from '@interfaces/weather/WeatherInterface'; const KEY_TO_NAME: Record = { incheon: '인천', @@ -85,30 +84,30 @@ function StatusBadge({ if (loading) { return ( - + 조회 중... ); } if (errorCount === total) { return ( - - + + 연계 오류 ); } if (errorCount > 0) { return ( - - + + 일부 오류 ({errorCount}/{total}) ); } return ( - - + + 정상 ); @@ -136,7 +135,7 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) { {headers.map((h) => (
@@ -146,7 +145,7 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) { {loading && rows.length === 0 ? Array.from({ length: 5 }).map((_, i) => ( - + {headers.map((_, j) => ( @@ -217,7 +216,7 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea {loading && rows.length === 0 ? Array.from({ length: 3 }).map((_, i) => ( - + {headers.map((_, j) => ( @@ -277,7 +276,7 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean }) {loading && rows.length === 0 ? Array.from({ length: 4 }).map((_, i) => ( - + {headers.map((_, j) => ( )) : rows.map((row) => ( - + @@ -294,9 +293,9 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean }) @@ -392,7 +392,7 @@ function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boo {loading && rows.length === 0 ? Array.from({ length: 8 }).map((_, i) => ( - + {HEADERS.map((_, j) => ( + + {OPER_CODES.map((oper) => { + const key = makeKey(node.code, oper); + const state = stateMap.get(key) ?? 'forced-denied'; + // READ 거부 시 CUD도 강제 거부 + const effectiveState = + oper !== 'READ' && readDenied ? ('forced-denied' as PermState) : state; + return ( + + ); + })} + + {hasChildren && + isExpanded && + node.children.map((child: PermTreeNode) => ( + + ))} + + ); +} diff --git a/frontend/src/components/admin/components/contents/UserDetailModal.tsx b/frontend/src/components/admin/components/contents/UserDetailModal.tsx new file mode 100644 index 0000000..7e7f3c0 --- /dev/null +++ b/frontend/src/components/admin/components/contents/UserDetailModal.tsx @@ -0,0 +1,293 @@ +import { useState } from 'react'; +import { changePasswordApi, updateUserApi, type UserListItem, type OrgItem, type RoleWithPermissions } from '@common/services/authApi'; +import { statusLabels } from '../adminConstants'; +import { formatDate } from '../UsersPanel'; + +interface UserDetailModalProps { + user: UserListItem; + allRoles: RoleWithPermissions[]; + allOrgs: OrgItem[]; + onClose: () => void; + onUpdated: () => void; +} + +export function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalProps) { + const [name, setName] = useState(user.name); + const [rank, setRank] = useState(user.rank || ''); + const [orgSn, setOrgSn] = useState(user.orgSn ?? ''); + const [saving, setSaving] = useState(false); + const [newPassword, setNewPassword] = useState(''); + const [resetPwLoading, setResetPwLoading] = useState(false); + const [resetPwDone, setResetPwDone] = useState(false); + const [unlockLoading, setUnlockLoading] = useState(false); + const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null); + + const handleSaveInfo = async () => { + setSaving(true); + setMessage(null); + try { + await updateUserApi(user.id, { + name: name.trim(), + rank: rank.trim() || undefined, + orgSn: orgSn !== '' ? orgSn : null, + }); + setMessage({ text: '사용자 정보가 수정되었습니다.', type: 'success' }); + onUpdated(); + } catch { + setMessage({ text: '사용자 정보 수정에 실패했습니다.', type: 'error' }); + } finally { + setSaving(false); + } + }; + + const handleResetPassword = async () => { + if (!newPassword.trim()) { + setMessage({ text: '새 비밀번호를 입력하세요.', type: 'error' }); + return; + } + setResetPwLoading(true); + setMessage(null); + try { + await changePasswordApi(user.id, newPassword); + setMessage({ text: '비밀번호가 초기화되었습니다.', type: 'success' }); + setResetPwDone(true); + setNewPassword(''); + } catch { + setMessage({ text: '비밀번호 초기화에 실패했습니다.', type: 'error' }); + } finally { + setResetPwLoading(false); + } + }; + + const handleUnlock = async () => { + setUnlockLoading(true); + setMessage(null); + try { + await updateUserApi(user.id, { status: 'ACTIVE' }); + setMessage({ text: '계정 잠금이 해제되었습니다.', type: 'success' }); + onUpdated(); + } catch { + setMessage({ text: '잠금 해제에 실패했습니다.', type: 'error' }); + } finally { + setUnlockLoading(false); + } + }; + + return ( +
+
+ {/* 헤더 */} +
+
+

사용자 정보

+

{user.account}

+
+ +
+ +
+ {/* 기본 정보 수정 */} +
+

+ 기본 정보 수정 +

+
+
+ + setName(e.target.value)} + className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean" + /> +
+
+
+ + setRank(e.target.value)} + placeholder="예: 팀장" + className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean" + /> +
+
+ + +
+
+ +
+
+ + {/* 구분선 */} +
+ + {/* 비밀번호 초기화 */} +
+

+ 비밀번호 초기화 +

+
+
+ + setNewPassword(e.target.value)} + placeholder="새 비밀번호 입력" + className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono" + /> +
+ + +
+

+ 초기화 후 사용자에게 새 비밀번호를 전달하세요. +

+
+ + {/* 구분선 */} +
+ + {/* 계정 잠금 해제 */} +
+

계정 상태

+
+
+
+ + + {(statusLabels[user.status] || statusLabels.INACTIVE).label} + + {user.failCount > 0 && ( + + (로그인 실패 {user.failCount}회) + + )} +
+ {user.status === 'LOCKED' && ( +

+ 비밀번호 5회 이상 오류로 잠금 처리됨 +

+ )} +
+ {user.status === 'LOCKED' && ( + + )} +
+
+ + {/* 기타 정보 (읽기 전용) */} +
+

기타 정보

+
+
+ 이메일: + {user.email || '-'} +
+
+ OAuth: + {user.oauthProvider || '-'} +
+
+ 최종 로그인: + + {user.lastLogin ? formatDate(user.lastLogin) : '-'} + +
+
+ 등록일: + {formatDate(user.regDtm)} +
+
+
+ + {/* 메시지 */} + {message && ( +
+ {message.text} +
+ )} +
+ + {/* 푸터 */} +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/UserPermTab.tsx b/frontend/src/components/admin/components/contents/UserPermTab.tsx new file mode 100644 index 0000000..3ffd7fe --- /dev/null +++ b/frontend/src/components/admin/components/contents/UserPermTab.tsx @@ -0,0 +1,336 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { UserListItem, PermTreeNode, RoleWithPermissions } from '@common/services/authApi'; +import { fetchUsers, assignRolesApi } from '@common/services/authApi'; +import { getRoleColor } from '../adminConstants'; +import type { OperCode, PermState } from '../PermissionsPanel'; +import { OPER_CODES, OPER_FULL_LABELS, OPER_LABELS, flattenTree, buildEffectiveStates } from '../PermissionsPanel'; +import { TreeRow } from './TreeRow'; +import { PermLegend } from './PermLegend'; + +interface UserPermTabProps { + roles: RoleWithPermissions[]; + permTree: PermTreeNode[]; + rolePerms: Map>; +} + +export function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) { + const [users, setUsers] = useState([]); + const [loadingUsers, setLoadingUsers] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [showDropdown, setShowDropdown] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [assignedRoleSns, setAssignedRoleSns] = useState([]); + const [savingRoles, setSavingRoles] = useState(false); + const [rolesDirty, setRolesDirty] = useState(false); + const [expanded, setExpanded] = useState>(new Set()); + const dropdownRef = useRef(null); + + const flatNodes = flattenTree(permTree); + + useEffect(() => { + const loadUsers = async () => { + setLoadingUsers(true); + try { + const data = await fetchUsers(); + setUsers(data); + } catch (err) { + console.error('사용자 목록 조회 실패:', err); + } finally { + setLoadingUsers(false); + } + }; + loadUsers(); + }, []); + + // 최상위 노드 기본 펼침 + useEffect(() => { + if (permTree.length > 0) { + setExpanded(new Set(permTree.map((n) => n.code))); + } + }, [permTree]); + + // 드롭다운 외부 클릭 시 닫기 + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setShowDropdown(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const filteredUsers = users.filter((u) => { + if (!searchQuery) return true; + const q = searchQuery.toLowerCase(); + return ( + u.name.toLowerCase().includes(q) || + u.account.toLowerCase().includes(q) || + (u.orgName?.toLowerCase().includes(q) ?? false) + ); + }); + + const handleSelectUser = (user: UserListItem) => { + setSelectedUser(user); + setSearchQuery(user.name); + setShowDropdown(false); + setAssignedRoleSns(user.roleSns ?? []); + setRolesDirty(false); + }; + + const handleToggleRole = (roleSn: number) => { + setAssignedRoleSns((prev) => { + const next = prev.includes(roleSn) ? prev.filter((sn) => sn !== roleSn) : [...prev, roleSn]; + return next; + }); + setRolesDirty(true); + }; + + const handleSaveRoles = async () => { + if (!selectedUser) return; + setSavingRoles(true); + try { + await assignRolesApi(selectedUser.id, assignedRoleSns); + setRolesDirty(false); + // 로컬 users 상태 갱신 + setUsers((prev) => + prev.map((u) => + u.id === selectedUser.id + ? { + ...u, + roleSns: assignedRoleSns, + roles: roles.filter((r) => assignedRoleSns.includes(r.sn)).map((r) => r.name), + } + : u, + ), + ); + setSelectedUser((prev) => + prev + ? { + ...prev, + roleSns: assignedRoleSns, + roles: roles.filter((r) => assignedRoleSns.includes(r.sn)).map((r) => r.name), + } + : null, + ); + } catch (err) { + console.error('역할 저장 실패:', err); + } finally { + setSavingRoles(false); + } + }; + + const handleToggleExpand = useCallback((code: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(code)) next.delete(code); + else next.add(code); + return next; + }); + }, []); + + // 사용자의 유효 권한: 할당된 역할들의 권한 병합 (OR 결합) + const effectiveStateMap = (() => { + if (!selectedUser || assignedRoleSns.length === 0) { + return new Map(); + } + + // 각 역할의 명시적 권한 병합: 어느 역할이든 granted=true면 허용 + const mergedPerms = new Map(); + for (const roleSn of assignedRoleSns) { + const perms = rolePerms.get(roleSn); + if (!perms) continue; + for (const [key, granted] of perms) { + if (granted) { + mergedPerms.set(key, true); + } else if (!mergedPerms.has(key)) { + mergedPerms.set(key, false); + } + } + } + + return buildEffectiveStates(flatNodes, mergedPerms); + })(); + + const noOpToggle = useCallback((_code: string, _oper: OperCode, _state: PermState): void => { + void _code; + void _oper; + void _state; + // 읽기 전용 — 토글 없음 + }, []); + + return ( +
+ {/* 사용자 검색/선택 */} +
+ +
+ { + setSearchQuery(e.target.value); + setShowDropdown(true); + if (selectedUser && e.target.value !== selectedUser.name) { + setSelectedUser(null); + setAssignedRoleSns([]); + setRolesDirty(false); + } + }} + onFocus={() => setShowDropdown(true)} + placeholder={loadingUsers ? '불러오는 중...' : '이름, 계정, 조직으로 검색...'} + disabled={loadingUsers} + className="w-full max-w-sm px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean disabled:opacity-50" + /> + {showDropdown && filteredUsers.length > 0 && ( +
+ {filteredUsers.map((user) => ( + + ))} +
+ )} + {showDropdown && !loadingUsers && filteredUsers.length === 0 && searchQuery && ( +
+ 검색 결과 없음 +
+ )} +
+
+ + {selectedUser ? ( + <> + {/* 역할 할당 섹션 */} +
+
+ 역할 할당 + +
+
+ {roles.map((role, idx) => { + const color = getRoleColor(role.code, idx); + const isChecked = assignedRoleSns.includes(role.sn); + return ( + + ); + })} +
+
+ + {/* 유효 권한 매트릭스 (읽기 전용) */} +
+ 유효 권한 (읽기 전용) + — 할당된 역할의 권한 합산 결과 +
+ + + + {assignedRoleSns.length > 0 ? ( +
+
1 && ( -
+
- {post.pinnedYn === 'Y' && [고정]} + {post.pinnedYn === 'Y' && [고정]} {post.title} {post.authorName} {h}
@@ -240,7 +240,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean }) return (
{idx + 1} @@ -258,7 +258,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean }) @@ -316,7 +316,7 @@ export default function CollectHrPanel() { return (
{/* 헤더 */} -
+

인사정보 수집 현황

{lastUpdate && ( @@ -353,9 +353,9 @@ export default function CollectHrPanel() {
{/* 상태 표시줄 */} -
- - +
+ + 수집 완료 {completedCount}건 diff --git a/frontend/src/components/admin/components/DeidentifyPanel.tsx b/frontend/src/components/admin/components/DeidentifyPanel.tsx new file mode 100644 index 0000000..935f683 --- /dev/null +++ b/frontend/src/components/admin/components/DeidentifyPanel.tsx @@ -0,0 +1,567 @@ +import { useState, useEffect, useCallback } from 'react'; +import { TaskTable } from './contents/TaskTable'; +import { AuditLogModal } from './contents/AuditLogModal'; +import { WizardModal } from './contents/WizardModal'; +/* eslint-disable react-refresh/only-export-components */ + +// ─── 타입 ────────────────────────────────────────────────── + +export type TaskStatus = '완료' | '진행중' | '대기' | '오류'; + +export interface AuditLogEntry { + id: string; + time: string; + operator: string; + operatorId: string; + action: string; + targetData: string; + result: string; + resultType: '성공' | '실패' | '거부' | '진행중'; + ip: string; + browser: string; + detail: { + dataCount: number; + rulesApplied: string; + processedCount: number; + errorCount: number; + }; +} + +export interface DeidentifyTask { + id: string; + name: string; + target: string; + status: TaskStatus; + startTime: string; + progress: number; + createdBy: string; +} + +export type SourceType = 'db' | 'file' | 'api'; +export type ProcessMode = 'immediate' | 'scheduled' | 'oneshot'; +export type RepeatType = 'daily' | 'weekly' | 'monthly'; +export type DeidentifyTechnique = '마스킹' | '삭제' | '범주화' | '암호화' | '샘플링' | '가명처리' | '유지'; + +export interface FieldConfig { + name: string; + dataType: string; + technique: DeidentifyTechnique; + configValue: string; + selected: boolean; +} + +export interface DbConfig { + host: string; + port: string; + database: string; + tableName: string; +} + +export interface ApiConfig { + url: string; + method: 'GET' | 'POST'; +} + +export interface ScheduleConfig { + hour: string; + repeatType: RepeatType; + weekday: string; + startDate: string; + notifyOnComplete: boolean; + notifyOnError: boolean; +} + +export interface OneshotConfig { + date: string; + hour: string; +} + +export interface WizardState { + step: number; + taskName: string; + sourceType: SourceType; + dbConfig: DbConfig; + apiConfig: ApiConfig; + fields: FieldConfig[]; + processMode: ProcessMode; + scheduleConfig: ScheduleConfig; + oneshotConfig: OneshotConfig; + saveAsTemplate: boolean; + applyTemplate: string; + confirmed: boolean; +} + +// ─── Mock 데이터 ──────────────────────────────────────────── + +export const MOCK_TASKS: DeidentifyTask[] = [ + { + id: '001', + name: 'customer_2024', + target: '선박/운항 - 선장·선원 성명', + status: '완료', + startTime: '2026-04-10 14:30', + progress: 100, + createdBy: '관리자', + }, + { + id: '002', + name: 'transaction_04', + target: '사고 현장 - 현장사진, 영상내 인물', + status: '진행중', + startTime: '2026-04-10 14:15', + progress: 82, + createdBy: '김담당', + }, + { + id: '003', + name: 'employee_info', + target: '인사정보 - 계정, 로그인 정보', + status: '대기', + startTime: '2026-04-10 22:00', + progress: 0, + createdBy: '이담당', + }, + { + id: '004', + name: 'vendor_data', + target: 'HNS 대응 - 화학물질 취급자, 방제업체 연락처', + status: '오류', + startTime: '2026-04-09 13:45', + progress: 45, + createdBy: '관리자', + }, + { + id: '005', + name: 'partner_contacts', + target: '시스템 운영 - 관리자, 운영자 접속로그', + status: '완료', + startTime: '2026-04-08 09:00', + progress: 100, + createdBy: '박담당', + }, +]; + +export const DEFAULT_FIELDS: FieldConfig[] = [ + { name: '고객ID', dataType: '문자열', technique: '삭제', configValue: '-', selected: true }, + { + name: '이름', + dataType: '문자열', + technique: '마스킹', + configValue: '*로 치환', + selected: true, + }, + { + name: '휴대폰', + dataType: '문자열', + technique: '마스킹', + configValue: '010-****-****', + selected: true, + }, + { + name: '주소', + dataType: '문자열', + technique: '범주화', + configValue: '시/도만 표시', + selected: true, + }, + { + name: '이메일', + dataType: '문자열', + technique: '가명처리', + configValue: '키: random_001', + selected: true, + }, + { + name: '생년월일', + dataType: '날짜', + technique: '범주화', + configValue: '연도만 표시', + selected: true, + }, + { name: '회사', dataType: '문자열', technique: '유지', configValue: '변경 없음', selected: true }, +]; + +export const TECHNIQUES: DeidentifyTechnique[] = [ + '마스킹', + '삭제', + '범주화', + '암호화', + '샘플링', + '가명처리', + '유지', +]; + +export const HOURS = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`); + +export const WEEKDAYS = ['월', '화', '수', '목', '금', '토', '일']; + +export const TEMPLATES = ['기본 개인정보', '금융데이터', '의료데이터']; + +export const MOCK_AUDIT_LOGS: Record = { + '001': [ + { + id: 'LOG_20260410_001', + time: '2026-04-10 14:30:45', + operator: '김철수', + operatorId: 'user_12345', + action: '처리완료', + targetData: 'customer_2024', + result: '성공 (100%)', + resultType: '성공', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { + dataCount: 15240, + rulesApplied: '마스킹 3, 범주화 2, 삭제 2', + processedCount: 15240, + errorCount: 0, + }, + }, + { + id: 'LOG_20260410_002', + time: '2026-04-10 14:15:10', + operator: '김철수', + operatorId: 'user_12345', + action: '처리시작', + targetData: 'customer_2024', + result: '성공', + resultType: '성공', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { + dataCount: 15240, + rulesApplied: '마스킹 3, 범주화 2, 삭제 2', + processedCount: 0, + errorCount: 0, + }, + }, + { + id: 'LOG_20260410_003', + time: '2026-04-10 14:10:30', + operator: '김철수', + operatorId: 'user_12345', + action: '규칙설정', + targetData: 'customer_2024', + result: '성공', + resultType: '성공', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { dataCount: 15240, rulesApplied: '7개 규칙 적용', processedCount: 0, errorCount: 0 }, + }, + ], + '002': [ + { + id: 'LOG_20260410_004', + time: '2026-04-10 14:15:22', + operator: '이영희', + operatorId: 'user_23456', + action: '처리시작', + targetData: 'transaction_04', + result: '진행중 (82%)', + resultType: '진행중', + ip: '192.168.1.101', + browser: 'Firefox 124.0', + detail: { + dataCount: 8920, + rulesApplied: '마스킹 2, 암호화 1, 삭제 3', + processedCount: 7314, + errorCount: 0, + }, + }, + ], + '003': [ + { + id: 'LOG_20260410_005', + time: '2026-04-10 13:45:30', + operator: '박민준', + operatorId: 'user_34567', + action: '규칙수정', + targetData: 'employee_info', + result: '성공', + resultType: '성공', + ip: '192.168.1.102', + browser: 'Chrome 123.0', + detail: { + dataCount: 3200, + rulesApplied: '마스킹 4, 가명처리 1', + processedCount: 0, + errorCount: 0, + }, + }, + ], + '004': [ + { + id: 'LOG_20260409_001', + time: '2026-04-09 13:45:30', + operator: '관리자', + operatorId: 'user_admin', + action: '처리오류', + targetData: 'vendor_data', + result: '오류 (45%)', + resultType: '실패', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { + dataCount: 5100, + rulesApplied: '마스킹 2, 범주화 1, 삭제 1', + processedCount: 2295, + errorCount: 12, + }, + }, + { + id: 'LOG_20260409_002', + time: '2026-04-09 13:40:15', + operator: '김철수', + operatorId: 'user_12345', + action: '규칙조회', + targetData: 'vendor_data', + result: '성공', + resultType: '성공', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { dataCount: 5100, rulesApplied: '4개 규칙', processedCount: 0, errorCount: 0 }, + }, + { + id: 'LOG_20260409_003', + time: '2026-04-09 09:25:00', + operator: '이영희', + operatorId: 'user_23456', + action: '삭제시도', + targetData: 'vendor_data', + result: '거부 (권한부족)', + resultType: '거부', + ip: '192.168.1.101', + browser: 'Firefox 124.0', + detail: { dataCount: 5100, rulesApplied: '-', processedCount: 0, errorCount: 0 }, + }, + ], + '005': [ + { + id: 'LOG_20260408_001', + time: '2026-04-08 09:15:00', + operator: '박담당', + operatorId: 'user_45678', + action: '처리완료', + targetData: 'partner_contacts', + result: '성공 (100%)', + resultType: '성공', + ip: '192.168.1.103', + browser: 'Edge 122.0', + detail: { + dataCount: 1850, + rulesApplied: '마스킹 2, 유지 3', + processedCount: 1850, + errorCount: 0, + }, + }, + ], +}; + +function fetchTasks(): Promise { + return new Promise((resolve) => { + setTimeout(() => resolve(MOCK_TASKS), 300); + }); +} + +// ─── 상태 뱃지 ───────────────────────────────────────────── + +export function getStatusBadgeClass(status: TaskStatus): string { + switch (status) { + case '완료': + return 'text-color-success bg-[rgba(34,197,94,0.1)]'; + case '진행중': + return 'text-color-accent bg-[rgba(6,182,212,0.1)]'; + case '대기': + return 'text-color-caution bg-[rgba(234,179,8,0.1)]'; + case '오류': + return 'text-color-danger bg-[rgba(239,68,68,0.1)]'; + } +} + +// ─── 진행률 바 ───────────────────────────────────────────── + +export const TABLE_HEADERS = ['작업ID', '작업명', '대상', '상태', '시작시간', '진행률', '등록자', '액션']; + +export const STEP_LABELS = ['소스선택', '데이터검증', '비식별화규칙', '처리방식', '최종확인']; + +export const INITIAL_WIZARD: WizardState = { + step: 1, + taskName: '', + sourceType: 'db', + dbConfig: { host: '', port: '5432', database: '', tableName: '' }, + apiConfig: { url: '', method: 'GET' }, + fields: DEFAULT_FIELDS, + processMode: 'immediate', + scheduleConfig: { + hour: '02:00', + repeatType: 'daily', + weekday: '월', + startDate: '', + notifyOnComplete: true, + notifyOnError: true, + }, + oneshotConfig: { date: '', hour: '02:00' }, + saveAsTemplate: false, + applyTemplate: '', + confirmed: false, +}; + +// ─── 메인 패널 ────────────────────────────────────────────── + +type FilterStatus = '모두' | TaskStatus; + +export default function DeidentifyPanel() { + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(false); + const [showWizard, setShowWizard] = useState(false); + const [auditTask, setAuditTask] = useState(null); + const [searchName, setSearchName] = useState(''); + const [filterStatus, setFilterStatus] = useState('모두'); + const [filterPeriod, setFilterPeriod] = useState<'7' | '30' | '90'>('30'); + + const loadTasks = useCallback(async () => { + setLoading(true); + const data = await fetchTasks(); + setTasks(data); + setLoading(false); + }, []); + + useEffect(() => { + let isMounted = true; + if (tasks.length === 0) { + void Promise.resolve().then(() => { + if (isMounted) void loadTasks(); + }); + } + return () => { + isMounted = false; + }; + }, [tasks.length, loadTasks]); + + const handleAction = useCallback((action: string, task: DeidentifyTask) => { + // TODO: 실제 API 연동 시 각 액션에 맞는 API 호출로 교체 + if (action === 'delete') { + setTasks((prev) => prev.filter((t) => t.id !== task.id)); + } else if (action === 'audit') { + setAuditTask(task); + } + }, []); + + const handleWizardSubmit = useCallback( + (wizard: WizardState) => { + const selectedFields = wizard.fields.filter((f) => f.selected).map((f) => f.name); + const newTask: DeidentifyTask = { + id: String(tasks.length + 1).padStart(3, '0'), + name: wizard.taskName, + target: selectedFields.join(', ') || '-', + status: wizard.processMode === 'immediate' ? '진행중' : '대기', + startTime: new Date() + .toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) + .replace(/\. /g, '-') + .replace('.', ''), + progress: 0, + createdBy: '관리자', + }; + setTasks((prev) => [newTask, ...prev]); + }, + [tasks.length], + ); + + const filteredTasks = tasks.filter((t) => { + if (searchName && !t.name.includes(searchName)) return false; + if (filterStatus !== '모두' && t.status !== filterStatus) return false; + return true; + }); + + const completedCount = tasks.filter((t) => t.status === '완료').length; + const inProgressCount = tasks.filter((t) => t.status === '진행중').length; + const errorCount = tasks.filter((t) => t.status === '오류').length; + + return ( +
+ {/* 헤더 */} +
+

비식별화조치

+ +
+ + {/* 상태 요약 */} +
+ + + 완료 {completedCount}건 + + + + 진행중 {inProgressCount}건 + + {errorCount > 0 && ( + + + 오류 {errorCount}건 + + )} + 전체 {tasks.length}건 +
+ + {/* 검색/필터 */} +
+ setSearchName(e.target.value)} + placeholder="작업명 검색" + className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 placeholder:text-t3 focus:outline-none focus:border-color-accent w-40" + /> + + +
+ + {/* 테이블 */} +
+ +
+ + {/* 감사로그 모달 */} + {auditTask && setAuditTask(null)} />} + + {/* 마법사 모달 */} + {showWizard && ( + setShowWizard(false)} onSubmit={handleWizardSubmit} /> + )} +
+ ); +} diff --git a/frontend/src/tabs/admin/components/DispersingZonePanel.tsx b/frontend/src/components/admin/components/DispersingZonePanel.tsx similarity index 96% rename from frontend/src/tabs/admin/components/DispersingZonePanel.tsx rename to frontend/src/components/admin/components/DispersingZonePanel.tsx index 884bd7b..14f7549 100644 --- a/frontend/src/tabs/admin/components/DispersingZonePanel.tsx +++ b/frontend/src/components/admin/components/DispersingZonePanel.tsx @@ -5,7 +5,7 @@ import { GeoJsonLayer } from '@deck.gl/layers'; import type { Layer } from '@deck.gl/core'; import 'maplibre-gl/dist/maplibre-gl.css'; import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; -import { S57EncOverlay } from '@common/components/map/S57EncOverlay'; +import { S57EncOverlay } from '@components/common/map/S57EncOverlay'; import { useMapStore } from '@common/store/mapStore'; const MAP_CENTER: [number, number] = [127.5, 36.0]; @@ -119,7 +119,7 @@ const DispersingZonePanel = () => { const isConsider = zone === 'consider'; const showLayer = isConsider ? showConsider : showRestrict; const setShowLayer = isConsider ? setShowConsider : setShowRestrict; - const swatchColor = isConsider ? 'bg-blue-500' : 'bg-red-500'; + const swatchColor = isConsider ? 'bg-color-info' : 'bg-color-danger'; const isExpanded = expandedZone === zone; return ( @@ -197,11 +197,11 @@ const DispersingZonePanel = () => { {/* 범례 */}
- + 사용고려해역
- + 사용제한해역
diff --git a/frontend/src/tabs/admin/components/LayerPanel.tsx b/frontend/src/components/admin/components/LayerPanel.tsx similarity index 97% rename from frontend/src/tabs/admin/components/LayerPanel.tsx rename to frontend/src/components/admin/components/LayerPanel.tsx index 533d81f..5ceef78 100644 --- a/frontend/src/tabs/admin/components/LayerPanel.tsx +++ b/frontend/src/components/admin/components/LayerPanel.tsx @@ -229,7 +229,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP {/* 레이어코드 */}
-

{formError}

+

{formError}

)} {/* 버튼 */} @@ -448,7 +448,7 @@ const LayerPanel = () => {
-

레이어 관리

+

레이어 관리

총 {total}개

diff --git a/frontend/src/tabs/admin/components/MapBasePanel.tsx b/frontend/src/components/admin/components/MapBasePanel.tsx similarity index 95% rename from frontend/src/tabs/admin/components/MapBasePanel.tsx rename to frontend/src/components/admin/components/MapBasePanel.tsx index 539f26f..45e463b 100644 --- a/frontend/src/tabs/admin/components/MapBasePanel.tsx +++ b/frontend/src/components/admin/components/MapBasePanel.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { api } from '@common/services/api'; import { useMapStore } from '@common/store/mapStore'; +/* eslint-disable react-refresh/only-export-components */ // ─── 타입 ───────────────────────────────────────────────── interface MapBaseItem { @@ -101,7 +102,7 @@ function MapBaseModal({ {/* 지도 이름 */}
setField('useYn', form.useYn === 'Y' ? 'N' : 'Y')} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ - form.useYn === 'Y' ? 'bg-color-accent' : 'bg-border' + form.useYn === 'Y' ? 'bg-color-accent' : 'bg-bg-elevated' }`} > {/* 에러 */} - {modalError &&

{modalError}

} + {modalError &&

{modalError}

}
{/* 모달 푸터 */} @@ -349,7 +350,7 @@ function MapBasePanel() { {/* 헤더 */}
-

지도 관리

+

지도 관리

총 {total}건

@@ -478,7 +479,7 @@ function MapBasePanel() { onClick={() => setPage(p)} className={`w-7 h-7 text-caption rounded ${ p === page - ? 'bg-blue-500/20 text-blue-400 font-medium' + ? 'bg-[rgba(6,182,212,0.15)] text-color-accent font-medium' : 'text-fg-disabled hover:bg-bg-elevated' }`} > diff --git a/frontend/src/tabs/admin/components/MenusPanel.tsx b/frontend/src/components/admin/components/MenusPanel.tsx similarity index 98% rename from frontend/src/tabs/admin/components/MenusPanel.tsx rename to frontend/src/components/admin/components/MenusPanel.tsx index d9e603b..14e6bf3 100644 --- a/frontend/src/tabs/admin/components/MenusPanel.tsx +++ b/frontend/src/components/admin/components/MenusPanel.tsx @@ -135,7 +135,7 @@ function MenusPanel() {
-

메뉴 관리

+

메뉴 관리

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

diff --git a/frontend/src/tabs/admin/components/MonitorForecastPanel.tsx b/frontend/src/components/admin/components/MonitorForecastPanel.tsx similarity index 87% rename from frontend/src/tabs/admin/components/MonitorForecastPanel.tsx rename to frontend/src/components/admin/components/MonitorForecastPanel.tsx index 79388d8..fde3f59 100644 --- a/frontend/src/tabs/admin/components/MonitorForecastPanel.tsx +++ b/frontend/src/components/admin/components/MonitorForecastPanel.tsx @@ -45,19 +45,19 @@ function formatTime(iso: string | null): string { function StatusCell({ row }: { row: NumericalDataStatus }) { if (row.lastStatus === 'COMPLETED') { - return 정상; + return 정상; } if (row.lastStatus === 'FAILED') { return ( - + 오류{row.consecutiveFailures > 0 ? ` (${row.consecutiveFailures}회 연속)` : ''} ); } if (row.lastStatus === 'STARTED') { return ( - - + + 실행 중 ); @@ -77,30 +77,30 @@ function StatusBadge({ if (loading) { return ( - + 조회 중... ); } if (errorCount === total && total > 0) { return ( - - + + 연계 오류 ); } if (errorCount > 0) { return ( - - + + 일부 오류 ({errorCount}/{total}) ); } return ( - - + + 정상 ); @@ -124,7 +124,7 @@ function ForecastTable({ rows, loading }: { rows: NumericalDataStatus[]; loading {TABLE_HEADERS.map((h) => (
{h}
@@ -143,7 +143,7 @@ function ForecastTable({ rows, loading }: { rows: NumericalDataStatus[]; loading
{row.modelName} {h}
@@ -157,7 +156,7 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) { : rows.map((row) => (
{row.stationName} @@ -172,9 +171,9 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) { {fmt(row.data?.tide_level, 0)} {row.error ? ( - 오류 + 오류 ) : row.data ? ( - 정상 + 정상 ) : ( - )} @@ -207,7 +206,7 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea {headers.map((h) => ( {h}
@@ -228,7 +227,7 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea : rows.map((row) => (
{row.stationName} @@ -241,9 +240,9 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea {fmt(row.data?.humidity, 0)} {row.error ? ( - 오류 + 오류 ) : row.data ? ( - 정상 + 정상 ) : ( - )} @@ -267,7 +266,7 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean }) {headers.map((h) => ( {h}
@@ -286,7 +285,7 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean })
{row.name} {fmt(row.data?.waveHeight)} {fmt(row.data?.windSpeed)}{fmt(row.data?.temperature)} {row.error ? ( - 오류 + 오류 ) : row.data ? ( - 정상 + 정상 ) : ( - )} @@ -440,7 +439,7 @@ export default function MonitorRealtimePanel() { return (
{/* 헤더 */} -
+

실시간 관측자료 모니터링

{lastUpdate && ( @@ -477,14 +476,14 @@ export default function MonitorRealtimePanel() {
{/* 탭 */} -
+
{TABS.map((tab) => (
{/* 상태 표시줄 */} -
+
{activeTab === 'khoa' && `관측소 ${totalCount}개`} diff --git a/frontend/src/tabs/admin/components/MonitorVesselPanel.tsx b/frontend/src/components/admin/components/MonitorVesselPanel.tsx similarity index 94% rename from frontend/src/tabs/admin/components/MonitorVesselPanel.tsx rename to frontend/src/components/admin/components/MonitorVesselPanel.tsx index aecfd69..abea8d4 100644 --- a/frontend/src/tabs/admin/components/MonitorVesselPanel.tsx +++ b/frontend/src/components/admin/components/MonitorVesselPanel.tsx @@ -301,7 +301,7 @@ function StatusBadge({ if (loading) { return ( - + 조회 중... ); @@ -309,23 +309,23 @@ function StatusBadge({ const offCount = total - onCount; if (offCount === total) { return ( - - + + 전체 OFF ); } if (offCount > 0) { return ( - - + + 일부 OFF ({offCount}/{total}) ); } return ( - - + + 전체 정상 ); @@ -342,7 +342,7 @@ function ConnectionBadge({ if (isNormal) { return (
- + ON {lastMessageTime && {lastMessageTime}} @@ -351,7 +351,7 @@ function ConnectionBadge({ } return (
- + OFF {lastMessageTime && {lastMessageTime}} @@ -382,7 +382,7 @@ function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boo {HEADERS.map((h) => (
{h}
@@ -403,7 +403,7 @@ function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boo : rows.map((row, idx) => (
{idx + 1} @@ -461,7 +461,7 @@ export default function MonitorVesselPanel() { return (
{/* 헤더 */} -
+

선박위치정보 모니터링

{lastUpdate && ( @@ -498,7 +498,7 @@ export default function MonitorVesselPanel() {
{/* 상태 표시줄 */} -
+
연계 채널 {rows.length}개 (ON: {onCount} / OFF: {rows.length - onCount}) diff --git a/frontend/src/components/admin/components/PermissionsPanel.tsx b/frontend/src/components/admin/components/PermissionsPanel.tsx new file mode 100644 index 0000000..aa1d016 --- /dev/null +++ b/frontend/src/components/admin/components/PermissionsPanel.tsx @@ -0,0 +1,417 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + fetchRoles, + fetchPermTree, + type RoleWithPermissions, + type PermTreeNode, +} from '@common/services/authApi'; +import { RolePermTab } from './contents/RolePermTab'; +import { UserPermTab } from './contents/UserPermTab'; +/* eslint-disable react-refresh/only-export-components */ + +// ─── 오퍼레이션 코드 ───────────────────────────────── +export const OPER_CODES = ['READ', 'CREATE', 'UPDATE', 'DELETE'] as const; +export type OperCode = (typeof OPER_CODES)[number]; +export const OPER_LABELS: Record = { READ: 'R', CREATE: 'C', UPDATE: 'U', DELETE: 'D' }; +export const OPER_FULL_LABELS: Record = { + READ: '조회', + CREATE: '생성', + UPDATE: '수정', + DELETE: '삭제', +}; + +// ─── 권한 상태 타입 ───────────────────────────────────── +export type PermState = 'explicit-granted' | 'inherited-granted' | 'explicit-denied' | 'forced-denied'; + +// ─── 키 유틸 ────────────────────────────────────────── +export function makeKey(rsrc: string, oper: string): string { + return `${rsrc}::${oper}`; +} + +// ─── 유틸: 플랫 노드 목록 추출 (트리 DFS) ───────────── +export function flattenTree(nodes: PermTreeNode[]): PermTreeNode[] { + const result: PermTreeNode[] = []; + function walk(list: PermTreeNode[]) { + for (const n of list) { + result.push(n); + if (n.children.length > 0) walk(n.children); + } + } + walk(nodes); + return result; +} + +// ─── 유틸: 권한 상태 계산 (오퍼레이션별) ────────────── +function resolvePermStateForOper( + code: string, + parentCode: string | null, + operCd: string, + explicitPerms: Map, + cache: Map, +): PermState { + const key = makeKey(code, operCd); + const cached = cache.get(key); + if (cached) return cached; + + const explicit = explicitPerms.get(key); + + if (parentCode === null) { + const state: PermState = + explicit === true + ? 'explicit-granted' + : explicit === false + ? 'explicit-denied' + : 'explicit-denied'; + cache.set(key, state); + return state; + } + + // 부모 READ 확인 (접근 게이트) + const parentReadKey = makeKey(parentCode, 'READ'); + const parentReadState = cache.get(parentReadKey); + if (parentReadState === 'explicit-denied' || parentReadState === 'forced-denied') { + cache.set(key, 'forced-denied'); + return 'forced-denied'; + } + + if (explicit === true) { + cache.set(key, 'explicit-granted'); + return 'explicit-granted'; + } + if (explicit === false) { + cache.set(key, 'explicit-denied'); + return 'explicit-denied'; + } + + // 부모의 같은 오퍼레이션 상속 + const parentOperKey = makeKey(parentCode, operCd); + const parentOperState = cache.get(parentOperKey); + if (parentOperState === 'explicit-granted' || parentOperState === 'inherited-granted') { + cache.set(key, 'inherited-granted'); + return 'inherited-granted'; + } + if (parentOperState === 'forced-denied') { + cache.set(key, 'forced-denied'); + return 'forced-denied'; + } + + cache.set(key, 'explicit-denied'); + return 'explicit-denied'; +} + +export function buildEffectiveStates( + flatNodes: PermTreeNode[], + explicitPerms: Map, +): Map { + const cache = new Map(); + for (const node of flatNodes) { + // READ 먼저 (CUD는 READ에 의존) + resolvePermStateForOper(node.code, node.parentCode, 'READ', explicitPerms, cache); + for (const oper of OPER_CODES) { + if (oper === 'READ') continue; + resolvePermStateForOper(node.code, node.parentCode, oper, explicitPerms, cache); + } + } + return cache; +} + +type ActiveTab = 'role' | 'user'; + +function PermissionsPanel() { + const [activeTab, setActiveTab] = useState('role'); + const [roles, setRoles] = useState([]); + const [permTree, setPermTree] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + 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(''); + const [expanded, setExpanded] = useState>(new Set()); + const [selectedRoleSn, setSelectedRoleSn] = useState(null); + + // 역할별 명시적 권한: Map> + const [rolePerms, setRolePerms] = useState>>(new Map()); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const [rolesData, treeData] = await Promise.all([fetchRoles(), fetchPermTree()]); + setRoles(rolesData); + setPermTree(treeData); + + // 명시적 권한 맵 초기화 (rsrc::oper 키 형식) + const permsMap = new Map>(); + for (const role of rolesData) { + const roleMap = new Map(); + for (const p of role.permissions) { + roleMap.set(makeKey(p.resourceCode, p.operationCode), p.granted); + } + permsMap.set(role.sn, roleMap); + } + setRolePerms(permsMap); + + // 최상위 노드 기본 펼침 + setExpanded(new Set(treeData.map((n) => n.code))); + // 첫 번째 역할 선택 + if (rolesData.length > 0 && !selectedRoleSn) { + setSelectedRoleSn(rolesData[0].sn); + } + setDirty(false); + } catch (err) { + console.error('권한 데이터 조회 실패:', err); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- 초기 마운트 시 1회만 실행 + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + // 플랫 노드 목록 + const flatNodes = flattenTree(permTree); + + const handleToggleExpand = useCallback((code: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(code)) next.delete(code); + else next.add(code); + return next; + }); + }, []); + + const handleTogglePerm = useCallback( + (code: string, oper: OperCode, currentState: PermState) => { + if (!selectedRoleSn) return; + + setRolePerms((prev) => { + const next = new Map(prev); + const roleMap = new Map(next.get(selectedRoleSn) ?? new Map()); + + const key = makeKey(code, oper); + const node = flatNodes.find((n) => n.code === code); + const isRoot = node ? node.parentCode === null : false; + + switch (currentState) { + case 'explicit-granted': + roleMap.set(key, false); + break; + case 'inherited-granted': + roleMap.set(key, false); + break; + case 'explicit-denied': + if (isRoot) { + roleMap.set(key, true); + } else { + roleMap.delete(key); + } + break; + default: + return prev; + } + + next.set(selectedRoleSn, roleMap); + return next; + }); + setDirty(true); + }, + [selectedRoleSn, flatNodes], + ); + + const handleSave = async () => { + setSaving(true); + setSaveError(null); + try { + for (const role of roles) { + const perms = rolePerms.get(role.sn); + if (!perms) continue; + + const permsList: Array<{ resourceCode: string; operationCode: string; granted: boolean }> = + []; + for (const [key, granted] of perms) { + const sepIdx = key.indexOf('::'); + permsList.push({ + resourceCode: key.substring(0, sepIdx), + operationCode: key.substring(sepIdx + 2), + granted, + }); + } + await updatePermissionsApi(role.sn, permsList); + } + setDirty(false); + } catch (err) { + console.error('권한 저장 실패:', err); + setSaveError('권한 저장에 실패했습니다. 다시 시도해주세요.'); + } finally { + setSaving(false); + } + }; + + const handleCreateRole = async () => { + setCreating(true); + setCreateError(''); + try { + await createRoleApi({ + code: newRoleCode, + name: newRoleName, + description: newRoleDesc || undefined, + }); + await loadData(); + setShowCreateForm(false); + setNewRoleCode(''); + setNewRoleName(''); + setNewRoleDesc(''); + } catch (err) { + const message = err instanceof Error ? err.message : '역할 생성에 실패했습니다.'; + setCreateError(message); + } finally { + setCreating(false); + } + }; + + const handleDeleteRole = async (roleSn: number, roleName: string) => { + if ( + !window.confirm( + `"${roleName}" 역할을 삭제하시겠습니까?\n이 역할을 가진 모든 사용자에서 해당 역할이 제거됩니다.`, + ) + ) { + return; + } + try { + await deleteRoleApi(roleSn); + if (selectedRoleSn === roleSn) setSelectedRoleSn(null); + await loadData(); + } catch (err) { + console.error('역할 삭제 실패:', err); + } + }; + + const handleStartEditName = (role: RoleWithPermissions) => { + setEditingRoleSn(role.sn); + setEditRoleName(role.name); + }; + + const handleSaveRoleName = async (roleSn: number) => { + if (!editRoleName.trim()) return; + try { + await updateRoleApi(roleSn, { name: editRoleName.trim() }); + setRoles((prev) => + prev.map((r) => (r.sn === roleSn ? { ...r, name: editRoleName.trim() } : r)), + ); + setEditingRoleSn(null); + } catch (err) { + console.error('역할 이름 수정 실패:', err); + } + }; + + const toggleDefault = async (roleSn: number) => { + const role = roles.find((r) => r.sn === roleSn); + if (!role) return; + const newValue = !role.isDefault; + try { + await updateRoleDefaultApi(roleSn, newValue); + setRoles((prev) => prev.map((r) => (r.sn === roleSn ? { ...r, isDefault: newValue } : r))); + } catch (err) { + console.error('기본 역할 변경 실패:', err); + } + }; + + if (loading) { + return ( +
+ 불러오는 중... +
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+

권한 관리

+

+ 역할별 리소스 × CRUD 권한 설정 +

+
+ {/* 탭 전환 */} +
+ + +
+
+ + {activeTab === 'role' ? ( + + ) : ( + + )} +
+ ); +} + +export default PermissionsPanel; diff --git a/frontend/src/tabs/admin/components/RndHnsAtmosPanel.tsx b/frontend/src/components/admin/components/RndHnsAtmosPanel.tsx similarity index 83% rename from frontend/src/tabs/admin/components/RndHnsAtmosPanel.tsx rename to frontend/src/components/admin/components/RndHnsAtmosPanel.tsx index 4f006e1..1e7d4e7 100644 --- a/frontend/src/tabs/admin/components/RndHnsAtmosPanel.tsx +++ b/frontend/src/components/admin/components/RndHnsAtmosPanel.tsx @@ -257,34 +257,34 @@ function fetchHnsAtmosData(): Promise { // ─── 유틸 ─────────────────────────────────────────────────────────────────────── function getPipelineStatusStyle(status: PipelineStatus): string { - if (status === '정상') return 'text-emerald-400 bg-emerald-500/10'; - if (status === '지연') return 'text-yellow-400 bg-yellow-500/10'; - return 'text-red-400 bg-red-500/10'; + if (status === '정상') return 'text-color-success bg-[rgba(34,197,94,0.08)]'; + if (status === '지연') return 'text-color-caution bg-[rgba(234,179,8,0.08)]'; + return 'text-color-danger bg-[rgba(239,68,68,0.08)]'; } function getPipelineBorderStyle(status: PipelineStatus): string { - if (status === '정상') return 'border-l-emerald-500'; - if (status === '지연') return 'border-l-yellow-500'; - return 'border-l-red-500'; + if (status === '정상') return 'border-l-color-success'; + if (status === '지연') return 'border-l-color-caution'; + return 'border-l-color-danger'; } function getReceiveStatusStyle(status: ReceiveStatus): string { - if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10'; - if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10'; - return 'text-red-400 bg-red-500/10'; + if (status === '수신완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]'; + if (status === '수신대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]'; + return 'text-color-danger bg-[rgba(239,68,68,0.08)]'; } function getProcessStatusStyle(status: ProcessStatus): string { - if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10'; - if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10'; - if (status === '대기') return 'text-yellow-400 bg-yellow-500/10'; - return 'text-red-400 bg-red-500/10'; + if (status === '처리완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]'; + if (status === '처리중') return 'text-color-accent bg-[rgba(6,182,212,0.08)]'; + if (status === '대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]'; + return 'text-color-danger bg-[rgba(239,68,68,0.08)]'; } function getAlertStyle(level: AlertLevel): string { - if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30'; - if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30'; - return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30'; + if (level === '경고') return 'text-color-danger bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)]'; + if (level === '주의') return 'text-color-caution bg-[rgba(234,179,8,0.08)] border-[rgba(234,179,8,0.3)]'; + return 'text-color-accent bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)]'; } // ─── 파이프라인 카드 ───────────────────────────────────────────────────────────── @@ -295,9 +295,9 @@ function PipelineCard({ node }: { node: PipelineNode }) { return (
-
{node.name}
+
{node.name}
@@ -316,7 +316,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool {Array.from({ length: 5 }).map((_, i) => (
- {i < 4 && } + {i < 4 && }
))}
@@ -328,9 +328,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool {nodes.map((node, idx) => (
- {idx < nodes.length - 1 && ( - - )} + {idx < nodes.length - 1 && }
))}
@@ -369,13 +367,13 @@ const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', ' function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) { return (
- +
{LOG_HEADERS.map((h) => ( @@ -385,7 +383,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean {loading && rows.length === 0 ? Array.from({ length: 8 }).map((_, i) => ( - + {LOG_HEADERS.map((_, j) => ( )) : rows.map((row) => ( - - + + @@ -444,7 +440,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean } if (alerts.length === 0) { - return

활성 알림이 없습니다.

; + return

활성 알림이 없습니다.

; } return ( @@ -452,7 +448,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean {alerts.map((alert) => (
[{alert.level}] {alert.message} @@ -511,12 +507,12 @@ export default function RndHnsAtmosPanel() { return (
{/* ── 헤더 ── */} -
+
-

HNS 대기확산 (충북대) 연계 모니터링

+

HNS 대기확산 (충북대) 연계 모니터링

{lastUpdate && ( - + 갱신:{' '} {lastUpdate.toLocaleTimeString('ko-KR', { hour: '2-digit', @@ -528,7 +524,7 @@ export default function RndHnsAtmosPanel() {
{/* 요약 통계 바 */} -
+
- 정상 수신:{' '} - {totalReceived}건 + 정상 수신: {totalReceived}건 | - 지연: {totalDelayed}건 + 지연: {totalDelayed}건 | - 실패: {totalFailed}건 + 실패: {totalFailed}건 | - 금일 예측 완료:{' '} - 2 / 4회 + 금일 예측 완료: 2 / 4회
@@ -572,17 +566,17 @@ export default function RndHnsAtmosPanel() { {/* ── 스크롤 영역 ── */}
{/* 파이프라인 현황 */} -
-

+
+

데이터 파이프라인 현황

{/* 필터 바 + 수신 이력 테이블 */} -
+
-

+

데이터 수신 이력

@@ -590,7 +584,7 @@ export default function RndHnsAtmosPanel() { setFilterReceive(e.target.value as FilterReceive)} - className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors" + className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors" > @@ -613,13 +607,13 @@ export default function RndHnsAtmosPanel() { - {filteredLogs.length}건 + {filteredLogs.length}건
@@ -627,9 +621,7 @@ export default function RndHnsAtmosPanel() { {/* 알림 현황 */}
-

- 알림 현황 -

+

알림 현황

diff --git a/frontend/src/tabs/admin/components/RndKospsPanel.tsx b/frontend/src/components/admin/components/RndKospsPanel.tsx similarity index 83% rename from frontend/src/tabs/admin/components/RndKospsPanel.tsx rename to frontend/src/components/admin/components/RndKospsPanel.tsx index 7e38abb..1d3dee8 100644 --- a/frontend/src/tabs/admin/components/RndKospsPanel.tsx +++ b/frontend/src/components/admin/components/RndKospsPanel.tsx @@ -257,34 +257,34 @@ function fetchKospsData(): Promise { // ─── 유틸 ─────────────────────────────────────────────────────────────────────── function getPipelineStatusStyle(status: PipelineStatus): string { - if (status === '정상') return 'text-emerald-400 bg-emerald-500/10'; - if (status === '지연') return 'text-yellow-400 bg-yellow-500/10'; - return 'text-red-400 bg-red-500/10'; + if (status === '정상') return 'text-color-success bg-[rgba(34,197,94,0.08)]'; + if (status === '지연') return 'text-color-caution bg-[rgba(234,179,8,0.08)]'; + return 'text-color-danger bg-[rgba(239,68,68,0.08)]'; } function getPipelineBorderStyle(status: PipelineStatus): string { - if (status === '정상') return 'border-l-emerald-500'; - if (status === '지연') return 'border-l-yellow-500'; - return 'border-l-red-500'; + if (status === '정상') return 'border-l-color-success'; + if (status === '지연') return 'border-l-color-caution'; + return 'border-l-color-danger'; } function getReceiveStatusStyle(status: ReceiveStatus): string { - if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10'; - if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10'; - return 'text-red-400 bg-red-500/10'; + if (status === '수신완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]'; + if (status === '수신대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]'; + return 'text-color-danger bg-[rgba(239,68,68,0.08)]'; } function getProcessStatusStyle(status: ProcessStatus): string { - if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10'; - if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10'; - if (status === '대기') return 'text-yellow-400 bg-yellow-500/10'; - return 'text-red-400 bg-red-500/10'; + if (status === '처리완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]'; + if (status === '처리중') return 'text-color-accent bg-[rgba(6,182,212,0.08)]'; + if (status === '대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]'; + return 'text-color-danger bg-[rgba(239,68,68,0.08)]'; } function getAlertStyle(level: AlertLevel): string { - if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30'; - if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30'; - return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30'; + if (level === '경고') return 'text-color-danger bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)]'; + if (level === '주의') return 'text-color-caution bg-[rgba(234,179,8,0.08)] border-[rgba(234,179,8,0.3)]'; + return 'text-color-accent bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)]'; } // ─── 파이프라인 카드 ───────────────────────────────────────────────────────────── @@ -295,9 +295,9 @@ function PipelineCard({ node }: { node: PipelineNode }) { return (
-
{node.name}
+
{node.name}
@@ -316,7 +316,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool {Array.from({ length: 5 }).map((_, i) => (
- {i < 4 && } + {i < 4 && }
))}
@@ -328,9 +328,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool {nodes.map((node, idx) => (
- {idx < nodes.length - 1 && ( - - )} + {idx < nodes.length - 1 && }
))}
@@ -369,13 +367,13 @@ const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', ' function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) { return (
-
{h}
@@ -394,10 +392,8 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
- {row.timestamp} -
{row.timestamp} {row.source} {row.dataType} {row.size}
+
{LOG_HEADERS.map((h) => ( @@ -385,7 +383,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean {loading && rows.length === 0 ? Array.from({ length: 8 }).map((_, i) => ( - + {LOG_HEADERS.map((_, j) => ( )) : rows.map((row) => ( - - + + @@ -444,7 +440,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean } if (alerts.length === 0) { - return

활성 알림이 없습니다.

; + return

활성 알림이 없습니다.

; } return ( @@ -452,7 +448,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean {alerts.map((alert) => (
[{alert.level}] {alert.message} @@ -511,12 +507,12 @@ export default function RndKospsPanel() { return (
{/* ── 헤더 ── */} -
+
-

유출유확산예측 (KOSPS) 연계 모니터링

+

유출유확산예측 (KOSPS) 연계 모니터링

{lastUpdate && ( - + 갱신:{' '} {lastUpdate.toLocaleTimeString('ko-KR', { hour: '2-digit', @@ -528,7 +524,7 @@ export default function RndKospsPanel() {
{/* 요약 통계 바 */} -
+
- 정상 수신:{' '} - {totalReceived}건 + 정상 수신: {totalReceived}건 | - 지연: {totalDelayed}건 + 지연: {totalDelayed}건 | - 실패: {totalFailed}건 + 실패: {totalFailed}건 | - 금일 예측 완료:{' '} - 3 / 6회 + 금일 예측 완료: 3 / 6회
@@ -572,17 +566,17 @@ export default function RndKospsPanel() { {/* ── 스크롤 영역 ── */}
{/* 파이프라인 현황 */} -
-

+
+

데이터 파이프라인 현황

{/* 필터 바 + 수신 이력 테이블 */} -
+
-

+

데이터 수신 이력

@@ -590,7 +584,7 @@ export default function RndKospsPanel() { setFilterReceive(e.target.value as FilterReceive)} - className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors" + className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors" > @@ -613,13 +607,13 @@ export default function RndKospsPanel() { - {filteredLogs.length}건 + {filteredLogs.length}건
@@ -627,9 +621,7 @@ export default function RndKospsPanel() { {/* 알림 현황 */}
-

- 알림 현황 -

+

알림 현황

diff --git a/frontend/src/tabs/admin/components/RndPoseidonPanel.tsx b/frontend/src/components/admin/components/RndPoseidonPanel.tsx similarity index 84% rename from frontend/src/tabs/admin/components/RndPoseidonPanel.tsx rename to frontend/src/components/admin/components/RndPoseidonPanel.tsx index 8d1bece..0a3903e 100644 --- a/frontend/src/tabs/admin/components/RndPoseidonPanel.tsx +++ b/frontend/src/components/admin/components/RndPoseidonPanel.tsx @@ -284,34 +284,34 @@ function fetchPoseidonData(): Promise { // ─── 유틸 ─────────────────────────────────────────────────────────────────────── function getPipelineStatusStyle(status: PipelineStatus): string { - if (status === '정상') return 'text-emerald-400 bg-emerald-500/10'; - if (status === '지연') return 'text-yellow-400 bg-yellow-500/10'; - return 'text-red-400 bg-red-500/10'; + if (status === '정상') return 'text-color-success bg-[rgba(34,197,94,0.08)]'; + if (status === '지연') return 'text-color-caution bg-[rgba(234,179,8,0.08)]'; + return 'text-color-danger bg-[rgba(239,68,68,0.08)]'; } function getPipelineBorderStyle(status: PipelineStatus): string { - if (status === '정상') return 'border-l-emerald-500'; - if (status === '지연') return 'border-l-yellow-500'; - return 'border-l-red-500'; + if (status === '정상') return 'border-l-color-success'; + if (status === '지연') return 'border-l-color-caution'; + return 'border-l-color-danger'; } function getReceiveStatusStyle(status: ReceiveStatus): string { - if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10'; - if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10'; - return 'text-red-400 bg-red-500/10'; + if (status === '수신완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]'; + if (status === '수신대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]'; + return 'text-color-danger bg-[rgba(239,68,68,0.08)]'; } function getProcessStatusStyle(status: ProcessStatus): string { - if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10'; - if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10'; - if (status === '대기') return 'text-yellow-400 bg-yellow-500/10'; - return 'text-red-400 bg-red-500/10'; + if (status === '처리완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]'; + if (status === '처리중') return 'text-color-accent bg-[rgba(6,182,212,0.08)]'; + if (status === '대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]'; + return 'text-color-danger bg-[rgba(239,68,68,0.08)]'; } function getAlertStyle(level: AlertLevel): string { - if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30'; - if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30'; - return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30'; + if (level === '경고') return 'text-color-danger bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)]'; + if (level === '주의') return 'text-color-caution bg-[rgba(234,179,8,0.08)] border-[rgba(234,179,8,0.3)]'; + return 'text-color-accent bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)]'; } // ─── 파이프라인 카드 ───────────────────────────────────────────────────────────── @@ -322,9 +322,9 @@ function PipelineCard({ node }: { node: PipelineNode }) { return (
-
{node.name}
+
{node.name}
@@ -343,7 +343,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool {Array.from({ length: 5 }).map((_, i) => (
- {i < 4 && } + {i < 4 && }
))}
@@ -355,9 +355,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool {nodes.map((node, idx) => (
- {idx < nodes.length - 1 && ( - - )} + {idx < nodes.length - 1 && }
))}
@@ -396,13 +394,13 @@ const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', ' function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) { return (
-
{h}
@@ -394,10 +392,8 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
- {row.timestamp} -
{row.timestamp} {row.source} {row.dataType} {row.size}
+
{LOG_HEADERS.map((h) => ( @@ -412,7 +410,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean {loading && rows.length === 0 ? Array.from({ length: 8 }).map((_, i) => ( - + {LOG_HEADERS.map((_, j) => ( )) : rows.map((row) => ( - - + + @@ -471,7 +467,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean } if (alerts.length === 0) { - return

활성 알림이 없습니다.

; + return

활성 알림이 없습니다.

; } return ( @@ -479,7 +475,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean {alerts.map((alert) => (
[{alert.level}] {alert.message} @@ -538,12 +534,12 @@ export default function RndPoseidonPanel() { return (
{/* ── 헤더 ── */} -
+
-

유출유확산예측 (포세이돈) 연계 모니터링

+

유출유확산예측 (포세이돈) 연계 모니터링

{lastUpdate && ( - + 갱신:{' '} {lastUpdate.toLocaleTimeString('ko-KR', { hour: '2-digit', @@ -555,7 +551,7 @@ export default function RndPoseidonPanel() {
{/* 요약 통계 바 */} -
+
- 정상 수신:{' '} - {totalReceived}건 + 정상 수신: {totalReceived}건 | - 지연: {totalDelayed}건 + 지연: {totalDelayed}건 | - 실패: {totalFailed}건 + 실패: {totalFailed}건 | - 금일 예측 완료:{' '} - 4 / 8회 + 금일 예측 완료: 4 / 8회
@@ -599,17 +593,17 @@ export default function RndPoseidonPanel() { {/* ── 스크롤 영역 ── */}
{/* 파이프라인 현황 */} -
-

+
+

데이터 파이프라인 현황

{/* 필터 바 + 수신 이력 테이블 */} -
+
-

+

데이터 수신 이력

@@ -617,7 +611,7 @@ export default function RndPoseidonPanel() { setFilterReceive(e.target.value as FilterReceive)} - className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors" + className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors" > @@ -640,13 +634,13 @@ export default function RndPoseidonPanel() { - {filteredLogs.length}건 + {filteredLogs.length}건
@@ -654,9 +648,7 @@ export default function RndPoseidonPanel() { {/* 알림 현황 */}
-

- 알림 현황 -

+

알림 현황

diff --git a/frontend/src/tabs/admin/components/RndRescuePanel.tsx b/frontend/src/components/admin/components/RndRescuePanel.tsx similarity index 83% rename from frontend/src/tabs/admin/components/RndRescuePanel.tsx rename to frontend/src/components/admin/components/RndRescuePanel.tsx index 231b9dc..bd720e4 100644 --- a/frontend/src/tabs/admin/components/RndRescuePanel.tsx +++ b/frontend/src/components/admin/components/RndRescuePanel.tsx @@ -257,34 +257,34 @@ function fetchRescueData(): Promise { // ─── 유틸 ─────────────────────────────────────────────────────────────────────── function getPipelineStatusStyle(status: PipelineStatus): string { - if (status === '정상') return 'text-emerald-400 bg-emerald-500/10'; - if (status === '지연') return 'text-yellow-400 bg-yellow-500/10'; - return 'text-red-400 bg-red-500/10'; + if (status === '정상') return 'text-color-success bg-[rgba(34,197,94,0.08)]'; + if (status === '지연') return 'text-color-caution bg-[rgba(234,179,8,0.08)]'; + return 'text-color-danger bg-[rgba(239,68,68,0.08)]'; } function getPipelineBorderStyle(status: PipelineStatus): string { - if (status === '정상') return 'border-l-emerald-500'; - if (status === '지연') return 'border-l-yellow-500'; - return 'border-l-red-500'; + if (status === '정상') return 'border-l-color-success'; + if (status === '지연') return 'border-l-color-caution'; + return 'border-l-color-danger'; } function getReceiveStatusStyle(status: ReceiveStatus): string { - if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10'; - if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10'; - return 'text-red-400 bg-red-500/10'; + if (status === '수신완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]'; + if (status === '수신대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]'; + return 'text-color-danger bg-[rgba(239,68,68,0.08)]'; } function getProcessStatusStyle(status: ProcessStatus): string { - if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10'; - if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10'; - if (status === '대기') return 'text-yellow-400 bg-yellow-500/10'; - return 'text-red-400 bg-red-500/10'; + if (status === '처리완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]'; + if (status === '처리중') return 'text-color-accent bg-[rgba(6,182,212,0.08)]'; + if (status === '대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]'; + return 'text-color-danger bg-[rgba(239,68,68,0.08)]'; } function getAlertStyle(level: AlertLevel): string { - if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30'; - if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30'; - return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30'; + if (level === '경고') return 'text-color-danger bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)]'; + if (level === '주의') return 'text-color-caution bg-[rgba(234,179,8,0.08)] border-[rgba(234,179,8,0.3)]'; + return 'text-color-accent bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)]'; } // ─── 파이프라인 카드 ───────────────────────────────────────────────────────────── @@ -295,9 +295,9 @@ function PipelineCard({ node }: { node: PipelineNode }) { return (
-
{node.name}
+
{node.name}
@@ -316,7 +316,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool {Array.from({ length: 5 }).map((_, i) => (
- {i < 4 && } + {i < 4 && }
))}
@@ -328,9 +328,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool {nodes.map((node, idx) => (
- {idx < nodes.length - 1 && ( - - )} + {idx < nodes.length - 1 && }
))}
@@ -369,13 +367,13 @@ const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', ' function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) { return (
-
{h}
@@ -421,10 +419,8 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
- {row.timestamp} -
{row.timestamp} {row.source} {row.dataType} {row.size}
+
{LOG_HEADERS.map((h) => ( @@ -385,7 +383,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean {loading && rows.length === 0 ? Array.from({ length: 8 }).map((_, i) => ( - + {LOG_HEADERS.map((_, j) => ( )) : rows.map((row) => ( - - + + @@ -444,7 +440,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean } if (alerts.length === 0) { - return

활성 알림이 없습니다.

; + return

활성 알림이 없습니다.

; } return ( @@ -452,7 +448,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean {alerts.map((alert) => (
[{alert.level}] {alert.message} @@ -511,12 +507,12 @@ export default function RndRescuePanel() { return (
{/* ── 헤더 ── */} -
+
-

긴급구난과제 연계 모니터링

+

긴급구난과제 연계 모니터링

{lastUpdate && ( - + 갱신:{' '} {lastUpdate.toLocaleTimeString('ko-KR', { hour: '2-digit', @@ -528,7 +524,7 @@ export default function RndRescuePanel() {
{/* 요약 통계 바 */} -
+
- 정상 수신:{' '} - {totalReceived}건 + 정상 수신: {totalReceived}건 | - 지연: {totalDelayed}건 + 지연: {totalDelayed}건 | - 실패: {totalFailed}건 + 실패: {totalFailed}건 | - 금일 분석 완료:{' '} - 5 / 6회 + 금일 분석 완료: 5 / 6회
@@ -572,17 +566,17 @@ export default function RndRescuePanel() { {/* ── 스크롤 영역 ── */}
{/* 파이프라인 현황 */} -
-

+
+

데이터 파이프라인 현황

{/* 필터 바 + 수신 이력 테이블 */} -
+
-

+

데이터 수신 이력

@@ -590,7 +584,7 @@ export default function RndRescuePanel() { setFilterReceive(e.target.value as FilterReceive)} - className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors" + className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors" > @@ -613,13 +607,13 @@ export default function RndRescuePanel() { - {filteredLogs.length}건 + {filteredLogs.length}건
@@ -627,9 +621,7 @@ export default function RndRescuePanel() { {/* 알림 현황 */}
-

- 알림 현황 -

+

알림 현황

diff --git a/frontend/src/tabs/admin/components/SensitiveLayerPanel.tsx b/frontend/src/components/admin/components/SensitiveLayerPanel.tsx similarity index 98% rename from frontend/src/tabs/admin/components/SensitiveLayerPanel.tsx rename to frontend/src/components/admin/components/SensitiveLayerPanel.tsx index de93bfc..53fd6fc 100644 --- a/frontend/src/tabs/admin/components/SensitiveLayerPanel.tsx +++ b/frontend/src/components/admin/components/SensitiveLayerPanel.tsx @@ -135,7 +135,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
-

{title}

+

{title}

총 {total}개

@@ -168,7 +168,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps) {/* 오류 메시지 */} {error && ( -
+
{error}
)} diff --git a/frontend/src/tabs/admin/components/SettingsPanel.tsx b/frontend/src/components/admin/components/SettingsPanel.tsx similarity index 91% rename from frontend/src/tabs/admin/components/SettingsPanel.tsx rename to frontend/src/components/admin/components/SettingsPanel.tsx index 36e8923..2bc9520 100644 --- a/frontend/src/tabs/admin/components/SettingsPanel.tsx +++ b/frontend/src/components/admin/components/SettingsPanel.tsx @@ -63,7 +63,7 @@ function SettingsPanel() { return (
-

시스템 설정

+

시스템 설정

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

@@ -87,9 +87,9 @@ function SettingsPanel() {
자동 승인

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

@@ -152,8 +152,8 @@ function SettingsPanel() {

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

@@ -226,25 +226,25 @@ function SettingsPanel() {
신규 사용자 등록 시{' '} {settings?.autoApprove ? ( - 즉시 활성화 + 즉시 활성화 ) : ( - 관리자 승인 필요 + 관리자 승인 필요 )}
기본 역할 자동 할당{' '} {settings?.defaultRole ? ( - 활성 + 활성 ) : ( 비활성 )} @@ -252,12 +252,12 @@ function SettingsPanel() {
Google OAuth 자동 승인 도메인{' '} {oauthSettings?.autoApproveDomains ? ( - + {oauthSettings.autoApproveDomains} ) : ( diff --git a/frontend/src/tabs/admin/components/SortableMenuItem.tsx b/frontend/src/components/admin/components/SortableMenuItem.tsx similarity index 100% rename from frontend/src/tabs/admin/components/SortableMenuItem.tsx rename to frontend/src/components/admin/components/SortableMenuItem.tsx diff --git a/frontend/src/components/admin/components/SystemArchPanel.tsx b/frontend/src/components/admin/components/SystemArchPanel.tsx new file mode 100644 index 0000000..cc8e136 --- /dev/null +++ b/frontend/src/components/admin/components/SystemArchPanel.tsx @@ -0,0 +1,57 @@ +import { useState } from 'react'; +import { FrameworkTab } from './contents/FrameworkTab'; +import { TargetArchTab } from './contents/TargetArchTab'; +import { InterfaceTab } from './contents/InterfaceTab'; +import { HeterogeneousTab } from './contents/HeterogeneousTab'; +import { CommonFeaturesTab } from './contents/CommonFeaturesTab'; + +type TabId = 'framework' | 'target' | 'interface' | 'heterogeneous' | 'common-features'; + +const TABS: { id: TabId; label: string }[] = [ + { id: 'framework', label: '표준 프레임워크' }, + { id: 'target', label: '목표시스템 아키텍쳐' }, + { id: 'interface', label: '시스템 인터페이스 연계' }, + { id: 'heterogeneous', label: '이기종시스템연계' }, + { id: 'common-features', label: '공통기능' }, +]; + +// ─── 기술 스택 테이블 데이터 ────────────────────────────────────────────────────── + +export default function SystemArchPanel() { + const [activeTab, setActiveTab] = useState('framework'); + + return ( +
+ {/* 헤더 */} +
+

시스템구조

+
+ + {/* 탭 버튼 */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* 탭 콘텐츠 */} +
+ {activeTab === 'framework' && } + {activeTab === 'target' && } + {activeTab === 'interface' && } + {activeTab === 'heterogeneous' && } + {activeTab === 'common-features' && } +
+
+ ); +} diff --git a/frontend/src/components/admin/components/UsersPanel.tsx b/frontend/src/components/admin/components/UsersPanel.tsx new file mode 100644 index 0000000..a493151 --- /dev/null +++ b/frontend/src/components/admin/components/UsersPanel.tsx @@ -0,0 +1,563 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { + fetchUsers, + fetchRoles, + fetchOrgs, + updateUserApi, + approveUserApi, + rejectUserApi, + assignRolesApi, + type UserListItem, + type RoleWithPermissions, + type OrgItem, +} from '@common/services/authApi'; +import { getRoleColor, statusLabels } from './adminConstants'; +import { RegisterModal } from './contents/RegisterModal'; +import { UserDetailModal } from './contents/UserDetailModal'; +/* eslint-disable react-refresh/only-export-components */ + +const PAGE_SIZE = 15; + +// ─── 포맷 헬퍼 ───────────────────────────────────────────────── +export function 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', + }); +} + + +function UsersPanel() { + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [orgFilter, setOrgFilter] = useState(''); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [allRoles, setAllRoles] = useState([]); + const [allOrgs, setAllOrgs] = useState([]); + const [roleEditUserId, setRoleEditUserId] = useState(null); + const [selectedRoleSns, setSelectedRoleSns] = useState([]); + const [showRegisterModal, setShowRegisterModal] = useState(false); + const [detailUser, setDetailUser] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const roleDropdownRef = useRef(null); + + const loadUsers = useCallback(async () => { + setLoading(true); + try { + const data = await fetchUsers(searchTerm || undefined, statusFilter || undefined); + setUsers(data); + setCurrentPage(1); + } catch (err) { + console.error('사용자 목록 조회 실패:', err); + } finally { + setLoading(false); + } + }, [searchTerm, statusFilter]); + + useEffect(() => { + loadUsers(); + }, [loadUsers]); + + useEffect(() => { + fetchRoles().then(setAllRoles).catch(console.error); + fetchOrgs().then(setAllOrgs).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]); + + // ─── 필터링 (org 클라이언트 사이드) ─────────────────────────── + const filteredUsers = orgFilter ? users.filter((u) => String(u.orgSn) === orgFilter) : users; + + // ─── 페이지네이션 ────────────────────────────────────────────── + const totalCount = filteredUsers.length; + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + const pagedUsers = filteredUsers.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); + + // ─── 액션 핸들러 ────────────────────────────────────────────── + 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 pendingCount = users.filter((u) => u.status === 'PENDING').length; + + return ( + <> +
+ {/* 헤더 */} +
+
+
+

사용자 관리

+

+ 총 {filteredUsers.length}명 +

+
+ {pendingCount > 0 && ( + + 승인대기 {pendingCount}명 + + )} +
+
+ {/* 소속 필터 */} + + {/* 상태 필터 */} + + {/* 텍스트 검색 */} + setSearchTerm(e.target.value)} + className="w-56 px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean" + /> + +
+
+ + {/* 테이블 */} +
+ {loading ? ( +
+ 불러오는 중... +
+ ) : ( +
{h}
@@ -394,10 +392,8 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
- {row.timestamp} -
{row.timestamp} {row.source} {row.dataType} {row.size}
+ + + + + + + + + + + + + + + {pagedUsers.length === 0 ? ( + + + + ) : ( + pagedUsers.map((user, idx) => { + const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE; + const rowNum = (currentPage - 1) * PAGE_SIZE + idx + 1; + return ( + + {/* 번호 */} + + + {/* ID(account) */} + + + {/* 사용자명 */} + + + {/* 직급 */} + + + {/* 소속 */} + + + {/* 이메일 */} + + + {/* 역할 (인라인 편집) */} + + + {/* 승인상태 */} + + + {/* 관리 */} + + + ); + }) + )} + +
+ 번호 + + ID + + 사용자명 + + 직급 + + 소속 + + 이메일 + + 역할 + + 승인상태 + + 관리 +
+ 조회된 사용자가 없습니다. +
+ {rowNum} + + {user.account} + + + + {user.rank || '-'} + + {user.orgAbbr || user.orgName || '-'} + + {user.email || '-'} + +
+
handleOpenRoleEdit(user)} + title="클릭하여 역할 변경" + > + {user.roles.length > 0 ? ( + user.roles.map((roleCode) => { + const roleName = + allRoles.find((r) => r.code === roleCode)?.name || roleCode; + return ( + + {roleName} + + ); + }) + ) : ( + + 역할 없음 + + )} + + + + + + +
+ {roleEditUserId === user.id && ( +
+
+ 역할 선택 +
+ {allRoles.map((role, roleIdx) => { + const color = getRoleColor(role.code, roleIdx); + return ( + + ); + })} +
+ + +
+
+ )} +
+
+ + + {statusInfo.label} + + +
+ {user.status === 'PENDING' && ( + <> + + + + )} + {user.status === 'LOCKED' && ( + + )} + {user.status === 'ACTIVE' && ( + + )} + {(user.status === 'INACTIVE' || user.status === 'REJECTED') && ( + + )} +
+
+ )} +
+ + {/* 페이지네이션 */} + {!loading && totalPages > 1 && ( +
+ + {(currentPage - 1) * PAGE_SIZE + 1}–{Math.min(currentPage * PAGE_SIZE, totalCount)} /{' '} + {totalCount}명 + +
+ + {Array.from({ length: totalPages }, (_, i) => i + 1) + .filter((p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2) + .reduce<(number | '...')[]>((acc, p, i, arr) => { + if ( + i > 0 && + typeof arr[i - 1] === 'number' && + (p as number) - (arr[i - 1] as number) > 1 + ) { + acc.push('...'); + } + acc.push(p); + return acc; + }, []) + .map((item, i) => + item === '...' ? ( + + … + + ) : ( + + ), + )} + +
+
+ )} +
+ + {/* 사용자 등록 모달 */} + {showRegisterModal && ( + setShowRegisterModal(false)} + onSuccess={loadUsers} + /> + )} + + {/* 사용자 상세/수정 모달 */} + {detailUser && ( + setDetailUser(null)} + onUpdated={() => { + loadUsers(); + // 최신 정보로 모달 갱신을 위해 닫지 않음 + }} + /> + )} + + ); +} + +export default UsersPanel; diff --git a/frontend/src/tabs/admin/components/VesselMaterialsPanel.tsx b/frontend/src/components/admin/components/VesselMaterialsPanel.tsx similarity index 94% rename from frontend/src/tabs/admin/components/VesselMaterialsPanel.tsx rename to frontend/src/components/admin/components/VesselMaterialsPanel.tsx index 1efd57b..8b85731 100644 --- a/frontend/src/tabs/admin/components/VesselMaterialsPanel.tsx +++ b/frontend/src/components/admin/components/VesselMaterialsPanel.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo } from 'react'; -import { fetchOrganizations } from '@tabs/assets/services/assetsApi'; -import type { AssetOrgCompat } from '@tabs/assets/services/assetsApi'; -import { typeTagCls } from '@tabs/assets/components/assetTypes'; +import { fetchOrganizations } from '@components/assets/services/assetsApi'; +import type { AssetOrgCompat } from '@interfaces/assets/AssetsInterface'; +import { typeTagCls } from '@components/assets/components/assetTypes'; const PAGE_SIZE = 20; @@ -89,7 +89,7 @@ function VesselMaterialsPanel() { {/* 헤더 */}
-

방제선 보유자재 현황

+

방제선 보유자재 현황

총 {filtered.length}개 기관 (방제선 보유)

@@ -327,16 +327,11 @@ function VesselMaterialsPanel() { diff --git a/frontend/src/tabs/admin/components/VesselSignalPanel.tsx b/frontend/src/components/admin/components/VesselSignalPanel.tsx similarity index 94% rename from frontend/src/tabs/admin/components/VesselSignalPanel.tsx rename to frontend/src/components/admin/components/VesselSignalPanel.tsx index a0d2e15..36ab62e 100644 --- a/frontend/src/tabs/admin/components/VesselSignalPanel.tsx +++ b/frontend/src/components/admin/components/VesselSignalPanel.tsx @@ -10,18 +10,10 @@ interface SignalSlot { } // ─── 상수 ────────────────────────────────────────────────── -const SOURCE_COLORS: Record = { - VTS: '#3b82f6', - 'VTS-AIS': '#a855f7', - 'V-PASS': '#22c55e', - 'E-NAVI': '#f97316', - 'S&P AIS': '#ec4899', -}; - const STATUS_COLOR: Record = { - ok: '#22c55e', - warn: '#eab308', - error: '#ef4444', + ok: 'var(--color-success)', + warn: 'var(--color-caution)', + error: 'var(--color-danger)', none: 'rgba(255,255,255,0.06)', }; @@ -141,18 +133,18 @@ export default function VesselSignalPanel() { return (
{/* 헤더 */} -
+

선박신호 수신 현황

setDate(e.target.value)} - className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-fg" + className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-fg" /> @@ -172,7 +164,6 @@ export default function VesselSignalPanel() { {/* 시간축 높이 맞춤 빈칸 */}
{SIGNAL_SOURCES.map((src) => { - const c = SOURCE_COLORS[src]; const st = stats.find((s) => s.src === src)!; return (
- + {src} - {st.rate}% + {st.rate}%
); })} diff --git a/frontend/src/tabs/admin/components/adminConstants.ts b/frontend/src/components/admin/components/adminConstants.ts similarity index 100% rename from frontend/src/tabs/admin/components/adminConstants.ts rename to frontend/src/components/admin/components/adminConstants.ts diff --git a/frontend/src/tabs/admin/components/adminMenuConfig.ts b/frontend/src/components/admin/components/adminMenuConfig.ts similarity index 100% rename from frontend/src/tabs/admin/components/adminMenuConfig.ts rename to frontend/src/components/admin/components/adminMenuConfig.ts diff --git a/frontend/src/components/admin/components/contents/AuditLogModal.tsx b/frontend/src/components/admin/components/contents/AuditLogModal.tsx new file mode 100644 index 0000000..b986b36 --- /dev/null +++ b/frontend/src/components/admin/components/contents/AuditLogModal.tsx @@ -0,0 +1,212 @@ +import { useState } from 'react'; +import type { AuditLogEntry, DeidentifyTask } from '../DeidentifyPanel'; +import { MOCK_AUDIT_LOGS } from '../DeidentifyPanel'; + +function getAuditResultClass(type: AuditLogEntry['resultType']): string { + switch (type) { + case '성공': + return 'text-emerald-400 bg-emerald-500/10'; + case '진행중': + return 'text-cyan-400 bg-cyan-500/10'; + case '실패': + return 'text-red-400 bg-red-500/10'; + case '거부': + return 'text-yellow-400 bg-yellow-500/10'; + } +} + +interface AuditLogModalProps { + task: DeidentifyTask; + onClose: () => void; +} + +export function AuditLogModal({ task, onClose }: AuditLogModalProps) { + const logs = MOCK_AUDIT_LOGS[task.id] ?? []; + const [selectedLog, setSelectedLog] = useState(null); + const [filterOperator, setFilterOperator] = useState('모두'); + const [startDate, setStartDate] = useState('2026-04-01'); + const [endDate, setEndDate] = useState('2026-04-11'); + + const operators = ['모두', ...Array.from(new Set(logs.map((l) => l.operator)))]; + const filteredLogs = logs.filter((l) => { + if (filterOperator !== '모두' && l.operator !== filterOperator) return false; + return true; + }); + + return ( +
+
+ {/* 헤더 */} +
+

감시 감독 (감사로그) — {task.name}

+ +
+ + {/* 필터 바 */} +
+ 기간: + setStartDate(e.target.value)} + className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent" + /> + ~ + setEndDate(e.target.value)} + className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent" + /> + 작업자: + +
+ + {/* 로그 테이블 */} +
+ + + + {['시간', '작업자', '작업', '대상 데이터', '결과', '상세'].map((h) => ( + + ))} + + + + {filteredLogs.length === 0 ? ( + + + + ) : ( + filteredLogs.map((log) => ( + setSelectedLog(log)} + > + + + + + + + + )) + )} + +
+ {h} +
+ 감사로그가 없습니다. +
+ {log.time.split(' ')[1]} + {log.operator}{log.action} + {log.targetData} + + + {log.result} + + + +
+
+ + {/* 로그 상세 정보 */} + {selectedLog && ( +
+

로그 상세 정보

+
+
+ 로그ID:{' '} + {selectedLog.id} +
+
+ 타임스탬프:{' '} + {selectedLog.time} +
+
+ 작업자:{' '} + + {selectedLog.operator} ({selectedLog.operatorId}) + +
+
+ 작업 유형:{' '} + {selectedLog.action} +
+
+ 대상:{' '} + + {selectedLog.targetData} ({selectedLog.detail.dataCount.toLocaleString()}건) + +
+
+ 적용 규칙:{' '} + {selectedLog.detail.rulesApplied} +
+
+ 결과:{' '} + + {selectedLog.result} (처리: {selectedLog.detail.processedCount.toLocaleString()}, + 오류: {selectedLog.detail.errorCount}) + +
+
+ IP 주소:{' '} + {selectedLog.ip} +
+
+ 브라우저:{' '} + {selectedLog.browser} +
+
+
+ )} + + {/* 하단 버튼 */} +
+ + + +
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/CommonFeaturesTab.tsx b/frontend/src/components/admin/components/contents/CommonFeaturesTab.tsx new file mode 100644 index 0000000..fa3f4c2 --- /dev/null +++ b/frontend/src/components/admin/components/contents/CommonFeaturesTab.tsx @@ -0,0 +1,765 @@ +interface CommonFeatureItem { + title: string; + description: string; + details: string[]; +} + +const COMMON_FEATURES: CommonFeatureItem[] = [ + { + title: '인증 시스템', + description: 'JWT 기반 세션 인증 + Google OAuth 소셜 로그인', + details: [ + 'HttpOnly 쿠키(WING_SESSION) 기반 토큰 관리 — XSS 방어', + 'Access Token(15분) + Refresh Token(7일) 이중 토큰 구조', + 'Google OAuth 2.0 소셜 로그인 지원', + 'Zustand authStore 기반 프론트엔드 인증 상태 통합 관리', + ], + }, + { + title: 'RBAC 2차원 권한', + description: 'AUTH_PERM 기반 기능별·역할별 2차원 권한 엔진', + details: [ + 'OPER_CD (R: 조회, C: 생성, U: 수정, D: 삭제) 4단계 조작 권한', + '역할(Role) × 기능(Feature) 매트릭스 기반 권한 매핑', + 'permResolver 엔진으로 백엔드·프론트엔드 동시 권한 검증', + '메뉴 접근, 버튼 노출, API 호출 3중 권한 통제', + ], + }, + { + title: 'API 통신 패턴', + description: 'Axios 기반 공통 API 클라이언트 + 자동 인증·에러 처리', + details: [ + 'GET/POST만 사용 (PUT/DELETE/PATCH 금지 — 보안취약점 점검 가이드 준수)', + '요청 인터셉터: 쿠키 자동 첨부 (withCredentials)', + '응답 인터셉터: 401 시 자동 토큰 갱신, 실패 시 로그아웃', + 'TanStack Query 기반 서버 상태 캐싱 및 자동 재검증', + ], + }, + { + title: '상태 관리', + description: 'Zustand(클라이언트) + TanStack Query(서버) 이중 상태 관리', + details: [ + 'Zustand: authStore(인증), menuStore(메뉴) 등 클라이언트 전역 상태', + 'TanStack Query: API 응답 캐싱, 자동 재요청, 낙관적 업데이트', + '컴포넌트 로컬 상태: useState 활용', + ], + }, + { + title: '메뉴 시스템', + description: 'DB 기반 동적 메뉴 + 권한 연동 자동 필터링', + details: [ + 'DB에서 메뉴 트리 구조를 동적으로 로드', + '사용자 권한에 따라 메뉴 항목 자동 필터링 (접근 불가 메뉴 미노출)', + '관리자 화면에서 메뉴 순서·표시 여부·아이콘 실시간 편집', + 'menuStore(Zustand)로 현재 활성 메뉴 상태 전역 관리', + ], + }, + { + title: '지도 엔진', + description: 'MapLibre GL JS 5.x + deck.gl 9.x 기반 GIS 시각화', + details: [ + 'MapLibre GL JS: 오픈소스 벡터 타일 기반 지도 렌더링', + 'deck.gl: 대규모 공간 데이터(파티클, 히트맵, 궤적) 고성능 시각화', + 'PostGIS 공간 쿼리 → GeoJSON → deck.gl 레이어 파이프라인', + '레이어 트리 UI로 사용자별 레이어 표시·숨김 제어', + ], + }, + { + title: '스타일링', + description: 'Tailwind CSS @layer 아키텍처 + CSS 변수 디자인 시스템', + details: [ + '@layer base → components → wing 3단계 CSS 계층 구조', + 'CSS 변수 기반 시맨틱 컬러 (bg-bg-base, text-t1, border-stroke 등)', + '다크 모드 기본 적용 — CSS 변수 전환으로 테마 일괄 변경', + '인라인 스타일 지양, Tailwind 유틸리티 클래스 우선', + ], + }, + { + title: '감사 로그', + description: '사용자 행위 자동 기록 — 접속·조회·변경 이력 추적', + details: [ + '로그인/로그아웃, 메뉴 접근, 데이터 변경 자동 기록', + 'App.tsx에서 탭 전환 시 감사 로그 자동 전송', + '관리자 화면에서 사용자별·기간별 감사 로그 조회 가능', + 'IP 주소, User-Agent, 요청 경로 등 부가 정보 기록', + ], + }, + { + title: '보안', + description: '입력 살균·CORS·CSP·Rate Limiting 다층 보안 정책', + details: [ + '입력 살균(sanitize): XSS·SQL Injection 방어 미들웨어 적용', + 'Helmet: CSP, X-Frame-Options, HSTS 등 보안 헤더 자동 설정', + 'CORS: 허용 오리진 화이트리스트 제한', + 'Rate Limiting: API 요청 빈도 제한으로 DoS 방어', + ], + }, +]; + +// ─── 방제대응 프로세스 데이터 ───────────────────────────────────────────────────── + +interface ProcessStep { + phase: string; + description: string; + modules: string[]; +} + +const RESPONSE_PROCESS: ProcessStep[] = [ + { + phase: '사고 접수', + description: '해양오염 사고 신고 접수 및 초동 상황 등록', + modules: ['사건/사고'], + }, + { + phase: '상황 파악', + description: '사고 현장 기상·해상 조건 확인, 유출원·유출량 파악', + modules: ['해양기상', '사건/사고'], + }, + { + phase: '확산 예측', + description: '유출유/HNS 확산 시뮬레이션 및 역추적 분석 수행', + modules: ['확산예측', 'HNS분석'], + }, + { + phase: '방제 계획', + description: '오일붐 배치, 유처리제 살포 구역, 방제선 투입 계획 수립', + modules: ['확산예측', '자산관리'], + }, + { + phase: '구조 작전', + description: '인명 구조 시나리오 수립, 표류 예측 기반 수색 구역 결정', + modules: ['구조시나리오'], + }, + { + phase: '항공 감시', + description: '위성·드론 영상으로 유막 면적 모니터링 및 방제 효과 확인', + modules: ['항공방제'], + }, + { + phase: '해안 조사', + description: 'Pre-SCAT 해안 오염 조사, 피해 범위 기록', + modules: ['SCAT조사'], + }, + { + phase: '상황 종료', + description: '방제 완료 보고, 감사 이력 정리, 사후 분석', + modules: ['사건/사고', '관리자'], + }, +]; + +// ─── 시스템별 기능 유무 매트릭스 데이터 ──────────────────────────────────────────── + +const SYSTEM_MODULES = [ + '확산예측', + 'HNS분석', + '구조시나리오', + '항공방제', + '해양기상', + '사건/사고', + '자산관리', + 'SCAT조사', + '게시판', + '관리자', +] as const; + +interface FeatureMatrixRow { + feature: string; + category: '공통기능' | '기본정보관리' | '업무기능'; + integrated: boolean; + systems: Record; +} + +const FEATURE_MATRIX: FeatureMatrixRow[] = [ + { + feature: '사용자 인증 (JWT)', + category: '공통기능', + integrated: true, + systems: { + 확산예측: true, + HNS분석: true, + 구조시나리오: true, + 항공방제: true, + 해양기상: true, + '사건/사고': true, + 자산관리: true, + SCAT조사: true, + 게시판: true, + 관리자: true, + }, + }, + { + feature: 'RBAC 권한 제어', + category: '공통기능', + integrated: true, + systems: { + 확산예측: true, + HNS분석: true, + 구조시나리오: true, + 항공방제: true, + 해양기상: true, + '사건/사고': true, + 자산관리: true, + SCAT조사: true, + 게시판: true, + 관리자: true, + }, + }, + { + feature: '감사 로그', + category: '공통기능', + integrated: true, + systems: { + 확산예측: true, + HNS분석: true, + 구조시나리오: true, + 항공방제: true, + 해양기상: true, + '사건/사고': true, + 자산관리: true, + SCAT조사: true, + 게시판: true, + 관리자: true, + }, + }, + { + feature: 'API 통신 (Axios)', + category: '공통기능', + integrated: true, + systems: { + 확산예측: true, + HNS분석: true, + 구조시나리오: true, + 항공방제: true, + 해양기상: true, + '사건/사고': true, + 자산관리: true, + SCAT조사: true, + 게시판: true, + 관리자: true, + }, + }, + { + feature: '입력 살균/보안', + category: '공통기능', + integrated: true, + systems: { + 확산예측: true, + HNS분석: true, + 구조시나리오: true, + 항공방제: true, + 해양기상: true, + '사건/사고': true, + 자산관리: true, + SCAT조사: true, + 게시판: true, + 관리자: true, + }, + }, + { + feature: '사용자 관리', + category: '기본정보관리', + integrated: true, + systems: { + 확산예측: false, + HNS분석: false, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: true, + }, + }, + { + feature: '지도 엔진 (MapLibre)', + category: '기본정보관리', + integrated: true, + systems: { + 확산예측: true, + HNS분석: true, + 구조시나리오: true, + 항공방제: true, + 해양기상: true, + '사건/사고': true, + 자산관리: false, + SCAT조사: true, + 게시판: false, + 관리자: false, + }, + }, + { + feature: '레이어 관리', + category: '기본정보관리', + integrated: true, + systems: { + 확산예측: true, + HNS분석: true, + 구조시나리오: true, + 항공방제: true, + 해양기상: true, + '사건/사고': true, + 자산관리: false, + SCAT조사: true, + 게시판: false, + 관리자: true, + }, + }, + { + feature: '메뉴 관리', + category: '기본정보관리', + integrated: true, + systems: { + 확산예측: false, + HNS분석: false, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: true, + }, + }, + { + feature: '시스템 설정', + category: '기본정보관리', + integrated: true, + systems: { + 확산예측: false, + HNS분석: false, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: true, + }, + }, + { + feature: '확산 시뮬레이션', + category: '업무기능', + integrated: false, + systems: { + 확산예측: true, + HNS분석: false, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: false, + }, + }, + { + feature: 'HNS 대기확산', + category: '업무기능', + integrated: false, + systems: { + 확산예측: false, + HNS분석: true, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: false, + }, + }, + { + feature: '표류 예측', + category: '업무기능', + integrated: false, + systems: { + 확산예측: false, + HNS분석: false, + 구조시나리오: true, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: false, + }, + }, + { + feature: '위성/드론 영상', + category: '업무기능', + integrated: false, + systems: { + 확산예측: false, + HNS분석: false, + 구조시나리오: false, + 항공방제: true, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: false, + }, + }, + { + feature: '기상/해상 정보', + category: '업무기능', + integrated: false, + systems: { + 확산예측: true, + HNS분석: true, + 구조시나리오: true, + 항공방제: false, + 해양기상: true, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: false, + }, + }, + { + feature: '역추적 분석', + category: '업무기능', + integrated: false, + systems: { + 확산예측: true, + HNS분석: false, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: false, + }, + }, + { + feature: '사고 등록/이력', + category: '업무기능', + integrated: false, + systems: { + 확산예측: false, + HNS분석: false, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': true, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: false, + }, + }, + { + feature: '장비/선박 관리', + category: '업무기능', + integrated: false, + systems: { + 확산예측: false, + HNS분석: false, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: true, + SCAT조사: false, + 게시판: false, + 관리자: false, + }, + }, + { + feature: '해안 조사', + category: '업무기능', + integrated: false, + systems: { + 확산예측: false, + HNS분석: false, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: true, + 게시판: false, + 관리자: false, + }, + }, + { + feature: '게시판 CRUD', + category: '업무기능', + integrated: false, + systems: { + 확산예측: false, + HNS분석: false, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: true, + 관리자: false, + }, + }, +]; + +const CATEGORY_STYLES: Record = { + 공통기능: 'bg-[rgba(6,182,212,0.2)] text-color-accent', + 기본정보관리: 'bg-[rgba(34,197,94,0.2)] text-color-success', + 업무기능: 'bg-bg-elevated text-t3', +}; + +export function CommonFeaturesTab() { + return ( +
+ {/* 1. 방제대응 프로세스 */} +
+

1. 방제대응 프로세스

+

+ 해양오염 사고 발생 시 사고 접수부터 상황 종료까지의 단계별 대응 프로세스이며, 각 단계에서 + 활용하는 시스템 모듈을 표시한다. +

+ {/* 프로세스 흐름도 */} +
+ {RESPONSE_PROCESS.map((step, idx) => ( +
+
+

{step.phase}

+
+ {step.modules.map((mod) => ( + + {mod} + + ))} +
+
+ {idx < RESPONSE_PROCESS.length - 1 && ( + + )} +
+ ))} +
+ {/* 프로세스 상세 */} +
+ {RESPONSE_PROCESS.map((step, idx) => ( +
+ + {idx + 1} + +
+

{step.phase}

+

{step.description}

+
+
+ {step.modules.map((mod) => ( + + {mod} + + ))} +
+
+ ))} +
+
+ + {/* 2. 시스템별 기능 유무 매트릭스 */} +
+

2. 시스템별 기능 유무 매트릭스

+

+ 각 시스템(업무 모듈)별 기능의 유무를 파악하여 공통기능, 기본정보 관리(사용자, 지도 등) 등 + 통합할 수 있는 기능을 표시한다.{' '} + 통합 대상 기능은 공통 모듈로 일원화하여 + 중복 개발을 방지한다. +

+
+ + + + + + + {SYSTEM_MODULES.map((mod) => ( + + ))} + + + + {FEATURE_MATRIX.map((row) => ( + + + + + {SYSTEM_MODULES.map((mod) => ( + + ))} + + ))} + +
+ 기능 + + 분류 + + 통합 + + {mod} +
+ {row.feature} + + + {row.category} + + + {row.integrated ? ( + 통합 + ) : ( + 개별 + )} + + {row.systems[mod] ? ( + O + ) : ( + - + )} +
+
+ {/* 범례 */} +
+
+ + 공통기능 + + 전 모듈 공통 적용 +
+
+ + 기본정보관리 + + 사용자·지도·메뉴·설정 통합 관리 +
+
+ + 업무기능 + + 모듈별 고유 기능 +
+
+
+ + {/* 3. 공통기능 상세 */} +
+

3. 공통기능 상세

+
+ {COMMON_FEATURES.map((feature, idx) => ( +
+
+ + {idx + 1} + +

{feature.title}

+
+

{feature.description}

+
    + {feature.details.map((detail) => ( +
  • + {detail} +
  • + ))} +
+
+ ))} +
+
+ + {/* 4. 공통 모듈 구조 */} +
+

4. 공통 모듈 디렉토리 구조

+
+ + + + {['디렉토리', '역할', '주요 파일'].map((h) => ( + + ))} + + + + {[ + { + dir: 'common/components/', + role: '공통 UI 컴포넌트', + files: 'auth/, layout/, map/, ui/, layer/', + }, + { + dir: 'common/hooks/', + role: '공통 커스텀 훅', + files: 'useLayers, useSubMenu, useFeatureTracking', + }, + { + dir: 'common/services/', + role: 'API 통신 모듈', + files: 'api.ts, authApi.ts, layerService.ts', + }, + { + dir: 'common/store/', + role: '전역 상태 스토어', + files: 'authStore.ts, menuStore.ts', + }, + { + dir: 'common/styles/', + role: 'CSS @layer 스타일', + files: 'base.css, components.css, wing.css', + }, + { + dir: 'common/types/', + role: '공통 타입 정의', + files: 'backtrack, hns, navigation 등', + }, + { + dir: 'common/utils/', + role: '유틸리티 함수', + files: 'coordinates, geo, sanitize, cn.ts', + }, + { dir: 'common/constants/', role: '상수 정의', files: 'featureIds.ts' }, + { dir: 'common/data/', role: 'UI 데이터', files: 'layerData.ts (레이어 트리)' }, + ].map((row) => ( + + + + + + ))} + +
+ {h} +
+ {row.dir} + {row.role}{row.files}
+
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/FrameworkTab.tsx b/frontend/src/components/admin/components/contents/FrameworkTab.tsx new file mode 100644 index 0000000..61e0e20 --- /dev/null +++ b/frontend/src/components/admin/components/contents/FrameworkTab.tsx @@ -0,0 +1,171 @@ +interface TechStackRow { + category: string; + tech: string; + version: string; + description: string; +} + +const TECH_STACK: TechStackRow[] = [ + { category: 'Frontend', tech: 'React', version: '19.x', description: '컴포넌트 기반 SPA' }, + { category: 'Frontend', tech: 'TypeScript', version: '5.9', description: '정적 타입 시스템' }, + { category: 'Frontend', tech: 'Vite', version: '7.x', description: '빌드 도구 (HMR)' }, + { category: 'Frontend', tech: 'Tailwind CSS', version: '3.x', description: '유틸리티 기반 CSS' }, + { category: 'Frontend', tech: 'MapLibre GL', version: '5.x', description: '오픈소스 GIS 엔진' }, + { category: 'Frontend', tech: 'deck.gl', version: '9.x', description: '대규모 데이터 시각화' }, + { category: 'Frontend', tech: 'Zustand', version: '-', description: '클라이언트 상태관리' }, + { category: 'Frontend', tech: 'TanStack Query', version: '-', description: '서버 상태관리/캐싱' }, + { category: 'Backend', tech: 'Express', version: '4.x', description: 'REST API 서버' }, + { category: 'Backend', tech: 'Socket.IO', version: '-', description: '실시간 양방향 통신' }, + { category: 'DB', tech: 'PostgreSQL', version: '16', description: '관계형 데이터베이스' }, + { category: 'DB', tech: 'PostGIS', version: '-', description: '공간정보 확장' }, + { + category: '인증', + tech: 'JWT', + version: '-', + description: '토큰 기반 인증 (HttpOnly Cookie)', + }, + { category: '인증', tech: 'Google OAuth', version: '2.0', description: 'SSO 연동' }, + { category: '보안', tech: 'Helmet', version: '-', description: 'HTTP 헤더 보안' }, + { category: '보안', tech: 'Rate Limiting', version: '-', description: 'API 호출 제한' }, + { category: 'CI/CD', tech: 'Gitea Actions', version: '-', description: '자동 빌드/배포' }, +]; + +// ─── 탭 모듈 데이터 ─────────────────────────────────────────────────────────────── + +export function FrameworkTab() { + return ( +
+ {/* 1. 개발 프레임워크 구성 */} +
+

1. 개발 프레임워크 구성

+
+ {/* 프레젠테이션 계층 */} +
+

프레젠테이션 계층

+

React 19 + TypeScript 5.9 + Tailwind CSS 3

+
+ {[ + { name: 'MapLibre', sub: 'GL JS 5' }, + { name: 'deck.gl', sub: '9.x' }, + { name: 'Zustand', sub: '상태관리' }, + { name: 'TanStack', sub: 'Query' }, + ].map((item) => ( +
+

{item.name}

+

{item.sub}

+
+ ))} +
+
+ {/* 비즈니스 로직 계층 */} +
+

비즈니스 로직 계층

+

Express 4 + TypeScript

+
+ {[ + { name: 'JWT 인증', sub: 'OAuth2.0' }, + { name: 'RBAC', sub: '권한엔진' }, + { name: 'Socket.IO', sub: '실시간' }, + { name: 'Helmet', sub: '보안' }, + ].map((item) => ( +
+

{item.name}

+

{item.sub}

+
+ ))} +
+
+ {/* 데이터 접근 계층 */} +
+

데이터 접근 계층

+

PostgreSQL 16 + PostGIS

+
+ {[ + { name: 'wing DB', sub: '운영 DB' }, + { name: 'wing_auth', sub: '인증 DB' }, + { name: 'PostGIS', sub: '공간정보' }, + ].map((item) => ( +
+

{item.name}

+

{item.sub}

+
+ ))} +
+
+
+
+ + {/* 2. 기술 스택 상세 */} +
+

2. 기술 스택 상세

+
+ + + + {['구분', '기술', '버전', '설명'].map((h) => ( + + ))} + + + + {TECH_STACK.map((row, idx) => ( + + + + + + + ))} + +
+ {h} +
+ {row.category} + {row.tech}{row.version}{row.description}
+
+
+ + {/* 3. 개발 표준 및 규칙 */} +
+

3. 개발 표준 및 규칙

+
+ {[ + { + title: 'HTTP 정책', + content: 'GET/POST만 사용 (PUT/DELETE/PATCH 금지) — 한국 보안취약점 점검 가이드 준수', + }, + { + title: '코드 표준', + content: 'ESLint + Prettier 적용, TypeScript strict 모드 필수', + }, + { + title: '모듈 구조', + content: '@common/ (공통 모듈) + @components/ (업무별 탭) Path Alias 기반 분리', + }, + { + title: '보안', + content: '입력 살균(sanitize), XSS/SQL Injection 방지, CORS 정책, Rate Limiting', + }, + ].map((item) => ( +
+

{item.title}

+

{item.content}

+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/HeterogeneousTab.tsx b/frontend/src/components/admin/components/contents/HeterogeneousTab.tsx new file mode 100644 index 0000000..bae4d8a --- /dev/null +++ b/frontend/src/components/admin/components/contents/HeterogeneousTab.tsx @@ -0,0 +1,367 @@ +interface HeterogeneousSystemRow { + system: string; + lang: string; + os: string; + location: string; + protocol: string; + description: string; +} + +const HETEROGENEOUS_SYSTEMS: HeterogeneousSystemRow[] = [ + { + system: 'KOSPS', + lang: 'Fortran', + os: 'Linux', + location: '광주', + protocol: 'HTTPS (REST 래퍼)', + description: '유출유 확산 예측 — Fortran DLL을 REST API로 래핑하여 연계', + }, + { + system: '충북대 HNS', + lang: 'Python / C++', + os: 'Linux', + location: '충북대', + protocol: 'HTTPS', + description: 'HNS 대기확산 예측 — Python/C++ 모델을 REST API로 호출', + }, + { + system: '긴급구난', + lang: 'Python', + os: 'Linux', + location: '해경 내부', + protocol: '내부망 API', + description: '구난 표류 분석 — Python 모델을 내부망 REST API로 연계', + }, + { + system: 'HYCOM', + lang: 'Fortran / NetCDF', + os: 'Linux HPC', + location: '미 해군 공개', + protocol: 'HTTPS / FTP', + description: '전지구 해류·수온 예측 — NetCDF 파일 수신 후 ETL 전처리', + }, + { + system: '기상청', + lang: '-', + os: '-', + location: '기상청 API Hub', + protocol: 'HTTPS', + description: '풍향·풍속·기온·강수 등 기상 데이터 REST API 수집', + }, + { + system: 'KHOA', + lang: '-', + os: '-', + location: '해양조사원', + protocol: 'HTTPS', + description: '조위·해류·수온 등 해양관측 데이터 REST API 수집', + }, + { + system: '해경 KBP', + lang: 'Java 전자정부', + os: 'Linux', + location: '해경 내부망', + protocol: '내부망 API', + description: '사용자·조직·직위 인사 데이터 배치 수집 (비식별화 적용)', + }, + { + system: 'AIS', + lang: '-', + os: '-', + location: '해경 AIS 서버', + protocol: 'Socket / API', + description: '선박 위치·속도·방향 실시간 수신', + }, +]; + +interface HeterogeneousStrategyCard { + challenge: string; + solution: string; + description: string; +} + +interface IntegrationPlanItem { + title: string; + description: string; + details?: string[]; +} + +const INTEGRATION_PLANS: IntegrationPlanItem[] = [ + { + title: '사용자 정보 연계', + description: + '해양경찰청의 인사관리플랫폼과 연계 또는 사용자 정보를 제공받아 구성할 수 있어야 함', + }, + { + title: '해양공간 데이터 연계', + description: + "해경 해양공간 데이터 구축사업(해양공간정보 활용체계 구축 및 빅데이터 관련 사업)의 '데이터통합저장소' 시스템과 연계하여 현장탐색 전자지도 자동표출 기술 등에 영상 및 사진자료를 연계하여 구축", + }, + { + title: 'DB 통합설계 기반 맞춤형 인터페이스', + description: + '플랫폼 변경 및 신규 통합설계 되는 데이터베이스(DB) 구조 설계를 기반으로 사용자 맞춤형 화면 인터페이스를 구현해야 함', + details: [ + 'DBMS는 분리되어 있는 시스템들을 통합설계를 통하여 공통, 분야별 등으로 설계하여야 함', + ], + }, + { + title: '유출유 확산예측 정확성 향상 (KOSPS 연계)', + description: + '유출유 확산예측 정확성 향상을 위해, 해양오염방제지원시스템(KOSPS)를 연계·탑재하여야 함', + details: [ + '다양한 유출유 확산 예측 결과를 사용자가 한눈에 확인 가능하여야 함', + '확산예측 기반으로 역추적, 최초 유출유 발생지점을 예측할 수 있어야 함', + '그 밖에 유출유 확산예측 정확성 향상을 위한 대책을 마련하여야 함', + ], + }, + { + title: '기타 시스템 연계', + description: '그 밖에 시스템 구축 중 효율적인 사고대응을 위한 타 시스템 연계할 수 있음', + }, +]; + +const HETEROGENEOUS_STRATEGIES: HeterogeneousStrategyCard[] = [ + { + challenge: '언어 이질성', + solution: 'REST API 래퍼 계층', + description: + 'Fortran, Python, C++, Java 등 각 언어로 작성된 모델을 REST API 래퍼로 감싸 언어·플랫폼 독립적인 표준 인터페이스 제공', + }, + { + challenge: '데이터 형식 차이', + solution: 'ETL 전처리 파이프라인', + description: + 'NetCDF, CSV, Binary, JSON 등 이기종 포맷을 ETL 파이프라인으로 표준 JSON/GeoJSON 형식으로 변환 후 DB 적재', + }, + { + challenge: '네트워크 분리', + solution: '이중 네트워크 연계', + description: + '외부망(인터넷) 연계와 내부망(해경 내부) 연계를 분리 운영하여 보안 정책 준수 및 데이터 안전성 확보', + }, + { + challenge: '가용성·장애 대응', + solution: '연계 모니터링 + 알림', + description: + '연계 상태를 실시간 모니터링하고 수신 지연·실패 발생 시 운영자에게 즉시 알림 발송하여 신속 대응', + }, + { + challenge: '인증·보안 차이', + solution: 'API Gateway 패턴', + description: + '시스템별 상이한 인증 방식(API Key, JWT, IP 제한 등)을 API Gateway 계층에서 통합 관리하여 단일 보안 정책 적용', + }, + { + challenge: '프로토콜 차이', + solution: '어댑터 패턴 적용', + description: + 'HTTP REST, FTP, Socket, 배치 파일 등 다양한 프로토콜을 어댑터 패턴으로 추상화하여 표준 인터페이스로 통일', + }, +]; + +const HETEROGENEOUS_FLOW_STEPS = [ + '원본 데이터', + '수집 어댑터', + 'ETL 전처리', + '표준 변환', + 'DB 적재', + 'API 제공', +]; + +interface SecurityPolicyCard { + title: string; + items: string[]; +} + +const HETEROGENEOUS_SECURITY: SecurityPolicyCard[] = [ + { + title: '외부망 연계', + items: [ + 'TLS 1.2+ 암호화 통신', + 'API Key / OAuth 인증', + 'IP 화이트리스트 제한', + 'Rate Limiting 적용', + ], + }, + { + title: '내부망 연계', + items: [ + '전용 내부망 구간 분리', + '상호 인증서 검증', + '비식별화 자동 처리', + '접근 이력 감사로그', + ], + }, + { + title: '데이터 보호', + items: [ + '개인정보 수집 최소화', + 'ETL 단계 비식별화', + '전송 구간 암호화', + '저장 데이터 접근 제어', + ], + }, +]; + +// ─── 탭 4: 이기종시스템연계 ─────────────────────────────────────────────────────── + +export function HeterogeneousTab() { + return ( +
+ {/* 1. 이기종시스템 연계 개요 */} +
+

1. 이기종시스템 연계 개요

+

+ 통합지원시스템은 Fortran, Python, C++, Java 등 다양한 언어와 플랫폼으로 구현된 이기종 + 시스템과 연계한다. REST API 표준화, ETL 전처리, 어댑터 패턴을 통해 언어·플랫폼 독립적인 + 연계 구조를 구현하며, 외부망·내부망 이중 네트워크 정책을 준수한다. +

+
+
+

이기종 시스템

+ {['Fortran KOSPS', 'Python/C++ 충북대', 'Java 해경KBP', 'NetCDF HYCOM'].map((item) => ( +

+ {item} +

+ ))} +
+
+ + +
+
+

연계 어댑터 계층

+ {['REST API 래퍼', 'ETL 전처리', '프로토콜 변환', '인증 통합'].map((item) => ( +

+ {item} +

+ ))} +
+
+ + +
+
+

통합지원시스템

+ {['Express REST API', 'PostgreSQL+PostGIS', 'React SPA', '표준 JSON'].map((item) => ( +

+ {item} +

+ ))} +
+
+
+ + {/* 2. 이기종 시스템 간의 연계 방안 */} +
+

2. 이기종 시스템 간의 연계 방안

+
+ {INTEGRATION_PLANS.map((item, idx) => ( +
+

+ {idx + 1}. {item.title} +

+

{item.description}

+ {item.details && ( +
    + {item.details.map((detail) => ( +
  • + {detail} +
  • + ))} +
+ )} +
+ ))} +
+
+ + {/* 3. 연계 대상 이기종 시스템 목록 */} +
+

3. 연계 대상 이기종 시스템 목록

+
+ + + + {['시스템', '구현 언어', 'OS', '위치', '연계 프로토콜', '연계 설명'].map((h) => ( + + ))} + + + + {HETEROGENEOUS_SYSTEMS.map((row) => ( + + + + + + + + + ))} + +
+ {h} +
{row.system}{row.lang}{row.os}{row.location}{row.protocol}{row.description}
+
+
+ + {/* 4. 이기종 연계 전략 */} +
+

4. 이기종 연계 전략

+
+ {HETEROGENEOUS_STRATEGIES.map((card) => ( +
+
+ {card.challenge} + + {card.solution} +
+

{card.description}

+
+ ))} +
+
+ + {/* 5. 이기종 데이터 변환 흐름 */} +
+

5. 이기종 데이터 변환 흐름

+
+ {HETEROGENEOUS_FLOW_STEPS.map((step, idx) => ( +
+
+

{step}

+
+ {idx < HETEROGENEOUS_FLOW_STEPS.length - 1 && ( + + )} +
+ ))} +
+
+ + {/* 6. 이기종 연계 보안 정책 */} +
+

6. 이기종 연계 보안 정책

+
+ {HETEROGENEOUS_SECURITY.map((card) => ( +
+

{card.title}

+
    + {card.items.map((item) => ( +
  • + · {item} +
  • + ))} +
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/InterfaceTab.tsx b/frontend/src/components/admin/components/contents/InterfaceTab.tsx new file mode 100644 index 0000000..bfd8f8c --- /dev/null +++ b/frontend/src/components/admin/components/contents/InterfaceTab.tsx @@ -0,0 +1,236 @@ +interface InterfaceRow { + system: string; + method: string; + data: string; + cycle: string; + protocol: string; +} + +const INTERFACES: InterfaceRow[] = [ + { + system: 'KHOA (해양조사원)', + method: 'REST API', + data: '조위, 해류, 수온', + cycle: '실시간/1시간', + protocol: 'HTTPS', + }, + { + system: '기상청', + method: 'REST API', + data: '풍향/풍속, 기압, 기온, 강수', + cycle: '3시간', + protocol: 'HTTPS', + }, + { + system: 'HYCOM', + method: '파일 수신', + data: 'SST, 해류(U/V), SSH', + cycle: '6시간', + protocol: 'HTTPS/FTP', + }, + { + system: '해경 KBP (인사)', + method: '배치 수집', + data: '사용자, 부서, 직위, 조직', + cycle: '1일 1회', + protocol: '내부망 API', + }, + { + system: 'AIS 선박위치', + method: '실시간 수집', + data: '선박 위치, 속도, 방향', + cycle: '실시간', + protocol: 'Socket/API', + }, + { + system: '포세이돈 R&D', + method: 'API 연계', + data: '유출유 확산 예측 결과', + cycle: '요청 시', + protocol: 'HTTPS', + }, + { + system: 'KOSPS (광주)', + method: 'DLL 호출', + data: '유출유 확산 예측 결과', + cycle: '요청 시', + protocol: 'HTTPS (Fortran DLL)', + }, + { + system: '충북대 HNS', + method: 'API 호출', + data: 'HNS 대기확산 결과', + cycle: '요청 시', + protocol: 'HTTPS', + }, + { + system: '긴급구난 R&D', + method: '내부 연계', + data: '구난 분석 결과', + cycle: '요청 시', + protocol: '내부망 API', + }, +]; + +// ─── 탭 1: 표준 프레임워크 ──────────────────────────────────────────────────────── + +export function InterfaceTab() { + const dataFlowSteps = ['수집', '전처리', '저장', '분석/예측', '시각화', '의사결정지원']; + + return ( +
+ {/* 1. 외부 시스템 연계 구성도 */} +
+

1. 외부 시스템 연계 구성도

+
+ {/* 외부 시스템 */} +
+

외부 시스템

+ {['KHOA API', '기상청 API', '해경 KBP', 'AIS 선박'].map((item) => ( +
+

{item}

+
+ ))} +
+ {/* 화살표 */} +
+ + +
+ {/* 통합지원시스템 */} +
+

+ 해양환경 위기대응 +
+ 통합지원시스템 +

+
+

연계관리 모듈

+
+ {['수집자료 관리', '연계 모니터링', '비식별화 조치'].map((item) => ( +

+ - {item} +

+ ))} +
+
+
+ {/* 화살표 */} +
+ + +
+ {/* R&D 시스템 */} +
+

R&D 시스템

+ {['포세이돈', 'KOSPS', '충북대 HNS', '긴급구난'].map((item) => ( +
+

{item}

+
+ ))} +
+
+
+ + {/* 2. 연계 인터페이스 목록 */} +
+

2. 연계 인터페이스 목록

+
+ + + + {['연계 시스템', '연계 방식', '데이터', '주기', '프로토콜'].map((h) => ( + + ))} + + + + {INTERFACES.map((row) => ( + + + + + + + + ))} + +
+ {h} +
{row.system}{row.method}{row.data}{row.cycle}{row.protocol}
+
+
+ + {/* 3. 데이터 흐름도 */} +
+

3. 데이터 흐름도

+
+ {dataFlowSteps.map((step, idx) => ( +
+
+

{step}

+
+ {idx < dataFlowSteps.length - 1 && ( + + )} +
+ ))} +
+
+ {[ + { step: '수집', desc: 'KHOA, 기상청, HYCOM, AIS 등 외부 원천 데이터 수신' }, + { step: '전처리', desc: '포맷 변환, 좌표계 통일, 비식별화, 품질 검사' }, + { step: '저장', desc: 'PostgreSQL 16 + PostGIS 공간정보 DB 적재' }, + { step: '분석/예측', desc: 'R&D 모델 연계 (포세이돈, KOSPS, 충북대, 긴급구난)' }, + { step: '시각화', desc: 'MapLibre GL + deck.gl 기반 지도 레이어 렌더링' }, + { step: '의사결정지원', desc: '방제작전 시나리오, 구조분석, 경보 발령 지원' }, + ].map((item) => ( +
+

{item.step}

+

{item.desc}

+
+ ))} +
+
+ + {/* 4. 연계 장애 대응 */} +
+

4. 연계 장애 대응

+
+ {[ + { + title: '연계 모니터링', + content: '관리자 > 연계관리 > 연계모니터링에서 실시간 연계 상태 확인', + }, + { + title: 'R&D 파이프라인 모니터링', + content: '관리자 > 연계관리 > R&D과제에서 과제별 데이터 수신 이력 및 처리 현황 확인', + }, + { + title: '장애 알림', + content: '데이터 수신 지연/실패 발생 시 알림 발생 — 운영자 즉시 인지 가능', + }, + { + title: '비식별화 조치', + content: '개인정보 포함 데이터(해경 KBP 인사 등) 수집 시 자동 비식별화 처리 적용', + }, + ].map((item) => ( +
+

{item.title}

+

{item.content}

+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/PermCell.tsx b/frontend/src/components/admin/components/contents/PermCell.tsx new file mode 100644 index 0000000..ded1995 --- /dev/null +++ b/frontend/src/components/admin/components/contents/PermCell.tsx @@ -0,0 +1,70 @@ +import type { PermState } from '../PermissionsPanel'; + +interface PermCellProps { + state: PermState; + onToggle: () => void; + label?: string; + readOnly?: boolean; +} + +export function PermCell({ state, onToggle, label, readOnly = false }: PermCellProps) { + const isDisabled = state === 'forced-denied' || readOnly; + + const baseClasses = + 'w-5 h-5 rounded border text-caption font-bold transition-all flex items-center justify-center'; + + let classes: string; + let icon: string; + + switch (state) { + case 'explicit-granted': + classes = readOnly + ? `${baseClasses} bg-[rgba(6,182,212,0.2)] border-color-accent text-color-accent cursor-default` + : `${baseClasses} bg-[rgba(6,182,212,0.2)] border-color-accent text-color-accent cursor-pointer hover:bg-[rgba(6,182,212,0.3)]`; + icon = '✓'; + break; + case 'inherited-granted': + classes = readOnly + ? `${baseClasses} bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] cursor-default` + : `${baseClasses} bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] cursor-pointer hover:border-color-accent`; + icon = '✓'; + break; + case 'explicit-denied': + classes = readOnly + ? `${baseClasses} bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-color-danger cursor-default` + : `${baseClasses} bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-color-danger cursor-pointer hover:border-color-danger`; + icon = '—'; + break; + case 'forced-denied': + classes = `${baseClasses} bg-bg-elevated border-stroke text-fg-disabled opacity-40 cursor-not-allowed`; + icon = '—'; + break; + } + + return ( + + ); +} diff --git a/frontend/src/components/admin/components/contents/PermLegend.tsx b/frontend/src/components/admin/components/contents/PermLegend.tsx new file mode 100644 index 0000000..9268659 --- /dev/null +++ b/frontend/src/components/admin/components/contents/PermLegend.tsx @@ -0,0 +1,36 @@ +export function PermLegend() { + return ( +
+ + + ✓ + + 허용 + + + + ✓ + + 상속 + + + + — + + 거부 + + + + — + + 비활성 + + + R=조회 C=생성 U=수정 D=삭제 + +
+ ); +} diff --git a/frontend/src/components/admin/components/contents/ProgressBar.tsx b/frontend/src/components/admin/components/contents/ProgressBar.tsx new file mode 100644 index 0000000..74e2b6a --- /dev/null +++ b/frontend/src/components/admin/components/contents/ProgressBar.tsx @@ -0,0 +1,15 @@ +export function ProgressBar({ value }: { value: number }) { + const colorClass = + value === 100 ? 'bg-color-success' : value > 0 ? 'bg-color-accent' : 'bg-bg-elevated'; + return ( +
+
+
+
+ {value}% +
+ ); +} diff --git a/frontend/src/components/admin/components/contents/RegisterModal.tsx b/frontend/src/components/admin/components/contents/RegisterModal.tsx new file mode 100644 index 0000000..6e8a54c --- /dev/null +++ b/frontend/src/components/admin/components/contents/RegisterModal.tsx @@ -0,0 +1,228 @@ +import { useState } from 'react'; +import { createUserApi, type RoleWithPermissions, type OrgItem } from '@common/services/authApi'; +import { getRoleColor } from '../adminConstants'; + +interface RegisterModalProps { + allRoles: RoleWithPermissions[]; + allOrgs: OrgItem[]; + onClose: () => void; + onSuccess: () => void; +} + +export function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalProps) { + const [account, setAccount] = useState(''); + const [password, setPassword] = useState(''); + const [name, setName] = useState(''); + const [rank, setRank] = useState(''); + const [orgSn, setOrgSn] = useState(() => { + const defaultOrg = allOrgs.find((o) => o.orgNm === '기동방제과'); + return defaultOrg ? defaultOrg.orgSn : ''; + }); + const [email, setEmail] = useState(''); + const [roleSns, setRoleSns] = useState([]); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const toggleRole = (sn: number) => { + setRoleSns((prev) => (prev.includes(sn) ? prev.filter((s) => s !== sn) : [...prev, sn])); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!account.trim() || !password.trim() || !name.trim()) { + setError('계정, 비밀번호, 사용자명은 필수 항목입니다.'); + return; + } + setSubmitting(true); + setError(null); + try { + await createUserApi({ + account: account.trim(), + password, + name: name.trim(), + rank: rank.trim() || undefined, + orgSn: orgSn !== '' ? orgSn : undefined, + roleSns: roleSns.length > 0 ? roleSns : undefined, + }); + onSuccess(); + onClose(); + } catch (err) { + setError('사용자 등록에 실패했습니다.'); + console.error('사용자 등록 실패:', err); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ {/* 헤더 */} +
+

사용자 등록

+ +
+ + {/* 폼 */} +
+
+ {/* 계정 */} +
+ + setAccount(e.target.value)} + placeholder="로그인 계정 ID" + className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono" + /> +
+ + {/* 비밀번호 */} +
+ + setPassword(e.target.value)} + placeholder="초기 비밀번호" + className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono" + /> +
+ + {/* 사용자명 */} +
+ + setName(e.target.value)} + placeholder="실명" + className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean" + /> +
+ + {/* 직급 */} +
+ + setRank(e.target.value)} + placeholder="예: 팀장, 주임 등" + className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean" + /> +
+ + {/* 소속 */} +
+ + +
+ + {/* 이메일 */} +
+ + setEmail(e.target.value)} + placeholder="이메일 주소" + className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono" + /> +
+ + {/* 역할 */} +
+ +
+ {allRoles.length === 0 ? ( +

역할 없음

+ ) : ( + allRoles.map((role, idx) => { + const color = getRoleColor(role.code, idx); + return ( + + ); + }) + )} +
+
+ + {/* 에러 메시지 */} + {error &&

{error}

} +
+ + {/* 푸터 */} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/RolePermTab.tsx b/frontend/src/components/admin/components/contents/RolePermTab.tsx new file mode 100644 index 0000000..62c9b06 --- /dev/null +++ b/frontend/src/components/admin/components/contents/RolePermTab.tsx @@ -0,0 +1,312 @@ +import type { PermTreeNode, RoleWithPermissions } from '@common/services/authApi'; +import type { PermState, OperCode } from '../PermissionsPanel'; +import { OPER_CODES, OPER_FULL_LABELS, OPER_LABELS, buildEffectiveStates } from '../PermissionsPanel'; +import { TreeRow } from './TreeRow'; +import { PermLegend } from './PermLegend'; + +interface RolePermTabProps { + roles: RoleWithPermissions[]; + permTree: PermTreeNode[]; + rolePerms: Map>; + setRolePerms: React.Dispatch>>>; + selectedRoleSn: number | null; + setSelectedRoleSn: (sn: number | null) => void; + dirty: boolean; + saving: boolean; + saveError: string | null; + handleSave: () => Promise; + handleToggleExpand: (code: string) => void; + handleTogglePerm: (code: string, oper: OperCode, currentState: PermState) => void; + expanded: Set; + flatNodes: PermTreeNode[]; + editingRoleSn: number | null; + editRoleName: string; + setEditRoleName: (name: string) => void; + handleStartEditName: (role: RoleWithPermissions) => void; + handleSaveRoleName: (roleSn: number) => Promise; + setEditingRoleSn: (sn: number | null) => void; + toggleDefault: (roleSn: number) => Promise; + handleDeleteRole: (roleSn: number, roleName: string) => Promise; + showCreateForm: boolean; + setShowCreateForm: (show: boolean) => void; + setCreateError: (err: string) => void; + newRoleCode: string; + setNewRoleCode: (code: string) => void; + newRoleName: string; + setNewRoleName: (name: string) => void; + newRoleDesc: string; + setNewRoleDesc: (desc: string) => void; + creating: boolean; + createError: string; + handleCreateRole: () => Promise; +} + +export function RolePermTab({ + roles, + permTree, + selectedRoleSn, + setSelectedRoleSn, + dirty, + saving, + saveError, + handleSave, + handleToggleExpand, + handleTogglePerm, + expanded, + flatNodes, + rolePerms, + editingRoleSn, + editRoleName, + setEditRoleName, + handleStartEditName, + handleSaveRoleName, + setEditingRoleSn, + toggleDefault, + handleDeleteRole, + showCreateForm, + setShowCreateForm, + setCreateError, + newRoleCode, + setNewRoleCode, + newRoleName, + setNewRoleName, + newRoleDesc, + setNewRoleDesc, + creating, + createError, + handleCreateRole, +}: RolePermTabProps) { + const currentStateMap = selectedRoleSn + ? buildEffectiveStates(flatNodes, rolePerms.get(selectedRoleSn) ?? new Map()) + : new Map(); + + return ( + <> + {/* 헤더 액션 버튼 */} +
+ + + {saveError && ( + {saveError} + )} +
+ + {/* 역할 탭 바 */} +
+ {roles.map((role) => { + const isSelected = selectedRoleSn === role.sn; + return ( +
+ + {isSelected && ( +
+ + {role.code !== 'ADMIN' && ( + + )} +
+ )} +
+ ); + })} +
+ + {/* 범례 */} + + + {/* CRUD 매트릭스 테이블 */} + {selectedRoleSn ? ( +
+ + + + + {OPER_CODES.map((oper) => ( + + ))} + + + + {permTree.map((rootNode) => ( + + ))} + +
+ 기능 + +
+ {OPER_LABELS[oper]} +
+
+ {OPER_FULL_LABELS[oper]} +
+
+
+ ) : ( +
+ 역할을 선택하세요 +
+ )} + + {/* 역할 생성 모달 */} + {showCreateForm && ( +
+
+
+

새 역할 추가

+
+
+
+ + + setNewRoleCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '')) + } + placeholder="CUSTOM_ROLE" + className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono" + /> +

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

+
+
+ + setNewRoleName(e.target.value)} + placeholder="사용자 정의 역할" + className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean" + /> +
+
+ + setNewRoleDesc(e.target.value)} + placeholder="역할에 대한 설명" + className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean" + /> +
+ {createError && ( +
+ {createError} +
+ )} +
+
+ + +
+
+
+ )} + + ); +} diff --git a/frontend/src/components/admin/components/contents/Step1.tsx b/frontend/src/components/admin/components/contents/Step1.tsx new file mode 100644 index 0000000..5a4cf52 --- /dev/null +++ b/frontend/src/components/admin/components/contents/Step1.tsx @@ -0,0 +1,120 @@ +import type { ApiConfig, DbConfig, SourceType, WizardState } from '../DeidentifyPanel'; + +interface Step1Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +export function Step1({ wizard, onChange }: Step1Props) { + const handleDbChange = (key: keyof DbConfig, value: string) => { + onChange({ dbConfig: { ...wizard.dbConfig, [key]: value } }); + }; + const handleApiChange = (key: keyof ApiConfig, value: string) => { + onChange({ apiConfig: { ...wizard.apiConfig, [key]: value } }); + }; + + return ( +
+
+ + onChange({ taskName: e.target.value })} + placeholder="작업 이름을 입력하세요" + className="w-full px-3 py-2 text-caption rounded bg-bg-elevated border border-stroke text-t1 placeholder:text-t3 focus:outline-none focus:border-color-accent" + /> +
+ +
+ +
+ {( + [ + ['db', '데이터베이스 연결'], + ['file', '파일 업로드'], + ['api', 'API 호출'], + ] as [SourceType, string][] + ).map(([val, label]) => ( + + ))} +
+
+ + {wizard.sourceType === 'db' && ( +
+ {( + [ + ['host', '호스트', 'localhost'], + ['port', '포트', '5432'], + ['database', '데이터베이스', 'wing'], + ['tableName', '테이블명', 'public.customers'], + ] as [keyof DbConfig, string, string][] + ).map(([key, labelText, placeholder]) => ( +
+ + handleDbChange(key, e.target.value)} + placeholder={placeholder} + className="w-full px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 placeholder:text-t3 focus:outline-none focus:border-color-accent" + /> +
+ ))} +
+ )} + + {wizard.sourceType === 'file' && ( +
+ + + +

파일을 드래그하거나 클릭하여 업로드

+

CSV, XLSX, JSON 지원 (최대 500MB)

+
+ )} + + {wizard.sourceType === 'api' && ( +
+
+ + handleApiChange('url', e.target.value)} + placeholder="https://api.example.com/data" + className="w-full px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 placeholder:text-t3 focus:outline-none focus:border-color-accent" + /> +
+
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/admin/components/contents/Step2.tsx b/frontend/src/components/admin/components/contents/Step2.tsx new file mode 100644 index 0000000..2194087 --- /dev/null +++ b/frontend/src/components/admin/components/contents/Step2.tsx @@ -0,0 +1,77 @@ +import type { WizardState } from '../DeidentifyPanel'; + +interface Step2Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +export function Step2({ wizard, onChange }: Step2Props) { + const toggleField = (idx: number) => { + const updated = wizard.fields.map((f, i) => (i === idx ? { ...f, selected: !f.selected } : f)); + onChange({ fields: updated }); + }; + + return ( +
+
+ {[ + { label: '총 데이터 건수', value: '15,240건', color: 'text-t1' }, + { label: '중복', value: '0건', color: 'text-color-success' }, + { label: '누락값', value: '23건', color: 'text-color-caution' }, + ].map((stat) => ( +
+

{stat.label}

+

{stat.value}

+
+ ))} +
+ +
+

스키마 분석 결과 — 포함 필드 선택

+
+ + + + + + + + + + {wizard.fields.map((field, idx) => ( + + + + + + ))} + +
+ f.selected)} + onChange={(e) => + onChange({ + fields: wizard.fields.map((f) => ({ ...f, selected: e.target.checked })), + }) + } + className="accent-cyan-500" + /> + 필드명 + 데이터 타입 +
+ toggleField(idx)} + className="accent-cyan-500" + /> + {field.name}{field.dataType}
+
+

+ {wizard.fields.filter((f) => f.selected).length}개 선택됨 (전체 {wizard.fields.length}개) +

+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/Step3.tsx b/frontend/src/components/admin/components/contents/Step3.tsx new file mode 100644 index 0000000..83dbecb --- /dev/null +++ b/frontend/src/components/admin/components/contents/Step3.tsx @@ -0,0 +1,97 @@ +import type { FieldConfig, WizardState } from '../DeidentifyPanel'; +import { TECHNIQUES, TEMPLATES } from '../DeidentifyPanel'; + +interface Step3Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +export function Step3({ wizard, onChange }: Step3Props) { + const updateField = (idx: number, key: keyof FieldConfig, value: string | boolean) => { + const updated = wizard.fields.map((f, i) => (i === idx ? { ...f, [key]: value } : f)); + onChange({ fields: updated }); + }; + + const selectedFields = wizard.fields.filter((f) => f.selected); + + return ( +
+
+ + + + + + + + + + + {selectedFields.map((field) => { + const globalIdx = wizard.fields.findIndex((f) => f.name === field.name); + return ( + + + + + + + ); + })} + +
필드명 + 데이터타입 + + 선택된 기법 + 설정값
{field.name}{field.dataType} + + + updateField(globalIdx, 'configValue', e.target.value)} + className="w-full px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent" + /> +
+
+ +
+ + +
+ 이전 템플릿 적용: + +
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/Step4.tsx b/frontend/src/components/admin/components/contents/Step4.tsx new file mode 100644 index 0000000..079c29b --- /dev/null +++ b/frontend/src/components/admin/components/contents/Step4.tsx @@ -0,0 +1,160 @@ +import type { OneshotConfig, ProcessMode, RepeatType, ScheduleConfig, WizardState } from '../DeidentifyPanel'; +import { HOURS, WEEKDAYS } from '../DeidentifyPanel'; + +interface Step4Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +export function Step4({ wizard, onChange }: Step4Props) { + const handleScheduleChange = (key: keyof ScheduleConfig, value: string | boolean) => { + onChange({ scheduleConfig: { ...wizard.scheduleConfig, [key]: value } }); + }; + const handleOneshotChange = (key: keyof OneshotConfig, value: string) => { + onChange({ oneshotConfig: { ...wizard.oneshotConfig, [key]: value } }); + }; + + return ( +
+
+ {( + [ + ['immediate', '즉시 처리', '지금 바로 데이터를 비식별화합니다.'], + ['scheduled', '배치 처리 - 정기 스케줄링', '반복 일정에 따라 자동으로 처리합니다.'], + ['oneshot', '배치 처리 - 일회성', '지정한 날짜/시간에 한 번 처리합니다.'], + ] as [ProcessMode, string, string][] + ).map(([val, label, desc]) => ( +
+ + + {val === 'scheduled' && wizard.processMode === 'scheduled' && ( +
+
+ + +
+
+ +
+ {( + [ + ['daily', '매일'], + ['weekly', '주 1회'], + ['monthly', '월 1회'], + ] as [RepeatType, string][] + ).map(([rt, rl]) => ( +
+ handleScheduleChange('repeatType', rt)} + className="accent-cyan-500" + /> + {rl} + {rt === 'weekly' && wizard.scheduleConfig.repeatType === 'weekly' && ( + + )} +
+ ))} +
+
+
+ + handleScheduleChange('startDate', e.target.value)} + className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent" + /> +
+
+ + +
+
+ )} + + {val === 'oneshot' && wizard.processMode === 'oneshot' && ( +
+
+ + handleOneshotChange('date', e.target.value)} + className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent" + /> +
+
+ + +
+
+ )} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/Step5.tsx b/frontend/src/components/admin/components/contents/Step5.tsx new file mode 100644 index 0000000..010307b --- /dev/null +++ b/frontend/src/components/admin/components/contents/Step5.tsx @@ -0,0 +1,62 @@ +import type { ProcessMode, WizardState } from '../DeidentifyPanel'; + +interface Step5Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +export function Step5({ wizard, onChange }: Step5Props) { + const selectedCount = wizard.fields.filter((f) => f.selected).length; + const ruleCount = wizard.fields.filter((f) => f.selected && f.technique !== '유지').length; + + const processModeLabel: Record = { + immediate: '즉시 처리', + scheduled: `배치 - 정기 (${wizard.scheduleConfig.hour} / ${wizard.scheduleConfig.repeatType === 'daily' ? '매일' : wizard.scheduleConfig.repeatType === 'weekly' ? `주1회 ${wizard.scheduleConfig.weekday}요일` : '월1회'})`, + oneshot: `배치 - 일회성 (${wizard.oneshotConfig.date} ${wizard.oneshotConfig.hour})`, + }; + + const summaryRows = [ + { label: '작업명', value: wizard.taskName || '(미입력)' }, + { + label: '소스', + value: + wizard.sourceType === 'db' + ? `DB: ${wizard.dbConfig.database}.${wizard.dbConfig.tableName}` + : wizard.sourceType === 'file' + ? '파일 업로드' + : `API: ${wizard.apiConfig.url}`, + }, + { label: '데이터 건수', value: '15,240건' }, + { label: '선택 필드 수', value: `${selectedCount}개` }, + { label: '비식별화 규칙 수', value: `${ruleCount}개` }, + { label: '처리 방식', value: processModeLabel[wizard.processMode] }, + { label: '예상 처리시간', value: '약 3~5분' }, + ]; + + return ( +
+
+ + + {summaryRows.map(({ label, value }) => ( + + + + + ))} + +
{label}{value}
+
+ + +
+ ); +} diff --git a/frontend/src/components/admin/components/contents/StepIndicator.tsx b/frontend/src/components/admin/components/contents/StepIndicator.tsx new file mode 100644 index 0000000..177cc61 --- /dev/null +++ b/frontend/src/components/admin/components/contents/StepIndicator.tsx @@ -0,0 +1,53 @@ +import { STEP_LABELS } from '../DeidentifyPanel'; + +export function StepIndicator({ current }: { current: number }) { + return ( +
+ {STEP_LABELS.map((label, i) => { + const stepNum = i + 1; + const isDone = stepNum < current; + const isActive = stepNum === current; + return ( +
+
+
+ {isDone ? ( + + + + ) : ( + stepNum + )} +
+ + {stepNum}.{label} + +
+ {i < STEP_LABELS.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/admin/components/contents/TargetArchTab.tsx b/frontend/src/components/admin/components/contents/TargetArchTab.tsx new file mode 100644 index 0000000..6e0657b --- /dev/null +++ b/frontend/src/components/admin/components/contents/TargetArchTab.tsx @@ -0,0 +1,196 @@ +interface TabModuleRow { + module: string; + name: string; + feature: string; + integration: string; +} + +const TAB_MODULES: TabModuleRow[] = [ + { + module: '확산예측', + name: 'prediction', + feature: '유출유 확산 시뮬레이션, 역추적 분석, 오일붐 배치', + integration: 'KOSPS, 포세이돈 R&D', + }, + { + module: 'HNS 분석', + name: 'hns', + feature: '화학물질 확산 예측, 물질 DB, 위험도 평가', + integration: '충북대 R&D, 물질 DB', + }, + { + module: '구조 시나리오', + name: 'rescue', + feature: '긴급구난 분석, 표류 예측', + integration: '긴급구난 R&D', + }, + { + module: '항공 방제', + name: 'aerial', + feature: '위성영상 분석, 드론 영상, 유막 면적 분석', + integration: '위성/드론 데이터', + }, + { + module: '해양 기상', + name: 'weather', + feature: '기상·해상 정보, 조위·해류 관측', + integration: 'KHOA API, 기상청 API', + }, + { + module: '사건/사고', + name: 'incidents', + feature: '해양오염 사고 등록·관리·이력', + integration: '해경 사고 DB', + }, + { + module: '자산 관리', + name: 'assets', + feature: '기관·장비·선박 보험 관리', + integration: '해경 자산 DB', + }, + { + module: 'SCAT 조사', + name: 'scat', + feature: 'Pre-SCAT 해안 조사 기록', + integration: '현장 조사 데이터', + }, + { + module: '관리자', + name: 'admin', + feature: '사용자/권한/메뉴/설정/연계 관리', + integration: '전체 시스템', + }, +]; + +// ─── 연계 인터페이스 데이터 ─────────────────────────────────────────────────────── + +export function TargetArchTab() { + return ( +
+ {/* 1. 시스템 전체 구성도 */} +
+

1. 시스템 전체 구성도

+
+ {/* 사용자 접근 계층 */} +
+

사용자 접근 계층

+

웹 브라우저 (React SPA)

+

+ 확산예측 | HNS분석 | 구조시나리오 | 항공방제 | 기상정보 | 사고관리 | SCAT조사 | + 자산관리 | 관리자 +

+
+ {/* 화살표 + 프로토콜 */} +
+ + HTTPS (TLS 1.2+) +
+ {/* API 서버 계층 */} +
+

API 서버 계층

+

Express 4 REST API (Port 3001)

+
+ {[ + 'JWT 인증 미들웨어', + 'RBAC 권한 엔진 (permResolver)', + '감사로그 자동 기록', + '입력 살균 / Rate Limiting / Helmet', + ].map((item) => ( +
+

{item}

+
+ ))} +
+
+ {/* 화살표 + 프로토콜 */} +
+ + pg connection pool +
+ {/* 데이터 계층 */} +
+

데이터 계층

+

PostgreSQL 16 + PostGIS

+
+ {[ + { name: 'wing DB', sub: '운영' }, + { name: 'wing_auth', sub: '인증' }, + ].map((item) => ( +
+

{item.name}

+

({item.sub})

+
+ ))} +
+
+
+
+ + {/* 2. 탭 기반 업무 모듈 구조 */} +
+

2. 탭 기반 업무 모듈 구조

+
+ + + + {['모듈', '패키지명', '기능', '주요 연계'].map((h) => ( + + ))} + + + + {TAB_MODULES.map((row) => ( + + + + + + + ))} + +
+ {h} +
{row.module}{row.name}{row.feature}{row.integration}
+
+
+ + {/* 3. RBAC 권한 체계 */} +
+

3. RBAC 권한 체계

+
+ {[ + { + title: '2차원 권한 엔진', + content: + 'AUTH_PERM OPER_CD 기반: R(조회), C(생성), U(수정), D(삭제) — 역할별 메뉴·기능 접근 제어', + }, + { + title: 'permResolver', + content: + '역할(Role)과 권한(Permission)의 2차원 매핑으로 메뉴 표시 여부 및 기능 사용 가능 여부를 동적으로 판단', + }, + { + title: '감사로그 자동 기록', + content: + '누가(사용자) / 언제(타임스탬프) / 무엇을(기능) / 어디서(IP, 메뉴) — 모든 주요 작업 자동 기록', + }, + ].map((item) => ( +
+

{item.title}

+

{item.content}

+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/TaskTable.tsx b/frontend/src/components/admin/components/contents/TaskTable.tsx new file mode 100644 index 0000000..932a85d --- /dev/null +++ b/frontend/src/components/admin/components/contents/TaskTable.tsx @@ -0,0 +1,100 @@ +import type { DeidentifyTask } from '../DeidentifyPanel'; +import { TABLE_HEADERS, getStatusBadgeClass } from '../DeidentifyPanel'; +import { ProgressBar } from './ProgressBar'; + +interface TaskTableProps { + rows: DeidentifyTask[]; + loading: boolean; + onAction: (action: string, task: DeidentifyTask) => void; +} + +export function TaskTable({ rows, loading, onAction }: TaskTableProps) { + return ( +
+ + + + {TABLE_HEADERS.map((h) => ( + + ))} + + + + {loading && rows.length === 0 + ? Array.from({ length: 5 }).map((_, i) => ( + + {TABLE_HEADERS.map((_, j) => ( + + ))} + + )) + : rows.map((row) => ( + + + + + + + + + + + ))} + +
+ {h} +
+
+
{row.id}{row.name} + {row.target} + + + {row.status} + + {row.startTime} + + {row.createdBy} +
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/TreeRow.tsx b/frontend/src/components/admin/components/contents/TreeRow.tsx new file mode 100644 index 0000000..3daf9c8 --- /dev/null +++ b/frontend/src/components/admin/components/contents/TreeRow.tsx @@ -0,0 +1,103 @@ +import type { PermTreeNode } from '@common/services/authApi'; +import type { PermState, OperCode } from '../PermissionsPanel'; +import { OPER_CODES, OPER_FULL_LABELS, makeKey } from '../PermissionsPanel'; +import { PermCell } from './PermCell'; + +interface TreeRowProps { + node: PermTreeNode; + stateMap: Map; + expanded: Set; + onToggleExpand: (code: string) => void; + onTogglePerm: (code: string, oper: OperCode, currentState: PermState) => void; + readOnly?: boolean; +} + +export function TreeRow({ + node, + stateMap, + expanded, + onToggleExpand, + onTogglePerm, + readOnly = false, +}: TreeRowProps) { + const hasChildren = node.children.length > 0; + const isExpanded = expanded.has(node.code); + const indent = node.level * 16; + + // 이 노드의 READ 상태 (CUD 비활성 판단용) + const readState = stateMap.get(makeKey(node.code, 'READ')) ?? 'forced-denied'; + const readDenied = readState === 'explicit-denied' || readState === 'forced-denied'; + + return ( + <> +
+
+ {hasChildren ? ( + + ) : ( + + {node.level > 0 ? '├' : ''} + + )} + {node.icon && {node.icon}} +
+
+ {node.name} +
+
+
+
+
+ onTogglePerm(node.code, oper, effectiveState)} + readOnly={readOnly} + /> +
+
+ + + + {OPER_CODES.map((oper) => ( + + ))} + + + + {permTree.map((rootNode) => ( + + ))} + +
+ 기능 + +
+ {OPER_LABELS[oper]} +
+
+ {OPER_FULL_LABELS[oper]} +
+
+
+ ) : ( +
+ 역할을 하나 이상 할당하면 유효 권한이 표시됩니다 +
+ )} + + ) : ( +
+ 사용자를 선택하세요 +
+ )} +
+ ); +} diff --git a/frontend/src/components/admin/components/contents/WizardModal.tsx b/frontend/src/components/admin/components/contents/WizardModal.tsx new file mode 100644 index 0000000..0a252e9 --- /dev/null +++ b/frontend/src/components/admin/components/contents/WizardModal.tsx @@ -0,0 +1,112 @@ +import { useState, useCallback } from 'react'; +import type { WizardState } from '../DeidentifyPanel'; +import { INITIAL_WIZARD } from '../DeidentifyPanel'; +import { StepIndicator } from './StepIndicator'; +import { Step1 } from './Step1'; +import { Step2 } from './Step2'; +import { Step3 } from './Step3'; +import { Step4 } from './Step4'; +import { Step5 } from './Step5'; + +interface WizardModalProps { + onClose: () => void; + onSubmit: (wizard: WizardState) => void; +} + +export function WizardModal({ onClose, onSubmit }: WizardModalProps) { + const [wizard, setWizard] = useState(INITIAL_WIZARD); + + const patch = useCallback((update: Partial) => { + setWizard((prev) => ({ ...prev, ...update })); + }, []); + + const handleNext = () => { + if (wizard.step < 5) patch({ step: wizard.step + 1 }); + }; + const handlePrev = () => { + if (wizard.step > 1) patch({ step: wizard.step - 1 }); + }; + const handleSubmit = () => { + onSubmit(wizard); + onClose(); + }; + + const canProceed = () => { + if (wizard.step === 1) return wizard.taskName.trim().length > 0; + if (wizard.step === 2) return wizard.fields.some((f) => f.selected); + if (wizard.step === 5) return wizard.confirmed; + return true; + }; + + return ( +
+
+ {/* 모달 헤더 */} +
+

새 비식별화 작업

+ +
+ + {/* 단계 표시기 */} + + + {/* 단계 내용 */} +
+ {wizard.step === 1 && } + {wizard.step === 2 && } + {wizard.step === 3 && } + {wizard.step === 4 && } + {wizard.step === 5 && } +
+ + {/* 푸터 버튼 */} +
+ +
+ + {wizard.step < 5 ? ( + + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/tabs/admin/index.ts b/frontend/src/components/admin/index.ts similarity index 100% rename from frontend/src/tabs/admin/index.ts rename to frontend/src/components/admin/index.ts diff --git a/frontend/src/tabs/admin/services/monitorApi.ts b/frontend/src/components/admin/services/monitorApi.ts similarity index 100% rename from frontend/src/tabs/admin/services/monitorApi.ts rename to frontend/src/components/admin/services/monitorApi.ts diff --git a/frontend/src/components/aerial/components/AerialTheoryView.tsx b/frontend/src/components/aerial/components/AerialTheoryView.tsx new file mode 100644 index 0000000..aee0345 --- /dev/null +++ b/frontend/src/components/aerial/components/AerialTheoryView.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react'; +import { PanelOverview } from './contents/PanelOverview'; +import { PanelDetection } from './contents/PanelDetection'; +import { PanelRemoteSensing } from './contents/PanelRemoteSensing'; +import { PanelESIMap } from './contents/PanelESIMap'; +import { PanelAreaCalc } from './contents/PanelAreaCalc'; +import { PanelSpreadModel } from './contents/PanelSpreadModel'; +import { PanelReferences } from './contents/PanelReferences'; + +const panels = [ + { id: 0, icon: '🌐', label: '개요' }, + { id: 1, icon: '🛸', label: '탐지 장비' }, + { id: 2, icon: '🛰', label: '원격탐사' }, + { id: 3, icon: '🗺️', label: 'ESI 방제지도' }, + { id: 4, icon: '📏', label: '면적 산정' }, + { id: 5, icon: '🔗', label: '확산예측 연계' }, + { id: 6, icon: '📚', label: '논문·특허' }, +]; + + +export function AerialTheoryView() { + const [activePanel, setActivePanel] = useState(0); + + return ( +
+
+ {/* 헤더 */} +
+
+
+ 📐 +
+
+
해양 항공탐색 · 원격탐사 이론
+
+ 유출유 원격탐지 · 항공감시 기법 · ESI 방제정보지도 · 등록특허 10-1567431 기반 +
+
+
+
+ + {/* 내부 네비게이션 */} +
+ {panels.map((p) => ( + + ))} +
+ + {/* 패널 */} + {activePanel === 0 && } + {activePanel === 1 && } + {activePanel === 2 && } + {activePanel === 3 && } + {activePanel === 4 && } + {activePanel === 5 && } + {activePanel === 6 && } +
+
+ ); +} diff --git a/frontend/src/tabs/aerial/components/AerialView.tsx b/frontend/src/components/aerial/components/AerialView.tsx old mode 100755 new mode 100644 similarity index 100% rename from frontend/src/tabs/aerial/components/AerialView.tsx rename to frontend/src/components/aerial/components/AerialView.tsx diff --git a/frontend/src/tabs/aerial/components/CCTVPlayer.tsx b/frontend/src/components/aerial/components/CCTVPlayer.tsx similarity index 100% rename from frontend/src/tabs/aerial/components/CCTVPlayer.tsx rename to frontend/src/components/aerial/components/CCTVPlayer.tsx diff --git a/frontend/src/tabs/aerial/components/CctvView.tsx b/frontend/src/components/aerial/components/CctvView.tsx similarity index 99% rename from frontend/src/tabs/aerial/components/CctvView.tsx rename to frontend/src/components/aerial/components/CctvView.tsx index 1cb1474..7764f06 100644 --- a/frontend/src/tabs/aerial/components/CctvView.tsx +++ b/frontend/src/components/aerial/components/CctvView.tsx @@ -3,9 +3,10 @@ import { Map, Marker, Popup } from '@vis.gl/react-maplibre'; import 'maplibre-gl/dist/maplibre-gl.css'; import { fetchCctvCameras } from '../services/aerialApi'; import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; -import { S57EncOverlay } from '@common/components/map/S57EncOverlay'; +import { S57EncOverlay } from '@components/common/map/S57EncOverlay'; import { useMapStore } from '@common/store/mapStore'; -import type { CctvCameraItem } from '../services/aerialApi'; +import { BaseMap } from '@components/common/map/BaseMap'; +import type { CctvCameraItem } from '@interfaces/aerial/AerialInterface'; import { CCTVPlayer } from './CCTVPlayer'; import type { CCTVPlayerHandle } from './CCTVPlayer'; @@ -1055,13 +1056,7 @@ export function CctvView() {
) : showMap ? (
- - + {filtered .filter((c) => c.lon && c.lat) .map((cam) => ( @@ -1221,7 +1216,7 @@ export function CctvView() {
)} - + {/* 지도 위 안내 배지 */}
( null, ); + const [previewItem, setPreviewItem] = useState(null); const modalRef = useRef(null); + const previewRef = useRef(null); + const fileInputRef = useRef(null); + const [uploadFile, setUploadFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [dragOver, setDragOver] = useState(false); + // const [uploadEquip, setUploadEquip] = useState('drone'); + // const [uploadEquipNm, setUploadEquipNm] = useState('드론 (DJI M300 RTK)'); + // const [uploadMemo, setUploadMemo] = useState(''); const loadData = useCallback(async () => { setLoading(true); @@ -82,6 +96,15 @@ export function MediaManagement() { return () => document.removeEventListener('mousedown', handler); }, [showUpload]); + useEffect(() => { + if (!previewItem) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setPreviewItem(null); + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [previewItem]); + const filtered = mediaItems.filter((f) => { if (equipFilter !== 'all' && f.equipTpCd !== equipFilter) return false; if (typeFilter.size > 0) { @@ -164,6 +187,38 @@ export function MediaManagement() { } }; + const handleUploadSubmit = async () => { + if (!uploadFile || uploading) return; + setUploading(true); + try { + await uploadAerialMedia(uploadFile, { + // equipTpCd: uploadEquip, + // equipNm: uploadEquipNm, + // memo: uploadMemo, + }); + setShowUpload(false); + setUploadFile(null); + // setUploadMemo(''); + await loadData(); + } catch { + alert('업로드 실패: 다시 시도해주세요.'); + } finally { + setUploading(false); + } + }; + + const handleFileDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files[0]; + if (file) setUploadFile(file); + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) setUploadFile(file); + }; + const droneCount = mediaItems.filter((f) => f.equipTpCd === 'drone').length; const planeCount = mediaItems.filter((f) => f.equipTpCd === 'plane').length; const satCount = mediaItems.filter((f) => f.equipTpCd === 'satellite').length; @@ -224,6 +279,12 @@ export function MediaManagement() { +
@@ -266,7 +327,7 @@ export function MediaManagement() { {/* File Table */}
-
+
@@ -332,56 +393,74 @@ export function MediaManagement() { ) : ( - sorted.map((f) => ( - toggleId(f.aerialMediaSn)} - className={`border-b border-stroke cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${ - selectedIds.has(f.aerialMediaSn) ? 'bg-[rgba(6,182,212,0.06)]' : '' - }`} - > - - - - - - - - - - - + - - )) + toggleId(f.aerialMediaSn)} + onClick={(e) => e.stopPropagation()} + className="accent-primary-blue" + /> + + + + + + + + + + + + + ); + }) )}
e.stopPropagation()}> - toggleId(f.aerialMediaSn)} - className="accent-primary-blue" - /> - {equipIcon(f.equipTpCd)} - {f.acdntSn != null ? String(f.acdntSn) : '—'} - - {f.locDc ?? '—'} - - {f.fileNm} - - - {f.equipNm} - - - - {f.mediaTpCd === '영상' ? '🎬' : '📷'} {f.mediaTpCd} - - {formatDtm(f.takngDtm)}{f.fileSz ?? '—'}{f.resolution ?? '—'} e.stopPropagation()}> -
toggleId(f.aerialMediaSn)} > - {downloadingId === f.aerialMediaSn ? '⏳' : '📥'} - -
{equipIcon(f.equipTpCd)} + {f.acdntSn != null ? String(f.acdntSn) : '—'} + + {f.locDc ?? '—'} + + {f.fileNm} + + + {f.equipNm} + + + {isPhoto ? ( + + ) : ( + + 🎬 {f.mediaTpCd} + + )} + {formatDtm(f.takngDtm)}{f.fileSz ?? '—'}{f.resolution ?? '—'} + +
@@ -398,20 +477,20 @@ export function MediaManagement() { onClick={toggleAll} className="px-3 py-1.5 text-label-2 font-semibold rounded bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover transition-colors font-korean" > - ☑ 전체선택 + 전체선택
@@ -457,28 +536,78 @@ export function MediaManagement() { className="bg-bg-surface border border-stroke rounded-md w-[480px] max-h-[80vh] overflow-y-auto p-6" >
- 📤 영상·사진 업로드 + 영상·사진 업로드
-
-
📁
-
- 파일을 드래그하거나 클릭하여 업로드 -
-
- JPG, TIFF, GeoTIFF, MP4, MOV 지원 · 최대 2GB -
+
{ + e.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={handleFileDrop} + onClick={() => fileInputRef.current?.click()} + className={`border-2 border-dashed rounded-md py-8 px-4 text-center mb-4 cursor-pointer transition-colors ${ + dragOver + ? 'border-color-accent bg-[rgba(6,182,212,0.06)]' + : uploadFile + ? 'border-[rgba(6,182,212,0.3)] bg-[rgba(6,182,212,0.04)]' + : 'border-stroke-light hover:border-[rgba(6,182,212,0.4)]' + }`} + > + + {uploadFile ? ( + <> +
+
+ {uploadFile.name} +
+
+ {(uploadFile.size / (1024 * 1024)).toFixed(2)} MB · 클릭하여 변경 +
+ + ) : ( + <> +
📁
+
+ 파일을 드래그하거나 클릭하여 업로드 +
+
+ JPG, TIFF, GeoTIFF, MP4, MOV 지원 · 최대 2GB +
+ + )}
-
+ {/*
- { + setUploadEquipNm(e.target.value); + const v = e.target.value; + if (v.startsWith('드론')) setUploadEquip('drone'); + else if (v.startsWith('유인')) setUploadEquip('plane'); + else if (v.startsWith('위성')) setUploadEquip('satellite'); + else setUploadEquip('drone'); + }} + > @@ -505,21 +634,88 @@ export function MediaManagement() {