diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index ab9c219..0f3483b 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,6 +1,6 @@ { "applied_global_version": "1.6.1", - "applied_date": "2026-03-13", + "applied_date": "2026-03-19", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true diff --git a/backend/src/map-base/mapBaseRouter.ts b/backend/src/map-base/mapBaseRouter.ts new file mode 100644 index 0000000..10058df --- /dev/null +++ b/backend/src/map-base/mapBaseRouter.ts @@ -0,0 +1,117 @@ +import { Router } from 'express' +import { requireAuth, requireRole } from '../auth/authMiddleware.js' +import { + listMapBase, + getActiveMapTypes, + createMapBase, + updateMapBase, + deleteMapBase, +} from './mapBaseService.js' + +const router = Router() + +// GET /api/map-base/active — 활성 지도 목록 (전체 사용자) +router.get('/active', requireAuth, async (_req, res) => { + try { + const types = await getActiveMapTypes() + res.json(types) + } catch (err) { + console.error('[map-base] 활성 목록 조회 오류:', err) + res.status(500).json({ error: '지도 목록 조회 중 오류가 발생했습니다.' }) + } +}) + +// GET /api/map-base — 전체 목록 (관리자) +router.get('/', requireAuth, requireRole('ADMIN'), async (req, res) => { + try { + const page = parseInt(req.query.page as string, 10) || 1 + const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100) + const result = await listMapBase(page, limit) + res.json(result) + } catch (err) { + console.error('[map-base] 목록 조회 오류:', err) + res.status(500).json({ error: '지도 목록 조회 중 오류가 발생했습니다.' }) + } +}) + +// POST /api/map-base — 등록 (관리자) +router.post('/', requireAuth, requireRole('ADMIN'), async (req, res) => { + try { + const { mapKey, mapNm, mapLevelCd, mapSrc, mapDc, useYn } = req.body as { + mapKey?: string; + mapNm?: string; + mapLevelCd?: string; + mapSrc?: string; + mapDc?: string; + useYn?: string; + } + if (!mapKey || !mapNm) { + res.status(400).json({ error: '지도 키와 이름은 필수입니다.' }) + return + } + const created = await createMapBase({ + mapKey, + mapNm, + mapLevelCd, + mapSrc, + mapDc, + useYn, + regId: req.user?.sub, + regNm: req.user?.name, + }) + res.json(created) + } catch (err) { + console.error('[map-base] 등록 오류:', err) + res.status(500).json({ error: '지도 등록 중 오류가 발생했습니다.' }) + } +}) + +// POST /api/map-base/update — 수정 (관리자) +router.post('/update', requireAuth, requireRole('ADMIN'), async (req, res) => { + try { + const { mapSn, mapKey, mapNm, mapLevelCd, mapSrc, mapDc, useYn } = req.body as { + mapSn?: number; + mapKey?: string; + mapNm?: string; + mapLevelCd?: string | null; + mapSrc?: string | null; + mapDc?: string | null; + useYn?: string; + } + if (!mapSn) { + res.status(400).json({ error: '지도 번호가 필요합니다.' }) + return + } + const updated = await updateMapBase(Number(mapSn), { mapKey, mapNm, mapLevelCd, mapSrc, mapDc, useYn }) + if (!updated) { + res.status(404).json({ error: '지도를 찾을 수 없습니다.' }) + return + } + res.json(updated) + } catch (err) { + console.error('[map-base] 수정 오류:', err) + res.status(500).json({ error: '지도 수정 중 오류가 발생했습니다.' }) + } +}) + +// POST /api/map-base/delete — 삭제 (관리자, soft-delete) +router.post('/delete', requireAuth, requireRole('ADMIN'), async (req, res) => { + try { + const { mapSn } = req.body as { mapSn?: number } + if (!mapSn) { + res.status(400).json({ error: '지도 번호가 필요합니다.' }) + return + } + const ok = await deleteMapBase(Number(mapSn)) + if (!ok) { + res.status(404).json({ error: '지도를 찾을 수 없습니다.' }) + return + } + res.json({ success: true }) + } catch (err) { + console.error('[map-base] 삭제 오류:', err) + res.status(500).json({ error: '지도 삭제 중 오류가 발생했습니다.' }) + } +}) + +export default router diff --git a/backend/src/map-base/mapBaseService.ts b/backend/src/map-base/mapBaseService.ts new file mode 100644 index 0000000..e5068a4 --- /dev/null +++ b/backend/src/map-base/mapBaseService.ts @@ -0,0 +1,140 @@ +import { wingPool } from '../db/wingDb.js' + +export interface MapBaseItem { + mapSn: number; + mapKey: string; + mapNm: string; + mapLevelCd: string | null; + mapSrc: string | null; + mapDc: string | null; + useYn: string; + regId: string | null; + regNm: string | null; + regDtm: string | null; +} + +export interface MapTypeItem { + mapKey: string; + mapNm: string; + mapLevelCd: string | null; +} + +function rowToItem(r: Record): MapBaseItem { + return { + mapSn: r.map_sn as number, + mapKey: r.map_key as string, + mapNm: r.map_nm as string, + mapLevelCd: (r.map_level_cd as string) ?? null, + mapSrc: (r.map_src as string) ?? null, + mapDc: (r.map_dc as string) ?? null, + useYn: r.use_yn as string, + regId: (r.reg_id as string) ?? null, + regNm: (r.reg_nm as string) ?? null, + regDtm: r.reg_dtm ? new Date(r.reg_dtm as string).toISOString().slice(0, 10) : null, + } +} + +export async function listMapBase( + page = 1, + limit = 20 +): Promise<{ rows: MapBaseItem[]; total: number }> { + const offset = (page - 1) * limit + const countResult = await wingPool.query(`SELECT COUNT(*) AS cnt FROM wing.MAP_BASE_DATA WHERE DEL_YN = 'N'`) + const total = parseInt(countResult.rows[0].cnt as string, 10) + + const { rows } = await wingPool.query( + `SELECT MAP_SN, MAP_KEY, MAP_NM, MAP_LEVEL_CD, MAP_SRC, MAP_DC, + USE_YN, REG_ID, REG_NM, REG_DTM + FROM wing.MAP_BASE_DATA + WHERE DEL_YN = 'N' + ORDER BY MAP_SN + LIMIT $1 OFFSET $2`, + [limit, offset] + ) + return { rows: rows.map(rowToItem), total } +} + +export async function getActiveMapTypes(): Promise { + const { rows } = await wingPool.query( + `SELECT MAP_KEY, MAP_NM, MAP_LEVEL_CD + FROM wing.MAP_BASE_DATA + WHERE USE_YN = 'Y' AND DEL_YN = 'N' + ORDER BY MAP_SN` + ) + return rows.map((r: Record) => ({ + mapKey: r.map_key as string, + mapNm: r.map_nm as string, + mapLevelCd: (r.map_level_cd as string) ?? null, + })) +} + +export async function createMapBase(data: { + mapKey: string; + mapNm: string; + mapLevelCd?: string; + mapSrc?: string; + mapDc?: string; + useYn?: string; + regId?: string; + regNm?: string; +}): Promise { + const { rows } = await wingPool.query( + `INSERT INTO wing.MAP_BASE_DATA + (MAP_KEY, MAP_NM, MAP_LEVEL_CD, MAP_SRC, MAP_DC, USE_YN, REG_ID, REG_NM) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [ + data.mapKey, + data.mapNm, + data.mapLevelCd ?? null, + data.mapSrc ?? null, + data.mapDc ?? null, + data.useYn ?? 'Y', + data.regId ?? null, + data.regNm ?? null, + ] + ) + return rowToItem(rows[0]) +} + +export async function updateMapBase( + mapSn: number, + data: { + mapKey?: string; + mapNm?: string; + mapLevelCd?: string | null; + mapSrc?: string | null; + mapDc?: string | null; + useYn?: string; + } +): Promise { + const fields: string[] = [] + const params: unknown[] = [] + let idx = 1 + + if (data.mapKey !== undefined) { fields.push(`MAP_KEY = $${idx++}`); params.push(data.mapKey) } + if (data.mapNm !== undefined) { fields.push(`MAP_NM = $${idx++}`); params.push(data.mapNm) } + if (data.mapLevelCd !== undefined) { fields.push(`MAP_LEVEL_CD = $${idx++}`); params.push(data.mapLevelCd) } + if (data.mapSrc !== undefined) { fields.push(`MAP_SRC = $${idx++}`); params.push(data.mapSrc) } + if (data.mapDc !== undefined) { fields.push(`MAP_DC = $${idx++}`); params.push(data.mapDc) } + if (data.useYn !== undefined) { fields.push(`USE_YN = $${idx++}`); params.push(data.useYn) } + + if (fields.length === 0) return null + fields.push(`MDFCN_DTM = NOW()`) + + params.push(mapSn) + const { rows } = await wingPool.query( + `UPDATE wing.MAP_BASE_DATA SET ${fields.join(', ')} WHERE MAP_SN = $${idx} RETURNING *`, + params + ) + if (rows.length === 0) return null + return rowToItem(rows[0]) +} + +export async function deleteMapBase(mapSn: number): Promise { + const { rowCount } = await wingPool.query( + `UPDATE wing.MAP_BASE_DATA SET DEL_YN = 'Y', MDFCN_DTM = NOW() WHERE MAP_SN = $1`, + [mapSn] + ) + return (rowCount ?? 0) > 0 +} diff --git a/backend/src/prediction/predictionService.ts b/backend/src/prediction/predictionService.ts index 115c4c4..6d5df40 100644 --- a/backend/src/prediction/predictionService.ts +++ b/backend/src/prediction/predictionService.ts @@ -457,6 +457,13 @@ interface SingleModelTrajectoryResult { beachedVolume: number; pollutionCoastLength: number; }; + stepSummaries: Array<{ + remainingVolume: number; + weatheredVolume: number; + pollutionArea: number; + beachedVolume: number; + pollutionCoastLength: number; + }>; centerPoints: Array<{ lat: number; lon: number; time: number; model: string }>; windData: TrajectoryWindPoint[][]; hydrData: ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]; @@ -475,6 +482,7 @@ interface TrajectoryResult { windDataByModel: Record; hydrDataByModel: Record; summaryByModel: Record; + stepSummariesByModel: Record; } function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: string): SingleModelTrajectoryResult { @@ -503,13 +511,20 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: strin : null ) .filter((p): p is { lat: number; lon: number; time: number; model: string } => p !== null); + const stepSummaries = rawResult.map((step) => ({ + remainingVolume: step.remaining_volume_m3, + weatheredVolume: step.weathered_volume_m3, + pollutionArea: step.pollution_area_km2, + beachedVolume: step.beached_volume_m3, + pollutionCoastLength: step.pollution_coast_length_m, + })); const windData = rawResult.map((step) => step.wind_data ?? []); const hydrData = rawResult.map((step) => step.hydr_data && step.hydr_grid ? { value: step.hydr_data, grid: step.hydr_grid } : null ); - return { trajectory, summary, centerPoints, windData, hydrData }; + return { trajectory, summary, stepSummaries, centerPoints, windData, hydrData }; } export async function getAnalysisTrajectory(acdntSn: number): Promise { @@ -533,6 +548,7 @@ export async function getAnalysisTrajectory(acdntSn: number): Promise = {}; const hydrDataByModel: Record = {}; const summaryByModel: Record = {}; + const stepSummariesByModel: Record = {}; // OpenDrift 우선, 없으면 POSEIDON 선택 (ORDER BY CMPL_DTM DESC이므로 첫 번째 행이 가장 최근) const opendriftRow = (rows as Array>).find((r) => r['algo_cd'] === 'OPENDRIFT'); @@ -549,6 +565,7 @@ export async function getAnalysisTrajectory(acdntSn: number): Promise { try { const { rows } = await wingPool.query( - `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' ORDER BY LAYER_CD` + `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD` ) const enrichedLayers = rows.map(enrichLayerWithMetadata) res.json(enrichedLayers) @@ -49,7 +50,7 @@ router.get('/', async (_req, res) => { router.get('/tree/all', async (_req, res) => { try { const { rows } = await wingPool.query( - `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' ORDER BY LAYER_CD` + `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD` ) const enrichedLayers = rows.map(enrichLayerWithMetadata) @@ -81,7 +82,7 @@ router.get('/tree/all', async (_req, res) => { router.get('/wms/all', async (_req, res) => { try { const { rows } = await wingPool.query( - `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE WMS_LAYER_NM IS NOT NULL AND USE_YN = 'Y' ORDER BY LAYER_CD` + `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE WMS_LAYER_NM IS NOT NULL AND USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD` ) const enrichedLayers = rows.map(enrichLayerWithMetadata) res.json(enrichedLayers) @@ -103,7 +104,7 @@ router.get('/level/:level', async (req, res) => { } const { rows } = await wingPool.query( - `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_LEVEL = $1 AND USE_YN = 'Y' ORDER BY LAYER_CD`, + `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_LEVEL = $1 AND USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`, [level] ) const enrichedLayers = rows.map(enrichLayerWithMetadata) @@ -127,7 +128,7 @@ router.get('/children/:parentId', async (req, res) => { const sanitizedId = sanitizeString(parentId) const { rows } = await wingPool.query( - `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE UP_LAYER_CD = $1 AND USE_YN = 'Y' ORDER BY LAYER_CD`, + `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE UP_LAYER_CD = $1 AND USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`, [sanitizedId] ) const enrichedLayers = rows.map(enrichLayerWithMetadata) @@ -151,7 +152,7 @@ router.get('/:id', async (req, res) => { const sanitizedId = sanitizeString(id) const { rows } = await wingPool.query( - `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_CD = $1`, + `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_CD = $1 AND DEL_YN = 'N'`, [sanitizedId] ) if (rows.length === 0) { @@ -164,4 +165,309 @@ router.get('/:id', async (req, res) => { } }) +// ── 관리자 전용 엔드포인트 ────────────────────────────────────── + +// 전체 레이어 목록 (페이지네이션 + 검색/필터, USE_YN 무관) +router.get('/admin/list', requireAuth, requireRole('ADMIN'), async (req, res) => { + try { + const page = Math.max(1, parseInt(String(req.query.page ?? '1'), 10) || 1) + const limit = Math.min(100, Math.max(1, parseInt(String(req.query.limit ?? '10'), 10) || 10)) + const offset = (page - 1) * limit + const search = sanitizeString(String(req.query.search ?? '')).trim() + const useYnFilter = String(req.query.useYn ?? '') + + // 동적 WHERE 절 구성 (DEL_YN = 'N' 기본 조건) + const conditions: string[] = ["DEL_YN = 'N'"] + const params: (string | number)[] = [] + + if (search) { + params.push(`%${search}%`) + const n = params.length + conditions.push(`(LAYER_CD ILIKE $${n} OR LAYER_NM ILIKE $${n} OR LAYER_FULL_NM ILIKE $${n})`) + } + + if (useYnFilter === 'Y' || useYnFilter === 'N') { + params.push(useYnFilter) + conditions.push(`USE_YN = $${params.length}`) + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + + // 데이터 쿼리 파라미터: WHERE 조건 + LIMIT + OFFSET + const dataParams = [...params, limit, offset] + const limitIdx = dataParams.length - 1 + const offsetIdx = dataParams.length + + const [dataResult, countResult] = await Promise.all([ + wingPool.query( + `SELECT + LAYER_CD AS "layerCd", + UP_LAYER_CD AS "upLayerCd", + LAYER_FULL_NM AS "layerFullNm", + LAYER_NM AS "layerNm", + LAYER_LEVEL AS "layerLevel", + WMS_LAYER_NM AS "wmsLayerNm", + USE_YN AS "useYn", + SORT_ORD AS "sortOrd", + TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm" + FROM LAYER + ${whereClause} + ORDER BY LAYER_CD + LIMIT $${limitIdx} OFFSET $${offsetIdx}`, + dataParams + ), + wingPool.query( + `SELECT COUNT(*)::int AS total FROM LAYER ${whereClause}`, + params + ), + ]) + + const total: number = countResult.rows[0].total + res.json({ + items: dataResult.rows, + total, + page, + totalPages: Math.ceil(total / limit), + }) + } catch { + res.status(500).json({ error: '레이어 목록 조회 실패' }) + } +}) + +// 드롭다운용 레이어 옵션 목록 +router.get('/admin/options', requireAuth, requireRole('ADMIN'), async (_req, res) => { + try { + const { rows } = await wingPool.query( + `SELECT LAYER_CD AS "layerCd", LAYER_NM AS "layerNm", + LAYER_FULL_NM AS "layerFullNm", LAYER_LEVEL AS "layerLevel" + FROM LAYER WHERE DEL_YN = 'N' ORDER BY LAYER_CD` + ) + res.json(rows) + } catch { + res.status(500).json({ error: '레이어 옵션 조회 실패' }) + } +}) + +// 상위코드 기반 다음 자식 코드 계산 +router.get('/admin/next-code', requireAuth, requireRole('ADMIN'), async (req, res) => { + const upLayerCd = sanitizeString(String(req.query.upLayerCd ?? '')).trim() + if (!upLayerCd || !/^[a-zA-Z0-9_-]+$/.test(upLayerCd) || !isValidStringLength(upLayerCd, 50)) { + return res.status(400).json({ error: '유효하지 않은 상위 레이어코드' }) + } + try { + const { rows } = await wingPool.query( + `SELECT LAYER_CD AS "layerCd" FROM LAYER + WHERE UP_LAYER_CD = $1 AND DEL_YN = 'N' + ORDER BY LAYER_CD DESC LIMIT 1`, + [upLayerCd] + ) + let nextCode: string + if (rows.length === 0) { + nextCode = upLayerCd + '001' + } else { + const lastCd = rows[0].layerCd as string + const suffix = lastCd.substring(upLayerCd.length) + const num = parseInt(suffix, 10) + nextCode = isNaN(num) + ? upLayerCd + '001' + : upLayerCd + String(num + 1).padStart(suffix.length, '0') + } + res.json({ nextCode }) + } catch { + res.status(500).json({ error: '다음 레이어코드 계산 실패' }) + } +}) + +// 레이어 생성 +router.post('/admin/create', requireAuth, requireRole('ADMIN'), async (req, res) => { + try { + const body = req.body as { + layerCd?: string + upLayerCd?: string + layerFullNm?: string + layerNm?: string + layerLevel?: number + wmsLayerNm?: string + useYn?: string + sortOrd?: number + } + + const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, useYn, sortOrd } = body + + // 필수 필드 검증 + if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) { + return res.status(400).json({ error: '유효하지 않은 레이어코드입니다. 영숫자, 언더스코어, 하이픈만 허용됩니다.' }) + } + if (!layerNm || !isValidStringLength(layerNm, 100)) { + return res.status(400).json({ error: '레이어명은 필수이며 100자 이내여야 합니다.' }) + } + if (!layerFullNm || !isValidStringLength(layerFullNm, 200)) { + return res.status(400).json({ error: '레이어 전체명은 필수이며 200자 이내여야 합니다.' }) + } + if (layerLevel === undefined || layerLevel === null || !isValidNumber(layerLevel, 1, 10)) { + return res.status(400).json({ error: '레이어 레벨은 1~10 범위의 정수여야 합니다.' }) + } + + // 선택 필드 검증 + if (upLayerCd !== undefined && upLayerCd !== null && upLayerCd !== '') { + if (!isValidStringLength(upLayerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(upLayerCd)) { + return res.status(400).json({ error: '유효하지 않은 상위 레이어코드입니다.' }) + } + } + if (wmsLayerNm !== undefined && wmsLayerNm !== null && wmsLayerNm !== '') { + if (!isValidStringLength(wmsLayerNm, 100)) { + return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' }) + } + } + + const sanitizedLayerCd = sanitizeString(layerCd) + const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null + const sanitizedLayerFullNm = sanitizeString(layerFullNm) + const sanitizedLayerNm = sanitizeString(layerNm) + const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null + const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y' + const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null + + const { rows } = await wingPool.query( + `INSERT INTO LAYER (LAYER_CD, UP_LAYER_CD, LAYER_FULL_NM, LAYER_NM, LAYER_LEVEL, WMS_LAYER_NM, USE_YN, SORT_ORD, DEL_YN) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'N') + RETURNING LAYER_CD AS "layerCd"`, + [sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedUseYn, sanitizedSortOrd] + ) + + res.json(rows[0]) + } catch (err) { + const pgErr = err as { code?: string } + if (pgErr.code === '23505') { + return res.status(409).json({ error: '이미 존재하는 레이어코드입니다.' }) + } + res.status(500).json({ error: '레이어 생성 실패' }) + } +}) + +// 레이어 수정 +router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res) => { + try { + const body = req.body as { + layerCd?: string + upLayerCd?: string + layerFullNm?: string + layerNm?: string + layerLevel?: number + wmsLayerNm?: string + useYn?: string + sortOrd?: number + } + + const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, useYn, sortOrd } = body + + // 필수 필드 검증 + if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) { + return res.status(400).json({ error: '유효하지 않은 레이어코드입니다. 영숫자, 언더스코어, 하이픈만 허용됩니다.' }) + } + if (!layerNm || !isValidStringLength(layerNm, 100)) { + return res.status(400).json({ error: '레이어명은 필수이며 100자 이내여야 합니다.' }) + } + if (!layerFullNm || !isValidStringLength(layerFullNm, 200)) { + return res.status(400).json({ error: '레이어 전체명은 필수이며 200자 이내여야 합니다.' }) + } + if (layerLevel === undefined || layerLevel === null || !isValidNumber(layerLevel, 1, 10)) { + return res.status(400).json({ error: '레이어 레벨은 1~10 범위의 정수여야 합니다.' }) + } + + // 선택 필드 검증 + if (upLayerCd !== undefined && upLayerCd !== null && upLayerCd !== '') { + if (!isValidStringLength(upLayerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(upLayerCd)) { + return res.status(400).json({ error: '유효하지 않은 상위 레이어코드입니다.' }) + } + } + if (wmsLayerNm !== undefined && wmsLayerNm !== null && wmsLayerNm !== '') { + if (!isValidStringLength(wmsLayerNm, 100)) { + return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' }) + } + } + + const sanitizedLayerCd = sanitizeString(layerCd) + const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null + const sanitizedLayerFullNm = sanitizeString(layerFullNm) + const sanitizedLayerNm = sanitizeString(layerNm) + const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null + const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y' + const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null + + const { rows } = await wingPool.query( + `UPDATE LAYER + SET UP_LAYER_CD = $2, LAYER_FULL_NM = $3, LAYER_NM = $4, LAYER_LEVEL = $5, + WMS_LAYER_NM = $6, USE_YN = $7, SORT_ORD = $8 + WHERE LAYER_CD = $1 + RETURNING LAYER_CD AS "layerCd"`, + [sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedUseYn, sanitizedSortOrd] + ) + + if (rows.length === 0) { + return res.status(404).json({ error: '레이어를 찾을 수 없습니다' }) + } + res.json(rows[0]) + } catch (err) { + const pgErr = err as { code?: string } + if (pgErr.code === '23505') { + return res.status(409).json({ error: '이미 존재하는 레이어코드입니다.' }) + } + res.status(500).json({ error: '레이어 수정 실패' }) + } +}) + +// 레이어 삭제 +router.post('/admin/delete', requireAuth, requireRole('ADMIN'), async (req, res) => { + try { + const { layerCd } = req.body as { layerCd?: string } + + if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) { + return res.status(400).json({ error: '유효하지 않은 레이어코드입니다.' }) + } + + const sanitizedCd = sanitizeString(layerCd) + + const { rows } = await wingPool.query( + `UPDATE LAYER SET DEL_YN = 'Y' WHERE LAYER_CD = $1 AND DEL_YN = 'N' + RETURNING LAYER_CD AS "layerCd"`, + [sanitizedCd] + ) + + if (rows.length === 0) { + return res.status(404).json({ error: '레이어를 찾을 수 없습니다' }) + } + res.json(rows[0]) + } catch { + res.status(500).json({ error: '레이어 삭제 실패' }) + } +}) + +// USE_YN 토글 +router.post('/admin/toggle-use', requireAuth, requireRole('ADMIN'), async (req, res) => { + try { + const { layerCd } = req.body as { layerCd?: string } + + if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) { + return res.status(400).json({ error: '유효하지 않은 레이어코드' }) + } + + const sanitizedCd = sanitizeString(layerCd) + const { rows } = await wingPool.query( + `UPDATE LAYER + SET USE_YN = CASE WHEN USE_YN = 'Y' THEN 'N' ELSE 'Y' END + WHERE LAYER_CD = $1 + RETURNING LAYER_CD AS "layerCd", USE_YN AS "useYn"`, + [sanitizedCd] + ) + + if (rows.length === 0) { + return res.status(404).json({ error: '레이어를 찾을 수 없습니다' }) + } + res.json(rows[0]) + } catch { + res.status(500).json({ error: 'USE_YN 변경 실패' }) + } +}) + export default router diff --git a/backend/src/server.ts b/backend/src/server.ts index d406340..8e48bdb 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -22,6 +22,7 @@ import scatRouter from './scat/scatRouter.js' import predictionRouter from './prediction/predictionRouter.js' import aerialRouter from './aerial/aerialRouter.js' import rescueRouter from './rescue/rescueRouter.js' +import mapBaseRouter from './map-base/mapBaseRouter.js' import { sanitizeBody, sanitizeQuery, @@ -168,6 +169,7 @@ app.use('/api/scat', scatRouter) app.use('/api/prediction', predictionRouter) app.use('/api/aerial', aerialRouter) app.use('/api/rescue', rescueRouter) +app.use('/api/map-base', mapBaseRouter) // 헬스 체크 app.get('/health', (_req, res) => { diff --git a/database/migration/001_layer_table.sql b/database/migration/001_layer_table.sql index 1d43b13..bf507dc 100644 --- a/database/migration/001_layer_table.sql +++ b/database/migration/001_layer_table.sql @@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS LAYER ( USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 사용여부 SORT_ORD INTEGER DEFAULT 0, -- 정렬순서 REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시 + DEL_YN CHAR(1) DEFAULT 'N' NOT NULL, CONSTRAINT PK_LAYER PRIMARY KEY (LAYER_CD), CONSTRAINT FK_LAYER_UP FOREIGN KEY (UP_LAYER_CD) REFERENCES LAYER(LAYER_CD), CONSTRAINT CK_LAYER_USE_YN CHECK (USE_YN IN ('Y', 'N')) diff --git a/database/migration/022_map_base.sql b/database/migration/022_map_base.sql new file mode 100644 index 0000000..1d42c72 --- /dev/null +++ b/database/migration/022_map_base.sql @@ -0,0 +1,43 @@ +-- ============================================================ +-- 022_map_base.sql — 지도 백데이터 관리 테이블 +-- ============================================================ +-- 관리자가 등록한 지도 유형(S-57, S-101, 3D 등)을 DB로 관리하며, +-- USE_YN 으로 활성/비활성을 제어해 TopBar 햄버거 메뉴 노출을 조정한다. +-- ============================================================ + +SET search_path TO wing; + +-- ============================================================ +-- 1. 지도 백데이터 마스터 +-- ============================================================ +CREATE TABLE IF NOT EXISTS MAP_BASE_DATA ( + MAP_SN SERIAL PRIMARY KEY, + MAP_KEY VARCHAR(30) NOT NULL UNIQUE, -- 지도 식별 키 (s57, s101, threeD, satellite 등) + MAP_NM VARCHAR(100) NOT NULL, -- 지도 표시명 (예: S-57 전자해도) + MAP_LEVEL_CD VARCHAR(20), -- 지도 레벨 코드: S-52 | S-57 | S-101 | 3D | SAT | 기타 + MAP_SRC TEXT, -- 타일 URL 또는 파일 경로 + MAP_DC TEXT, -- 상세 설명 + USE_YN CHAR(1) DEFAULT 'Y', -- 사용 여부: Y(사용중) / N(미사용) + DEL_YN CHAR(1) DEFAULT 'N', -- 삭제 여부: Y(삭제됨) / N(정상) + REG_ID VARCHAR(50), -- 등록자 ID + REG_NM VARCHAR(50), -- 등록자 이름 + REG_DTM TIMESTAMPTZ DEFAULT NOW(), -- 등록 일시 + MDFCN_DTM TIMESTAMPTZ DEFAULT NOW() -- 수정 일시 +); + +-- ============================================================ +-- 2. 인덱스 +-- ============================================================ +CREATE INDEX IF NOT EXISTS IDX_MAP_BASE_USE ON MAP_BASE_DATA(USE_YN); +CREATE INDEX IF NOT EXISTS IDX_MAP_BASE_KEY ON MAP_BASE_DATA(MAP_KEY); +CREATE INDEX IF NOT EXISTS IDX_MAP_BASE_DEL ON MAP_BASE_DATA(DEL_YN); + +-- ============================================================ +-- 3. 초기 데이터 — 기존 하드코딩 4개 지도 유형 +-- ============================================================ +INSERT INTO MAP_BASE_DATA (MAP_KEY, MAP_NM, MAP_LEVEL_CD, USE_YN) VALUES + ('s57', 'S-57 전자해도', 'S-57', 'Y'), + ('s101', 'S-101 전자해도', 'S-101', 'Y'), + ('threeD', '3D 지도', '3D', 'Y'), + ('satellite', '위성 영상', 'SAT', 'Y') +ON CONFLICT (MAP_KEY) DO NOTHING; diff --git a/database/migration/023_layer_del_yn.sql b/database/migration/023_layer_del_yn.sql new file mode 100644 index 0000000..d54dfe1 --- /dev/null +++ b/database/migration/023_layer_del_yn.sql @@ -0,0 +1,10 @@ +-- 023_layer_del_yn.sql +-- LAYER 테이블에 소프트 삭제 플래그 추가 + +ALTER TABLE LAYER ADD COLUMN IF NOT EXISTS DEL_YN CHAR(1) DEFAULT 'N' NOT NULL; + +-- 기존 데이터 초기화 +UPDATE LAYER SET DEL_YN = 'N' WHERE DEL_YN IS NULL; + +-- 인덱스 +CREATE INDEX IF NOT EXISTS IDX_LAYER_DEL ON LAYER (DEL_YN); diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 4d3c4f5..46d21af 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,24 +4,29 @@ ## [Unreleased] +## [2026-03-19.2] + ### 추가 +- 관리자: 지도 베이스 관리 패널, 레이어 패널 추가 및 보고서 기능 개선 - 항공 방제: WingAI (AI 탐지/분석) 서브탭 추가 - 항공 방제: UP42 위성 패스 조회 + 궤도 지도 표시 -- 항공 방제: 위성 요청 취소 기능 -- 항공 방제: 위성 히스토리 지도에 캘린더 + 날짜별 촬영 리스트 + 영상 오버레이 +- 항공 방제: 위성 요청 취소 기능 추가 - 항공 방제: 위성 요청 목록/히스토리 지도 탭 분리 +- 항공 방제: 위성 히스토리 지도에 캘린더 + 날짜별 촬영 리스트 + 영상 오버레이 - 항공 방제: 완료 촬영 클릭 시 VWorld 위성 영상 오버레이 표시 -- 사건/사고: 오염물 배출규정 기능 추가 -- Pre-SCAT 해안조사 UI 개선 +- 항공 방제: 위성 요청 목록 더보기 → 페이징 처리로 변경 +- 사고관리: UI 개선 + 오염물 배출규정 기능 추가 +- Pre-SCAT 해안조사 UI 개선 + WeatherRightPanel 정리 ### 수정 - 항공 방제: UP42 모달 지도 크기 탭별 동일하게 고정 -- 항공 방제: 촬영 히스토리 지도 리스트 위치 좌하단 이동 +- 항공 방제: 촬영 히스토리 지도 리스트 위치 좌하단으로 이동 ### 변경 -- 기상 패널 레이어 체크박스/글자 사이즈 조정 -- 위성 요청 목록 더보기 → 페이징 처리로 변경 -- WeatherRightPanel 중복 코드 정리 +- 기상: 지역별 기상정보 패널 글자 사이즈 조정 + 시각화 개선 + +### 기타 +- 기상 탭 머지 충돌 해결 ## [2026-03-19] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eb46896..8283fa6 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { LoginPage } from '@common/components/auth/LoginPage' import { registerMainTabSwitcher } from '@common/hooks/useSubMenu' import { useAuthStore } from '@common/store/authStore' import { useMenuStore } from '@common/store/menuStore' +import { useMapStore } from '@common/store/mapStore' import { API_BASE_URL } from '@common/services/api' import { OilSpillView } from '@tabs/prediction' import { ReportsView } from '@tabs/reports' @@ -25,6 +26,7 @@ function App() { const [activeMainTab, setActiveMainTab] = useState('prediction') const { isAuthenticated, isLoading, checkSession } = useAuthStore() const { loadMenuConfig } = useMenuStore() + const { loadMapTypes } = useMapStore() useEffect(() => { checkSession() @@ -33,8 +35,9 @@ function App() { useEffect(() => { if (isAuthenticated) { loadMenuConfig() + loadMapTypes() } - }, [isAuthenticated, loadMenuConfig]) + }, [isAuthenticated, loadMenuConfig, loadMapTypes]) useEffect(() => { registerMainTabSwitcher(setActiveMainTab) diff --git a/frontend/src/common/components/layout/TopBar.tsx b/frontend/src/common/components/layout/TopBar.tsx index 16a01c7..c0faf06 100755 --- a/frontend/src/common/components/layout/TopBar.tsx +++ b/frontend/src/common/components/layout/TopBar.tsx @@ -16,7 +16,7 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) { const quickMenuRef = useRef(null) const { hasPermission, user, logout } = useAuthStore() const { menuConfig, isLoaded } = useMenuStore() - const { mapToggles, toggleMap, measureMode, setMeasureMode } = useMapStore() + const { mapToggles, toggleMap, mapTypes, measureMode, setMeasureMode } = useMapStore() const MAP_TABS = new Set(['prediction', 'hns', 'scat', 'weather']) const isMapTab = MAP_TABS.has(activeTab) @@ -218,18 +218,13 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
🗺 지도 유형
- {([ - { key: 's57' as const, label: 'S-57 전자해도', icon: '🗺' }, - { key: 's101' as const, label: 'S-101 전자해도', icon: '🗺' }, - { key: 'threeD' as const, label: '3D 지도', icon: '🗺' }, - { key: 'satellite' as const, label: '위성 영상', icon: '🛰' }, - ]).map(item => ( - ))} diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 140504e..36a2bc1 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -1236,7 +1236,7 @@ export function MapView({ ]) // 3D 모드 / 밝은 톤에 따른 지도 스타일 전환 - const currentMapStyle = mapToggles.threeD ? SATELLITE_3D_STYLE : lightMode ? LIGHT_STYLE : BASE_STYLE + const currentMapStyle = mapToggles['threeD'] ? SATELLITE_3D_STYLE : lightMode ? LIGHT_STYLE : BASE_STYLE return (
@@ -1258,7 +1258,7 @@ export function MapView({ {/* 지도 중앙 좌표 + 줌 추적 */} {/* 3D 모드 pitch 제어 */} - + {/* 사고 지점 변경 시 지도 이동 */} {/* 외부에서 flyTo 트리거 */} diff --git a/frontend/src/common/hooks/useSubMenu.ts b/frontend/src/common/hooks/useSubMenu.ts index af37161..af07f7f 100755 --- a/frontend/src/common/hooks/useSubMenu.ts +++ b/frontend/src/common/hooks/useSubMenu.ts @@ -212,6 +212,13 @@ export interface OilReportPayload { coastal: { firstTime: string | null; }; + spreadSteps?: Array<{ + elapsed: string; + weathered: string; + seaRemain: string; + coastAttach: string; + area: string; + }>; hasSimulation: boolean; mapData: { center: [number, number]; diff --git a/frontend/src/common/store/mapStore.ts b/frontend/src/common/store/mapStore.ts index aaa7821..1c48250 100644 --- a/frontend/src/common/store/mapStore.ts +++ b/frontend/src/common/store/mapStore.ts @@ -1,11 +1,11 @@ import { create } from 'zustand' +import { api } from '../services/api' import { haversineDistance, polygonAreaKm2 } from '../utils/geo' -interface MapToggles { - s57: boolean; - s101: boolean; - threeD: boolean; - satellite: boolean; +export interface MapTypeItem { + mapKey: string; + mapNm: string; + mapLevelCd: string | null; } export type MeasureMode = 'distance' | 'area' | null; @@ -22,9 +22,18 @@ export interface MeasureResult { value: number; // distance(m) or area(km²) } +interface MapToggles { + s57: boolean; + s101: boolean; + threeD: boolean; + satellite: boolean; +} + interface MapState { mapToggles: MapToggles; + mapTypes: MapTypeItem[]; toggleMap: (key: keyof MapToggles) => void; + loadMapTypes: () => Promise; // 측정 measureMode: MeasureMode; measureInProgress: MeasurePoint[]; @@ -36,14 +45,42 @@ interface MapState { clearAllMeasurements: () => void; } +const DEFAULT_MAP_TYPES: MapTypeItem[] = [ + { mapKey: 's57', mapNm: 'S-57 전자해도', mapLevelCd: 'S-57' }, + { mapKey: 's101', mapNm: 'S-101 전자해도', mapLevelCd: 'S-101' }, + { mapKey: 'threeD', mapNm: '3D 지도', mapLevelCd: '3D' }, + { mapKey: 'satellite', mapNm: '위성 영상', mapLevelCd: 'SAT' }, +] + let measureIdCounter = 0; export const useMapStore = create((set, get) => ({ mapToggles: { s57: true, s101: false, threeD: false, satellite: false }, + mapTypes: DEFAULT_MAP_TYPES, toggleMap: (key) => set((s) => ({ mapToggles: { ...s.mapToggles, [key]: !s.mapToggles[key] }, })), + loadMapTypes: async () => { + try { + const res = await api.get('/map-base/active') + const types = res.data + const current = get().mapToggles + const newToggles: Partial = {} + for (const t of types) { + if (t.mapKey in current) { + newToggles[t.mapKey as keyof MapToggles] = current[t.mapKey as keyof MapToggles] ?? false + } + } + // s57 기본값 유지 + if (newToggles['s57'] === undefined && types.find(t => t.mapKey === 's57')) { + newToggles['s57'] = true + } + set({ mapTypes: types, mapToggles: { ...current, ...newToggles } }) + } catch { + // API 실패 시 fallback 유지 + } + }, // 측정 measureMode: null, diff --git a/frontend/src/tabs/admin/components/AdminView.tsx b/frontend/src/tabs/admin/components/AdminView.tsx index 3fe69c8..db84512 100755 --- a/frontend/src/tabs/admin/components/AdminView.tsx +++ b/frontend/src/tabs/admin/components/AdminView.tsx @@ -10,6 +10,8 @@ import BoardMgmtPanel from './BoardMgmtPanel'; import VesselSignalPanel from './VesselSignalPanel'; import CleanupEquipPanel from './CleanupEquipPanel'; import AssetUploadPanel from './AssetUploadPanel'; +import MapBasePanel from './MapBasePanel'; +import LayerPanel from './LayerPanel'; /** 기존 패널이 있는 메뉴 ID 매핑 */ const PANEL_MAP: Record JSX.Element> = { @@ -23,6 +25,8 @@ const PANEL_MAP: Record JSX.Element> = { 'collect-vessel-signal': () => , 'cleanup-equip': () => , 'asset-upload': () => , + 'map-base': () => , + 'map-layer': () => , }; export function AdminView() { diff --git a/frontend/src/tabs/admin/components/LayerPanel.tsx b/frontend/src/tabs/admin/components/LayerPanel.tsx new file mode 100644 index 0000000..c224f05 --- /dev/null +++ b/frontend/src/tabs/admin/components/LayerPanel.tsx @@ -0,0 +1,620 @@ +import { useEffect, useState, useCallback } from 'react'; +import { api } from '@common/services/api'; + +interface LayerAdminItem { + layerCd: string; + upLayerCd: string | null; + layerFullNm: string; + layerNm: string; + layerLevel: number; + wmsLayerNm: string | null; + useYn: string; + sortOrd: number; + regDtm: string | null; +} + +interface LayerListResponse { + items: LayerAdminItem[]; + total: number; + page: number; + totalPages: number; +} + +interface LayerOption { + layerCd: string; + layerNm: string; + layerFullNm: string; + layerLevel: number; +} + +interface LayerFormData { + layerCd: string; + upLayerCd: string; + layerFullNm: string; + layerNm: string; + layerLevel: number; + wmsLayerNm: string; + useYn: string; + sortOrd: number; +} + +const PAGE_SIZE = 10; + +async function fetchLayers(page: number, search: string, useYn: string): Promise { + const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE) }); + if (search) params.set('search', search); + if (useYn) params.set('useYn', useYn); + const res = await api.get(`/layers/admin/list?${params}`); + return res.data; +} + +async function fetchLayerOptions(): Promise { + const res = await api.get('/layers/admin/options'); + return res.data; +} + +async function toggleLayerUse(layerCd: string): Promise<{ layerCd: string; useYn: string }> { + const res = await api.post<{ layerCd: string; useYn: string }>('/layers/admin/toggle-use', { layerCd }); + return res.data; +} + +async function createLayer(body: LayerFormData): Promise { + await api.post('/layers/admin/create', body); +} + +async function updateLayer(body: LayerFormData): Promise { + await api.post('/layers/admin/update', body); +} + +async function deleteLayer(layerCd: string): Promise { + await api.post('/layers/admin/delete', { layerCd }); +} + +async function fetchNextLayerCode(upLayerCd: string): Promise { + const params = new URLSearchParams({ upLayerCd }); + const res = await api.get<{ nextCode: string }>(`/layers/admin/next-code?${params}`); + return res.data.nextCode; +} + +// ---------- LayerFormModal ---------- + +interface LayerFormModalProps { + mode: 'create' | 'edit'; + initialData?: LayerAdminItem; + onClose: () => void; + onSaved: () => void; +} + +const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalProps) => { + const [form, setForm] = useState({ + layerCd: initialData?.layerCd ?? '', + upLayerCd: initialData?.upLayerCd ?? '', + layerFullNm: initialData?.layerFullNm ?? '', + layerNm: initialData?.layerNm ?? '', + layerLevel: initialData?.layerLevel ?? 1, + wmsLayerNm: initialData?.wmsLayerNm ?? '', + useYn: initialData?.useYn ?? 'Y', + sortOrd: initialData?.sortOrd ?? 0, + }); + const [options, setOptions] = useState([]); + const [saving, setSaving] = useState(false); + const [formError, setFormError] = useState(null); + const [parentInfo, setParentInfo] = useState<{ fullNm: string; level: number } | null>(null); + + useEffect(() => { + fetchLayerOptions().then(setOptions).catch(() => {}); + }, []); + + const handleField = (key: K, value: LayerFormData[K]) => { + setForm(prev => ({ ...prev, [key]: value })); + }; + + const handleParentChange = async (upLayerCd: string) => { + if (!upLayerCd) { + setParentInfo(null); + setForm(prev => ({ ...prev, upLayerCd: '' })); + return; + } + const parent = options.find(o => o.layerCd === upLayerCd); + if (parent) { + setParentInfo({ fullNm: parent.layerFullNm, level: parent.layerLevel }); + setForm(prev => ({ + ...prev, + upLayerCd, + layerLevel: parent.layerLevel + 1, + layerFullNm: prev.layerNm ? `${parent.layerFullNm} ${prev.layerNm}` : parent.layerFullNm, + })); + } + try { + const nextCode = await fetchNextLayerCode(upLayerCd); + setForm(prev => ({ ...prev, layerCd: nextCode })); + } catch { /* 실패 시 사용자 수동 입력 */ } + }; + + const handleLayerNmChange = (value: string) => { + setForm(prev => ({ + ...prev, + layerNm: value, + ...(parentInfo && { + layerFullNm: value ? `${parentInfo.fullNm} ${value}` : parentInfo.fullNm, + }), + })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!form.layerCd.trim()) { setFormError('레이어코드는 필수입니다.'); return; } + if (!form.layerNm.trim()) { setFormError('레이어명은 필수입니다.'); return; } + if (!form.layerFullNm.trim()) { setFormError('레이어전체명은 필수입니다.'); return; } + setSaving(true); + setFormError(null); + try { + if (mode === 'create') { + await createLayer(form); + } else { + await updateLayer(form); + } + onSaved(); + onClose(); + } catch (err: unknown) { + const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; + setFormError(msg ?? (mode === 'create' ? '등록에 실패했습니다.' : '수정에 실패했습니다.')); + } finally { + setSaving(false); + } + }; + + const inputCls = 'w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none'; + const labelCls = 'block text-[11px] font-semibold text-text-2 font-korean mb-1.5'; + + return ( +
+
+ {/* 헤더 */} +
+

+ {mode === 'create' ? '레이어 등록' : '레이어 수정'} +

+ +
+ {/* 폼 */} +
+
+ {/* 상위 레이어코드 */} +
+ + +
+ {/* 레이어코드 */} +
+ + handleField('layerCd', e.target.value)} + readOnly={mode === 'edit'} + placeholder="예: LAYER_001" + className={`${inputCls} font-mono${mode === 'edit' ? ' bg-bg-0 text-text-3 cursor-not-allowed' : ''}`} + /> +
+ {/* 레이어명 */} +
+ + mode === 'create' ? handleLayerNmChange(e.target.value) : handleField('layerNm', e.target.value)} + maxLength={100} + placeholder="레이어 이름" + className={inputCls} + /> +
+ {/* 레이어전체명 */} +
+ + handleField('layerFullNm', e.target.value)} + maxLength={200} + placeholder="레이어 전체 경로명" + className={inputCls} + /> +
+ {/* 레벨 */} +
+ + handleField('layerLevel', Number(e.target.value))} + min={1} + max={10} + className={inputCls} + /> +
+ {/* WMS레이어명 */} +
+ + handleField('wmsLayerNm', e.target.value)} + placeholder="WMS 레이어명 (선택)" + className={`${inputCls} font-mono`} + /> +
+ {/* 정렬순서 */} +
+ + handleField('sortOrd', Number(e.target.value))} + className={inputCls} + /> +
+ {/* 사용여부 */} +
+ + +
+
+ {/* 에러 */} + {formError && ( +
+

{formError}

+
+ )} + {/* 버튼 */} +
+ + +
+
+
+
+ ); +}; + +// ---------- LayerPanel ---------- + +const LayerPanel = () => { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [totalPages, setTotalPages] = useState(1); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [toggling, setToggling] = useState(null); + const [error, setError] = useState(null); + + // 검색 + const [searchInput, setSearchInput] = useState(''); + const [appliedSearch, setAppliedSearch] = useState(''); + const [filterUseYn, setFilterUseYn] = useState(''); + + // 모달 + const [modal, setModal] = useState<{ mode: 'create' | 'edit'; data?: LayerAdminItem } | null>(null); + + const load = useCallback(async (p: number, search: string, useYn: string) => { + setLoading(true); + setError(null); + try { + const res = await fetchLayers(p, search, useYn); + setItems(res.items); + setTotal(res.total); + setTotalPages(res.totalPages); + } catch { + setError('레이어 목록을 불러오지 못했습니다.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(page, appliedSearch, filterUseYn); + }, [load, page, appliedSearch, filterUseYn]); + + const handleSearch = () => { + setAppliedSearch(searchInput); + setPage(1); + }; + + const handleToggle = async (layerCd: string) => { + if (toggling) return; + setToggling(layerCd); + try { + const result = await toggleLayerUse(layerCd); + setItems(prev => + prev.map(item => + item.layerCd === result.layerCd ? { ...item, useYn: result.useYn } : item + ) + ); + } catch { + setError('사용여부 변경에 실패했습니다.'); + } finally { + setToggling(null); + } + }; + + const handleDelete = async (layerCd: string) => { + if (!confirm(`레이어 [${layerCd}]를 삭제하시겠습니까?`)) return; + try { + await deleteLayer(layerCd); + load(page, appliedSearch, filterUseYn); + } catch (err: unknown) { + const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; + setError(msg ?? '레이어 삭제에 실패했습니다.'); + } + }; + + const buildPageButtons = () => { + const buttons: (number | 'ellipsis')[] = []; + const delta = 2; + const left = page - delta; + const right = page + delta; + + for (let i = 1; i <= totalPages; i++) { + if (i === 1 || i === totalPages || (i >= left && i <= right)) { + buttons.push(i); + } else if (buttons[buttons.length - 1] !== 'ellipsis') { + buttons.push('ellipsis'); + } + } + return buttons; + }; + + return ( +
+ {/* 헤더 */} +
+
+
+

레이어 관리

+

총 {total}개

+
+ +
+
+ setSearchInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleSearch()} + placeholder="레이어코드 / 레이어명 검색" + className="flex-1 px-3 py-1.5 text-xs bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" + /> + + +
+
+ + {/* 오류 메시지 */} + {error && ( +
+ {error} +
+ )} + + {/* 테이블 영역 */} +
+ {loading ? ( +
+ 불러오는 중... +
+ ) : ( + + + + + + + + + + + + + + + + + {items.length === 0 ? ( + + + + ) : ( + items.map((item, idx) => ( + + {/* 번호 */} + + {/* 레이어코드 */} + + {/* 레이어명 */} + + {/* 레이어전체명 */} + + {/* 레벨 */} + + {/* WMS레이어명 */} + + {/* 정렬순서 */} + + {/* 등록일시 */} + + {/* 사용여부 토글 */} + + {/* 액션 */} + + + )) + )} + +
번호레이어코드레이어명레이어전체명레벨WMS레이어명정렬등록일시사용여부액션
+ 데이터가 없습니다. +
+ {(page - 1) * PAGE_SIZE + idx + 1} + + {item.layerCd} + + {item.layerNm} + + + {item.layerFullNm} + + + + {item.layerLevel} + + + {item.wmsLayerNm ?? -} + + {item.sortOrd} + + {item.regDtm ?? '-'} + + + +
+ + +
+
+ )} +
+ + {/* 페이지네이션 */} + {!loading && totalPages > 1 && ( +
+ + {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} / {total}개 + +
+ + {buildPageButtons().map((btn, i) => + btn === 'ellipsis' ? ( + + ) : ( + + ) + )} + +
+
+ )} + + {/* 모달 */} + {modal && ( + setModal(null)} + onSaved={() => load(page, appliedSearch, filterUseYn)} + /> + )} +
+ ); +}; + +export default LayerPanel; diff --git a/frontend/src/tabs/admin/components/MapBasePanel.tsx b/frontend/src/tabs/admin/components/MapBasePanel.tsx new file mode 100644 index 0000000..b274629 --- /dev/null +++ b/frontend/src/tabs/admin/components/MapBasePanel.tsx @@ -0,0 +1,505 @@ +import { useState, useEffect } from 'react'; +import { api } from '@common/services/api'; +import { useMapStore } from '@common/store/mapStore'; + +// ─── 타입 ───────────────────────────────────────────────── +interface MapBaseItem { + mapSn: number; + mapKey: string; + mapNm: string; + mapLevelCd: string | null; + mapSrc: string | null; + mapDc: string | null; + useYn: string; + regId: string | null; + regNm: string | null; + regDtm: string | null; +} + +interface MapBaseForm { + mapKey: string; + mapNm: string; + mapLevelCd: string; + mapSrc: string; + mapDc: string; + useYn: string; +} + +interface Message { + type: 'success' | 'error'; + text: string; +} + +// ─── 상수 ───────────────────────────────────────────────── +const EMPTY_FORM: MapBaseForm = { + mapKey: '', + mapNm: '', + mapLevelCd: '', + mapSrc: '', + mapDc: '', + useYn: 'Y', +}; + +const MAP_LEVEL_OPTIONS = ['S-52', 'S-57', 'S-101', '3D', 'SAT', '기타'] as const; + +// ─── 모달 ───────────────────────────────────────────────── +interface MapBaseModalProps { + editItem: MapBaseItem | null; + form: MapBaseForm; + onFormChange: (form: MapBaseForm) => void; + onClose: () => void; + onSave: () => Promise; + saving: boolean; + modalError: string | null; +} + +function MapBaseModal({ + editItem, + form, + onFormChange, + onClose, + onSave, + saving, + modalError, +}: MapBaseModalProps) { + const isEdit = editItem !== null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await onSave(); + }; + + const setField = (key: K, value: MapBaseForm[K]) => { + onFormChange({ ...form, [key]: value }); + }; + + return ( +
+
+ {/* 모달 헤더 */} +
+

+ {isEdit ? '지도 수정' : '지도 등록'} +

+ +
+ + {/* 폼 */} +
+
+ {/* 지도 이름 */} +
+ + setField('mapNm', e.target.value)} + placeholder="지도 이름을 입력하세요" + className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" + /> +
+ + {/* 지도 키 */} +
+ + setField('mapKey', e.target.value)} + placeholder="고유 식별 키 (영문/숫자)" + disabled={isEdit} + className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono disabled:opacity-50 disabled:cursor-not-allowed" + /> +
+ + {/* 지도 레벨 */} +
+ + +
+ + {/* 파일 소스 */} +
+ + setField('mapSrc', e.target.value)} + placeholder="타일 URL 또는 파일 경로" + className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono" + /> +
+ + {/* 상세 설명 */} +
+ +