feat(admin): 지도 베이스 관리 패널, 레이어 패널 추가 및 보고서 기능 개선
This commit is contained in:
부모
63cf614365
커밋
f336f6b93a
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"applied_global_version": "1.6.1",
|
"applied_global_version": "1.6.1",
|
||||||
"applied_date": "2026-03-13",
|
"applied_date": "2026-03-19",
|
||||||
"project_type": "react-ts",
|
"project_type": "react-ts",
|
||||||
"gitea_url": "https://gitea.gc-si.dev",
|
"gitea_url": "https://gitea.gc-si.dev",
|
||||||
"custom_pre_commit": true
|
"custom_pre_commit": true
|
||||||
|
|||||||
117
backend/src/map-base/mapBaseRouter.ts
Normal file
117
backend/src/map-base/mapBaseRouter.ts
Normal file
@ -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
|
||||||
140
backend/src/map-base/mapBaseService.ts
Normal file
140
backend/src/map-base/mapBaseService.ts
Normal file
@ -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<string, unknown>): 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<MapTypeItem[]> {
|
||||||
|
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<string, unknown>) => ({
|
||||||
|
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<MapBaseItem> {
|
||||||
|
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<MapBaseItem | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@ -457,6 +457,13 @@ interface SingleModelTrajectoryResult {
|
|||||||
beachedVolume: number;
|
beachedVolume: number;
|
||||||
pollutionCoastLength: 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 }>;
|
centerPoints: Array<{ lat: number; lon: number; time: number; model: string }>;
|
||||||
windData: TrajectoryWindPoint[][];
|
windData: TrajectoryWindPoint[][];
|
||||||
hydrData: ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[];
|
hydrData: ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[];
|
||||||
@ -475,6 +482,7 @@ interface TrajectoryResult {
|
|||||||
windDataByModel: Record<string, TrajectoryWindPoint[][]>;
|
windDataByModel: Record<string, TrajectoryWindPoint[][]>;
|
||||||
hydrDataByModel: Record<string, ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]>;
|
hydrDataByModel: Record<string, ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]>;
|
||||||
summaryByModel: Record<string, SingleModelTrajectoryResult['summary']>;
|
summaryByModel: Record<string, SingleModelTrajectoryResult['summary']>;
|
||||||
|
stepSummariesByModel: Record<string, SingleModelTrajectoryResult['stepSummaries']>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: string): SingleModelTrajectoryResult {
|
function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: string): SingleModelTrajectoryResult {
|
||||||
@ -503,13 +511,20 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: strin
|
|||||||
: null
|
: null
|
||||||
)
|
)
|
||||||
.filter((p): p is { lat: number; lon: number; time: number; model: string } => p !== 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 windData = rawResult.map((step) => step.wind_data ?? []);
|
||||||
const hydrData = rawResult.map((step) =>
|
const hydrData = rawResult.map((step) =>
|
||||||
step.hydr_data && step.hydr_grid
|
step.hydr_data && step.hydr_grid
|
||||||
? { value: step.hydr_data, grid: step.hydr_grid }
|
? { value: step.hydr_data, grid: step.hydr_grid }
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
return { trajectory, summary, centerPoints, windData, hydrData };
|
return { trajectory, summary, stepSummaries, centerPoints, windData, hydrData };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAnalysisTrajectory(acdntSn: number): Promise<TrajectoryResult | null> {
|
export async function getAnalysisTrajectory(acdntSn: number): Promise<TrajectoryResult | null> {
|
||||||
@ -533,6 +548,7 @@ export async function getAnalysisTrajectory(acdntSn: number): Promise<Trajectory
|
|||||||
const windDataByModel: Record<string, TrajectoryWindPoint[][]> = {};
|
const windDataByModel: Record<string, TrajectoryWindPoint[][]> = {};
|
||||||
const hydrDataByModel: Record<string, ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]> = {};
|
const hydrDataByModel: Record<string, ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]> = {};
|
||||||
const summaryByModel: Record<string, SingleModelTrajectoryResult['summary']> = {};
|
const summaryByModel: Record<string, SingleModelTrajectoryResult['summary']> = {};
|
||||||
|
const stepSummariesByModel: Record<string, SingleModelTrajectoryResult['stepSummaries']> = {};
|
||||||
|
|
||||||
// OpenDrift 우선, 없으면 POSEIDON 선택 (ORDER BY CMPL_DTM DESC이므로 첫 번째 행이 가장 최근)
|
// OpenDrift 우선, 없으면 POSEIDON 선택 (ORDER BY CMPL_DTM DESC이므로 첫 번째 행이 가장 최근)
|
||||||
const opendriftRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'OPENDRIFT');
|
const opendriftRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'OPENDRIFT');
|
||||||
@ -549,6 +565,7 @@ export async function getAnalysisTrajectory(acdntSn: number): Promise<Trajectory
|
|||||||
windDataByModel[modelName] = parsed.windData;
|
windDataByModel[modelName] = parsed.windData;
|
||||||
hydrDataByModel[modelName] = parsed.hydrData;
|
hydrDataByModel[modelName] = parsed.hydrData;
|
||||||
summaryByModel[modelName] = parsed.summary;
|
summaryByModel[modelName] = parsed.summary;
|
||||||
|
stepSummariesByModel[modelName] = parsed.stepSummaries;
|
||||||
|
|
||||||
if (row === baseRow) {
|
if (row === baseRow) {
|
||||||
baseResult = parsed;
|
baseResult = parsed;
|
||||||
@ -564,6 +581,7 @@ export async function getAnalysisTrajectory(acdntSn: number): Promise<Trajectory
|
|||||||
windDataByModel,
|
windDataByModel,
|
||||||
hydrDataByModel,
|
hydrDataByModel,
|
||||||
summaryByModel,
|
summaryByModel,
|
||||||
|
stepSummariesByModel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
isValidNumber,
|
isValidNumber,
|
||||||
isValidStringLength,
|
isValidStringLength,
|
||||||
} from '../middleware/security.js'
|
} from '../middleware/security.js'
|
||||||
|
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
@ -36,7 +37,7 @@ router.use(sanitizeParams)
|
|||||||
router.get('/', async (_req, res) => {
|
router.get('/', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`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)
|
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||||
res.json(enrichedLayers)
|
res.json(enrichedLayers)
|
||||||
@ -49,7 +50,7 @@ router.get('/', async (_req, res) => {
|
|||||||
router.get('/tree/all', async (_req, res) => {
|
router.get('/tree/all', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`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)
|
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||||
|
|
||||||
@ -81,7 +82,7 @@ router.get('/tree/all', async (_req, res) => {
|
|||||||
router.get('/wms/all', async (_req, res) => {
|
router.get('/wms/all', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`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)
|
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||||
res.json(enrichedLayers)
|
res.json(enrichedLayers)
|
||||||
@ -103,7 +104,7 @@ router.get('/level/:level', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`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]
|
[level]
|
||||||
)
|
)
|
||||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||||
@ -127,7 +128,7 @@ router.get('/children/:parentId', async (req, res) => {
|
|||||||
|
|
||||||
const sanitizedId = sanitizeString(parentId)
|
const sanitizedId = sanitizeString(parentId)
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`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]
|
[sanitizedId]
|
||||||
)
|
)
|
||||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||||
@ -151,7 +152,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
|
|
||||||
const sanitizedId = sanitizeString(id)
|
const sanitizedId = sanitizeString(id)
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_CD = $1`,
|
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_CD = $1 AND DEL_YN = 'N'`,
|
||||||
[sanitizedId]
|
[sanitizedId]
|
||||||
)
|
)
|
||||||
if (rows.length === 0) {
|
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
|
export default router
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import scatRouter from './scat/scatRouter.js'
|
|||||||
import predictionRouter from './prediction/predictionRouter.js'
|
import predictionRouter from './prediction/predictionRouter.js'
|
||||||
import aerialRouter from './aerial/aerialRouter.js'
|
import aerialRouter from './aerial/aerialRouter.js'
|
||||||
import rescueRouter from './rescue/rescueRouter.js'
|
import rescueRouter from './rescue/rescueRouter.js'
|
||||||
|
import mapBaseRouter from './map-base/mapBaseRouter.js'
|
||||||
import {
|
import {
|
||||||
sanitizeBody,
|
sanitizeBody,
|
||||||
sanitizeQuery,
|
sanitizeQuery,
|
||||||
@ -168,6 +169,7 @@ app.use('/api/scat', scatRouter)
|
|||||||
app.use('/api/prediction', predictionRouter)
|
app.use('/api/prediction', predictionRouter)
|
||||||
app.use('/api/aerial', aerialRouter)
|
app.use('/api/aerial', aerialRouter)
|
||||||
app.use('/api/rescue', rescueRouter)
|
app.use('/api/rescue', rescueRouter)
|
||||||
|
app.use('/api/map-base', mapBaseRouter)
|
||||||
|
|
||||||
// 헬스 체크
|
// 헬스 체크
|
||||||
app.get('/health', (_req, res) => {
|
app.get('/health', (_req, res) => {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS LAYER (
|
|||||||
USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 사용여부
|
USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 사용여부
|
||||||
SORT_ORD INTEGER DEFAULT 0, -- 정렬순서
|
SORT_ORD INTEGER DEFAULT 0, -- 정렬순서
|
||||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
||||||
|
DEL_YN CHAR(1) DEFAULT 'N' NOT NULL,
|
||||||
CONSTRAINT PK_LAYER PRIMARY KEY (LAYER_CD),
|
CONSTRAINT PK_LAYER PRIMARY KEY (LAYER_CD),
|
||||||
CONSTRAINT FK_LAYER_UP FOREIGN KEY (UP_LAYER_CD) REFERENCES LAYER(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'))
|
CONSTRAINT CK_LAYER_USE_YN CHECK (USE_YN IN ('Y', 'N'))
|
||||||
|
|||||||
43
database/migration/022_map_base.sql
Normal file
43
database/migration/022_map_base.sql
Normal file
@ -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;
|
||||||
10
database/migration/023_layer_del_yn.sql
Normal file
10
database/migration/023_layer_del_yn.sql
Normal file
@ -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);
|
||||||
@ -6,6 +6,7 @@ import { LoginPage } from '@common/components/auth/LoginPage'
|
|||||||
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu'
|
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu'
|
||||||
import { useAuthStore } from '@common/store/authStore'
|
import { useAuthStore } from '@common/store/authStore'
|
||||||
import { useMenuStore } from '@common/store/menuStore'
|
import { useMenuStore } from '@common/store/menuStore'
|
||||||
|
import { useMapStore } from '@common/store/mapStore'
|
||||||
import { API_BASE_URL } from '@common/services/api'
|
import { API_BASE_URL } from '@common/services/api'
|
||||||
import { OilSpillView } from '@tabs/prediction'
|
import { OilSpillView } from '@tabs/prediction'
|
||||||
import { ReportsView } from '@tabs/reports'
|
import { ReportsView } from '@tabs/reports'
|
||||||
@ -25,6 +26,7 @@ function App() {
|
|||||||
const [activeMainTab, setActiveMainTab] = useState<MainTab>('prediction')
|
const [activeMainTab, setActiveMainTab] = useState<MainTab>('prediction')
|
||||||
const { isAuthenticated, isLoading, checkSession } = useAuthStore()
|
const { isAuthenticated, isLoading, checkSession } = useAuthStore()
|
||||||
const { loadMenuConfig } = useMenuStore()
|
const { loadMenuConfig } = useMenuStore()
|
||||||
|
const { loadMapTypes } = useMapStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkSession()
|
checkSession()
|
||||||
@ -33,8 +35,9 @@ function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
loadMenuConfig()
|
loadMenuConfig()
|
||||||
|
loadMapTypes()
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, loadMenuConfig])
|
}, [isAuthenticated, loadMenuConfig, loadMapTypes])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerMainTabSwitcher(setActiveMainTab)
|
registerMainTabSwitcher(setActiveMainTab)
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
|||||||
const quickMenuRef = useRef<HTMLDivElement>(null)
|
const quickMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const { hasPermission, user, logout } = useAuthStore()
|
const { hasPermission, user, logout } = useAuthStore()
|
||||||
const { menuConfig, isLoaded } = useMenuStore()
|
const { menuConfig, isLoaded } = useMenuStore()
|
||||||
const { mapToggles, toggleMap } = useMapStore()
|
const { mapToggles, toggleMap, mapTypes } = useMapStore()
|
||||||
|
|
||||||
const tabs = useMemo(() => {
|
const tabs = useMemo(() => {
|
||||||
if (!isLoaded || menuConfig.length === 0) return []
|
if (!isLoaded || menuConfig.length === 0) return []
|
||||||
@ -187,18 +187,13 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
|||||||
<div className="px-3 py-1.5 flex items-center gap-2 text-[11px] font-bold text-text-3">
|
<div className="px-3 py-1.5 flex items-center gap-2 text-[11px] font-bold text-text-3">
|
||||||
<span>🗺</span> 지도 유형
|
<span>🗺</span> 지도 유형
|
||||||
</div>
|
</div>
|
||||||
{([
|
{mapTypes.map(item => (
|
||||||
{ key: 's57' as const, label: 'S-57 전자해도', icon: '🗺' },
|
<button key={item.mapKey} onClick={() => toggleMap(item.mapKey)} className="w-full px-3 py-2 flex items-center justify-between text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] transition-all">
|
||||||
{ key: 's101' as const, label: 'S-101 전자해도', icon: '🗺' },
|
|
||||||
{ key: 'threeD' as const, label: '3D 지도', icon: '🗺' },
|
|
||||||
{ key: 'satellite' as const, label: '위성 영상', icon: '🛰' },
|
|
||||||
]).map(item => (
|
|
||||||
<button key={item.key} onClick={() => toggleMap(item.key)} className="w-full px-3 py-2 flex items-center justify-between text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] transition-all">
|
|
||||||
<span className="flex items-center gap-2.5">
|
<span className="flex items-center gap-2.5">
|
||||||
<span className="text-[13px]">{item.icon}</span> {item.label}
|
<span className="text-[13px]">🗺</span> {item.mapNm}
|
||||||
</span>
|
</span>
|
||||||
<div className={`w-[34px] h-[18px] rounded-full transition-all relative ${mapToggles[item.key] ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'}`}>
|
<div className={`w-[34px] h-[18px] rounded-full transition-all relative ${mapToggles[item.mapKey] ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'}`}>
|
||||||
<div className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow transition-all ${mapToggles[item.key] ? 'left-[16px]' : 'left-[2px]'}`} />
|
<div className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow transition-all ${mapToggles[item.mapKey] ? 'left-[16px]' : 'left-[2px]'}`} />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -1225,7 +1225,7 @@ export function MapView({
|
|||||||
])
|
])
|
||||||
|
|
||||||
// 3D 모드 / 밝은 톤에 따른 지도 스타일 전환
|
// 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 (
|
return (
|
||||||
<div className="w-full h-full relative">
|
<div className="w-full h-full relative">
|
||||||
@ -1247,7 +1247,7 @@ export function MapView({
|
|||||||
{/* 지도 중앙 좌표 + 줌 추적 */}
|
{/* 지도 중앙 좌표 + 줌 추적 */}
|
||||||
<MapCenterTracker onCenterChange={handleMapCenterChange} />
|
<MapCenterTracker onCenterChange={handleMapCenterChange} />
|
||||||
{/* 3D 모드 pitch 제어 */}
|
{/* 3D 모드 pitch 제어 */}
|
||||||
<MapPitchController threeD={mapToggles.threeD} />
|
<MapPitchController threeD={mapToggles['threeD'] ?? false} />
|
||||||
{/* 사고 지점 변경 시 지도 이동 */}
|
{/* 사고 지점 변경 시 지도 이동 */}
|
||||||
<MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} />
|
<MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} />
|
||||||
{/* 외부에서 flyTo 트리거 */}
|
{/* 외부에서 flyTo 트리거 */}
|
||||||
|
|||||||
@ -211,6 +211,13 @@ export interface OilReportPayload {
|
|||||||
coastal: {
|
coastal: {
|
||||||
firstTime: string | null;
|
firstTime: string | null;
|
||||||
};
|
};
|
||||||
|
spreadSteps?: Array<{
|
||||||
|
elapsed: string;
|
||||||
|
weathered: string;
|
||||||
|
seaRemain: string;
|
||||||
|
coastAttach: string;
|
||||||
|
area: string;
|
||||||
|
}>;
|
||||||
hasSimulation: boolean;
|
hasSimulation: boolean;
|
||||||
mapData: {
|
mapData: {
|
||||||
center: [number, number];
|
center: [number, number];
|
||||||
|
|||||||
@ -1,21 +1,56 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
import { api } from '../services/api'
|
||||||
|
|
||||||
interface MapToggles {
|
export interface MapTypeItem {
|
||||||
s57: boolean;
|
mapKey: string;
|
||||||
s101: boolean;
|
mapNm: string;
|
||||||
threeD: boolean;
|
mapLevelCd: string | null;
|
||||||
satellite: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MapState {
|
interface MapState {
|
||||||
mapToggles: MapToggles;
|
mapToggles: Record<string, boolean>;
|
||||||
toggleMap: (key: keyof MapToggles) => void;
|
mapTypes: MapTypeItem[];
|
||||||
|
toggleMap: (key: string) => void;
|
||||||
|
loadMapTypes: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMapStore = create<MapState>((set) => ({
|
const DEFAULT_MAP_TYPES: MapTypeItem[] = [
|
||||||
mapToggles: { s57: true, s101: false, threeD: false, satellite: false },
|
{ 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' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DEFAULT_TOGGLES: Record<string, boolean> = {
|
||||||
|
s57: true,
|
||||||
|
s101: false,
|
||||||
|
threeD: false,
|
||||||
|
satellite: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMapStore = create<MapState>((set, get) => ({
|
||||||
|
mapToggles: { ...DEFAULT_TOGGLES },
|
||||||
|
mapTypes: DEFAULT_MAP_TYPES,
|
||||||
toggleMap: (key) =>
|
toggleMap: (key) =>
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
mapToggles: { ...s.mapToggles, [key]: !s.mapToggles[key] },
|
mapToggles: { ...s.mapToggles, [key]: !s.mapToggles[key] },
|
||||||
})),
|
})),
|
||||||
|
loadMapTypes: async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<MapTypeItem[]>('/map-base/active')
|
||||||
|
const types = res.data
|
||||||
|
const current = get().mapToggles
|
||||||
|
const newToggles: Record<string, boolean> = {}
|
||||||
|
for (const t of types) {
|
||||||
|
newToggles[t.mapKey] = current[t.mapKey] ?? false
|
||||||
|
}
|
||||||
|
// s57 기본값 유지
|
||||||
|
if (newToggles['s57'] === undefined && types.find(t => t.mapKey === 's57')) {
|
||||||
|
newToggles['s57'] = true
|
||||||
|
}
|
||||||
|
set({ mapTypes: types, mapToggles: newToggles })
|
||||||
|
} catch {
|
||||||
|
// API 실패 시 fallback 유지
|
||||||
|
}
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import BoardMgmtPanel from './BoardMgmtPanel';
|
|||||||
import VesselSignalPanel from './VesselSignalPanel';
|
import VesselSignalPanel from './VesselSignalPanel';
|
||||||
import CleanupEquipPanel from './CleanupEquipPanel';
|
import CleanupEquipPanel from './CleanupEquipPanel';
|
||||||
import AssetUploadPanel from './AssetUploadPanel';
|
import AssetUploadPanel from './AssetUploadPanel';
|
||||||
|
import MapBasePanel from './MapBasePanel';
|
||||||
|
import LayerPanel from './LayerPanel';
|
||||||
|
|
||||||
/** 기존 패널이 있는 메뉴 ID 매핑 */
|
/** 기존 패널이 있는 메뉴 ID 매핑 */
|
||||||
const PANEL_MAP: Record<string, () => JSX.Element> = {
|
const PANEL_MAP: Record<string, () => JSX.Element> = {
|
||||||
@ -23,6 +25,8 @@ const PANEL_MAP: Record<string, () => JSX.Element> = {
|
|||||||
'collect-vessel-signal': () => <VesselSignalPanel />,
|
'collect-vessel-signal': () => <VesselSignalPanel />,
|
||||||
'cleanup-equip': () => <CleanupEquipPanel />,
|
'cleanup-equip': () => <CleanupEquipPanel />,
|
||||||
'asset-upload': () => <AssetUploadPanel />,
|
'asset-upload': () => <AssetUploadPanel />,
|
||||||
|
'map-base': () => <MapBasePanel />,
|
||||||
|
'map-layer': () => <LayerPanel />,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AdminView() {
|
export function AdminView() {
|
||||||
|
|||||||
620
frontend/src/tabs/admin/components/LayerPanel.tsx
Normal file
620
frontend/src/tabs/admin/components/LayerPanel.tsx
Normal file
@ -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<LayerListResponse> {
|
||||||
|
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<LayerListResponse>(`/layers/admin/list?${params}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLayerOptions(): Promise<LayerOption[]> {
|
||||||
|
const res = await api.get<LayerOption[]>('/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<void> {
|
||||||
|
await api.post('/layers/admin/create', body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateLayer(body: LayerFormData): Promise<void> {
|
||||||
|
await api.post('/layers/admin/update', body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteLayer(layerCd: string): Promise<void> {
|
||||||
|
await api.post('/layers/admin/delete', { layerCd });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchNextLayerCode(upLayerCd: string): Promise<string> {
|
||||||
|
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<LayerFormData>({
|
||||||
|
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<LayerOption[]>([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [parentInfo, setParentInfo] = useState<{ fullNm: string; level: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLayerOptions().then(setOptions).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleField = <K extends keyof LayerFormData>(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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="bg-bg-1 border border-border rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||||||
|
<h2 className="text-sm font-bold text-text-1 font-korean">
|
||||||
|
{mode === 'create' ? '레이어 등록' : '레이어 수정'}
|
||||||
|
</h2>
|
||||||
|
<button onClick={onClose} className="text-text-3 hover:text-text-1 transition-colors">✕</button>
|
||||||
|
</div>
|
||||||
|
{/* 폼 */}
|
||||||
|
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
|
||||||
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
{/* 상위 레이어코드 */}
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>상위 레이어코드</label>
|
||||||
|
<select
|
||||||
|
value={form.upLayerCd}
|
||||||
|
onChange={e => mode === 'create' ? handleParentChange(e.target.value) : handleField('upLayerCd', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">(없음)</option>
|
||||||
|
{options
|
||||||
|
.filter(o => o.layerCd !== form.layerCd)
|
||||||
|
.map(o => (
|
||||||
|
<option key={o.layerCd} value={o.layerCd}>
|
||||||
|
{o.layerCd} — {o.layerNm}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/* 레이어코드 */}
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>레이어코드 <span className="text-red-400">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.layerCd}
|
||||||
|
onChange={e => 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' : ''}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* 레이어명 */}
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>레이어명 <span className="text-red-400">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.layerNm}
|
||||||
|
onChange={e => mode === 'create' ? handleLayerNmChange(e.target.value) : handleField('layerNm', e.target.value)}
|
||||||
|
maxLength={100}
|
||||||
|
placeholder="레이어 이름"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* 레이어전체명 */}
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>레이어전체명 <span className="text-red-400">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.layerFullNm}
|
||||||
|
onChange={e => handleField('layerFullNm', e.target.value)}
|
||||||
|
maxLength={200}
|
||||||
|
placeholder="레이어 전체 경로명"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* 레벨 */}
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>레벨</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.layerLevel}
|
||||||
|
onChange={e => handleField('layerLevel', Number(e.target.value))}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* WMS레이어명 */}
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>WMS레이어명</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.wmsLayerNm}
|
||||||
|
onChange={e => handleField('wmsLayerNm', e.target.value)}
|
||||||
|
placeholder="WMS 레이어명 (선택)"
|
||||||
|
className={`${inputCls} font-mono`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* 정렬순서 */}
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>정렬순서</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.sortOrd}
|
||||||
|
onChange={e => handleField('sortOrd', Number(e.target.value))}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* 사용여부 */}
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>사용여부</label>
|
||||||
|
<select
|
||||||
|
value={form.useYn}
|
||||||
|
onChange={e => handleField('useYn', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="Y">사용</option>
|
||||||
|
<option value="N">미사용</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 에러 */}
|
||||||
|
{formError && (
|
||||||
|
<div className="px-6 pb-2">
|
||||||
|
<p className="text-[11px] text-red-400 font-korean">{formError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-3 py-1.5 text-xs border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="px-3 py-1.5 text-xs bg-primary-cyan text-bg-0 rounded hover:opacity-90 disabled:opacity-50 transition-all font-korean"
|
||||||
|
>
|
||||||
|
{saving ? '저장 중...' : mode === 'create' ? '등록' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- LayerPanel ----------
|
||||||
|
|
||||||
|
const LayerPanel = () => {
|
||||||
|
const [items, setItems] = useState<LayerAdminItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [toggling, setToggling] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="px-6 py-4 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-text-1 font-korean">레이어 관리</h1>
|
||||||
|
<p className="text-xs text-text-3 mt-1 font-korean">총 {total}개</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setModal({ mode: 'create' })}
|
||||||
|
className="px-3 py-1.5 text-xs font-semibold bg-primary-cyan text-bg-0 rounded hover:opacity-90 transition-opacity font-korean"
|
||||||
|
>
|
||||||
|
신규 등록
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={filterUseYn}
|
||||||
|
onChange={e => setFilterUseYn(e.target.value)}
|
||||||
|
className="px-2 py-1.5 text-xs bg-bg-2 border border-border rounded text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||||
|
>
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option value="Y">사용</option>
|
||||||
|
<option value="N">미사용</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
className="px-3 py-1.5 text-xs border border-border text-text-2 rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
검색
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오류 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<div className="px-6 py-2 text-xs text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-border shrink-0 font-korean">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 테이블 영역 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-text-3 text-sm font-korean">
|
||||||
|
불러오는 중...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-bg-1 sticky top-0 z-10">
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-10 whitespace-nowrap">번호</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-mono">레이어코드</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">레이어명</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">레이어전체명</th>
|
||||||
|
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-12 whitespace-nowrap">레벨</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-mono">WMS레이어명</th>
|
||||||
|
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-16">정렬</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-28">등록일시</th>
|
||||||
|
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-20">사용여부</th>
|
||||||
|
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-28 whitespace-nowrap">액션</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={10} className="px-4 py-12 text-center text-text-3 text-sm font-korean">
|
||||||
|
데이터가 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
items.map((item, idx) => (
|
||||||
|
<tr
|
||||||
|
key={item.layerCd}
|
||||||
|
className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||||
|
>
|
||||||
|
{/* 번호 */}
|
||||||
|
<td className="px-4 py-3 text-xs text-text-3 font-mono">
|
||||||
|
{(page - 1) * PAGE_SIZE + idx + 1}
|
||||||
|
</td>
|
||||||
|
{/* 레이어코드 */}
|
||||||
|
<td className="px-4 py-3 text-[11px] text-text-2 font-mono">
|
||||||
|
{item.layerCd}
|
||||||
|
</td>
|
||||||
|
{/* 레이어명 */}
|
||||||
|
<td className="px-4 py-3 text-xs text-text-1 font-korean">
|
||||||
|
{item.layerNm}
|
||||||
|
</td>
|
||||||
|
{/* 레이어전체명 */}
|
||||||
|
<td className="px-4 py-3 text-xs text-text-2 font-korean max-w-[200px]">
|
||||||
|
<span className="block truncate" title={item.layerFullNm}>
|
||||||
|
{item.layerFullNm}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{/* 레벨 */}
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<span className="inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-semibold bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-[rgba(6,182,212,0.3)]">
|
||||||
|
{item.layerLevel}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{/* WMS레이어명 */}
|
||||||
|
<td className="px-4 py-3 text-[11px] text-text-2 font-mono">
|
||||||
|
{item.wmsLayerNm ?? <span className="text-text-3">-</span>}
|
||||||
|
</td>
|
||||||
|
{/* 정렬순서 */}
|
||||||
|
<td className="px-4 py-3 text-xs text-text-3 text-center font-mono">
|
||||||
|
{item.sortOrd}
|
||||||
|
</td>
|
||||||
|
{/* 등록일시 */}
|
||||||
|
<td className="px-4 py-3 text-[11px] text-text-3 font-mono">
|
||||||
|
{item.regDtm ?? '-'}
|
||||||
|
</td>
|
||||||
|
{/* 사용여부 토글 */}
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggle(item.layerCd)}
|
||||||
|
disabled={toggling === item.layerCd}
|
||||||
|
title={item.useYn === 'Y' ? '사용 중 (클릭하여 비활성화)' : '미사용 (클릭하여 활성화)'}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-50 ${
|
||||||
|
item.useYn === 'Y'
|
||||||
|
? 'bg-primary-cyan'
|
||||||
|
: 'bg-[rgba(255,255,255,0.08)] border border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
||||||
|
item.useYn === 'Y' ? 'translate-x-[18px]' : 'translate-x-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
{/* 액션 */}
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1.5 flex-nowrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setModal({ mode: 'edit', data: item })}
|
||||||
|
className="px-3 py-1 text-xs rounded bg-[rgba(6,182,212,0.15)] text-primary-cyan hover:bg-[rgba(6,182,212,0.25)] font-korean whitespace-nowrap"
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(item.layerCd)}
|
||||||
|
className="px-3 py-1 text-xs rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 font-korean whitespace-nowrap"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{!loading && totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-t border-border bg-bg-1 shrink-0">
|
||||||
|
<span className="text-[11px] text-text-3 font-korean">
|
||||||
|
{(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} / {total}개
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="px-2.5 py-1 text-[11px] border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
{buildPageButtons().map((btn, i) =>
|
||||||
|
btn === 'ellipsis' ? (
|
||||||
|
<span key={`e${i}`} className="px-1.5 text-[11px] text-text-3">…</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={btn}
|
||||||
|
onClick={() => setPage(btn)}
|
||||||
|
className={`px-2.5 py-1 text-[11px] rounded transition-all ${
|
||||||
|
page === btn
|
||||||
|
? 'bg-primary-cyan text-bg-0 font-semibold shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||||||
|
: 'border border-border text-text-3 hover:bg-[rgba(255,255,255,0.04)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{btn}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="px-2.5 py-1 text-[11px] border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 모달 */}
|
||||||
|
{modal && (
|
||||||
|
<LayerFormModal
|
||||||
|
mode={modal.mode}
|
||||||
|
initialData={modal.data}
|
||||||
|
onClose={() => setModal(null)}
|
||||||
|
onSaved={() => load(page, appliedSearch, filterUseYn)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LayerPanel;
|
||||||
505
frontend/src/tabs/admin/components/MapBasePanel.tsx
Normal file
505
frontend/src/tabs/admin/components/MapBasePanel.tsx
Normal file
@ -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<void>;
|
||||||
|
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 = <K extends keyof MapBaseForm>(key: K, value: MapBaseForm[K]) => {
|
||||||
|
onFormChange({ ...form, [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="bg-bg-1 border border-border rounded-lg shadow-lg w-[520px] max-h-[90vh] flex flex-col">
|
||||||
|
{/* 모달 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
|
<h2 className="text-sm font-bold text-text-1 font-korean">
|
||||||
|
{isEdit ? '지도 수정' : '지도 등록'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-text-3 hover:text-text-1 transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 폼 */}
|
||||||
|
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
|
||||||
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
{/* 지도 이름 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||||
|
지도 이름 <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.mapNm}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 지도 키 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||||
|
지도 키 <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.mapKey}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 지도 레벨 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||||
|
지도 레벨
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={form.mapLevelCd}
|
||||||
|
onChange={e => setField('mapLevelCd', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||||
|
>
|
||||||
|
<option value="">선택</option>
|
||||||
|
{MAP_LEVEL_OPTIONS.map(opt => (
|
||||||
|
<option key={opt} value={opt}>{opt}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 파일 소스 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||||
|
파일 소스
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.mapSrc}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 설명 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||||
|
상세 설명
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={form.mapDc}
|
||||||
|
onChange={e => setField('mapDc', 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 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사용여부 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||||
|
사용여부
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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-primary-cyan' : 'bg-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
form.useYn === 'Y' ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-text-2 font-korean">
|
||||||
|
{form.useYn === 'Y' ? '사용' : '미사용'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 */}
|
||||||
|
{modalError && (
|
||||||
|
<p className="text-[11px] text-red-400 font-korean">{modalError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모달 푸터 */}
|
||||||
|
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-xs border border-border text-text-2 rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
|
||||||
|
>
|
||||||
|
{saving ? '저장 중...' : isEdit ? '수정' : '등록'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 메인 패널 ─────────────────────────────────────────────
|
||||||
|
function MapBasePanel() {
|
||||||
|
const loadMapTypes = useMapStore(s => s.loadMapTypes);
|
||||||
|
const [items, setItems] = useState<MapBaseItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [message, setMessage] = useState<Message | null>(null);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editItem, setEditItem] = useState<MapBaseItem | null>(null);
|
||||||
|
const [form, setForm] = useState<MapBaseForm>(EMPTY_FORM);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [modalError, setModalError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ rows: MapBaseItem[]; total: number }>(
|
||||||
|
`/map-base?page=${page}&limit=10`
|
||||||
|
);
|
||||||
|
setItems(res.data.rows);
|
||||||
|
setTotal(res.data.total);
|
||||||
|
setTotalPages(Math.max(1, Math.ceil(res.data.total / 10)));
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: '지도 목록을 불러오는 데 실패했습니다.' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// page 변경 시 목록 재조회 (loadData는 page를 클로저로 참조)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
useEffect(() => { loadData(); }, [page]);
|
||||||
|
|
||||||
|
const openModal = (item: MapBaseItem | null) => {
|
||||||
|
setEditItem(item);
|
||||||
|
setModalError(null);
|
||||||
|
if (item) {
|
||||||
|
setForm({
|
||||||
|
mapKey: item.mapKey,
|
||||||
|
mapNm: item.mapNm,
|
||||||
|
mapLevelCd: item.mapLevelCd ?? '',
|
||||||
|
mapSrc: item.mapSrc ?? '',
|
||||||
|
mapDc: item.mapDc ?? '',
|
||||||
|
useYn: item.useYn,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
}
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setShowModal(false);
|
||||||
|
setEditItem(null);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
setModalError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.mapNm.trim()) {
|
||||||
|
setModalError('지도 이름은 필수 항목입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.mapKey.trim()) {
|
||||||
|
setModalError('지도 키는 필수 항목입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setModalError(null);
|
||||||
|
try {
|
||||||
|
if (editItem) {
|
||||||
|
await api.post('/map-base/update', { mapSn: editItem.mapSn, ...form });
|
||||||
|
} else {
|
||||||
|
await api.post('/map-base', form);
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
await loadData();
|
||||||
|
setMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: editItem ? '지도 정보가 수정되었습니다.' : '지도가 등록되었습니다.',
|
||||||
|
});
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
} catch {
|
||||||
|
setModalError(editItem ? '지도 수정에 실패했습니다.' : '지도 등록에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (item: MapBaseItem) => {
|
||||||
|
if (!window.confirm('이 지도를 삭제하시겠습니까?')) return;
|
||||||
|
try {
|
||||||
|
await api.post('/map-base/delete', { mapSn: item.mapSn });
|
||||||
|
await loadData();
|
||||||
|
setMessage({ type: 'success', text: '지도가 삭제되었습니다.' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: '지도 삭제에 실패했습니다.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleUse = async (item: MapBaseItem) => {
|
||||||
|
try {
|
||||||
|
await api.post('/map-base/update', {
|
||||||
|
mapSn: item.mapSn,
|
||||||
|
useYn: item.useYn === 'Y' ? 'N' : 'Y',
|
||||||
|
});
|
||||||
|
await loadData();
|
||||||
|
await loadMapTypes();
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: '사용여부 변경에 실패했습니다.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden bg-bg-0">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-text-1 font-korean">지도 관리</h1>
|
||||||
|
<p className="text-xs text-text-3 mt-1 font-korean">총 {total}건</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => openModal(null)}
|
||||||
|
className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
+ 등록
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메시지 */}
|
||||||
|
{message && (
|
||||||
|
<div
|
||||||
|
className={`mx-6 mt-2 px-3 py-2 text-[11px] rounded-md font-korean ${
|
||||||
|
message.type === 'success'
|
||||||
|
? 'text-green-400 bg-[rgba(74,222,128,0.08)] border border-[rgba(74,222,128,0.2)]'
|
||||||
|
: 'text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 테이블 영역 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-bg-1 z-10">
|
||||||
|
<tr className="border-b border-border text-text-3">
|
||||||
|
<th className="w-12 py-3 text-center">번호</th>
|
||||||
|
<th className="py-3 text-left pl-4">지도 리스트</th>
|
||||||
|
<th className="w-20 py-3 text-center">지도 레벨</th>
|
||||||
|
<th className="w-24 py-3 text-center">등록자</th>
|
||||||
|
<th className="w-28 py-3 text-center">등록일</th>
|
||||||
|
<th className="w-16 py-3 text-center">사용여부</th>
|
||||||
|
<th className="w-12 py-3 text-center">수정</th>
|
||||||
|
<th className="w-12 py-3 text-center">삭제</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="py-8 text-center text-text-3 font-korean">
|
||||||
|
불러오는 중...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : items.map((item, idx) => (
|
||||||
|
<tr
|
||||||
|
key={item.mapSn}
|
||||||
|
className="border-b border-border hover:bg-bg-1/50 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="py-3 text-center text-text-3">
|
||||||
|
{(page - 1) * 10 + idx + 1}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pl-4">
|
||||||
|
<span className="text-text-1 font-korean">{item.mapNm}</span>
|
||||||
|
<span className="ml-2 text-[10px] text-text-3 font-mono">{item.mapKey}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-center text-text-2">{item.mapLevelCd ?? '-'}</td>
|
||||||
|
<td className="py-3 text-center text-text-2 font-korean">{item.regNm ?? '-'}</td>
|
||||||
|
<td className="py-3 text-center text-text-3">{item.regDtm ?? '-'}</td>
|
||||||
|
<td
|
||||||
|
className="py-3 text-center cursor-pointer"
|
||||||
|
onClick={() => handleToggleUse(item)}
|
||||||
|
>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||||
|
item.useYn === 'Y' ? 'bg-primary-cyan' : 'bg-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
|
||||||
|
item.useYn === 'Y' ? 'translate-x-5' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => openModal(item)}
|
||||||
|
className="px-3 py-1 text-xs rounded bg-[rgba(6,182,212,0.15)] text-primary-cyan hover:bg-[rgba(6,182,212,0.25)]"
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(item)}
|
||||||
|
className="px-3 py-1 text-xs rounded bg-red-500/20 text-red-400 hover:bg-red-500/30"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{!loading && items.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center h-32 text-xs text-text-3 font-korean">
|
||||||
|
등록된 지도가 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-1 py-2 border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="px-2 py-1 text-xs rounded text-text-3 hover:bg-bg-2 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => {
|
||||||
|
const startPage = Math.max(1, Math.min(page - 4, totalPages - 9));
|
||||||
|
const p = startPage + i;
|
||||||
|
if (p > totalPages) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPage(p)}
|
||||||
|
className={`w-7 h-7 text-xs rounded ${
|
||||||
|
p === page ? 'bg-blue-500/20 text-blue-400 font-medium' : 'text-text-3 hover:bg-bg-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="px-2 py-1 text-xs rounded text-text-3 hover:bg-bg-2 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 모달 */}
|
||||||
|
{showModal && (
|
||||||
|
<MapBaseModal
|
||||||
|
editItem={editItem}
|
||||||
|
form={form}
|
||||||
|
onFormChange={setForm}
|
||||||
|
onClose={closeModal}
|
||||||
|
onSave={handleSave}
|
||||||
|
saving={saving}
|
||||||
|
modalError={modalError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MapBasePanel;
|
||||||
@ -36,7 +36,7 @@ export const ADMIN_MENU: AdminMenuItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'map-mgmt', label: '지도관리',
|
id: 'map-mgmt', label: '지도관리',
|
||||||
children: [
|
children: [
|
||||||
{ id: 'map-vector', label: '지도벡데이터' },
|
{ id: 'map-base', label: '지도백데이터' },
|
||||||
{ id: 'map-layer', label: '레이어' },
|
{ id: 'map-layer', label: '레이어' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -193,6 +193,7 @@ export function OilSpillView() {
|
|||||||
const [recalcModalOpen, setRecalcModalOpen] = useState(false)
|
const [recalcModalOpen, setRecalcModalOpen] = useState(false)
|
||||||
const [simulationSummary, setSimulationSummary] = useState<SimulationSummary | null>(null)
|
const [simulationSummary, setSimulationSummary] = useState<SimulationSummary | null>(null)
|
||||||
const [summaryByModel, setSummaryByModel] = useState<Record<string, SimulationSummary>>({})
|
const [summaryByModel, setSummaryByModel] = useState<Record<string, SimulationSummary>>({})
|
||||||
|
const [stepSummariesByModel, setStepSummariesByModel] = useState<Record<string, SimulationSummary[]>>({})
|
||||||
|
|
||||||
// 오염분석 상태
|
// 오염분석 상태
|
||||||
const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon')
|
const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon')
|
||||||
@ -405,15 +406,19 @@ export function OilSpillView() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// windHydrModel이 visibleModels에 없으면 자동으로 적절한 모델로 전환
|
// visibleModels 변경 시 windHydrModel 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visibleModels.size === 0) return;
|
if (visibleModels.size === 0) return;
|
||||||
if (!visibleModels.has(windHydrModel as PredictionModel)) {
|
if (visibleModels.size === 1) {
|
||||||
|
// 단일 모델 → 항상 해당 모델로 동기화
|
||||||
|
setWindHydrModel(Array.from(visibleModels)[0]);
|
||||||
|
} else if (!visibleModels.has(windHydrModel as PredictionModel)) {
|
||||||
|
// 다중 모델이지만 현재 선택이 사라진 경우 → fallback
|
||||||
const preferred: PredictionModel[] = ['OpenDrift', 'POSEIDON', 'KOSPS'];
|
const preferred: PredictionModel[] = ['OpenDrift', 'POSEIDON', 'KOSPS'];
|
||||||
const next = preferred.find(m => visibleModels.has(m)) ?? Array.from(visibleModels)[0];
|
const next = preferred.find(m => visibleModels.has(m)) ?? Array.from(visibleModels)[0];
|
||||||
setWindHydrModel(next);
|
setWindHydrModel(next);
|
||||||
}
|
}
|
||||||
}, [visibleModels, windHydrModel]);
|
}, [visibleModels]);
|
||||||
|
|
||||||
// 플레이어 재생 애니메이션 (1x = 1초/스텝, 2x = 0.5초/스텝, 4x = 0.25초/스텝)
|
// 플레이어 재생 애니메이션 (1x = 1초/스텝, 2x = 0.5초/스텝, 4x = 0.25초/스텝)
|
||||||
const timeSteps = useMemo(() => {
|
const timeSteps = useMemo(() => {
|
||||||
@ -502,7 +507,7 @@ export function OilSpillView() {
|
|||||||
analysis.opendriftStatus === 'completed' || analysis.poseidonStatus === 'completed';
|
analysis.opendriftStatus === 'completed' || analysis.poseidonStatus === 'completed';
|
||||||
if (hasCompletedModel) {
|
if (hasCompletedModel) {
|
||||||
try {
|
try {
|
||||||
const { trajectory, summary, centerPoints: cp, windDataByModel: wdByModel, hydrDataByModel: hdByModel, summaryByModel: sbModel } = await fetchAnalysisTrajectory(analysis.acdntSn)
|
const { trajectory, summary, centerPoints: cp, windDataByModel: wdByModel, hydrDataByModel: hdByModel, summaryByModel: sbModel, stepSummariesByModel: stepSbModel } = await fetchAnalysisTrajectory(analysis.acdntSn)
|
||||||
if (trajectory && trajectory.length > 0) {
|
if (trajectory && trajectory.length > 0) {
|
||||||
setOilTrajectory(trajectory)
|
setOilTrajectory(trajectory)
|
||||||
if (summary) setSimulationSummary(summary)
|
if (summary) setSimulationSummary(summary)
|
||||||
@ -510,6 +515,7 @@ export function OilSpillView() {
|
|||||||
setWindDataByModel(wdByModel ?? {});
|
setWindDataByModel(wdByModel ?? {});
|
||||||
setHydrDataByModel(hdByModel ?? {});
|
setHydrDataByModel(hdByModel ?? {});
|
||||||
if (sbModel) setSummaryByModel(sbModel);
|
if (sbModel) setSummaryByModel(sbModel);
|
||||||
|
if (stepSbModel) setStepSummariesByModel(stepSbModel);
|
||||||
if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings))
|
if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings))
|
||||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||||
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
||||||
@ -529,6 +535,7 @@ export function OilSpillView() {
|
|||||||
setWindDataByModel({})
|
setWindDataByModel({})
|
||||||
setHydrDataByModel({})
|
setHydrDataByModel({})
|
||||||
setSummaryByModel({})
|
setSummaryByModel({})
|
||||||
|
setStepSummariesByModel({})
|
||||||
const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48)
|
const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48)
|
||||||
setOilTrajectory(demoTrajectory)
|
setOilTrajectory(demoTrajectory)
|
||||||
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings))
|
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings))
|
||||||
@ -825,7 +832,13 @@ export function OilSpillView() {
|
|||||||
incident: {
|
incident: {
|
||||||
name: accidentName,
|
name: accidentName,
|
||||||
occurTime,
|
occurTime,
|
||||||
location: selectedAnalysis?.location || analysisDetail?.acdnt?.location || '',
|
location: selectedAnalysis?.location || analysisDetail?.acdnt?.location || (() => {
|
||||||
|
const _lat = incidentCoord?.lat ?? selectedAnalysis?.lat ?? null;
|
||||||
|
const _lon = incidentCoord?.lon ?? selectedAnalysis?.lon ?? null;
|
||||||
|
return (_lat != null && _lon != null)
|
||||||
|
? `위도 ${Number(_lat).toFixed(4)}, 경도 ${Number(_lon).toFixed(4)}`
|
||||||
|
: '';
|
||||||
|
})(),
|
||||||
lat: incidentCoord?.lat ?? selectedAnalysis?.lat ?? null,
|
lat: incidentCoord?.lat ?? selectedAnalysis?.lat ?? null,
|
||||||
lon: incidentCoord?.lon ?? selectedAnalysis?.lon ?? null,
|
lon: incidentCoord?.lon ?? selectedAnalysis?.lon ?? null,
|
||||||
pollutant: OIL_TYPE_CODE[oilType] || oilType,
|
pollutant: OIL_TYPE_CODE[oilType] || oilType,
|
||||||
@ -859,6 +872,17 @@ export function OilSpillView() {
|
|||||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
})(),
|
})(),
|
||||||
},
|
},
|
||||||
|
spreadSteps: (() => {
|
||||||
|
const steps = stepSummariesByModel[windHydrModel] ?? [];
|
||||||
|
const toRow = (elapsed: string, s: typeof steps[0] | undefined) => ({
|
||||||
|
elapsed,
|
||||||
|
weathered: s ? s.weatheredVolume.toFixed(2) : '',
|
||||||
|
seaRemain: s ? s.remainingVolume.toFixed(2) : '',
|
||||||
|
coastAttach: s ? s.beachedVolume.toFixed(2) : '',
|
||||||
|
area: s ? s.pollutionArea.toFixed(2) : '',
|
||||||
|
});
|
||||||
|
return [toRow('3시간', steps[3]), toRow('6시간', steps[6])];
|
||||||
|
})(),
|
||||||
hasSimulation: simulationSummary !== null,
|
hasSimulation: simulationSummary !== null,
|
||||||
mapData: incidentCoord ? {
|
mapData: incidentCoord ? {
|
||||||
center: [incidentCoord.lat, incidentCoord.lon],
|
center: [incidentCoord.lat, incidentCoord.lon],
|
||||||
@ -1125,16 +1149,21 @@ export function OilSpillView() {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '14px' }}>
|
<div style={{ display: 'flex', gap: '14px' }}>
|
||||||
{[
|
{(() => {
|
||||||
{ label: '풍화율', value: `${Math.min(99, Math.round(progressPct * 0.4))}%` },
|
const stepSummary = stepSummariesByModel[windHydrModel]?.[currentStep] ?? null;
|
||||||
{ label: '면적', value: `${(progressPct * 0.08).toFixed(1)} km²` },
|
const weatheredVal = stepSummary ? `${stepSummary.weatheredVolume.toFixed(2)} m³` : '—';
|
||||||
{ label: '차단율', value: boomLines.length > 0 ? `${Math.min(95, 70 + Math.round(progressPct * 0.2))}%` : '—', color: 'var(--boom)' },
|
const areaVal = stepSummary ? `${stepSummary.pollutionArea.toFixed(1)} km²` : '—';
|
||||||
].map((s, i) => (
|
return [
|
||||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px' }}>
|
{ label: '풍화량', value: weatheredVal },
|
||||||
<span className="text-text-3">{s.label}</span>
|
{ label: '면적', value: areaVal },
|
||||||
<span style={{ color: s.color, fontWeight: 600, fontFamily: 'var(--fM)' }}>{s.value}</span>
|
{ label: '차단율', value: boomLines.length > 0 ? `${Math.min(95, 70 + Math.round(progressPct * 0.2))}%` : '—', color: 'var(--boom)' },
|
||||||
</div>
|
].map((s, i) => (
|
||||||
))}
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px' }}>
|
||||||
|
<span className="text-text-3">{s.label}</span>
|
||||||
|
<span style={{ color: s.color, fontWeight: 600, fontFamily: 'var(--fM)' }}>{s.value}</span>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1167,7 +1196,7 @@ export function OilSpillView() {
|
|||||||
onOpenRecalc={() => setRecalcModalOpen(true)}
|
onOpenRecalc={() => setRecalcModalOpen(true)}
|
||||||
onOpenReport={handleOpenReport}
|
onOpenReport={handleOpenReport}
|
||||||
detail={analysisDetail}
|
detail={analysisDetail}
|
||||||
summary={simulationSummary}
|
summary={stepSummariesByModel[windHydrModel]?.[currentStep] ?? summaryByModel[windHydrModel] ?? simulationSummary}
|
||||||
displayControls={displayControls}
|
displayControls={displayControls}
|
||||||
onDisplayControlsChange={setDisplayControls}
|
onDisplayControlsChange={setDisplayControls}
|
||||||
windHydrModel={windHydrModel}
|
windHydrModel={windHydrModel}
|
||||||
|
|||||||
@ -210,6 +210,7 @@ export interface TrajectoryResponse {
|
|||||||
windDataByModel?: Record<string, WindPoint[][]>;
|
windDataByModel?: Record<string, WindPoint[][]>;
|
||||||
hydrDataByModel?: Record<string, (HydrDataStep | null)[]>;
|
hydrDataByModel?: Record<string, (HydrDataStep | null)[]>;
|
||||||
summaryByModel?: Record<string, SimulationSummary>;
|
summaryByModel?: Record<string, SimulationSummary>;
|
||||||
|
stepSummariesByModel?: Record<string, SimulationSummary[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchAnalysisTrajectory = async (acdntSn: number): Promise<TrajectoryResponse> => {
|
export const fetchAnalysisTrajectory = async (acdntSn: number): Promise<TrajectoryResponse> => {
|
||||||
|
|||||||
@ -71,6 +71,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
report.author = '시스템 자동생성'
|
report.author = '시스템 자동생성'
|
||||||
if (activeCat === 0) {
|
if (activeCat === 0) {
|
||||||
if (oilPayload) {
|
if (oilPayload) {
|
||||||
|
// 사고 기본정보
|
||||||
report.incident.name = oilPayload.incident.name;
|
report.incident.name = oilPayload.incident.name;
|
||||||
report.incident.occurTime = oilPayload.incident.occurTime;
|
report.incident.occurTime = oilPayload.incident.occurTime;
|
||||||
report.incident.location = oilPayload.incident.location;
|
report.incident.location = oilPayload.incident.location;
|
||||||
@ -79,6 +80,49 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
report.incident.shipName = oilPayload.incident.shipName;
|
report.incident.shipName = oilPayload.incident.shipName;
|
||||||
report.incident.pollutant = oilPayload.pollution.oilType;
|
report.incident.pollutant = oilPayload.pollution.oilType;
|
||||||
report.incident.spillAmount = oilPayload.pollution.spillAmount;
|
report.incident.spillAmount = oilPayload.pollution.spillAmount;
|
||||||
|
// 해안부착 시간·오염길이 (oil-coastal)
|
||||||
|
report.incident.agent = [
|
||||||
|
oilPayload.coastal?.firstTime ? `해안도달: ${oilPayload.coastal.firstTime}` : '',
|
||||||
|
oilPayload.pollution.coastLength ? `오염해안: ${oilPayload.pollution.coastLength}km` : '',
|
||||||
|
].filter(Boolean).join(', ');
|
||||||
|
|
||||||
|
// 조석·기상정보 (oil-tide)
|
||||||
|
if (oilPayload.weather) {
|
||||||
|
report.weather = [{
|
||||||
|
time: '',
|
||||||
|
sunrise: '',
|
||||||
|
sunset: '',
|
||||||
|
windDir: oilPayload.weather.windDir,
|
||||||
|
windSpeed: oilPayload.weather.windSpeed,
|
||||||
|
currentDir: '',
|
||||||
|
currentSpeed: '',
|
||||||
|
waveHeight: oilPayload.weather.waveHeight,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오염종합상황 + 해안부착 현황 (oil-pollution, oil-coastal)
|
||||||
|
report.result = {
|
||||||
|
spillTotal: oilPayload.pollution.spillAmount,
|
||||||
|
weatheredTotal: oilPayload.pollution.weathered,
|
||||||
|
recoveredTotal: '',
|
||||||
|
seaRemainTotal: oilPayload.pollution.seaRemain,
|
||||||
|
coastAttachTotal: oilPayload.pollution.coastAttach,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 유출유확산예측 결과 — 모델별 비교 (oil-spread)
|
||||||
|
const spreadLines = [
|
||||||
|
oilPayload.spread.kosps ? `KOSPS: ${oilPayload.spread.kosps}` : '',
|
||||||
|
oilPayload.spread.openDrift ? `OpenDrift: ${oilPayload.spread.openDrift}` : '',
|
||||||
|
oilPayload.spread.poseidon ? `POSEIDON: ${oilPayload.spread.poseidon}` : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
if (spreadLines.length > 0) {
|
||||||
|
report.analysis = spreadLines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스텝별 오염종합 상황 (3h/6h) → report.spread
|
||||||
|
if (oilPayload.spreadSteps) {
|
||||||
|
report.spread = oilPayload.spreadSteps;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
report.incident.pollutant = '';
|
report.incident.pollutant = '';
|
||||||
report.incident.spillAmount = '';
|
report.incident.spillAmount = '';
|
||||||
|
|||||||
@ -344,6 +344,12 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// location이 비어있고 좌표가 있으면 좌표 문자열로 대체 (기존 보고서 대응)
|
||||||
|
if (!reportData.incident.location && reportData.incident.lat && reportData.incident.lon) {
|
||||||
|
reportData.incident.location =
|
||||||
|
`위도 ${parseFloat(reportData.incident.lat).toFixed(4)}, 경도 ${parseFloat(reportData.incident.lon).toFixed(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (detail.mapCaptureImg) {
|
if (detail.mapCaptureImg) {
|
||||||
reportData.capturedMapImage = detail.mapCaptureImg;
|
reportData.capturedMapImage = detail.mapCaptureImg;
|
||||||
}
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user