refactor(phase4-5): 대형 View 분할 + RBAC 권한 시스템 + DB 통합 + 게시판 CRUD #25
@ -1,11 +1,13 @@
|
|||||||
import type { Request, Response, NextFunction } from 'express'
|
import type { Request, Response, NextFunction } from 'express'
|
||||||
import { verifyToken, getTokenFromCookie } from './jwtProvider.js'
|
import { verifyToken, getTokenFromCookie } from './jwtProvider.js'
|
||||||
import type { JwtPayload } from './jwtProvider.js'
|
import type { JwtPayload } from './jwtProvider.js'
|
||||||
|
import { getUserInfo } from './authService.js'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace Express {
|
namespace Express {
|
||||||
interface Request {
|
interface Request {
|
||||||
user?: JwtPayload
|
user?: JwtPayload
|
||||||
|
resolvedPermissions?: Record<string, string[]>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -43,3 +45,43 @@ export function requireRole(...roles: string[]) {
|
|||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리소스 + 오퍼레이션 기반 권한 검사 미들웨어.
|
||||||
|
*
|
||||||
|
* OPER_CD는 HTTP Method가 아닌 비즈니스 의미로 결정한다.
|
||||||
|
* 오퍼레이션 미지정 시 기본 'READ'.
|
||||||
|
*
|
||||||
|
* 사용 예:
|
||||||
|
* router.post('/notice/list', requirePermission('board:notice', 'READ'), handler)
|
||||||
|
* router.post('/notice/create', requirePermission('board:notice', 'CREATE'), handler)
|
||||||
|
* router.post('/notice/update', requirePermission('board:notice', 'UPDATE'), handler)
|
||||||
|
* router.post('/notice/delete', requirePermission('board:notice', 'DELETE'), handler)
|
||||||
|
*/
|
||||||
|
export function requirePermission(resource: string, operation: string = 'READ') {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ error: '인증이 필요합니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// req에 캐싱된 permissions 재사용 (요청당 1회만 DB 조회)
|
||||||
|
if (!req.resolvedPermissions) {
|
||||||
|
const userInfo = await getUserInfo(req.user.sub)
|
||||||
|
req.resolvedPermissions = userInfo.permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedOps = req.resolvedPermissions[resource]
|
||||||
|
if (allowedOps && allowedOps.includes(operation)) {
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(403).json({ error: '접근 권한이 없습니다.' })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[auth] 권한 확인 오류:', err)
|
||||||
|
res.status(500).json({ error: '권한 확인 중 오류가 발생했습니다.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import bcrypt from 'bcrypt'
|
|||||||
import { authPool } from '../db/authDb.js'
|
import { authPool } from '../db/authDb.js'
|
||||||
import { signToken, setTokenCookie } from './jwtProvider.js'
|
import { signToken, setTokenCookie } from './jwtProvider.js'
|
||||||
import type { Response } from 'express'
|
import type { Response } from 'express'
|
||||||
|
import { resolvePermissions, makePermKey, grantedSetToRecord } from '../roles/permResolver.js'
|
||||||
|
import { getPermTreeNodes } from '../roles/roleService.js'
|
||||||
|
|
||||||
const MAX_FAIL_COUNT = 5
|
const MAX_FAIL_COUNT = 5
|
||||||
const SALT_ROUNDS = 10
|
const SALT_ROUNDS = 10
|
||||||
@ -24,7 +26,7 @@ interface AuthUserInfo {
|
|||||||
rank: string | null
|
rank: string | null
|
||||||
org: { sn: number; name: string; abbr: string } | null
|
org: { sn: number; name: string; abbr: string } | null
|
||||||
roles: string[]
|
roles: string[]
|
||||||
permissions: string[]
|
permissions: Record<string, string[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(
|
export async function login(
|
||||||
@ -127,9 +129,9 @@ export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
|
|||||||
|
|
||||||
const row = userResult.rows[0]
|
const row = userResult.rows[0]
|
||||||
|
|
||||||
// 역할 조회
|
// 역할 조회 (ROLE_SN + ROLE_CD)
|
||||||
const rolesResult = await authPool.query(
|
const rolesResult = await authPool.query(
|
||||||
`SELECT r.ROLE_CD as role_cd
|
`SELECT r.ROLE_SN as role_sn, r.ROLE_CD as role_cd
|
||||||
FROM AUTH_USER_ROLE ur
|
FROM AUTH_USER_ROLE ur
|
||||||
JOIN AUTH_ROLE r ON ur.ROLE_SN = r.ROLE_SN
|
JOIN AUTH_ROLE r ON ur.ROLE_SN = r.ROLE_SN
|
||||||
WHERE ur.USER_ID = $1`,
|
WHERE ur.USER_ID = $1`,
|
||||||
@ -137,17 +139,63 @@ export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const roles = rolesResult.rows.map((r: { role_cd: string }) => r.role_cd)
|
const roles = rolesResult.rows.map((r: { role_cd: string }) => r.role_cd)
|
||||||
|
const roleSns = rolesResult.rows.map((r: { role_sn: number }) => r.role_sn)
|
||||||
|
|
||||||
// 권한 조회 (역할 기반)
|
// 트리 기반 resolved permissions (리소스 × 오퍼레이션)
|
||||||
const permsResult = await authPool.query(
|
let permissions: Record<string, string[]>
|
||||||
`SELECT DISTINCT p.RSRC_CD as rsrc_cd
|
try {
|
||||||
FROM AUTH_PERM p
|
const treeNodes = await getPermTreeNodes()
|
||||||
JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN
|
|
||||||
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`,
|
|
||||||
[userId]
|
|
||||||
)
|
|
||||||
|
|
||||||
const permissions = permsResult.rows.map((p: { rsrc_cd: string }) => p.rsrc_cd)
|
if (treeNodes.length > 0) {
|
||||||
|
// AUTH_PERM_TREE가 존재 → 트리 기반 resolve
|
||||||
|
const explicitPermsResult = await authPool.query(
|
||||||
|
`SELECT ROLE_SN as role_sn, RSRC_CD as rsrc_cd, OPER_CD as oper_cd, GRANT_YN as grant_yn
|
||||||
|
FROM AUTH_PERM WHERE ROLE_SN = ANY($1)`,
|
||||||
|
[roleSns]
|
||||||
|
)
|
||||||
|
|
||||||
|
const explicitPermsPerRole = new Map<number, Map<string, boolean>>()
|
||||||
|
for (const sn of roleSns) {
|
||||||
|
explicitPermsPerRole.set(sn, new Map())
|
||||||
|
}
|
||||||
|
for (const p of explicitPermsResult.rows) {
|
||||||
|
const roleMap = explicitPermsPerRole.get(p.role_sn)
|
||||||
|
if (roleMap) {
|
||||||
|
const key = makePermKey(p.rsrc_cd, p.oper_cd)
|
||||||
|
roleMap.set(key, p.grant_yn === 'Y')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const granted = resolvePermissions(treeNodes, explicitPermsPerRole)
|
||||||
|
permissions = grantedSetToRecord(granted)
|
||||||
|
} else {
|
||||||
|
// AUTH_PERM_TREE 미존재 (마이그레이션 전) → 기존 플랫 방식 fallback
|
||||||
|
const permsResult = await authPool.query(
|
||||||
|
`SELECT DISTINCT p.RSRC_CD as rsrc_cd
|
||||||
|
FROM AUTH_PERM p
|
||||||
|
JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN
|
||||||
|
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`,
|
||||||
|
[userId]
|
||||||
|
)
|
||||||
|
permissions = {}
|
||||||
|
for (const p of permsResult.rows) {
|
||||||
|
permissions[p.rsrc_cd] = ['READ']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// AUTH_PERM_TREE 테이블 미존재 시 fallback
|
||||||
|
const permsResult = await authPool.query(
|
||||||
|
`SELECT DISTINCT p.RSRC_CD as rsrc_cd
|
||||||
|
FROM AUTH_PERM p
|
||||||
|
JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN
|
||||||
|
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`,
|
||||||
|
[userId]
|
||||||
|
)
|
||||||
|
permissions = {}
|
||||||
|
for (const p of permsResult.rows) {
|
||||||
|
permissions[p.rsrc_cd] = ['READ']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.user_id,
|
id: row.user_id,
|
||||||
|
|||||||
137
backend/src/board/boardRouter.ts
Normal file
137
backend/src/board/boardRouter.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
||||||
|
import { AuthError } from '../auth/authService.js'
|
||||||
|
import { listPosts, getPost, createPost, updatePost, deletePost } from './boardService.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
// 카테고리 → 리소스 매핑
|
||||||
|
const CATEGORY_RESOURCE: Record<string, string> = {
|
||||||
|
NOTICE: 'board:notice',
|
||||||
|
DATA: 'board:data',
|
||||||
|
QNA: 'board:qna',
|
||||||
|
MANUAL: 'board:manual',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GET /api/board — 게시글 목록
|
||||||
|
// ============================================================
|
||||||
|
router.get('/', requireAuth, requirePermission('board', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { categoryCd, search, page, size } = req.query
|
||||||
|
const result = await listPosts({
|
||||||
|
categoryCd: categoryCd as string | undefined,
|
||||||
|
search: search as string | undefined,
|
||||||
|
page: page ? parseInt(page as string, 10) : undefined,
|
||||||
|
size: size ? parseInt(size as string, 10) : undefined,
|
||||||
|
})
|
||||||
|
res.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[board] 목록 조회 오류:', err)
|
||||||
|
res.status(500).json({ error: '게시글 목록 조회 중 오류가 발생했습니다.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GET /api/board/:sn — 게시글 상세
|
||||||
|
// ============================================================
|
||||||
|
router.get('/:sn', requireAuth, requirePermission('board', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sn = parseInt(req.params.sn as string, 10)
|
||||||
|
if (isNaN(sn)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const post = await getPost(sn)
|
||||||
|
res.json(post)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AuthError) {
|
||||||
|
res.status(err.status).json({ error: err.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error('[board] 상세 조회 오류:', err)
|
||||||
|
res.status(500).json({ error: '게시글 조회 중 오류가 발생했습니다.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// POST /api/board — 게시글 작성 (카테고리별 CREATE 권한)
|
||||||
|
// ============================================================
|
||||||
|
router.post('/', requireAuth, async (req, res, next) => {
|
||||||
|
const resource = CATEGORY_RESOURCE[req.body.categoryCd] || 'board'
|
||||||
|
requirePermission(resource, 'CREATE')(req, res, next)
|
||||||
|
}, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { categoryCd, title, content, pinnedYn } = req.body
|
||||||
|
|
||||||
|
if (!categoryCd || !title) {
|
||||||
|
res.status(400).json({ error: '카테고리와 제목은 필수입니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createPost({
|
||||||
|
categoryCd,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
authorId: req.user!.sub,
|
||||||
|
pinnedYn,
|
||||||
|
})
|
||||||
|
res.status(201).json(result)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AuthError) {
|
||||||
|
res.status(err.status).json({ error: err.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error('[board] 작성 오류:', err)
|
||||||
|
res.status(500).json({ error: '게시글 작성 중 오류가 발생했습니다.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PUT /api/board/:sn — 게시글 수정 (소유자 검증은 서비스에서)
|
||||||
|
// ============================================================
|
||||||
|
router.put('/:sn', requireAuth, requirePermission('board', 'UPDATE'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sn = parseInt(req.params.sn as string, 10)
|
||||||
|
if (isNaN(sn)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, content, pinnedYn } = req.body
|
||||||
|
await updatePost(sn, { title, content, pinnedYn }, req.user!.sub)
|
||||||
|
res.json({ success: true })
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AuthError) {
|
||||||
|
res.status(err.status).json({ error: err.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error('[board] 수정 오류:', err)
|
||||||
|
res.status(500).json({ error: '게시글 수정 중 오류가 발생했습니다.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DELETE /api/board/:sn — 게시글 삭제 (논리 삭제, 소유자 검증)
|
||||||
|
// ============================================================
|
||||||
|
router.delete('/:sn', requireAuth, requirePermission('board', 'DELETE'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sn = parseInt(req.params.sn as string, 10)
|
||||||
|
if (isNaN(sn)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await deletePost(sn, req.user!.sub)
|
||||||
|
res.json({ success: true })
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AuthError) {
|
||||||
|
res.status(err.status).json({ error: err.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error('[board] 삭제 오류:', err)
|
||||||
|
res.status(500).json({ error: '게시글 삭제 중 오류가 발생했습니다.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
243
backend/src/board/boardService.ts
Normal file
243
backend/src/board/boardService.ts
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
import { wingPool } from '../db/wingDb.js'
|
||||||
|
import { AuthError } from '../auth/authService.js'
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 인터페이스
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface PostListItem {
|
||||||
|
sn: number
|
||||||
|
categoryCd: string
|
||||||
|
title: string
|
||||||
|
authorId: string
|
||||||
|
authorName: string
|
||||||
|
viewCnt: number
|
||||||
|
pinnedYn: string
|
||||||
|
regDtm: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PostDetail extends PostListItem {
|
||||||
|
content: string | null
|
||||||
|
mdfcnDtm: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListPostsInput {
|
||||||
|
categoryCd?: string
|
||||||
|
search?: string
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListPostsResult {
|
||||||
|
items: PostListItem[]
|
||||||
|
totalCount: number
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatePostInput {
|
||||||
|
categoryCd: string
|
||||||
|
title: string
|
||||||
|
content?: string
|
||||||
|
authorId: string
|
||||||
|
pinnedYn?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdatePostInput {
|
||||||
|
title?: string
|
||||||
|
content?: string
|
||||||
|
pinnedYn?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// CRUD 함수
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const VALID_CATEGORIES = ['NOTICE', 'DATA', 'QNA', 'MANUAL']
|
||||||
|
|
||||||
|
export async function listPosts(input: ListPostsInput): Promise<ListPostsResult> {
|
||||||
|
const page = input.page && input.page > 0 ? input.page : 1
|
||||||
|
const size = input.size && input.size > 0 ? Math.min(input.size, 100) : 20
|
||||||
|
const offset = (page - 1) * size
|
||||||
|
|
||||||
|
let whereClause = `WHERE bp.USE_YN = 'Y'`
|
||||||
|
const params: (string | number)[] = []
|
||||||
|
let paramIdx = 1
|
||||||
|
|
||||||
|
if (input.categoryCd) {
|
||||||
|
whereClause += ` AND bp.CATEGORY_CD = $${paramIdx++}`
|
||||||
|
params.push(input.categoryCd)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.search) {
|
||||||
|
whereClause += ` AND (bp.TITLE ILIKE $${paramIdx} OR u.USER_NM ILIKE $${paramIdx})`
|
||||||
|
params.push(`%${input.search}%`)
|
||||||
|
paramIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전체 건수
|
||||||
|
const countResult = await wingPool.query(
|
||||||
|
`SELECT COUNT(*) as cnt
|
||||||
|
FROM BOARD_POST bp
|
||||||
|
JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
const totalCount = parseInt(countResult.rows[0].cnt, 10)
|
||||||
|
|
||||||
|
// 목록 (상단고정 우선, 등록일 내림차순)
|
||||||
|
const listParams = [...params, size, offset]
|
||||||
|
const listResult = await wingPool.query(
|
||||||
|
`SELECT bp.POST_SN as sn, bp.CATEGORY_CD as category_cd, bp.TITLE as title,
|
||||||
|
bp.AUTHOR_ID as author_id, u.USER_NM as author_name,
|
||||||
|
bp.VIEW_CNT as view_cnt, bp.PINNED_YN as pinned_yn,
|
||||||
|
bp.REG_DTM as reg_dtm
|
||||||
|
FROM BOARD_POST bp
|
||||||
|
JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY bp.PINNED_YN DESC, bp.REG_DTM DESC
|
||||||
|
LIMIT $${paramIdx++} OFFSET $${paramIdx}`,
|
||||||
|
listParams
|
||||||
|
)
|
||||||
|
|
||||||
|
const items: PostListItem[] = listResult.rows.map((r: Record<string, unknown>) => ({
|
||||||
|
sn: r.sn as number,
|
||||||
|
categoryCd: r.category_cd as string,
|
||||||
|
title: r.title as string,
|
||||||
|
authorId: r.author_id as string,
|
||||||
|
authorName: r.author_name as string,
|
||||||
|
viewCnt: r.view_cnt as number,
|
||||||
|
pinnedYn: r.pinned_yn as string,
|
||||||
|
regDtm: r.reg_dtm as string,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { items, totalCount, page, size }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPost(postSn: number): Promise<PostDetail> {
|
||||||
|
// 조회수 증가 + 상세 조회 (단일 쿼리)
|
||||||
|
const result = await wingPool.query(
|
||||||
|
`UPDATE BOARD_POST SET VIEW_CNT = VIEW_CNT + 1
|
||||||
|
WHERE POST_SN = $1 AND USE_YN = 'Y'
|
||||||
|
RETURNING POST_SN as sn, CATEGORY_CD as category_cd, TITLE as title,
|
||||||
|
CONTENT as content, AUTHOR_ID as author_id,
|
||||||
|
VIEW_CNT as view_cnt, PINNED_YN as pinned_yn,
|
||||||
|
REG_DTM as reg_dtm, MDFCN_DTM as mdfcn_dtm`,
|
||||||
|
[postSn]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = result.rows[0]
|
||||||
|
|
||||||
|
// 작성자명 조회
|
||||||
|
const authorResult = await wingPool.query(
|
||||||
|
'SELECT USER_NM as name FROM AUTH_USER WHERE USER_ID = $1',
|
||||||
|
[row.author_id]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
sn: row.sn,
|
||||||
|
categoryCd: row.category_cd,
|
||||||
|
title: row.title,
|
||||||
|
content: row.content,
|
||||||
|
authorId: row.author_id,
|
||||||
|
authorName: authorResult.rows[0]?.name || '알 수 없음',
|
||||||
|
viewCnt: row.view_cnt,
|
||||||
|
pinnedYn: row.pinned_yn,
|
||||||
|
regDtm: row.reg_dtm,
|
||||||
|
mdfcnDtm: row.mdfcn_dtm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPost(input: CreatePostInput): Promise<{ sn: number }> {
|
||||||
|
if (!VALID_CATEGORIES.includes(input.categoryCd)) {
|
||||||
|
throw new AuthError('유효하지 않은 카테고리입니다.', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.title || input.title.trim().length === 0) {
|
||||||
|
throw new AuthError('제목은 필수입니다.', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await wingPool.query(
|
||||||
|
`INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID, PINNED_YN)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING POST_SN as sn`,
|
||||||
|
[input.categoryCd, input.title.trim(), input.content || null, input.authorId, input.pinnedYn || 'N']
|
||||||
|
)
|
||||||
|
|
||||||
|
return { sn: result.rows[0].sn }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePost(
|
||||||
|
postSn: number,
|
||||||
|
input: UpdatePostInput,
|
||||||
|
requesterId: string
|
||||||
|
): Promise<void> {
|
||||||
|
// 게시글 존재 + 작성자 확인
|
||||||
|
const existing = await wingPool.query(
|
||||||
|
`SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`,
|
||||||
|
[postSn]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existing.rows.length === 0) {
|
||||||
|
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.rows[0].author_id !== requesterId) {
|
||||||
|
throw new AuthError('본인의 게시글만 수정할 수 있습니다.', 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sets: string[] = []
|
||||||
|
const params: (string | number | null)[] = []
|
||||||
|
let idx = 1
|
||||||
|
|
||||||
|
if (input.title !== undefined) {
|
||||||
|
sets.push(`TITLE = $${idx++}`)
|
||||||
|
params.push(input.title.trim())
|
||||||
|
}
|
||||||
|
if (input.content !== undefined) {
|
||||||
|
sets.push(`CONTENT = $${idx++}`)
|
||||||
|
params.push(input.content)
|
||||||
|
}
|
||||||
|
if (input.pinnedYn !== undefined) {
|
||||||
|
sets.push(`PINNED_YN = $${idx++}`)
|
||||||
|
params.push(input.pinnedYn)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sets.length === 0) {
|
||||||
|
throw new AuthError('수정할 항목이 없습니다.', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
sets.push('MDFCN_DTM = NOW()')
|
||||||
|
params.push(postSn)
|
||||||
|
|
||||||
|
await wingPool.query(
|
||||||
|
`UPDATE BOARD_POST SET ${sets.join(', ')} WHERE POST_SN = $${idx}`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePost(postSn: number, requesterId: string): Promise<void> {
|
||||||
|
// 게시글 존재 + 작성자 확인
|
||||||
|
const existing = await wingPool.query(
|
||||||
|
`SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`,
|
||||||
|
[postSn]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existing.rows.length === 0) {
|
||||||
|
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.rows[0].author_id !== requesterId) {
|
||||||
|
throw new AuthError('본인의 게시글만 삭제할 수 있습니다.', 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 논리 삭제
|
||||||
|
await wingPool.query(
|
||||||
|
`UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`,
|
||||||
|
[postSn]
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,33 +1,13 @@
|
|||||||
import pg from 'pg'
|
// ============================================================
|
||||||
|
// 하위 호환: authPool → wingPool re-export
|
||||||
|
// DB 통합으로 wing_auth DB가 wing DB의 auth 스키마로 이전됨.
|
||||||
|
// 기존 코드에서 authPool을 import하는 곳에서 에러 없이 동작하도록 유지.
|
||||||
|
// 신규 코드는 wingDb.ts의 wingPool을 직접 import할 것.
|
||||||
|
// ============================================================
|
||||||
|
import { wingPool, testWingDbConnection } from './wingDb.js'
|
||||||
|
|
||||||
const { Pool } = pg
|
export const authPool = wingPool
|
||||||
|
|
||||||
const authPool = new Pool({
|
|
||||||
host: process.env.AUTH_DB_HOST || 'localhost',
|
|
||||||
port: Number(process.env.AUTH_DB_PORT) || 5432,
|
|
||||||
database: process.env.AUTH_DB_NAME || 'wing_auth',
|
|
||||||
user: process.env.AUTH_DB_USER || 'wing_auth',
|
|
||||||
password: process.env.AUTH_DB_PASSWORD || 'WingAuth2026',
|
|
||||||
max: 10,
|
|
||||||
idleTimeoutMillis: 30000,
|
|
||||||
connectionTimeoutMillis: 5000,
|
|
||||||
})
|
|
||||||
|
|
||||||
authPool.on('error', (err) => {
|
|
||||||
console.error('[authDb] 예기치 않은 연결 오류:', err.message)
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function testAuthDbConnection(): Promise<boolean> {
|
export async function testAuthDbConnection(): Promise<boolean> {
|
||||||
try {
|
return testWingDbConnection()
|
||||||
const client = await authPool.connect()
|
|
||||||
await client.query('SELECT 1')
|
|
||||||
client.release()
|
|
||||||
console.log('[authDb] wing_auth 데이터베이스 연결 성공')
|
|
||||||
return true
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[authDb] wing_auth 데이터베이스 연결 실패:', (err as Error).message)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { authPool }
|
|
||||||
|
|||||||
@ -2,19 +2,30 @@ import pg from 'pg'
|
|||||||
|
|
||||||
const { Pool } = pg
|
const { Pool } = pg
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// wing DB 통합 Pool (wing 스키마 + auth 스키마)
|
||||||
|
// - wing 스키마: 운영 데이터 (LAYER, BOARD_POST 등)
|
||||||
|
// - auth 스키마: 인증/인가 데이터 (AUTH_USER, AUTH_ROLE 등)
|
||||||
|
// - public 스키마: PostGIS 시스템 테이블만 유지
|
||||||
|
// ============================================================
|
||||||
const wingPool = new Pool({
|
const wingPool = new Pool({
|
||||||
host: process.env.WING_DB_HOST || 'localhost',
|
host: process.env.DB_HOST || process.env.WING_DB_HOST || 'localhost',
|
||||||
port: Number(process.env.WING_DB_PORT) || 5432,
|
port: Number(process.env.DB_PORT || process.env.WING_DB_PORT) || 5432,
|
||||||
database: process.env.WING_DB_NAME || 'wing',
|
database: process.env.DB_NAME || process.env.WING_DB_NAME || 'wing',
|
||||||
user: process.env.WING_DB_USER || 'wing',
|
user: process.env.DB_USER || process.env.WING_DB_USER || 'wing',
|
||||||
password: process.env.WING_DB_PASSWORD || 'Wing2026',
|
password: process.env.DB_PASSWORD || process.env.WING_DB_PASSWORD || 'Wing2026',
|
||||||
max: 10,
|
max: 20,
|
||||||
idleTimeoutMillis: 30000,
|
idleTimeoutMillis: 30000,
|
||||||
connectionTimeoutMillis: 5000,
|
connectionTimeoutMillis: 5000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 연결 시 search_path 자동 설정 (public 미사용)
|
||||||
|
wingPool.on('connect', (client) => {
|
||||||
|
client.query('SET search_path = wing, auth, public')
|
||||||
|
})
|
||||||
|
|
||||||
wingPool.on('error', (err) => {
|
wingPool.on('error', (err) => {
|
||||||
console.error('[wingDb] 예기치 않은 연결 오류:', err.message)
|
console.error('[db] 예기치 않은 연결 오류:', err.message)
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function testWingDbConnection(): Promise<boolean> {
|
export async function testWingDbConnection(): Promise<boolean> {
|
||||||
@ -22,10 +33,10 @@ export async function testWingDbConnection(): Promise<boolean> {
|
|||||||
const client = await wingPool.connect()
|
const client = await wingPool.connect()
|
||||||
await client.query('SELECT 1')
|
await client.query('SELECT 1')
|
||||||
client.release()
|
client.release()
|
||||||
console.log('[wingDb] wing 데이터베이스 연결 성공')
|
console.log('[db] wing 데이터베이스 연결 성공 (wing + auth 스키마)')
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[wingDb] wing 데이터베이스 연결 실패:', (err as Error).message)
|
console.warn('[db] wing 데이터베이스 연결 실패:', (err as Error).message)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
197
backend/src/roles/permResolver.ts
Normal file
197
backend/src/roles/permResolver.ts
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* 트리 구조 기반 권한 해석(Resolution) 유틸.
|
||||||
|
*
|
||||||
|
* 2차원 모델: 리소스 트리(상속) × 오퍼레이션(RCUD, 플랫)
|
||||||
|
*
|
||||||
|
* 규칙:
|
||||||
|
* 1. 부모 리소스의 READ가 N → 자식의 모든 오퍼레이션 강제 N
|
||||||
|
* 2. 해당 (RSRC_CD, OPER_CD) 명시적 레코드 있으면 → 그 값 사용
|
||||||
|
* 3. 명시적 레코드 없으면 → 부모의 같은 OPER_CD 상속
|
||||||
|
* 4. 최상위까지 없으면 → 기본 N (거부)
|
||||||
|
*
|
||||||
|
* 키 형식: "rsrcCode::operCd" (더블콜론으로 리소스와 오퍼레이션 구분)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const OPERATIONS = ['READ', 'CREATE', 'UPDATE', 'DELETE'] as const
|
||||||
|
export type OperationCode = (typeof OPERATIONS)[number]
|
||||||
|
|
||||||
|
export interface PermTreeNode {
|
||||||
|
code: string
|
||||||
|
parentCode: string | null
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
icon: string | null
|
||||||
|
level: number
|
||||||
|
sortOrder: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 리소스::오퍼레이션 키 생성 */
|
||||||
|
export function makePermKey(rsrcCode: string, operCd: string): string {
|
||||||
|
return `${rsrcCode}::${operCd}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 키에서 리소스 코드와 오퍼레이션 코드 분리 */
|
||||||
|
export function parsePermKey(key: string): { rsrcCode: string; operCd: string } {
|
||||||
|
const idx = key.indexOf('::')
|
||||||
|
return {
|
||||||
|
rsrcCode: key.substring(0, idx),
|
||||||
|
operCd: key.substring(idx + 2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트리 노드 + 역할별 명시적 권한 → granted된 "rsrc::oper" Set 반환.
|
||||||
|
* 다중 역할: 역할별 resolve 후 OR (하나라도 Y면 Y).
|
||||||
|
*/
|
||||||
|
export function resolvePermissions(
|
||||||
|
treeNodes: PermTreeNode[],
|
||||||
|
explicitPermsPerRole: Map<number, Map<string, boolean>>,
|
||||||
|
): Set<string> {
|
||||||
|
const granted = new Set<string>()
|
||||||
|
|
||||||
|
const nodeMap = new Map<string, PermTreeNode>()
|
||||||
|
for (const node of treeNodes) {
|
||||||
|
nodeMap.set(node.code, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, explicitPerms] of explicitPermsPerRole) {
|
||||||
|
const roleResolved = resolveForSingleRole(treeNodes, nodeMap, explicitPerms)
|
||||||
|
for (const key of roleResolved) {
|
||||||
|
granted.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return granted
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 역할에 대한 권한 해석.
|
||||||
|
*/
|
||||||
|
function resolveForSingleRole(
|
||||||
|
treeNodes: PermTreeNode[],
|
||||||
|
nodeMap: Map<string, PermTreeNode>,
|
||||||
|
explicitPerms: Map<string, boolean>,
|
||||||
|
): Set<string> {
|
||||||
|
const effective = new Map<string, boolean>()
|
||||||
|
|
||||||
|
// 레벨 순(0→1→2→...)으로 처리하여 부모 → 자식 순서 보장
|
||||||
|
const sorted = [...treeNodes].sort((a, b) => a.level - b.level || a.sortOrder - b.sortOrder)
|
||||||
|
|
||||||
|
for (const node of sorted) {
|
||||||
|
// READ 먼저 resolve (CUD는 READ 결과에 의존)
|
||||||
|
resolveNodeOper(node, 'READ', explicitPerms, effective)
|
||||||
|
|
||||||
|
// CUD resolve
|
||||||
|
for (const oper of OPERATIONS) {
|
||||||
|
if (oper === 'READ') continue
|
||||||
|
resolveNodeOper(node, oper, explicitPerms, effective)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const granted = new Set<string>()
|
||||||
|
for (const [key, value] of effective) {
|
||||||
|
if (value) granted.add(key)
|
||||||
|
}
|
||||||
|
return granted
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개별 노드 × 오퍼레이션의 effective 값 계산.
|
||||||
|
*/
|
||||||
|
function resolveNodeOper(
|
||||||
|
node: PermTreeNode,
|
||||||
|
operCd: string,
|
||||||
|
explicitPerms: Map<string, boolean>,
|
||||||
|
effective: Map<string, boolean>,
|
||||||
|
): void {
|
||||||
|
const key = makePermKey(node.code, operCd)
|
||||||
|
if (effective.has(key)) return
|
||||||
|
|
||||||
|
const explicit = explicitPerms.get(key)
|
||||||
|
|
||||||
|
if (node.parentCode === null) {
|
||||||
|
// 최상위: 명시적 값 또는 기본 거부
|
||||||
|
effective.set(key, explicit ?? false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부모의 READ 확인 (접근 게이트)
|
||||||
|
const parentReadKey = makePermKey(node.parentCode, 'READ')
|
||||||
|
const parentReadEffective = effective.get(parentReadKey)
|
||||||
|
|
||||||
|
if (parentReadEffective === false) {
|
||||||
|
// 부모 READ 차단 → 모든 오퍼레이션 강제 차단
|
||||||
|
effective.set(key, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 명시적 값 있으면 사용
|
||||||
|
if (explicit !== undefined) {
|
||||||
|
effective.set(key, explicit)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부모의 같은 오퍼레이션 상속
|
||||||
|
const parentOperKey = makePermKey(node.parentCode, operCd)
|
||||||
|
const parentOperEffective = effective.get(parentOperKey)
|
||||||
|
effective.set(key, parentOperEffective ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resolved Set → Record<rsrcCode, operCd[]> 변환 (API 반환용).
|
||||||
|
*/
|
||||||
|
export function grantedSetToRecord(granted: Set<string>): Record<string, string[]> {
|
||||||
|
const result: Record<string, string[]> = {}
|
||||||
|
for (const key of granted) {
|
||||||
|
const { rsrcCode, operCd } = parsePermKey(key)
|
||||||
|
if (!result[rsrcCode]) result[rsrcCode] = []
|
||||||
|
result[rsrcCode].push(operCd)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플랫 노드 배열 → 트리 구조 변환 (프론트엔드 UI용).
|
||||||
|
*/
|
||||||
|
export interface PermTreeResponse {
|
||||||
|
code: string
|
||||||
|
parentCode: string | null
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
icon: string | null
|
||||||
|
level: number
|
||||||
|
sortOrder: number
|
||||||
|
children: PermTreeResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPermTree(nodes: PermTreeNode[]): PermTreeResponse[] {
|
||||||
|
const nodeMap = new Map<string, PermTreeResponse>()
|
||||||
|
const roots: PermTreeResponse[] = []
|
||||||
|
|
||||||
|
const sorted = [...nodes].sort((a, b) => a.level - b.level || a.sortOrder - b.sortOrder)
|
||||||
|
|
||||||
|
for (const node of sorted) {
|
||||||
|
const treeNode: PermTreeResponse = {
|
||||||
|
code: node.code,
|
||||||
|
parentCode: node.parentCode,
|
||||||
|
name: node.name,
|
||||||
|
description: node.description,
|
||||||
|
icon: node.icon,
|
||||||
|
level: node.level,
|
||||||
|
sortOrder: node.sortOrder,
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
|
nodeMap.set(node.code, treeNode)
|
||||||
|
|
||||||
|
if (node.parentCode === null) {
|
||||||
|
roots.push(treeNode)
|
||||||
|
} else {
|
||||||
|
const parent = nodeMap.get(node.parentCode)
|
||||||
|
if (parent) {
|
||||||
|
parent.children.push(treeNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots
|
||||||
|
}
|
||||||
@ -1,13 +1,24 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
||||||
import { AuthError } from '../auth/authService.js'
|
import { AuthError } from '../auth/authService.js'
|
||||||
import { listRolesWithPermissions, createRole, updateRole, deleteRole, updatePermissions, updateRoleDefault } from './roleService.js'
|
import { listRolesWithPermissions, createRole, updateRole, deleteRole, updatePermissions, updateRoleDefault, getPermTree } from './roleService.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
router.use(requireAuth)
|
router.use(requireAuth)
|
||||||
router.use(requireRole('ADMIN'))
|
router.use(requireRole('ADMIN'))
|
||||||
|
|
||||||
|
// GET /api/roles/perm-tree — 권한 트리 구조 조회
|
||||||
|
router.get('/perm-tree', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const tree = await getPermTree()
|
||||||
|
res.json(tree)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[roles] 권한 트리 조회 오류:', err)
|
||||||
|
res.status(500).json({ error: '권한 트리 조회 중 오류가 발생했습니다.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// GET /api/roles
|
// GET /api/roles
|
||||||
router.get('/', async (_req, res) => {
|
router.get('/', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
@ -76,6 +87,7 @@ router.delete('/:id', async (req, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// PUT /api/roles/:id/permissions
|
// PUT /api/roles/:id/permissions
|
||||||
|
// 요청: { permissions: [{ resourceCode, operationCode, granted }] }
|
||||||
router.put('/:id/permissions', async (req, res) => {
|
router.put('/:id/permissions', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const roleSn = Number(req.params.id)
|
const roleSn = Number(req.params.id)
|
||||||
@ -86,6 +98,13 @@ router.put('/:id/permissions', async (req, res) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const p of permissions) {
|
||||||
|
if (!p.resourceCode || !p.operationCode || typeof p.granted !== 'boolean') {
|
||||||
|
res.status(400).json({ error: '각 권한에는 resourceCode, operationCode, granted가 필요합니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await updatePermissions(roleSn, permissions)
|
await updatePermissions(roleSn, permissions)
|
||||||
res.json({ success: true })
|
res.json({ success: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -1,13 +1,34 @@
|
|||||||
import { authPool } from '../db/authDb.js'
|
import { authPool } from '../db/authDb.js'
|
||||||
import { AuthError } from '../auth/authService.js'
|
import { AuthError } from '../auth/authService.js'
|
||||||
|
import { type PermTreeNode, buildPermTree, type PermTreeResponse } from './permResolver.js'
|
||||||
const PERM_RESOURCE_CODES = [
|
|
||||||
'prediction', 'hns', 'rescue', 'reports', 'aerial',
|
|
||||||
'assets', 'scat', 'incidents', 'board', 'weather', 'admin',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const PROTECTED_ROLE_CODES = ['ADMIN']
|
const PROTECTED_ROLE_CODES = ['ADMIN']
|
||||||
|
|
||||||
|
/** AUTH_PERM_TREE에서 level 0 리소스 코드를 동적 조회 */
|
||||||
|
async function getTopLevelResourceCodes(): Promise<string[]> {
|
||||||
|
const result = await authPool.query(
|
||||||
|
`SELECT RSRC_CD FROM AUTH_PERM_TREE WHERE RSRC_LEVEL = 0 AND USE_YN = 'Y' ORDER BY SORT_ORD`
|
||||||
|
)
|
||||||
|
return result.rows.map((r: { rsrc_cd: string }) => r.rsrc_cd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AUTH_PERM_TREE 전체 노드 조회 */
|
||||||
|
export async function getPermTreeNodes(): Promise<PermTreeNode[]> {
|
||||||
|
const result = await authPool.query(
|
||||||
|
`SELECT RSRC_CD as code, PARENT_CD as "parentCode", RSRC_NM as name,
|
||||||
|
RSRC_DESC as description, ICON as icon, RSRC_LEVEL as level, SORT_ORD as "sortOrder"
|
||||||
|
FROM AUTH_PERM_TREE WHERE USE_YN = 'Y'
|
||||||
|
ORDER BY RSRC_LEVEL, SORT_ORD`
|
||||||
|
)
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 트리 구조로 변환하여 반환 (프론트엔드 UI용) */
|
||||||
|
export async function getPermTree(): Promise<PermTreeResponse[]> {
|
||||||
|
const nodes = await getPermTreeNodes()
|
||||||
|
return buildPermTree(nodes)
|
||||||
|
}
|
||||||
|
|
||||||
interface RoleWithPermissions {
|
interface RoleWithPermissions {
|
||||||
sn: number
|
sn: number
|
||||||
code: string
|
code: string
|
||||||
@ -17,6 +38,7 @@ interface RoleWithPermissions {
|
|||||||
permissions: Array<{
|
permissions: Array<{
|
||||||
sn: number
|
sn: number
|
||||||
resourceCode: string
|
resourceCode: string
|
||||||
|
operationCode: string
|
||||||
granted: boolean
|
granted: boolean
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
@ -42,8 +64,8 @@ export async function listRolesWithPermissions(): Promise<RoleWithPermissions[]>
|
|||||||
|
|
||||||
for (const row of rolesResult.rows) {
|
for (const row of rolesResult.rows) {
|
||||||
const permsResult = await authPool.query(
|
const permsResult = await authPool.query(
|
||||||
`SELECT PERM_SN as sn, RSRC_CD as resource_code, GRANT_YN as granted
|
`SELECT PERM_SN as sn, RSRC_CD as resource_code, OPER_CD as operation_code, GRANT_YN as granted
|
||||||
FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD`,
|
FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD, OPER_CD`,
|
||||||
[row.sn]
|
[row.sn]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -53,9 +75,12 @@ export async function listRolesWithPermissions(): Promise<RoleWithPermissions[]>
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
isDefault: row.is_default === 'Y',
|
isDefault: row.is_default === 'Y',
|
||||||
permissions: permsResult.rows.map((p: { sn: number; resource_code: string; granted: string }) => ({
|
permissions: permsResult.rows.map((p: {
|
||||||
|
sn: number; resource_code: string; operation_code: string; granted: string
|
||||||
|
}) => ({
|
||||||
sn: p.sn,
|
sn: p.sn,
|
||||||
resourceCode: p.resource_code,
|
resourceCode: p.resource_code,
|
||||||
|
operationCode: p.operation_code,
|
||||||
granted: p.granted === 'Y',
|
granted: p.granted === 'Y',
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
@ -94,17 +119,20 @@ export async function createRole(input: CreateRoleInput): Promise<RoleWithPermis
|
|||||||
)
|
)
|
||||||
const row = result.rows[0]
|
const row = result.rows[0]
|
||||||
|
|
||||||
for (const rsrc of PERM_RESOURCE_CODES) {
|
// 새 역할: level 0 리소스에 READ='N' 초기화
|
||||||
|
const topLevelCodes = await getTopLevelResourceCodes()
|
||||||
|
for (const rsrc of topLevelCodes) {
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES ($1, $2, $3)',
|
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES ($1, $2, $3, $4)',
|
||||||
[row.sn, rsrc, 'N']
|
[row.sn, rsrc, 'READ', 'N']
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query('COMMIT')
|
await client.query('COMMIT')
|
||||||
|
|
||||||
const permsResult = await authPool.query(
|
const permsResult = await authPool.query(
|
||||||
'SELECT PERM_SN as sn, RSRC_CD as resource_code, GRANT_YN as granted FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD',
|
`SELECT PERM_SN as sn, RSRC_CD as resource_code, OPER_CD as operation_code, GRANT_YN as granted
|
||||||
|
FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD, OPER_CD`,
|
||||||
[row.sn]
|
[row.sn]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -114,9 +142,12 @@ export async function createRole(input: CreateRoleInput): Promise<RoleWithPermis
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
isDefault: row.is_default === 'Y',
|
isDefault: row.is_default === 'Y',
|
||||||
permissions: permsResult.rows.map((p: { sn: number; resource_code: string; granted: string }) => ({
|
permissions: permsResult.rows.map((p: {
|
||||||
|
sn: number; resource_code: string; operation_code: string; granted: string
|
||||||
|
}) => ({
|
||||||
sn: p.sn,
|
sn: p.sn,
|
||||||
resourceCode: p.resource_code,
|
resourceCode: p.resource_code,
|
||||||
|
operationCode: p.operation_code,
|
||||||
granted: p.granted === 'Y',
|
granted: p.granted === 'Y',
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
@ -177,23 +208,23 @@ export async function deleteRole(roleSn: number): Promise<void> {
|
|||||||
|
|
||||||
export async function updatePermissions(
|
export async function updatePermissions(
|
||||||
roleSn: number,
|
roleSn: number,
|
||||||
permissions: Array<{ resourceCode: string; granted: boolean }>
|
permissions: Array<{ resourceCode: string; operationCode: string; granted: boolean }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const perm of permissions) {
|
for (const perm of permissions) {
|
||||||
const existing = await authPool.query(
|
const existing = await authPool.query(
|
||||||
'SELECT PERM_SN FROM AUTH_PERM WHERE ROLE_SN = $1 AND RSRC_CD = $2',
|
'SELECT PERM_SN FROM AUTH_PERM WHERE ROLE_SN = $1 AND RSRC_CD = $2 AND OPER_CD = $3',
|
||||||
[roleSn, perm.resourceCode]
|
[roleSn, perm.resourceCode, perm.operationCode]
|
||||||
)
|
)
|
||||||
|
|
||||||
if (existing.rows.length > 0) {
|
if (existing.rows.length > 0) {
|
||||||
await authPool.query(
|
await authPool.query(
|
||||||
'UPDATE AUTH_PERM SET GRANT_YN = $1 WHERE ROLE_SN = $2 AND RSRC_CD = $3',
|
'UPDATE AUTH_PERM SET GRANT_YN = $1 WHERE ROLE_SN = $2 AND RSRC_CD = $3 AND OPER_CD = $4',
|
||||||
[perm.granted ? 'Y' : 'N', roleSn, perm.resourceCode]
|
[perm.granted ? 'Y' : 'N', roleSn, perm.resourceCode, perm.operationCode]
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
await authPool.query(
|
await authPool.query(
|
||||||
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES ($1, $2, $3)',
|
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES ($1, $2, $3, $4)',
|
||||||
[roleSn, perm.resourceCode, perm.granted ? 'Y' : 'N']
|
[roleSn, perm.resourceCode, perm.operationCode, perm.granted ? 'Y' : 'N']
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import cors from 'cors'
|
|||||||
import helmet from 'helmet'
|
import helmet from 'helmet'
|
||||||
import rateLimit from 'express-rate-limit'
|
import rateLimit from 'express-rate-limit'
|
||||||
import cookieParser from 'cookie-parser'
|
import cookieParser from 'cookie-parser'
|
||||||
import { testAuthDbConnection } from './db/authDb.js'
|
|
||||||
import { testWingDbConnection } from './db/wingDb.js'
|
import { testWingDbConnection } from './db/wingDb.js'
|
||||||
import layersRouter from './routes/layers.js'
|
import layersRouter from './routes/layers.js'
|
||||||
import simulationRouter from './routes/simulation.js'
|
import simulationRouter from './routes/simulation.js'
|
||||||
@ -14,6 +13,7 @@ import roleRouter from './roles/roleRouter.js'
|
|||||||
import settingsRouter from './settings/settingsRouter.js'
|
import settingsRouter from './settings/settingsRouter.js'
|
||||||
import menuRouter from './menus/menuRouter.js'
|
import menuRouter from './menus/menuRouter.js'
|
||||||
import auditRouter from './audit/auditRouter.js'
|
import auditRouter from './audit/auditRouter.js'
|
||||||
|
import boardRouter from './board/boardRouter.js'
|
||||||
import hnsRouter from './hns/hnsRouter.js'
|
import hnsRouter from './hns/hnsRouter.js'
|
||||||
import {
|
import {
|
||||||
sanitizeBody,
|
sanitizeBody,
|
||||||
@ -49,6 +49,7 @@ app.use(helmet({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
crossOriginEmbedderPolicy: false, // API 서버이므로 비활성
|
crossOriginEmbedderPolicy: false, // API 서버이므로 비활성
|
||||||
|
crossOriginResourcePolicy: { policy: 'cross-origin' }, // sendBeacon cross-origin 허용
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 2. 서버 정보 제거 (공격자에게 기술 스택 노출 방지)
|
// 2. 서버 정보 제거 (공격자에게 기술 스택 노출 방지)
|
||||||
@ -136,6 +137,7 @@ app.use('/api/menus', menuRouter)
|
|||||||
app.use('/api/audit', auditRouter)
|
app.use('/api/audit', auditRouter)
|
||||||
|
|
||||||
// API 라우트 — 업무
|
// API 라우트 — 업무
|
||||||
|
app.use('/api/board', boardRouter)
|
||||||
app.use('/api/layers', layersRouter)
|
app.use('/api/layers', layersRouter)
|
||||||
app.use('/api/simulation', simulationLimiter, simulationRouter)
|
app.use('/api/simulation', simulationLimiter, simulationRouter)
|
||||||
app.use('/api/hns', hnsRouter)
|
app.use('/api/hns', hnsRouter)
|
||||||
@ -174,17 +176,13 @@ app.use((err: Error, _req: express.Request, res: express.Response, _next: expres
|
|||||||
app.listen(PORT, async () => {
|
app.listen(PORT, async () => {
|
||||||
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
|
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
|
||||||
|
|
||||||
// wing DB (운영 데이터) 연결 확인
|
// wing DB 연결 확인 (wing + auth 스키마 통합)
|
||||||
await testWingDbConnection()
|
const connected = await testWingDbConnection()
|
||||||
|
|
||||||
// wing_auth DB (인증 데이터) 연결 확인
|
|
||||||
const connected = await testAuthDbConnection()
|
|
||||||
if (connected) {
|
if (connected) {
|
||||||
// SETTING_VAL VARCHAR(500) → TEXT 마이그레이션 (메뉴 설정 JSON 확장 대응)
|
// SETTING_VAL VARCHAR(500) → TEXT 마이그레이션 (메뉴 설정 JSON 확장 대응)
|
||||||
try {
|
try {
|
||||||
const { authPool } = await import('./db/authDb.js')
|
const { wingPool } = await import('./db/wingDb.js')
|
||||||
await authPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`)
|
await wingPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`)
|
||||||
console.log('[migration] SETTING_VAL → TEXT 변환 완료')
|
|
||||||
} catch {
|
} catch {
|
||||||
// 이미 TEXT이거나 권한 없으면 무시
|
// 이미 TEXT이거나 권한 없으면 무시
|
||||||
}
|
}
|
||||||
|
|||||||
@ -134,18 +134,21 @@ CREATE TABLE AUTH_PERM (
|
|||||||
PERM_SN SERIAL NOT NULL,
|
PERM_SN SERIAL NOT NULL,
|
||||||
ROLE_SN INTEGER NOT NULL,
|
ROLE_SN INTEGER NOT NULL,
|
||||||
RSRC_CD VARCHAR(50) NOT NULL,
|
RSRC_CD VARCHAR(50) NOT NULL,
|
||||||
|
OPER_CD VARCHAR(20) NOT NULL,
|
||||||
GRANT_YN CHAR(1) NOT NULL DEFAULT 'Y',
|
GRANT_YN CHAR(1) NOT NULL DEFAULT 'Y',
|
||||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
CONSTRAINT PK_AUTH_PERM PRIMARY KEY (PERM_SN),
|
CONSTRAINT PK_AUTH_PERM PRIMARY KEY (PERM_SN),
|
||||||
CONSTRAINT FK_AP_ROLE FOREIGN KEY (ROLE_SN) REFERENCES AUTH_ROLE(ROLE_SN) ON DELETE CASCADE,
|
CONSTRAINT FK_AP_ROLE FOREIGN KEY (ROLE_SN) REFERENCES AUTH_ROLE(ROLE_SN) ON DELETE CASCADE,
|
||||||
CONSTRAINT UK_AUTH_PERM UNIQUE (ROLE_SN, RSRC_CD),
|
CONSTRAINT UK_AUTH_PERM UNIQUE (ROLE_SN, RSRC_CD, OPER_CD),
|
||||||
CONSTRAINT CK_AUTH_PERM_GRANT CHECK (GRANT_YN IN ('Y','N'))
|
CONSTRAINT CK_AUTH_PERM_GRANT CHECK (GRANT_YN IN ('Y','N')),
|
||||||
|
CONSTRAINT CK_AUTH_PERM_OPER CHECK (OPER_CD IN ('READ','CREATE','UPDATE','DELETE','MANAGE','EXPORT'))
|
||||||
);
|
);
|
||||||
|
|
||||||
COMMENT ON TABLE AUTH_PERM IS '역할별권한';
|
COMMENT ON TABLE AUTH_PERM IS '역할별권한';
|
||||||
COMMENT ON COLUMN AUTH_PERM.PERM_SN IS '권한순번';
|
COMMENT ON COLUMN AUTH_PERM.PERM_SN IS '권한순번';
|
||||||
COMMENT ON COLUMN AUTH_PERM.ROLE_SN IS '역할순번';
|
COMMENT ON COLUMN AUTH_PERM.ROLE_SN IS '역할순번';
|
||||||
COMMENT ON COLUMN AUTH_PERM.RSRC_CD IS '리소스코드 (탭 ID: prediction, hns, rescue 등)';
|
COMMENT ON COLUMN AUTH_PERM.RSRC_CD IS '리소스코드 (탭 ID: prediction, hns, rescue 등)';
|
||||||
|
COMMENT ON COLUMN AUTH_PERM.OPER_CD IS '오퍼레이션코드 (READ, CREATE, UPDATE, DELETE, MANAGE, EXPORT)';
|
||||||
COMMENT ON COLUMN AUTH_PERM.GRANT_YN IS '부여여부 (Y:허용, N:거부)';
|
COMMENT ON COLUMN AUTH_PERM.GRANT_YN IS '부여여부 (Y:허용, N:거부)';
|
||||||
COMMENT ON COLUMN AUTH_PERM.REG_DTM IS '등록일시';
|
COMMENT ON COLUMN AUTH_PERM.REG_DTM IS '등록일시';
|
||||||
|
|
||||||
@ -239,6 +242,7 @@ CREATE UNIQUE INDEX UK_AUTH_USER_OAUTH ON AUTH_USER(OAUTH_PROVIDER, OAUTH_SUB) W
|
|||||||
CREATE UNIQUE INDEX UK_AUTH_USER_EMAIL ON AUTH_USER(EMAIL) WHERE EMAIL IS NOT NULL;
|
CREATE UNIQUE INDEX UK_AUTH_USER_EMAIL ON AUTH_USER(EMAIL) WHERE EMAIL IS NOT NULL;
|
||||||
CREATE INDEX IDX_AUTH_PERM_ROLE ON AUTH_PERM (ROLE_SN);
|
CREATE INDEX IDX_AUTH_PERM_ROLE ON AUTH_PERM (ROLE_SN);
|
||||||
CREATE INDEX IDX_AUTH_PERM_RSRC ON AUTH_PERM (RSRC_CD);
|
CREATE INDEX IDX_AUTH_PERM_RSRC ON AUTH_PERM (RSRC_CD);
|
||||||
|
CREATE INDEX IDX_AUTH_PERM_OPER ON AUTH_PERM (OPER_CD);
|
||||||
CREATE INDEX IDX_AUTH_LOGIN_USER ON AUTH_LOGIN_HIST (USER_ID);
|
CREATE INDEX IDX_AUTH_LOGIN_USER ON AUTH_LOGIN_HIST (USER_ID);
|
||||||
CREATE INDEX IDX_AUTH_LOGIN_DTM ON AUTH_LOGIN_HIST (LOGIN_DTM);
|
CREATE INDEX IDX_AUTH_LOGIN_DTM ON AUTH_LOGIN_HIST (LOGIN_DTM);
|
||||||
CREATE INDEX IDX_AUDIT_LOG_USER ON AUTH_AUDIT_LOG (USER_ID);
|
CREATE INDEX IDX_AUDIT_LOG_USER ON AUTH_AUDIT_LOG (USER_ID);
|
||||||
@ -257,36 +261,65 @@ INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN) VALUES
|
|||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 11. 초기 데이터: 역할별 권한 (탭 접근 매트릭스)
|
-- 11. 초기 데이터: 역할별 권한 (리소스 × 오퍼레이션 매트릭스)
|
||||||
|
-- OPER_CD: READ(조회), CREATE(생성), UPDATE(수정), DELETE(삭제)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
-- ADMIN (ROLE_SN=1): 모든 탭 접근
|
-- ADMIN (ROLE_SN=1): 모든 탭 × 모든 오퍼레이션 허용
|
||||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||||
(1, 'prediction', 'Y'), (1, 'hns', 'Y'), (1, 'rescue', 'Y'),
|
(1, 'prediction', 'READ', 'Y'), (1, 'prediction', 'CREATE', 'Y'), (1, 'prediction', 'UPDATE', 'Y'), (1, 'prediction', 'DELETE', 'Y'),
|
||||||
(1, 'reports', 'Y'), (1, 'aerial', 'Y'), (1, 'assets', 'Y'),
|
(1, 'hns', 'READ', 'Y'), (1, 'hns', 'CREATE', 'Y'), (1, 'hns', 'UPDATE', 'Y'), (1, 'hns', 'DELETE', 'Y'),
|
||||||
(1, 'scat', 'Y'), (1, 'incidents', 'Y'), (1, 'board', 'Y'),
|
(1, 'rescue', 'READ', 'Y'), (1, 'rescue', 'CREATE', 'Y'), (1, 'rescue', 'UPDATE', 'Y'), (1, 'rescue', 'DELETE', 'Y'),
|
||||||
(1, 'weather', 'Y'), (1, 'admin', 'Y');
|
(1, 'reports', 'READ', 'Y'), (1, 'reports', 'CREATE', 'Y'), (1, 'reports', 'UPDATE', 'Y'), (1, 'reports', 'DELETE', 'Y'),
|
||||||
|
(1, 'aerial', 'READ', 'Y'), (1, 'aerial', 'CREATE', 'Y'), (1, 'aerial', 'UPDATE', 'Y'), (1, 'aerial', 'DELETE', 'Y'),
|
||||||
|
(1, 'assets', 'READ', 'Y'), (1, 'assets', 'CREATE', 'Y'), (1, 'assets', 'UPDATE', 'Y'), (1, 'assets', 'DELETE', 'Y'),
|
||||||
|
(1, 'scat', 'READ', 'Y'), (1, 'scat', 'CREATE', 'Y'), (1, 'scat', 'UPDATE', 'Y'), (1, 'scat', 'DELETE', 'Y'),
|
||||||
|
(1, 'incidents', 'READ', 'Y'), (1, 'incidents', 'CREATE', 'Y'), (1, 'incidents', 'UPDATE', 'Y'), (1, 'incidents', 'DELETE', 'Y'),
|
||||||
|
(1, 'board', 'READ', 'Y'), (1, 'board', 'CREATE', 'Y'), (1, 'board', 'UPDATE', 'Y'), (1, 'board', 'DELETE', 'Y'),
|
||||||
|
(1, 'weather', 'READ', 'Y'), (1, 'weather', 'CREATE', 'Y'), (1, 'weather', 'UPDATE', 'Y'), (1, 'weather', 'DELETE', 'Y'),
|
||||||
|
(1, 'admin', 'READ', 'Y'), (1, 'admin', 'CREATE', 'Y'), (1, 'admin', 'UPDATE', 'Y'), (1, 'admin', 'DELETE', 'Y');
|
||||||
|
|
||||||
-- MANAGER (ROLE_SN=2): admin 탭 제외
|
-- MANAGER (ROLE_SN=2): admin 탭 제외, RCUD 허용
|
||||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||||
(2, 'prediction', 'Y'), (2, 'hns', 'Y'), (2, 'rescue', 'Y'),
|
(2, 'prediction', 'READ', 'Y'), (2, 'prediction', 'CREATE', 'Y'), (2, 'prediction', 'UPDATE', 'Y'), (2, 'prediction', 'DELETE', 'Y'),
|
||||||
(2, 'reports', 'Y'), (2, 'aerial', 'Y'), (2, 'assets', 'Y'),
|
(2, 'hns', 'READ', 'Y'), (2, 'hns', 'CREATE', 'Y'), (2, 'hns', 'UPDATE', 'Y'), (2, 'hns', 'DELETE', 'Y'),
|
||||||
(2, 'scat', 'Y'), (2, 'incidents', 'Y'), (2, 'board', 'Y'),
|
(2, 'rescue', 'READ', 'Y'), (2, 'rescue', 'CREATE', 'Y'), (2, 'rescue', 'UPDATE', 'Y'), (2, 'rescue', 'DELETE', 'Y'),
|
||||||
(2, 'weather', 'Y'), (2, 'admin', 'N');
|
(2, 'reports', 'READ', 'Y'), (2, 'reports', 'CREATE', 'Y'), (2, 'reports', 'UPDATE', 'Y'), (2, 'reports', 'DELETE', 'Y'),
|
||||||
|
(2, 'aerial', 'READ', 'Y'), (2, 'aerial', 'CREATE', 'Y'), (2, 'aerial', 'UPDATE', 'Y'), (2, 'aerial', 'DELETE', 'Y'),
|
||||||
|
(2, 'assets', 'READ', 'Y'), (2, 'assets', 'CREATE', 'Y'), (2, 'assets', 'UPDATE', 'Y'), (2, 'assets', 'DELETE', 'Y'),
|
||||||
|
(2, 'scat', 'READ', 'Y'), (2, 'scat', 'CREATE', 'Y'), (2, 'scat', 'UPDATE', 'Y'), (2, 'scat', 'DELETE', 'Y'),
|
||||||
|
(2, 'incidents', 'READ', 'Y'), (2, 'incidents', 'CREATE', 'Y'), (2, 'incidents', 'UPDATE', 'Y'), (2, 'incidents', 'DELETE', 'Y'),
|
||||||
|
(2, 'board', 'READ', 'Y'), (2, 'board', 'CREATE', 'Y'), (2, 'board', 'UPDATE', 'Y'), (2, 'board', 'DELETE', 'Y'),
|
||||||
|
(2, 'weather', 'READ', 'Y'), (2, 'weather', 'CREATE', 'Y'), (2, 'weather', 'UPDATE', 'Y'), (2, 'weather', 'DELETE', 'Y'),
|
||||||
|
(2, 'admin', 'READ', 'N');
|
||||||
|
|
||||||
-- USER (ROLE_SN=3): assets, admin 탭 제외
|
-- USER (ROLE_SN=3): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음
|
||||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||||
(3, 'prediction', 'Y'), (3, 'hns', 'Y'), (3, 'rescue', 'Y'),
|
(3, 'prediction', 'READ', 'Y'), (3, 'prediction', 'CREATE', 'Y'), (3, 'prediction', 'UPDATE', 'Y'),
|
||||||
(3, 'reports', 'Y'), (3, 'aerial', 'Y'), (3, 'assets', 'N'),
|
(3, 'hns', 'READ', 'Y'), (3, 'hns', 'CREATE', 'Y'), (3, 'hns', 'UPDATE', 'Y'),
|
||||||
(3, 'scat', 'Y'), (3, 'incidents', 'Y'), (3, 'board', 'Y'),
|
(3, 'rescue', 'READ', 'Y'), (3, 'rescue', 'CREATE', 'Y'), (3, 'rescue', 'UPDATE', 'Y'),
|
||||||
(3, 'weather', 'Y'), (3, 'admin', 'N');
|
(3, 'reports', 'READ', 'Y'), (3, 'reports', 'CREATE', 'Y'), (3, 'reports', 'UPDATE', 'Y'),
|
||||||
|
(3, 'aerial', 'READ', 'Y'), (3, 'aerial', 'CREATE', 'Y'), (3, 'aerial', 'UPDATE', 'Y'),
|
||||||
|
(3, 'assets', 'READ', 'N'),
|
||||||
|
(3, 'scat', 'READ', 'Y'), (3, 'scat', 'CREATE', 'Y'), (3, 'scat', 'UPDATE', 'Y'),
|
||||||
|
(3, 'incidents', 'READ', 'Y'), (3, 'incidents', 'CREATE', 'Y'), (3, 'incidents', 'UPDATE', 'Y'),
|
||||||
|
(3, 'board', 'READ', 'Y'), (3, 'board', 'CREATE', 'Y'), (3, 'board', 'UPDATE', 'Y'),
|
||||||
|
(3, 'weather', 'READ', 'Y'),
|
||||||
|
(3, 'admin', 'READ', 'N');
|
||||||
|
|
||||||
-- VIEWER (ROLE_SN=4): reports, assets, scat, admin 제외
|
-- VIEWER (ROLE_SN=4): 제한적 탭의 READ만 허용
|
||||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||||
(4, 'prediction', 'Y'), (4, 'hns', 'Y'), (4, 'rescue', 'Y'),
|
(4, 'prediction', 'READ', 'Y'),
|
||||||
(4, 'reports', 'N'), (4, 'aerial', 'Y'), (4, 'assets', 'N'),
|
(4, 'hns', 'READ', 'Y'),
|
||||||
(4, 'scat', 'N'), (4, 'incidents', 'Y'), (4, 'board', 'Y'),
|
(4, 'rescue', 'READ', 'Y'),
|
||||||
(4, 'weather', 'Y'), (4, 'admin', 'N');
|
(4, 'reports', 'READ', 'N'),
|
||||||
|
(4, 'aerial', 'READ', 'Y'),
|
||||||
|
(4, 'assets', 'READ', 'N'),
|
||||||
|
(4, 'scat', 'READ', 'N'),
|
||||||
|
(4, 'incidents', 'READ', 'Y'),
|
||||||
|
(4, 'board', 'READ', 'Y'),
|
||||||
|
(4, 'weather', 'READ', 'Y'),
|
||||||
|
(4, 'admin', 'READ', 'N');
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
108
database/migration/003_perm_tree.sql
Normal file
108
database/migration/003_perm_tree.sql
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- AUTH_PERM_TREE: 트리 구조 기반 리소스(메뉴) 권한 정의
|
||||||
|
-- 부모-자식 관계로 N-depth 서브탭 권한 제어 지원
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS AUTH_PERM_TREE (
|
||||||
|
RSRC_CD VARCHAR(50) NOT NULL, -- 콜론 구분 경로: 'prediction', 'aerial:media'
|
||||||
|
PARENT_CD VARCHAR(50), -- NULL이면 최상위 탭
|
||||||
|
RSRC_NM VARCHAR(100) NOT NULL, -- 표시명
|
||||||
|
RSRC_DESC VARCHAR(200), -- 설명 (NULL 허용)
|
||||||
|
ICON VARCHAR(20), -- 아이콘 (NULL 허용, 선택 옵션)
|
||||||
|
RSRC_LEVEL SMALLINT NOT NULL DEFAULT 0, -- depth (0=탭, 1=서브탭, 2+)
|
||||||
|
SORT_ORD SMALLINT NOT NULL DEFAULT 0, -- 형제 노드 간 정렬
|
||||||
|
USE_YN CHAR(1) NOT NULL DEFAULT 'Y',
|
||||||
|
CONSTRAINT PK_PERM_TREE PRIMARY KEY (RSRC_CD),
|
||||||
|
CONSTRAINT FK_PERM_TREE_PARENT FOREIGN KEY (PARENT_CD)
|
||||||
|
REFERENCES AUTH_PERM_TREE(RSRC_CD)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_PERM_TREE_PARENT ON AUTH_PERM_TREE(PARENT_CD);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 초기 데이터
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Level 0: 메인 탭 (11개)
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_DESC, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('prediction', NULL, '유출유 확산예측', '확산 예측 실행 및 결과 조회', 0, 1),
|
||||||
|
('hns', NULL, 'HNS·대기확산', '대기확산 분석 실행 및 조회', 0, 2),
|
||||||
|
('rescue', NULL, '긴급구난', '구난 예측 실행 및 조회', 0, 3),
|
||||||
|
('reports', NULL, '보고자료', '사고 보고서 작성 및 조회', 0, 4),
|
||||||
|
('aerial', NULL, '항공탐색', '항공 탐색 데이터 조회', 0, 5),
|
||||||
|
('assets', NULL, '방제자산 관리', '방제 장비 및 자산 관리', 0, 6),
|
||||||
|
('scat', NULL, '해안평가', 'SCAT 조사 실행 및 조회', 0, 7),
|
||||||
|
('board', NULL, '게시판', '자료실 및 공지사항 조회', 0, 8),
|
||||||
|
('weather', NULL, '기상정보', '기상 및 해상 정보 조회', 0, 9),
|
||||||
|
('incidents', NULL, '통합조회', '사고 상세 정보 조회', 0, 10),
|
||||||
|
('admin', NULL, '관리', '사용자 및 권한 관리', 0, 11)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 1: prediction 하위
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('prediction:analysis', 'prediction', '확산분석', 1, 1),
|
||||||
|
('prediction:list', 'prediction', '분석 목록', 1, 2),
|
||||||
|
('prediction:theory', 'prediction', '확산모델 이론', 1, 3),
|
||||||
|
('prediction:boom-theory', 'prediction', '오일펜스 배치 이론', 1, 4)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 1: hns 하위
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('hns:analysis', 'hns', '대기확산 분석', 1, 1),
|
||||||
|
('hns:list', 'hns', '분석 목록', 1, 2),
|
||||||
|
('hns:scenario', 'hns', '시나리오 관리', 1, 3),
|
||||||
|
('hns:manual', 'hns', 'HNS 대응매뉴얼', 1, 4),
|
||||||
|
('hns:theory', 'hns', '확산모델 이론', 1, 5),
|
||||||
|
('hns:substance', 'hns', 'HNS 물질정보', 1, 6)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 1: rescue 하위
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('rescue:rescue', 'rescue', '긴급구난예측', 1, 1),
|
||||||
|
('rescue:list', 'rescue', '긴급구난 목록', 1, 2),
|
||||||
|
('rescue:scenario', 'rescue', '시나리오 관리', 1, 3),
|
||||||
|
('rescue:theory', 'rescue', '긴급구난모델 이론', 1, 4)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 1: reports 하위
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('reports:report-list', 'reports', '보고서 목록', 1, 1),
|
||||||
|
('reports:template', 'reports', '표준보고서 템플릿', 1, 2),
|
||||||
|
('reports:generate', 'reports', '보고서 생성', 1, 3)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 1: aerial 하위
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('aerial:media', 'aerial', '영상사진관리', 1, 1),
|
||||||
|
('aerial:analysis', 'aerial', '유출유면적분석', 1, 2),
|
||||||
|
('aerial:realtime', 'aerial', '실시간드론', 1, 3),
|
||||||
|
('aerial:sensor', 'aerial', '오염/선박3D분석', 1, 4),
|
||||||
|
('aerial:satellite', 'aerial', '위성요청', 1, 5),
|
||||||
|
('aerial:cctv', 'aerial', 'CCTV 조회', 1, 6),
|
||||||
|
('aerial:theory', 'aerial', '항공탐색 이론', 1, 7)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 1: assets 하위
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('assets:management', 'assets', '자산 관리', 1, 1),
|
||||||
|
('assets:upload', 'assets', '자산 현행화', 1, 2),
|
||||||
|
('assets:theory', 'assets', '방제자원 이론', 1, 3),
|
||||||
|
('assets:insurance', 'assets', '선박 보험정보', 1, 4)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 1: board 하위
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('board:all', 'board', '전체', 1, 1),
|
||||||
|
('board:notice', 'board', '공지사항', 1, 2),
|
||||||
|
('board:data', 'board', '자료실', 1, 3),
|
||||||
|
('board:qna', 'board', 'Q&A', 1, 4),
|
||||||
|
('board:manual', 'board', '해경매뉴얼', 1, 5)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 1: admin 하위
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('admin:users', 'admin', '사용자 관리', 1, 1),
|
||||||
|
('admin:permissions', 'admin', '권한 관리', 1, 2),
|
||||||
|
('admin:menus', 'admin', '메뉴 관리', 1, 3),
|
||||||
|
('admin:settings', 'admin', '시스템 설정', 1, 4)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
55
database/migration/004_oper_cd.sql
Normal file
55
database/migration/004_oper_cd.sql
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 마이그레이션 004: AUTH_PERM에 OPER_CD 컬럼 추가
|
||||||
|
-- 리소스 단일 권한 → 리소스 × 오퍼레이션(RCUD) 2차원 권한 모델
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Step 1: OPER_CD 컬럼 추가 (기존 레코드는 'READ'로 설정)
|
||||||
|
ALTER TABLE AUTH_PERM ADD COLUMN IF NOT EXISTS OPER_CD VARCHAR(20) NOT NULL DEFAULT 'READ';
|
||||||
|
COMMENT ON COLUMN AUTH_PERM.OPER_CD IS '오퍼레이션코드 (READ, CREATE, UPDATE, DELETE, MANAGE, EXPORT)';
|
||||||
|
|
||||||
|
-- Step 2: UNIQUE 제약 변경 (ROLE_SN, RSRC_CD) → (ROLE_SN, RSRC_CD, OPER_CD)
|
||||||
|
-- INSERT 전에 변경해야 CUD 레코드 삽입 시 충돌 없음
|
||||||
|
ALTER TABLE AUTH_PERM DROP CONSTRAINT IF EXISTS UK_AUTH_PERM;
|
||||||
|
ALTER TABLE AUTH_PERM ADD CONSTRAINT UK_AUTH_PERM UNIQUE (ROLE_SN, RSRC_CD, OPER_CD);
|
||||||
|
|
||||||
|
-- Step 3: 기존 GRANT_YN='Y' 레코드를 CREATE/UPDATE/DELETE로 확장
|
||||||
|
-- (기존에 허용된 리소스는 RCUD 모두 허용하여 동작 보존)
|
||||||
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
|
||||||
|
SELECT ROLE_SN, RSRC_CD, 'CREATE', GRANT_YN
|
||||||
|
FROM AUTH_PERM WHERE OPER_CD = 'READ' AND GRANT_YN = 'Y'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
|
||||||
|
SELECT ROLE_SN, RSRC_CD, 'UPDATE', GRANT_YN
|
||||||
|
FROM AUTH_PERM WHERE OPER_CD = 'READ' AND GRANT_YN = 'Y'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
|
||||||
|
SELECT ROLE_SN, RSRC_CD, 'DELETE', GRANT_YN
|
||||||
|
FROM AUTH_PERM WHERE OPER_CD = 'READ' AND GRANT_YN = 'Y'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Step 3-1: VIEWER(조회 전용) 역할의 CUD 레코드 제거
|
||||||
|
-- VIEWER는 READ만 허용, CUD 확장은 의미 없음
|
||||||
|
DELETE FROM AUTH_PERM
|
||||||
|
WHERE ROLE_SN = (SELECT ROLE_SN FROM AUTH_ROLE WHERE ROLE_CD = 'VIEWER')
|
||||||
|
AND OPER_CD != 'READ';
|
||||||
|
|
||||||
|
-- Step 4: 기본값 제거 (신규 레코드는 반드시 OPER_CD 명시)
|
||||||
|
ALTER TABLE AUTH_PERM ALTER COLUMN OPER_CD DROP DEFAULT;
|
||||||
|
|
||||||
|
-- Step 5: CHECK 제약 추가 (확장 가능: MANAGE, EXPORT 포함)
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE AUTH_PERM ADD CONSTRAINT CK_AUTH_PERM_OPER
|
||||||
|
CHECK (OPER_CD IN ('READ','CREATE','UPDATE','DELETE','MANAGE','EXPORT'));
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Step 6: 인덱스
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_AUTH_PERM_OPER ON AUTH_PERM (OPER_CD);
|
||||||
|
|
||||||
|
-- 검증
|
||||||
|
SELECT ROLE_SN, OPER_CD, COUNT(*), STRING_AGG(GRANT_YN, '') as grants
|
||||||
|
FROM AUTH_PERM
|
||||||
|
GROUP BY ROLE_SN, OPER_CD
|
||||||
|
ORDER BY ROLE_SN, OPER_CD;
|
||||||
45
database/migration/005_db_consolidation.sql
Normal file
45
database/migration/005_db_consolidation.sql
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 마이그레이션 005: DB 통합 (wing + wing_auth → wing 단일 DB)
|
||||||
|
--
|
||||||
|
-- 스키마 구조:
|
||||||
|
-- wing — 운영 데이터 (LAYER, BOARD_POST, HNS_SUBSTANCE 등)
|
||||||
|
-- auth — 인증/인가 데이터 (AUTH_USER, AUTH_ROLE 등)
|
||||||
|
-- public — PostGIS 시스템 테이블만 유지 (spatial_ref_sys)
|
||||||
|
--
|
||||||
|
-- 실행 순서:
|
||||||
|
-- 1. 이 SQL을 wing DB에서 실행 (스키마 생성 + 테이블 이동)
|
||||||
|
-- 2. wing_auth DB 덤프 → auth 스키마로 복원 (별도 쉘)
|
||||||
|
-- 3. search_path 설정
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Step 1: 명시적 스키마 생성
|
||||||
|
CREATE SCHEMA IF NOT EXISTS wing;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS auth;
|
||||||
|
|
||||||
|
-- Step 2: 기존 public 운영 테이블을 wing 스키마로 이동
|
||||||
|
-- (PostGIS 시스템 테이블 spatial_ref_sys, topology는 public에 유지)
|
||||||
|
ALTER TABLE IF EXISTS public.layer SET SCHEMA wing;
|
||||||
|
ALTER TABLE IF EXISTS public.hns_substance SET SCHEMA wing;
|
||||||
|
|
||||||
|
-- Step 3: 기본 search_path 설정 (DB 레벨)
|
||||||
|
-- wing 사용자가 스키마 접두사 없이 양쪽 테이블 접근 가능
|
||||||
|
ALTER DATABASE wing SET search_path = wing, auth;
|
||||||
|
|
||||||
|
-- Step 4: wing 사용자에게 auth 스키마 권한 부여
|
||||||
|
-- (wing_auth 데이터 복원 후 적용)
|
||||||
|
GRANT USAGE ON SCHEMA auth TO wing;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA auth TO wing;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA auth TO wing;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT ALL ON TABLES TO wing;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT ALL ON SEQUENCES TO wing;
|
||||||
|
|
||||||
|
-- Step 5: wing 스키마 기본 권한
|
||||||
|
GRANT ALL PRIVILEGES ON SCHEMA wing TO wing;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA wing GRANT ALL ON TABLES TO wing;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA wing GRANT ALL ON SEQUENCES TO wing;
|
||||||
|
|
||||||
|
-- 검증
|
||||||
|
SELECT schemaname, tablename
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname IN ('wing', 'auth')
|
||||||
|
ORDER BY schemaname, tablename;
|
||||||
61
database/migration/006_board.sql
Normal file
61
database/migration/006_board.sql
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 마이그레이션 006: 게시판 (BOARD_POST)
|
||||||
|
-- wing 스키마에 생성, auth.AUTH_USER FK 참조
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Step 1: 게시판 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS BOARD_POST (
|
||||||
|
POST_SN SERIAL PRIMARY KEY,
|
||||||
|
CATEGORY_CD VARCHAR(20) NOT NULL,
|
||||||
|
TITLE VARCHAR(200) NOT NULL,
|
||||||
|
CONTENT TEXT,
|
||||||
|
AUTHOR_ID UUID NOT NULL,
|
||||||
|
VIEW_CNT INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PINNED_YN CHAR(1) NOT NULL DEFAULT 'N',
|
||||||
|
USE_YN CHAR(1) NOT NULL DEFAULT 'Y',
|
||||||
|
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
MDFCN_DTM TIMESTAMPTZ,
|
||||||
|
|
||||||
|
CONSTRAINT FK_BOARD_AUTHOR FOREIGN KEY (AUTHOR_ID)
|
||||||
|
REFERENCES auth.AUTH_USER(USER_ID),
|
||||||
|
CONSTRAINT CK_BOARD_CATEGORY
|
||||||
|
CHECK (CATEGORY_CD IN ('NOTICE','DATA','QNA','MANUAL')),
|
||||||
|
CONSTRAINT CK_BOARD_PINNED CHECK (PINNED_YN IN ('Y','N')),
|
||||||
|
CONSTRAINT CK_BOARD_USE CHECK (USE_YN IN ('Y','N'))
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE BOARD_POST IS '게시판 게시글';
|
||||||
|
COMMENT ON COLUMN BOARD_POST.CATEGORY_CD IS '카테고리: NOTICE=공지, DATA=자료실, QNA=Q&A, MANUAL=해경매뉴얼';
|
||||||
|
COMMENT ON COLUMN BOARD_POST.PINNED_YN IS '상단고정 여부';
|
||||||
|
COMMENT ON COLUMN BOARD_POST.USE_YN IS '사용여부 (N=논리삭제)';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_BOARD_CATEGORY ON BOARD_POST(CATEGORY_CD);
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_BOARD_AUTHOR ON BOARD_POST(AUTHOR_ID);
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_BOARD_REG_DTM ON BOARD_POST(REG_DTM DESC);
|
||||||
|
|
||||||
|
-- Step 2: 초기 데이터 (기존 프론트엔드 mockPosts 이전)
|
||||||
|
-- admin 사용자 ID 조회
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_admin_id UUID;
|
||||||
|
BEGIN
|
||||||
|
SELECT USER_ID INTO v_admin_id FROM auth.AUTH_USER WHERE USER_ACNT = 'admin' LIMIT 1;
|
||||||
|
|
||||||
|
IF v_admin_id IS NOT NULL THEN
|
||||||
|
INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID, VIEW_CNT, PINNED_YN, REG_DTM) VALUES
|
||||||
|
('NOTICE', '시스템 업데이트 안내', '시스템 업데이트 관련 안내사항입니다.', v_admin_id, 245, 'Y', '2025-02-15'::timestamptz),
|
||||||
|
('NOTICE', '2025년 방제 교육 일정 안내', '2025년도 방제 교육 일정을 안내합니다.', v_admin_id, 189, 'Y', '2025-02-14'::timestamptz),
|
||||||
|
('DATA', '방제 매뉴얼 업데이트 (2025년 개정판)', '2025년 개정판 방제 매뉴얼입니다.', v_admin_id, 423, 'N', '2025-02-10'::timestamptz),
|
||||||
|
('QNA', 'HNS 대기확산 분석 결과 해석 문의', 'HNS 분석 결과 해석 방법을 문의합니다.', v_admin_id, 156, 'N', '2025-02-08'::timestamptz),
|
||||||
|
('DATA', '2024년 유류오염사고 통계 자료', '2024년도 유류오염사고 통계 자료를 공유합니다.', v_admin_id, 312, 'N', '2025-02-05'::timestamptz),
|
||||||
|
('QNA', '유출유 확산 예측 알고리즘 선택 기준', '확산 예측 시 알고리즘 선택 기준을 문의합니다.', v_admin_id, 267, 'N', '2025-02-03'::timestamptz),
|
||||||
|
('DATA', '해양오염 방제 장비 운용 가이드', '방제 장비 운용 가이드 문서입니다.', v_admin_id, 534, 'N', '2025-01-28'::timestamptz),
|
||||||
|
('QNA', 'SCAT 조사 방법 관련 질문', 'SCAT 현장 조사 방법에 대해 질문합니다.', v_admin_id, 198, 'N', '2025-01-25'::timestamptz),
|
||||||
|
('DATA', 'HNS 물질 안전보건자료 (MSDS) 모음', 'HNS 물질별 MSDS 자료 모음입니다.', v_admin_id, 645, 'N', '2025-01-20'::timestamptz),
|
||||||
|
('QNA', '항공촬영 드론 운용 시 주의사항', '드론 운용 시 주의할 점을 문의합니다.', v_admin_id, 221, 'N', '2025-01-15'::timestamptz)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 검증
|
||||||
|
SELECT POST_SN, CATEGORY_CD, TITLE, VIEW_CNT, PINNED_YN FROM BOARD_POST ORDER BY POST_SN;
|
||||||
@ -10,21 +10,79 @@
|
|||||||
### 개요
|
### 개요
|
||||||
JWT 기반 세션 인증. HttpOnly 쿠키(`WING_SESSION`)로 토큰을 관리하며, 프론트엔드에서는 Zustand `authStore`로 상태를 관리합니다.
|
JWT 기반 세션 인증. HttpOnly 쿠키(`WING_SESSION`)로 토큰을 관리하며, 프론트엔드에서는 Zustand `authStore`로 상태를 관리합니다.
|
||||||
|
|
||||||
|
### 권한 모델: 리소스 × 오퍼레이션 (RBAC)
|
||||||
|
|
||||||
|
**2차원 권한 모델**: 리소스 트리(상속) × 오퍼레이션(RCUD, 플랫)
|
||||||
|
|
||||||
|
```
|
||||||
|
AUTH_PERM 테이블: (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
|
||||||
|
|
||||||
|
리소스 트리 (AUTH_PERM_TREE) 오퍼레이션 (플랫)
|
||||||
|
├── prediction READ = 조회/열람
|
||||||
|
│ ├── prediction:analysis CREATE = 생성
|
||||||
|
│ ├── prediction:list UPDATE = 수정
|
||||||
|
│ └── prediction:theory DELETE = 삭제
|
||||||
|
├── board
|
||||||
|
│ ├── board:notice
|
||||||
|
│ └── board:data
|
||||||
|
└── admin
|
||||||
|
├── admin:users
|
||||||
|
└── admin:permissions
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 오퍼레이션 코드
|
||||||
|
|
||||||
|
| OPER_CD | 설명 | 비고 |
|
||||||
|
|---------|------|------|
|
||||||
|
| `READ` | 조회/열람 | 목록, 상세 조회 |
|
||||||
|
| `CREATE` | 생성 | 새 데이터 등록 |
|
||||||
|
| `UPDATE` | 수정 | 기존 데이터 변경 |
|
||||||
|
| `DELETE` | 삭제 | 데이터 삭제 |
|
||||||
|
| `MANAGE` | 관리 | 관리자 설정 (확장용) |
|
||||||
|
| `EXPORT` | 내보내기 | 다운로드/출력 (확장용) |
|
||||||
|
|
||||||
|
#### 상속 규칙
|
||||||
|
|
||||||
|
1. 부모 리소스의 **READ**가 N → 자식의 **모든 오퍼레이션** 강제 N (접근 자체 차단)
|
||||||
|
2. 해당 `(RSRC_CD, OPER_CD)` 명시적 레코드 있으면 → 그 값 사용
|
||||||
|
3. 명시적 레코드 없으면 → 부모의 **같은 OPER_CD** 상속
|
||||||
|
4. 최상위까지 없으면 → 기본 N (거부)
|
||||||
|
|
||||||
|
```
|
||||||
|
예시: board (READ:Y, CREATE:Y, UPDATE:Y, DELETE:N)
|
||||||
|
└── board:notice
|
||||||
|
├── READ: 상속 Y (부모 READ Y)
|
||||||
|
├── CREATE: 상속 Y (부모 CREATE Y)
|
||||||
|
├── UPDATE: 명시적 N (override 가능)
|
||||||
|
└── DELETE: 상속 N (부모 DELETE N)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 키 구분자
|
||||||
|
- 리소스 내부 경로: `:` (board:notice)
|
||||||
|
- 리소스-오퍼레이션 결합 (내부용): `::` (board:notice::READ)
|
||||||
|
|
||||||
### 백엔드
|
### 백엔드
|
||||||
|
|
||||||
#### 미들웨어 적용
|
#### 미들웨어
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// backend/src/auth/authMiddleware.ts
|
import { requireAuth, requireRole, requirePermission } from '../auth/authMiddleware.js'
|
||||||
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
|
||||||
|
|
||||||
// 인증만 필요한 라우트
|
// 인증만 필요한 라우트
|
||||||
router.use(requireAuth)
|
router.use(requireAuth)
|
||||||
|
|
||||||
// 특정 역할 필요
|
// 역할 기반 (관리 API용)
|
||||||
router.use(requireRole('ADMIN'))
|
router.use(requireRole('ADMIN'))
|
||||||
router.use(requireRole('ADMIN', 'MANAGER'))
|
|
||||||
|
// 리소스×오퍼레이션 기반 (일반 비즈니스 API용)
|
||||||
|
router.post('/notice/list', requirePermission('board:notice', 'READ'), handler)
|
||||||
|
router.post('/notice/create', requirePermission('board:notice', 'CREATE'), handler)
|
||||||
|
router.post('/notice/update', requirePermission('board:notice', 'UPDATE'), handler)
|
||||||
|
router.post('/notice/delete', requirePermission('board:notice', 'DELETE'), handler)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`requirePermission`은 요청당 1회만 DB 조회하고 `req.resolvedPermissions`에 캐싱합니다.
|
||||||
|
|
||||||
#### JWT 페이로드 (req.user)
|
#### JWT 페이로드 (req.user)
|
||||||
`requireAuth` 통과 후 `req.user`에 담기는 정보:
|
`requireAuth` 통과 후 `req.user`에 담기는 정보:
|
||||||
```typescript
|
```typescript
|
||||||
@ -36,25 +94,21 @@ interface JwtPayload {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 라우터 패턴
|
#### 라우터 패턴 (CRUD 구조)
|
||||||
```typescript
|
```typescript
|
||||||
// backend/src/[모듈]/[모듈]Router.ts
|
// backend/src/[모듈]/[모듈]Router.ts
|
||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
router.use(requireAuth)
|
router.use(requireAuth)
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
// 리소스별 CRUD 엔드포인트
|
||||||
try {
|
router.post('/list', requirePermission('module:sub', 'READ'), listHandler)
|
||||||
const userId = req.user!.sub
|
router.post('/detail', requirePermission('module:sub', 'READ'), detailHandler)
|
||||||
// 비즈니스 로직...
|
router.post('/create', requirePermission('module:sub', 'CREATE'), createHandler)
|
||||||
res.json(result)
|
router.post('/update', requirePermission('module:sub', 'UPDATE'), updateHandler)
|
||||||
} catch (err) {
|
router.post('/delete', requirePermission('module:sub', 'DELETE'), deleteHandler)
|
||||||
console.error('[모듈] 오류:', err)
|
|
||||||
res.status(500).json({ error: '처리 중 오류가 발생했습니다.' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
```
|
```
|
||||||
@ -63,32 +117,36 @@ export default router
|
|||||||
|
|
||||||
#### authStore (Zustand)
|
#### authStore (Zustand)
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/store/authStore.ts
|
import { useAuthStore } from '@common/store/authStore'
|
||||||
import { useAuthStore } from '../store/authStore'
|
|
||||||
|
|
||||||
// 컴포넌트 내에서 사용
|
|
||||||
const { user, isAuthenticated, hasPermission, logout } = useAuthStore()
|
const { user, isAuthenticated, hasPermission, logout } = useAuthStore()
|
||||||
|
|
||||||
// 사용자 정보
|
// 사용자 정보
|
||||||
user?.id // UUID
|
user?.id // UUID
|
||||||
user?.name // 이름
|
user?.name // 이름
|
||||||
user?.roles // ['ADMIN', 'USER']
|
user?.roles // ['ADMIN', 'USER']
|
||||||
|
user?.permissions // { 'prediction': ['READ','CREATE','UPDATE','DELETE'], ... }
|
||||||
|
|
||||||
// 권한 확인 (탭 ID 기준)
|
// 권한 확인 (리소스 × 오퍼레이션)
|
||||||
hasPermission('prediction') // true/false
|
hasPermission('prediction') // READ 확인 (기본값)
|
||||||
hasPermission('admin') // true/false
|
hasPermission('prediction', 'READ') // 명시적 READ 확인
|
||||||
|
hasPermission('board:notice', 'CREATE') // 공지사항 생성 권한
|
||||||
|
hasPermission('board:notice', 'DELETE') // 공지사항 삭제 권한
|
||||||
|
|
||||||
|
// 하위 호환: operation 생략 시 'READ' 기본값
|
||||||
|
hasPermission('admin') // === hasPermission('admin', 'READ')
|
||||||
```
|
```
|
||||||
|
|
||||||
#### API 클라이언트
|
#### API 클라이언트
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/services/api.ts
|
import { api } from '@common/services/api'
|
||||||
import { api } from './api'
|
|
||||||
|
|
||||||
// withCredentials: true 설정으로 JWT 쿠키 자동 포함
|
// withCredentials: true 설정으로 JWT 쿠키 자동 포함
|
||||||
const response = await api.get('/your-endpoint')
|
const response = await api.post('/your-endpoint/list', params)
|
||||||
const response = await api.post('/your-endpoint', data)
|
const response = await api.post('/your-endpoint/create', data)
|
||||||
|
|
||||||
// 401 응답 시 자동 로그아웃 처리 (인터셉터)
|
// 401 응답 시 자동 로그아웃 처리 (인터셉터)
|
||||||
|
// 403 응답 시 권한 부족 (requirePermission 미들웨어)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -103,13 +161,15 @@ const response = await api.post('/your-endpoint', data)
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/App.tsx (자동 적용, 수정 불필요)
|
// frontend/src/App.tsx (자동 적용, 수정 불필요)
|
||||||
|
import { API_BASE_URL } from '@common/services/api'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return
|
if (!isAuthenticated) return
|
||||||
const blob = new Blob(
|
const blob = new Blob(
|
||||||
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
|
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
|
||||||
{ type: 'text/plain' }
|
{ type: 'text/plain' }
|
||||||
)
|
)
|
||||||
navigator.sendBeacon('/api/audit/log', blob)
|
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
|
||||||
}, [activeMainTab, isAuthenticated])
|
}, [activeMainTab, isAuthenticated])
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -117,12 +177,13 @@ useEffect(() => {
|
|||||||
특정 작업에 대해 명시적으로 감사 로그를 기록하려면:
|
특정 작업에 대해 명시적으로 감사 로그를 기록하려면:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 프론트엔드에서 sendBeacon 사용
|
import { API_BASE_URL } from '@common/services/api'
|
||||||
|
|
||||||
const blob = new Blob(
|
const blob = new Blob(
|
||||||
[JSON.stringify({ action: 'ADMIN_ACTION', detail: '사용자 승인' })],
|
[JSON.stringify({ action: 'ADMIN_ACTION', detail: '사용자 승인' })],
|
||||||
{ type: 'text/plain' }
|
{ type: 'text/plain' }
|
||||||
)
|
)
|
||||||
navigator.sendBeacon('/api/audit/log', blob)
|
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 감사 로그 테이블 구조 (AUTH_AUDIT_LOG)
|
### 감사 로그 테이블 구조 (AUTH_AUDIT_LOG)
|
||||||
@ -275,19 +336,72 @@ const mutation = useMutation({
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 백엔드 모듈 추가 절차
|
## 6. 백엔드 API CRUD 규칙
|
||||||
|
|
||||||
|
> 상세 가이드 + 게시판 실전 튜토리얼: **[CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md)** 참조
|
||||||
|
|
||||||
|
### HTTP Method 정책 (보안 가이드 준수)
|
||||||
|
- 보안 취약점 점검 가이드에 따라 **POST 메서드를 기본**으로 사용한다.
|
||||||
|
- GET은 단순 조회 중 민감하지 않은 경우에만 허용 (필요 시 POST로 전환).
|
||||||
|
- PUT, DELETE, PATCH 등 기타 메서드는 사용하지 않는다.
|
||||||
|
|
||||||
|
### 오퍼레이션 기반 권한 미들웨어
|
||||||
|
OPER_CD는 HTTP Method가 아닌 **비즈니스 의미**로 결정한다.
|
||||||
|
`requirePermission` 미들웨어에 명시적으로 오퍼레이션을 지정한다.
|
||||||
|
|
||||||
|
| URL 패턴 | OPER_CD | 미들웨어 |
|
||||||
|
|----------|---------|----------|
|
||||||
|
| `/resource/list` | READ | `requirePermission(resource, 'READ')` |
|
||||||
|
| `/resource/detail` | READ | `requirePermission(resource, 'READ')` |
|
||||||
|
| `/resource/create` | CREATE | `requirePermission(resource, 'CREATE')` |
|
||||||
|
| `/resource/update` | UPDATE | `requirePermission(resource, 'UPDATE')` |
|
||||||
|
| `/resource/delete` | DELETE | `requirePermission(resource, 'DELETE')` |
|
||||||
|
|
||||||
|
### 라우터 작성 예시
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// backend/src/board/noticeRouter.ts
|
||||||
|
import { Router } from 'express'
|
||||||
|
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
router.use(requireAuth)
|
||||||
|
|
||||||
|
// 조회
|
||||||
|
router.post('/list', requirePermission('board:notice', 'READ'), listHandler)
|
||||||
|
router.post('/detail', requirePermission('board:notice', 'READ'), detailHandler)
|
||||||
|
|
||||||
|
// 생성/수정/삭제
|
||||||
|
router.post('/create', requirePermission('board:notice', 'CREATE'), createHandler)
|
||||||
|
router.post('/update', requirePermission('board:notice', 'UPDATE'), updateHandler)
|
||||||
|
router.post('/delete', requirePermission('board:notice', 'DELETE'), deleteHandler)
|
||||||
|
|
||||||
|
export default router
|
||||||
|
```
|
||||||
|
|
||||||
|
### 관리 API (예외)
|
||||||
|
사용자/역할/설정 등 관리 API는 `requireRole('ADMIN')` 유지:
|
||||||
|
```typescript
|
||||||
|
router.use(requireAuth)
|
||||||
|
router.use(requireRole('ADMIN'))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 백엔드 모듈 추가 절차
|
||||||
|
|
||||||
새 백엔드 모듈을 추가할 때:
|
새 백엔드 모듈을 추가할 때:
|
||||||
|
|
||||||
1. `backend/src/[모듈명]/` 디렉토리 생성
|
1. `backend/src/[모듈명]/` 디렉토리 생성
|
||||||
2. `[모듈명]Service.ts` — 비즈니스 로직 (DB 쿼리)
|
2. `[모듈명]Service.ts` — 비즈니스 로직 (DB 쿼리)
|
||||||
3. `[모듈명]Router.ts` — Express 라우터 (입력 검증, 에러 처리)
|
3. `[모듈명]Router.ts` — Express 라우터 (CRUD 엔드포인트 + requirePermission)
|
||||||
4. `backend/src/server.ts`에 라우터 등록:
|
4. `backend/src/server.ts`에 라우터 등록:
|
||||||
```typescript
|
```typescript
|
||||||
import newRouter from './[모듈명]/[모듈명]Router.js'
|
import newRouter from './[모듈명]/[모듈명]Router.js'
|
||||||
app.use('/api/[경로]', newRouter)
|
app.use('/api/[경로]', newRouter)
|
||||||
```
|
```
|
||||||
5. DB 테이블 필요 시 `database/auth_init.sql`에 DDL 추가
|
5. DB 테이블 필요 시 `database/auth_init.sql`에 DDL 추가
|
||||||
|
6. 리소스 코드를 `AUTH_PERM_TREE`에 등록 (마이그레이션 SQL)
|
||||||
|
|
||||||
### DB 접근
|
### DB 접근
|
||||||
```typescript
|
```typescript
|
||||||
@ -306,20 +420,30 @@ const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1'
|
|||||||
|
|
||||||
```
|
```
|
||||||
frontend/src/
|
frontend/src/
|
||||||
├── services/api.ts Axios 인스턴스 + 인터셉터
|
├── common/
|
||||||
├── services/authApi.ts 인증/사용자/역할/설정/메뉴/감사로그 API
|
│ ├── services/api.ts Axios 인스턴스 + API_BASE_URL + 인터셉터
|
||||||
├── store/authStore.ts 인증 상태 (Zustand)
|
│ ├── services/authApi.ts 인증/사용자/역할/설정/메뉴/감사로그 API
|
||||||
├── store/menuStore.ts 메뉴 상태 (Zustand)
|
│ ├── store/authStore.ts 인증 상태 + hasPermission (Zustand)
|
||||||
└── App.tsx 탭 라우팅 + 감사 로그 자동 기록
|
│ ├── store/menuStore.ts 메뉴 상태 (Zustand)
|
||||||
|
│ └── hooks/ useSubMenu, useFeatureTracking 등
|
||||||
|
├── tabs/ 탭별 패키지 (11개)
|
||||||
|
└── App.tsx 탭 라우팅 + 감사 로그 자동 기록
|
||||||
|
|
||||||
backend/src/
|
backend/src/
|
||||||
├── auth/ 인증 (JWT, OAuth, 미들웨어)
|
├── auth/ 인증 (JWT, OAuth, 미들웨어, requirePermission)
|
||||||
├── users/ 사용자 관리
|
├── users/ 사용자 관리
|
||||||
├── roles/ 역할/권한 관리
|
├── roles/ 역할/권한 관리 (permResolver, roleService)
|
||||||
├── settings/ 시스템 설정
|
├── settings/ 시스템 설정
|
||||||
├── menus/ 메뉴 설정
|
├── menus/ 메뉴 설정
|
||||||
├── audit/ 감사 로그
|
├── audit/ 감사 로그
|
||||||
├── db/ DB 연결 (authDb, database)
|
├── db/ DB 연결 (authDb, wingDb)
|
||||||
├── middleware/ 보안 미들웨어
|
├── middleware/ 보안 미들웨어
|
||||||
└── server.ts Express 진입점 + 라우터 등록
|
└── server.ts Express 진입점 + 라우터 등록
|
||||||
|
|
||||||
|
database/
|
||||||
|
├── auth_init.sql 인증 DB DDL + 초기 데이터
|
||||||
|
├── init.sql 운영 DB DDL
|
||||||
|
└── migration/ 마이그레이션 스크립트
|
||||||
|
├── 003_perm_tree.sql 리소스 트리 (AUTH_PERM_TREE)
|
||||||
|
└── 004_oper_cd.sql 오퍼레이션 코드 (OPER_CD) 추가
|
||||||
```
|
```
|
||||||
|
|||||||
1433
docs/CRUD-API-GUIDE.md
Normal file
1433
docs/CRUD-API-GUIDE.md
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
72
frontend/src/common/constants/featureIds.ts
Normal file
72
frontend/src/common/constants/featureIds.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* FEATURE_ID 레지스트리
|
||||||
|
*
|
||||||
|
* 서브탭 단위의 기능 식별자. AUTH_PERM.RSRC_CD 및 감사로그 ACTION_DTL과 동기화.
|
||||||
|
* 형식: '{메인탭}:{서브탭}'
|
||||||
|
*/
|
||||||
|
export const FEATURE_IDS = {
|
||||||
|
// prediction
|
||||||
|
'prediction:analysis': '확산 분석',
|
||||||
|
'prediction:list': '시뮬레이션 목록',
|
||||||
|
'prediction:theory': '확산 이론',
|
||||||
|
'prediction:boom-theory': '오일펜스 배치 이론',
|
||||||
|
|
||||||
|
// hns
|
||||||
|
'hns:analysis': 'HNS 분석',
|
||||||
|
'hns:list': 'HNS 시뮬레이션 목록',
|
||||||
|
'hns:scenario': 'HNS 시나리오',
|
||||||
|
'hns:manual': 'HNS 매뉴얼',
|
||||||
|
'hns:theory': 'HNS 이론',
|
||||||
|
'hns:substance': 'HNS 물질정보',
|
||||||
|
|
||||||
|
// rescue
|
||||||
|
'rescue:rescue': '구난 메인',
|
||||||
|
'rescue:list': '구난 목록',
|
||||||
|
'rescue:scenario': '구난 시나리오',
|
||||||
|
'rescue:theory': '구난 이론',
|
||||||
|
|
||||||
|
// aerial
|
||||||
|
'aerial:media': '영상 관리',
|
||||||
|
'aerial:analysis': '유출 면적 분석',
|
||||||
|
'aerial:realtime': '실시간 드론',
|
||||||
|
'aerial:sensor': '센서 분석',
|
||||||
|
'aerial:satellite': '위성 요청',
|
||||||
|
'aerial:cctv': 'CCTV 모니터링',
|
||||||
|
'aerial:theory': '항공탐색 이론',
|
||||||
|
|
||||||
|
// reports
|
||||||
|
'reports:report-list': '보고서 목록',
|
||||||
|
'reports:template': '보고서 템플릿',
|
||||||
|
'reports:generate': '보고서 생성',
|
||||||
|
|
||||||
|
// board
|
||||||
|
'board:all': '전체 게시판',
|
||||||
|
'board:notice': '공지사항',
|
||||||
|
'board:data': '자료실',
|
||||||
|
'board:qna': '질의응답',
|
||||||
|
'board:manual': '매뉴얼',
|
||||||
|
|
||||||
|
// assets
|
||||||
|
'assets:management': '자산 관리',
|
||||||
|
'assets:upload': '자산 현행화',
|
||||||
|
'assets:theory': '방제자원 이론',
|
||||||
|
'assets:insurance': '선박 보험정보',
|
||||||
|
|
||||||
|
// scat
|
||||||
|
'scat:survey': 'SCAT 조사',
|
||||||
|
|
||||||
|
// weather
|
||||||
|
'weather:current': '현재 기상',
|
||||||
|
'weather:forecast': '기상 예보',
|
||||||
|
|
||||||
|
// incidents
|
||||||
|
'incidents:list': '사고 목록',
|
||||||
|
|
||||||
|
// admin
|
||||||
|
'admin:users': '사용자 관리',
|
||||||
|
'admin:permissions': '권한 매트릭스',
|
||||||
|
'admin:menus': '메뉴 관리',
|
||||||
|
'admin:settings': '시스템 설정',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type FeatureId = keyof typeof FEATURE_IDS;
|
||||||
24
frontend/src/common/hooks/useFeatureTracking.ts
Normal file
24
frontend/src/common/hooks/useFeatureTracking.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAuthStore } from '@common/store/authStore';
|
||||||
|
import { API_BASE_URL } from '@common/services/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서브탭 진입 시 감사 로그를 기록하는 훅.
|
||||||
|
* App.tsx의 탭 레벨 TAB_VIEW와 함께, 서브탭 레벨 SUBTAB_VIEW를 기록한다.
|
||||||
|
*
|
||||||
|
* N-depth 지원: 콜론 구분 경로 (예: 'aerial:media', 'admin:users', 'a:b:c:d')
|
||||||
|
*
|
||||||
|
* @param featureId - 콜론 구분 리소스 경로
|
||||||
|
*/
|
||||||
|
export function useFeatureTracking(featureId: string) {
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated || !featureId) return;
|
||||||
|
const blob = new Blob(
|
||||||
|
[JSON.stringify({ action: 'SUBTAB_VIEW', detail: featureId })],
|
||||||
|
{ type: 'text/plain' },
|
||||||
|
);
|
||||||
|
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob);
|
||||||
|
}, [featureId, isAuthenticated]);
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import type { MainTab } from '../types/navigation'
|
import type { MainTab } from '../types/navigation'
|
||||||
|
import { useAuthStore } from '@common/store/authStore'
|
||||||
|
import { API_BASE_URL } from '@common/services/api'
|
||||||
|
|
||||||
interface SubMenuItem {
|
interface SubMenuItem {
|
||||||
id: string
|
id: string
|
||||||
@ -91,6 +93,8 @@ function subscribe(listener: () => void) {
|
|||||||
|
|
||||||
export function useSubMenu(mainTab: MainTab) {
|
export function useSubMenu(mainTab: MainTab) {
|
||||||
const [activeSubTab, setActiveSubTabLocal] = useState(subMenuState[mainTab])
|
const [activeSubTab, setActiveSubTabLocal] = useState(subMenuState[mainTab])
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||||
|
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = subscribe(() => {
|
const unsubscribe = subscribe(() => {
|
||||||
@ -103,10 +107,27 @@ export function useSubMenu(mainTab: MainTab) {
|
|||||||
setSubTab(mainTab, subTab)
|
setSubTab(mainTab, subTab)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 권한 기반 서브메뉴 필터링
|
||||||
|
const rawConfig = subMenuConfigs[mainTab]
|
||||||
|
const filteredConfig = rawConfig?.filter(item =>
|
||||||
|
hasPermission(`${mainTab}:${item.id}`)
|
||||||
|
) ?? null
|
||||||
|
|
||||||
|
// 서브탭 전환 시 자동 감사 로그 (N-depth 지원: 콜론 구분 경로)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated || !activeSubTab) return
|
||||||
|
const resourcePath = `${mainTab}:${activeSubTab}`
|
||||||
|
const blob = new Blob(
|
||||||
|
[JSON.stringify({ action: 'SUBTAB_VIEW', detail: resourcePath })],
|
||||||
|
{ type: 'text/plain' },
|
||||||
|
)
|
||||||
|
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
|
||||||
|
}, [mainTab, activeSubTab, isAuthenticated])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeSubTab,
|
activeSubTab,
|
||||||
setActiveSubTab,
|
setActiveSubTab,
|
||||||
subMenuConfig: subMenuConfigs[mainTab]
|
subMenuConfig: filteredConfig,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
|
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
|
||||||
|
|
||||||
export const api = axios.create({
|
export const api = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
|
|||||||
@ -7,7 +7,7 @@ export interface AuthUser {
|
|||||||
rank: string | null
|
rank: string | null
|
||||||
org: { sn: number; name: string; abbr: string } | null
|
org: { sn: number; name: string; abbr: string } | null
|
||||||
roles: string[]
|
roles: string[]
|
||||||
permissions: string[]
|
permissions: Record<string, string[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
@ -117,6 +117,7 @@ export interface RoleWithPermissions {
|
|||||||
permissions: Array<{
|
permissions: Array<{
|
||||||
sn: number
|
sn: number
|
||||||
resourceCode: string
|
resourceCode: string
|
||||||
|
operationCode: string
|
||||||
granted: boolean
|
granted: boolean
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
@ -126,9 +127,26 @@ export async function fetchRoles(): Promise<RoleWithPermissions[]> {
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 권한 트리 구조 API
|
||||||
|
export interface PermTreeNode {
|
||||||
|
code: string
|
||||||
|
parentCode: string | null
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
icon: string | null
|
||||||
|
level: number
|
||||||
|
sortOrder: number
|
||||||
|
children: PermTreeNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPermTree(): Promise<PermTreeNode[]> {
|
||||||
|
const response = await api.get<PermTreeNode[]>('/roles/perm-tree')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function updatePermissionsApi(
|
export async function updatePermissionsApi(
|
||||||
roleSn: number,
|
roleSn: number,
|
||||||
permissions: Array<{ resourceCode: string; granted: boolean }>
|
permissions: Array<{ resourceCode: string; operationCode: string; granted: boolean }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await api.put(`/roles/${roleSn}/permissions`, { permissions })
|
await api.put(`/roles/${roleSn}/permissions`, { permissions })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ interface AuthState {
|
|||||||
googleLogin: (credential: string) => Promise<void>
|
googleLogin: (credential: string) => Promise<void>
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
checkSession: () => Promise<void>
|
checkSession: () => Promise<void>
|
||||||
hasPermission: (resource: string) => boolean
|
hasPermission: (resource: string, operation?: string) => boolean
|
||||||
clearError: () => void
|
clearError: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,10 +70,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
hasPermission: (resource: string) => {
|
hasPermission: (resource: string, operation?: string) => {
|
||||||
const { user } = get()
|
const { user } = get()
|
||||||
if (!user) return false
|
if (!user) return false
|
||||||
return user.permissions.includes(resource)
|
const ops = user.permissions[resource]
|
||||||
|
if (!ops) return false
|
||||||
|
return ops.includes(operation ?? 'READ')
|
||||||
},
|
},
|
||||||
|
|
||||||
clearError: () => set({ error: null, pendingMessage: null }),
|
clearError: () => set({ error: null, pendingMessage: null }),
|
||||||
|
|||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
198
frontend/src/tabs/admin/components/MenusPanel.tsx
Normal file
198
frontend/src/tabs/admin/components/MenusPanel.tsx
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
DragOverlay,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable'
|
||||||
|
import {
|
||||||
|
fetchMenuConfig,
|
||||||
|
updateMenuConfigApi,
|
||||||
|
type MenuConfigItem,
|
||||||
|
} from '@common/services/authApi'
|
||||||
|
import { useMenuStore } from '@common/store/menuStore'
|
||||||
|
import SortableMenuItem from './SortableMenuItem'
|
||||||
|
|
||||||
|
// ─── 메뉴 관리 패널 ─────────────────────────────────────────
|
||||||
|
function MenusPanel() {
|
||||||
|
const [menus, setMenus] = useState<MenuConfigItem[]>([])
|
||||||
|
const [originalMenus, setOriginalMenus] = useState<MenuConfigItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [emojiPickerId, setEmojiPickerId] = useState<string | null>(null)
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null)
|
||||||
|
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { setMenuConfig } = useMenuStore()
|
||||||
|
|
||||||
|
const hasChanges = JSON.stringify(menus) !== JSON.stringify(originalMenus)
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||||
|
)
|
||||||
|
|
||||||
|
const loadMenus = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const config = await fetchMenuConfig()
|
||||||
|
setMenus(config)
|
||||||
|
setOriginalMenus(config)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('메뉴 설정 조회 실패:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMenus()
|
||||||
|
}, [loadMenus])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!emojiPickerId) return
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target as Node)) {
|
||||||
|
setEmojiPickerId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [emojiPickerId])
|
||||||
|
|
||||||
|
const toggleMenu = (id: string) => {
|
||||||
|
setMenus(prev => prev.map(m => m.id === id ? { ...m, enabled: !m.enabled } : m))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateMenuField = (id: string, field: 'label' | 'icon', value: string) => {
|
||||||
|
setMenus(prev => prev.map(m => m.id === id ? { ...m, [field]: value } : m))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEmojiSelect = (emoji: { native: string }) => {
|
||||||
|
if (emojiPickerId) {
|
||||||
|
updateMenuField(emojiPickerId, 'icon', emoji.native)
|
||||||
|
setEmojiPickerId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveMenu = (idx: number, direction: -1 | 1) => {
|
||||||
|
const targetIdx = idx + direction
|
||||||
|
if (targetIdx < 0 || targetIdx >= menus.length) return
|
||||||
|
setMenus(prev => {
|
||||||
|
const arr = [...prev]
|
||||||
|
;[arr[idx], arr[targetIdx]] = [arr[targetIdx], arr[idx]]
|
||||||
|
return arr.map((m, i) => ({ ...m, order: i + 1 }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event
|
||||||
|
setActiveId(null)
|
||||||
|
if (!over || active.id === over.id) return
|
||||||
|
setMenus(prev => {
|
||||||
|
const oldIndex = prev.findIndex(m => m.id === active.id)
|
||||||
|
const newIndex = prev.findIndex(m => m.id === over.id)
|
||||||
|
const reordered = arrayMove(prev, oldIndex, newIndex)
|
||||||
|
return reordered.map((m, i) => ({ ...m, order: i + 1 }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const updated = await updateMenuConfigApi(menus)
|
||||||
|
setMenus(updated)
|
||||||
|
setOriginalMenus(updated)
|
||||||
|
setMenuConfig(updated)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('메뉴 설정 저장 실패:', err)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-text-3 text-sm font-korean">메뉴 설정을 불러오는 중...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeMenu = activeId ? menus.find(m => m.id === activeId) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<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">메뉴 표시 여부, 순서, 라벨, 아이콘을 관리합니다</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges || saving}
|
||||||
|
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean ${
|
||||||
|
hasChanges && !saving
|
||||||
|
? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||||
|
: 'bg-bg-3 text-text-3 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{saving ? '저장 중...' : '변경사항 저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto px-6 py-4">
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={(event) => setActiveId(event.active.id as string)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext items={menus.map(m => m.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="flex flex-col gap-2 max-w-[700px]">
|
||||||
|
{menus.map((menu, idx) => (
|
||||||
|
<SortableMenuItem
|
||||||
|
key={menu.id}
|
||||||
|
menu={menu}
|
||||||
|
idx={idx}
|
||||||
|
totalCount={menus.length}
|
||||||
|
isEditing={editingId === menu.id}
|
||||||
|
emojiPickerId={emojiPickerId}
|
||||||
|
emojiPickerRef={emojiPickerRef}
|
||||||
|
onToggle={toggleMenu}
|
||||||
|
onMove={moveMenu}
|
||||||
|
onEditStart={setEditingId}
|
||||||
|
onEditEnd={() => { setEditingId(null); setEmojiPickerId(null) }}
|
||||||
|
onEmojiPickerToggle={setEmojiPickerId}
|
||||||
|
onLabelChange={(id, value) => updateMenuField(id, 'label', value)}
|
||||||
|
onEmojiSelect={handleEmojiSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
<DragOverlay>
|
||||||
|
{activeMenu ? (
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3 rounded-md border border-primary-cyan bg-bg-1 shadow-lg opacity-90 max-w-[700px]">
|
||||||
|
<span className="text-text-3 text-xs">⠿</span>
|
||||||
|
<span className="text-[16px]">{activeMenu.icon}</span>
|
||||||
|
<span className="text-[13px] font-semibold text-text-1 font-korean">{activeMenu.label}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MenusPanel
|
||||||
667
frontend/src/tabs/admin/components/PermissionsPanel.tsx
Normal file
667
frontend/src/tabs/admin/components/PermissionsPanel.tsx
Normal file
@ -0,0 +1,667 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
fetchRoles,
|
||||||
|
fetchPermTree,
|
||||||
|
updatePermissionsApi,
|
||||||
|
createRoleApi,
|
||||||
|
updateRoleApi,
|
||||||
|
deleteRoleApi,
|
||||||
|
updateRoleDefaultApi,
|
||||||
|
type RoleWithPermissions,
|
||||||
|
type PermTreeNode,
|
||||||
|
} from '@common/services/authApi'
|
||||||
|
import { getRoleColor } from './adminConstants'
|
||||||
|
|
||||||
|
// ─── 오퍼레이션 코드 ─────────────────────────────────
|
||||||
|
const OPER_CODES = ['READ', 'CREATE', 'UPDATE', 'DELETE'] as const
|
||||||
|
type OperCode = (typeof OPER_CODES)[number]
|
||||||
|
const OPER_LABELS: Record<OperCode, string> = { READ: 'R', CREATE: 'C', UPDATE: 'U', DELETE: 'D' }
|
||||||
|
const OPER_FULL_LABELS: Record<OperCode, string> = { READ: '조회', CREATE: '생성', UPDATE: '수정', DELETE: '삭제' }
|
||||||
|
|
||||||
|
// ─── 권한 상태 타입 ─────────────────────────────────────
|
||||||
|
type PermState = 'explicit-granted' | 'inherited-granted' | 'explicit-denied' | 'forced-denied'
|
||||||
|
|
||||||
|
// ─── 키 유틸 ──────────────────────────────────────────
|
||||||
|
function makeKey(rsrc: string, oper: string): string { return `${rsrc}::${oper}` }
|
||||||
|
|
||||||
|
// ─── 유틸: 플랫 노드 목록 추출 (트리 DFS) ─────────────
|
||||||
|
function flattenTree(nodes: PermTreeNode[]): PermTreeNode[] {
|
||||||
|
const result: PermTreeNode[] = []
|
||||||
|
function walk(list: PermTreeNode[]) {
|
||||||
|
for (const n of list) {
|
||||||
|
result.push(n)
|
||||||
|
if (n.children.length > 0) walk(n.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(nodes)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 유틸: 권한 상태 계산 (오퍼레이션별) ──────────────
|
||||||
|
function resolvePermStateForOper(
|
||||||
|
code: string,
|
||||||
|
parentCode: string | null,
|
||||||
|
operCd: string,
|
||||||
|
explicitPerms: Map<string, boolean>,
|
||||||
|
cache: Map<string, PermState>,
|
||||||
|
): PermState {
|
||||||
|
const key = makeKey(code, operCd)
|
||||||
|
const cached = cache.get(key)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const explicit = explicitPerms.get(key)
|
||||||
|
|
||||||
|
if (parentCode === null) {
|
||||||
|
const state: PermState = explicit === true ? 'explicit-granted'
|
||||||
|
: explicit === false ? 'explicit-denied'
|
||||||
|
: 'explicit-denied'
|
||||||
|
cache.set(key, state)
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부모 READ 확인 (접근 게이트)
|
||||||
|
const parentReadKey = makeKey(parentCode, 'READ')
|
||||||
|
const parentReadState = cache.get(parentReadKey)
|
||||||
|
if (parentReadState === 'explicit-denied' || parentReadState === 'forced-denied') {
|
||||||
|
cache.set(key, 'forced-denied')
|
||||||
|
return 'forced-denied'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (explicit === true) {
|
||||||
|
cache.set(key, 'explicit-granted')
|
||||||
|
return 'explicit-granted'
|
||||||
|
}
|
||||||
|
if (explicit === false) {
|
||||||
|
cache.set(key, 'explicit-denied')
|
||||||
|
return 'explicit-denied'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부모의 같은 오퍼레이션 상속
|
||||||
|
const parentOperKey = makeKey(parentCode, operCd)
|
||||||
|
const parentOperState = cache.get(parentOperKey)
|
||||||
|
if (parentOperState === 'explicit-granted' || parentOperState === 'inherited-granted') {
|
||||||
|
cache.set(key, 'inherited-granted')
|
||||||
|
return 'inherited-granted'
|
||||||
|
}
|
||||||
|
if (parentOperState === 'forced-denied') {
|
||||||
|
cache.set(key, 'forced-denied')
|
||||||
|
return 'forced-denied'
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(key, 'explicit-denied')
|
||||||
|
return 'explicit-denied'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEffectiveStates(
|
||||||
|
flatNodes: PermTreeNode[],
|
||||||
|
explicitPerms: Map<string, boolean>,
|
||||||
|
): Map<string, PermState> {
|
||||||
|
const cache = new Map<string, PermState>()
|
||||||
|
for (const node of flatNodes) {
|
||||||
|
// READ 먼저 (CUD는 READ에 의존)
|
||||||
|
resolvePermStateForOper(node.code, node.parentCode, 'READ', explicitPerms, cache)
|
||||||
|
for (const oper of OPER_CODES) {
|
||||||
|
if (oper === 'READ') continue
|
||||||
|
resolvePermStateForOper(node.code, node.parentCode, oper, explicitPerms, cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 체크박스 셀 컴포넌트 ────────────────────────────
|
||||||
|
interface PermCellProps {
|
||||||
|
state: PermState
|
||||||
|
onToggle: () => void
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermCell({ state, onToggle, label }: PermCellProps) {
|
||||||
|
const isDisabled = state === 'forced-denied'
|
||||||
|
|
||||||
|
const baseClasses = 'w-7 h-7 rounded border text-xs font-bold transition-all flex items-center justify-center'
|
||||||
|
|
||||||
|
let classes: string
|
||||||
|
let icon: string
|
||||||
|
|
||||||
|
switch (state) {
|
||||||
|
case 'explicit-granted':
|
||||||
|
classes = `${baseClasses} bg-[rgba(6,182,212,0.2)] border-primary-cyan text-primary-cyan cursor-pointer hover:bg-[rgba(6,182,212,0.3)]`
|
||||||
|
icon = '✓'
|
||||||
|
break
|
||||||
|
case 'inherited-granted':
|
||||||
|
classes = `${baseClasses} bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] cursor-pointer hover:border-primary-cyan`
|
||||||
|
icon = '✓'
|
||||||
|
break
|
||||||
|
case 'explicit-denied':
|
||||||
|
classes = `${baseClasses} bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-red-400 cursor-pointer hover:border-red-400`
|
||||||
|
icon = '—'
|
||||||
|
break
|
||||||
|
case 'forced-denied':
|
||||||
|
classes = `${baseClasses} bg-bg-2 border-border text-text-3 opacity-40 cursor-not-allowed`
|
||||||
|
icon = '—'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={isDisabled ? undefined : onToggle}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={classes}
|
||||||
|
title={
|
||||||
|
state === 'explicit-granted' ? `${label ?? ''} 명시적 허용 (클릭: 거부로 전환)`
|
||||||
|
: state === 'inherited-granted' ? `${label ?? ''} 부모 상속 허용 (클릭: 명시적 거부)`
|
||||||
|
: state === 'explicit-denied' ? `${label ?? ''} 명시적 거부 (클릭: 허용으로 전환)`
|
||||||
|
: `${label ?? ''} 부모 거부로 비활성`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 트리 행 컴포넌트 ────────────────────────────────
|
||||||
|
interface TreeRowProps {
|
||||||
|
node: PermTreeNode
|
||||||
|
stateMap: Map<string, PermState>
|
||||||
|
expanded: Set<string>
|
||||||
|
onToggleExpand: (code: string) => void
|
||||||
|
onTogglePerm: (code: string, oper: OperCode, currentState: PermState) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeRow({ node, stateMap, expanded, onToggleExpand, onTogglePerm }: TreeRowProps) {
|
||||||
|
const hasChildren = node.children.length > 0
|
||||||
|
const isExpanded = expanded.has(node.code)
|
||||||
|
const indent = node.level * 24
|
||||||
|
|
||||||
|
// 이 노드의 READ 상태 (CUD 비활성 판단용)
|
||||||
|
const readState = stateMap.get(makeKey(node.code, 'READ')) ?? 'forced-denied'
|
||||||
|
const readDenied = readState === 'explicit-denied' || readState === 'forced-denied'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex items-center" style={{ paddingLeft: indent }}>
|
||||||
|
{hasChildren ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleExpand(node.code)}
|
||||||
|
className="w-5 h-5 flex items-center justify-center text-text-3 hover:text-text-1 transition-colors mr-1 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
|
||||||
|
className={`transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||||
|
>
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="w-5 mr-1 flex-shrink-0 text-center text-text-3 text-[10px]">
|
||||||
|
{node.level > 0 ? '├' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{node.icon && <span className="mr-1.5 flex-shrink-0">{node.icon}</span>}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className={`text-[12px] font-korean truncate ${node.level === 0 ? 'font-bold text-text-1' : 'font-medium text-text-2'}`}>
|
||||||
|
{node.name}
|
||||||
|
</div>
|
||||||
|
{node.description && node.level === 0 && (
|
||||||
|
<div className="text-[10px] text-text-3 font-korean truncate mt-0.5">{node.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{OPER_CODES.map(oper => {
|
||||||
|
const key = makeKey(node.code, oper)
|
||||||
|
const state = stateMap.get(key) ?? 'forced-denied'
|
||||||
|
// READ 거부 시 CUD도 강제 거부
|
||||||
|
const effectiveState = (oper !== 'READ' && readDenied) ? 'forced-denied' as PermState : state
|
||||||
|
return (
|
||||||
|
<td key={oper} className="px-2 py-2.5 text-center">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<PermCell
|
||||||
|
state={effectiveState}
|
||||||
|
label={OPER_FULL_LABELS[oper]}
|
||||||
|
onToggle={() => onTogglePerm(node.code, oper, effectiveState)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
{hasChildren && isExpanded && node.children.map(child => (
|
||||||
|
<TreeRow
|
||||||
|
key={child.code}
|
||||||
|
node={child}
|
||||||
|
stateMap={stateMap}
|
||||||
|
expanded={expanded}
|
||||||
|
onToggleExpand={onToggleExpand}
|
||||||
|
onTogglePerm={onTogglePerm}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 메인 PermissionsPanel ──────────────────────────
|
||||||
|
function PermissionsPanel() {
|
||||||
|
const [roles, setRoles] = useState<RoleWithPermissions[]>([])
|
||||||
|
const [permTree, setPermTree] = useState<PermTreeNode[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||||
|
const [newRoleCode, setNewRoleCode] = useState('')
|
||||||
|
const [newRoleName, setNewRoleName] = useState('')
|
||||||
|
const [newRoleDesc, setNewRoleDesc] = useState('')
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [createError, setCreateError] = useState('')
|
||||||
|
const [editingRoleSn, setEditingRoleSn] = useState<number | null>(null)
|
||||||
|
const [editRoleName, setEditRoleName] = useState('')
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||||
|
const [selectedRoleSn, setSelectedRoleSn] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// 역할별 명시적 권한: Map<roleSn, Map<"rsrc::oper", boolean>>
|
||||||
|
const [rolePerms, setRolePerms] = useState<Map<number, Map<string, boolean>>>(new Map())
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [rolesData, treeData] = await Promise.all([fetchRoles(), fetchPermTree()])
|
||||||
|
setRoles(rolesData)
|
||||||
|
setPermTree(treeData)
|
||||||
|
|
||||||
|
// 명시적 권한 맵 초기화 (rsrc::oper 키 형식)
|
||||||
|
const permsMap = new Map<number, Map<string, boolean>>()
|
||||||
|
for (const role of rolesData) {
|
||||||
|
const roleMap = new Map<string, boolean>()
|
||||||
|
for (const p of role.permissions) {
|
||||||
|
roleMap.set(makeKey(p.resourceCode, p.operationCode), p.granted)
|
||||||
|
}
|
||||||
|
permsMap.set(role.sn, roleMap)
|
||||||
|
}
|
||||||
|
setRolePerms(permsMap)
|
||||||
|
|
||||||
|
// 최상위 노드 기본 펼침
|
||||||
|
setExpanded(new Set(treeData.map(n => n.code)))
|
||||||
|
// 첫 번째 역할 선택
|
||||||
|
if (rolesData.length > 0 && !selectedRoleSn) {
|
||||||
|
setSelectedRoleSn(rolesData[0].sn)
|
||||||
|
}
|
||||||
|
setDirty(false)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('권한 데이터 조회 실패:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- 초기 마운트 시 1회만 실행
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
// 플랫 노드 목록
|
||||||
|
const flatNodes = flattenTree(permTree)
|
||||||
|
|
||||||
|
// 선택된 역할의 effective state 계산
|
||||||
|
const currentStateMap = selectedRoleSn
|
||||||
|
? buildEffectiveStates(flatNodes, rolePerms.get(selectedRoleSn) ?? new Map())
|
||||||
|
: new Map<string, PermState>()
|
||||||
|
|
||||||
|
const handleToggleExpand = useCallback((code: string) => {
|
||||||
|
setExpanded(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(code)) next.delete(code)
|
||||||
|
else next.add(code)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTogglePerm = useCallback((code: string, oper: OperCode, currentState: PermState) => {
|
||||||
|
if (!selectedRoleSn) return
|
||||||
|
|
||||||
|
setRolePerms(prev => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
const roleMap = new Map(next.get(selectedRoleSn) ?? new Map())
|
||||||
|
|
||||||
|
const key = makeKey(code, oper)
|
||||||
|
const node = flatNodes.find(n => n.code === code)
|
||||||
|
const isRoot = node ? node.parentCode === null : false
|
||||||
|
|
||||||
|
switch (currentState) {
|
||||||
|
case 'explicit-granted':
|
||||||
|
roleMap.set(key, false)
|
||||||
|
break
|
||||||
|
case 'inherited-granted':
|
||||||
|
roleMap.set(key, false)
|
||||||
|
break
|
||||||
|
case 'explicit-denied':
|
||||||
|
if (isRoot) {
|
||||||
|
roleMap.set(key, true)
|
||||||
|
} else {
|
||||||
|
roleMap.delete(key)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
|
||||||
|
next.set(selectedRoleSn, roleMap)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setDirty(true)
|
||||||
|
}, [selectedRoleSn, flatNodes])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
for (const role of roles) {
|
||||||
|
const perms = rolePerms.get(role.sn)
|
||||||
|
if (!perms) continue
|
||||||
|
|
||||||
|
const permsList: Array<{ resourceCode: string; operationCode: string; granted: boolean }> = []
|
||||||
|
for (const [key, granted] of perms) {
|
||||||
|
const sepIdx = key.indexOf('::')
|
||||||
|
permsList.push({
|
||||||
|
resourceCode: key.substring(0, sepIdx),
|
||||||
|
operationCode: key.substring(sepIdx + 2),
|
||||||
|
granted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await updatePermissionsApi(role.sn, permsList)
|
||||||
|
}
|
||||||
|
setDirty(false)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('권한 저장 실패:', err)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateRole = async () => {
|
||||||
|
setCreating(true)
|
||||||
|
setCreateError('')
|
||||||
|
try {
|
||||||
|
await createRoleApi({ code: newRoleCode, name: newRoleName, description: newRoleDesc || undefined })
|
||||||
|
await loadData()
|
||||||
|
setShowCreateForm(false)
|
||||||
|
setNewRoleCode('')
|
||||||
|
setNewRoleName('')
|
||||||
|
setNewRoleDesc('')
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '역할 생성에 실패했습니다.'
|
||||||
|
setCreateError(message)
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteRole = async (roleSn: number, roleName: string) => {
|
||||||
|
if (!window.confirm(`"${roleName}" 역할을 삭제하시겠습니까?\n이 역할을 가진 모든 사용자에서 해당 역할이 제거됩니다.`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteRoleApi(roleSn)
|
||||||
|
if (selectedRoleSn === roleSn) setSelectedRoleSn(null)
|
||||||
|
await loadData()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('역할 삭제 실패:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStartEditName = (role: RoleWithPermissions) => {
|
||||||
|
setEditingRoleSn(role.sn)
|
||||||
|
setEditRoleName(role.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveRoleName = async (roleSn: number) => {
|
||||||
|
if (!editRoleName.trim()) return
|
||||||
|
try {
|
||||||
|
await updateRoleApi(roleSn, { name: editRoleName.trim() })
|
||||||
|
setRoles(prev => prev.map(r =>
|
||||||
|
r.sn === roleSn ? { ...r, name: editRoleName.trim() } : r
|
||||||
|
))
|
||||||
|
setEditingRoleSn(null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('역할 이름 수정 실패:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleDefault = async (roleSn: number) => {
|
||||||
|
const role = roles.find(r => r.sn === roleSn)
|
||||||
|
if (!role) return
|
||||||
|
const newValue = !role.isDefault
|
||||||
|
try {
|
||||||
|
await updateRoleDefaultApi(roleSn, newValue)
|
||||||
|
setRoles(prev => prev.map(r =>
|
||||||
|
r.sn === roleSn ? { ...r, isDefault: newValue } : r
|
||||||
|
))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('기본 역할 변경 실패:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">불러오는 중...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<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">역할별 리소스 × CRUD 권한을 설정합니다</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowCreateForm(true); setCreateError('') }}
|
||||||
|
className="px-4 py-2 text-xs font-semibold rounded-md border border-primary-cyan text-primary-cyan hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
+ 역할 추가
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!dirty || saving}
|
||||||
|
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean ${
|
||||||
|
dirty ? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]' : 'bg-bg-3 text-text-3 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{saving ? '저장 중...' : '변경사항 저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 역할 탭 바 */}
|
||||||
|
<div className="flex items-center gap-2 px-6 py-3 border-b border-border bg-bg-1 overflow-x-auto" style={{ flexShrink: 0 }}>
|
||||||
|
{roles.map((role, idx) => {
|
||||||
|
const color = getRoleColor(role.code, idx)
|
||||||
|
const isSelected = selectedRoleSn === role.sn
|
||||||
|
return (
|
||||||
|
<div key={role.sn} className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedRoleSn(role.sn)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-semibold rounded-md transition-all font-korean ${
|
||||||
|
isSelected
|
||||||
|
? 'border-2 shadow-[0_0_8px_rgba(6,182,212,0.2)]'
|
||||||
|
: 'border border-border text-text-3 hover:border-border'
|
||||||
|
}`}
|
||||||
|
style={isSelected ? { borderColor: color, color } : undefined}
|
||||||
|
>
|
||||||
|
{editingRoleSn === role.sn ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editRoleName}
|
||||||
|
onChange={(e) => setEditRoleName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSaveRoleName(role.sn)
|
||||||
|
if (e.key === 'Escape') setEditingRoleSn(null)
|
||||||
|
}}
|
||||||
|
onBlur={() => handleSaveRoleName(role.sn)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
autoFocus
|
||||||
|
className="w-20 px-1 py-0 text-[11px] font-semibold bg-bg-2 border border-primary-cyan rounded text-center text-text-1 focus:outline-none font-korean"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span onDoubleClick={() => handleStartEditName(role)}>
|
||||||
|
{role.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-1 text-[9px] font-mono opacity-50">{role.code}</span>
|
||||||
|
{role.isDefault && <span className="ml-1 text-[9px] text-primary-cyan">기본</span>}
|
||||||
|
</button>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleDefault(role.sn)}
|
||||||
|
className={`px-1.5 py-0.5 text-[9px] rounded transition-all font-korean ${
|
||||||
|
role.isDefault
|
||||||
|
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan'
|
||||||
|
: 'text-text-3 hover:text-text-2'
|
||||||
|
}`}
|
||||||
|
title="신규 사용자 기본 역할 설정"
|
||||||
|
>
|
||||||
|
{role.isDefault ? '기본역할' : '기본설정'}
|
||||||
|
</button>
|
||||||
|
{role.code !== 'ADMIN' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteRole(role.sn, role.name)}
|
||||||
|
className="w-5 h-5 flex items-center justify-center text-text-3 hover:text-red-400 transition-colors"
|
||||||
|
title="역할 삭제"
|
||||||
|
>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 범례 */}
|
||||||
|
<div className="flex items-center gap-4 px-6 py-2 border-b border-border bg-bg-1 text-[10px] text-text-3 font-korean" style={{ flexShrink: 0 }}>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block w-4 h-4 rounded border bg-[rgba(6,182,212,0.2)] border-primary-cyan text-primary-cyan text-center text-[9px] leading-4">✓</span>
|
||||||
|
명시적 허용
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block w-4 h-4 rounded border bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] text-center text-[9px] leading-4">✓</span>
|
||||||
|
상속 허용
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block w-4 h-4 rounded border bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-red-400 text-center text-[9px] leading-4">—</span>
|
||||||
|
명시적 거부
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block w-4 h-4 rounded border bg-bg-2 border-border text-text-3 opacity-40 text-center text-[9px] leading-4">—</span>
|
||||||
|
강제 거부
|
||||||
|
</span>
|
||||||
|
<span className="ml-4 border-l border-border pl-4 text-text-3">
|
||||||
|
R=조회 C=생성 U=수정 D=삭제
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CRUD 매트릭스 테이블 */}
|
||||||
|
{selectedRoleSn ? (
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<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 min-w-[240px]">기능</th>
|
||||||
|
{OPER_CODES.map(oper => (
|
||||||
|
<th key={oper} className="px-2 py-3 text-center w-16">
|
||||||
|
<div className="text-[11px] font-semibold text-text-2">{OPER_LABELS[oper]}</div>
|
||||||
|
<div className="text-[9px] text-text-3 font-korean">{OPER_FULL_LABELS[oper]}</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{permTree.map(rootNode => (
|
||||||
|
<TreeRow
|
||||||
|
key={rootNode.code}
|
||||||
|
node={rootNode}
|
||||||
|
stateMap={currentStateMap}
|
||||||
|
expanded={expanded}
|
||||||
|
onToggleExpand={handleToggleExpand}
|
||||||
|
onTogglePerm={handleTogglePerm}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-text-3 text-sm font-korean">
|
||||||
|
역할을 선택하세요
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 역할 생성 모달 */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="w-[400px] bg-bg-1 rounded-lg border border-border shadow-2xl">
|
||||||
|
<div className="px-5 py-4 border-b border-border">
|
||||||
|
<h3 className="text-sm font-bold text-text-1 font-korean">새 역할 추가</h3>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] text-text-3 font-korean block mb-1">역할 코드</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newRoleCode}
|
||||||
|
onChange={(e) => setNewRoleCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))}
|
||||||
|
placeholder="CUSTOM_ROLE"
|
||||||
|
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-text-3 mt-1 font-korean">영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] text-text-3 font-korean block mb-1">역할 이름</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newRoleName}
|
||||||
|
onChange={(e) => setNewRoleName(e.target.value)}
|
||||||
|
placeholder="사용자 정의 역할"
|
||||||
|
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] text-text-3 font-korean block mb-1">설명 (선택)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newRoleDesc}
|
||||||
|
onChange={(e) => setNewRoleDesc(e.target.value)}
|
||||||
|
placeholder="역할에 대한 설명"
|
||||||
|
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{createError && (
|
||||||
|
<div className="px-3 py-2 text-[11px] text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)] rounded-md font-korean">
|
||||||
|
{createError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-3 border-t border-border flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(false)}
|
||||||
|
className="px-4 py-2 text-xs text-text-3 border border-border rounded-md hover:bg-bg-hover font-korean"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateRole}
|
||||||
|
disabled={!newRoleCode || !newRoleName || creating}
|
||||||
|
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 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{creating ? '생성 중...' : '생성'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PermissionsPanel
|
||||||
237
frontend/src/tabs/admin/components/SettingsPanel.tsx
Normal file
237
frontend/src/tabs/admin/components/SettingsPanel.tsx
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
fetchRegistrationSettings,
|
||||||
|
updateRegistrationSettingsApi,
|
||||||
|
fetchOAuthSettings,
|
||||||
|
updateOAuthSettingsApi,
|
||||||
|
type RegistrationSettings,
|
||||||
|
type OAuthSettings,
|
||||||
|
} from '@common/services/authApi'
|
||||||
|
|
||||||
|
// ─── 시스템 설정 패널 ────────────────────────────────────────
|
||||||
|
function SettingsPanel() {
|
||||||
|
const [settings, setSettings] = useState<RegistrationSettings | null>(null)
|
||||||
|
const [oauthSettings, setOauthSettings] = useState<OAuthSettings | null>(null)
|
||||||
|
const [oauthDomainInput, setOauthDomainInput] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [savingOAuth, setSavingOAuth] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSettings()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadSettings = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [regData, oauthData] = await Promise.all([
|
||||||
|
fetchRegistrationSettings(),
|
||||||
|
fetchOAuthSettings(),
|
||||||
|
])
|
||||||
|
setSettings(regData)
|
||||||
|
setOauthSettings(oauthData)
|
||||||
|
setOauthDomainInput(oauthData.autoApproveDomains)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('설정 조회 실패:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggle = async (key: keyof RegistrationSettings) => {
|
||||||
|
if (!settings) return
|
||||||
|
const newValue = !settings[key]
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const updated = await updateRegistrationSettingsApi({ [key]: newValue })
|
||||||
|
setSettings(updated)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('설정 변경 실패:', err)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">불러오는 중...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="px-6 py-4 border-b border-border">
|
||||||
|
<h1 className="text-lg font-bold text-text-1 font-korean">시스템 설정</h1>
|
||||||
|
<p className="text-xs text-text-3 mt-1 font-korean">사용자 등록 및 권한 관련 시스템 설정을 관리합니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto px-6 py-6">
|
||||||
|
<div className="max-w-[640px] flex flex-col gap-6">
|
||||||
|
{/* 사용자 등록 설정 */}
|
||||||
|
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
|
||||||
|
<div className="px-5 py-3 border-b border-border">
|
||||||
|
<h2 className="text-sm font-bold text-text-1 font-korean">사용자 등록 설정</h2>
|
||||||
|
<p className="text-[11px] text-text-3 mt-0.5 font-korean">신규 사용자 등록 시 적용되는 정책을 설정합니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{/* 자동 승인 */}
|
||||||
|
<div className="px-5 py-4 flex items-center justify-between">
|
||||||
|
<div className="flex-1 mr-4">
|
||||||
|
<div className="text-[13px] font-semibold text-text-1 font-korean">자동 승인</div>
|
||||||
|
<p className="text-[11px] text-text-3 mt-1 font-korean leading-relaxed">
|
||||||
|
활성화하면 신규 사용자가 등록 즉시 <span className="text-green-400 font-semibold">ACTIVE</span> 상태가 됩니다.
|
||||||
|
비활성화하면 관리자 승인 전까지 <span className="text-yellow-400 font-semibold">PENDING</span> 상태로 대기합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggle('autoApprove')}
|
||||||
|
disabled={saving}
|
||||||
|
className={`relative w-12 h-6 rounded-full transition-all flex-shrink-0 ${
|
||||||
|
settings?.autoApprove ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
|
||||||
|
} ${saving ? 'opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all ${
|
||||||
|
settings?.autoApprove ? 'left-[26px]' : 'left-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기본 역할 자동 할당 */}
|
||||||
|
<div className="px-5 py-4 flex items-center justify-between">
|
||||||
|
<div className="flex-1 mr-4">
|
||||||
|
<div className="text-[13px] font-semibold text-text-1 font-korean">기본 역할 자동 할당</div>
|
||||||
|
<p className="text-[11px] text-text-3 mt-1 font-korean leading-relaxed">
|
||||||
|
활성화하면 신규 사용자에게 <span className="text-primary-cyan font-semibold">기본 역할</span>이 자동으로 할당됩니다.
|
||||||
|
기본 역할은 권한 관리 탭에서 설정할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggle('defaultRole')}
|
||||||
|
disabled={saving}
|
||||||
|
className={`relative w-12 h-6 rounded-full transition-all flex-shrink-0 ${
|
||||||
|
settings?.defaultRole ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
|
||||||
|
} ${saving ? 'opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all ${
|
||||||
|
settings?.defaultRole ? 'left-[26px]' : 'left-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OAuth 설정 */}
|
||||||
|
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
|
||||||
|
<div className="px-5 py-3 border-b border-border">
|
||||||
|
<h2 className="text-sm font-bold text-text-1 font-korean">Google OAuth 설정</h2>
|
||||||
|
<p className="text-[11px] text-text-3 mt-0.5 font-korean">Google 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<div className="flex-1 mr-4 mb-3">
|
||||||
|
<div className="text-[13px] font-semibold text-text-1 font-korean mb-1">자동 승인 도메인</div>
|
||||||
|
<p className="text-[11px] text-text-3 font-korean leading-relaxed mb-3">
|
||||||
|
지정된 도메인의 Google 계정은 가입 즉시 <span className="text-green-400 font-semibold">ACTIVE</span> 상태가 됩니다.
|
||||||
|
미지정 도메인은 <span className="text-yellow-400 font-semibold">PENDING</span> 상태로 관리자 승인이 필요합니다.
|
||||||
|
여러 도메인은 쉼표(,)로 구분합니다.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={oauthDomainInput}
|
||||||
|
onChange={(e) => setOauthDomainInput(e.target.value)}
|
||||||
|
placeholder="gcsc.co.kr, example.com"
|
||||||
|
className="flex-1 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setSavingOAuth(true)
|
||||||
|
try {
|
||||||
|
const updated = await updateOAuthSettingsApi({ autoApproveDomains: oauthDomainInput.trim() })
|
||||||
|
setOauthSettings(updated)
|
||||||
|
setOauthDomainInput(updated.autoApproveDomains)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('OAuth 설정 변경 실패:', err)
|
||||||
|
} finally {
|
||||||
|
setSavingOAuth(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={savingOAuth || oauthDomainInput.trim() === (oauthSettings?.autoApproveDomains || '')}
|
||||||
|
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean whitespace-nowrap ${
|
||||||
|
oauthDomainInput.trim() !== (oauthSettings?.autoApproveDomains || '')
|
||||||
|
? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||||
|
: 'bg-bg-3 text-text-3 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{savingOAuth ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{oauthSettings?.autoApproveDomains && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||||
|
{oauthSettings.autoApproveDomains.split(',').map(d => d.trim()).filter(Boolean).map(domain => (
|
||||||
|
<span
|
||||||
|
key={domain}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-mono rounded-md"
|
||||||
|
style={{ background: 'rgba(6,182,212,0.1)', color: 'var(--cyan)', border: '1px solid rgba(6,182,212,0.25)' }}
|
||||||
|
>
|
||||||
|
@{domain}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 현재 설정 상태 요약 */}
|
||||||
|
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
|
||||||
|
<div className="px-5 py-3 border-b border-border">
|
||||||
|
<h2 className="text-sm font-bold text-text-1 font-korean">설정 상태 요약</h2>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<div className="flex flex-col gap-3 text-[12px] font-korean">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${settings?.autoApprove ? 'bg-green-400' : 'bg-yellow-400'}`} />
|
||||||
|
<span className="text-text-2">
|
||||||
|
신규 사용자 등록 시{' '}
|
||||||
|
{settings?.autoApprove ? (
|
||||||
|
<span className="text-green-400 font-semibold">즉시 활성화</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-yellow-400 font-semibold">관리자 승인 필요</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${settings?.defaultRole ? 'bg-green-400' : 'bg-text-3'}`} />
|
||||||
|
<span className="text-text-2">
|
||||||
|
기본 역할 자동 할당{' '}
|
||||||
|
{settings?.defaultRole ? (
|
||||||
|
<span className="text-green-400 font-semibold">활성</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-3 font-semibold">비활성</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${oauthSettings?.autoApproveDomains ? 'bg-blue-400' : 'bg-text-3'}`} />
|
||||||
|
<span className="text-text-2">
|
||||||
|
Google OAuth 자동 승인 도메인{' '}
|
||||||
|
{oauthSettings?.autoApproveDomains ? (
|
||||||
|
<span className="text-blue-400 font-semibold font-mono">{oauthSettings.autoApproveDomains}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-3 font-semibold">미설정</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsPanel
|
||||||
161
frontend/src/tabs/admin/components/SortableMenuItem.tsx
Normal file
161
frontend/src/tabs/admin/components/SortableMenuItem.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import data from '@emoji-mart/data'
|
||||||
|
import EmojiPicker from '@emoji-mart/react'
|
||||||
|
import { useSortable } from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { type MenuConfigItem } from '@common/services/authApi'
|
||||||
|
|
||||||
|
// ─── 메뉴 항목 (Sortable) ────────────────────────────────────
|
||||||
|
export interface SortableMenuItemProps {
|
||||||
|
menu: MenuConfigItem
|
||||||
|
idx: number
|
||||||
|
totalCount: number
|
||||||
|
isEditing: boolean
|
||||||
|
emojiPickerId: string | null
|
||||||
|
emojiPickerRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
onToggle: (id: string) => void
|
||||||
|
onMove: (idx: number, direction: -1 | 1) => void
|
||||||
|
onEditStart: (id: string) => void
|
||||||
|
onEditEnd: () => void
|
||||||
|
onEmojiPickerToggle: (id: string | null) => void
|
||||||
|
onLabelChange: (id: string, value: string) => void
|
||||||
|
onEmojiSelect: (emoji: { native: string }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableMenuItem({
|
||||||
|
menu, idx, totalCount, isEditing, emojiPickerId, emojiPickerRef,
|
||||||
|
onToggle, onMove, onEditStart, onEditEnd, onEmojiPickerToggle, onLabelChange, onEmojiSelect,
|
||||||
|
}: SortableMenuItemProps) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: menu.id })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.4 : 1,
|
||||||
|
zIndex: isDragging ? 50 : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`flex items-center justify-between px-4 py-3 rounded-md border transition-all ${
|
||||||
|
menu.enabled
|
||||||
|
? 'bg-bg-1 border-border'
|
||||||
|
: 'bg-bg-0 border-border opacity-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="cursor-grab active:cursor-grabbing w-6 h-7 flex items-center justify-center text-text-3 hover:text-text-1 transition-all shrink-0"
|
||||||
|
title="드래그하여 순서 변경"
|
||||||
|
>
|
||||||
|
<svg width="12" height="16" viewBox="0 0 12 16" fill="currentColor">
|
||||||
|
<circle cx="3" cy="2" r="1.5" /><circle cx="9" cy="2" r="1.5" />
|
||||||
|
<circle cx="3" cy="8" r="1.5" /><circle cx="9" cy="8" r="1.5" />
|
||||||
|
<circle cx="3" cy="14" r="1.5" /><circle cx="9" cy="14" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span className="text-text-3 text-xs font-mono w-6 text-center shrink-0">{idx + 1}</span>
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => onEmojiPickerToggle(emojiPickerId === menu.id ? null : menu.id)}
|
||||||
|
className="w-10 h-10 text-[20px] bg-bg-2 border border-border rounded flex items-center justify-center hover:border-primary-cyan transition-all"
|
||||||
|
title="아이콘 변경"
|
||||||
|
>
|
||||||
|
{menu.icon}
|
||||||
|
</button>
|
||||||
|
{emojiPickerId === menu.id && (
|
||||||
|
<div ref={emojiPickerRef} className="absolute top-12 left-0 z-[300]">
|
||||||
|
<EmojiPicker
|
||||||
|
data={data}
|
||||||
|
onEmojiSelect={onEmojiSelect}
|
||||||
|
theme="dark"
|
||||||
|
locale="kr"
|
||||||
|
previewPosition="none"
|
||||||
|
skinTonePosition="search"
|
||||||
|
perLine={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={menu.label}
|
||||||
|
onChange={(e) => onLabelChange(menu.id, e.target.value)}
|
||||||
|
className="w-full h-8 text-[13px] font-semibold font-korean bg-bg-2 border border-border rounded px-2 text-text-1 focus:border-primary-cyan focus:outline-none"
|
||||||
|
/>
|
||||||
|
<div className="text-[10px] text-text-3 font-mono mt-0.5">{menu.id}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onEditEnd}
|
||||||
|
className="shrink-0 px-2 py-1 text-[10px] font-semibold text-primary-cyan border border-primary-cyan rounded hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
완료
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-[16px] shrink-0">{menu.icon}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className={`text-[13px] font-semibold font-korean ${menu.enabled ? 'text-text-1' : 'text-text-3'}`}>
|
||||||
|
{menu.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-text-3 font-mono">{menu.id}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onEditStart(menu.id)}
|
||||||
|
className="shrink-0 w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-[11px] flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all"
|
||||||
|
title="라벨/아이콘 편집"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 ml-3 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle(menu.id)}
|
||||||
|
className={`relative w-10 h-5 rounded-full transition-all ${
|
||||||
|
menu.enabled ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
|
||||||
|
menu.enabled ? 'left-[22px]' : 'left-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onMove(idx, -1)}
|
||||||
|
disabled={idx === 0}
|
||||||
|
className="w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-xs flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onMove(idx, 1)}
|
||||||
|
disabled={idx === totalCount - 1}
|
||||||
|
className="w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-xs flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SortableMenuItem
|
||||||
350
frontend/src/tabs/admin/components/UsersPanel.tsx
Normal file
350
frontend/src/tabs/admin/components/UsersPanel.tsx
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
fetchUsers,
|
||||||
|
fetchRoles,
|
||||||
|
updateUserApi,
|
||||||
|
approveUserApi,
|
||||||
|
rejectUserApi,
|
||||||
|
assignRolesApi,
|
||||||
|
type UserListItem,
|
||||||
|
type RoleWithPermissions,
|
||||||
|
} from '@common/services/authApi'
|
||||||
|
import { getRoleColor, statusLabels } from './adminConstants'
|
||||||
|
|
||||||
|
// ─── 사용자 관리 패널 ─────────────────────────────────────────
|
||||||
|
function UsersPanel() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||||
|
const [users, setUsers] = useState<UserListItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [allRoles, setAllRoles] = useState<RoleWithPermissions[]>([])
|
||||||
|
const [roleEditUserId, setRoleEditUserId] = useState<string | null>(null)
|
||||||
|
const [selectedRoleSns, setSelectedRoleSns] = useState<number[]>([])
|
||||||
|
const roleDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const loadUsers = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchUsers(searchTerm || undefined, statusFilter || undefined)
|
||||||
|
setUsers(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('사용자 목록 조회 실패:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [searchTerm, statusFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers()
|
||||||
|
}, [loadUsers])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoles().then(setAllRoles).catch(console.error)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (roleDropdownRef.current && !roleDropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setRoleEditUserId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (roleEditUserId) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [roleEditUserId])
|
||||||
|
|
||||||
|
const handleUnlock = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
await updateUserApi(userId, { status: 'ACTIVE' })
|
||||||
|
await loadUsers()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('계정 잠금 해제 실패:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApprove = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
await approveUserApi(userId)
|
||||||
|
await loadUsers()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('사용자 승인 실패:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReject = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
await rejectUserApi(userId)
|
||||||
|
await loadUsers()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('사용자 거절 실패:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeactivate = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
await updateUserApi(userId, { status: 'INACTIVE' })
|
||||||
|
await loadUsers()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('사용자 비활성화 실패:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleActivate = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
await updateUserApi(userId, { status: 'ACTIVE' })
|
||||||
|
await loadUsers()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('사용자 활성화 실패:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenRoleEdit = (user: UserListItem) => {
|
||||||
|
setRoleEditUserId(user.id)
|
||||||
|
setSelectedRoleSns(user.roleSns || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleRoleSelection = (roleSn: number) => {
|
||||||
|
setSelectedRoleSns(prev =>
|
||||||
|
prev.includes(roleSn) ? prev.filter(s => s !== roleSn) : [...prev, roleSn]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveRoles = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
await assignRolesApi(userId, selectedRoleSns)
|
||||||
|
await loadUsers()
|
||||||
|
setRoleEditUserId(null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('역할 할당 실패:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string | null) => {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
return new Date(dateStr).toLocaleString('ko-KR', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingCount = users.filter(u => u.status === 'PENDING').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
|
<div className="flex items-center gap-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">총 {users.length}명</p>
|
||||||
|
</div>
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<span className="px-2.5 py-1 text-[10px] font-bold rounded-full bg-[rgba(250,204,21,0.15)] text-yellow-400 border border-[rgba(250,204,21,0.3)] animate-pulse font-korean">
|
||||||
|
승인대기 {pendingCount}명
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="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>
|
||||||
|
<option value="PENDING">승인대기</option>
|
||||||
|
<option value="ACTIVE">활성</option>
|
||||||
|
<option value="LOCKED">잠김</option>
|
||||||
|
<option value="INACTIVE">비활성</option>
|
||||||
|
<option value="REJECTED">거절됨</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="이름, 계정 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-56 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||||
|
/>
|
||||||
|
<button 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">불러오는 중...</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-bg-1">
|
||||||
|
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">이름</th>
|
||||||
|
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">계정</th>
|
||||||
|
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">소속</th>
|
||||||
|
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">역할</th>
|
||||||
|
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">인증</th>
|
||||||
|
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">상태</th>
|
||||||
|
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">최근 로그인</th>
|
||||||
|
<th className="px-6 py-3 text-right text-[11px] font-semibold text-text-3 font-korean">관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((user) => {
|
||||||
|
const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE
|
||||||
|
return (
|
||||||
|
<tr key={user.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
||||||
|
<td className="px-6 py-3 text-[12px] text-text-1 font-semibold font-korean">{user.name}</td>
|
||||||
|
<td className="px-6 py-3 text-[12px] text-text-2 font-mono">{user.account}</td>
|
||||||
|
<td className="px-6 py-3 text-[12px] text-text-2 font-korean">{user.orgAbbr || '-'}</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap gap-1 cursor-pointer"
|
||||||
|
onClick={() => handleOpenRoleEdit(user)}
|
||||||
|
title="클릭하여 역할 변경"
|
||||||
|
>
|
||||||
|
{user.roles.length > 0 ? user.roles.map((roleCode, idx) => {
|
||||||
|
const color = getRoleColor(roleCode, idx)
|
||||||
|
const roleName = allRoles.find(r => r.code === roleCode)?.name || roleCode
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={roleCode}
|
||||||
|
className="px-2 py-0.5 text-[10px] font-semibold rounded-md font-korean"
|
||||||
|
style={{
|
||||||
|
background: `${color}20`,
|
||||||
|
color: color,
|
||||||
|
border: `1px solid ${color}40`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{roleName}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}) : (
|
||||||
|
<span className="text-[10px] text-text-3 font-korean">역할 없음</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-text-3 ml-0.5">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{roleEditUserId === user.id && (
|
||||||
|
<div
|
||||||
|
ref={roleDropdownRef}
|
||||||
|
className="absolute z-20 top-full left-0 mt-1 p-2 bg-bg-1 border border-border rounded-lg shadow-lg min-w-[200px]"
|
||||||
|
>
|
||||||
|
<div className="text-[10px] text-text-3 font-korean font-semibold mb-1.5 px-1">역할 선택</div>
|
||||||
|
{allRoles.map((role, idx) => {
|
||||||
|
const color = getRoleColor(role.code, idx)
|
||||||
|
return (
|
||||||
|
<label key={role.sn} className="flex items-center gap-2 px-2 py-1.5 hover:bg-[rgba(255,255,255,0.04)] rounded cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedRoleSns.includes(role.sn)}
|
||||||
|
onChange={() => toggleRoleSelection(role.sn)}
|
||||||
|
style={{ accentColor: color }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-korean" style={{ color }}>{role.name}</span>
|
||||||
|
<span className="text-[10px] text-text-3 font-mono">{role.code}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="flex justify-end gap-2 mt-2 pt-2 border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => setRoleEditUserId(null)}
|
||||||
|
className="px-3 py-1 text-[10px] text-text-3 border border-border rounded hover:bg-bg-hover font-korean"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveRoles(user.id)}
|
||||||
|
disabled={selectedRoleSns.length === 0}
|
||||||
|
className="px-3 py-1 text-[10px] font-semibold rounded bg-primary-cyan text-bg-0 hover:shadow-[0_0_8px_rgba(6,182,212,0.3)] disabled:opacity-50 font-korean"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
{user.oauthProvider ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded-md font-mono"
|
||||||
|
style={{ background: 'rgba(66,133,244,0.15)', color: '#4285F4', border: '1px solid rgba(66,133,244,0.3)' }}
|
||||||
|
title={user.email || undefined}
|
||||||
|
>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 48 48"><path fill="#4285F4" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#34A853" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59A14.5 14.5 0 019.5 24c0-1.59.28-3.14.76-4.59l-7.98-6.19A23.99 23.99 0 000 24c0 3.77.9 7.35 2.56 10.54l7.97-5.95z"/><path fill="#EA4335" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 5.95C6.51 42.62 14.62 48 24 48z"/></svg>
|
||||||
|
Google
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded-md font-korean"
|
||||||
|
style={{ background: 'rgba(148,163,184,0.15)', color: 'var(--t3)', border: '1px solid rgba(148,163,184,0.2)' }}
|
||||||
|
>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||||
|
ID/PW
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 text-[10px] font-semibold font-korean ${statusInfo.color}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot}`} />
|
||||||
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-[11px] text-text-3 font-mono">{formatDate(user.lastLogin)}</td>
|
||||||
|
<td className="px-6 py-3 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{user.status === 'PENDING' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleApprove(user.id)}
|
||||||
|
className="px-2 py-1 text-[10px] font-semibold text-green-400 border border-green-400 rounded hover:bg-[rgba(74,222,128,0.1)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
승인
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleReject(user.id)}
|
||||||
|
className="px-2 py-1 text-[10px] font-semibold text-red-400 border border-red-400 rounded hover:bg-[rgba(248,113,113,0.1)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
거절
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{user.status === 'LOCKED' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleUnlock(user.id)}
|
||||||
|
className="px-2 py-1 text-[10px] font-semibold text-yellow-400 border border-yellow-400 rounded hover:bg-[rgba(250,204,21,0.1)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
잠금해제
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{user.status === 'ACTIVE' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeactivate(user.id)}
|
||||||
|
className="px-2 py-1 text-[10px] font-semibold text-text-3 border border-border rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
비활성화
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(user.status === 'INACTIVE' || user.status === 'REJECTED') && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleActivate(user.id)}
|
||||||
|
className="px-2 py-1 text-[10px] font-semibold text-green-400 border border-green-400 rounded hover:bg-[rgba(74,222,128,0.1)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
활성화
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UsersPanel
|
||||||
24
frontend/src/tabs/admin/components/adminConstants.ts
Normal file
24
frontend/src/tabs/admin/components/adminConstants.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export const DEFAULT_ROLE_COLORS: Record<string, string> = {
|
||||||
|
ADMIN: 'var(--red)',
|
||||||
|
MANAGER: 'var(--orange)',
|
||||||
|
USER: 'var(--cyan)',
|
||||||
|
VIEWER: 'var(--t3)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CUSTOM_ROLE_COLORS = [
|
||||||
|
'#a78bfa', '#34d399', '#f472b6', '#fbbf24', '#60a5fa', '#2dd4bf',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getRoleColor(code: string, index: number): string {
|
||||||
|
return DEFAULT_ROLE_COLORS[code] || CUSTOM_ROLE_COLORS[index % CUSTOM_ROLE_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const statusLabels: Record<string, { label: string; color: string; dot: string }> = {
|
||||||
|
PENDING: { label: '승인대기', color: 'text-yellow-400', dot: 'bg-yellow-400' },
|
||||||
|
ACTIVE: { label: '활성', color: 'text-green-400', dot: 'bg-green-400' },
|
||||||
|
LOCKED: { label: '잠김', color: 'text-red-400', dot: 'bg-red-400' },
|
||||||
|
INACTIVE: { label: '비활성', color: 'text-text-3', dot: 'bg-text-3' },
|
||||||
|
REJECTED: { label: '거절됨', color: 'text-red-300', dot: 'bg-red-300' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// PERM_RESOURCES 제거됨 — GET /api/roles/perm-tree에서 동적 로드 (PermissionsPanel)
|
||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
343
frontend/src/tabs/aerial/components/CctvView.tsx
Normal file
343
frontend/src/tabs/aerial/components/CctvView.tsx
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface CctvCamera {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
region: '제주' | '남해' | '서해' | '동해'
|
||||||
|
location: string
|
||||||
|
coord: string
|
||||||
|
status: 'live' | 'offline'
|
||||||
|
ptz: boolean
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const cctvCameras: CctvCamera[] = [
|
||||||
|
{ id: 1, name: '서귀포항 동측', region: '제주', location: '제주 서귀포시 서귀동', coord: '33.24°N 126.57°E', status: 'live', ptz: true, source: 'TAGO' },
|
||||||
|
{ id: 2, name: '제주항 입구', region: '제주', location: '제주 제주시 건입동', coord: '33.52°N 126.53°E', status: 'live', ptz: true, source: 'TAGO' },
|
||||||
|
{ id: 3, name: '성산포항', region: '제주', location: '제주 서귀포시 성산읍', coord: '33.46°N 126.93°E', status: 'live', ptz: false, source: 'TAGO' },
|
||||||
|
{ id: 4, name: '모슬포항', region: '제주', location: '제주 서귀포시 대정읍', coord: '33.21°N 126.25°E', status: 'live', ptz: false, source: 'KBS' },
|
||||||
|
{ id: 5, name: '여수 신항', region: '남해', location: '전남 여수시 웅천동', coord: '34.73°N 127.68°E', status: 'live', ptz: true, source: 'TAGO' },
|
||||||
|
{ id: 6, name: '통영항', region: '남해', location: '경남 통영시 항남동', coord: '34.84°N 128.43°E', status: 'live', ptz: true, source: 'TAGO' },
|
||||||
|
{ id: 7, name: '부산 감천항', region: '남해', location: '부산 서구 암남동', coord: '35.08°N 129.01°E', status: 'live', ptz: false, source: 'KBS' },
|
||||||
|
{ id: 8, name: '목포 내항', region: '서해', location: '전남 목포시 항동', coord: '34.79°N 126.38°E', status: 'live', ptz: true, source: 'TAGO' },
|
||||||
|
{ id: 9, name: '군산 외항', region: '서해', location: '전북 군산시 소룡동', coord: '35.97°N 126.72°E', status: 'live', ptz: false, source: 'TAGO' },
|
||||||
|
{ id: 10, name: '인천항 연안', region: '서해', location: '인천 중구 항동', coord: '37.45°N 126.60°E', status: 'offline', ptz: false, source: 'KBS' },
|
||||||
|
{ id: 11, name: '동해항', region: '동해', location: '강원 동해시 송정동', coord: '37.52°N 129.12°E', status: 'live', ptz: true, source: 'TAGO' },
|
||||||
|
{ id: 12, name: '포항 영일만', region: '동해', location: '경북 포항시 남구', coord: '36.02°N 129.38°E', status: 'live', ptz: false, source: 'TAGO' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const cctvFavorites = [
|
||||||
|
{ name: '서귀포항 동측', reason: '유출 사고 인접' },
|
||||||
|
{ name: '여수 신항', reason: '주요 방제 거점' },
|
||||||
|
{ name: '목포 내항', reason: '서해 모니터링' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function CctvView() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [regionFilter, setRegionFilter] = useState('전체')
|
||||||
|
const [selectedCamera, setSelectedCamera] = useState<CctvCamera | null>(null)
|
||||||
|
const [gridMode, setGridMode] = useState(1)
|
||||||
|
const [activeCells, setActiveCells] = useState<CctvCamera[]>([])
|
||||||
|
|
||||||
|
const regions = ['전체', '제주', '남해', '서해', '동해']
|
||||||
|
const regionIcons: Record<string, string> = { '전체': '', '제주': '🌊', '남해': '⚓', '서해': '🐟', '동해': '🌅' }
|
||||||
|
|
||||||
|
const filtered = cctvCameras.filter(c => {
|
||||||
|
if (regionFilter !== '전체' && c.region !== regionFilter) return false
|
||||||
|
if (searchTerm && !c.name.includes(searchTerm) && !c.location.includes(searchTerm)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSelectCamera = (cam: CctvCamera) => {
|
||||||
|
setSelectedCamera(cam)
|
||||||
|
if (gridMode === 1) {
|
||||||
|
setActiveCells([cam])
|
||||||
|
} else {
|
||||||
|
setActiveCells(prev => {
|
||||||
|
if (prev.length < gridMode && !prev.find(c => c.id === cam.id)) return [...prev, cam]
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridCols = gridMode === 1 ? 1 : gridMode === 4 ? 2 : 3
|
||||||
|
const totalCells = gridMode
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
|
||||||
|
{/* 왼쪽: 목록 패널 */}
|
||||||
|
<div className="flex flex-col overflow-hidden bg-bg-1 border-r border-border" style={{ width: 290, minWidth: 290 }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="p-3 pb-2.5 border-b border-border shrink-0 bg-bg-2">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="text-xs font-bold text-text-1 font-korean flex items-center gap-1.5">
|
||||||
|
<span className="w-[7px] h-[7px] rounded-full inline-block animate-pulse" style={{ background: 'var(--red)' }} />
|
||||||
|
실시간 해안 CCTV
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[9px] text-text-3 font-korean">API 상태</span>
|
||||||
|
<span className="w-[7px] h-[7px] rounded-full inline-block" style={{ background: 'var(--green)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="flex items-center gap-2 bg-bg-0 border border-border rounded-md px-2.5 py-1.5 mb-2 focus-within:border-primary-cyan/50 transition-colors">
|
||||||
|
<span className="text-text-3 text-[11px]">🔍</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="지점명 또는 지역 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
|
className="flex-1 bg-transparent border-none text-text-1 text-[11px] font-korean outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* 지역 필터 */}
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{regions.map(r => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
onClick={() => setRegionFilter(r)}
|
||||||
|
className="px-2 py-0.5 rounded text-[9px] font-semibold cursor-pointer font-korean border transition-colors"
|
||||||
|
style={regionFilter === r
|
||||||
|
? { background: 'rgba(6,182,212,.15)', color: 'var(--cyan)', borderColor: 'rgba(6,182,212,.3)' }
|
||||||
|
: { background: 'var(--bg3)', color: 'var(--t2)', borderColor: 'var(--bd)' }
|
||||||
|
}
|
||||||
|
>{regionIcons[r] ? `${regionIcons[r]} ` : ''}{r}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 바 */}
|
||||||
|
<div className="flex items-center justify-between px-3.5 py-1 border-b border-border shrink-0 bg-bg-1">
|
||||||
|
<div className="text-[9px] text-text-3 font-korean">출처: 국립해양조사원 · KBS 재난안전포털</div>
|
||||||
|
<div className="text-[10px] text-text-2 font-korean"><b className="text-text-1">{filtered.length}</b>개</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카메라 목록 */}
|
||||||
|
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||||
|
{filtered.map(cam => (
|
||||||
|
<div
|
||||||
|
key={cam.id}
|
||||||
|
onClick={() => handleSelectCamera(cam)}
|
||||||
|
className="flex items-center gap-2.5 px-3.5 py-2.5 border-b cursor-pointer transition-colors"
|
||||||
|
style={{
|
||||||
|
borderColor: 'rgba(255,255,255,.04)',
|
||||||
|
background: selectedCamera?.id === cam.id ? 'rgba(6,182,212,.08)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<div className="w-8 h-8 rounded-md bg-bg-3 flex items-center justify-center text-sm">📹</div>
|
||||||
|
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full border border-bg-1" style={{ background: cam.status === 'live' ? 'var(--green)' : 'var(--t3)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[11px] font-semibold text-text-1 font-korean truncate">{cam.name}</div>
|
||||||
|
<div className="text-[9px] text-text-3 font-korean truncate">{cam.location}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-0.5 shrink-0">
|
||||||
|
{cam.status === 'live' ? (
|
||||||
|
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: 'rgba(34,197,94,.12)', color: 'var(--green)' }}>LIVE</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: 'rgba(255,255,255,.06)', color: 'var(--t3)' }}>OFF</span>
|
||||||
|
)}
|
||||||
|
{cam.ptz && <span className="text-[8px] text-text-3 font-mono">PTZ</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 가운데: 영상 뷰어 */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden min-w-0" style={{ background: '#04070f' }}>
|
||||||
|
{/* 뷰어 툴바 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-bg-2 shrink-0 gap-2.5">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className="text-xs font-bold text-text-1 font-korean whitespace-nowrap overflow-hidden text-ellipsis">
|
||||||
|
{selectedCamera ? `📹 ${selectedCamera.name}` : '📹 카메라를 선택하세요'}
|
||||||
|
</div>
|
||||||
|
{selectedCamera?.status === 'live' && (
|
||||||
|
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(239,68,68,.14)', border: '1px solid rgba(239,68,68,.35)', color: 'var(--red)' }}>
|
||||||
|
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--red)' }} />LIVE
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{/* PTZ 컨트롤 */}
|
||||||
|
{selectedCamera?.ptz && (
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1 bg-bg-3 border border-border rounded-[5px]">
|
||||||
|
<span className="text-[9px] text-text-3 font-korean mr-1">PTZ</span>
|
||||||
|
{['◀', '▲', '▼', '▶'].map((d, i) => (
|
||||||
|
<button key={i} className="w-5 h-5 flex items-center justify-center bg-bg-0 border border-border rounded text-[9px] text-text-2 cursor-pointer hover:bg-bg-hover transition-colors">{d}</button>
|
||||||
|
))}
|
||||||
|
<div className="w-px h-4 bg-border mx-0.5" />
|
||||||
|
{['+', '−'].map((z, i) => (
|
||||||
|
<button key={i} className="w-5 h-5 flex items-center justify-center bg-bg-0 border border-border rounded text-[9px] text-text-2 cursor-pointer hover:bg-bg-hover transition-colors">{z}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 분할 모드 */}
|
||||||
|
<div className="flex border border-border rounded-[5px] overflow-hidden">
|
||||||
|
{[
|
||||||
|
{ mode: 1, icon: '▣', label: '1화면' },
|
||||||
|
{ mode: 4, icon: '⊞', label: '4분할' },
|
||||||
|
{ mode: 9, icon: '⊟', label: '9분할' },
|
||||||
|
].map(g => (
|
||||||
|
<button
|
||||||
|
key={g.mode}
|
||||||
|
onClick={() => { setGridMode(g.mode); setActiveCells(prev => prev.slice(0, g.mode)) }}
|
||||||
|
title={g.label}
|
||||||
|
className="px-2 py-1 text-[11px] cursor-pointer border-none transition-colors"
|
||||||
|
style={gridMode === g.mode
|
||||||
|
? { background: 'rgba(6,182,212,.15)', color: 'var(--cyan)' }
|
||||||
|
: { background: 'var(--bg3)', color: 'var(--t2)' }
|
||||||
|
}
|
||||||
|
>{g.icon}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button className="px-2.5 py-1 bg-bg-3 border border-border rounded-[5px] text-text-2 text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-hover transition-colors">📷 캡처</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 영상 그리드 */}
|
||||||
|
<div className="flex-1 gap-0.5 p-0.5 overflow-hidden relative" style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
|
||||||
|
gridTemplateRows: `repeat(${gridCols}, 1fr)`,
|
||||||
|
background: '#000',
|
||||||
|
}}>
|
||||||
|
{Array.from({ length: totalCells }).map((_, i) => {
|
||||||
|
const cam = activeCells[i]
|
||||||
|
return (
|
||||||
|
<div key={i} className="relative flex items-center justify-center overflow-hidden" style={{ background: '#0a0e18', border: '1px solid rgba(255,255,255,.06)' }}>
|
||||||
|
{cam ? (
|
||||||
|
<>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="text-4xl opacity-20">📹</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-2 left-2 flex items-center gap-1.5">
|
||||||
|
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(0,0,0,.7)', color: 'var(--t1)' }}>{cam.name}</span>
|
||||||
|
<span className="text-[8px] font-bold px-1 py-0.5 rounded" style={{ background: 'rgba(239,68,68,.3)', color: '#f87171' }}>● REC</span>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded" style={{ background: 'rgba(0,0,0,.7)', color: 'var(--t3)' }}>
|
||||||
|
{cam.coord} · {cam.source}
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-[11px] font-korean text-text-3 opacity-60">
|
||||||
|
CCTV 스트리밍 영역
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] text-text-3 font-korean opacity-40">카메라를 선택하세요</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 정보 바 */}
|
||||||
|
<div className="flex items-center gap-3.5 px-4 py-1.5 border-t border-border bg-bg-2 shrink-0">
|
||||||
|
<div className="text-[10px] text-text-3 font-korean">선택: <b className="text-text-1">{selectedCamera?.name ?? '–'}</b></div>
|
||||||
|
<div className="text-[10px] text-text-3 font-korean">위치: <span className="text-text-2">{selectedCamera?.location ?? '–'}</span></div>
|
||||||
|
<div className="text-[10px] text-text-3 font-korean">좌표: <span className="text-primary-cyan font-mono text-[9px]">{selectedCamera?.coord ?? '–'}</span></div>
|
||||||
|
<div className="ml-auto text-[9px] text-text-3 font-korean">API: 국립해양조사원 TAGO 해양 CCTV</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽: 미니맵 + 정보 */}
|
||||||
|
<div className="flex flex-col overflow-hidden bg-bg-1 border-l border-border" style={{ width: 232, minWidth: 232 }}>
|
||||||
|
{/* 지도 헤더 */}
|
||||||
|
<div className="px-3 py-2 border-b border-border text-[11px] font-bold text-text-1 font-korean bg-bg-2 shrink-0 flex items-center justify-between">
|
||||||
|
<span>🗺 위치 지도</span>
|
||||||
|
<span className="text-[9px] text-text-3 font-normal">클릭하여 선택</span>
|
||||||
|
</div>
|
||||||
|
{/* 미니맵 (placeholder) */}
|
||||||
|
<div className="w-full bg-bg-3 flex items-center justify-center shrink-0 relative" style={{ height: 210 }}>
|
||||||
|
<div className="text-[10px] text-text-3 font-korean opacity-50">지도 영역</div>
|
||||||
|
{/* 간략 지도 표현 */}
|
||||||
|
<div className="absolute inset-2 rounded-md border border-border/30 overflow-hidden" style={{ background: 'linear-gradient(180deg, rgba(6,182,212,.03), rgba(59,130,246,.05))' }}>
|
||||||
|
{cctvCameras.filter(c => c.status === 'live').slice(0, 6).map((c, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="absolute w-2 h-2 rounded-full cursor-pointer"
|
||||||
|
style={{
|
||||||
|
background: selectedCamera?.id === c.id ? 'var(--cyan)' : 'var(--green)',
|
||||||
|
boxShadow: selectedCamera?.id === c.id ? '0 0 6px var(--cyan)' : 'none',
|
||||||
|
top: `${20 + (i * 25) % 70}%`,
|
||||||
|
left: `${15 + (i * 30) % 70}%`,
|
||||||
|
}}
|
||||||
|
title={c.name}
|
||||||
|
onClick={() => handleSelectCamera(c)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카메라 정보 */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-3 py-2.5 border-t border-border" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||||
|
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">📋 카메라 정보</div>
|
||||||
|
{selectedCamera ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{[
|
||||||
|
['카메라명', selectedCamera.name],
|
||||||
|
['지역', selectedCamera.region],
|
||||||
|
['위치', selectedCamera.location],
|
||||||
|
['좌표', selectedCamera.coord],
|
||||||
|
['상태', selectedCamera.status === 'live' ? '● 송출중' : '● 오프라인'],
|
||||||
|
['PTZ', selectedCamera.ptz ? '지원' : '미지원'],
|
||||||
|
['출처', selectedCamera.source],
|
||||||
|
].map(([k, v], i) => (
|
||||||
|
<div key={i} className="flex justify-between px-2 py-1 bg-bg-0 rounded text-[9px]">
|
||||||
|
<span className="text-text-3 font-korean">{k}</span>
|
||||||
|
<span className="font-mono text-text-1">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] text-text-3 font-korean">카메라를 선택하세요</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 방제 즐겨찾기 */}
|
||||||
|
<div className="mt-3 pt-2.5 border-t border-border">
|
||||||
|
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">⭐ 방제 핵심 지점</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{cctvFavorites.map((fav, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 bg-bg-3 rounded-[5px] cursor-pointer hover:bg-bg-hover transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
const found = cctvCameras.find(c => c.name === fav.name)
|
||||||
|
if (found) handleSelectCamera(found)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-[9px]">⭐</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[9px] font-semibold text-text-1 font-korean truncate">{fav.name}</div>
|
||||||
|
<div className="text-[8px] text-text-3 font-korean">{fav.reason}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API 연동 현황 */}
|
||||||
|
<div className="mt-3 pt-2.5 border-t border-border">
|
||||||
|
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">🔌 API 연동 현황</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{[
|
||||||
|
{ name: '해양조사원 TAGO', status: '● 연결', color: 'var(--green)' },
|
||||||
|
{ name: 'KBS 재난안전포털', status: '● 연결', color: 'var(--green)' },
|
||||||
|
].map((api, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between px-2 py-1 bg-bg-3 rounded-[5px]" style={{ border: '1px solid rgba(34,197,94,.2)' }}>
|
||||||
|
<span className="text-[9px] text-text-2 font-korean">{api.name}</span>
|
||||||
|
<span className="text-[9px] font-bold" style={{ color: api.color }}>{api.status}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center justify-between px-2 py-1 bg-bg-3 rounded-[5px]" style={{ border: '1px solid rgba(59,130,246,.2)' }}>
|
||||||
|
<span className="text-[9px] text-text-2 font-korean">갱신 주기</span>
|
||||||
|
<span className="text-[9px] font-bold font-mono" style={{ color: 'var(--blue)' }}>1 fps</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-text-3 font-mono text-right mt-0.5">최종갱신: {new Date().toLocaleTimeString('ko-KR')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
335
frontend/src/tabs/aerial/components/MediaManagement.tsx
Normal file
335
frontend/src/tabs/aerial/components/MediaManagement.tsx
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
|
||||||
|
// ── Types & Mock Data ──
|
||||||
|
|
||||||
|
interface MediaFile {
|
||||||
|
id: number
|
||||||
|
incident: string
|
||||||
|
location: string
|
||||||
|
filename: string
|
||||||
|
equipment: string
|
||||||
|
equipType: 'drone' | 'plane' | 'satellite'
|
||||||
|
mediaType: '사진' | '영상' | '적외선' | 'SAR' | '가시광' | '광학'
|
||||||
|
datetime: string
|
||||||
|
size: string
|
||||||
|
resolution: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaFiles: MediaFile[] = [
|
||||||
|
{ id: 1, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_001.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:20', size: '12.4 MB', resolution: '5472×3648' },
|
||||||
|
{ id: 2, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_002.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:21', size: '11.8 MB', resolution: '5472×3648' },
|
||||||
|
{ id: 3, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_003.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:22', size: '13.1 MB', resolution: '5472×3648' },
|
||||||
|
{ id: 4, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_004.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:23', size: '12.9 MB', resolution: '5472×3648' },
|
||||||
|
{ id: 5, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_005.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:24', size: '11.5 MB', resolution: '5472×3648' },
|
||||||
|
{ id: 6, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_006.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:25', size: '13.3 MB', resolution: '5472×3648' },
|
||||||
|
{ id: 7, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론영상_01.mp4', equipment: 'DJI M300', equipType: 'drone', mediaType: '영상', datetime: '2026-01-18 15:30', size: '842 MB', resolution: '4K 30fps' },
|
||||||
|
{ id: 8, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론영상_02.mp4', equipment: 'Mavic3', equipType: 'drone', mediaType: '영상', datetime: '2026-01-18 16:00', size: '624 MB', resolution: '4K 30fps' },
|
||||||
|
{ id: 9, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공_광역_01.tif', equipment: 'CN-235', equipType: 'plane', mediaType: '적외선', datetime: '2026-01-18 14:00', size: '156 MB', resolution: '8192×6144' },
|
||||||
|
{ id: 10, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공_광역_02.tif', equipment: 'CN-235', equipType: 'plane', mediaType: '가시광', datetime: '2026-01-18 14:10', size: '148 MB', resolution: '8192×6144' },
|
||||||
|
{ id: 11, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공영상_01.mp4', equipment: 'B-512', equipType: 'plane', mediaType: '영상', datetime: '2026-01-18 14:30', size: '1.2 GB', resolution: 'FHD 60fps' },
|
||||||
|
{ id: 12, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: 'Sentinel1_SAR_20260118.tif', equipment: 'Sentinel-1', equipType: 'satellite', mediaType: 'SAR', datetime: '2026-01-18 10:00', size: '420 MB', resolution: '10m/px' },
|
||||||
|
{ id: 13, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: 'KompSat5_여수_20260118.tif', equipment: '다목적5호', equipType: 'satellite', mediaType: 'SAR', datetime: '2026-01-18 11:00', size: '380 MB', resolution: '1m/px' },
|
||||||
|
{ id: 14, incident: '통영 해역 기름오염', location: '34.85°N, 128.43°E', filename: '통영_드론_001.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 09:30', size: '10.2 MB', resolution: '5472×3648' },
|
||||||
|
{ id: 15, incident: '군산항 인근 오염', location: '35.97°N, 126.72°E', filename: '군산_항공촬영_01.tif', equipment: 'B-512', equipType: 'plane', mediaType: '가시광', datetime: '2026-01-18 13:00', size: '132 MB', resolution: '8192×6144' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const equipIcon = (t: string) => t === 'drone' ? '🛸' : t === 'plane' ? '✈' : '🛰'
|
||||||
|
|
||||||
|
const equipTagCls = (t: string) =>
|
||||||
|
t === 'drone'
|
||||||
|
? 'bg-[rgba(59,130,246,0.12)] text-primary-blue'
|
||||||
|
: t === 'plane'
|
||||||
|
? 'bg-[rgba(34,197,94,0.12)] text-status-green'
|
||||||
|
: 'bg-[rgba(168,85,247,0.12)] text-primary-purple'
|
||||||
|
|
||||||
|
const mediaTagCls = (t: string) =>
|
||||||
|
t === '영상'
|
||||||
|
? 'bg-[rgba(239,68,68,0.12)] text-status-red'
|
||||||
|
: 'bg-[rgba(234,179,8,0.12)] text-status-yellow'
|
||||||
|
|
||||||
|
const FilterBtn = ({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`px-2.5 py-1 text-[10px] font-semibold rounded font-korean transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30'
|
||||||
|
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function MediaManagement() {
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||||
|
const [equipFilter, setEquipFilter] = useState<string>('all')
|
||||||
|
const [typeFilter, setTypeFilter] = useState<Set<string>>(new Set())
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [sortBy, setSortBy] = useState('latest')
|
||||||
|
const [showUpload, setShowUpload] = useState(false)
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||||
|
setShowUpload(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showUpload) document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [showUpload])
|
||||||
|
|
||||||
|
const filtered = mediaFiles.filter(f => {
|
||||||
|
if (equipFilter !== 'all' && f.equipType !== equipFilter) return false
|
||||||
|
if (typeFilter.size > 0) {
|
||||||
|
const isPhoto = !['영상'].includes(f.mediaType)
|
||||||
|
const isVideo = f.mediaType === '영상'
|
||||||
|
if (typeFilter.has('photo') && !isPhoto) return false
|
||||||
|
if (typeFilter.has('video') && !isVideo) return false
|
||||||
|
}
|
||||||
|
if (searchTerm && !f.filename.toLowerCase().includes(searchTerm.toLowerCase())) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const sorted = [...filtered].sort((a, b) => {
|
||||||
|
if (sortBy === 'name') return a.filename.localeCompare(b.filename)
|
||||||
|
if (sortBy === 'size') return parseFloat(b.size) - parseFloat(a.size)
|
||||||
|
return b.datetime.localeCompare(a.datetime)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleId = (id: number) => {
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) { next.delete(id) } else { next.add(id) }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
if (selectedIds.size === sorted.length) {
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
} else {
|
||||||
|
setSelectedIds(new Set(sorted.map(f => f.id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleTypeFilter = (t: string) => {
|
||||||
|
setTypeFilter(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(t)) { next.delete(t) } else { next.add(t) }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const droneCount = mediaFiles.filter(f => f.equipType === 'drone').length
|
||||||
|
const planeCount = mediaFiles.filter(f => f.equipType === 'plane').length
|
||||||
|
const satCount = mediaFiles.filter(f => f.equipType === 'satellite').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex gap-1.5 items-center">
|
||||||
|
<span className="text-[11px] text-text-3 font-korean">촬영 장비:</span>
|
||||||
|
<FilterBtn label="전체" active={equipFilter === 'all'} onClick={() => setEquipFilter('all')} />
|
||||||
|
<FilterBtn label="🛸 드론" active={equipFilter === 'drone'} onClick={() => setEquipFilter('drone')} />
|
||||||
|
<FilterBtn label="✈ 유인항공기" active={equipFilter === 'plane'} onClick={() => setEquipFilter('plane')} />
|
||||||
|
<FilterBtn label="🛰 위성" active={equipFilter === 'satellite'} onClick={() => setEquipFilter('satellite')} />
|
||||||
|
<span className="w-px h-4 bg-border mx-1" />
|
||||||
|
<span className="text-[11px] text-text-3 font-korean">유형:</span>
|
||||||
|
<FilterBtn label="📷 사진" active={typeFilter.has('photo')} onClick={() => toggleTypeFilter('photo')} />
|
||||||
|
<FilterBtn label="🎬 영상" active={typeFilter.has('video')} onClick={() => toggleTypeFilter('video')} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="파일명 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
|
className="px-3 py-1.5 bg-bg-0 border border-border rounded-sm text-text-1 font-korean text-[11px] outline-none w-40 focus:border-primary-cyan"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={e => setSortBy(e.target.value)}
|
||||||
|
className="prd-i py-1.5 w-auto"
|
||||||
|
>
|
||||||
|
<option value="latest">최신순</option>
|
||||||
|
<option value="name">이름순</option>
|
||||||
|
<option value="size">크기순</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<div className="flex gap-2.5 mb-4">
|
||||||
|
{[
|
||||||
|
{ icon: '📸', value: String(mediaFiles.length), label: '총 파일', color: 'text-primary-cyan' },
|
||||||
|
{ icon: '🛸', value: String(droneCount), label: '드론', color: 'text-text-1' },
|
||||||
|
{ icon: '✈', value: String(planeCount), label: '유인항공기', color: 'text-text-1' },
|
||||||
|
{ icon: '🛰', value: String(satCount), label: '위성', color: 'text-text-1' },
|
||||||
|
{ icon: '💾', value: '3.8 GB', label: '총 용량', color: 'text-text-1' },
|
||||||
|
].map((s, i) => (
|
||||||
|
<div key={i} className="flex-1 flex items-center gap-2.5 px-4 py-3 bg-bg-3 border border-border rounded-sm">
|
||||||
|
<span className="text-xl">{s.icon}</span>
|
||||||
|
<div>
|
||||||
|
<div className={`text-base font-bold font-mono ${s.color}`}>{s.value}</div>
|
||||||
|
<div className="text-[10px] text-text-3 font-korean">{s.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Table */}
|
||||||
|
<div className="flex-1 bg-bg-3 border border-border rounded-md overflow-hidden flex flex-col">
|
||||||
|
<div className="overflow-auto flex-1">
|
||||||
|
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: 36 }} />
|
||||||
|
<col style={{ width: 36 }} />
|
||||||
|
<col style={{ width: 120 }} />
|
||||||
|
<col style={{ width: 130 }} />
|
||||||
|
<col />
|
||||||
|
<col style={{ width: 95 }} />
|
||||||
|
<col style={{ width: 85 }} />
|
||||||
|
<col style={{ width: 145 }} />
|
||||||
|
<col style={{ width: 85 }} />
|
||||||
|
<col style={{ width: 95 }} />
|
||||||
|
<col style={{ width: 50 }} />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-bg-2">
|
||||||
|
<th className="px-2 py-2.5 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.size === sorted.length && sorted.length > 0}
|
||||||
|
onChange={toggleAll}
|
||||||
|
className="accent-primary-blue"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="px-1 py-2.5" />
|
||||||
|
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">사고명</th>
|
||||||
|
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">위치</th>
|
||||||
|
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean">파일명</th>
|
||||||
|
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean">장비</th>
|
||||||
|
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean">유형</th>
|
||||||
|
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">촬영일시</th>
|
||||||
|
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">용량</th>
|
||||||
|
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">해상도</th>
|
||||||
|
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 text-center">📥</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sorted.map(f => (
|
||||||
|
<tr
|
||||||
|
key={f.id}
|
||||||
|
onClick={() => toggleId(f.id)}
|
||||||
|
className={`border-b border-border/50 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${
|
||||||
|
selectedIds.has(f.id) ? 'bg-[rgba(6,182,212,0.06)]' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="px-2 py-2 text-center" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.has(f.id)}
|
||||||
|
onChange={() => toggleId(f.id)}
|
||||||
|
className="accent-primary-blue"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-2 text-base">{equipIcon(f.equipType)}</td>
|
||||||
|
<td className="px-2 py-2 text-[10px] font-semibold text-text-1 font-korean truncate">{f.incident}</td>
|
||||||
|
<td className="px-2 py-2 text-[10px] text-primary-cyan font-mono truncate">{f.location}</td>
|
||||||
|
<td className="px-2 py-2 text-[11px] font-semibold text-text-1 font-korean truncate">{f.filename}</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${equipTagCls(f.equipType)}`}>
|
||||||
|
{f.equipment}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${mediaTagCls(f.mediaType)}`}>
|
||||||
|
{f.mediaType === '영상' ? '🎬' : '📷'} {f.mediaType}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-[11px] font-mono">{f.datetime}</td>
|
||||||
|
<td className="px-2 py-2 text-[11px] font-mono">{f.size}</td>
|
||||||
|
<td className="px-2 py-2 text-[11px] font-mono">{f.resolution}</td>
|
||||||
|
<td className="px-2 py-2 text-center" onClick={e => e.stopPropagation()}>
|
||||||
|
<button className="px-2 py-1 text-[10px] rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/20 hover:bg-[rgba(6,182,212,0.2)] transition-colors">
|
||||||
|
📥
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Actions */}
|
||||||
|
<div className="flex justify-between items-center mt-4 pt-3.5 border-t border-border">
|
||||||
|
<div className="text-[11px] text-text-3 font-korean">
|
||||||
|
선택된 파일: <span className="text-primary-cyan font-semibold">{selectedIds.size}</span>건
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={toggleAll} className="px-3 py-1.5 text-[11px] font-semibold rounded bg-bg-3 border border-border text-text-2 hover:bg-bg-hover transition-colors font-korean">
|
||||||
|
☑ 전체선택
|
||||||
|
</button>
|
||||||
|
<button className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/30 hover:bg-[rgba(6,182,212,0.2)] transition-colors font-korean">
|
||||||
|
📥 선택 다운로드
|
||||||
|
</button>
|
||||||
|
<button className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(168,85,247,0.1)] text-primary-purple border border-primary-purple/30 hover:bg-[rgba(168,85,247,0.2)] transition-colors font-korean">
|
||||||
|
🧩 유출유면적분석으로 →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Modal */}
|
||||||
|
{showUpload && (
|
||||||
|
<div className="fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center">
|
||||||
|
<div ref={modalRef} className="bg-bg-1 border border-border rounded-md w-[480px] max-h-[80vh] overflow-y-auto p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<span className="text-base font-bold font-korean">📤 영상·사진 업로드</span>
|
||||||
|
<button onClick={() => setShowUpload(false)} className="text-text-3 text-lg hover:text-text-1">✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="border-2 border-dashed border-border-light rounded-md py-8 px-4 text-center mb-4 cursor-pointer hover:border-primary-cyan/40 transition-colors">
|
||||||
|
<div className="text-3xl mb-2 opacity-50">📁</div>
|
||||||
|
<div className="text-[13px] font-semibold mb-1 font-korean">파일을 드래그하거나 클릭하여 업로드</div>
|
||||||
|
<div className="text-[11px] text-text-3 font-korean">JPG, TIFF, GeoTIFF, MP4, MOV 지원 · 최대 2GB</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">촬영 장비</label>
|
||||||
|
<select className="prd-i w-full">
|
||||||
|
<option>드론 (DJI M300 RTK)</option>
|
||||||
|
<option>드론 (DJI Mavic 3E)</option>
|
||||||
|
<option>유인항공기 (CN-235)</option>
|
||||||
|
<option>유인항공기 (헬기 B-512)</option>
|
||||||
|
<option>위성 (Sentinel-1)</option>
|
||||||
|
<option>위성 (다목적위성5호)</option>
|
||||||
|
<option>기타</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">연관 사고</label>
|
||||||
|
<select className="prd-i w-full">
|
||||||
|
<option>여수항 유류유출 (2026-01-18)</option>
|
||||||
|
<option>통영 해역 기름오염 (2026-01-18)</option>
|
||||||
|
<option>군산항 인근 오염 (2026-01-18)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">메모</label>
|
||||||
|
<textarea
|
||||||
|
className="prd-i w-full h-[60px] resize-y"
|
||||||
|
placeholder="촬영 조건, 비고 등..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="w-full py-3 rounded-sm text-sm font-bold font-korean text-white border-none cursor-pointer" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>
|
||||||
|
📤 업로드 실행
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
212
frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx
Normal file
212
frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
// ── Types & Mock Data ──
|
||||||
|
|
||||||
|
interface MosaicImage {
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
status: 'done' | 'processing' | 'waiting'
|
||||||
|
hasOil: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const mosaicImages: MosaicImage[] = [
|
||||||
|
{ id: 'T1', filename: '드론_001.jpg', status: 'done', hasOil: true },
|
||||||
|
{ id: 'T2', filename: '드론_002.jpg', status: 'done', hasOil: true },
|
||||||
|
{ id: 'T3', filename: '드론_003.jpg', status: 'done', hasOil: true },
|
||||||
|
{ id: 'T4', filename: '드론_004.jpg', status: 'done', hasOil: true },
|
||||||
|
{ id: 'T5', filename: '드론_005.jpg', status: 'processing', hasOil: false },
|
||||||
|
{ id: 'T6', filename: '드론_006.jpg', status: 'waiting', hasOil: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function OilAreaAnalysis() {
|
||||||
|
const [activeStep, setActiveStep] = useState(1)
|
||||||
|
const [analyzing, setAnalyzing] = useState(false)
|
||||||
|
const [analyzed, setAnalyzed] = useState(false)
|
||||||
|
|
||||||
|
const handleAnalyze = () => {
|
||||||
|
setAnalyzing(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
setAnalyzing(false)
|
||||||
|
setAnalyzed(true)
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepCls = (idx: number) => {
|
||||||
|
if (idx < activeStep) return 'border-status-green text-status-green bg-[rgba(34,197,94,0.05)]'
|
||||||
|
if (idx === activeStep) return 'border-primary-cyan text-primary-cyan bg-[rgba(6,182,212,0.05)]'
|
||||||
|
return 'border-border text-text-3 bg-bg-3'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-5 h-full overflow-hidden">
|
||||||
|
{/* Left Panel */}
|
||||||
|
<div className="w-[340px] min-w-[340px] flex flex-col overflow-y-auto scrollbar-thin">
|
||||||
|
<div className="text-sm font-bold mb-1 font-korean">🧩 유출유면적분석</div>
|
||||||
|
<div className="text-[11px] text-text-3 mb-4 font-korean">단면 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.</div>
|
||||||
|
|
||||||
|
{/* Step Indicator */}
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
{['① 사진 선택', '② 정합·합성', '③ 면적 산정'].map((label, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setActiveStep(i)}
|
||||||
|
className={`flex-1 py-2 rounded-sm border text-center text-[10px] font-semibold font-korean cursor-pointer transition-colors ${stepCls(i)}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Images */}
|
||||||
|
<div className="text-[11px] font-bold mb-2 font-korean">선택된 사진 (6장)</div>
|
||||||
|
<div className="flex flex-col gap-1 mb-3.5">
|
||||||
|
{['여수항_드론_001.jpg', '여수항_드론_002.jpg', '여수항_드론_003.jpg', '여수항_드론_004.jpg', '여수항_드론_005.jpg', '여수항_드론_006.jpg'].map((name, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 px-2 py-1.5 bg-bg-3 border border-border rounded-sm text-[11px] font-korean">
|
||||||
|
<span>🛸</span>
|
||||||
|
<span className="flex-1 truncate">{name}</span>
|
||||||
|
<span className={`text-[9px] font-semibold ${
|
||||||
|
i < 4 ? 'text-status-green' : i === 4 ? 'text-status-orange' : 'text-text-3'
|
||||||
|
}`}>
|
||||||
|
{i < 4 ? '✓ 정합' : i === 4 ? '⏳ 정합중' : '대기'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Analysis Parameters */}
|
||||||
|
<div className="text-[11px] font-bold mb-2 font-korean">분석 파라미터</div>
|
||||||
|
<div className="flex flex-col gap-1.5 mb-3.5">
|
||||||
|
{[
|
||||||
|
['촬영 고도', '120 m'],
|
||||||
|
['GSD (지상해상도)', '3.2 cm/px'],
|
||||||
|
['오버랩 비율', '80% / 70%'],
|
||||||
|
['좌표계', 'EPSG:5186'],
|
||||||
|
['유종 판별 기준', 'NDVI + NIR'],
|
||||||
|
['유막 두께 추정', 'Bonn Agreement'],
|
||||||
|
].map(([label, value], i) => (
|
||||||
|
<div key={i} className="flex justify-between items-center text-[11px]">
|
||||||
|
<span className="text-text-3 font-korean">{label}</span>
|
||||||
|
<span className="font-mono font-semibold">{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<button
|
||||||
|
onClick={handleAnalyze}
|
||||||
|
disabled={analyzing}
|
||||||
|
className={`w-full py-3 rounded-sm text-[13px] font-bold font-korean cursor-pointer border-none mb-2 transition-colors ${
|
||||||
|
analyzed
|
||||||
|
? 'bg-[rgba(34,197,94,0.15)] text-status-green border border-status-green'
|
||||||
|
: 'text-white'
|
||||||
|
}`}
|
||||||
|
style={!analyzed ? { background: 'linear-gradient(135deg, var(--cyan), var(--blue))' } : undefined}
|
||||||
|
>
|
||||||
|
{analyzing ? '⏳ 분석중...' : analyzed ? '✅ 분석 완료!' : '🧩 면적분석 실행'}
|
||||||
|
</button>
|
||||||
|
<button className="w-full py-2.5 border border-border bg-bg-3 text-text-2 rounded-sm text-xs font-semibold font-korean cursor-pointer hover:bg-bg-hover transition-colors">
|
||||||
|
📥 결과 다운로드 (GeoTIFF)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-xs font-bold font-korean">🗺 합성 영상 및 유막 탐지 결과</span>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[rgba(239,68,68,0.1)] text-status-red font-semibold font-korean">■ 유막 탐지</span>
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[rgba(6,182,212,0.1)] text-primary-cyan font-semibold font-korean">□ 원본 타일</span>
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[rgba(34,197,94,0.1)] text-status-green font-semibold font-korean">정합률 96.2%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image Grid 3×2 */}
|
||||||
|
<div className="grid grid-cols-3 gap-1.5 mb-3">
|
||||||
|
{mosaicImages.map(img => (
|
||||||
|
<div key={img.id} className="bg-bg-3 border border-border rounded-sm overflow-hidden cursor-pointer hover:border-border-light transition-colors">
|
||||||
|
<div
|
||||||
|
className="h-[100px] relative flex items-center justify-center overflow-hidden"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #0c1624, #1a1a2e)' }}
|
||||||
|
>
|
||||||
|
{img.hasOil && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(239,68,68,0.15)',
|
||||||
|
border: '1px solid rgba(239,68,68,0.35)',
|
||||||
|
clipPath: 'polygon(20% 30%,60% 15%,85% 40%,70% 80%,30% 75%,10% 50%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="text-lg font-bold text-white/[0.08] font-mono">{img.id}</div>
|
||||||
|
<div className={`absolute top-1.5 right-1.5 px-1.5 py-0.5 rounded-md text-[9px] font-bold font-korean ${
|
||||||
|
img.status === 'done' && img.hasOil ? 'bg-[rgba(239,68,68,0.2)] text-status-red' :
|
||||||
|
img.status === 'processing' ? 'bg-[rgba(249,115,22,0.2)] text-status-orange' :
|
||||||
|
'bg-[rgba(100,116,139,0.2)] text-text-3'
|
||||||
|
}`}>
|
||||||
|
{img.status === 'done' && img.hasOil ? '유막' : img.status === 'processing' ? '정합중' : '대기'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1.5 flex justify-between items-center text-[10px] font-korean text-text-2">
|
||||||
|
<span>{img.filename}</span>
|
||||||
|
<span className={
|
||||||
|
img.status === 'done' ? 'text-status-green' :
|
||||||
|
img.status === 'processing' ? 'text-status-orange' :
|
||||||
|
'text-text-3'
|
||||||
|
}>
|
||||||
|
{img.status === 'done' ? '✓' : img.status === 'processing' ? '⏳' : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Merged Result Preview */}
|
||||||
|
<div className="relative h-[140px] bg-bg-0 border border-border rounded-sm overflow-hidden mb-3">
|
||||||
|
<div className="absolute inset-0" style={{ background: 'radial-gradient(ellipse at 40% 50%, rgba(10,25,40,0.7), rgba(8,14,26,0.95))' }}>
|
||||||
|
<div className="absolute border border-dashed rounded flex items-center justify-center text-[10px] font-korean" style={{ top: '15%', left: '10%', width: '65%', height: '70%', borderColor: 'rgba(6,182,212,0.3)', color: 'rgba(6,182,212,0.5)' }}>
|
||||||
|
합성 영역 (3×2 그리드)
|
||||||
|
</div>
|
||||||
|
<div className="absolute" style={{ top: '22%', left: '18%', width: '35%', height: '40%', background: 'rgba(239,68,68,0.12)', border: '1.5px solid rgba(239,68,68,0.4)', borderRadius: '30% 50% 40% 60%' }} />
|
||||||
|
<div className="absolute" style={{ top: '40%', left: '38%', width: '20%', height: '30%', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: '50% 30% 60% 40%' }} />
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-1.5 left-2.5 text-[9px] text-text-3 font-mono">34.7312°N, 127.6845°E</div>
|
||||||
|
<div className="absolute bottom-1.5 right-2.5 text-[9px] text-text-3 font-mono">축척 ≈ 1:2,500</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Analysis Results */}
|
||||||
|
<div className="p-4 bg-bg-3 border border-border rounded-md">
|
||||||
|
<div className="text-xs font-bold mb-2.5 font-korean">📊 유출유 분석 결과</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{[
|
||||||
|
{ value: '0.42 km²', label: '유막 면적', color: 'text-status-red' },
|
||||||
|
{ value: '12.6 kL', label: '추정 유출량', color: 'text-status-orange' },
|
||||||
|
{ value: '1.84 km²', label: '합성 영역 면적', color: 'text-primary-cyan' },
|
||||||
|
].map((r, i) => (
|
||||||
|
<div key={i} className="text-center py-2.5 px-2 bg-bg-0 border border-border rounded-sm">
|
||||||
|
<div className={`text-lg font-bold font-mono ${r.color}`}>{r.value}</div>
|
||||||
|
<div className="text-[9px] text-text-3 mt-0.5 font-korean">{r.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5 mt-2.5 text-[11px]">
|
||||||
|
{[
|
||||||
|
['두꺼운 유막 (>1mm)', '0.08 km²', 'text-status-red'],
|
||||||
|
['얇은 유막 (<1mm)', '0.34 km²', 'text-status-orange'],
|
||||||
|
['무지개 빛깔', '0.12 km²', 'text-status-yellow'],
|
||||||
|
['Bonn 코드', 'Code 3~4', 'text-text-1'],
|
||||||
|
].map(([label, value, color], i) => (
|
||||||
|
<div key={i} className="flex justify-between px-2 py-1 bg-bg-0 rounded">
|
||||||
|
<span className="text-text-3 font-korean">{label}</span>
|
||||||
|
<span className={`font-semibold font-mono ${color}`}>{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
252
frontend/src/tabs/aerial/components/RealtimeDrone.tsx
Normal file
252
frontend/src/tabs/aerial/components/RealtimeDrone.tsx
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface DroneInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: 'active' | 'returning' | 'standby' | 'charging'
|
||||||
|
battery: number
|
||||||
|
altitude: number
|
||||||
|
speed: number
|
||||||
|
sensor: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const drones: DroneInfo[] = [
|
||||||
|
{ id: 'D-01', name: 'DJI M300 #1', status: 'active', battery: 78, altitude: 150, speed: 12, sensor: '광학 4K', color: 'var(--blue)' },
|
||||||
|
{ id: 'D-02', name: 'DJI M300 #2', status: 'active', battery: 65, altitude: 200, speed: 8, sensor: 'IR 열화상', color: 'var(--red)' },
|
||||||
|
{ id: 'D-03', name: 'Mavic 3E', status: 'active', battery: 82, altitude: 120, speed: 15, sensor: '광학 4K', color: 'var(--purple)' },
|
||||||
|
{ id: 'D-04', name: 'DJI M30T', status: 'active', battery: 45, altitude: 180, speed: 10, sensor: '다중센서', color: 'var(--green)' },
|
||||||
|
{ id: 'D-05', name: 'DJI M300 #3', status: 'returning', battery: 12, altitude: 80, speed: 18, sensor: '광학 4K', color: 'var(--orange)' },
|
||||||
|
{ id: 'D-06', name: 'Mavic 3E #2', status: 'charging', battery: 35, altitude: 0, speed: 0, sensor: '광학 4K', color: 'var(--t3)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface AlertItem {
|
||||||
|
time: string
|
||||||
|
type: 'warning' | 'info' | 'danger'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const alerts: AlertItem[] = [
|
||||||
|
{ time: '15:42', type: 'danger', message: 'D-05 배터리 부족 — 자동 복귀' },
|
||||||
|
{ time: '15:38', type: 'warning', message: '오염원 신규 탐지 (34.82°N)' },
|
||||||
|
{ time: '15:35', type: 'info', message: 'D-01~D-03 다시점 융합 완료' },
|
||||||
|
{ time: '15:30', type: 'warning', message: 'AIS OFF 선박 2척 추가 탐지' },
|
||||||
|
{ time: '15:25', type: 'info', message: 'D-04 센서 데이터 수집 시작' },
|
||||||
|
{ time: '15:20', type: 'danger', message: '유류오염 확산 속도 증가 감지' },
|
||||||
|
{ time: '15:15', type: 'info', message: '3D 재구성 시작 (불명선박-B)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function RealtimeDrone() {
|
||||||
|
const [reconProgress, setReconProgress] = useState(0)
|
||||||
|
const [reconDone, setReconDone] = useState(false)
|
||||||
|
const [selectedDrone, setSelectedDrone] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reconDone) return
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setReconProgress(prev => {
|
||||||
|
if (prev >= 100) {
|
||||||
|
clearInterval(timer)
|
||||||
|
setReconDone(true)
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return prev + 2
|
||||||
|
})
|
||||||
|
}, 300)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [reconDone])
|
||||||
|
|
||||||
|
const statusLabel = (s: string) => {
|
||||||
|
if (s === 'active') return { text: '비행중', cls: 'text-status-green' }
|
||||||
|
if (s === 'returning') return { text: '복귀중', cls: 'text-status-orange' }
|
||||||
|
if (s === 'charging') return { text: '충전중', cls: 'text-text-3' }
|
||||||
|
return { text: '대기', cls: 'text-text-3' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertColor = (t: string) =>
|
||||||
|
t === 'danger' ? 'border-l-status-red bg-[rgba(239,68,68,0.05)]'
|
||||||
|
: t === 'warning' ? 'border-l-status-orange bg-[rgba(249,115,22,0.05)]'
|
||||||
|
: 'border-l-primary-blue bg-[rgba(59,130,246,0.05)]'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
|
||||||
|
{/* Map Area */}
|
||||||
|
<div className="flex-1 relative bg-bg-0 overflow-hidden">
|
||||||
|
{/* Simulated map background */}
|
||||||
|
<div className="absolute inset-0" style={{ background: 'radial-gradient(ellipse at 50% 50%, #0c1a2e, #060c18)' }}>
|
||||||
|
{/* Grid lines */}
|
||||||
|
<div className="absolute inset-0 opacity-[0.06]" style={{ backgroundImage: 'linear-gradient(rgba(6,182,212,0.3) 1px, transparent 1px), linear-gradient(90deg, rgba(6,182,212,0.3) 1px, transparent 1px)', backgroundSize: '60px 60px' }} />
|
||||||
|
{/* Coastline hint */}
|
||||||
|
<div className="absolute" style={{ top: '20%', left: '5%', width: '40%', height: '60%', border: '1px solid rgba(34,197,94,0.15)', borderRadius: '40% 60% 50% 30%' }} />
|
||||||
|
{/* Drone position markers */}
|
||||||
|
{drones.filter(d => d.status !== 'charging').map((d, i) => (
|
||||||
|
<div
|
||||||
|
key={d.id}
|
||||||
|
className="absolute cursor-pointer"
|
||||||
|
style={{ top: `${25 + i * 12}%`, left: `${30 + i * 10}%` }}
|
||||||
|
onClick={() => setSelectedDrone(d.id)}
|
||||||
|
>
|
||||||
|
<div className="w-3 h-3 rounded-full animate-pulse-dot" style={{ background: d.color, boxShadow: `0 0 8px ${d.color}` }} />
|
||||||
|
<div className="absolute -top-4 left-4 text-[8px] font-bold font-mono whitespace-nowrap" style={{ color: d.color }}>{d.id}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Oil spill areas */}
|
||||||
|
<div className="absolute" style={{ top: '35%', left: '45%', width: '120px', height: '80px', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: '40% 60% 50% 40%' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overlay Stats */}
|
||||||
|
<div className="absolute top-2.5 left-2.5 flex gap-1.5 z-[2]">
|
||||||
|
{[
|
||||||
|
{ label: '탐지 객체', value: '847', unit: '건', color: 'text-primary-blue' },
|
||||||
|
{ label: '식별 선박', value: '312', unit: '척', color: 'text-primary-cyan' },
|
||||||
|
{ label: 'AIS OFF', value: '14', unit: '척', color: 'text-status-red' },
|
||||||
|
{ label: '오염 탐지', value: '3', unit: '건', color: 'text-status-orange' },
|
||||||
|
].map((s, i) => (
|
||||||
|
<div key={i} className="bg-[rgba(15,21,36,0.9)] backdrop-blur-sm rounded-sm px-2.5 py-1.5 border border-border">
|
||||||
|
<div className="text-[7px] text-text-3">{s.label}</div>
|
||||||
|
<div>
|
||||||
|
<span className={`font-mono font-bold text-base ${s.color}`}>{s.value}</span>
|
||||||
|
<span className="text-[7px] text-text-3 ml-0.5">{s.unit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3D Reconstruction Progress */}
|
||||||
|
<div className="absolute bottom-2.5 right-2.5 bg-[rgba(15,21,36,0.9)] rounded-sm px-3 py-2 border z-[3] min-w-[175px] cursor-pointer transition-colors hover:border-primary-cyan/40" style={{ borderColor: 'rgba(6,182,212,0.18)' }}>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-[9px] font-bold text-primary-cyan">🧊 3D 재구성</span>
|
||||||
|
<span className="font-mono font-bold text-[13px] text-primary-cyan">{reconProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-[3px] bg-white/[0.06] rounded-sm mb-1">
|
||||||
|
<div className="h-full rounded-sm transition-all duration-500" style={{ width: `${reconProgress}%`, background: 'linear-gradient(90deg, var(--cyan), var(--blue))' }} />
|
||||||
|
</div>
|
||||||
|
{!reconDone ? (
|
||||||
|
<div className="text-[7px] text-text-3">D-01~D-03 다각도 영상 융합중...</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[8px] font-bold text-status-green mt-0.5 animate-pulse-dot">✅ 완료 — 클릭하여 정밀분석</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live Feed Panel */}
|
||||||
|
{selectedDrone && (() => {
|
||||||
|
const drone = drones.find(d => d.id === selectedDrone)
|
||||||
|
if (!drone) return null
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-[rgba(15,21,36,0.95)] z-[5] border-t" style={{ borderColor: 'rgba(59,130,246,0.2)', height: 190 }}>
|
||||||
|
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border">
|
||||||
|
<div className="text-[10px] font-bold flex items-center gap-1.5" style={{ color: drone.color }}>
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full animate-pulse-dot" style={{ background: drone.color }} />
|
||||||
|
{drone.id} 실시간 영상
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setSelectedDrone(null)} className="w-5 h-5 rounded bg-white/5 border border-border text-text-3 text-[11px] flex items-center justify-center cursor-pointer hover:text-text-1">✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid h-[calc(100%-30px)]" style={{ gridTemplateColumns: '1fr 180px' }}>
|
||||||
|
<div className="relative overflow-hidden" style={{ background: 'radial-gradient(ellipse at center, #0c1a2e, #060c18)' }}>
|
||||||
|
{/* Simulated video feed */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="text-text-3/20 text-2xl font-mono">LIVE FEED</div>
|
||||||
|
</div>
|
||||||
|
{/* HUD overlay */}
|
||||||
|
<div className="absolute top-1.5 left-2 z-[2]">
|
||||||
|
<span className="text-[11px] font-bold" style={{ color: drone.color }}>{drone.id}</span>
|
||||||
|
<span className="text-[7px] px-1 py-px rounded bg-white/[0.08] ml-1">{drone.sensor}</span>
|
||||||
|
<div className="text-[7px] text-text-3 font-mono mt-0.5">34.82°N, 128.95°E</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-1.5 right-2 z-[2] flex items-center gap-1 text-[8px] font-bold text-status-red">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-status-red" />REC
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-1 left-2 z-[2] text-[7px] text-text-3">
|
||||||
|
ALT {drone.altitude}m · SPD {drone.speed}m/s · HDG 045°
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 overflow-auto text-[9px] border-l border-border">
|
||||||
|
<div className="font-bold text-text-2 mb-1.5 font-korean">비행 정보</div>
|
||||||
|
{[
|
||||||
|
['드론 ID', drone.id],
|
||||||
|
['기체', drone.name],
|
||||||
|
['배터리', `${drone.battery}%`],
|
||||||
|
['고도', `${drone.altitude}m`],
|
||||||
|
['속도', `${drone.speed}m/s`],
|
||||||
|
['센서', drone.sensor],
|
||||||
|
['상태', statusLabel(drone.status).text],
|
||||||
|
].map(([k, v], i) => (
|
||||||
|
<div key={i} className="flex justify-between py-0.5">
|
||||||
|
<span className="text-text-3 font-korean">{k}</span>
|
||||||
|
<span className="font-mono font-semibold text-text-1">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Sidebar */}
|
||||||
|
<div className="w-[260px] bg-[rgba(15,21,36,0.88)] border-l border-border flex flex-col overflow-auto">
|
||||||
|
{/* Drone Swarm Status */}
|
||||||
|
<div className="p-2.5 px-3 border-b border-border">
|
||||||
|
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">군집 드론 현황 · 4/6 운용</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{drones.map(d => {
|
||||||
|
const st = statusLabel(d.status)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={d.id}
|
||||||
|
onClick={() => d.status !== 'charging' && setSelectedDrone(d.id)}
|
||||||
|
className={`flex items-center gap-2 px-2 py-1.5 rounded-sm cursor-pointer transition-colors ${
|
||||||
|
selectedDrone === d.id ? 'bg-[rgba(6,182,212,0.08)] border border-primary-cyan/20' : 'hover:bg-white/[0.02] border border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-2 h-2 rounded-full" style={{ background: d.color }} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[9px] font-bold" style={{ color: d.color }}>{d.id}</div>
|
||||||
|
<div className="text-[7px] text-text-3 truncate">{d.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className={`text-[8px] font-semibold ${st.cls}`}>{st.text}</div>
|
||||||
|
<div className="text-[7px] font-mono text-text-3">{d.battery}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Multi-Angle Analysis */}
|
||||||
|
<div className="p-2.5 px-3 border-b border-border">
|
||||||
|
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">다각화 분석</div>
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
{[
|
||||||
|
{ icon: '🎯', label: '다시점 융합', value: '28건', sub: '360° 식별' },
|
||||||
|
{ icon: '🧊', label: '3D 재구성', value: '12건', sub: '선박+오염원' },
|
||||||
|
{ icon: '📡', label: '다센서 융합', value: '45건', sub: '광학+IR+SAR' },
|
||||||
|
{ icon: '🛢️', label: '오염원 3D', value: '3건', sub: '유류+HNS' },
|
||||||
|
].map((a, i) => (
|
||||||
|
<div key={i} className="bg-white/[0.02] rounded-sm px-1.5 py-1.5 border border-white/[0.03]">
|
||||||
|
<div className="text-[10px] mb-px">{a.icon}</div>
|
||||||
|
<div className="text-[7px] text-text-3">{a.label}</div>
|
||||||
|
<div className="text-xs font-bold font-mono text-primary-cyan my-px">{a.value}</div>
|
||||||
|
<div className="text-[6px] text-text-3">{a.sub}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Real-time Alerts */}
|
||||||
|
<div className="p-2.5 px-3 flex-1 overflow-auto">
|
||||||
|
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">실시간 경보</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{alerts.map((a, i) => (
|
||||||
|
<div key={i} className={`px-2 py-1.5 border-l-2 rounded-sm text-[9px] font-korean ${alertColor(a.type)}`}>
|
||||||
|
<span className="font-mono text-text-3 mr-1.5">{a.time}</span>
|
||||||
|
<span className="text-text-2">{a.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
787
frontend/src/tabs/aerial/components/SatelliteRequest.tsx
Normal file
787
frontend/src/tabs/aerial/components/SatelliteRequest.tsx
Normal file
@ -0,0 +1,787 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface SatRequest {
|
||||||
|
id: string
|
||||||
|
zone: string
|
||||||
|
zoneCoord: string
|
||||||
|
zoneArea: string
|
||||||
|
satellite: string
|
||||||
|
requestDate: string
|
||||||
|
expectedReceive: string
|
||||||
|
resolution: string
|
||||||
|
status: '촬영중' | '대기' | '완료'
|
||||||
|
provider?: string
|
||||||
|
purpose?: string
|
||||||
|
requester?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const satRequests: SatRequest[] = [
|
||||||
|
{ id: 'SAT-004', zone: '제주 서귀포 해상 (유출 해역 중심)', zoneCoord: '33.24°N 126.50°E', zoneArea: '15km²', satellite: 'KOMPSAT-3A', requestDate: '02-20 08:14', expectedReceive: '02-20 14:30', resolution: '0.5m', status: '촬영중', provider: 'KARI', purpose: '유출유 확산 모니터링', requester: '방제과 김해양' },
|
||||||
|
{ id: 'SAT-005', zone: '가파도 북쪽 해안선', zoneCoord: '33.17°N 126.27°E', zoneArea: '8km²', satellite: 'KOMPSAT-3', requestDate: '02-20 09:02', expectedReceive: '02-21 09:00', resolution: '1.0m', status: '대기', provider: 'KARI', purpose: '해안선 오염 확인', requester: '방제과 이민수' },
|
||||||
|
{ id: 'SAT-006', zone: '마라도 주변 해역', zoneCoord: '33.11°N 126.27°E', zoneArea: '12km²', satellite: 'Sentinel-2', requestDate: '02-20 09:30', expectedReceive: '02-21 11:00', resolution: '10m', status: '대기', provider: 'ESA Copernicus', purpose: '수질 분석용 다분광 촬영', requester: '환경분석팀 박수진' },
|
||||||
|
{ id: 'SAT-007', zone: '대정읍 해안 오염 확산 구역', zoneCoord: '33.21°N 126.10°E', zoneArea: '20km²', satellite: 'KOMPSAT-3A', requestDate: '02-20 10:05', expectedReceive: '02-22 08:00', resolution: '0.5m', status: '대기', provider: 'KARI', purpose: '확산 예측 모델 검증', requester: '방제과 김해양' },
|
||||||
|
{ id: 'SAT-003', zone: '제주 남방 100해리 해상', zoneCoord: '33.00°N 126.50°E', zoneArea: '25km²', satellite: 'Sentinel-1', requestDate: '02-19 14:00', expectedReceive: '02-19 23:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: 'SAR 유막 탐지', requester: '환경분석팀 박수진' },
|
||||||
|
{ id: 'SAT-002', zone: '여수 오동도 인근 해역', zoneCoord: '34.73°N 127.68°E', zoneArea: '18km²', satellite: 'KOMPSAT-3A', requestDate: '02-18 11:30', expectedReceive: '02-18 17:45', resolution: '0.5m', status: '완료', provider: 'KARI', purpose: '유출 초기 범위 확인', requester: '방제과 김해양' },
|
||||||
|
{ id: 'SAT-001', zone: '통영 해역 남측', zoneCoord: '34.85°N 128.43°E', zoneArea: '30km²', satellite: 'Sentinel-1', requestDate: '02-17 09:00', expectedReceive: '02-17 21:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: '야간 SAR 유막 모니터링', requester: '환경분석팀 박수진' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const satellites = [
|
||||||
|
{ name: 'KOMPSAT-3A', desc: '해상도 0.5m · 광학 / IR · 촬영 가능', status: '가용', statusColor: 'var(--green)', borderColor: 'rgba(34,197,94,.2)', pulse: true },
|
||||||
|
{ name: 'KOMPSAT-3', desc: '해상도 1.0m · 광학 · 임무 중', status: '임무중', statusColor: 'var(--yellow)', borderColor: 'rgba(234,179,8,.2)', pulse: true },
|
||||||
|
{ name: 'Sentinel-1 (ESA)', desc: '해상도 20m · SAR · 야간/우천 가능', status: '가용', statusColor: 'var(--green)', borderColor: 'var(--bd)', pulse: false },
|
||||||
|
{ name: 'Sentinel-2 (ESA)', desc: '해상도 10m · 다분광 · 수질 분석 적합', status: '가용', statusColor: 'var(--green)', borderColor: 'var(--bd)', pulse: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
const passSchedules = [
|
||||||
|
{ time: '14:10 – 14:24', desc: 'KOMPSAT-3A 패스 (제주 남방)', today: true },
|
||||||
|
{ time: '16:55 – 17:08', desc: 'Sentinel-1 패스 (제주 전역)', today: true },
|
||||||
|
{ time: '내일 09:12', desc: 'KOMPSAT-3 패스 (가파도~마라도)', today: false },
|
||||||
|
{ time: '내일 10:40', desc: 'Sentinel-2 패스 (제주 서측)', today: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
// UP42 위성 카탈로그 데이터
|
||||||
|
const up42Satellites = [
|
||||||
|
{ id: 'mwl-hd15', name: 'Maxar WorldView Legion HD15', res: '0.3m', type: 'optical' as const, color: '#3b82f6', cloud: 15 },
|
||||||
|
{ id: 'pneo-hd15', name: 'Pléiades Neo HD15', res: '0.3m', type: 'optical' as const, color: '#06b6d4', cloud: 10 },
|
||||||
|
{ id: 'mwl', name: 'Maxar WorldView Legion', res: '0.5m', type: 'optical' as const, color: '#3b82f6', cloud: 20 },
|
||||||
|
{ id: 'mwv3', name: 'Maxar WorldView-3', res: '0.5m', type: 'optical' as const, color: '#3b82f6', cloud: 20 },
|
||||||
|
{ id: 'pneo', name: 'Pléiades Neo', res: '0.5m', type: 'optical' as const, color: '#06b6d4', cloud: 15 },
|
||||||
|
{ id: 'bj3n', name: 'Beijing-3N', res: '0.5m', type: 'optical' as const, color: '#f97316', cloud: 20, delay: true },
|
||||||
|
{ id: 'skysat', name: 'SkySat', res: '0.7m', type: 'optical' as const, color: '#22c55e', cloud: 15 },
|
||||||
|
{ id: 'kmp3a', name: 'KOMPSAT-3A', res: '0.5m', type: 'optical' as const, color: '#a855f7', cloud: 10 },
|
||||||
|
{ id: 'kmp3', name: 'KOMPSAT-3', res: '1.0m', type: 'optical' as const, color: '#a855f7', cloud: 15 },
|
||||||
|
{ id: 'spot7', name: 'SPOT 7', res: '1.5m', type: 'optical' as const, color: '#eab308', cloud: 20 },
|
||||||
|
{ id: 's2', name: 'Sentinel-2', res: '10m', type: 'optical' as const, color: '#ec4899', cloud: 20 },
|
||||||
|
{ id: 's1', name: 'Sentinel-1 SAR', res: '20m', type: 'sar' as const, color: '#f59e0b', cloud: 0 },
|
||||||
|
{ id: 'alos2', name: 'ALOS-2 PALSAR-2', res: '3m', type: 'sar' as const, color: '#f59e0b', cloud: 0 },
|
||||||
|
{ id: 'rcm', name: 'RCM (Radarsat)', res: '3m', type: 'sar' as const, color: '#f59e0b', cloud: 0 },
|
||||||
|
{ id: 'srtm', name: 'SRTM DEM', res: '30m', type: 'elevation' as const, color: '#64748b', cloud: 0 },
|
||||||
|
{ id: 'cop-dem', name: 'Copernicus DEM', res: '10m', type: 'elevation' as const, color: '#64748b', cloud: 0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const up42Passes = [
|
||||||
|
{ sat: 'KOMPSAT-3A', time: '오늘 14:10–14:24', res: '0.5m', cloud: '≤10%', note: '최우선 추천', color: '#a855f7' },
|
||||||
|
{ sat: 'Pléiades Neo', time: '오늘 14:38–14:52', res: '0.3m', cloud: '≤15%', note: '초고해상도', color: '#06b6d4' },
|
||||||
|
{ sat: 'Sentinel-1 SAR', time: '오늘 16:55–17:08', res: '20m', cloud: '야간/우천 가능', note: 'SAR', color: '#f59e0b' },
|
||||||
|
{ sat: 'KOMPSAT-3', time: '내일 09:12', res: '1.0m', cloud: '≤15%', note: '', color: '#a855f7' },
|
||||||
|
{ sat: 'Maxar WV-3', time: '내일 13:20', res: '0.5m', cloud: '≤20%', note: '', color: '#3b82f6' },
|
||||||
|
]
|
||||||
|
|
||||||
|
type SatModalPhase = 'none' | 'provider' | 'blacksky' | 'up42'
|
||||||
|
|
||||||
|
export function SatelliteRequest() {
|
||||||
|
const [statusFilter, setStatusFilter] = useState('전체')
|
||||||
|
const [modalPhase, setModalPhase] = useState<SatModalPhase>('none')
|
||||||
|
const [selectedRequest, setSelectedRequest] = useState<SatRequest | null>(null)
|
||||||
|
const [showMoreCompleted, setShowMoreCompleted] = useState(false)
|
||||||
|
// UP42 sub-tab
|
||||||
|
const [up42SubTab, setUp42SubTab] = useState<'optical' | 'sar' | 'elevation'>('optical')
|
||||||
|
const [up42SelSat, setUp42SelSat] = useState<string | null>(null)
|
||||||
|
const [up42SelPass, setUp42SelPass] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||||
|
setModalPhase('none')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (modalPhase !== 'none') document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [modalPhase])
|
||||||
|
|
||||||
|
const allRequests = showMoreCompleted ? satRequests : satRequests.filter(r => r.status !== '완료' || r.id === 'SAT-003')
|
||||||
|
|
||||||
|
const filtered = allRequests.filter(r => {
|
||||||
|
if (statusFilter === '전체') return true
|
||||||
|
if (statusFilter === '대기') return r.status === '대기'
|
||||||
|
if (statusFilter === '진행') return r.status === '촬영중'
|
||||||
|
if (statusFilter === '완료') return r.status === '완료'
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusBadge = (s: SatRequest['status']) => {
|
||||||
|
if (s === '촬영중') return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(234,179,8,.15)', border: '1px solid rgba(234,179,8,.3)', color: 'var(--yellow)' }}>
|
||||||
|
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--yellow)' }} />촬영중
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
if (s === '대기') return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(59,130,246,.15)', border: '1px solid rgba(59,130,246,.3)', color: 'var(--blue)' }}>⏳ 대기</span>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(34,197,94,.1)', border: '1px solid rgba(34,197,94,.2)', color: 'var(--green)' }}>✅ 완료</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ value: '3', label: '요청 대기', color: 'var(--blue)' },
|
||||||
|
{ value: '1', label: '촬영 진행 중', color: 'var(--yellow)' },
|
||||||
|
{ value: '7', label: '수신 완료', color: 'var(--green)' },
|
||||||
|
{ value: '0.5m', label: '최고 해상도', color: 'var(--cyan)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const filters = ['전체', '대기', '진행', '완료']
|
||||||
|
|
||||||
|
const up42Filtered = up42Satellites.filter(s => s.type === up42SubTab)
|
||||||
|
|
||||||
|
// ── 섹션 헤더 헬퍼 (BlackSky 폼) ──
|
||||||
|
const sectionHeader = (num: number, label: string) => (
|
||||||
|
<div className="text-[11px] font-bold font-korean mb-2.5 flex items-center gap-1.5" style={{ color: '#818cf8' }}>
|
||||||
|
<div className="w-[18px] h-[18px] rounded-[5px] flex items-center justify-center text-[9px] font-bold" style={{ background: 'rgba(99,102,241,.12)', color: '#818cf8' }}>{num}</div>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const bsInput = "w-full px-3 py-2 rounded-md text-[11px] font-korean outline-none box-border"
|
||||||
|
const bsInputStyle = { border: '1px solid #21262d', background: '#161b22', color: '#e2e8f0' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-y-auto" style={{ padding: '20px 24px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-[10px] flex items-center justify-center text-xl" style={{ background: 'linear-gradient(135deg,rgba(59,130,246,.2),rgba(168,85,247,.2))', border: '1px solid rgba(59,130,246,.3)' }}>🛰</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-bold font-korean text-text-1">위성 촬영 요청</div>
|
||||||
|
<div className="text-[11px] text-text-3 font-korean mt-0.5">위성 촬영 임무를 요청하고 수신 현황을 관리합니다</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setModalPhase('provider')} className="px-4 py-2.5 text-white border-none rounded-sm text-[13px] font-semibold cursor-pointer font-korean flex items-center gap-1.5" style={{ background: 'linear-gradient(135deg,var(--blue),var(--purple))' }}>🛰 새 요청</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 요약 통계 */}
|
||||||
|
<div className="grid grid-cols-4 gap-3 mb-5">
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<div key={i} className="bg-bg-2 border border-border rounded-md p-3.5 text-center">
|
||||||
|
<div className="text-[22px] font-bold font-mono" style={{ color: s.color }}>{s.value}</div>
|
||||||
|
<div className="text-[10px] text-text-3 mt-1 font-korean">{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 요청 목록 */}
|
||||||
|
<div className="bg-bg-2 border border-border rounded-md overflow-hidden mb-5">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3.5 border-b border-border">
|
||||||
|
<div className="text-[13px] font-bold font-korean text-text-1">📋 위성 요청 목록</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{filters.map(f => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setStatusFilter(f)}
|
||||||
|
className="px-2.5 py-1 rounded text-[10px] font-semibold cursor-pointer font-korean border"
|
||||||
|
style={statusFilter === f
|
||||||
|
? { background: 'rgba(59,130,246,.15)', color: 'var(--blue)', borderColor: 'rgba(59,130,246,.3)' }
|
||||||
|
: { background: 'var(--bg3)', color: 'var(--t2)', borderColor: 'var(--bd)' }
|
||||||
|
}
|
||||||
|
>{f}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 행 */}
|
||||||
|
<div className="grid gap-0 px-4 py-2 bg-bg-3 border-b border-border" style={{ gridTemplateColumns: '60px 1fr 100px 100px 120px 80px 90px' }}>
|
||||||
|
{['번호', '촬영 구역', '위성', '요청일시', '예상수신', '해상도', '상태'].map(h => (
|
||||||
|
<div key={h} className="text-[9px] font-bold text-text-3 font-korean uppercase tracking-wider">{h}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 행 */}
|
||||||
|
{filtered.map(r => (
|
||||||
|
<div key={r.id}>
|
||||||
|
<div
|
||||||
|
onClick={() => setSelectedRequest(selectedRequest?.id === r.id ? null : r)}
|
||||||
|
className="grid gap-0 px-4 py-3 border-b items-center cursor-pointer hover:bg-bg-hover/30 transition-colors"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: '60px 1fr 100px 100px 120px 80px 90px',
|
||||||
|
borderColor: 'rgba(255,255,255,.04)',
|
||||||
|
background: selectedRequest?.id === r.id ? 'rgba(99,102,241,.06)' : r.status === '촬영중' ? 'rgba(234,179,8,.03)' : 'transparent',
|
||||||
|
opacity: r.status === '완료' ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-[11px] font-mono text-text-2">{r.id}</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-text-1 font-korean">{r.zone}</div>
|
||||||
|
<div className="text-[10px] text-text-3 font-mono mt-0.5">{r.zoneCoord} · {r.zoneArea}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] font-semibold text-text-1 font-korean">{r.satellite}</div>
|
||||||
|
<div className="text-[10px] text-text-2 font-mono">{r.requestDate}</div>
|
||||||
|
<div className="text-[10px] font-semibold font-mono" style={{ color: r.status === '촬영중' ? 'var(--yellow)' : 'var(--t2)' }}>{r.expectedReceive}</div>
|
||||||
|
<div className="text-[11px] font-bold font-mono" style={{ color: r.status === '완료' ? 'var(--t3)' : 'var(--cyan)' }}>{r.resolution}</div>
|
||||||
|
<div>{statusBadge(r.status)}</div>
|
||||||
|
</div>
|
||||||
|
{/* 상세 정보 패널 */}
|
||||||
|
{selectedRequest?.id === r.id && (
|
||||||
|
<div className="px-4 py-3 border-b" style={{ borderColor: 'rgba(255,255,255,.04)', background: 'rgba(99,102,241,.03)' }}>
|
||||||
|
<div className="grid grid-cols-4 gap-3 mb-2">
|
||||||
|
{[
|
||||||
|
['제공자', r.provider || '-'],
|
||||||
|
['요청 목적', r.purpose || '-'],
|
||||||
|
['요청자', r.requester || '-'],
|
||||||
|
['촬영 면적', r.zoneArea],
|
||||||
|
].map(([k, v], i) => (
|
||||||
|
<div key={i} className="px-2.5 py-2 bg-bg-0 rounded">
|
||||||
|
<div className="text-[8px] font-bold text-text-3 font-korean mb-1 uppercase">{k}</div>
|
||||||
|
<div className="text-[10px] font-semibold text-text-1 font-korean">{v}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors" style={{ background: 'rgba(6,182,212,.08)', borderColor: 'rgba(6,182,212,.2)', color: 'var(--cyan)' }}>📍 지도에서 보기</button>
|
||||||
|
{r.status === '완료' && (
|
||||||
|
<button className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors" style={{ background: 'rgba(34,197,94,.08)', borderColor: 'rgba(34,197,94,.2)', color: 'var(--green)' }}>📥 영상 다운로드</button>
|
||||||
|
)}
|
||||||
|
{r.status === '대기' && (
|
||||||
|
<button className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors" style={{ background: 'rgba(239,68,68,.08)', borderColor: 'rgba(239,68,68,.2)', color: 'var(--red)' }}>✕ 요청 취소</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={() => setShowMoreCompleted(!showMoreCompleted)}
|
||||||
|
className="text-center py-2.5 text-[10px] text-text-3 font-korean cursor-pointer hover:text-text-2 transition-colors"
|
||||||
|
>
|
||||||
|
{showMoreCompleted ? '▲ 완료 목록 접기' : '▼ 이전 완료 목록 더보기 (6건)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 위성 궤도 정보 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3.5">
|
||||||
|
{/* 가용 위성 현황 */}
|
||||||
|
<div className="bg-bg-2 border border-border rounded-md p-4">
|
||||||
|
<div className="text-xs font-bold text-text-1 font-korean mb-3">🛰 가용 위성 현황</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{satellites.map((sat, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2.5 px-3 py-2 bg-bg-3 rounded-md" style={{ border: `1px solid ${sat.borderColor}` }}>
|
||||||
|
<div className={`w-2 h-2 rounded-full shrink-0 ${sat.pulse ? 'animate-pulse' : ''}`} style={{ background: sat.statusColor }} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[11px] font-semibold text-text-1 font-korean">{sat.name}</div>
|
||||||
|
<div className="text-[9px] text-text-3 font-korean">{sat.desc}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-bold font-korean" style={{ color: sat.statusColor }}>{sat.status}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오늘 촬영 가능 시간 */}
|
||||||
|
<div className="bg-bg-2 border border-border rounded-md p-4">
|
||||||
|
<div className="text-xs font-bold text-text-1 font-korean mb-3">⏰ 오늘 촬영 가능 시간 (KST)</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{passSchedules.map((ps, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-2 px-2.5 py-[7px] rounded-[5px]"
|
||||||
|
style={{
|
||||||
|
background: ps.today ? 'rgba(34,197,94,.05)' : 'rgba(59,130,246,.05)',
|
||||||
|
border: ps.today ? '1px solid rgba(34,197,94,.15)' : '1px solid rgba(59,130,246,.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-bold font-mono min-w-[90px]" style={{ color: ps.today ? 'var(--cyan)' : 'var(--blue)' }}>{ps.time}</span>
|
||||||
|
<span className="text-[10px] text-text-1 font-korean">{ps.desc}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══ 모달: 제공자 선택 ═══ */}
|
||||||
|
{modalPhase !== 'none' && (
|
||||||
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center" style={{ background: 'rgba(5,8,18,.75)', backdropFilter: 'blur(8px)' }}>
|
||||||
|
<div ref={modalRef}>
|
||||||
|
|
||||||
|
{/* ── 제공자 선택 ── */}
|
||||||
|
{modalPhase === 'provider' && (
|
||||||
|
<div className="border rounded-2xl w-[640px] overflow-hidden" style={{ background: 'var(--bg1)', borderColor: 'rgba(99,102,241,.3)', boxShadow: '0 24px 80px rgba(0,0,0,.6)' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="px-7 pt-6 pb-4 relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: 'linear-gradient(90deg,#6366f1,#3b82f6,#06b6d4)' }} />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-xl" style={{ background: 'linear-gradient(135deg,rgba(99,102,241,.15),rgba(59,130,246,.08))' }}>🛰</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-bold text-text-1 font-korean">위성 촬영 요청 — 제공자 선택</div>
|
||||||
|
<div className="text-[10px] text-text-3 font-korean mt-0.5">요청할 위성 서비스 제공자를 선택하세요</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setModalPhase('none')} className="text-lg cursor-pointer text-text-3 p-1 bg-transparent border-none hover:text-text-1 transition-colors">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 제공자 카드 */}
|
||||||
|
<div className="px-7 pb-6 flex flex-col gap-3.5">
|
||||||
|
{/* BlackSky (Maxar) */}
|
||||||
|
<div
|
||||||
|
onClick={() => setModalPhase('blacksky')}
|
||||||
|
className="cursor-pointer bg-bg-2 border border-border rounded-xl p-5 relative overflow-hidden hover:border-[rgba(99,102,241,.5)] hover:bg-[rgba(99,102,241,.04)] transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-[42px] h-[42px] rounded-[10px] flex items-center justify-center border" style={{ background: 'linear-gradient(135deg,#1a1a2e,#16213e)', borderColor: 'rgba(99,102,241,.3)' }}>
|
||||||
|
<span className="text-[11px] font-extrabold font-mono" style={{ color: '#818cf8', letterSpacing: '-.5px' }}>B<span style={{ color: '#a78bfa' }}>Sky</span></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-text-1 font-korean">BlackSky</div>
|
||||||
|
<div className="text-[9px] text-text-3 font-korean mt-px">Maxar Electro-Optical API</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="px-2 py-0.5 rounded-[10px] text-[8px] font-semibold" style={{ background: 'rgba(34,197,94,.1)', border: '1px solid rgba(34,197,94,.2)', color: '#22c55e' }}>API 연결됨</span>
|
||||||
|
<span className="text-base text-text-3">→</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2 mb-2.5">
|
||||||
|
{[
|
||||||
|
['유형', 'EO (광학)', '#818cf8'],
|
||||||
|
['해상도', '~1m', 'var(--t1)'],
|
||||||
|
['재방문', '≤1시간', 'var(--t1)'],
|
||||||
|
['납기', '90분 이내', '#22c55e'],
|
||||||
|
].map(([k, v, c], i) => (
|
||||||
|
<div key={i} className="text-center p-1.5 bg-bg-0 rounded-md">
|
||||||
|
<div className="text-[7px] text-text-3 font-korean mb-0.5">{k}</div>
|
||||||
|
<div className="text-[10px] font-bold font-mono" style={{ color: c }}>{v}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-text-3 font-korean leading-relaxed">고빈도 소형위성 군집 기반 긴급 촬영. 해양 사고 현장 신속 모니터링에 최적화. Dawn-to-Dusk 촬영 가능.</div>
|
||||||
|
<div className="mt-2 text-[8px] text-text-3 font-mono">API: <span style={{ color: '#818cf8' }}>eapi.maxar.com/e1so/rapidoc</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* UP42 (EO + SAR) */}
|
||||||
|
<div
|
||||||
|
onClick={() => setModalPhase('up42')}
|
||||||
|
className="cursor-pointer bg-bg-2 border border-border rounded-xl p-5 relative overflow-hidden hover:border-[rgba(59,130,246,.5)] hover:bg-[rgba(59,130,246,.04)] transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-[42px] h-[42px] rounded-[10px] flex items-center justify-center border" style={{ background: 'linear-gradient(135deg,#0a1628,#162a50)', borderColor: 'rgba(59,130,246,.3)' }}>
|
||||||
|
<span className="text-[13px] font-extrabold font-mono" style={{ color: '#60a5fa', letterSpacing: '-.5px' }}>up<sup className="text-[7px] align-super">42</sup></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-text-1 font-korean">UP42 — EO + SAR</div>
|
||||||
|
<div className="text-[9px] text-text-3 font-korean mt-px">Optical · SAR · Elevation 통합 마켓플레이스</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="px-2 py-0.5 rounded-[10px] text-[8px] font-semibold" style={{ background: 'rgba(34,197,94,.1)', border: '1px solid rgba(34,197,94,.2)', color: '#22c55e' }}>API 연결됨</span>
|
||||||
|
<span className="text-base text-text-3">→</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2 mb-2.5">
|
||||||
|
{[
|
||||||
|
['유형', 'EO + SAR', '#60a5fa'],
|
||||||
|
['해상도', '0.3~5m', 'var(--t1)'],
|
||||||
|
['위성 수', '16+ 컬렉션', 'var(--t1)'],
|
||||||
|
['야간/악천후', 'SAR 가능', '#22c55e'],
|
||||||
|
].map(([k, v, c], i) => (
|
||||||
|
<div key={i} className="text-center p-1.5 bg-bg-0 rounded-md">
|
||||||
|
<div className="text-[7px] text-text-3 font-korean mb-0.5">{k}</div>
|
||||||
|
<div className="text-[10px] font-bold font-mono" style={{ color: c }}>{v}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5 mb-2.5 flex-wrap">
|
||||||
|
{['Pléiades Neo', 'SPOT 6/7'].map((t, i) => (
|
||||||
|
<span key={i} className="px-1.5 py-px rounded text-[8px] font-korean" style={{ background: 'rgba(59,130,246,.08)', border: '1px solid rgba(59,130,246,.15)', color: '#60a5fa' }}>{t}</span>
|
||||||
|
))}
|
||||||
|
{['TerraSAR-X', 'Capella SAR', 'ICEYE'].map((t, i) => (
|
||||||
|
<span key={i} className="px-1.5 py-px rounded text-[8px] font-korean" style={{ background: 'rgba(6,182,212,.08)', border: '1px solid rgba(6,182,212,.15)', color: 'var(--cyan)' }}>{t}</span>
|
||||||
|
))}
|
||||||
|
<span className="px-1.5 py-px rounded text-[8px] font-korean" style={{ background: 'rgba(139,148,158,.08)', border: '1px solid rgba(139,148,158,.15)', color: 'var(--t3)' }}>+11 more</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-text-3 font-korean leading-relaxed">광학(EO) + 합성개구레이더(SAR) 통합 마켓. 야간·악천후 시 SAR 활용. 다중 위성 소스 자동 최적 선택.</div>
|
||||||
|
<div className="mt-2 text-[8px] text-text-3 font-mono">API: <span style={{ color: '#60a5fa' }}>up42.com</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 */}
|
||||||
|
<div className="px-7 pb-5 flex items-center justify-between">
|
||||||
|
<div className="text-[9px] text-text-3 font-korean leading-relaxed">💡 긴급 촬영: BlackSky 권장 (90분 납기) · 야간/악천후: UP42 SAR 권장</div>
|
||||||
|
<button onClick={() => setModalPhase('none')} className="px-4 py-2 rounded-lg border text-[11px] font-semibold cursor-pointer font-korean" style={{ borderColor: 'var(--bd)', background: 'var(--bg3)', color: 'var(--t2)' }}>닫기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── BlackSky 긴급 촬영 요청 ── */}
|
||||||
|
{modalPhase === 'blacksky' && (
|
||||||
|
<div className="border rounded-[14px] w-[860px] max-h-[90vh] flex flex-col overflow-hidden" style={{ background: '#0d1117', borderColor: 'rgba(99,102,241,.3)', boxShadow: '0 24px 80px rgba(0,0,0,.7)' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="px-6 py-4 border-b flex items-center justify-between shrink-0 relative" style={{ borderColor: '#21262d' }}>
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: 'linear-gradient(90deg,#6366f1,#818cf8,#a78bfa)' }} />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-lg flex items-center justify-center border" style={{ background: 'linear-gradient(135deg,#1a1a2e,#16213e)', borderColor: 'rgba(99,102,241,.3)' }}>
|
||||||
|
<span className="text-[10px] font-extrabold font-mono" style={{ color: '#818cf8' }}>B<span style={{ color: '#a78bfa' }}>Sky</span></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[15px] font-bold font-korean" style={{ color: '#e2e8f0' }}>BlackSky — 긴급 위성 촬영 요청</div>
|
||||||
|
<div className="text-[9px] font-korean mt-0.5" style={{ color: '#64748b' }}>Maxar E1SO RapiDoc API · 고빈도 긴급 태스킹</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-3 py-1 rounded-md text-[9px] font-semibold font-korean" style={{ background: 'rgba(99,102,241,.1)', border: '1px solid rgba(99,102,241,.25)', color: '#818cf8' }}>API Docs ↗</span>
|
||||||
|
<button onClick={() => setModalPhase('none')} className="text-lg cursor-pointer p-1 bg-transparent border-none" style={{ color: '#64748b' }}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-4" style={{ scrollbarWidth: 'thin', scrollbarColor: '#21262d transparent' }}>
|
||||||
|
{/* API 상태 */}
|
||||||
|
<div className="flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg" style={{ background: 'rgba(34,197,94,.06)', border: '1px solid rgba(34,197,94,.15)' }}>
|
||||||
|
<div className="w-2 h-2 rounded-full" style={{ background: '#22c55e', boxShadow: '0 0 6px rgba(34,197,94,.5)' }} />
|
||||||
|
<span className="text-[10px] font-semibold font-korean" style={{ color: '#22c55e' }}>API Connected</span>
|
||||||
|
<span className="text-[9px] font-mono" style={{ color: '#64748b' }}>eapi.maxar.com/e1so/rapidoc · Latency: 142ms</span>
|
||||||
|
<span className="ml-auto text-[8px] font-mono" style={{ color: '#64748b' }}>Quota: 47/50 요청 잔여</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ① 태스킹 유형 */}
|
||||||
|
<div>
|
||||||
|
{sectionHeader(1, '태스킹 유형 · 우선순위')}
|
||||||
|
<div className="grid grid-cols-3 gap-2.5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>촬영 유형 <span style={{ color: '#f87171' }}>*</span></label>
|
||||||
|
<select className={bsInput} style={bsInputStyle}>
|
||||||
|
<option>긴급 태스킹 (Emergency)</option>
|
||||||
|
<option>표준 태스킹 (Standard)</option>
|
||||||
|
<option>아카이브 검색 (Archive)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>우선순위 <span style={{ color: '#f87171' }}>*</span></label>
|
||||||
|
<select className={bsInput} style={bsInputStyle}>
|
||||||
|
<option>P1 — 긴급 (90분 내)</option>
|
||||||
|
<option>P2 — 높음 (6시간 내)</option>
|
||||||
|
<option>P3 — 보통 (24시간 내)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>촬영 모드</label>
|
||||||
|
<select className={bsInput} style={bsInputStyle}>
|
||||||
|
<option>Single Collect</option>
|
||||||
|
<option>Multi-pass Monitoring</option>
|
||||||
|
<option>Continuous (매 패스)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ② AOI 지정 */}
|
||||||
|
<div>
|
||||||
|
{sectionHeader(2, '관심 영역 (AOI)')}
|
||||||
|
<div className="grid grid-cols-3 gap-2.5 items-end">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>중심 위도 <span style={{ color: '#f87171' }}>*</span></label>
|
||||||
|
<input type="text" defaultValue="34.5832" className={bsInput} style={bsInputStyle} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>중심 경도 <span style={{ color: '#f87171' }}>*</span></label>
|
||||||
|
<input type="text" defaultValue="128.4217" className={bsInput} style={bsInputStyle} />
|
||||||
|
</div>
|
||||||
|
<button className="px-3.5 py-2 rounded-md text-[10px] font-semibold cursor-pointer font-korean whitespace-nowrap" style={{ border: '1px solid rgba(99,102,241,.3)', background: 'rgba(99,102,241,.08)', color: '#818cf8' }}>📍 지도에서 AOI 그리기</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-3 gap-2.5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>AOI 반경 (km)</label>
|
||||||
|
<input type="number" defaultValue={10} step={1} min={1} className={bsInput} style={bsInputStyle} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>최대 구름량 (%)</label>
|
||||||
|
<input type="number" defaultValue={20} step={5} min={0} max={100} className={bsInput} style={bsInputStyle} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>최대 Off-nadir (°)</label>
|
||||||
|
<input type="number" defaultValue={25} step={5} min={0} max={45} className={bsInput} style={bsInputStyle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ③ 촬영 기간 */}
|
||||||
|
<div>
|
||||||
|
{sectionHeader(3, '촬영 기간 · 반복')}
|
||||||
|
<div className="grid grid-cols-3 gap-2.5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>촬영 시작 <span style={{ color: '#f87171' }}>*</span></label>
|
||||||
|
<input type="datetime-local" defaultValue="2026-02-26T08:00" className={bsInput} style={bsInputStyle} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>촬영 종료 <span style={{ color: '#f87171' }}>*</span></label>
|
||||||
|
<input type="datetime-local" defaultValue="2026-02-27T20:00" className={bsInput} style={bsInputStyle} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>반복 촬영</label>
|
||||||
|
<select className={bsInput} style={bsInputStyle}>
|
||||||
|
<option>1회 (단일)</option>
|
||||||
|
<option>매 패스 (가용 시)</option>
|
||||||
|
<option>매 6시간</option>
|
||||||
|
<option>매 12시간</option>
|
||||||
|
<option>매일 1회</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ④ 산출물 설정 */}
|
||||||
|
<div>
|
||||||
|
{sectionHeader(4, '산출물 설정')}
|
||||||
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>산출물 형식 <span style={{ color: '#f87171' }}>*</span></label>
|
||||||
|
<select className={bsInput} style={bsInputStyle}>
|
||||||
|
<option>Ortho-Rectified (정사보정)</option>
|
||||||
|
<option>Pan-sharpened (팬샤프닝)</option>
|
||||||
|
<option>Basic L1 (원본)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>파일 포맷</label>
|
||||||
|
<select className={bsInput} style={bsInputStyle}>
|
||||||
|
<option>GeoTIFF</option>
|
||||||
|
<option>NITF</option>
|
||||||
|
<option>JPEG2000</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-3">
|
||||||
|
{[
|
||||||
|
{ label: '유출유 탐지 분석 (자동)', checked: true },
|
||||||
|
{ label: 'GIS 상황판 자동 오버레이', checked: true },
|
||||||
|
{ label: '변화탐지 (Change Detection)', checked: false },
|
||||||
|
{ label: '웹훅 알림', checked: false },
|
||||||
|
].map((opt, i) => (
|
||||||
|
<label key={i} className="flex items-center gap-1 text-[9px] cursor-pointer font-korean" style={{ color: '#94a3b8' }}>
|
||||||
|
<input type="checkbox" defaultChecked={opt.checked} style={{ accentColor: '#818cf8', transform: 'scale(.85)' }} /> {opt.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ⑤ 연계 사고 · 비고 */}
|
||||||
|
<div>
|
||||||
|
{sectionHeader(5, '연계 사고 · 비고')}
|
||||||
|
<div className="grid grid-cols-2 gap-2.5 mb-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>연계 사고번호</label>
|
||||||
|
<select className={bsInput} style={bsInputStyle}>
|
||||||
|
<option>OIL-2024-0892 · M/V STELLAR DAISY</option>
|
||||||
|
<option>HNS-2024-041 · 울산 온산항 톨루엔 유출</option>
|
||||||
|
<option>RSC-2024-0127 · M/V SEA GUARDIAN</option>
|
||||||
|
<option value="">연계 없음</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>요청자</label>
|
||||||
|
<input type="text" placeholder="소속 / 이름" className={bsInput} style={bsInputStyle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
placeholder="촬영 요청 목적, 특이사항, 관심 대상 등을 기록합니다..."
|
||||||
|
className="w-full h-[50px] px-3 py-2.5 rounded-md text-[10px] font-korean outline-none resize-y leading-relaxed box-border"
|
||||||
|
style={{ border: '1px solid #21262d', background: '#161b22', color: '#e2e8f0' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 버튼 */}
|
||||||
|
<div className="px-6 py-3.5 border-t flex items-center gap-2 shrink-0" style={{ borderColor: '#21262d' }}>
|
||||||
|
<div className="flex-1 text-[9px] font-korean leading-relaxed" style={{ color: '#64748b' }}>
|
||||||
|
<span style={{ color: '#f87171' }}>*</span> 필수 항목 · 긴급 태스킹은 P1 우선순위로 90분 내 최초 영상 수신
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setModalPhase('provider')} className="px-5 py-2.5 rounded-lg border text-xs font-semibold cursor-pointer font-korean" style={{ borderColor: '#21262d', background: '#161b22', color: '#94a3b8' }}>← 뒤로</button>
|
||||||
|
<button onClick={() => setModalPhase('none')} className="px-7 py-2.5 rounded-lg border-none text-xs font-bold cursor-pointer font-korean text-white" style={{ background: 'linear-gradient(135deg,#6366f1,#818cf8)', boxShadow: '0 4px 16px rgba(99,102,241,.35)' }}>🛰 BlackSky 촬영 요청 제출</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── UP42 카탈로그 주문 ── */}
|
||||||
|
{modalPhase === 'up42' && (
|
||||||
|
<div className="border rounded-[14px] w-[920px] max-h-[90vh] flex flex-col overflow-hidden" style={{ background: '#0d1117', borderColor: 'rgba(59,130,246,.3)', boxShadow: '0 24px 80px rgba(0,0,0,.7)' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="px-6 py-4 border-b flex items-center justify-between shrink-0 relative" style={{ borderColor: '#21262d' }}>
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: 'linear-gradient(90deg,#3b82f6,#06b6d4,#22c55e)' }} />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-lg flex items-center justify-center border" style={{ background: 'linear-gradient(135deg,#0a1628,#162a50)', borderColor: 'rgba(59,130,246,.3)' }}>
|
||||||
|
<span className="text-[13px] font-extrabold font-mono" style={{ color: '#60a5fa', letterSpacing: '-.5px' }}>up<sup className="text-[7px] align-super">42</sup></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[15px] font-bold font-korean" style={{ color: '#e2e8f0' }}>위성 촬영 요청 — 새 태스킹 주문</div>
|
||||||
|
<div className="text-[9px] font-korean mt-0.5" style={{ color: '#64748b' }}>관심 지역(AOI)을 그리고 위성 패스를 선택하세요</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-3 py-1 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(234,179,8,.1)', border: '1px solid rgba(234,179,8,.25)', color: '#eab308' }}>⚠ Beijing-3N 납기 지연 2.15–2.23</span>
|
||||||
|
<button onClick={() => setModalPhase('none')} className="text-lg cursor-pointer p-1 bg-transparent border-none" style={{ color: '#64748b' }}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 (좌: 사이드바, 우: 지도+AOI) */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* 왼쪽: 위성 카탈로그 */}
|
||||||
|
<div className="flex flex-col overflow-hidden border-r" style={{ width: 320, minWidth: 320, borderColor: '#21262d', background: '#0d1117' }}>
|
||||||
|
{/* Optical / SAR / Elevation 탭 */}
|
||||||
|
<div className="flex border-b shrink-0" style={{ borderColor: '#21262d' }}>
|
||||||
|
{(['optical', 'sar', 'elevation'] as const).map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setUp42SubTab(t)}
|
||||||
|
className="flex-1 py-2 text-[10px] font-bold cursor-pointer border-none font-korean transition-colors"
|
||||||
|
style={up42SubTab === t
|
||||||
|
? { background: 'rgba(59,130,246,.1)', color: '#60a5fa', borderBottom: '2px solid #3b82f6' }
|
||||||
|
: { background: 'transparent', color: '#64748b' }
|
||||||
|
}
|
||||||
|
>{t === 'optical' ? 'Optical' : t === 'sar' ? 'SAR' : 'Elevation'}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 바 */}
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-2 border-b shrink-0" style={{ borderColor: '#21262d' }}>
|
||||||
|
<span className="px-2 py-0.5 rounded text-[9px] font-semibold" style={{ background: 'rgba(59,130,246,.1)', color: '#60a5fa', border: '1px solid rgba(59,130,246,.2)' }}>Filters ✎</span>
|
||||||
|
<span className="px-2 py-0.5 rounded text-[9px] font-semibold" style={{ background: 'rgba(99,102,241,.1)', color: '#818cf8', border: '1px solid rgba(99,102,241,.2)' }}>☁ 구름 ≤ 20% ✕</span>
|
||||||
|
<span className="ml-auto text-[9px] font-mono" style={{ color: '#64748b' }}>↕ 해상도 우선</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬렉션 수 */}
|
||||||
|
<div className="px-3 py-1.5 border-b text-[9px] font-korean shrink-0" style={{ borderColor: '#21262d', color: '#64748b' }}>
|
||||||
|
이 지역에서 <b style={{ color: '#e2e8f0' }}>{up42Filtered.length}</b>개 컬렉션 사용 가능
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 위성 목록 */}
|
||||||
|
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: '#21262d transparent' }}>
|
||||||
|
{up42Filtered.map(sat => (
|
||||||
|
<div
|
||||||
|
key={sat.id}
|
||||||
|
onClick={() => setUp42SelSat(up42SelSat === sat.id ? null : sat.id)}
|
||||||
|
className="flex items-center gap-2.5 px-3 py-2.5 border-b cursor-pointer transition-colors"
|
||||||
|
style={{
|
||||||
|
borderColor: '#161b22',
|
||||||
|
background: up42SelSat === sat.id ? 'rgba(59,130,246,.08)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-1 h-8 rounded-full shrink-0" style={{ background: sat.color }} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[11px] font-semibold truncate font-korean" style={{ color: '#e2e8f0' }}>{sat.name}</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className="text-[9px] font-bold font-mono" style={{ color: sat.color }}>{sat.res}</span>
|
||||||
|
{sat.cloud > 0 && <span className="text-[8px] font-mono" style={{ color: '#64748b' }}>☁ ≤{sat.cloud}%</span>}
|
||||||
|
{'delay' in sat && sat.delay && <span className="text-[8px] font-bold" style={{ color: '#eab308' }}>⚠ 지연</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{up42SelSat === sat.id && <span className="text-[12px]" style={{ color: '#3b82f6' }}>✓</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽: 지도 + AOI + 패스 */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||||
|
{/* 지도 영역 (placeholder) */}
|
||||||
|
<div className="flex-1 relative" style={{ background: '#0a0e18' }}>
|
||||||
|
{/* 검색바 */}
|
||||||
|
<div className="absolute top-3 left-3 right-3 flex items-center gap-2 px-3 py-2 rounded-lg z-10" style={{ background: 'rgba(13,17,23,.9)', border: '1px solid #21262d', backdropFilter: 'blur(8px)' }}>
|
||||||
|
<span style={{ color: '#8690a6', fontSize: 13 }}>🔍</span>
|
||||||
|
<input type="text" placeholder="위치 또는 좌표 입력..." className="flex-1 bg-transparent border-none outline-none text-[11px] font-korean" style={{ color: '#e2e8f0' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 지도 placeholder */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl mb-2 opacity-20">🗺</div>
|
||||||
|
<div className="text-[11px] font-korean opacity-40" style={{ color: '#64748b' }}>지도 영역 — AOI를 그려 위성 패스를 확인하세요</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AOI 도구 버튼 (오른쪽 사이드) */}
|
||||||
|
<div className="absolute top-14 right-3 flex flex-col gap-1 p-1.5 rounded-lg z-10" style={{ background: 'rgba(13,17,23,.9)', border: '1px solid #21262d' }}>
|
||||||
|
<div className="text-[7px] font-bold text-center mb-0.5" style={{ color: '#64748b' }}>ADD</div>
|
||||||
|
{[
|
||||||
|
{ icon: '⬜', title: '사각형 AOI' },
|
||||||
|
{ icon: '🔷', title: '다각형 AOI' },
|
||||||
|
{ icon: '⭕', title: '원형 AOI' },
|
||||||
|
{ icon: '📁', title: '파일 업로드' },
|
||||||
|
].map((t, i) => (
|
||||||
|
<button key={i} className="w-7 h-7 flex items-center justify-center rounded text-sm cursor-pointer border-none" style={{ background: '#161b22', color: '#8690a6' }} title={t.title}>{t.icon}</button>
|
||||||
|
))}
|
||||||
|
<div className="h-px my-0.5" style={{ background: '#21262d' }} />
|
||||||
|
<div className="text-[7px] font-bold text-center mb-0.5" style={{ color: '#64748b' }}>AOI</div>
|
||||||
|
<button className="w-7 h-7 flex items-center justify-center rounded text-sm cursor-pointer border-none" style={{ background: '#161b22', color: '#8690a6' }} title="저장된 AOI">💾</button>
|
||||||
|
<button className="w-7 h-7 flex items-center justify-center rounded text-sm cursor-pointer border-none" style={{ background: '#161b22', color: '#ef4444' }} title="AOI 삭제">🗑</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 줌 컨트롤 */}
|
||||||
|
<div className="absolute bottom-3 right-3 flex flex-col rounded-md overflow-hidden z-10" style={{ border: '1px solid #21262d' }}>
|
||||||
|
<button className="w-7 h-7 flex items-center justify-center text-sm cursor-pointer border-none" style={{ background: '#161b22', color: '#8690a6' }}>+</button>
|
||||||
|
<button className="w-7 h-7 flex items-center justify-center text-sm cursor-pointer border-none border-t" style={{ background: '#161b22', color: '#8690a6', borderTopColor: '#21262d' }}>−</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이 지역 검색 버튼 */}
|
||||||
|
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 z-10">
|
||||||
|
<button className="px-4 py-2 rounded-full text-[10px] font-semibold cursor-pointer font-korean" style={{ background: 'rgba(59,130,246,.9)', color: '#fff', border: 'none', boxShadow: '0 2px 12px rgba(59,130,246,.3)' }}>🔍 이 지역 검색</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 위성 패스 타임라인 */}
|
||||||
|
<div className="border-t px-4 py-3 shrink-0" style={{ borderColor: '#21262d', background: 'rgba(13,17,23,.95)' }}>
|
||||||
|
<div className="text-[10px] font-bold font-korean mb-2" style={{ color: '#e2e8f0' }}>🛰 오늘 가용 위성 패스 — 선택된 AOI 통과 예정</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{up42Passes.map((p, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
onClick={() => setUp42SelPass(up42SelPass === i ? null : i)}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer transition-colors"
|
||||||
|
style={{
|
||||||
|
background: up42SelPass === i ? 'rgba(59,130,246,.1)' : '#161b22',
|
||||||
|
border: up42SelPass === i ? '1px solid rgba(59,130,246,.3)' : '1px solid #21262d',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-1.5 h-5 rounded-full shrink-0" style={{ background: p.color }} />
|
||||||
|
<div className="flex-1 flex items-center gap-3 min-w-0">
|
||||||
|
<span className="text-[10px] font-bold font-korean min-w-[100px]" style={{ color: '#e2e8f0' }}>{p.sat}</span>
|
||||||
|
<span className="text-[9px] font-bold font-mono min-w-[110px]" style={{ color: '#60a5fa' }}>{p.time}</span>
|
||||||
|
<span className="text-[9px] font-mono" style={{ color: '#06b6d4' }}>{p.res}</span>
|
||||||
|
<span className="text-[8px] font-mono" style={{ color: '#64748b' }}>{p.cloud}</span>
|
||||||
|
</div>
|
||||||
|
{p.note && (
|
||||||
|
<span className="px-1.5 py-px rounded text-[8px] font-bold shrink-0" style={{
|
||||||
|
background: p.note === '최우선 추천' ? 'rgba(34,197,94,.1)' : p.note === '초고해상도' ? 'rgba(6,182,212,.1)' : p.note === 'SAR' ? 'rgba(245,158,11,.1)' : 'rgba(99,102,241,.1)',
|
||||||
|
color: p.note === '최우선 추천' ? '#22c55e' : p.note === '초고해상도' ? '#06b6d4' : p.note === 'SAR' ? '#f59e0b' : '#818cf8',
|
||||||
|
}}>{p.note}</span>
|
||||||
|
)}
|
||||||
|
{up42SelPass === i && <span className="text-xs" style={{ color: '#3b82f6' }}>✓</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<div className="px-6 py-3 border-t flex items-center justify-between shrink-0" style={{ borderColor: '#21262d' }}>
|
||||||
|
<div className="text-[9px] font-korean" style={{ color: '#64748b' }}>원하는 위성을 찾지 못했나요? <span style={{ color: '#60a5fa', cursor: 'pointer' }}>태스킹 주문 생성</span> 또는 <span style={{ color: '#60a5fa', cursor: 'pointer' }}>자세히 보기 ↗</span></div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-korean mr-1.5" style={{ color: '#8690a6' }}>
|
||||||
|
선택: {up42SelSat ? up42Satellites.find(s => s.id === up42SelSat)?.name : '없음'}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => setModalPhase('provider')} className="px-4 py-2 rounded-lg border text-[11px] font-semibold cursor-pointer font-korean" style={{ borderColor: '#21262d', background: '#161b22', color: '#94a3b8' }}>← 뒤로</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setModalPhase('none')}
|
||||||
|
className="px-6 py-2 rounded-lg border-none text-[11px] font-bold cursor-pointer font-korean text-white transition-opacity"
|
||||||
|
style={{
|
||||||
|
background: up42SelSat ? 'linear-gradient(135deg,#3b82f6,#06b6d4)' : '#21262d',
|
||||||
|
opacity: up42SelSat ? 1 : 0.5,
|
||||||
|
color: up42SelSat ? '#fff' : '#64748b',
|
||||||
|
boxShadow: up42SelSat ? '0 4px 16px rgba(59,130,246,.35)' : 'none',
|
||||||
|
}}
|
||||||
|
>🛰 촬영 요청 제출</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
497
frontend/src/tabs/aerial/components/SensorAnalysis.tsx
Normal file
497
frontend/src/tabs/aerial/components/SensorAnalysis.tsx
Normal file
@ -0,0 +1,497 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface ReconItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: 'vessel' | 'pollution'
|
||||||
|
status: 'complete' | 'processing'
|
||||||
|
points: string
|
||||||
|
polygons: string
|
||||||
|
coverage: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const reconItems: ReconItem[] = [
|
||||||
|
{ id: 'V-001', name: '불명선박-A', type: 'vessel', status: 'complete', points: '980K', polygons: '38K', coverage: '97.1%' },
|
||||||
|
{ id: 'V-002', name: '불명선박-B', type: 'vessel', status: 'complete', points: '1.2M', polygons: '48K', coverage: '98.4%' },
|
||||||
|
{ id: 'V-003', name: '어선 #37', type: 'vessel', status: 'processing', points: '420K', polygons: '16K', coverage: '64.2%' },
|
||||||
|
{ id: 'P-001', name: '유류오염-A', type: 'pollution', status: 'complete', points: '560K', polygons: '22K', coverage: '95.8%' },
|
||||||
|
{ id: 'P-002', name: '유류오염-B', type: 'pollution', status: 'processing', points: '310K', polygons: '12K', coverage: '52.1%' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string }) {
|
||||||
|
const isProcessing = status === 'processing'
|
||||||
|
const isWire = viewMode === 'wire'
|
||||||
|
const isPoint = viewMode === 'point'
|
||||||
|
|
||||||
|
const [vesselPoints] = useState(() =>
|
||||||
|
Array.from({ length: 300 }, (_, i) => {
|
||||||
|
const x = 35 + Math.random() * 355
|
||||||
|
const y = 15 + Math.random() * 160
|
||||||
|
const inHull = y > 60 && y < 175 && x > 35 && x < 390
|
||||||
|
const inBridge = x > 260 && x < 330 && y > 25 && y < 60
|
||||||
|
if (!inHull && !inBridge && Math.random() > 0.15) return null
|
||||||
|
const alpha = 0.15 + Math.random() * 0.55
|
||||||
|
const r = 0.8 + Math.random() * 0.8
|
||||||
|
return { i, x, y, r, alpha }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// 선박 SVG 와이어프레임/솔리드 3D 투시
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center" style={{ perspective: '800px' }}>
|
||||||
|
<div style={{ transform: 'rotateX(15deg) rotateY(-25deg) rotateZ(2deg)', transformStyle: 'preserve-3d', position: 'relative', width: '420px', height: '200px' }}>
|
||||||
|
<svg viewBox="0 0 420 200" width="420" height="200" style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }}>
|
||||||
|
{/* 수선 (waterline) */}
|
||||||
|
<ellipse cx="210" cy="165" rx="200" ry="12" fill="none" stroke="rgba(6,182,212,0.15)" strokeWidth="0.5" strokeDasharray="4 2" />
|
||||||
|
|
||||||
|
{/* 선체 (hull) - 3D 효과 */}
|
||||||
|
<path d="M 30 140 Q 40 170 100 175 L 320 175 Q 380 170 395 140 L 390 100 Q 385 85 370 80 L 50 80 Q 35 85 30 100 Z"
|
||||||
|
fill={isWire || isPoint ? 'none' : 'rgba(6,182,212,0.08)'}
|
||||||
|
stroke={isProcessing ? 'rgba(6,182,212,0.2)' : 'rgba(6,182,212,0.5)'}
|
||||||
|
strokeWidth={isWire ? '0.8' : '1.2'} />
|
||||||
|
|
||||||
|
{/* 선체 하부 */}
|
||||||
|
<path d="M 30 140 Q 20 155 60 168 L 100 175 M 395 140 Q 405 155 360 168 L 320 175"
|
||||||
|
fill="none" stroke="rgba(6,182,212,0.3)" strokeWidth="0.7" />
|
||||||
|
|
||||||
|
{/* 갑판 (deck) */}
|
||||||
|
<path d="M 50 80 Q 45 65 55 60 L 365 60 Q 375 65 370 80"
|
||||||
|
fill={isWire || isPoint ? 'none' : 'rgba(6,182,212,0.05)'}
|
||||||
|
stroke={isProcessing ? 'rgba(6,182,212,0.15)' : 'rgba(6,182,212,0.45)'}
|
||||||
|
strokeWidth={isWire ? '0.8' : '1'} />
|
||||||
|
|
||||||
|
{/* 선교 (bridge) */}
|
||||||
|
<rect x="260" y="25" width="70" height="35" rx="2"
|
||||||
|
fill={isWire || isPoint ? 'none' : 'rgba(6,182,212,0.1)'}
|
||||||
|
stroke={isProcessing ? 'rgba(6,182,212,0.15)' : 'rgba(6,182,212,0.5)'}
|
||||||
|
strokeWidth={isWire ? '0.8' : '1'} />
|
||||||
|
{/* 선교 창문 */}
|
||||||
|
{!isPoint && <g stroke="rgba(6,182,212,0.3)" strokeWidth="0.5" fill="none">
|
||||||
|
<rect x="268" y="30" width="10" height="6" rx="1" />
|
||||||
|
<rect x="282" y="30" width="10" height="6" rx="1" />
|
||||||
|
<rect x="296" y="30" width="10" height="6" rx="1" />
|
||||||
|
<rect x="310" y="30" width="10" height="6" rx="1" />
|
||||||
|
</g>}
|
||||||
|
|
||||||
|
{/* 마스트 */}
|
||||||
|
<line x1="295" y1="25" x2="295" y2="8" stroke="rgba(6,182,212,0.4)" strokeWidth="1" />
|
||||||
|
<line x1="288" y1="12" x2="302" y2="12" stroke="rgba(6,182,212,0.3)" strokeWidth="0.8" />
|
||||||
|
|
||||||
|
{/* 연통 (funnel) */}
|
||||||
|
<rect x="235" y="38" width="18" height="22" rx="1"
|
||||||
|
fill={isWire || isPoint ? 'none' : 'rgba(239,68,68,0.1)'}
|
||||||
|
stroke={isProcessing ? 'rgba(239,68,68,0.15)' : 'rgba(239,68,68,0.4)'}
|
||||||
|
strokeWidth={isWire ? '0.8' : '1'} />
|
||||||
|
|
||||||
|
{/* 화물 크레인 */}
|
||||||
|
<g stroke={isProcessing ? 'rgba(249,115,22,0.15)' : 'rgba(249,115,22,0.4)'} strokeWidth="0.8" fill="none">
|
||||||
|
<line x1="150" y1="60" x2="150" y2="20" />
|
||||||
|
<line x1="150" y1="22" x2="120" y2="40" />
|
||||||
|
<line x1="180" y1="60" x2="180" y2="25" />
|
||||||
|
<line x1="180" y1="27" x2="155" y2="42" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* 선체 리브 (와이어프레임 / 포인트 모드) */}
|
||||||
|
{(isWire || isPoint) && <g stroke="rgba(6,182,212,0.15)" strokeWidth="0.4">
|
||||||
|
{[80, 120, 160, 200, 240, 280, 320, 360].map(x => (
|
||||||
|
<line key={x} x1={x} y1="60" x2={x} y2="175" />
|
||||||
|
))}
|
||||||
|
{[80, 100, 120, 140, 160].map(y => (
|
||||||
|
<line key={y} x1="30" y1={y} x2="395" y2={y} />
|
||||||
|
))}
|
||||||
|
</g>}
|
||||||
|
|
||||||
|
{/* 포인트 클라우드 모드 */}
|
||||||
|
{isPoint && <g>
|
||||||
|
{vesselPoints.map(p => p && (
|
||||||
|
<circle key={p.i} cx={p.x} cy={p.y} r={p.r} fill={`rgba(6,182,212,${p.alpha})`} />
|
||||||
|
))}
|
||||||
|
</g>}
|
||||||
|
|
||||||
|
{/* 선수/선미 표시 */}
|
||||||
|
<text x="395" y="95" fill="rgba(6,182,212,0.3)" fontSize="8" fontFamily="var(--fM)">선수</text>
|
||||||
|
<text x="15" y="95" fill="rgba(6,182,212,0.3)" fontSize="8" fontFamily="var(--fM)">선미</text>
|
||||||
|
|
||||||
|
{/* 측정선 (3D 모드) */}
|
||||||
|
{viewMode === '3d' && <>
|
||||||
|
<line x1="30" y1="185" x2="395" y2="185" stroke="rgba(34,197,94,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
|
||||||
|
<text x="200" y="195" fill="rgba(34,197,94,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="middle">84.7m</text>
|
||||||
|
<line x1="405" y1="60" x2="405" y2="175" stroke="rgba(249,115,22,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
|
||||||
|
<text x="415" y="120" fill="rgba(249,115,22,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="start" transform="rotate(90, 415, 120)">14.2m</text>
|
||||||
|
</>}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* 처리중 오버레이 */}
|
||||||
|
{isProcessing && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-primary-cyan/40 text-xs font-mono animate-pulse">재구성 처리중...</div>
|
||||||
|
<div className="w-24 h-0.5 bg-bg-3 rounded-full mt-2 mx-auto overflow-hidden">
|
||||||
|
<div className="h-full bg-primary-cyan/40 rounded-full" style={{ width: '64%', animation: 'pulse 2s infinite' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pollution3DModel({ viewMode, status }: { viewMode: string; status: string }) {
|
||||||
|
const isProcessing = status === 'processing'
|
||||||
|
const isWire = viewMode === 'wire'
|
||||||
|
const isPoint = viewMode === 'point'
|
||||||
|
|
||||||
|
const [pollutionPoints] = useState(() =>
|
||||||
|
Array.from({ length: 400 }, (_, i) => {
|
||||||
|
const cx = 190, cy = 145, rx = 130, ry = 75
|
||||||
|
const angle = Math.random() * Math.PI * 2
|
||||||
|
const r = Math.sqrt(Math.random())
|
||||||
|
const x = cx + r * rx * Math.cos(angle)
|
||||||
|
const y = cy + r * ry * Math.sin(angle)
|
||||||
|
if (x < 40 || x > 340 || y < 50 || y > 230) return null
|
||||||
|
const dist = Math.sqrt(((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2)
|
||||||
|
const intensity = Math.max(0.1, 1 - dist)
|
||||||
|
const color = dist < 0.4 ? `rgba(239,68,68,${intensity * 0.7})` : dist < 0.7 ? `rgba(249,115,22,${intensity * 0.5})` : `rgba(234,179,8,${intensity * 0.3})`
|
||||||
|
const circleR = 0.6 + Math.random() * 1.2
|
||||||
|
return { i, x, y, r: circleR, color }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center" style={{ perspective: '800px' }}>
|
||||||
|
<div style={{ transform: 'rotateX(40deg) rotateY(-10deg)', transformStyle: 'preserve-3d', position: 'relative', width: '380px', height: '260px' }}>
|
||||||
|
<svg viewBox="0 0 380 260" width="380" height="260" style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }}>
|
||||||
|
{/* 해수면 그리드 */}
|
||||||
|
<g stroke="rgba(6,182,212,0.08)" strokeWidth="0.4">
|
||||||
|
{Array.from({ length: 15 }, (_, i) => <line key={`h${i}`} x1="0" y1={i * 20} x2="380" y2={i * 20} />)}
|
||||||
|
{Array.from({ length: 20 }, (_, i) => <line key={`v${i}`} x1={i * 20} y1="0" x2={i * 20} y2="260" />)}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* 유막 메인 형태 - 불규칙 blob */}
|
||||||
|
<path d="M 120 80 Q 80 90 70 120 Q 55 155 80 180 Q 100 205 140 210 Q 180 220 220 205 Q 270 195 300 170 Q 320 145 310 115 Q 300 85 270 75 Q 240 65 200 70 Q 160 68 120 80 Z"
|
||||||
|
fill={isWire || isPoint ? 'none' : 'rgba(239,68,68,0.08)'}
|
||||||
|
stroke={isProcessing ? 'rgba(239,68,68,0.15)' : 'rgba(239,68,68,0.45)'}
|
||||||
|
strokeWidth={isWire ? '0.8' : '1.5'} />
|
||||||
|
|
||||||
|
{/* 유막 두께 등고선 */}
|
||||||
|
<path d="M 155 100 Q 125 115 120 140 Q 115 165 135 180 Q 155 195 190 190 Q 230 185 255 165 Q 270 145 260 120 Q 250 100 225 95 Q 195 88 155 100 Z"
|
||||||
|
fill={isWire || isPoint ? 'none' : 'rgba(249,115,22,0.08)'}
|
||||||
|
stroke={isProcessing ? 'rgba(249,115,22,0.12)' : 'rgba(249,115,22,0.35)'}
|
||||||
|
strokeWidth="0.8" strokeDasharray={isWire ? '4 2' : 'none'} />
|
||||||
|
|
||||||
|
{/* 유막 최고 두께 핵심 */}
|
||||||
|
<path d="M 175 120 Q 160 130 165 150 Q 170 170 195 170 Q 220 168 230 150 Q 235 130 220 120 Q 205 110 175 120 Z"
|
||||||
|
fill={isWire || isPoint ? 'none' : 'rgba(239,68,68,0.15)'}
|
||||||
|
stroke={isProcessing ? 'rgba(239,68,68,0.15)' : 'rgba(239,68,68,0.5)'}
|
||||||
|
strokeWidth="0.8" />
|
||||||
|
|
||||||
|
{/* 확산 방향 화살표 */}
|
||||||
|
<g stroke="rgba(249,115,22,0.5)" strokeWidth="1" fill="rgba(249,115,22,0.5)">
|
||||||
|
<line x1="250" y1="140" x2="330" y2="120" />
|
||||||
|
<polygon points="330,120 322,115 324,123" />
|
||||||
|
<text x="335" y="122" fill="rgba(249,115,22,0.6)" fontSize="8" fontFamily="var(--fM)">ESE 0.3km/h</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* 와이어프레임 추가 등고선 */}
|
||||||
|
{(isWire || isPoint) && <g stroke="rgba(239,68,68,0.12)" strokeWidth="0.3">
|
||||||
|
<ellipse cx="190" cy="145" rx="140" ry="80" fill="none" />
|
||||||
|
<ellipse cx="190" cy="145" rx="100" ry="55" fill="none" />
|
||||||
|
<ellipse cx="190" cy="145" rx="60" ry="35" fill="none" />
|
||||||
|
</g>}
|
||||||
|
|
||||||
|
{/* 포인트 클라우드 */}
|
||||||
|
{isPoint && <g>
|
||||||
|
{pollutionPoints.map(p => p && (
|
||||||
|
<circle key={p.i} cx={p.x} cy={p.y} r={p.r} fill={p.color} />
|
||||||
|
))}
|
||||||
|
</g>}
|
||||||
|
|
||||||
|
{/* 두께 색상 범례 */}
|
||||||
|
{viewMode === '3d' && <>
|
||||||
|
<text x="165" y="148" fill="rgba(239,68,68,0.7)" fontSize="7" fontFamily="var(--fM)" textAnchor="middle">3.2mm</text>
|
||||||
|
<text x="130" y="165" fill="rgba(249,115,22,0.5)" fontSize="7" fontFamily="var(--fM)" textAnchor="middle">1.5mm</text>
|
||||||
|
<text x="95" y="130" fill="rgba(234,179,8,0.4)" fontSize="7" fontFamily="var(--fM)" textAnchor="middle">0.3mm</text>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{/* 측정선 (3D 모드) */}
|
||||||
|
{viewMode === '3d' && <>
|
||||||
|
<line x1="55" y1="240" x2="320" y2="240" stroke="rgba(34,197,94,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
|
||||||
|
<text x="187" y="252" fill="rgba(34,197,94,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="middle">1.24 km</text>
|
||||||
|
<line x1="25" y1="80" x2="25" y2="210" stroke="rgba(59,130,246,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
|
||||||
|
<text x="15" y="150" fill="rgba(59,130,246,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="middle" transform="rotate(-90, 15, 150)">0.68 km</text>
|
||||||
|
</>}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* 두께 색상 범례 바 */}
|
||||||
|
{viewMode === '3d' && !isProcessing && (
|
||||||
|
<div className="absolute bottom-0 right-2 flex items-center gap-1" style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fM)' }}>
|
||||||
|
<span>0mm</span>
|
||||||
|
<div style={{ width: '60px', height: '4px', borderRadius: '2px', background: 'linear-gradient(90deg, rgba(234,179,8,0.6), rgba(249,115,22,0.7), rgba(239,68,68,0.8))' }} />
|
||||||
|
<span>3.2mm</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isProcessing && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-status-red/40 text-xs font-mono animate-pulse">재구성 처리중...</div>
|
||||||
|
<div className="w-24 h-0.5 bg-bg-3 rounded-full mt-2 mx-auto overflow-hidden">
|
||||||
|
<div className="h-full bg-status-red/40 rounded-full" style={{ width: '52%', animation: 'pulse 2s infinite' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SensorAnalysis() {
|
||||||
|
const [subTab, setSubTab] = useState<'vessel' | 'pollution'>('vessel')
|
||||||
|
const [viewMode, setViewMode] = useState('3d')
|
||||||
|
const [selectedItem, setSelectedItem] = useState<ReconItem>(reconItems[1])
|
||||||
|
|
||||||
|
const filteredItems = reconItems.filter(r => r.type === (subTab === 'vessel' ? 'vessel' : 'pollution'))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
|
||||||
|
{/* Left Panel */}
|
||||||
|
<div className="w-[280px] bg-bg-1 border-r border-border flex flex-col overflow-auto">
|
||||||
|
{/* 3D Reconstruction List */}
|
||||||
|
<div className="p-2.5 px-3 border-b border-border">
|
||||||
|
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">📋 3D 재구성 완료 목록</div>
|
||||||
|
<div className="flex gap-1 mb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSubTab('vessel')}
|
||||||
|
className={`flex-1 py-1.5 text-center text-[9px] font-semibold rounded cursor-pointer border transition-colors font-korean ${
|
||||||
|
subTab === 'vessel'
|
||||||
|
? 'text-primary-cyan bg-[rgba(6,182,212,0.08)] border-primary-cyan/20'
|
||||||
|
: 'text-text-3 bg-bg-0 border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🚢 선박
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSubTab('pollution')}
|
||||||
|
className={`flex-1 py-1.5 text-center text-[9px] font-semibold rounded cursor-pointer border transition-colors font-korean ${
|
||||||
|
subTab === 'pollution'
|
||||||
|
? 'text-primary-cyan bg-[rgba(6,182,212,0.08)] border-primary-cyan/20'
|
||||||
|
: 'text-text-3 bg-bg-0 border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🛢️ 오염원
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{filteredItems.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => setSelectedItem(item)}
|
||||||
|
className={`flex items-center gap-2 px-2 py-2 rounded-sm cursor-pointer transition-colors border ${
|
||||||
|
selectedItem.id === item.id
|
||||||
|
? 'bg-[rgba(6,182,212,0.08)] border-primary-cyan/20'
|
||||||
|
: 'border-transparent hover:bg-white/[0.02]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[10px] font-bold text-text-1 font-korean">{item.name}</div>
|
||||||
|
<div className="text-[8px] text-text-3 font-mono">{item.id} · {item.points} pts</div>
|
||||||
|
</div>
|
||||||
|
<span className={`text-[8px] font-semibold ${item.status === 'complete' ? 'text-status-green' : 'text-status-orange'}`}>
|
||||||
|
{item.status === 'complete' ? '✅ 완료' : '⏳ 처리중'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source Images */}
|
||||||
|
<div className="p-2.5 px-3 flex-1 min-h-0 flex flex-col">
|
||||||
|
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">📹 촬영 원본</div>
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
{[
|
||||||
|
{ label: 'D-01 정면', sensor: '광학', color: 'text-primary-blue' },
|
||||||
|
{ label: 'D-02 좌현', sensor: 'IR', color: 'text-status-red' },
|
||||||
|
{ label: 'D-03 우현', sensor: '광학', color: 'text-primary-purple' },
|
||||||
|
{ label: 'D-02 상부', sensor: 'IR', color: 'text-status-red' },
|
||||||
|
].map((src, i) => (
|
||||||
|
<div key={i} className="relative rounded-sm bg-bg-0 border border-border overflow-hidden aspect-square">
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #0c1624, #1a1a2e)' }}>
|
||||||
|
<div className="text-text-3/10 text-xs font-mono">{src.label.split(' ')[0]}</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1.5 py-1 flex justify-between text-[8px] text-text-3 font-korean">
|
||||||
|
<span>{src.label}</span>
|
||||||
|
<span className={src.color}>{src.sensor}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center Panel - 3D Canvas */}
|
||||||
|
<div className="flex-1 relative bg-bg-0 border-x border-border flex items-center justify-center overflow-hidden">
|
||||||
|
{/* Simulated 3D viewport */}
|
||||||
|
<div className="absolute inset-0" style={{ background: 'radial-gradient(ellipse at 50% 50%, #0c1a2e, #060c18)' }}>
|
||||||
|
{/* Grid floor */}
|
||||||
|
<div className="absolute inset-0 opacity-[0.06]" style={{ backgroundImage: 'linear-gradient(rgba(6,182,212,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(6,182,212,0.5) 1px, transparent 1px)', backgroundSize: '40px 40px', transform: 'perspective(500px) rotateX(55deg)', transformOrigin: 'center 80%' }} />
|
||||||
|
|
||||||
|
{/* 3D Model Visualization */}
|
||||||
|
{selectedItem.type === 'vessel' ? (
|
||||||
|
<Vessel3DModel viewMode={viewMode} status={selectedItem.status} />
|
||||||
|
) : (
|
||||||
|
<Pollution3DModel viewMode={viewMode} status={selectedItem.status} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Axis indicator */}
|
||||||
|
<div className="absolute bottom-16 left-4" style={{ fontSize: '9px', fontFamily: 'var(--fM)' }}>
|
||||||
|
<div style={{ color: '#ef4444' }}>X →</div>
|
||||||
|
<div style={{ color: '#22c55e' }}>Y ↑</div>
|
||||||
|
<div style={{ color: '#3b82f6' }}>Z ⊙</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div className="absolute top-3 left-3 z-[2]">
|
||||||
|
<div className="text-[10px] font-bold text-text-3 uppercase tracking-wider">3D Vessel Analysis</div>
|
||||||
|
<div className="text-[13px] font-bold text-primary-cyan my-1 font-korean">{selectedItem.name} 정밀분석</div>
|
||||||
|
<div className="text-[9px] text-text-3 font-mono">34.58°N, 129.30°E · {selectedItem.status === 'complete' ? '재구성 완료' : '처리중'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Mode Buttons */}
|
||||||
|
<div className="absolute top-3 right-3 flex gap-1 z-[2]">
|
||||||
|
{[
|
||||||
|
{ id: '3d', label: '3D모델' },
|
||||||
|
{ id: 'point', label: '포인트클라우드' },
|
||||||
|
{ id: 'wire', label: '와이어프레임' },
|
||||||
|
].map(m => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => setViewMode(m.id)}
|
||||||
|
className={`px-2.5 py-1.5 text-[10px] font-semibold rounded-sm cursor-pointer border font-korean transition-colors ${
|
||||||
|
viewMode === m.id
|
||||||
|
? 'bg-[rgba(6,182,212,0.2)] border-primary-cyan/50 text-primary-cyan'
|
||||||
|
: 'bg-black/40 border-primary-cyan/20 text-text-3 hover:bg-black/60 hover:border-primary-cyan/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Stats */}
|
||||||
|
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-3 bg-black/50 backdrop-blur-lg px-4 py-2 rounded-md border z-[2]" style={{ borderColor: 'rgba(6,182,212,0.15)' }}>
|
||||||
|
{[
|
||||||
|
{ value: selectedItem.points, label: '포인트' },
|
||||||
|
{ value: selectedItem.polygons, label: '폴리곤' },
|
||||||
|
{ value: '3', label: '시점' },
|
||||||
|
{ value: selectedItem.coverage, label: '커버리지' },
|
||||||
|
{ value: '0.023m', label: 'RMS오차' },
|
||||||
|
].map((s, i) => (
|
||||||
|
<div key={i} className="text-center">
|
||||||
|
<div className="font-mono font-bold text-sm text-primary-cyan">{s.value}</div>
|
||||||
|
<div className="text-[8px] text-text-3 mt-0.5 font-korean">{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel - Analysis Details */}
|
||||||
|
<div className="w-[270px] bg-bg-1 border-l border-border flex flex-col overflow-auto">
|
||||||
|
{/* Ship/Pollution Info */}
|
||||||
|
<div className="p-2.5 px-3 border-b border-border">
|
||||||
|
<div className="text-[10px] font-bold text-text-3 mb-2 uppercase tracking-wider">📊 분석 정보</div>
|
||||||
|
<div className="flex flex-col gap-1.5 text-[10px]">
|
||||||
|
{(selectedItem.type === 'vessel' ? [
|
||||||
|
['대상', selectedItem.name],
|
||||||
|
['선종 추정', '일반화물선 (추정)'],
|
||||||
|
['길이', '약 85m'],
|
||||||
|
['폭', '약 14m'],
|
||||||
|
['AIS 상태', 'OFF (미식별)'],
|
||||||
|
['최초 탐지', '2026-01-18 14:20'],
|
||||||
|
['촬영 시점', '3 시점 (정면/좌현/우현)'],
|
||||||
|
['센서', '광학 4K + IR 열화상'],
|
||||||
|
] : [
|
||||||
|
['대상', selectedItem.name],
|
||||||
|
['유형', '유류 오염'],
|
||||||
|
['추정 면적', '0.42 km²'],
|
||||||
|
['추정 유출량', '12.6 kL'],
|
||||||
|
['유종', 'B-C유 (추정)'],
|
||||||
|
['최초 탐지', '2026-01-18 13:50'],
|
||||||
|
['확산 속도', '0.3 km/h (ESE 방향)'],
|
||||||
|
]).map(([k, v], i) => (
|
||||||
|
<div key={i} className="flex justify-between items-start">
|
||||||
|
<span className="text-text-3 font-korean">{k}</span>
|
||||||
|
<span className="font-mono font-semibold text-text-1 text-right ml-2">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Detection Results */}
|
||||||
|
<div className="p-2.5 px-3 border-b border-border">
|
||||||
|
<div className="text-[10px] font-bold text-text-3 mb-2 uppercase tracking-wider">🤖 AI 탐지 결과</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{(selectedItem.type === 'vessel' ? [
|
||||||
|
{ label: '선박 식별', confidence: 94, color: 'bg-status-green' },
|
||||||
|
{ label: '선종 분류', confidence: 78, color: 'bg-status-yellow' },
|
||||||
|
{ label: '손상 감지', confidence: 45, color: 'bg-status-orange' },
|
||||||
|
{ label: '화물 분석', confidence: 62, color: 'bg-status-yellow' },
|
||||||
|
] : [
|
||||||
|
{ label: '유막 탐지', confidence: 97, color: 'bg-status-green' },
|
||||||
|
{ label: '유종 분류', confidence: 85, color: 'bg-status-green' },
|
||||||
|
{ label: '두께 추정', confidence: 72, color: 'bg-status-yellow' },
|
||||||
|
{ label: '확산 예측', confidence: 68, color: 'bg-status-orange' },
|
||||||
|
]).map((r, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="flex justify-between text-[9px] mb-0.5">
|
||||||
|
<span className="text-text-3 font-korean">{r.label}</span>
|
||||||
|
<span className="font-mono font-semibold text-text-1">{r.confidence}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1 bg-bg-0 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full ${r.color}`} style={{ width: `${r.confidence}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comparison / Measurements */}
|
||||||
|
<div className="p-2.5 px-3 border-b border-border">
|
||||||
|
<div className="text-[10px] font-bold text-text-3 mb-2 uppercase tracking-wider">📐 3D 측정값</div>
|
||||||
|
<div className="flex flex-col gap-1 text-[10px]">
|
||||||
|
{(selectedItem.type === 'vessel' ? [
|
||||||
|
['전장 (LOA)', '84.7 m'],
|
||||||
|
['형폭 (Breadth)', '14.2 m'],
|
||||||
|
['건현 (Freeboard)', '3.8 m'],
|
||||||
|
['흘수 (Draft)', '5.6 m (추정)'],
|
||||||
|
['마스트 높이', '22.3 m'],
|
||||||
|
] : [
|
||||||
|
['유막 면적', '0.42 km²'],
|
||||||
|
['최대 길이', '1.24 km'],
|
||||||
|
['최대 폭', '0.68 km'],
|
||||||
|
['평균 두께', '0.8 mm'],
|
||||||
|
['최대 두께', '3.2 mm'],
|
||||||
|
]).map(([k, v], i) => (
|
||||||
|
<div key={i} className="flex justify-between px-2 py-1 bg-bg-0 rounded">
|
||||||
|
<span className="text-text-3 font-korean">{k}</span>
|
||||||
|
<span className="font-mono font-semibold text-primary-cyan">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="p-2.5 px-3">
|
||||||
|
<button className="w-full py-2.5 rounded-sm text-xs font-bold font-korean text-white border-none cursor-pointer mb-2" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>
|
||||||
|
📊 상세 보고서 생성
|
||||||
|
</button>
|
||||||
|
<button className="w-full py-2 border border-border bg-bg-3 text-text-2 rounded-sm text-[11px] font-semibold font-korean cursor-pointer hover:bg-bg-hover transition-colors">
|
||||||
|
📥 3D 모델 다운로드
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
332
frontend/src/tabs/assets/components/AssetManagement.tsx
Normal file
332
frontend/src/tabs/assets/components/AssetManagement.tsx
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import type { AssetOrg } from './assetTypes'
|
||||||
|
import { typeTagCls } from './assetTypes'
|
||||||
|
import { organizations } from './assetMockData'
|
||||||
|
import AssetMap from './AssetMap'
|
||||||
|
|
||||||
|
function AssetManagement() {
|
||||||
|
const [viewMode, setViewMode] = useState<'list' | 'map'>('list')
|
||||||
|
const [selectedOrg, setSelectedOrg] = useState<AssetOrg>(organizations[0])
|
||||||
|
const [detailTab, setDetailTab] = useState<'equip' | 'material' | 'contact'>('equip')
|
||||||
|
const [regionFilter, setRegionFilter] = useState('all')
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [typeFilterVal, setTypeFilterVal] = useState('all')
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const pageSize = 15
|
||||||
|
|
||||||
|
const filtered = organizations.filter(o => {
|
||||||
|
if (regionFilter !== 'all' && !o.jurisdiction.includes(regionFilter)) return false
|
||||||
|
if (typeFilterVal !== 'all' && o.type !== typeFilterVal) return false
|
||||||
|
if (searchTerm && !o.name.includes(searchTerm) && !o.address.includes(searchTerm)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize))
|
||||||
|
const safePage = Math.min(currentPage, totalPages)
|
||||||
|
const paged = filtered.slice((safePage - 1) * pageSize, safePage * pageSize)
|
||||||
|
|
||||||
|
// 필터 변경 시 첫 페이지로
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
useEffect(() => { setCurrentPage(1) }, [regionFilter, typeFilterVal, searchTerm])
|
||||||
|
|
||||||
|
const regionShort = (j: string) => {
|
||||||
|
if (j.includes('중부')) return '중부청'
|
||||||
|
if (j.includes('서해')) return '서해청'
|
||||||
|
if (j.includes('남해')) return '남해청'
|
||||||
|
if (j.includes('동해')) return '동해청'
|
||||||
|
if (j.includes('중앙')) return '중특단'
|
||||||
|
return '제주청'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* View Switcher & Filters */}
|
||||||
|
<div className="flex items-center justify-between mb-3 pb-3 border-b border-border">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={`px-3 py-1.5 text-[11px] font-semibold rounded-sm font-korean transition-colors ${
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30'
|
||||||
|
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
📋 방제자산리스트
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('map')}
|
||||||
|
className={`px-3 py-1.5 text-[11px] font-semibold rounded-sm font-korean transition-colors ${
|
||||||
|
viewMode === 'map'
|
||||||
|
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30'
|
||||||
|
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🗺 지도 보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5 items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="기관명 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
|
className="prd-i w-40 py-1.5 px-2.5"
|
||||||
|
/>
|
||||||
|
<select value={regionFilter} onChange={e => setRegionFilter(e.target.value)} className="prd-i w-[100px] py-1.5 px-2">
|
||||||
|
<option value="all">전체 관할</option>
|
||||||
|
<option value="남해">남해청</option>
|
||||||
|
<option value="서해">서해청</option>
|
||||||
|
<option value="중부">중부청</option>
|
||||||
|
<option value="동해">동해청</option>
|
||||||
|
<option value="제주">제주청</option>
|
||||||
|
</select>
|
||||||
|
<select value={typeFilterVal} onChange={e => setTypeFilterVal(e.target.value)} className="prd-i w-[100px] py-1.5 px-2">
|
||||||
|
<option value="all">전체 유형</option>
|
||||||
|
<option value="해경관할">해경관할</option>
|
||||||
|
<option value="해경경찰서">해경경찰서</option>
|
||||||
|
<option value="파출소">파출소</option>
|
||||||
|
<option value="관련기관">관련기관</option>
|
||||||
|
<option value="해양환경공단">해양환경공단</option>
|
||||||
|
<option value="업체">업체</option>
|
||||||
|
<option value="지자체">지자체</option>
|
||||||
|
<option value="기름저장시설">기름저장시설</option>
|
||||||
|
<option value="정유사">정유사</option>
|
||||||
|
<option value="해군">해군</option>
|
||||||
|
<option value="기타">기타</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewMode === 'list' ? (
|
||||||
|
/* ── LIST VIEW ── */
|
||||||
|
<div className="flex-1 bg-bg-3 border border-border rounded-md overflow-hidden flex flex-col">
|
||||||
|
<div className="flex-1">
|
||||||
|
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: '3.5%' }} />
|
||||||
|
<col style={{ width: '7%' }} />
|
||||||
|
<col style={{ width: '7%' }} />
|
||||||
|
<col style={{ width: '12%' }} />
|
||||||
|
<col />
|
||||||
|
<col style={{ width: '8%' }} />
|
||||||
|
<col style={{ width: '7%' }} />
|
||||||
|
<col style={{ width: '7%' }} />
|
||||||
|
<col style={{ width: '5%' }} />
|
||||||
|
<col style={{ width: '5%' }} />
|
||||||
|
<col style={{ width: '5%' }} />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-bg-0">
|
||||||
|
{['번호', '유형', '관할청', '기관명', '주소', '방제선', '유회수기', '이송펌프', '방제차량', '살포장치', '총자산'].map((h, i) => (
|
||||||
|
<th key={i} className={`px-2.5 py-2.5 text-[10px] font-bold text-text-2 font-korean border-b border-border ${[0,5,6,7,8,9,10].includes(i) ? 'text-center' : ''}`}>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paged.map((org, idx) => (
|
||||||
|
<tr
|
||||||
|
key={org.id}
|
||||||
|
className={`border-b border-border/50 hover:bg-[rgba(255,255,255,0.02)] cursor-pointer transition-colors ${
|
||||||
|
selectedOrg.id === org.id ? 'bg-[rgba(6,182,212,0.03)]' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => { setSelectedOrg(org); setViewMode('map') }}
|
||||||
|
>
|
||||||
|
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{(safePage - 1) * pageSize + idx + 1}</td>
|
||||||
|
<td className="px-2.5 py-2">
|
||||||
|
<span className={`text-[9px] px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}>{org.type}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2.5 py-2 text-[10px] font-semibold font-korean">{regionShort(org.jurisdiction)}</td>
|
||||||
|
<td className="px-2.5 py-2 text-[10px] font-semibold text-primary-cyan font-korean cursor-pointer truncate">{org.name}</td>
|
||||||
|
<td className="px-2.5 py-2 text-[10px] text-text-3 font-korean truncate">{org.address}</td>
|
||||||
|
<td className="px-2.5 py-2 text-center font-mono text-[10px] font-semibold">{org.vessel}척</td>
|
||||||
|
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.skimmer}대</td>
|
||||||
|
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.pump}대</td>
|
||||||
|
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.vehicle}대</td>
|
||||||
|
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.sprayer}대</td>
|
||||||
|
<td className="px-2.5 py-2 text-center font-bold text-primary-cyan font-mono text-[10px]">{org.totalAssets}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-center gap-4 px-4 py-2.5 border-t border-border bg-bg-0">
|
||||||
|
<span className="text-[10px] text-text-3 font-korean">
|
||||||
|
전체 <span className="font-semibold text-text-2">{filtered.length}</span>건 중{' '}
|
||||||
|
<span className="font-semibold text-text-2">{(safePage - 1) * pageSize + 1}-{Math.min(safePage * pageSize, filtered.length)}</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
disabled={safePage <= 1}
|
||||||
|
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||||
|
>«</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={safePage <= 1}
|
||||||
|
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||||
|
>‹</button>
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setCurrentPage(p)}
|
||||||
|
className={`w-6 h-6 text-[10px] font-bold rounded transition-colors cursor-pointer ${
|
||||||
|
p === safePage
|
||||||
|
? 'bg-primary-cyan/20 text-primary-cyan border border-primary-cyan/40'
|
||||||
|
: 'border border-border bg-bg-3 text-text-3 hover:bg-bg-hover'
|
||||||
|
}`}
|
||||||
|
>{p}</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={safePage >= totalPages}
|
||||||
|
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||||
|
>›</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
disabled={safePage >= totalPages}
|
||||||
|
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||||
|
>»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ── MAP VIEW ── */
|
||||||
|
<div className="flex-1 flex overflow-hidden rounded-md border border-border">
|
||||||
|
{/* Map */}
|
||||||
|
<div className="flex-1 relative overflow-hidden">
|
||||||
|
<AssetMap
|
||||||
|
organizations={filtered}
|
||||||
|
selectedOrg={selectedOrg}
|
||||||
|
onSelectOrg={setSelectedOrg}
|
||||||
|
regionFilter={regionFilter}
|
||||||
|
onRegionFilterChange={setRegionFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Detail Panel */}
|
||||||
|
<aside className="w-[340px] min-w-[340px] bg-bg-1 border-l border-border flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b border-border">
|
||||||
|
<div className="text-sm font-bold mb-1 font-korean">{selectedOrg.name}</div>
|
||||||
|
<div className="text-[11px] text-text-2 font-semibold font-korean mb-1">{selectedOrg.type} · {regionShort(selectedOrg.jurisdiction)} · {selectedOrg.area}</div>
|
||||||
|
<div className="text-[11px] text-text-3 font-korean">{selectedOrg.address}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-tabs */}
|
||||||
|
<div className="flex border-b border-border">
|
||||||
|
{(['equip', 'material', 'contact'] as const).map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setDetailTab(t)}
|
||||||
|
className={`flex-1 py-2.5 text-center text-[11px] font-semibold font-korean border-b-2 transition-colors cursor-pointer ${
|
||||||
|
detailTab === t
|
||||||
|
? 'text-primary-cyan border-primary-cyan'
|
||||||
|
: 'text-text-3 border-transparent hover:text-text-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === 'equip' ? '장비' : t === 'material' ? '자재' : '연락처'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-3.5 scrollbar-thin">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="grid grid-cols-3 gap-1.5 mb-3">
|
||||||
|
{[
|
||||||
|
{ value: `${selectedOrg.vessel}척`, label: '방제선' },
|
||||||
|
{ value: `${selectedOrg.skimmer}대`, label: '유회수기' },
|
||||||
|
{ value: String(selectedOrg.totalAssets), label: '총 자산' },
|
||||||
|
].map((s, i) => (
|
||||||
|
<div key={i} className="bg-bg-3 border border-border rounded-sm p-2.5 text-center">
|
||||||
|
<div className="text-lg font-bold text-primary-cyan font-mono">{s.value}</div>
|
||||||
|
<div className="text-[9px] text-text-3 mt-0.5 font-korean">{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailTab === 'equip' && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{selectedOrg.equipment.length > 0 ? selectedOrg.equipment.map((cat, ci) => {
|
||||||
|
const unitMap: Record<string, string> = {
|
||||||
|
'방제선': '척', '유회수기': '대', '비치크리너': '대', '이송펌프': '대', '방제차량': '대',
|
||||||
|
'해안운반차': '대', '고압세척기': '대', '저압세척기': '대', '동력분무기': '대', '유량계측기': '대',
|
||||||
|
'방제창고': '개소', '발전기': '대', '현장지휘소': '개', '지원장비': '대', '장비부품': '개',
|
||||||
|
'경비함정방제': '대', '살포장치': '대',
|
||||||
|
}
|
||||||
|
const unit = unitMap[cat.category] || '개'
|
||||||
|
return (
|
||||||
|
<div key={ci} className="flex items-center justify-between px-2.5 py-2 bg-bg-3 border border-border rounded-sm hover:bg-bg-hover transition-colors">
|
||||||
|
<span className="text-[11px] font-semibold flex items-center gap-1.5 font-korean">
|
||||||
|
{cat.icon} {cat.category}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-bold font-mono"><span className="text-primary-cyan">{cat.count}</span><span className="text-text-3 font-normal ml-0.5">{unit}</span></span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}) : (
|
||||||
|
<div className="text-center text-text-3 text-xs py-8 font-korean">상세 장비 데이터가 없습니다.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detailTab === 'material' && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{[
|
||||||
|
['방제선', `${selectedOrg.vessel}척`],
|
||||||
|
['유회수기', `${selectedOrg.skimmer}대`],
|
||||||
|
['이송펌프', `${selectedOrg.pump}대`],
|
||||||
|
['방제차량', `${selectedOrg.vehicle}대`],
|
||||||
|
['살포장치', `${selectedOrg.sprayer}대`],
|
||||||
|
['총 자산', `${selectedOrg.totalAssets}건`],
|
||||||
|
].map(([k, v], i) => (
|
||||||
|
<div key={i} className="flex justify-between px-2.5 py-2 bg-bg-0 rounded text-[11px]">
|
||||||
|
<span className="text-text-3 font-korean">{k}</span>
|
||||||
|
<span className="font-mono font-semibold text-text-1">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detailTab === 'contact' && (
|
||||||
|
<div className="bg-bg-3 border border-border rounded-sm p-3">
|
||||||
|
{selectedOrg.contacts.length > 0 ? selectedOrg.contacts.map((c, i) => (
|
||||||
|
<div key={i} className="flex flex-col gap-1 mb-3 last:mb-0">
|
||||||
|
{[
|
||||||
|
['기관/업체', c.name],
|
||||||
|
['연락처', c.phone],
|
||||||
|
].map(([k, v], j) => (
|
||||||
|
<div key={j} className="flex justify-between py-1 text-[11px]">
|
||||||
|
<span className="text-text-3 font-korean">{k}</span>
|
||||||
|
<span className="font-mono text-text-1">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{i < selectedOrg.contacts.length - 1 && <div className="border-t border-border my-1" />}
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="text-center text-text-3 text-xs py-4 font-korean">연락처 정보가 없습니다.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Actions */}
|
||||||
|
<div className="p-3.5 border-t border-border flex gap-2">
|
||||||
|
<button className="flex-1 py-2.5 rounded-sm text-xs font-semibold font-korean text-white border-none cursor-pointer" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>
|
||||||
|
📥 다운로드
|
||||||
|
</button>
|
||||||
|
<button className="flex-1 py-2.5 rounded-sm text-xs font-semibold font-korean bg-bg-3 border border-border text-text-2 cursor-pointer hover:bg-bg-hover transition-colors">
|
||||||
|
✏ 수정
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AssetManagement
|
||||||
161
frontend/src/tabs/assets/components/AssetMap.tsx
Normal file
161
frontend/src/tabs/assets/components/AssetMap.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import L from 'leaflet'
|
||||||
|
import 'leaflet/dist/leaflet.css'
|
||||||
|
import type { AssetOrg } from './assetTypes'
|
||||||
|
import { typeColor } from './assetTypes'
|
||||||
|
|
||||||
|
interface AssetMapProps {
|
||||||
|
organizations: AssetOrg[]
|
||||||
|
selectedOrg: AssetOrg
|
||||||
|
onSelectOrg: (o: AssetOrg) => void
|
||||||
|
regionFilter: string
|
||||||
|
onRegionFilterChange: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssetMap({
|
||||||
|
organizations: orgs,
|
||||||
|
selectedOrg,
|
||||||
|
onSelectOrg,
|
||||||
|
regionFilter,
|
||||||
|
onRegionFilterChange,
|
||||||
|
}: AssetMapProps) {
|
||||||
|
const mapContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const mapRef = useRef<L.Map | null>(null)
|
||||||
|
const markersRef = useRef<L.LayerGroup | null>(null)
|
||||||
|
|
||||||
|
// Initialize map once
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapContainerRef.current || mapRef.current) return
|
||||||
|
|
||||||
|
const map = L.map(mapContainerRef.current, {
|
||||||
|
center: [35.9, 127.8],
|
||||||
|
zoom: 7,
|
||||||
|
zoomControl: false,
|
||||||
|
attributionControl: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Dark-themed OpenStreetMap tiles
|
||||||
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(map)
|
||||||
|
|
||||||
|
L.control.zoom({ position: 'topright' }).addTo(map)
|
||||||
|
L.control.attribution({ position: 'bottomright' }).addAttribution(
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>'
|
||||||
|
).addTo(map)
|
||||||
|
|
||||||
|
mapRef.current = map
|
||||||
|
markersRef.current = L.layerGroup().addTo(map)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.remove()
|
||||||
|
mapRef.current = null
|
||||||
|
markersRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Update markers when orgs or selectedOrg changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current || !markersRef.current) return
|
||||||
|
|
||||||
|
markersRef.current.clearLayers()
|
||||||
|
|
||||||
|
orgs.forEach(org => {
|
||||||
|
const isSelected = selectedOrg.id === org.id
|
||||||
|
const tc = typeColor(org.type)
|
||||||
|
const radius = org.pinSize === 'hq' ? 14 : org.pinSize === 'lg' ? 10 : 7
|
||||||
|
|
||||||
|
const cm = L.circleMarker([org.lat, org.lng], {
|
||||||
|
radius: isSelected ? radius + 4 : radius,
|
||||||
|
fillColor: isSelected ? tc.selected : tc.bg,
|
||||||
|
color: isSelected ? tc.selected : tc.border,
|
||||||
|
weight: isSelected ? 3 : 2,
|
||||||
|
fillOpacity: isSelected ? 0.9 : 0.7,
|
||||||
|
})
|
||||||
|
cm.bindTooltip(
|
||||||
|
`<div style="text-align:center;font-family:'Noto Sans KR',sans-serif;">
|
||||||
|
<div style="font-weight:700;font-size:11px;">${org.name}</div>
|
||||||
|
<div style="font-size:10px;opacity:0.7;">${org.type} · 자산 ${org.totalAssets}건</div>
|
||||||
|
</div>`,
|
||||||
|
{ permanent: org.pinSize === 'hq' || isSelected, direction: 'top', offset: [0, -radius - 2], className: 'asset-map-tooltip' }
|
||||||
|
)
|
||||||
|
cm.on('click', () => onSelectOrg(org))
|
||||||
|
|
||||||
|
markersRef.current!.addLayer(cm)
|
||||||
|
})
|
||||||
|
}, [orgs, selectedOrg, onSelectOrg])
|
||||||
|
|
||||||
|
// Pan to selected org
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current) return
|
||||||
|
mapRef.current.flyTo([selectedOrg.lat, selectedOrg.lng], 10, { duration: 0.8 })
|
||||||
|
}, [selectedOrg])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full relative">
|
||||||
|
<style>{`
|
||||||
|
.asset-map-tooltip {
|
||||||
|
background: rgba(15,21,36,0.92) !important;
|
||||||
|
border: 1px solid rgba(30,42,66,0.8) !important;
|
||||||
|
color: #e4e8f1 !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
padding: 4px 8px !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.4) !important;
|
||||||
|
}
|
||||||
|
.asset-map-tooltip::before {
|
||||||
|
border-top-color: rgba(15,21,36,0.92) !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div ref={mapContainerRef} className="w-full h-full" />
|
||||||
|
|
||||||
|
{/* Region filter overlay */}
|
||||||
|
<div className="absolute top-3 left-3 z-[1000] flex gap-1">
|
||||||
|
{[
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: '남해', label: '남해청' },
|
||||||
|
{ value: '서해', label: '서해청' },
|
||||||
|
{ value: '중부', label: '중부청' },
|
||||||
|
{ value: '동해', label: '동해청' },
|
||||||
|
{ value: '제주', label: '제주청' },
|
||||||
|
].map(r => (
|
||||||
|
<button
|
||||||
|
key={r.value}
|
||||||
|
onClick={() => onRegionFilterChange(r.value)}
|
||||||
|
className={`px-2.5 py-1.5 text-[10px] font-bold rounded font-korean transition-colors ${
|
||||||
|
regionFilter === r.value
|
||||||
|
? 'bg-primary-cyan/20 text-primary-cyan border border-primary-cyan/40'
|
||||||
|
: 'bg-bg-0/80 text-text-2 border border-border hover:bg-bg-hover/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend overlay */}
|
||||||
|
<div className="absolute bottom-3 left-3 z-[1000] bg-bg-0/90 border border-border rounded-sm p-2.5 backdrop-blur-sm">
|
||||||
|
<div className="text-[9px] text-text-3 font-bold mb-1.5 font-korean">범례</div>
|
||||||
|
{[
|
||||||
|
{ color: '#06b6d4', label: '해경관할' },
|
||||||
|
{ color: '#3b82f6', label: '해경경찰서' },
|
||||||
|
{ color: '#22c55e', label: '파출소' },
|
||||||
|
{ color: '#a855f7', label: '관련기관' },
|
||||||
|
{ color: '#14b8a6', label: '해양환경공단' },
|
||||||
|
{ color: '#f59e0b', label: '업체' },
|
||||||
|
{ color: '#ec4899', label: '지자체' },
|
||||||
|
{ color: '#8b5cf6', label: '기름저장시설' },
|
||||||
|
{ color: '#0d9488', label: '정유사' },
|
||||||
|
{ color: '#64748b', label: '해군' },
|
||||||
|
{ color: '#6b7280', label: '기타' },
|
||||||
|
].map((item, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1.5 mb-0.5 last:mb-0">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full inline-block flex-shrink-0" style={{ background: item.color }} />
|
||||||
|
<span className="text-[10px] text-text-2 font-korean">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AssetMap
|
||||||
255
frontend/src/tabs/assets/components/AssetTheory.tsx
Normal file
255
frontend/src/tabs/assets/components/AssetTheory.tsx
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
interface TheoryItem {
|
||||||
|
title: string
|
||||||
|
source: string
|
||||||
|
desc: string
|
||||||
|
tags?: { label: string; color: string }[]
|
||||||
|
highlight?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TheorySection {
|
||||||
|
icon: string
|
||||||
|
title: string
|
||||||
|
color: string
|
||||||
|
bgTint: string
|
||||||
|
items: TheoryItem[]
|
||||||
|
dividerAfter?: number
|
||||||
|
dividerLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEORY_SECTIONS: TheorySection[] = [
|
||||||
|
{
|
||||||
|
icon: '🚢', title: '방제선 성능 기준', color: 'var(--blue)', bgTint: 'rgba(59,130,246,.08)',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: '해양경찰청 방제선 성능기준 고시',
|
||||||
|
source: '해양경찰청 고시 제2022-11호 | 방제선·방제정 등급별 회수용량·속력·펌프사양 기준 정의',
|
||||||
|
desc: '1~5등급 방제선 기준 · 회수능력(㎥/h) · 오일펜스 전장 탑재량 · WING 자산 등급 필터링 근거',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'IMO OPRC 1990 — 방제자원 비축 기준',
|
||||||
|
source: 'International Convention on Oil Pollution Preparedness, Response and Co-operation | IMO, 1990',
|
||||||
|
desc: '국가 방제역량 비축 최저 기준 · 항만별 Tier 1/2/3 대응자원 분류 · 국내 방제자원 DB 설계 기초',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '해양오염방제업 등록기준 (해양환경관리법 시행규칙)',
|
||||||
|
source: '해양수산부령 | 별표 9 — 방제업 종류별 방제선·기자재 보유기준',
|
||||||
|
desc: '제1종·제2종 방제업 자산 보유기준 · 오일펜스 전장·회수기 용량 법적 최저기준 · WING 자산현황 적법성 검증 기준',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🪢', title: '오일펜스·흡착재 규격', color: 'var(--boom, #f59e0b)', bgTint: 'rgba(245,158,11,.08)',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: 'ASTM F625 — Standard Guide for Selecting Mechanical Oil Spill Equipment',
|
||||||
|
source: 'ASTM International | 오일펜스·회수기·흡착재 성능시험·선정 기준 가이드',
|
||||||
|
desc: '오일펜스 인장강도·부력기준 · 흡착포 흡수율(g/g) 측정법 · WING 자산 성능등급 분류 참조 기준',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '기름오염방제시 오일펜스 사용지침 (ITOPF TIP 03 한국어판)',
|
||||||
|
source: 'ITOPF | 해양경찰청·해양환경관리공단 번역, 2011',
|
||||||
|
desc: '커튼형·펜스형·해안용 규격분류 · 유속별 운용한계(0.7~3.0 kt) · 힘 계산식 F=100·A·V² · 앵커 파지력 기준표',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '⚙️', title: '방제자원 배치·동원 이론', color: 'var(--purple)', bgTint: 'rgba(168,85,247,.08)',
|
||||||
|
dividerAfter: 2, dividerLabel: '📐 최적화 수리모델 참고문헌',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: 'An Emergency Scheduling Model for Oil Containment Boom in Dynamically Changing Marine Oil Spills',
|
||||||
|
source: 'Xu, Y. et al. | Ningbo Univ. | Systems 2025, 13, 716 · DOI: 10.3390/systems13080716',
|
||||||
|
desc: 'IMOGWO 다목적 최적화 · 스케줄링 시간+경제·생태손실 동시 최소화 · 동적 오일필름 기반 방제정 라우팅',
|
||||||
|
highlight: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Dynamic Resource Allocation to Support Oil Spill Response Planning',
|
||||||
|
source: 'Garrett, R.A. et al. | Eur. J. Oper. Res. 257:272–286, 2017',
|
||||||
|
desc: '불확실성 하 방제자원 동적 배분 최적화 · 시나리오별 비축량 산정 · WING 자산 우선순위 배치 알고리즘 이론 기반',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '해양오염방제 국가긴급방제계획 (NOSCP)',
|
||||||
|
source: '해양경찰청 | 국가긴급방제계획, 2023년판',
|
||||||
|
desc: 'Tier 3급 대형사고 자원 동원체계 · 기관별 역할분담·지휘계통 · WING 방제자산 연계 법적 근거',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'A Mixed Integer Programming Approach to Improve Oil Spill Response Resource Allocation in the Canadian Arctic',
|
||||||
|
source: 'Das, T., Goerlandt, F. & Pelot, R. | Multimodal Transportation Vol.3 No.1, 100110, 2023',
|
||||||
|
desc: '혼합정수계획법으로 응급 방제자원 거점 위치 선택 + 자원 할당 동시 최적화. 비용·응답시간 트레이드오프 파레토 분석.',
|
||||||
|
highlight: true,
|
||||||
|
tags: [
|
||||||
|
{ label: 'MIP 수리모델', color: 'var(--purple)' },
|
||||||
|
{ label: '자원 위치 선택', color: 'var(--blue)' },
|
||||||
|
{ label: '북극해 적용', color: 'var(--cyan)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '유전알고리즘을 이용하여 최적화된 방제자원 배치안의 분포도 분석',
|
||||||
|
source: '김혜진, 김용혁 | 한국융합학회논문지 Vol.11 No.4, pp.11–16, 2020',
|
||||||
|
desc: 'GA(유전알고리즘)로 방제자원 배치 최적화 및 시뮬레이션 분포도 분석. 국내 해역 실정에 맞는 자원 배치 패턴 도출.',
|
||||||
|
highlight: true,
|
||||||
|
tags: [
|
||||||
|
{ label: 'GA 메타휴리스틱', color: 'var(--purple)' },
|
||||||
|
{ label: '국내 연구', color: 'var(--green, #22c55e)' },
|
||||||
|
{ label: '배치 분포도 분석', color: 'var(--boom, #f59e0b)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'A Two-Stage Stochastic Optimization Framework for Environmentally Sensitive Oil Spill Response Resource Allocation',
|
||||||
|
source: 'Rahman, M.A., Kuhel, M.T. & Novoa, C. | arXiv preprint arXiv:2511.22218, 2025',
|
||||||
|
desc: '확률적 MILP 2단계 프레임워크로 불확실성 포함 최적 자원 배치. 환경민감구역 가중치 반영.',
|
||||||
|
highlight: true,
|
||||||
|
tags: [
|
||||||
|
{ label: '확률적 MILP', color: 'var(--purple)' },
|
||||||
|
{ label: '2단계 최적화', color: 'var(--blue)' },
|
||||||
|
{ label: '환경민감구역', color: 'var(--green, #22c55e)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Mixed-Integer Dynamic Optimization for Oil-Spill Response Planning with Integration of Dynamic Oil Weathering Model',
|
||||||
|
source: 'You, F. & Leyffer, S. | Argonne National Laboratory Technical Note, 2008',
|
||||||
|
desc: '동적 최적화(MINLP/MILP) 프레임워크로 오일스필 대응 스케줄링 + 오일 풍화·거동 물리모델 통합.',
|
||||||
|
highlight: true,
|
||||||
|
tags: [
|
||||||
|
{ label: 'MINLP 동적 최적화', color: 'var(--purple)' },
|
||||||
|
{ label: '오일 풍화 모델 통합', color: 'var(--boom, #f59e0b)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🗄', title: '자산 현행화·데이터 관리', color: 'var(--green, #22c55e)', bgTint: 'rgba(34,197,94,.08)',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: '해양오염방제자원 현황관리 지침',
|
||||||
|
source: '해양경찰청 예규 | 방제자원 등록·현행화·이력관리 절차 규정',
|
||||||
|
desc: '분기별 자산 실사 기준 · 자산분류코드 체계 · WING 업로드 양식(xlsx) 필드 정의 근거',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'ISO 55000 — Asset Management: Overview, Principles and Terminology',
|
||||||
|
source: 'International Organization for Standardization | ISO 55000:2014',
|
||||||
|
desc: '자산 생애주기 관리 원칙 · 자산가치·상태 평가 프레임워크 · WING 자산 노후도·교체주기 산정 이론 기준',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const TAG_COLORS: Record<string, { bg: string; bd: string; fg: string }> = {
|
||||||
|
'var(--purple)': { bg: 'rgba(168,85,247,0.08)', bd: 'rgba(168,85,247,0.2)', fg: '#a855f7' },
|
||||||
|
'var(--blue)': { bg: 'rgba(59,130,246,0.08)', bd: 'rgba(59,130,246,0.2)', fg: '#3b82f6' },
|
||||||
|
'var(--cyan)': { bg: 'rgba(6,182,212,0.08)', bd: 'rgba(6,182,212,0.2)', fg: '#06b6d4' },
|
||||||
|
'var(--green, #22c55e)': { bg: 'rgba(34,197,94,0.08)', bd: 'rgba(34,197,94,0.2)', fg: '#22c55e' },
|
||||||
|
'var(--boom, #f59e0b)': { bg: 'rgba(245,158,11,0.08)', bd: 'rgba(245,158,11,0.2)', fg: '#f59e0b' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function TheoryCard({ section }: { section: TheorySection }) {
|
||||||
|
const badgeBg = section.bgTint.replace(/[\d.]+\)$/, '0.15)')
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg3)', border: '1px solid var(--bd)',
|
||||||
|
borderRadius: 'var(--rM, 10px)', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Section Header */}
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px', background: section.bgTint,
|
||||||
|
borderBottom: '1px solid var(--bd)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '8px',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '14px' }}>{section.icon}</span>
|
||||||
|
<span style={{ fontSize: '12px', fontWeight: 700, color: section.color, fontFamily: 'var(--fK)' }}>
|
||||||
|
{section.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div style={{ padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: '8px', fontSize: '9px', fontFamily: 'var(--fK)' }}>
|
||||||
|
{section.items.map((item, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
{/* Divider */}
|
||||||
|
{section.dividerAfter !== undefined && i === section.dividerAfter + 1 && (
|
||||||
|
<div style={{ borderTop: '1px dashed var(--bd)', margin: '4px 0 12px', paddingTop: '8px' }}>
|
||||||
|
<div style={{ fontSize: '8px', fontWeight: 700, color: section.color, marginBottom: '6px', opacity: 0.7 }}>
|
||||||
|
{section.dividerLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid', gridTemplateColumns: '24px 1fr', gap: '8px',
|
||||||
|
padding: '8px 10px', background: 'var(--bg0)', borderRadius: '6px',
|
||||||
|
borderLeft: item.highlight ? `2px solid ${section.color}` : undefined,
|
||||||
|
}}>
|
||||||
|
{/* Number badge */}
|
||||||
|
<div style={{
|
||||||
|
width: '20px', height: '20px', borderRadius: '4px',
|
||||||
|
background: badgeBg,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: '9px', flexShrink: 0,
|
||||||
|
fontWeight: item.highlight ? 700 : 400,
|
||||||
|
color: item.highlight ? section.color : undefined,
|
||||||
|
}}>
|
||||||
|
{['①','②','③','④','⑤','⑥','⑦','⑧','⑨','⑩'][i]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--t1)', fontWeight: 700, marginBottom: '2px' }}>
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--t3)', lineHeight: '1.6' }}>
|
||||||
|
{item.source}
|
||||||
|
</div>
|
||||||
|
{/* Tags */}
|
||||||
|
{item.tags && (
|
||||||
|
<div style={{ marginTop: '3px', display: 'flex', flexWrap: 'wrap', gap: '3px' }}>
|
||||||
|
{item.tags.map((tag, ti) => {
|
||||||
|
const tc = TAG_COLORS[tag.color] || { bg: 'rgba(107,114,128,0.08)', bd: 'rgba(107,114,128,0.2)', fg: '#6b7280' }
|
||||||
|
return (
|
||||||
|
<span key={ti} style={{
|
||||||
|
padding: '1px 5px', borderRadius: '3px', fontSize: '8px',
|
||||||
|
color: tc.fg, background: tc.bg, border: `1px solid ${tc.bd}`,
|
||||||
|
}}>
|
||||||
|
{tag.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ marginTop: '2px', color: 'var(--t2)' }}>
|
||||||
|
{item.desc}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssetTheory() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0' }}>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: 700, fontFamily: 'var(--fK)', marginBottom: '4px' }}>
|
||||||
|
📚 방제자원 이론
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: '24px' }}>
|
||||||
|
방제자산 운용 기준·성능 이론 및 관련 법령·고시 근거 문헌
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '18px', alignItems: 'start' }}>
|
||||||
|
{/* Left column */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
{THEORY_SECTIONS.slice(0, 2).map((sec) => (
|
||||||
|
<TheoryCard key={sec.title} section={sec} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Right column */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
{THEORY_SECTIONS.slice(2).map((sec) => (
|
||||||
|
<TheoryCard key={sec.title} section={sec} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AssetTheory
|
||||||
124
frontend/src/tabs/assets/components/AssetUpload.tsx
Normal file
124
frontend/src/tabs/assets/components/AssetUpload.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { uploadHistory } from './assetMockData'
|
||||||
|
|
||||||
|
function AssetUpload() {
|
||||||
|
const [uploadMode, setUploadMode] = useState<'add' | 'replace'>('add')
|
||||||
|
const [uploaded, setUploaded] = useState(false)
|
||||||
|
|
||||||
|
const handleUpload = () => {
|
||||||
|
setUploaded(true)
|
||||||
|
setTimeout(() => setUploaded(false), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-8 h-full overflow-auto">
|
||||||
|
{/* Left - Upload */}
|
||||||
|
<div className="flex-1 max-w-[580px]">
|
||||||
|
<div className="text-[13px] font-bold mb-3.5 font-korean">📤 자산 데이터 업로드</div>
|
||||||
|
|
||||||
|
{/* Drop Zone */}
|
||||||
|
<div className="border-2 border-dashed border-border-light rounded-md py-10 px-5 text-center mb-5 cursor-pointer hover:border-primary-cyan/40 transition-colors">
|
||||||
|
<div className="text-4xl mb-2.5 opacity-50">📁</div>
|
||||||
|
<div className="text-sm font-semibold mb-1.5 font-korean">파일을 드래그하거나 클릭하여 업로드</div>
|
||||||
|
<div className="text-[11px] text-text-3 mb-4 font-korean">엑셀(.xlsx), CSV 파일 지원 · 최대 10MB</div>
|
||||||
|
<button className="px-7 py-2.5 text-[13px] font-semibold rounded-sm text-white border-none cursor-pointer font-korean" style={{ background: 'linear-gradient(135deg, var(--blue), #2563eb)' }}>
|
||||||
|
파일 선택
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Asset Classification */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">자산 분류</label>
|
||||||
|
<select className="prd-i w-full">
|
||||||
|
<option>장비자재</option>
|
||||||
|
<option>방제선</option>
|
||||||
|
<option>경비함정</option>
|
||||||
|
<option>방제창고</option>
|
||||||
|
<option>공단·지자체</option>
|
||||||
|
<option>MPRS·행안부</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Jurisdiction */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">업로드 대상 관할</label>
|
||||||
|
<select className="prd-i w-full">
|
||||||
|
<option>남해청 - 여수서</option>
|
||||||
|
<option>남해청 - 부산서</option>
|
||||||
|
<option>남해청 - 울산서</option>
|
||||||
|
<option>서해청 - 목포서</option>
|
||||||
|
<option>중부청 - 인천서</option>
|
||||||
|
<option>동해청 - 동해서</option>
|
||||||
|
<option>제주청 - 제주서</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Mode */}
|
||||||
|
<div className="mb-5">
|
||||||
|
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">업로드 방식</label>
|
||||||
|
<div className="flex gap-4 text-xs text-text-2 font-korean">
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<input type="radio" checked={uploadMode === 'add'} onChange={() => setUploadMode('add')} className="accent-primary-blue" />
|
||||||
|
추가 (기존 + 신규)
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<input type="radio" checked={uploadMode === 'replace'} onChange={() => setUploadMode('replace')} className="accent-primary-blue" />
|
||||||
|
전체 교체
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleUpload}
|
||||||
|
className={`w-full py-3.5 rounded-sm text-sm font-bold font-korean border-none cursor-pointer transition-all ${
|
||||||
|
uploaded
|
||||||
|
? 'bg-[rgba(34,197,94,0.2)] text-status-green border border-status-green'
|
||||||
|
: 'text-white'
|
||||||
|
}`}
|
||||||
|
style={!uploaded ? { background: 'linear-gradient(135deg, var(--blue), #2563eb)' } : undefined}
|
||||||
|
>
|
||||||
|
{uploaded ? '✅ 업로드 완료!' : '📤 업로드 실행'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right - Permission & History */}
|
||||||
|
<div className="flex-1 max-w-[480px]">
|
||||||
|
{/* Permission System */}
|
||||||
|
<div className="text-[13px] font-bold mb-3.5 font-korean">🔐 수정 권한 체계</div>
|
||||||
|
<div className="flex flex-col gap-2 mb-7">
|
||||||
|
{[
|
||||||
|
{ icon: '👑', role: '본청 관리자', desc: '전체 자산 조회·수정·삭제·업로드', color: 'text-status-red', bg: 'rgba(239,68,68,0.15)' },
|
||||||
|
{ icon: '🏛', role: '지방청 담당자', desc: '소속 지방청 및 하위 해경서 자산 수정·업로드', color: 'text-status-orange', bg: 'rgba(249,115,22,0.15)' },
|
||||||
|
{ icon: '⚓', role: '해경서 담당자', desc: '소속 해경서 자산 수정·업로드', color: 'text-primary-blue', bg: 'rgba(59,130,246,0.15)' },
|
||||||
|
{ icon: '👤', role: '일반 사용자', desc: '조회·다운로드만 가능', color: 'text-text-2', bg: 'rgba(100,116,139,0.15)' },
|
||||||
|
].map((p, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 p-3.5 px-4 bg-bg-3 border border-border rounded-sm">
|
||||||
|
<div className="w-9 h-9 rounded-full flex items-center justify-center text-base" style={{ background: p.bg }}>{p.icon}</div>
|
||||||
|
<div>
|
||||||
|
<div className={`text-xs font-bold font-korean ${p.color}`}>{p.role}</div>
|
||||||
|
<div className="text-[10px] text-text-3 font-korean">{p.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload History */}
|
||||||
|
<div className="text-[13px] font-bold mb-3.5 font-korean">📋 최근 업로드 이력</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{uploadHistory.map((h, i) => (
|
||||||
|
<div key={i} className="flex justify-between items-center p-3.5 px-4 bg-bg-3 border border-border rounded-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold font-korean">{h.filename}</div>
|
||||||
|
<div className="text-[10px] text-text-3 mt-0.5 font-korean">{h.date} · {h.uploader} · {h.count}건</div>
|
||||||
|
</div>
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-[rgba(34,197,94,0.15)] text-status-green">완료</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AssetUpload
|
||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
319
frontend/src/tabs/assets/components/ShipInsurance.tsx
Normal file
319
frontend/src/tabs/assets/components/ShipInsurance.tsx
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { InsuranceRow } from './assetTypes'
|
||||||
|
import { insuranceDemoData } from './assetMockData'
|
||||||
|
|
||||||
|
function ShipInsurance() {
|
||||||
|
const [apiConnected, setApiConnected] = useState(false)
|
||||||
|
const [showConfig, setShowConfig] = useState(false)
|
||||||
|
const [configEndpoint, setConfigEndpoint] = useState('https://api.haewoon.or.kr/v1/insurance')
|
||||||
|
const [configApiKey, setConfigApiKey] = useState('')
|
||||||
|
const [configKeyType, setConfigKeyType] = useState('mmsi')
|
||||||
|
const [configRespType, setConfigRespType] = useState('json')
|
||||||
|
const [searchType, setSearchType] = useState('mmsi')
|
||||||
|
const [searchVal, setSearchVal] = useState('')
|
||||||
|
const [insTypeFilter, setInsTypeFilter] = useState('전체')
|
||||||
|
const [viewState, setViewState] = useState<'empty' | 'loading' | 'result'>('empty')
|
||||||
|
const [resultData, setResultData] = useState<InsuranceRow[]>([])
|
||||||
|
const [lastSync, setLastSync] = useState('—')
|
||||||
|
|
||||||
|
const placeholderMap: Record<string, string> = {
|
||||||
|
mmsi: 'MMSI 번호 입력 (예: 440123456)',
|
||||||
|
imo: 'IMO 번호 입력 (예: 9876543)',
|
||||||
|
shipname: '선박명 입력 (예: 한라호)',
|
||||||
|
callsign: '호출부호 입력 (예: HLXX1)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatus = (expiry: string) => {
|
||||||
|
const now = new Date()
|
||||||
|
const exp = new Date(expiry)
|
||||||
|
const daysLeft = Math.ceil((exp.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
if (exp < now) return 'expired' as const
|
||||||
|
if (daysLeft <= 30) return 'soon' as const
|
||||||
|
return 'valid' as const
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveConfig = () => {
|
||||||
|
if (!configApiKey) { alert('API Key를 입력하세요.'); return }
|
||||||
|
setShowConfig(false)
|
||||||
|
alert('API 설정이 저장되었습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTestConnect = async () => {
|
||||||
|
await new Promise(r => setTimeout(r, 1200))
|
||||||
|
alert('⚠ API Key가 설정되지 않았습니다.\n[API 설정] 버튼에서 한국해운조합 API Key를 먼저 등록하세요.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDemoData = () => {
|
||||||
|
setResultData(insuranceDemoData)
|
||||||
|
setViewState('result')
|
||||||
|
setApiConnected(false)
|
||||||
|
setLastSync(new Date().toLocaleString('ko-KR'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQuery = async () => {
|
||||||
|
if (!searchVal.trim()) { alert('조회값을 입력하세요.'); return }
|
||||||
|
setViewState('loading')
|
||||||
|
await new Promise(r => setTimeout(r, 900))
|
||||||
|
loadDemoData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBatchQuery = async () => {
|
||||||
|
setViewState('loading')
|
||||||
|
await new Promise(r => setTimeout(r, 1400))
|
||||||
|
loadDemoData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFullSync = async () => {
|
||||||
|
setLastSync('동기화 중...')
|
||||||
|
await new Promise(r => setTimeout(r, 1000))
|
||||||
|
setLastSync(new Date().toLocaleString('ko-KR'))
|
||||||
|
alert('전체 동기화는 API 연동 후 활성화됩니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// summary computation
|
||||||
|
const validCount = resultData.filter(r => getStatus(r.expiry) !== 'expired').length
|
||||||
|
const soonList = resultData.filter(r => getStatus(r.expiry) === 'soon')
|
||||||
|
const expiredList = resultData.filter(r => getStatus(r.expiry) === 'expired')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'auto' }}>
|
||||||
|
|
||||||
|
{/* ── 헤더 ── */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, fontFamily: 'var(--fK)' }}>🛡 선박 보험정보 조회</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '3px 10px', borderRadius: 10,
|
||||||
|
fontSize: 10, fontWeight: 700, fontFamily: 'var(--fK)',
|
||||||
|
background: apiConnected ? 'rgba(34,197,94,.12)' : 'rgba(239,68,68,.12)',
|
||||||
|
color: apiConnected ? 'var(--green)' : 'var(--red)',
|
||||||
|
border: `1px solid ${apiConnected ? 'rgba(34,197,94,.25)' : 'rgba(239,68,68,.25)'}`,
|
||||||
|
}}>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: '50%', background: apiConnected ? 'var(--green)' : 'var(--red)', display: 'inline-block' }} />
|
||||||
|
{apiConnected ? 'API 연결됨' : 'API 미연결'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>한국해운조합(KSA) Open API 연동 · 선박 P&I 보험 및 선주 책임보험 실시간 조회</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button onClick={handleTestConnect} style={{ padding: '8px 16px', background: 'rgba(6,182,212,.12)', color: 'var(--cyan)', border: '1px solid rgba(6,182,212,.3)', borderRadius: 'var(--rS)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔌 연결 테스트</button>
|
||||||
|
<button onClick={() => setShowConfig(v => !v)} style={{ padding: '8px 16px', background: 'var(--bg3)', color: 'var(--t2)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)' }}>⚙ API 설정</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── API 설정 패널 ── */}
|
||||||
|
{showConfig && (
|
||||||
|
<div style={{ background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 'var(--rM)', padding: '20px 24px', marginBottom: 20 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, fontFamily: 'var(--fK)', marginBottom: 14, color: 'var(--cyan)' }}>⚙ 한국해운조합 API 연동 설정</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 5 }}>API Endpoint URL</label>
|
||||||
|
<input type="text" value={configEndpoint} onChange={e => setConfigEndpoint(e.target.value)} placeholder="https://api.haewoon.or.kr/v1/..."
|
||||||
|
style={{ width: '100%', padding: '9px 12px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', color: 'var(--t1)', fontFamily: 'var(--fM)', fontSize: 12, outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 5 }}>API Key</label>
|
||||||
|
<input type="password" value={configApiKey} onChange={e => setConfigApiKey(e.target.value)} placeholder="발급받은 API Key 입력"
|
||||||
|
style={{ width: '100%', padding: '9px 12px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', color: 'var(--t1)', fontFamily: 'var(--fM)', fontSize: 12, outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 5 }}>조회 기본값 — 조회 키 타입</label>
|
||||||
|
<select value={configKeyType} onChange={e => setConfigKeyType(e.target.value)} className="prd-i" style={{ borderColor: 'var(--bd)', width: '100%' }}>
|
||||||
|
<option value="mmsi">MMSI</option>
|
||||||
|
<option value="imo">IMO 번호</option>
|
||||||
|
<option value="shipname">선박명</option>
|
||||||
|
<option value="callsign">호출부호</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 5 }}>응답 형식</label>
|
||||||
|
<select value={configRespType} onChange={e => setConfigRespType(e.target.value)} className="prd-i" style={{ borderColor: 'var(--bd)', width: '100%' }}>
|
||||||
|
<option value="json">JSON</option>
|
||||||
|
<option value="xml">XML</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button onClick={handleSaveConfig} style={{ padding: '9px 20px', background: 'linear-gradient(135deg, var(--cyan), var(--blue))', color: '#fff', border: 'none', borderRadius: 'var(--rS)', fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'var(--fK)' }}>💾 저장</button>
|
||||||
|
<button onClick={() => setShowConfig(false)} style={{ padding: '9px 16px', background: 'var(--bg0)', color: 'var(--t2)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', fontSize: 12, cursor: 'pointer', fontFamily: 'var(--fK)' }}>취소</button>
|
||||||
|
</div>
|
||||||
|
{/* API 연동 안내 */}
|
||||||
|
<div style={{ marginTop: 16, padding: '12px 16px', background: 'rgba(6,182,212,.05)', border: '1px solid rgba(6,182,212,.15)', borderRadius: 'var(--rS)', fontSize: 10, color: 'var(--t3)', fontFamily: 'var(--fK)', lineHeight: 1.8 }}>
|
||||||
|
<span style={{ color: 'var(--cyan)', fontWeight: 700 }}>📋 한국해운조합 API 발급 안내</span><br />
|
||||||
|
• 한국해운조합 공공데이터포털 또는 해운조합 IT지원팀에 API 키 신청<br />
|
||||||
|
• 해양경찰청 기관 계정으로 신청 시 전용 엔드포인트 및 키 발급<br />
|
||||||
|
• 조회 가능 데이터: P&I 보험, 선주책임보험, 해상보험 가입 여부, 증권번호, 보험기간, 보상한도
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 검색 영역 ── */}
|
||||||
|
<div style={{ background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 'var(--rM)', padding: '18px 20px', marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, fontFamily: 'var(--fK)', marginBottom: 12, color: 'var(--t2)' }}>🔍 보험정보 조회</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: 10, fontWeight: 600, color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: 4 }}>조회 키 타입</label>
|
||||||
|
<select value={searchType} onChange={e => setSearchType(e.target.value)} className="prd-i" style={{ borderColor: 'var(--bd)', minWidth: 120 }}>
|
||||||
|
<option value="mmsi">MMSI</option>
|
||||||
|
<option value="imo">IMO 번호</option>
|
||||||
|
<option value="shipname">선박명</option>
|
||||||
|
<option value="callsign">호출부호</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 220 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 10, fontWeight: 600, color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: 4 }}>조회값</label>
|
||||||
|
<input type="text" value={searchVal} onChange={e => setSearchVal(e.target.value)} placeholder={placeholderMap[searchType]}
|
||||||
|
style={{ width: '100%', padding: '9px 14px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', color: 'var(--t1)', fontFamily: 'var(--fM)', fontSize: 13, outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: 10, fontWeight: 600, color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: 4 }}>보험 종류</label>
|
||||||
|
<select value={insTypeFilter} onChange={e => setInsTypeFilter(e.target.value)} className="prd-i" style={{ borderColor: 'var(--bd)', minWidth: 140 }}>
|
||||||
|
<option>전체</option>
|
||||||
|
<option>P&I 보험</option>
|
||||||
|
<option>선주책임보험</option>
|
||||||
|
<option>해상보험(선박)</option>
|
||||||
|
<option>방제보증보험</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleQuery} style={{ padding: '9px 24px', background: 'linear-gradient(135deg, var(--cyan), var(--blue))', color: '#fff', border: 'none', borderRadius: 'var(--rS)', fontSize: 13, fontWeight: 700, cursor: 'pointer', fontFamily: 'var(--fK)', flexShrink: 0 }}>🔍 조회</button>
|
||||||
|
<button onClick={handleBatchQuery} style={{ padding: '9px 18px', background: 'rgba(168,85,247,.12)', color: 'var(--purple)', border: '1px solid rgba(168,85,247,.3)', borderRadius: 'var(--rS)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)', flexShrink: 0 }}>📋 자산목록 일괄조회</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 결과 영역 ── */}
|
||||||
|
|
||||||
|
{/* 초기 안내 상태 */}
|
||||||
|
{viewState === 'empty' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '60px 20px', background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 'var(--rM)' }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16, opacity: 0.3 }}>🛡</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 8 }}>한국해운조합 API 연동 대기 중</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--t3)', fontFamily: 'var(--fK)', textAlign: 'center', lineHeight: 1.8 }}>
|
||||||
|
API 설정에서 한국해운조합 API Key를 등록하거나<br />
|
||||||
|
MMSI·IMO·선박명으로 직접 조회하세요.<br />
|
||||||
|
<span style={{ color: 'var(--cyan)' }}>자산목록 일괄조회</span> 시 등록된 방제자산 전체의 보험 현황을 한번에 확인할 수 있습니다.
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 20, display: 'flex', gap: 10 }}>
|
||||||
|
<button onClick={() => setShowConfig(true)} style={{ padding: '10px 20px', background: 'rgba(6,182,212,.12)', color: 'var(--cyan)', border: '1px solid rgba(6,182,212,.3)', borderRadius: 'var(--rS)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)' }}>⚙ API 설정</button>
|
||||||
|
<button onClick={loadDemoData} style={{ padding: '10px 20px', background: 'var(--bg0)', color: 'var(--t2)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)' }}>📊 샘플 데이터 보기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 로딩 */}
|
||||||
|
{viewState === 'loading' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 60, background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 'var(--rM)' }}>
|
||||||
|
<div style={{ width: 36, height: 36, border: '3px solid var(--bd)', borderTopColor: 'var(--cyan)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', marginBottom: 14 }} />
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--t2)', fontFamily: 'var(--fK)' }}>한국해운조합 API 조회 중...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 결과 테이블 */}
|
||||||
|
{viewState === 'result' && (
|
||||||
|
<>
|
||||||
|
{/* 요약 카드 */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, marginBottom: 14 }}>
|
||||||
|
{[
|
||||||
|
{ label: '전체', val: resultData.length, color: 'var(--cyan)', bg: 'rgba(6,182,212,.08)' },
|
||||||
|
{ label: '유효', val: validCount, color: 'var(--green)', bg: 'rgba(34,197,94,.08)' },
|
||||||
|
{ label: '만료임박(30일)', val: soonList.length, color: 'var(--yellow)', bg: 'rgba(234,179,8,.08)' },
|
||||||
|
{ label: '만료/미가입', val: resultData.length - validCount, color: 'var(--red)', bg: 'rgba(239,68,68,.08)' },
|
||||||
|
].map((c, i) => (
|
||||||
|
<div key={i} style={{ padding: '14px 16px', background: c.bg, border: `1px solid ${c.color}33`, borderRadius: 'var(--rS)', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 800, color: c.color, fontFamily: 'var(--fM)' }}>{c.val}</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--t3)', fontFamily: 'var(--fK)', marginTop: 2 }}>{c.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div style={{ background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 'var(--rM)', overflow: 'hidden', marginBottom: 12 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: '1px solid var(--bd)' }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, fontFamily: 'var(--fK)', color: 'var(--t1)' }}>조회 결과 <span style={{ color: 'var(--cyan)' }}>{resultData.length}</span>건</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button onClick={() => alert('엑셀 내보내기 기능은 실제 API 연동 후 활성화됩니다.')} style={{ padding: '5px 12px', background: 'rgba(34,197,94,.1)', color: 'var(--green)', border: '1px solid rgba(34,197,94,.25)', borderRadius: 'var(--rS)', fontSize: 11, fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)' }}>📥 엑셀 내보내기</button>
|
||||||
|
<button onClick={handleQuery} style={{ padding: '5px 12px', background: 'var(--bg0)', color: 'var(--t2)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', fontSize: 11, cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔄 새로고침</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11, fontFamily: 'var(--fK)' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: 'var(--bg0)' }}>
|
||||||
|
{[
|
||||||
|
{ label: '선박명', align: 'left' },
|
||||||
|
{ label: 'MMSI', align: 'center' },
|
||||||
|
{ label: 'IMO', align: 'center' },
|
||||||
|
{ label: '보험종류', align: 'center' },
|
||||||
|
{ label: '보험사', align: 'center' },
|
||||||
|
{ label: '증권번호', align: 'center' },
|
||||||
|
{ label: '보험기간', align: 'center' },
|
||||||
|
{ label: '보상한도', align: 'right' },
|
||||||
|
{ label: '상태', align: 'center' },
|
||||||
|
].map((h, i) => (
|
||||||
|
<th key={i} style={{ padding: '10px 14px', textAlign: h.align as 'left' | 'center' | 'right', fontWeight: 700, color: 'var(--t2)', borderBottom: '1px solid var(--bd)', whiteSpace: 'nowrap' }}>{h.label}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{resultData.map((r, i) => {
|
||||||
|
const st = getStatus(r.expiry)
|
||||||
|
const isExp = st === 'expired'
|
||||||
|
const isSoon = st === 'soon'
|
||||||
|
return (
|
||||||
|
<tr key={i} style={{ borderBottom: '1px solid var(--bd)', background: isExp ? 'rgba(239,68,68,.03)' : undefined }}>
|
||||||
|
<td style={{ padding: '10px 14px', fontWeight: 600 }}>{r.shipName}</td>
|
||||||
|
<td style={{ padding: '10px 14px', textAlign: 'center', fontFamily: 'var(--fM)', fontSize: 11 }}>{r.mmsi || '—'}</td>
|
||||||
|
<td style={{ padding: '10px 14px', textAlign: 'center', fontFamily: 'var(--fM)', fontSize: 11 }}>{r.imo || '—'}</td>
|
||||||
|
<td style={{ padding: '10px 14px', textAlign: 'center' }}>{r.insType}</td>
|
||||||
|
<td style={{ padding: '10px 14px', textAlign: 'center' }}>{r.insurer}</td>
|
||||||
|
<td style={{ padding: '10px 14px', textAlign: 'center', fontFamily: 'var(--fM)', fontSize: 10, color: 'var(--t3)' }}>{r.policyNo}</td>
|
||||||
|
<td style={{ padding: '10px 14px', textAlign: 'center', fontFamily: 'var(--fM)', fontSize: 11, color: isExp ? 'var(--red)' : isSoon ? 'var(--yellow)' : undefined, fontWeight: isExp || isSoon ? 700 : undefined }}>{r.start} ~ {r.expiry}</td>
|
||||||
|
<td style={{ padding: '10px 14px', textAlign: 'right', fontWeight: 700, fontFamily: 'var(--fM)' }}>{r.limit}</td>
|
||||||
|
<td style={{ padding: '10px 14px', textAlign: 'center' }}>
|
||||||
|
<span style={{
|
||||||
|
padding: '3px 10px', borderRadius: 10, fontSize: 10, fontWeight: 600,
|
||||||
|
background: isExp ? 'rgba(239,68,68,.15)' : isSoon ? 'rgba(234,179,8,.15)' : 'rgba(34,197,94,.15)',
|
||||||
|
color: isExp ? 'var(--red)' : isSoon ? 'var(--yellow)' : 'var(--green)',
|
||||||
|
}}>
|
||||||
|
{isExp ? '만료' : isSoon ? '만료임박' : '유효'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 경고 */}
|
||||||
|
{(expiredList.length > 0 || soonList.length > 0) && (
|
||||||
|
<div style={{ padding: '12px 16px', background: 'rgba(234,179,8,.06)', border: '1px solid rgba(234,179,8,.25)', borderRadius: 'var(--rS)', fontSize: 12, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 12 }}>
|
||||||
|
{expiredList.length > 0 && (
|
||||||
|
<><span style={{ color: 'var(--red)', fontWeight: 700 }}>⛔ 만료 {expiredList.length}건:</span> {expiredList.map(r => r.shipName).join(', ')}<br /></>
|
||||||
|
)}
|
||||||
|
{soonList.length > 0 && (
|
||||||
|
<><span style={{ color: 'var(--yellow)', fontWeight: 700 }}>⚠ 만료임박(30일) {soonList.length}건:</span> {soonList.map(r => r.shipName).join(', ')}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── API 연동 정보 푸터 ── */}
|
||||||
|
<div style={{ marginTop: 16, padding: '12px 16px', background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--t3)', fontFamily: 'var(--fK)', lineHeight: 1.7 }}>
|
||||||
|
<span style={{ color: 'var(--t2)', fontWeight: 700 }}>데이터 출처:</span> 한국해운조합(KSA) · haewoon.or.kr<br />
|
||||||
|
<span style={{ color: 'var(--t2)', fontWeight: 700 }}>연동 방식:</span> REST API (JSON) · 실시간 조회 · 캐시 TTL 1시간
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>마지막 동기화:</span>
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--t2)', fontFamily: 'var(--fM)' }}>{lastSync}</span>
|
||||||
|
<button onClick={handleFullSync} style={{ padding: '4px 10px', background: 'var(--bg0)', color: 'var(--t2)', border: '1px solid var(--bd)', borderRadius: 4, fontSize: 10, cursor: 'pointer', fontFamily: 'var(--fK)' }}>전체 동기화</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShipInsurance
|
||||||
755
frontend/src/tabs/assets/components/assetMockData.ts
Normal file
755
frontend/src/tabs/assets/components/assetMockData.ts
Normal file
@ -0,0 +1,755 @@
|
|||||||
|
import type { AssetOrg, InsuranceRow } from './assetTypes'
|
||||||
|
|
||||||
|
export const organizations: AssetOrg[] = [
|
||||||
|
// ── 중부지방해양경찰청 ──
|
||||||
|
{
|
||||||
|
id: 1, type: '해경관할', jurisdiction: '중부지방해양경찰청', area: '인천', name: '인천해양경찰서',
|
||||||
|
address: '인천광역시 중구 북성동1가 80-8', vessel: 19, skimmer: 30, pump: 18, vehicle: 2, sprayer: 15, totalAssets: 234, phone: '010-4779-4191',
|
||||||
|
lat: 37.4563, lng: 126.5922, pinSize: 'hq',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 19 }, { category: '유회수기', icon: '⚙', count: 30 }, { category: '비치크리너', icon: '🏖', count: 2 },
|
||||||
|
{ category: '이송펌프', icon: '🔧', count: 18 }, { category: '방제차량', icon: '🚛', count: 2 }, { category: '해안운반차', icon: '🚜', count: 1 },
|
||||||
|
{ category: '고압세척기', icon: '💧', count: 26 }, { category: '저압세척기', icon: '🚿', count: 3 }, { category: '동력분무기', icon: '💨', count: 14 },
|
||||||
|
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 19 }, { category: '발전기', icon: '⚡', count: 9 },
|
||||||
|
{ category: '현장지휘소', icon: '🏕', count: 2 }, { category: '지원장비', icon: '🔩', count: 9 }, { category: '장비부품', icon: '🔗', count: 46 },
|
||||||
|
{ category: '경비함정방제', icon: '⚓', count: 18 }, { category: '살포장치', icon: '🌊', count: 15 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제과장', name: '김○○', phone: '032-835-0001' }, { role: '방제담당', name: '이○○', phone: '032-835-0002' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2, type: '해경경찰서', jurisdiction: '중부지방해양경찰청', area: '평택', name: '평택해양경찰서',
|
||||||
|
address: '평택시 만호리 706번지', vessel: 14, skimmer: 27, pump: 33, vehicle: 3, sprayer: 22, totalAssets: 193, phone: '010-9812-8102',
|
||||||
|
lat: 36.9694, lng: 126.8300, pinSize: 'lg',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 14 }, { category: '유회수기', icon: '⚙', count: 27 }, { category: '비치크리너', icon: '🏖', count: 1 },
|
||||||
|
{ category: '이송펌프', icon: '🔧', count: 33 }, { category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 },
|
||||||
|
{ category: '고압세척기', icon: '💧', count: 12 }, { category: '저압세척기', icon: '🚿', count: 5 }, { category: '동력분무기', icon: '💨', count: 2 },
|
||||||
|
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 35 }, { category: '발전기', icon: '⚡', count: 9 },
|
||||||
|
{ category: '지원장비', icon: '🔩', count: 10 }, { category: '장비부품', icon: '🔗', count: 4 },
|
||||||
|
{ category: '경비함정방제', icon: '⚓', count: 14 }, { category: '살포장치', icon: '🌊', count: 22 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제담당', name: '박○○', phone: '031-682-0001' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3, type: '해경경찰서', jurisdiction: '중부지방해양경찰청', area: '태안', name: '태안해양경찰서',
|
||||||
|
address: '충남 태안군 근흥면 신진부두길2', vessel: 10, skimmer: 27, pump: 21, vehicle: 8, sprayer: 15, totalAssets: 185, phone: '010-2965-4423',
|
||||||
|
lat: 36.7456, lng: 126.2978, pinSize: 'lg',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 10 }, { category: '유회수기', icon: '⚙', count: 27 }, { category: '비치크리너', icon: '🏖', count: 4 },
|
||||||
|
{ category: '이송펌프', icon: '🔧', count: 21 }, { category: '방제차량', icon: '🚛', count: 8 }, { category: '해안운반차', icon: '🚜', count: 8 },
|
||||||
|
{ category: '고압세척기', icon: '💧', count: 14 }, { category: '저압세척기', icon: '🚿', count: 8 }, { category: '동력분무기', icon: '💨', count: 6 },
|
||||||
|
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 28 }, { category: '발전기', icon: '⚡', count: 11 },
|
||||||
|
{ category: '지원장비', icon: '🔩', count: 16 }, { category: '경비함정방제', icon: '⚓', count: 8 }, { category: '살포장치', icon: '🌊', count: 15 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제담당', name: '최○○', phone: '041-674-0001' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4, type: '파출소', jurisdiction: '중부지방해양경찰청', area: '보령', name: '보령해양경찰서',
|
||||||
|
address: '보령시 해안로 740', vessel: 3, skimmer: 8, pump: 5, vehicle: 3, sprayer: 11, totalAssets: 80, phone: '010-2940-6343',
|
||||||
|
lat: 36.3335, lng: 126.5874, pinSize: 'md',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 3 }, { category: '유회수기', icon: '⚙', count: 8 }, { category: '이송펌프', icon: '🔧', count: 5 },
|
||||||
|
{ category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 5 },
|
||||||
|
{ category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '유량계측기', icon: '📏', count: 1 },
|
||||||
|
{ category: '방제창고', icon: '🏭', count: 22 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '지원장비', icon: '🔩', count: 6 },
|
||||||
|
{ category: '장비부품', icon: '🔗', count: 4 }, { category: '경비함정방제', icon: '⚓', count: 6 }, { category: '살포장치', icon: '🌊', count: 11 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제담당', name: '정○○', phone: '041-931-0001' }],
|
||||||
|
},
|
||||||
|
// ── 서해지방해양경찰청 ──
|
||||||
|
{
|
||||||
|
id: 5, type: '해경관할', jurisdiction: '서해지방해양경찰청', area: '여수', name: '여수해양경찰서',
|
||||||
|
address: '광양시 항만9로 89', vessel: 55, skimmer: 92, pump: 63, vehicle: 12, sprayer: 47, totalAssets: 464, phone: '010-2785-2493',
|
||||||
|
lat: 34.7407, lng: 127.7385, pinSize: 'hq',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 55 }, { category: '유회수기', icon: '⚙', count: 92 }, { category: '비치크리너', icon: '🏖', count: 5 },
|
||||||
|
{ category: '이송펌프', icon: '🔧', count: 63 }, { category: '방제차량', icon: '🚛', count: 12 }, { category: '해안운반차', icon: '🚜', count: 4 },
|
||||||
|
{ category: '고압세척기', icon: '💧', count: 48 }, { category: '저압세척기', icon: '🚿', count: 7 }, { category: '동력분무기', icon: '💨', count: 25 },
|
||||||
|
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 37 }, { category: '발전기', icon: '⚡', count: 16 },
|
||||||
|
{ category: '현장지휘소', icon: '🏕', count: 2 }, { category: '지원장비', icon: '🔩', count: 14 }, { category: '장비부품', icon: '🔗', count: 14 },
|
||||||
|
{ category: '경비함정방제', icon: '⚓', count: 22 }, { category: '살포장치', icon: '🌊', count: 47 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제과장', name: '윤○○', phone: '061-660-0001' }, { role: '방제담당', name: '장○○', phone: '061-660-0002' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6, type: '해경경찰서', jurisdiction: '서해지방해양경찰청', area: '목포', name: '목포해양경찰서',
|
||||||
|
address: '목포시 고하대로 597번길 99-64', vessel: 10, skimmer: 19, pump: 18, vehicle: 3, sprayer: 16, totalAssets: 169, phone: '010-9812-8439',
|
||||||
|
lat: 34.7936, lng: 126.3839, pinSize: 'lg',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 10 }, { category: '유회수기', icon: '⚙', count: 19 }, { category: '이송펌프', icon: '🔧', count: 18 },
|
||||||
|
{ category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 7 },
|
||||||
|
{ category: '저압세척기', icon: '🚿', count: 4 }, { category: '동력분무기', icon: '💨', count: 2 }, { category: '유량계측기', icon: '📏', count: 1 },
|
||||||
|
{ category: '방제창고', icon: '🏭', count: 21 }, { category: '발전기', icon: '⚡', count: 4 }, { category: '지원장비', icon: '🔩', count: 31 },
|
||||||
|
{ category: '장비부품', icon: '🔗', count: 17 }, { category: '경비함정방제', icon: '⚓', count: 15 }, { category: '살포장치', icon: '🌊', count: 16 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제담당', name: '조○○', phone: '061-244-0001' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7, type: '해경경찰서', jurisdiction: '서해지방해양경찰청', area: '군산', name: '군산해양경찰서',
|
||||||
|
address: '전북 군산시 오식도동 506', vessel: 6, skimmer: 22, pump: 12, vehicle: 3, sprayer: 17, totalAssets: 155, phone: '010-2618-3406',
|
||||||
|
lat: 35.9900, lng: 126.7133, pinSize: 'lg',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 6 }, { category: '유회수기', icon: '⚙', count: 22 }, { category: '비치크리너', icon: '🏖', count: 2 },
|
||||||
|
{ category: '이송펌프', icon: '🔧', count: 12 }, { category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 },
|
||||||
|
{ category: '고압세척기', icon: '💧', count: 5 }, { category: '저압세척기', icon: '🚿', count: 4 }, { category: '동력분무기', icon: '💨', count: 2 },
|
||||||
|
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 6 }, { category: '발전기', icon: '⚡', count: 5 },
|
||||||
|
{ category: '현장지휘소', icon: '🏕', count: 3 }, { category: '지원장비', icon: '🔩', count: 11 }, { category: '장비부품', icon: '🔗', count: 50 },
|
||||||
|
{ category: '경비함정방제', icon: '⚓', count: 5 }, { category: '살포장치', icon: '🌊', count: 17 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제담당', name: '한○○', phone: '063-462-0001' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8, type: '해경경찰서', jurisdiction: '서해지방해양경찰청', area: '완도', name: '완도해양경찰서',
|
||||||
|
address: '완도군 완도읍 장보고대로 383', vessel: 3, skimmer: 9, pump: 7, vehicle: 3, sprayer: 11, totalAssets: 75, phone: '061-550-2183',
|
||||||
|
lat: 34.3110, lng: 126.7550, pinSize: 'lg',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 3 }, { category: '유회수기', icon: '⚙', count: 9 }, { category: '이송펌프', icon: '🔧', count: 7 },
|
||||||
|
{ category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 3 },
|
||||||
|
{ category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '유량계측기', icon: '📏', count: 1 },
|
||||||
|
{ category: '방제창고', icon: '🏭', count: 24 }, { category: '발전기', icon: '⚡', count: 2 },
|
||||||
|
{ category: '경비함정방제', icon: '⚓', count: 8 }, { category: '살포장치', icon: '🌊', count: 11 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제담당', name: '이○○', phone: '061-550-0001' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9, type: '파출소', jurisdiction: '서해지방해양경찰청', area: '부안', name: '부안해양경찰서',
|
||||||
|
address: '전북 군산시 오식도동 506', vessel: 2, skimmer: 8, pump: 7, vehicle: 2, sprayer: 7, totalAssets: 66, phone: '063-928-xxxx',
|
||||||
|
lat: 35.7316, lng: 126.7328, pinSize: 'md',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 2 }, { category: '유회수기', icon: '⚙', count: 8 }, { category: '이송펌프', icon: '🔧', count: 7 },
|
||||||
|
{ category: '방제차량', icon: '🚛', count: 2 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 2 },
|
||||||
|
{ category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '유량계측기', icon: '📏', count: 1 },
|
||||||
|
{ category: '방제창고', icon: '🏭', count: 15 }, { category: '발전기', icon: '⚡', count: 3 }, { category: '현장지휘소', icon: '🏕', count: 2 },
|
||||||
|
{ category: '지원장비', icon: '🔩', count: 6 }, { category: '경비함정방제', icon: '⚓', count: 7 }, { category: '살포장치', icon: '🌊', count: 7 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제담당', name: '김○○', phone: '063-928-0001' }],
|
||||||
|
},
|
||||||
|
// ── 남해지방해양경찰청 ──
|
||||||
|
{
|
||||||
|
id: 10, type: '해경관할', jurisdiction: '남해지방해양경찰청', area: '부산', name: '부산해양경찰서',
|
||||||
|
address: '부산시 영도구 해양로 293', vessel: 108, skimmer: 22, pump: 25, vehicle: 10, sprayer: 24, totalAssets: 313, phone: '010-2609-1456',
|
||||||
|
lat: 35.0746, lng: 129.0686, pinSize: 'hq',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 108 }, { category: '유회수기', icon: '⚙', count: 22 }, { category: '이송펌프', icon: '🔧', count: 25 },
|
||||||
|
{ category: '방제차량', icon: '🚛', count: 10 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 38 },
|
||||||
|
{ category: '저압세척기', icon: '🚿', count: 8 }, { category: '동력분무기', icon: '💨', count: 6 }, { category: '유량계측기', icon: '📏', count: 1 },
|
||||||
|
{ category: '방제창고', icon: '🏭', count: 21 }, { category: '발전기', icon: '⚡', count: 11 }, { category: '현장지휘소', icon: '🏕', count: 2 },
|
||||||
|
{ category: '지원장비', icon: '🔩', count: 20 }, { category: '경비함정방제', icon: '⚓', count: 16 }, { category: '살포장치', icon: '🌊', count: 24 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제과장', name: '임○○', phone: '051-400-0001' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11, type: '해경관할', jurisdiction: '남해지방해양경찰청', area: '울산', name: '울산해양경찰서',
|
||||||
|
address: '울산광역시 남구 장생포 고래로 166', vessel: 46, skimmer: 69, pump: 26, vehicle: 11, sprayer: 20, totalAssets: 311, phone: '010-9812-8210',
|
||||||
|
lat: 35.5008, lng: 129.3824, pinSize: 'hq',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 46 }, { category: '유회수기', icon: '⚙', count: 69 }, { category: '비치크리너', icon: '🏖', count: 4 },
|
||||||
|
{ category: '이송펌프', icon: '🔧', count: 26 }, { category: '방제차량', icon: '🚛', count: 11 }, { category: '해안운반차', icon: '🚜', count: 5 },
|
||||||
|
{ category: '고압세척기', icon: '💧', count: 23 }, { category: '저압세척기', icon: '🚿', count: 6 }, { category: '동력분무기', icon: '💨', count: 6 },
|
||||||
|
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 32 }, { category: '발전기', icon: '⚡', count: 7 },
|
||||||
|
{ category: '현장지휘소', icon: '🏕', count: 1 }, { category: '지원장비', icon: '🔩', count: 40 },
|
||||||
|
{ category: '경비함정방제', icon: '⚓', count: 14 }, { category: '살포장치', icon: '🌊', count: 20 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제과장', name: '강○○', phone: '052-228-0001' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 12, type: '해경경찰서', jurisdiction: '남해지방해양경찰청', area: '창원', name: '창원해양경찰서',
|
||||||
|
address: '창원시 마산합포구 신포동 1가', vessel: 12, skimmer: 25, pump: 14, vehicle: 10, sprayer: 10, totalAssets: 139, phone: '010-4634-7364',
|
||||||
|
lat: 35.1796, lng: 128.5681, pinSize: 'lg',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 12 }, { category: '유회수기', icon: '⚙', count: 25 }, { category: '비치크리너', icon: '🏖', count: 2 },
|
||||||
|
{ category: '이송펌프', icon: '🔧', count: 14 }, { category: '방제차량', icon: '🚛', count: 10 }, { category: '해안운반차', icon: '🚜', count: 1 },
|
||||||
|
{ category: '고압세척기', icon: '💧', count: 7 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 },
|
||||||
|
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 21 }, { category: '발전기', icon: '⚡', count: 4 },
|
||||||
|
{ category: '현장지휘소', icon: '🏕', count: 2 }, { category: '지원장비', icon: '🔩', count: 20 },
|
||||||
|
{ category: '경비함정방제', icon: '⚓', count: 7 }, { category: '살포장치', icon: '🌊', count: 10 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제담당', name: '송○○', phone: '055-220-0001' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 13, type: '해경경찰서', jurisdiction: '남해지방해양경찰청', area: '통영', name: '통영해양경찰서',
|
||||||
|
address: '통영시 광도면 죽림리 1564-4', vessel: 6, skimmer: 15, pump: 9, vehicle: 5, sprayer: 13, totalAssets: 104, phone: '010-9812-8495',
|
||||||
|
lat: 34.8544, lng: 128.4331, pinSize: 'lg',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 6 }, { category: '유회수기', icon: '⚙', count: 15 }, { category: '비치크리너', icon: '🏖', count: 2 },
|
||||||
|
{ category: '이송펌프', icon: '🔧', count: 9 }, { category: '방제차량', icon: '🚛', count: 5 }, { category: '해안운반차', icon: '🚜', count: 1 },
|
||||||
|
{ category: '고압세척기', icon: '💧', count: 3 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 },
|
||||||
|
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 18 }, { category: '발전기', icon: '⚡', count: 4 },
|
||||||
|
{ category: '현장지휘소', icon: '🏕', count: 1 }, { category: '지원장비', icon: '🔩', count: 11 },
|
||||||
|
{ category: '경비함정방제', icon: '⚓', count: 12 }, { category: '살포장치', icon: '🌊', count: 13 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제담당', name: '서○○', phone: '055-640-0001' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 14, type: '해경경찰서', jurisdiction: '남해지방해양경찰청', area: '사천', name: '사천해양경찰서',
|
||||||
|
address: '사천시 신항만길 1길 17', vessel: 2, skimmer: 9, pump: 6, vehicle: 2, sprayer: 7, totalAssets: 80, phone: '010-9812-8352',
|
||||||
|
lat: 34.9310, lng: 128.0660, pinSize: 'lg',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 2 }, { category: '유회수기', icon: '⚙', count: 9 }, { category: '이송펌프', icon: '🔧', count: 6 },
|
||||||
|
{ category: '방제차량', icon: '🚛', count: 2 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 2 },
|
||||||
|
{ category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 4 }, { category: '유량계측기', icon: '📏', count: 1 },
|
||||||
|
{ category: '방제창고', icon: '🏭', count: 31 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '현장지휘소', icon: '🏕', count: 2 },
|
||||||
|
{ category: '지원장비', icon: '🔩', count: 1 }, { category: '경비함정방제', icon: '⚓', count: 8 }, { category: '살포장치', icon: '🌊', count: 7 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제담당', name: '박○○', phone: '055-830-0001' }],
|
||||||
|
},
|
||||||
|
// ── 동해지방해양경찰청 ──
|
||||||
|
{
|
||||||
|
id: 15, type: '해경경찰서', jurisdiction: '동해지방해양경찰청', area: '동해', name: '동해해양경찰서',
|
||||||
|
address: '동해시 임항로 130', vessel: 6, skimmer: 23, pump: 11, vehicle: 6, sprayer: 14, totalAssets: 156, phone: '010-9812-8073',
|
||||||
|
lat: 37.5247, lng: 129.1143, pinSize: 'lg',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 6 }, { category: '유회수기', icon: '⚙', count: 23 }, { category: '비치크리너', icon: '🏖', count: 1 },
|
||||||
|
{ category: '이송펌프', icon: '🔧', count: 11 }, { category: '방제차량', icon: '🚛', count: 6 }, { category: '해안운반차', icon: '🚜', count: 3 },
|
||||||
|
{ category: '고압세척기', icon: '💧', count: 5 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 5 },
|
||||||
|
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 38 }, { category: '발전기', icon: '⚡', count: 2 },
|
||||||
|
{ category: '현장지휘소', icon: '🏕', count: 1 }, { category: '지원장비', icon: '🔩', count: 20 }, { category: '장비부품', icon: '🔗', count: 10 },
|
||||||
|
{ category: '경비함정방제', icon: '⚓', count: 8 }, { category: '살포장치', icon: '🌊', count: 14 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제담당', name: '남○○', phone: '033-530-0001' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 16, type: '해경경찰서', jurisdiction: '동해지방해양경찰청', area: '포항', name: '포항해양경찰서',
|
||||||
|
address: '포항시 남구 희망대로 1341', vessel: 10, skimmer: 13, pump: 21, vehicle: 4, sprayer: 21, totalAssets: 135, phone: '010-3108-2183',
|
||||||
|
lat: 36.0190, lng: 129.3651, pinSize: 'lg',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 10 }, { category: '유회수기', icon: '⚙', count: 13 }, { category: '비치크리너', icon: '🏖', count: 1 },
|
||||||
|
{ category: '이송펌프', icon: '🔧', count: 21 }, { category: '방제차량', icon: '🚛', count: 4 }, { category: '해안운반차', icon: '🚜', count: 1 },
|
||||||
|
{ category: '고압세척기', icon: '💧', count: 7 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 3 },
|
||||||
|
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 15 }, { category: '발전기', icon: '⚡', count: 5 },
|
||||||
|
{ category: '현장지휘소', icon: '🏕', count: 1 }, { category: '지원장비', icon: '🔩', count: 20 },
|
||||||
|
{ category: '경비함정방제', icon: '⚓', count: 10 }, { category: '살포장치', icon: '🌊', count: 21 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제담당', name: '오○○', phone: '054-244-0001' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 17, type: '파출소', jurisdiction: '동해지방해양경찰청', area: '속초', name: '속초해양경찰서',
|
||||||
|
address: '속초시 설악금강대교로 206', vessel: 2, skimmer: 6, pump: 4, vehicle: 1, sprayer: 17, totalAssets: 85, phone: '033-634-2186',
|
||||||
|
lat: 38.2070, lng: 128.5918, pinSize: 'md',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 2 }, { category: '유회수기', icon: '⚙', count: 6 }, { category: '이송펌프', icon: '🔧', count: 4 },
|
||||||
|
{ category: '방제차량', icon: '🚛', count: 1 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 2 },
|
||||||
|
{ category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '유량계측기', icon: '📏', count: 1 },
|
||||||
|
{ category: '방제창고', icon: '🏭', count: 16 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '지원장비', icon: '🔩', count: 11 },
|
||||||
|
{ category: '장비부품', icon: '🔗', count: 11 }, { category: '경비함정방제', icon: '⚓', count: 8 }, { category: '살포장치', icon: '🌊', count: 17 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제담당', name: '양○○', phone: '033-633-0001' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 18, type: '파출소', jurisdiction: '동해지방해양경찰청', area: '울진', name: '울진해양경찰서',
|
||||||
|
address: '울진군 후포면 후포리 623-148', vessel: 2, skimmer: 6, pump: 4, vehicle: 1, sprayer: 8, totalAssets: 66, phone: '010-9812-8076',
|
||||||
|
lat: 36.9932, lng: 129.4003, pinSize: 'md',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 2 }, { category: '유회수기', icon: '⚙', count: 6 }, { category: '이송펌프', icon: '🔧', count: 4 },
|
||||||
|
{ category: '방제차량', icon: '🚛', count: 1 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 3 },
|
||||||
|
{ category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '유량계측기', icon: '📏', count: 1 },
|
||||||
|
{ category: '방제창고', icon: '🏭', count: 13 }, { category: '발전기', icon: '⚡', count: 4 }, { category: '현장지휘소', icon: '🏕', count: 2 },
|
||||||
|
{ category: '지원장비', icon: '🔩', count: 4 }, { category: '장비부품', icon: '🔗', count: 4 },
|
||||||
|
{ category: '경비함정방제', icon: '⚓', count: 10 }, { category: '살포장치', icon: '🌊', count: 8 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제담당', name: '배○○', phone: '054-782-0001' }],
|
||||||
|
},
|
||||||
|
// ── 제주지방해양경찰청 ──
|
||||||
|
{
|
||||||
|
id: 19, type: '해경경찰서', jurisdiction: '제주지방해양경찰청', area: '제주', name: '제주해양경찰서',
|
||||||
|
address: '제주시 임항로 85', vessel: 4, skimmer: 21, pump: 17, vehicle: 3, sprayer: 16, totalAssets: 113, phone: '064-766-2691',
|
||||||
|
lat: 33.5154, lng: 126.5268, pinSize: 'lg',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 4 }, { category: '유회수기', icon: '⚙', count: 21 }, { category: '비치크리너', icon: '🏖', count: 2 },
|
||||||
|
{ category: '이송펌프', icon: '🔧', count: 17 }, { category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 },
|
||||||
|
{ category: '고압세척기', icon: '💧', count: 5 }, { category: '저압세척기', icon: '🚿', count: 3 }, { category: '동력분무기', icon: '💨', count: 4 },
|
||||||
|
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 24 }, { category: '발전기', icon: '⚡', count: 6 },
|
||||||
|
{ category: '현장지휘소', icon: '🏕', count: 2 }, { category: '경비함정방제', icon: '⚓', count: 4 }, { category: '살포장치', icon: '🌊', count: 16 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제담당', name: '문○○', phone: '064-750-0001' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 20, type: '해경경찰서', jurisdiction: '제주지방해양경찰청', area: '서귀포', name: '서귀포해양경찰서',
|
||||||
|
address: '서귀포시 안덕면 화순해안로69', vessel: 3, skimmer: 9, pump: 15, vehicle: 3, sprayer: 14, totalAssets: 67, phone: '064-793-2186',
|
||||||
|
lat: 33.2469, lng: 126.5600, pinSize: 'lg',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 3 }, { category: '유회수기', icon: '⚙', count: 9 }, { category: '비치크리너', icon: '🏖', count: 1 },
|
||||||
|
{ category: '이송펌프', icon: '🔧', count: 15 }, { category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 },
|
||||||
|
{ category: '고압세척기', icon: '💧', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '유량계측기', icon: '📏', count: 1 },
|
||||||
|
{ category: '방제창고', icon: '🏭', count: 10 }, { category: '발전기', icon: '⚡', count: 3 },
|
||||||
|
{ category: '경비함정방제', icon: '⚓', count: 4 }, { category: '살포장치', icon: '🌊', count: 14 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '방제담당', name: '고○○', phone: '064-730-0001' }],
|
||||||
|
},
|
||||||
|
// ── 중앙특수구조단 ──
|
||||||
|
{
|
||||||
|
id: 21, type: '관련기관', jurisdiction: '해양경찰청(중앙)', area: '중앙', name: '중앙특수구조단',
|
||||||
|
address: '부산광역시 영도구 해양로 301', vessel: 1, skimmer: 0, pump: 5, vehicle: 2, sprayer: 0, totalAssets: 39, phone: '051-580-2044',
|
||||||
|
lat: 35.0580, lng: 129.0590, pinSize: 'md',
|
||||||
|
equipment: [
|
||||||
|
{ category: '방제선', icon: '🚢', count: 1 }, { category: '이송펌프', icon: '🔧', count: 5 }, { category: '방제차량', icon: '🚛', count: 2 },
|
||||||
|
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '지원장비', icon: '🔩', count: 27 },
|
||||||
|
{ category: '경비함정방제', icon: '⚓', count: 1 },
|
||||||
|
],
|
||||||
|
contacts: [{ role: '구조단장', name: '김○○', phone: '051-580-2044' }],
|
||||||
|
},
|
||||||
|
// ── 기름저장시설 ──
|
||||||
|
{
|
||||||
|
id: 22, type: '기름저장시설', jurisdiction: '서해지방해양경찰청', area: '여수', name: '오일허브코리아여수㈜ 외 4개',
|
||||||
|
address: '전남 여수시 신덕동 325', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '061-686-3611',
|
||||||
|
lat: 34.745, lng: 127.745, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '오일허브코리아여수㈜', phone: '061-686-3611' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 23, type: '기름저장시설', jurisdiction: '남해지방해양경찰청', area: '부산', name: 'SK에너지 외 2개',
|
||||||
|
address: '부산시 영도구 해양로 1', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 3, phone: '051-643-3331',
|
||||||
|
lat: 35.175, lng: 129.075, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '방제창고', icon: '🏭', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: 'HD현대오일뱅크㈜', phone: '051-643-3331' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 24, type: '기름저장시설', jurisdiction: '남해지방해양경찰청', area: '울산', name: 'SK지오센트릭 외 5개',
|
||||||
|
address: '울산광역시 남구 신여천로 2', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 9, phone: '052-208-2851',
|
||||||
|
lat: 35.535, lng: 129.305, pinSize: 'md',
|
||||||
|
equipment: [{ category: '동력분무기', icon: '💨', count: 4 }, { category: '방제창고', icon: '🏭', count: 5 }],
|
||||||
|
contacts: [{ role: '담당', name: 'SK엔텀㈜', phone: '052-208-2851' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 25, type: '기름저장시설', jurisdiction: '남해지방해양경찰청', area: '통영', name: '한국가스공사 통영기지본부',
|
||||||
|
address: '통영시 광도면 안정로 770', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '055-640-6014',
|
||||||
|
lat: 35.05, lng: 128.41, pinSize: 'md',
|
||||||
|
equipment: [{ category: '방제창고', icon: '🏭', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '한국가스공사', phone: '055-640-6014' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 26, type: '기름저장시설', jurisdiction: '동해지방해양경찰청', area: '동해', name: 'HD현대오일뱅크㈜ 외 4개',
|
||||||
|
address: '강릉시 옥계면 동해대로 206', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 9, phone: '033-534-2093',
|
||||||
|
lat: 37.52, lng: 129.11, pinSize: 'md',
|
||||||
|
equipment: [{ category: '동력분무기', icon: '💨', count: 2 }, { category: '방제창고', icon: '🏭', count: 6 }, { category: '발전기', icon: '⚡', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: 'HD현대오일뱅크㈜', phone: '033-534-2093' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 27, type: '기름저장시설', jurisdiction: '동해지방해양경찰청', area: '포항', name: '포스코케미칼 외 1개',
|
||||||
|
address: '포항시 남구 동해안로 6262', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 2, phone: '054-290-8222',
|
||||||
|
lat: 37.73, lng: 129.01, pinSize: 'md',
|
||||||
|
equipment: [{ category: '동력분무기', icon: '💨', count: 1 }, { category: '살포장치', icon: '🌊', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: 'OCI(주)', phone: '054-290-8222' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 28, type: '기름저장시설', jurisdiction: '서해지방해양경찰청', area: '목포', name: '흑산도내연발전소 외 2개',
|
||||||
|
address: '전남 신안군 흑산일주로70', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 4, phone: '061-351-2342',
|
||||||
|
lat: 35.04, lng: 126.58, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '발전기', icon: '⚡', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '안마도내연발전소', phone: '061-351-2342' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 29, type: '기름저장시설', jurisdiction: '서해지방해양경찰청', area: '여수', name: '오일허브코리아여수㈜ 외 4개',
|
||||||
|
address: '전남 여수시 신덕동 325', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 4, phone: '061-686-3611',
|
||||||
|
lat: 34.75, lng: 127.735, pinSize: 'md',
|
||||||
|
equipment: [{ category: '동력분무기', icon: '💨', count: 1 }, { category: '방제창고', icon: '🏭', count: 3 }],
|
||||||
|
contacts: [{ role: '담당', name: '오일허브코리아여수㈜', phone: '061-686-3611' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 30, type: '기름저장시설', jurisdiction: '중부지방해양경찰청', area: '인천', name: 'GS칼텍스㈜ 외 10개',
|
||||||
|
address: '인천광역시 중구 월미로 182', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 13, phone: '010-8777-6922',
|
||||||
|
lat: 37.45, lng: 126.505, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 7 }, { category: '동력분무기', icon: '💨', count: 6 }],
|
||||||
|
contacts: [{ role: '담당', name: 'GS칼텍스㈜', phone: '010-8777-6922' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 31, type: '기름저장시설', jurisdiction: '중부지방해양경찰청', area: '태안', name: 'HD현대케미칼 외 4개',
|
||||||
|
address: '충남 서산시 대산읍 평신2로 26', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 4, phone: '041-924-1068',
|
||||||
|
lat: 36.91, lng: 126.415, pinSize: 'md',
|
||||||
|
equipment: [{ category: '해안운반차', icon: '🚜', count: 2 }, { category: '동력분무기', icon: '💨', count: 2 }],
|
||||||
|
contacts: [{ role: '담당', name: 'HD현대케미칼', phone: '041-924-1068' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 32, type: '기름저장시설', jurisdiction: '중부지방해양경찰청', area: '평택', name: '현대오일터미널(주) 외 4개',
|
||||||
|
address: '평택시 포승읍 포승공단순환로 11', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 4, phone: '031-683-5101',
|
||||||
|
lat: 36.985, lng: 126.835, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 2 }, { category: '발전기', icon: '⚡', count: 1 }, { category: '살포장치', icon: '🌊', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '(주)경동탱크터미널', phone: '031-683-5101' }],
|
||||||
|
},
|
||||||
|
// ── 기타 ──
|
||||||
|
{
|
||||||
|
id: 33, type: '기타', jurisdiction: '남해지방해양경찰청', area: '사천', name: '한국남동발전(주) 외 2개',
|
||||||
|
address: '고성군 하이면 하이로1', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 8, phone: '070-4486-7474',
|
||||||
|
lat: 34.965, lng: 128.56, pinSize: 'md',
|
||||||
|
equipment: [{ category: '동력분무기', icon: '💨', count: 3 }, { category: '방제창고', icon: '🏭', count: 5 }],
|
||||||
|
contacts: [{ role: '담당', name: '(주)고성그린파워', phone: '070-4486-7474' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 34, type: '기타', jurisdiction: '남해지방해양경찰청', area: '울산', name: 'HD현대미포',
|
||||||
|
address: '울산광역시 동구 방어진순환도로100', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '052-250-3551',
|
||||||
|
lat: 35.53, lng: 129.315, pinSize: 'md',
|
||||||
|
equipment: [{ category: '방제창고', icon: '🏭', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: 'HD현대미포', phone: '052-250-3551' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 35, type: '기타', jurisdiction: '남해지방해양경찰청', area: '통영', name: '삼성중공업 외 1개',
|
||||||
|
address: '거제시 장평3로 80', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 2, phone: '055-630-5373',
|
||||||
|
lat: 35.05, lng: 128.41, pinSize: 'md',
|
||||||
|
equipment: [{ category: '동력분무기', icon: '💨', count: 2 }],
|
||||||
|
contacts: [{ role: '담당', name: '삼성중공업', phone: '055-630-5373' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 36, type: '기타', jurisdiction: '동해지방해양경찰청', area: '동해', name: '한국남부발전㈜',
|
||||||
|
address: '삼척시 원덕읍 삼척로 734', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 2, phone: '070-7713-5153',
|
||||||
|
lat: 37.45, lng: 129.17, pinSize: 'md',
|
||||||
|
equipment: [{ category: '방제창고', icon: '🏭', count: 2 }],
|
||||||
|
contacts: [{ role: '담당', name: '한국남부발전㈜', phone: '070-7713-5153' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 37, type: '기타', jurisdiction: '동해지방해양경찰청', area: '울진', name: '한울원전',
|
||||||
|
address: '울진군 북면 울진북로 2040', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 4, phone: '054-785-4833',
|
||||||
|
lat: 37.065, lng: 129.39, pinSize: 'md',
|
||||||
|
equipment: [{ category: '방제창고', icon: '🏭', count: 2 }, { category: '지원장비', icon: '🔩', count: 2 }],
|
||||||
|
contacts: [{ role: '담당', name: '한울원전', phone: '054-785-4833' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 38, type: '기타', jurisdiction: '서해지방해양경찰청', area: '여수', name: '㈜HR-PORT 외 5개',
|
||||||
|
address: '여수시 제철로', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 16, phone: '061-791-0358',
|
||||||
|
lat: 34.755, lng: 127.73, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '동력분무기', icon: '💨', count: 7 }, { category: '방제창고', icon: '🏭', count: 3 }, { category: '발전기', icon: '⚡', count: 1 }, { category: '지원장비', icon: '🔩', count: 3 }],
|
||||||
|
contacts: [{ role: '담당', name: '㈜ 한진', phone: '061-791-0358' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 39, type: '기타', jurisdiction: '중부지방해양경찰청', area: '인천', name: '삼광조선공업㈜ 외 1개',
|
||||||
|
address: '인천 동구 보세로42번길41', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 5, phone: '010-3321-2959',
|
||||||
|
lat: 37.45, lng: 126.505, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 5 }],
|
||||||
|
contacts: [{ role: '담당', name: '삼광조선공업㈜', phone: '010-3321-2959' }],
|
||||||
|
},
|
||||||
|
// ── 방제유창청소업체 ──
|
||||||
|
{
|
||||||
|
id: 40, type: '업체', jurisdiction: '중부지방해양경찰청', area: '인천', name: '방제유창청소업체(㈜클린포트)',
|
||||||
|
address: '㈜클린포트', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 3, phone: '032-882-8279',
|
||||||
|
lat: 37.45, lng: 126.505, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 2 }, { category: '발전기', icon: '⚡', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '㈜클린포트', phone: '032-882-8279' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 41, type: '업체', jurisdiction: '남해지방해양경찰청', area: '부산', name: '방제유창청소업체(대용환경㈜ 외 38개)',
|
||||||
|
address: '㈜태평양해양산업', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 51, phone: '051-242-0622',
|
||||||
|
lat: 35.18, lng: 129.085, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 31 }, { category: '저압세척기', icon: '🚿', count: 5 }, { category: '동력분무기', icon: '💨', count: 3 }, { category: '방제창고', icon: '🏭', count: 5 }, { category: '발전기', icon: '⚡', count: 6 }, { category: '현장지휘소', icon: '🏕', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '(주)경원마린서비스', phone: '051-242-0622' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 42, type: '업체', jurisdiction: '남해지방해양경찰청', area: '울산', name: '방제유창청소업체((주)한유마린서비스 외 8개)',
|
||||||
|
address: '대상해운(주)', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 12, phone: '010-5499-7401',
|
||||||
|
lat: 35.54, lng: 129.295, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 11 }, { category: '방제창고', icon: '🏭', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '(주)골든씨', phone: '010-5499-7401' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 43, type: '업체', jurisdiction: '동해지방해양경찰청', area: '포항', name: '방제유창청소업체(블루씨 외 1개)',
|
||||||
|
address: '(주)블루씨', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 3, phone: '054-278-8200',
|
||||||
|
lat: 36.015, lng: 129.365, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 2 }, { category: '살포장치', icon: '🌊', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '(주)블루씨', phone: '054-278-8200' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 44, type: '업체', jurisdiction: '서해지방해양경찰청', area: '목포', name: '방제유창청소업체(㈜한국해운 외 1개)',
|
||||||
|
address: '㈜한국해운 목포지사', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '010-8615-4326',
|
||||||
|
lat: 35.04, lng: 126.58, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '㈜아라', phone: '010-8615-4326' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 45, type: '업체', jurisdiction: '서해지방해양경찰청', area: '여수', name: '방제유창청소업체(마로해운 외 11개)',
|
||||||
|
address: '㈜우진실업', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 2, totalAssets: 54, phone: '061-654-9603',
|
||||||
|
lat: 34.74, lng: 127.75, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 28 }, { category: '동력분무기', icon: '💨', count: 15 }, { category: '방제창고', icon: '🏭', count: 5 }, { category: '발전기', icon: '⚡', count: 4 }, { category: '살포장치', icon: '🌊', count: 2 }],
|
||||||
|
contacts: [{ role: '담당', name: '(유)피케이엘', phone: '061-654-9603' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 46, type: '업체', jurisdiction: '중부지방해양경찰청', area: '태안', name: '방제유창청소업체(우진해운㈜)',
|
||||||
|
address: '우진해운㈜', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 6, phone: '010-4384-6817',
|
||||||
|
lat: 36.905, lng: 126.42, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '발전기', icon: '⚡', count: 2 }],
|
||||||
|
contacts: [{ role: '담당', name: '우진해운㈜', phone: '010-4384-6817' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 47, type: '업체', jurisdiction: '중부지방해양경찰청', area: '평택', name: '방제유창청소업체((주)씨앤 외 3개)',
|
||||||
|
address: '㈜씨앤', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 6, phone: '031-683-2389',
|
||||||
|
lat: 36.99, lng: 126.825, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '발전기', icon: '⚡', count: 2 }],
|
||||||
|
contacts: [{ role: '담당', name: '(주)소스코리아', phone: '031-683-2389' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 48, type: '업체', jurisdiction: '남해지방해양경찰청', area: '부산', name: '방제유창청소업체(㈜지앤비마린서비스)',
|
||||||
|
address: '㈜지앤비마린서비스', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '051-242-0622',
|
||||||
|
lat: 35.185, lng: 129.07, pinSize: 'md',
|
||||||
|
equipment: [{ category: '방제창고', icon: '🏭', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '(주)경원마린서비스', phone: '051-242-0622' }],
|
||||||
|
},
|
||||||
|
// ── 정유사 ──
|
||||||
|
{
|
||||||
|
id: 49, type: '정유사', jurisdiction: '남해지방해양경찰청', area: '울산', name: 'SK엔텀(주) 외 4개',
|
||||||
|
address: '울산광역시 남구 고사동 110-64', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 5, phone: '052-231-2318',
|
||||||
|
lat: 35.545, lng: 129.31, pinSize: 'md',
|
||||||
|
equipment: [{ category: '방제창고', icon: '🏭', count: 5 }],
|
||||||
|
contacts: [{ role: '담당', name: 'S-OIL㈜', phone: '052-231-2318' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 50, type: '정유사', jurisdiction: '서해지방해양경찰청', area: '여수', name: 'GS칼텍스㈜',
|
||||||
|
address: '여수시 낙포단지길 251', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 20, totalAssets: 27, phone: '061-680-2121',
|
||||||
|
lat: 34.735, lng: 127.755, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '방제창고', icon: '🏭', count: 4 }, { category: '살포장치', icon: '🌊', count: 20 }],
|
||||||
|
contacts: [{ role: '담당', name: 'GS칼텍스㈜', phone: '061-680-2121' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 51, type: '정유사', jurisdiction: '중부지방해양경찰청', area: '태안', name: 'HD현대오일뱅크㈜',
|
||||||
|
address: '서산시 대산읍 평신2로 182', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 2, phone: '010-2050-5291',
|
||||||
|
lat: 36.915, lng: 126.41, pinSize: 'md',
|
||||||
|
equipment: [{ category: '동력분무기', icon: '💨', count: 2 }],
|
||||||
|
contacts: [{ role: '담당', name: 'HD현대오일뱅크㈜', phone: '010-2050-5291' }],
|
||||||
|
},
|
||||||
|
// ── 지자체 ──
|
||||||
|
{
|
||||||
|
id: 52, type: '지자체', jurisdiction: '남해지방해양경찰청', area: '부산', name: '부산광역시 외 8개',
|
||||||
|
address: '부산광역시 동구 좌천동', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 12, phone: '051-607-4484',
|
||||||
|
lat: 35.17, lng: 129.08, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '방제창고', icon: '🏭', count: 11 }],
|
||||||
|
contacts: [{ role: '담당', name: '남구청', phone: '051-607-4484' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 53, type: '지자체', jurisdiction: '남해지방해양경찰청', area: '사천', name: '사천시 외 3개',
|
||||||
|
address: '사천시 신항로 3', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 6, phone: '055-670-2484',
|
||||||
|
lat: 34.935, lng: 128.075, pinSize: 'md',
|
||||||
|
equipment: [{ category: '방제창고', icon: '🏭', count: 6 }],
|
||||||
|
contacts: [{ role: '담당', name: '고성군', phone: '055-670-2484' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 54, type: '지자체', jurisdiction: '남해지방해양경찰청', area: '울산', name: '울산북구청 외 2개',
|
||||||
|
address: '울산광역시 북구 구유동 654-2', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 4, phone: '051-709-4611',
|
||||||
|
lat: 35.55, lng: 129.32, pinSize: 'md',
|
||||||
|
equipment: [{ category: '방제창고', icon: '🏭', count: 4 }],
|
||||||
|
contacts: [{ role: '담당', name: '부산기장군청', phone: '051-709-4611' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 55, type: '지자체', jurisdiction: '남해지방해양경찰청', area: '창원', name: '창원 진해구 외 1개',
|
||||||
|
address: '창원시 진해구 천자로 105', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 2, phone: '051-970-4482',
|
||||||
|
lat: 35.055, lng: 128.645, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '방제창고', icon: '🏭', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '부산 강서구', phone: '051-970-4482' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 56, type: '지자체', jurisdiction: '동해지방해양경찰청', area: '동해', name: '삼척시 외 1개',
|
||||||
|
address: '삼척시 근덕면 덕산리 107-74', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 4, phone: '033-640-5284',
|
||||||
|
lat: 37.45, lng: 129.16, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '방제창고', icon: '🏭', count: 3 }],
|
||||||
|
contacts: [{ role: '담당', name: '강릉시', phone: '033-640-5284' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 57, type: '지자체', jurisdiction: '동해지방해양경찰청', area: '울진', name: '영덕군',
|
||||||
|
address: '남정면 장사리 74-1', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 3, phone: '054-730-6562',
|
||||||
|
lat: 36.35, lng: 129.4, pinSize: 'md',
|
||||||
|
equipment: [{ category: '방제창고', icon: '🏭', count: 3 }],
|
||||||
|
contacts: [{ role: '담당', name: '영덕군', phone: '054-730-6562' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 58, type: '지자체', jurisdiction: '서해지방해양경찰청', area: '목포', name: '영광군 외 1개',
|
||||||
|
address: '영광군 염산면 향화로', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 5, phone: '061-270-3419',
|
||||||
|
lat: 35.04, lng: 126.58, pinSize: 'md',
|
||||||
|
equipment: [{ category: '방제창고', icon: '🏭', count: 5 }],
|
||||||
|
contacts: [{ role: '담당', name: '목포시', phone: '061-270-3419' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 59, type: '지자체', jurisdiction: '서해지방해양경찰청', area: '여수', name: '광양시 외 1개',
|
||||||
|
address: '순천시 진상면 성지로 8', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 3, phone: '061-797-2791',
|
||||||
|
lat: 34.76, lng: 127.725, pinSize: 'md',
|
||||||
|
equipment: [{ category: '방제창고', icon: '🏭', count: 3 }],
|
||||||
|
contacts: [{ role: '담당', name: '광양시', phone: '061-797-2791' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 60, type: '지자체', jurisdiction: '중부지방해양경찰청', area: '인천', name: '옹진군청 외 4개',
|
||||||
|
address: '인천광역시 옹진군 덕적면 진리 387', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 10, phone: '010-2740-9388',
|
||||||
|
lat: 37.45, lng: 126.505, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 5 }, { category: '동력분무기', icon: '💨', count: 4 }, { category: '발전기', icon: '⚡', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '김포시청', phone: '010-2740-9388' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 61, type: '지자체', jurisdiction: '중부지방해양경찰청', area: '태안', name: '태안군청',
|
||||||
|
address: '충남 태안군 근흥면 신진도리 75-36', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '041-670-2877',
|
||||||
|
lat: 36.745, lng: 126.305, pinSize: 'md',
|
||||||
|
equipment: [{ category: '방제창고', icon: '🏭', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '태안군청', phone: '041-670-2877' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 62, type: '지자체', jurisdiction: '중부지방해양경찰청', area: '평택', name: '안산시청 외 2개',
|
||||||
|
address: '경기도 안산시 단원구 진두길 97', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 6, phone: '041-350-4292',
|
||||||
|
lat: 37.32, lng: 126.83, pinSize: 'md',
|
||||||
|
equipment: [{ category: '동력분무기', icon: '💨', count: 1 }, { category: '방제창고', icon: '🏭', count: 5 }],
|
||||||
|
contacts: [{ role: '담당', name: '당진시청', phone: '041-350-4292' }],
|
||||||
|
},
|
||||||
|
// ── 하역시설 ──
|
||||||
|
{
|
||||||
|
id: 63, type: '기타', jurisdiction: '서해지방해양경찰청', area: '여수', name: '㈜HR-PORT 외 5개',
|
||||||
|
address: '여수시 제철로', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '061-791-0358',
|
||||||
|
lat: 34.748, lng: 127.74, pinSize: 'md',
|
||||||
|
equipment: [{ category: '방제창고', icon: '🏭', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '㈜ 한진', phone: '061-791-0358' }],
|
||||||
|
},
|
||||||
|
// ── 해군 ──
|
||||||
|
{
|
||||||
|
id: 64, type: '해군', jurisdiction: '동해지방해양경찰청', area: '동해', name: '해군1함대사령부 외 1개',
|
||||||
|
address: '동해시 대동로 430', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '033-539-7323',
|
||||||
|
lat: 37.525, lng: 129.115, pinSize: 'md',
|
||||||
|
equipment: [{ category: '방제창고', icon: '🏭', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '1함대 사령부', phone: '033-539-7323' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 65, type: '해군', jurisdiction: '중부지방해양경찰청', area: '인천', name: '해병대 제9518부대',
|
||||||
|
address: '인천광역시 옹진군', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 2, phone: '010-4801-3473',
|
||||||
|
lat: 37.45, lng: 126.505, pinSize: 'md',
|
||||||
|
equipment: [{ category: '발전기', icon: '⚡', count: 2 }],
|
||||||
|
contacts: [{ role: '담당', name: '해병대 제9518부대', phone: '010-4801-3473' }],
|
||||||
|
},
|
||||||
|
// ── 해양환경공단 ──
|
||||||
|
{
|
||||||
|
id: 66, type: '해양환경공단', jurisdiction: '남해지방해양경찰청', area: '부산', name: '부산지사',
|
||||||
|
address: '창원시 진해구 안골동', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 6, totalAssets: 14, phone: '051-466-3944',
|
||||||
|
lat: 35.105, lng: 128.715, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '방제창고', icon: '🏭', count: 1 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '현장지휘소', icon: '🏕', count: 1 }, { category: '살포장치', icon: '🌊', count: 6 }],
|
||||||
|
contacts: [{ role: '담당', name: '부산지사', phone: '051-466-3944' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 67, type: '해양환경공단', jurisdiction: '남해지방해양경찰청', area: '사천', name: '마산지사',
|
||||||
|
address: '사천시 신항만1길 23', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 9, phone: '010-3598-4202',
|
||||||
|
lat: 34.925, lng: 128.065, pinSize: 'md',
|
||||||
|
equipment: [{ category: '방제창고', icon: '🏭', count: 8 }, { category: '발전기', icon: '⚡', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '마산지사', phone: '010-3598-4202' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 68, type: '해양환경공단', jurisdiction: '남해지방해양경찰청', area: '울산', name: '울산지사',
|
||||||
|
address: '울산광역시 남구 장생포고래로 276번길 27', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 16, phone: '052-238-7718',
|
||||||
|
lat: 35.538, lng: 129.3, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 6 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '방제창고', icon: '🏭', count: 4 }, { category: '발전기', icon: '⚡', count: 4 }, { category: '살포장치', icon: '🌊', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '울산지사', phone: '052-238-7718' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 69, type: '해양환경공단', jurisdiction: '남해지방해양경찰청', area: '창원', name: '마산지사',
|
||||||
|
address: '창원시 마산합포구 드림베이대로59', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 7, phone: '010-2265-3928',
|
||||||
|
lat: 35.055, lng: 128.645, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 4 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '살포장치', icon: '🌊', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '마산지사', phone: '010-2265-3928' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 70, type: '해양환경공단', jurisdiction: '남해지방해양경찰청', area: '통영', name: '마산지사',
|
||||||
|
address: '거제시 장승로 112', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 8, phone: '010-2636-5313',
|
||||||
|
lat: 35.05, lng: 128.41, pinSize: 'md',
|
||||||
|
equipment: [{ category: '방제창고', icon: '🏭', count: 8 }],
|
||||||
|
contacts: [{ role: '담당', name: '마산지사', phone: '010-2636-5313' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 71, type: '해양환경공단', jurisdiction: '동해지방해양경찰청', area: '동해', name: '동해지사',
|
||||||
|
address: '동해시 대동로 210', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 2, totalAssets: 17, phone: '010-7499-0257',
|
||||||
|
lat: 37.515, lng: 129.105, pinSize: 'md',
|
||||||
|
equipment: [{ category: '해안운반차', icon: '🚜', count: 2 }, { category: '고압세척기', icon: '💧', count: 2 }, { category: '동력분무기', icon: '💨', count: 2 }, { category: '방제창고', icon: '🏭', count: 8 }, { category: '발전기', icon: '⚡', count: 1 }, { category: '살포장치', icon: '🌊', count: 2 }],
|
||||||
|
contacts: [{ role: '담당', name: '동해지사', phone: '010-7499-0257' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 72, type: '해양환경공단', jurisdiction: '동해지방해양경찰청', area: '울진', name: '포항지사',
|
||||||
|
address: '울진군 죽변면 죽변리 36-88', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 2, phone: '054-273-5595',
|
||||||
|
lat: 37.06, lng: 129.42, pinSize: 'md',
|
||||||
|
equipment: [{ category: '방제창고', icon: '🏭', count: 2 }],
|
||||||
|
contacts: [{ role: '담당', name: '포항지사', phone: '054-273-5595' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 73, type: '해양환경공단', jurisdiction: '동해지방해양경찰청', area: '포항', name: '포항지사',
|
||||||
|
address: '포항시 북구 해안로 44-10', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 2, totalAssets: 8, phone: '054-273-5595',
|
||||||
|
lat: 36.025, lng: 129.375, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '현장지휘소', icon: '🏕', count: 1 }, { category: '살포장치', icon: '🌊', count: 2 }],
|
||||||
|
contacts: [{ role: '담당', name: '포항지사', phone: '054-273-5595' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 74, type: '해양환경공단', jurisdiction: '서해지방해양경찰청', area: '군산', name: '군산지사',
|
||||||
|
address: '군산시 임해로 452', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 4, totalAssets: 12, phone: '063-443-4813',
|
||||||
|
lat: 35.975, lng: 126.715, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 2 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '발전기', icon: '⚡', count: 3 }, { category: '살포장치', icon: '🌊', count: 4 }],
|
||||||
|
contacts: [{ role: '담당', name: '군산지사', phone: '063-443-4813' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 75, type: '해양환경공단', jurisdiction: '서해지방해양경찰청', area: '목포', name: '목포지사',
|
||||||
|
address: '전남 목포시 죽교동 683', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 10, phone: '061-242-9663',
|
||||||
|
lat: 35.04, lng: 126.58, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '방제창고', icon: '🏭', count: 6 }, { category: '발전기', icon: '⚡', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '목포지사', phone: '061-242-9663' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 76, type: '해양환경공단', jurisdiction: '서해지방해양경찰청', area: '여수', name: '여수지사',
|
||||||
|
address: '여수시 덕충동', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 3, totalAssets: 12, phone: '061-654-6431',
|
||||||
|
lat: 34.742, lng: 127.748, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 5 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '발전기', icon: '⚡', count: 3 }, { category: '살포장치', icon: '🌊', count: 3 }],
|
||||||
|
contacts: [{ role: '담당', name: '여수지사', phone: '061-654-6431' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 77, type: '해양환경공단', jurisdiction: '서해지방해양경찰청', area: '완도', name: '목포지사 완도사업소',
|
||||||
|
address: '완도군 완도읍 해변공원로 20-1', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 3, phone: '061-242-9663',
|
||||||
|
lat: 34.315, lng: 126.755, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '방제창고', icon: '🏭', count: 2 }],
|
||||||
|
contacts: [{ role: '담당', name: '목포지사', phone: '061-242-9663' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 78, type: '해양환경공단', jurisdiction: '제주지방해양경찰청', area: '서귀포', name: '제주지사(서귀포)',
|
||||||
|
address: '서귀포시 칠십리로72번길 14', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 1, phone: '064-753-4356',
|
||||||
|
lat: 33.245, lng: 126.565, pinSize: 'md',
|
||||||
|
equipment: [{ category: '살포장치', icon: '🌊', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '제주지사', phone: '064-753-4356' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 79, type: '해양환경공단', jurisdiction: '제주지방해양경찰청', area: '제주', name: '제주지사(제주)',
|
||||||
|
address: '제주시 임항로97', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 3, totalAssets: 20, phone: '064-753-4356',
|
||||||
|
lat: 33.517, lng: 126.528, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '동력분무기', icon: '💨', count: 2 }, { category: '방제창고', icon: '🏭', count: 10 }, { category: '발전기', icon: '⚡', count: 1 }, { category: '살포장치', icon: '🌊', count: 3 }],
|
||||||
|
contacts: [{ role: '담당', name: '제주지사', phone: '064-753-4356' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 80, type: '해양환경공단', jurisdiction: '중부지방해양경찰청', area: '보령', name: '대산지사(보령)',
|
||||||
|
address: '보령시 해안로 740', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 5, phone: '041-664-9101',
|
||||||
|
lat: 36.333, lng: 126.612, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '방제창고', icon: '🏭', count: 4 }],
|
||||||
|
contacts: [{ role: '담당', name: '대산지사', phone: '041-664-9101' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 81, type: '해양환경공단', jurisdiction: '중부지방해양경찰청', area: '인천', name: '인천지사',
|
||||||
|
address: '인천광역시 중구 연안부두로 128번길 35', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 11, phone: '010-7133-2167',
|
||||||
|
lat: 37.45, lng: 126.505, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 5 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '동력분무기', icon: '💨', count: 3 }, { category: '발전기', icon: '⚡', count: 2 }],
|
||||||
|
contacts: [{ role: '담당', name: '인천지사', phone: '010-7133-2167' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 82, type: '해양환경공단', jurisdiction: '중부지방해양경찰청', area: '태안', name: '대산지사(태안)',
|
||||||
|
address: '서산시 대산읍 대죽1로 325', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 17, phone: '041-664-9101',
|
||||||
|
lat: 36.908, lng: 126.413, pinSize: 'md',
|
||||||
|
equipment: [{ category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 5 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '방제창고', icon: '🏭', count: 5 }, { category: '발전기', icon: '⚡', count: 3 }, { category: '살포장치', icon: '🌊', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '대산지사', phone: '041-664-9101' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 83, type: '해양환경공단', jurisdiction: '중부지방해양경찰청', area: '평택', name: '평택지사',
|
||||||
|
address: '당진시 송악읍 고대공단2길', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 13, phone: '031-683-7973',
|
||||||
|
lat: 36.905, lng: 126.635, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '방제창고', icon: '🏭', count: 3 }, { category: '발전기', icon: '⚡', count: 4 }, { category: '살포장치', icon: '🌊', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '평택지사', phone: '031-683-7973' }],
|
||||||
|
},
|
||||||
|
// ── 수협 ──
|
||||||
|
{
|
||||||
|
id: 84, type: '기타', jurisdiction: '남해지방해양경찰청', area: '통영', name: '삼성중공업 외 1개',
|
||||||
|
address: '거제시 장평3로 80', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '055-630-5373',
|
||||||
|
lat: 35.05, lng: 128.41, pinSize: 'md',
|
||||||
|
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }],
|
||||||
|
contacts: [{ role: '담당', name: '삼성중공업', phone: '055-630-5373' }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const insuranceDemoData: InsuranceRow[] = [
|
||||||
|
{ shipName: '유조선 한라호', mmsi: '440123456', imo: '9876001', insType: 'P&I 보험', insurer: '한국P&I클럽', policyNo: 'PI-2025-1234', start: '2025-07-01', expiry: '2026-06-30', limit: '50억' },
|
||||||
|
{ shipName: '화학물질운반선 제주호', mmsi: '440345678', imo: '9876002', insType: '선주책임보험', insurer: '삼성화재', policyNo: 'SF-2025-9012', start: '2025-09-16', expiry: '2026-09-15', limit: '80억' },
|
||||||
|
{ shipName: '방제선 OCEAN STAR', mmsi: '440123789', imo: '9876003', insType: 'P&I 보험', insurer: '한국P&I클럽', policyNo: 'PI-2025-3456', start: '2025-11-21', expiry: '2026-11-20', limit: '120억' },
|
||||||
|
{ shipName: 'LNG운반선 부산호', mmsi: '440567890', imo: '9876004', insType: '해상보험', insurer: 'DB손해보험', policyNo: 'DB-2025-7890', start: '2025-08-02', expiry: '2026-08-01', limit: '200억' },
|
||||||
|
{ shipName: '유조선 백두호', mmsi: '440789012', imo: '9876005', insType: 'P&I 보험', insurer: 'SK해운보험', policyNo: 'MH-2025-5678', start: '2025-01-01', expiry: '2025-12-31', limit: '30억' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const uploadHistory = [
|
||||||
|
{ filename: '여수서_장비자재_2601.xlsx', date: '2026-01-25 14:30', uploader: '남해청_방제과', count: 45 },
|
||||||
|
{ filename: '인천서_오일펜스현황.xlsx', date: '2026-01-22 10:15', uploader: '중부청_방제과', count: 12 },
|
||||||
|
{ filename: '전체_방제정_현황.xlsx', date: '2026-01-20 09:00', uploader: '본청_방제과', count: 18 },
|
||||||
|
]
|
||||||
65
frontend/src/tabs/assets/components/assetTypes.ts
Normal file
65
frontend/src/tabs/assets/components/assetTypes.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
export type AssetsTab = 'management' | 'upload' | 'theory' | 'insurance'
|
||||||
|
|
||||||
|
export interface AssetOrg {
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
jurisdiction: string
|
||||||
|
area: string
|
||||||
|
name: string
|
||||||
|
address: string
|
||||||
|
vessel: number
|
||||||
|
skimmer: number
|
||||||
|
pump: number
|
||||||
|
vehicle: number
|
||||||
|
sprayer: number
|
||||||
|
totalAssets: number
|
||||||
|
phone: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
pinSize: 'hq' | 'lg' | 'md'
|
||||||
|
equipment: { category: string; icon: string; count: number }[]
|
||||||
|
contacts: { role: string; name: string; phone: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsuranceRow {
|
||||||
|
shipName: string
|
||||||
|
mmsi: string
|
||||||
|
imo: string
|
||||||
|
insType: string
|
||||||
|
insurer: string
|
||||||
|
policyNo: string
|
||||||
|
start: string
|
||||||
|
expiry: string
|
||||||
|
limit: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const typeTagCls = (type: string) => {
|
||||||
|
if (type === '해경관할') return 'bg-[rgba(239,68,68,0.1)] text-status-red'
|
||||||
|
if (type === '해경경찰서') return 'bg-[rgba(59,130,246,0.1)] text-primary-blue'
|
||||||
|
if (type === '파출소') return 'bg-[rgba(34,197,94,0.1)] text-status-green'
|
||||||
|
if (type === '관련기관') return 'bg-[rgba(168,85,247,0.1)] text-primary-purple'
|
||||||
|
if (type === '해양환경공단') return 'bg-[rgba(6,182,212,0.1)] text-primary-cyan'
|
||||||
|
if (type === '업체') return 'bg-[rgba(245,158,11,0.1)] text-status-orange'
|
||||||
|
if (type === '지자체') return 'bg-[rgba(236,72,153,0.1)] text-[#ec4899]'
|
||||||
|
if (type === '기름저장시설') return 'bg-[rgba(139,92,246,0.1)] text-[#8b5cf6]'
|
||||||
|
if (type === '정유사') return 'bg-[rgba(20,184,166,0.1)] text-[#14b8a6]'
|
||||||
|
if (type === '해군') return 'bg-[rgba(100,116,139,0.1)] text-[#64748b]'
|
||||||
|
if (type === '기타') return 'bg-[rgba(107,114,128,0.1)] text-[#6b7280]'
|
||||||
|
return 'bg-[rgba(156,163,175,0.1)] text-[#9ca3af]'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const typeColor = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case '해경관할': return { bg: 'rgba(6,182,212,0.3)', border: '#06b6d4', selected: '#22d3ee' }
|
||||||
|
case '해경경찰서': return { bg: 'rgba(59,130,246,0.3)', border: '#3b82f6', selected: '#60a5fa' }
|
||||||
|
case '파출소': return { bg: 'rgba(34,197,94,0.3)', border: '#22c55e', selected: '#4ade80' }
|
||||||
|
case '관련기관': return { bg: 'rgba(168,85,247,0.3)', border: '#a855f7', selected: '#c084fc' }
|
||||||
|
case '해양환경공단': return { bg: 'rgba(20,184,166,0.3)', border: '#14b8a6', selected: '#2dd4bf' }
|
||||||
|
case '업체': return { bg: 'rgba(245,158,11,0.3)', border: '#f59e0b', selected: '#fbbf24' }
|
||||||
|
case '지자체': return { bg: 'rgba(236,72,153,0.3)', border: '#ec4899', selected: '#f472b6' }
|
||||||
|
case '기름저장시설': return { bg: 'rgba(139,92,246,0.3)', border: '#8b5cf6', selected: '#a78bfa' }
|
||||||
|
case '정유사': return { bg: 'rgba(13,148,136,0.3)', border: '#0d9488', selected: '#2dd4bf' }
|
||||||
|
case '해군': return { bg: 'rgba(100,116,139,0.3)', border: '#64748b', selected: '#94a3b8' }
|
||||||
|
default: return { bg: 'rgba(107,114,128,0.3)', border: '#6b7280', selected: '#9ca3af' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,247 +1,245 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useAuthStore } from '@common/store/authStore';
|
||||||
|
import { fetchBoardPosts, type BoardPostItem } from '../services/boardApi';
|
||||||
|
|
||||||
interface BoardPost {
|
// 카테고리 코드 ↔ 표시명 매핑
|
||||||
id: number
|
const CATEGORY_MAP: Record<string, string> = {
|
||||||
category: string
|
NOTICE: '공지사항',
|
||||||
title: string
|
DATA: '자료실',
|
||||||
author: string
|
QNA: 'Q&A',
|
||||||
date: string
|
MANUAL: '해경매뉴얼',
|
||||||
views: number
|
};
|
||||||
isNotice?: boolean
|
|
||||||
}
|
const CATEGORY_FILTER: { label: string; code: string | null }[] = [
|
||||||
|
{ label: '전체', code: null },
|
||||||
|
{ label: '공지사항', code: 'NOTICE' },
|
||||||
|
{ label: '자료실', code: 'DATA' },
|
||||||
|
{ label: 'Q&A', code: 'QNA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CATEGORY_STYLE: Record<string, string> = {
|
||||||
|
NOTICE: 'bg-red-500/20 text-red-400',
|
||||||
|
DATA: 'bg-blue-500/20 text-blue-400',
|
||||||
|
QNA: 'bg-green-500/20 text-green-400',
|
||||||
|
MANUAL: 'bg-yellow-500/20 text-yellow-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
interface BoardListTableProps {
|
interface BoardListTableProps {
|
||||||
onPostClick: (id: number) => void
|
onPostClick: (id: number) => void;
|
||||||
onWriteClick: () => void
|
onWriteClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockPosts: BoardPost[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
category: '공지사항',
|
|
||||||
title: '시스템 업데이트 안내',
|
|
||||||
author: '관리자',
|
|
||||||
date: '2025-02-15',
|
|
||||||
views: 245,
|
|
||||||
isNotice: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
category: '공지사항',
|
|
||||||
title: '2025년 방제 교육 일정 안내',
|
|
||||||
author: '관리자',
|
|
||||||
date: '2025-02-14',
|
|
||||||
views: 189,
|
|
||||||
isNotice: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
category: '자료실',
|
|
||||||
title: '방제 매뉴얼 업데이트 (2025년 개정판)',
|
|
||||||
author: '김철수',
|
|
||||||
date: '2025-02-10',
|
|
||||||
views: 423
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
category: 'Q&A',
|
|
||||||
title: 'HNS 대기확산 분석 결과 해석 문의',
|
|
||||||
author: '이영희',
|
|
||||||
date: '2025-02-08',
|
|
||||||
views: 156
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
category: '자료실',
|
|
||||||
title: '2024년 유류오염사고 통계 자료',
|
|
||||||
author: '박민수',
|
|
||||||
date: '2025-02-05',
|
|
||||||
views: 312
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
category: 'Q&A',
|
|
||||||
title: '유출유 확산 예측 알고리즘 선택 기준',
|
|
||||||
author: '정수진',
|
|
||||||
date: '2025-02-03',
|
|
||||||
views: 267
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
category: '자료실',
|
|
||||||
title: '해양오염 방제 장비 운용 가이드',
|
|
||||||
author: '최동현',
|
|
||||||
date: '2025-01-28',
|
|
||||||
views: 534
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 8,
|
|
||||||
category: 'Q&A',
|
|
||||||
title: 'SCAT 조사 방법 관련 질문',
|
|
||||||
author: '강지은',
|
|
||||||
date: '2025-01-25',
|
|
||||||
views: 198
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
category: '자료실',
|
|
||||||
title: 'HNS 물질 안전보건자료 (MSDS) 모음',
|
|
||||||
author: '윤성호',
|
|
||||||
date: '2025-01-20',
|
|
||||||
views: 645
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 10,
|
|
||||||
category: 'Q&A',
|
|
||||||
title: '항공촬영 드론 운용 시 주의사항',
|
|
||||||
author: '송미래',
|
|
||||||
date: '2025-01-15',
|
|
||||||
views: 221
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProps) {
|
export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProps) {
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const hasPermission = useAuthStore((s) => s.hasPermission);
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>('전체')
|
|
||||||
|
|
||||||
const categories = ['전체', '공지사항', '자료실', 'Q&A']
|
const [posts, setPosts] = useState<BoardPostItem[]>([]);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const filteredPosts = mockPosts.filter((post) => {
|
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
|
||||||
const matchesCategory = selectedCategory === '전체' || post.category === selectedCategory
|
|
||||||
const matchesSearch =
|
// 카테고리별 서브리소스 권한 확인 (전체 선택 시 board CREATE)
|
||||||
post.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const canWrite = selectedCategory
|
||||||
post.author.toLowerCase().includes(searchTerm.toLowerCase())
|
? hasPermission(`board:${selectedCategory.toLowerCase()}`, 'CREATE')
|
||||||
return matchesCategory && matchesSearch
|
: hasPermission('board', 'CREATE');
|
||||||
})
|
|
||||||
|
const loadPosts = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await fetchBoardPosts({
|
||||||
|
categoryCd: selectedCategory || undefined,
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
page,
|
||||||
|
size: PAGE_SIZE,
|
||||||
|
});
|
||||||
|
setPosts(result.items);
|
||||||
|
setTotalCount(result.totalCount);
|
||||||
|
} catch {
|
||||||
|
setPosts([]);
|
||||||
|
setTotalCount(0);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedCategory, searchTerm, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPosts();
|
||||||
|
}, [loadPosts]);
|
||||||
|
|
||||||
|
const handleCategoryChange = (code: string | null) => {
|
||||||
|
setSelectedCategory(code);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setSearchTerm(searchInput);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') handleSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dtm: string) => {
|
||||||
|
return new Date(dtm).toLocaleDateString('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-bg-0">
|
<div className="flex flex-col h-full bg-bg-0">
|
||||||
{/* Header with Search and Write Button */}
|
{/* Header with Search and Write Button */}
|
||||||
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
|
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Category Filters */}
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{categories.map((category) => (
|
{CATEGORY_FILTER.map((cat) => (
|
||||||
<button
|
<button
|
||||||
key={category}
|
key={cat.label}
|
||||||
onClick={() => setSelectedCategory(category)}
|
onClick={() => handleCategoryChange(cat.code)}
|
||||||
className={`px-4 py-2 text-sm font-semibold rounded transition-all ${
|
className={`px-4 py-2 text-sm font-semibold rounded transition-all ${
|
||||||
selectedCategory === category
|
selectedCategory === cat.code
|
||||||
? 'bg-primary-cyan text-bg-0'
|
? 'bg-primary-cyan text-bg-0'
|
||||||
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1'
|
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{category}
|
{cat.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Search Input */}
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="제목, 작성자 검색..."
|
placeholder="제목, 작성자 검색..."
|
||||||
value={searchTerm}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
onKeyDown={handleSearchKeyDown}
|
||||||
className="px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none w-64"
|
className="px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none w-64"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Write Button */}
|
{canWrite && (
|
||||||
<button
|
<button
|
||||||
onClick={onWriteClick}
|
onClick={onWriteClick}
|
||||||
className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity flex items-center gap-2"
|
className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<span>✏️</span>
|
<span>+</span>
|
||||||
<span>글쓰기</span>
|
<span>글쓰기</span>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Board List Table */}
|
{/* Board List Table */}
|
||||||
<div className="flex-1 overflow-auto px-8 py-6">
|
<div className="flex-1 overflow-auto px-8 py-6">
|
||||||
<table className="w-full border-collapse">
|
{loading ? (
|
||||||
<thead>
|
|
||||||
<tr className="border-b-2 border-border">
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-20">번호</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">분류</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2">제목</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">작성자</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">작성일</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-24">조회수</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filteredPosts.map((post) => (
|
|
||||||
<tr
|
|
||||||
key={post.id}
|
|
||||||
onClick={() => onPostClick(post.id)}
|
|
||||||
className="border-b border-border hover:bg-bg-2 cursor-pointer transition-colors"
|
|
||||||
>
|
|
||||||
<td className="px-4 py-4 text-sm text-text-1">
|
|
||||||
{post.isNotice ? (
|
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400">
|
|
||||||
공지
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
post.id
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${
|
|
||||||
post.category === '공지사항'
|
|
||||||
? 'bg-red-500/20 text-red-400'
|
|
||||||
: post.category === '자료실'
|
|
||||||
? 'bg-blue-500/20 text-blue-400'
|
|
||||||
: 'bg-green-500/20 text-green-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{post.category}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
<span
|
|
||||||
className={`text-sm ${
|
|
||||||
post.isNotice ? 'font-semibold text-text-1' : 'text-text-1'
|
|
||||||
} hover:text-primary-cyan transition-colors`}
|
|
||||||
>
|
|
||||||
{post.title}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4 text-sm text-text-2">{post.author}</td>
|
|
||||||
<td className="px-4 py-4 text-sm text-text-3">{post.date}</td>
|
|
||||||
<td className="px-4 py-4 text-sm text-text-3">{post.views}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{filteredPosts.length === 0 && (
|
|
||||||
<div className="text-center py-20">
|
<div className="text-center py-20">
|
||||||
<p className="text-text-3 text-sm">검색 결과가 없습니다.</p>
|
<p className="text-text-3 text-sm">불러오는 중...</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b-2 border-border">
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-20">번호</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">분류</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2">제목</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">작성자</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">작성일</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-24">조회수</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{posts.map((post) => (
|
||||||
|
<tr
|
||||||
|
key={post.sn}
|
||||||
|
onClick={() => onPostClick(post.sn)}
|
||||||
|
className="border-b border-border hover:bg-bg-2 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-4 text-sm text-text-1">
|
||||||
|
{post.pinnedYn === 'Y' ? (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400">
|
||||||
|
공지
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
post.sn
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${
|
||||||
|
CATEGORY_STYLE[post.categoryCd] || 'bg-gray-500/20 text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{CATEGORY_MAP[post.categoryCd] || post.categoryCd}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<span
|
||||||
|
className={`text-sm ${
|
||||||
|
post.pinnedYn === 'Y' ? 'font-semibold text-text-1' : 'text-text-1'
|
||||||
|
} hover:text-primary-cyan transition-colors`}
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-sm text-text-2">{post.authorName}</td>
|
||||||
|
<td className="px-4 py-4 text-sm text-text-3">{formatDate(post.regDtm)}</td>
|
||||||
|
<td className="px-4 py-4 text-sm text-text-3">{post.viewCnt}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{posts.length === 0 && (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<p className="text-text-3 text-sm">검색 결과가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
<div className="flex items-center justify-center gap-2 px-8 py-4 border-t border-border bg-bg-1">
|
{totalPages > 1 && (
|
||||||
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors">
|
<div className="flex items-center justify-center gap-2 px-8 py-4 border-t border-border bg-bg-1">
|
||||||
이전
|
<button
|
||||||
</button>
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
<button className="px-3 py-1.5 text-sm rounded bg-primary-cyan text-bg-0 font-semibold">
|
disabled={page <= 1}
|
||||||
1
|
className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors disabled:opacity-40"
|
||||||
</button>
|
>
|
||||||
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors">
|
이전
|
||||||
2
|
</button>
|
||||||
</button>
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||||
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors">
|
<button
|
||||||
3
|
key={p}
|
||||||
</button>
|
onClick={() => setPage(p)}
|
||||||
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors">
|
className={`px-3 py-1.5 text-sm rounded ${
|
||||||
다음
|
page === p
|
||||||
</button>
|
? 'bg-primary-cyan text-bg-0 font-semibold'
|
||||||
</div>
|
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
75
frontend/src/tabs/board/services/boardApi.ts
Normal file
75
frontend/src/tabs/board/services/boardApi.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 인터페이스
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface BoardPostItem {
|
||||||
|
sn: number;
|
||||||
|
categoryCd: string;
|
||||||
|
title: string;
|
||||||
|
authorId: string;
|
||||||
|
authorName: string;
|
||||||
|
viewCnt: number;
|
||||||
|
pinnedYn: string;
|
||||||
|
regDtm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardPostDetail extends BoardPostItem {
|
||||||
|
content: string | null;
|
||||||
|
mdfcnDtm: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardListResponse {
|
||||||
|
items: BoardPostItem[];
|
||||||
|
totalCount: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardListParams {
|
||||||
|
categoryCd?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateBoardPostInput {
|
||||||
|
categoryCd: string;
|
||||||
|
title: string;
|
||||||
|
content?: string;
|
||||||
|
pinnedYn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateBoardPostInput {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
pinnedYn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// API 함수
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function fetchBoardPosts(params?: BoardListParams): Promise<BoardListResponse> {
|
||||||
|
const response = await api.get<BoardListResponse>('/board', { params });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBoardPost(sn: number): Promise<BoardPostDetail> {
|
||||||
|
const response = await api.get<BoardPostDetail>(`/board/${sn}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBoardPost(input: CreateBoardPostInput): Promise<{ sn: number }> {
|
||||||
|
const response = await api.post<{ sn: number }>('/board', input);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBoardPost(sn: number, input: UpdateBoardPostInput): Promise<void> {
|
||||||
|
await api.put(`/board/${sn}`, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBoardPost(sn: number): Promise<void> {
|
||||||
|
await api.delete(`/board/${sn}`);
|
||||||
|
}
|
||||||
196
frontend/src/tabs/prediction/components/InfoLayerSection.tsx
Normal file
196
frontend/src/tabs/prediction/components/InfoLayerSection.tsx
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { LayerTree } from '@common/components/layer/LayerTree'
|
||||||
|
import { useLayerTree } from '@common/hooks/useLayers'
|
||||||
|
import { layerData } from '../../../data/layerData'
|
||||||
|
import type { LayerNode } from '../../../data/layerData'
|
||||||
|
import type { Layer } from '../../../data/layerDatabase'
|
||||||
|
|
||||||
|
interface InfoLayerSectionProps {
|
||||||
|
expanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
enabledLayers: Set<string>
|
||||||
|
onToggleLayer: (layerId: string, enabled: boolean) => void
|
||||||
|
layerOpacity: number
|
||||||
|
onLayerOpacityChange: (val: number) => void
|
||||||
|
layerBrightness: number
|
||||||
|
onLayerBrightnessChange: (val: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfoLayerSection = ({
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
enabledLayers,
|
||||||
|
onToggleLayer,
|
||||||
|
layerOpacity,
|
||||||
|
onLayerOpacityChange,
|
||||||
|
layerBrightness,
|
||||||
|
onLayerBrightnessChange,
|
||||||
|
}: InfoLayerSectionProps) => {
|
||||||
|
// API에서 레이어 트리 데이터 가져오기
|
||||||
|
const { data: layerTree, isLoading } = useLayerTree()
|
||||||
|
|
||||||
|
const [layerColors, setLayerColors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
// 정적 데이터를 Layer 형식으로 변환 (API 실패 시 폴백)
|
||||||
|
const staticLayers = useMemo(() => {
|
||||||
|
const convert = (node: LayerNode): Layer => ({
|
||||||
|
id: node.code,
|
||||||
|
parentId: node.parentCode,
|
||||||
|
name: node.name,
|
||||||
|
fullName: node.fullName,
|
||||||
|
level: node.level,
|
||||||
|
wmsLayer: node.layerName,
|
||||||
|
icon: node.icon,
|
||||||
|
count: node.count,
|
||||||
|
children: node.children?.map(convert),
|
||||||
|
})
|
||||||
|
return layerData.map(convert)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// API 데이터 우선, 실패 시 정적 데이터 폴백
|
||||||
|
const effectiveLayers = (layerTree && layerTree.length > 0) ? layerTree : staticLayers
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between p-4 hover:bg-[rgba(255,255,255,0.02)]"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
onClick={onToggle}
|
||||||
|
className="text-[13px] font-bold text-text-1 font-korean cursor-pointer"
|
||||||
|
>
|
||||||
|
📂 정보 레이어
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
// Get all layer IDs from layerTree recursively
|
||||||
|
const getAllLayerIds = (layers: Layer[]): string[] => {
|
||||||
|
const ids: string[] = []
|
||||||
|
layers?.forEach(layer => {
|
||||||
|
ids.push(layer.id)
|
||||||
|
if (layer.children) {
|
||||||
|
ids.push(...getAllLayerIds(layer.children))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
const allIds = getAllLayerIds(effectiveLayers)
|
||||||
|
allIds.forEach(id => onToggleLayer(id, true))
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: 'var(--fK)',
|
||||||
|
border: '1px solid var(--cyan)',
|
||||||
|
borderRadius: 'var(--rS)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--cyan)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: '0.15s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(6,182,212,0.1)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
전체 켜기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
// Get all layer IDs from layerTree recursively
|
||||||
|
const getAllLayerIds = (layers: Layer[]): string[] => {
|
||||||
|
const ids: string[] = []
|
||||||
|
layers?.forEach(layer => {
|
||||||
|
ids.push(layer.id)
|
||||||
|
if (layer.children) {
|
||||||
|
ids.push(...getAllLayerIds(layer.children))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
const allIds = getAllLayerIds(effectiveLayers)
|
||||||
|
allIds.forEach(id => onToggleLayer(id, false))
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: 'var(--fK)',
|
||||||
|
border: '1px solid var(--red)',
|
||||||
|
borderRadius: 'var(--rS)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--red)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: '0.15s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(239,68,68,0.1)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
전체 끄기
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
onClick={onToggle}
|
||||||
|
className="text-[10px] text-text-3 cursor-pointer"
|
||||||
|
>
|
||||||
|
{expanded ? '▼' : '▶'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-4 pb-2">
|
||||||
|
{isLoading && effectiveLayers.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-text-3 py-2">레이어 로딩 중...</p>
|
||||||
|
) : effectiveLayers.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-text-3 py-2">레이어 데이터가 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
<LayerTree
|
||||||
|
layers={effectiveLayers}
|
||||||
|
enabledLayers={enabledLayers}
|
||||||
|
onToggleLayer={onToggleLayer}
|
||||||
|
layerColors={layerColors}
|
||||||
|
onColorChange={(id, color) => setLayerColors(prev => ({ ...prev, [id]: color }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 레이어 스타일 조절 */}
|
||||||
|
<div className="lyr-style-box">
|
||||||
|
<div className="lyr-style-label">레이어 스타일</div>
|
||||||
|
<div className="lyr-style-row">
|
||||||
|
<span className="lyr-style-name">투명도</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="lyr-style-slider"
|
||||||
|
min={0} max={100} value={layerOpacity}
|
||||||
|
onChange={e => onLayerOpacityChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span className="lyr-style-val">{layerOpacity}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="lyr-style-row">
|
||||||
|
<span className="lyr-style-name">밝기</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="lyr-style-slider"
|
||||||
|
min={0} max={100} value={layerBrightness}
|
||||||
|
onChange={e => onLayerBrightnessChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span className="lyr-style-val">{layerBrightness}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InfoLayerSection
|
||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
548
frontend/src/tabs/prediction/components/OilBoomSection.tsx
Normal file
548
frontend/src/tabs/prediction/components/OilBoomSection.tsx
Normal file
@ -0,0 +1,548 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
|
||||||
|
import { generateAIBoomLines, runContainmentAnalysis, computePolylineLength, computeBearing } from '@common/utils/geo'
|
||||||
|
|
||||||
|
interface OilBoomSectionProps {
|
||||||
|
expanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
boomLines: BoomLine[]
|
||||||
|
onBoomLinesChange: (lines: BoomLine[]) => void
|
||||||
|
oilTrajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>
|
||||||
|
incidentCoord: { lon: number; lat: number }
|
||||||
|
algorithmSettings: AlgorithmSettings
|
||||||
|
onAlgorithmSettingsChange: (settings: AlgorithmSettings) => void
|
||||||
|
isDrawingBoom: boolean
|
||||||
|
onDrawingBoomChange: (drawing: boolean) => void
|
||||||
|
drawingPoints: BoomLineCoord[]
|
||||||
|
onDrawingPointsChange: (points: BoomLineCoord[]) => void
|
||||||
|
containmentResult: ContainmentResult | null
|
||||||
|
onContainmentResultChange: (result: ContainmentResult | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const OilBoomSection = ({
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
boomLines,
|
||||||
|
onBoomLinesChange,
|
||||||
|
oilTrajectory,
|
||||||
|
incidentCoord,
|
||||||
|
algorithmSettings,
|
||||||
|
onAlgorithmSettingsChange,
|
||||||
|
isDrawingBoom,
|
||||||
|
onDrawingBoomChange,
|
||||||
|
drawingPoints,
|
||||||
|
onDrawingPointsChange,
|
||||||
|
containmentResult,
|
||||||
|
onContainmentResultChange,
|
||||||
|
}: OilBoomSectionProps) => {
|
||||||
|
const [boomPlacementTab, setBoomPlacementTab] = useState<'ai' | 'manual' | 'simulation'>('simulation')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border">
|
||||||
|
<div
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]"
|
||||||
|
>
|
||||||
|
<h3 className="text-[13px] font-bold text-text-2 font-korean">
|
||||||
|
🛡 오일펜스 배치 가이드
|
||||||
|
</h3>
|
||||||
|
<span className="text-[10px] text-text-3">
|
||||||
|
{expanded ? '▼' : '▶'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-4 pb-4" style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
|
||||||
|
{/* Tab Buttons + Reset */}
|
||||||
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
|
{[
|
||||||
|
{ id: 'ai' as const, label: 'AI 자동 추천' },
|
||||||
|
{ id: 'manual' as const, label: '수동 배치' },
|
||||||
|
{ id: 'simulation' as const, label: '시뮬레이션' }
|
||||||
|
].map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setBoomPlacementTab(tab.id)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 8px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: 'var(--fK)',
|
||||||
|
borderRadius: 'var(--rS)',
|
||||||
|
border: boomPlacementTab === tab.id ? '1px solid var(--orange)' : '1px solid var(--bd)',
|
||||||
|
background: boomPlacementTab === tab.id ? 'rgba(245,158,11,0.1)' : 'var(--bg0)',
|
||||||
|
color: boomPlacementTab === tab.id ? 'var(--orange)' : 'var(--t3)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: '0.15s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onBoomLinesChange([])
|
||||||
|
onDrawingBoomChange(false)
|
||||||
|
onDrawingPointsChange([])
|
||||||
|
onContainmentResultChange(null)
|
||||||
|
onAlgorithmSettingsChange({
|
||||||
|
currentOrthogonalCorrection: 15,
|
||||||
|
safetyMarginMinutes: 60,
|
||||||
|
minContainmentEfficiency: 80,
|
||||||
|
waveHeightCorrectionFactor: 1.0,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={boomLines.length === 0 && !isDrawingBoom && !containmentResult}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: 'var(--fK)',
|
||||||
|
borderRadius: 'var(--rS)',
|
||||||
|
border: '1px solid var(--bd)',
|
||||||
|
background: 'var(--bg0)',
|
||||||
|
color: (boomLines.length === 0 && !isDrawingBoom && !containmentResult) ? 'var(--t3)' : 'var(--red)',
|
||||||
|
cursor: (boomLines.length === 0 && !isDrawingBoom && !containmentResult) ? 'not-allowed' : 'pointer',
|
||||||
|
transition: '0.15s',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Metrics (동적) */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px' }}>
|
||||||
|
{[
|
||||||
|
{ value: String(boomLines.length), label: '배치 라인', color: 'var(--orange)' },
|
||||||
|
{ value: boomLines.length > 0 ? `${(boomLines.reduce((s, l) => s + l.length, 0) / 1000).toFixed(1)}km` : '0km', label: '총 길이', color: 'var(--cyan)' },
|
||||||
|
{ value: boomLines.length > 0 ? `${Math.round(boomLines.reduce((s, l) => s + l.efficiency, 0) / boomLines.length)}%` : '—', label: '평균 효율', color: 'var(--orange)' }
|
||||||
|
].map((metric, idx) => (
|
||||||
|
<div key={idx} style={{
|
||||||
|
padding: '10px 8px',
|
||||||
|
background: 'var(--bg0)',
|
||||||
|
border: '1px solid var(--bd)',
|
||||||
|
borderRadius: 'var(--rS)',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: 700, color: metric.color, fontFamily: 'var(--fM)', marginBottom: '2px' }}>
|
||||||
|
{metric.value}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}>
|
||||||
|
{metric.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== AI 자동 추천 탭 ===== */}
|
||||||
|
{boomPlacementTab === 'ai' && (
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
padding: '12px',
|
||||||
|
background: 'rgba(245,158,11,0.05)',
|
||||||
|
border: '1px solid rgba(245,158,11,0.3)',
|
||||||
|
borderRadius: 'var(--rM)'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '8px' }}>
|
||||||
|
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--t3)' }} />
|
||||||
|
<span style={{ fontSize: '10px', fontWeight: 700, color: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--t3)', fontFamily: 'var(--fK)' }}>
|
||||||
|
{oilTrajectory.length > 0 ? '확산 데이터 준비 완료' : '확산 예측을 먼저 실행하세요'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 style={{ fontSize: '13px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)', marginBottom: '8px' }}>
|
||||||
|
확산 예측 기반 최적 배치안
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<p style={{ fontSize: '9px', color: 'var(--t3)', fontFamily: 'var(--fK)', lineHeight: '1.5', marginBottom: '10px' }}>
|
||||||
|
{oilTrajectory.length > 0
|
||||||
|
? '확산 궤적을 분석하여 해류 직교 방향 1차 방어선, U형 포위 2차 방어선, 연안 보호 3차 방어선을 자동 생성합니다.'
|
||||||
|
: '상단에서 확산 예측을 실행한 뒤 AI 배치를 적용할 수 있습니다.'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const lines = generateAIBoomLines(
|
||||||
|
oilTrajectory,
|
||||||
|
{ lat: incidentCoord.lat, lon: incidentCoord.lon },
|
||||||
|
algorithmSettings
|
||||||
|
)
|
||||||
|
onBoomLinesChange(lines)
|
||||||
|
}}
|
||||||
|
disabled={oilTrajectory.length === 0}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: 'var(--fK)',
|
||||||
|
background: oilTrajectory.length > 0 ? 'rgba(245,158,11,0.15)' : 'var(--bg0)',
|
||||||
|
border: oilTrajectory.length > 0 ? '2px solid var(--orange)' : '1px solid var(--bd)',
|
||||||
|
borderRadius: 'var(--rS)',
|
||||||
|
color: oilTrajectory.length > 0 ? 'var(--orange)' : 'var(--t3)',
|
||||||
|
cursor: oilTrajectory.length > 0 ? 'pointer' : 'not-allowed',
|
||||||
|
transition: '0.15s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🛡 추천 배치안 적용하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 알고리즘 설정 */}
|
||||||
|
<div>
|
||||||
|
<h4 style={{ fontSize: '11px', fontWeight: 700, color: 'var(--cyan)', fontFamily: 'var(--fK)', marginBottom: '8px', letterSpacing: '0.5px' }}>
|
||||||
|
📊 배치 알고리즘 설정
|
||||||
|
</h4>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{[
|
||||||
|
{ label: '해류 직교 보정', key: 'currentOrthogonalCorrection' as const, unit: '°', value: algorithmSettings.currentOrthogonalCorrection },
|
||||||
|
{ label: '안전 마진 (도달시간)', key: 'safetyMarginMinutes' as const, unit: '분', value: algorithmSettings.safetyMarginMinutes },
|
||||||
|
{ label: '최소 차단 효율', key: 'minContainmentEfficiency' as const, unit: '%', value: algorithmSettings.minContainmentEfficiency },
|
||||||
|
{ label: '파고 보정 계수', key: 'waveHeightCorrectionFactor' as const, unit: 'x', value: algorithmSettings.waveHeightCorrectionFactor },
|
||||||
|
].map((setting) => (
|
||||||
|
<div key={setting.key} style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '6px 8px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)'
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '9px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}>● {setting.label}</span>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={setting.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = parseFloat(e.target.value) || 0
|
||||||
|
onAlgorithmSettingsChange({ ...algorithmSettings, [setting.key]: val })
|
||||||
|
}}
|
||||||
|
className="boom-setting-input"
|
||||||
|
step={setting.key === 'waveHeightCorrectionFactor' ? 0.1 : 1}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '9px', color: 'var(--orange)', fontFamily: 'var(--fK)' }}>{setting.unit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== 수동 배치 탭 ===== */}
|
||||||
|
{boomPlacementTab === 'manual' && (
|
||||||
|
<>
|
||||||
|
{/* 드로잉 컨트롤 */}
|
||||||
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
|
{!isDrawingBoom ? (
|
||||||
|
<button
|
||||||
|
onClick={() => { onDrawingBoomChange(true); onDrawingPointsChange([]) }}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '10px', fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)',
|
||||||
|
background: 'rgba(245,158,11,0.15)', border: '2px solid var(--orange)',
|
||||||
|
borderRadius: 'var(--rS)', color: 'var(--orange)', cursor: 'pointer', transition: '0.15s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🛡 배치 시작
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (drawingPoints.length >= 2) {
|
||||||
|
const newLine: BoomLine = {
|
||||||
|
id: `boom-manual-${Date.now()}`,
|
||||||
|
name: `수동 방어선 ${boomLines.length + 1}`,
|
||||||
|
priority: 'HIGH',
|
||||||
|
type: '기타',
|
||||||
|
coords: [...drawingPoints],
|
||||||
|
length: computePolylineLength(drawingPoints),
|
||||||
|
angle: computeBearing(drawingPoints[0], drawingPoints[drawingPoints.length - 1]),
|
||||||
|
efficiency: 0,
|
||||||
|
status: 'PLANNED',
|
||||||
|
}
|
||||||
|
onBoomLinesChange([...boomLines, newLine])
|
||||||
|
}
|
||||||
|
onDrawingBoomChange(false)
|
||||||
|
onDrawingPointsChange([])
|
||||||
|
}}
|
||||||
|
disabled={drawingPoints.length < 2}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '10px', fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)',
|
||||||
|
background: drawingPoints.length >= 2 ? 'rgba(34,197,94,0.15)' : 'var(--bg0)',
|
||||||
|
border: drawingPoints.length >= 2 ? '2px solid var(--green)' : '1px solid var(--bd)',
|
||||||
|
borderRadius: 'var(--rS)',
|
||||||
|
color: drawingPoints.length >= 2 ? 'var(--green)' : 'var(--t3)',
|
||||||
|
cursor: drawingPoints.length >= 2 ? 'pointer' : 'not-allowed', transition: '0.15s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
배치 완료 ({drawingPoints.length}점)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { onDrawingBoomChange(false); onDrawingPointsChange([]) }}
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px', fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)',
|
||||||
|
background: 'rgba(239,68,68,0.1)', border: '1px solid var(--red)',
|
||||||
|
borderRadius: 'var(--rS)', color: 'var(--red)', cursor: 'pointer', transition: '0.15s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 드로잉 실시간 정보 */}
|
||||||
|
{isDrawingBoom && drawingPoints.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 10px', background: 'rgba(245,158,11,0.05)',
|
||||||
|
border: '1px solid rgba(245,158,11,0.3)', borderRadius: 'var(--rS)',
|
||||||
|
display: 'flex', gap: '12px', fontSize: '10px', fontFamily: 'var(--fK)', color: 'var(--t2)'
|
||||||
|
}}>
|
||||||
|
<span>포인트: <strong style={{ color: 'var(--orange)', fontFamily: 'var(--fM)' }}>{drawingPoints.length}</strong></span>
|
||||||
|
<span>길이: <strong style={{ color: 'var(--cyan)', fontFamily: 'var(--fM)' }}>{computePolylineLength(drawingPoints).toFixed(0)}m</strong></span>
|
||||||
|
{drawingPoints.length >= 2 && (
|
||||||
|
<span>방위각: <strong style={{ color: 'var(--t1)', fontFamily: 'var(--fM)' }}>{computeBearing(drawingPoints[0], drawingPoints[drawingPoints.length - 1]).toFixed(0)}°</strong></span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 배치된 라인 목록 */}
|
||||||
|
{boomLines.length === 0 ? (
|
||||||
|
<p style={{ fontSize: '10px', color: 'var(--t3)', fontFamily: 'var(--fK)', textAlign: 'center', padding: '16px 0' }}>
|
||||||
|
배치된 오일펜스 라인이 없습니다.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
boomLines.map((line, idx) => (
|
||||||
|
<div key={line.id} style={{
|
||||||
|
padding: '10px', background: 'var(--bg0)', border: '1px solid var(--bd)',
|
||||||
|
borderLeft: `3px solid ${line.priority === 'CRITICAL' ? 'var(--red)' : line.priority === 'HIGH' ? 'var(--orange)' : 'var(--yellow)'}`,
|
||||||
|
borderRadius: 'var(--rS)'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '6px' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={line.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...boomLines]
|
||||||
|
updated[idx] = { ...updated[idx], name: e.target.value }
|
||||||
|
onBoomLinesChange(updated)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flex: 1, fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)',
|
||||||
|
background: 'transparent', border: 'none', color: 'var(--t1)', outline: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => onBoomLinesChange(boomLines.filter(l => l.id !== line.id))}
|
||||||
|
style={{
|
||||||
|
fontSize: '10px', color: 'var(--red)', background: 'none', border: 'none',
|
||||||
|
cursor: 'pointer', padding: '2px 6px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px', fontSize: '9px', fontFamily: 'var(--fK)' }}>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: 'var(--t3)' }}>길이</span>
|
||||||
|
<div style={{ fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fM)' }}>{line.length.toFixed(0)}m</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: 'var(--t3)' }}>각도</span>
|
||||||
|
<div style={{ fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fM)' }}>{line.angle.toFixed(0)}°</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: 'var(--t3)' }}>우선순위</span>
|
||||||
|
<select
|
||||||
|
value={line.priority}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...boomLines]
|
||||||
|
updated[idx] = { ...updated[idx], priority: e.target.value as BoomLine['priority'] }
|
||||||
|
onBoomLinesChange(updated)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%', fontSize: '10px', fontWeight: 600, fontFamily: 'var(--fK)',
|
||||||
|
background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: '3px',
|
||||||
|
color: 'var(--t1)', padding: '2px', outline: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="CRITICAL">긴급</option>
|
||||||
|
<option value="HIGH">중요</option>
|
||||||
|
<option value="MEDIUM">보통</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== 시뮬레이션 탭 ===== */}
|
||||||
|
{boomPlacementTab === 'simulation' && (
|
||||||
|
<>
|
||||||
|
{/* 전제조건 체크 */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '6px', padding: '6px 10px',
|
||||||
|
background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)',
|
||||||
|
fontSize: '10px', fontFamily: 'var(--fK)'
|
||||||
|
}}>
|
||||||
|
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--red)' }} />
|
||||||
|
<span style={{ color: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--t3)' }}>
|
||||||
|
확산 궤적 데이터 {oilTrajectory.length > 0 ? `(${oilTrajectory.length}개 입자)` : '없음'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '6px', padding: '6px 10px',
|
||||||
|
background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)',
|
||||||
|
fontSize: '10px', fontFamily: 'var(--fK)'
|
||||||
|
}}>
|
||||||
|
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: boomLines.length > 0 ? 'var(--green)' : 'var(--red)' }} />
|
||||||
|
<span style={{ color: boomLines.length > 0 ? 'var(--green)' : 'var(--t3)' }}>
|
||||||
|
오일펜스 라인 {boomLines.length > 0 ? `(${boomLines.length}개 배치)` : '없음'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 실행 버튼 */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const result = runContainmentAnalysis(oilTrajectory, boomLines)
|
||||||
|
onContainmentResultChange(result)
|
||||||
|
}}
|
||||||
|
disabled={oilTrajectory.length === 0 || boomLines.length === 0}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '10px', fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)',
|
||||||
|
background: (oilTrajectory.length > 0 && boomLines.length > 0) ? 'rgba(6,182,212,0.15)' : 'var(--bg0)',
|
||||||
|
border: (oilTrajectory.length > 0 && boomLines.length > 0) ? '2px solid var(--cyan)' : '1px solid var(--bd)',
|
||||||
|
borderRadius: 'var(--rS)',
|
||||||
|
color: (oilTrajectory.length > 0 && boomLines.length > 0) ? 'var(--cyan)' : 'var(--t3)',
|
||||||
|
cursor: (oilTrajectory.length > 0 && boomLines.length > 0) ? 'pointer' : 'not-allowed',
|
||||||
|
transition: '0.15s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔬 차단 시뮬레이션 실행
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 시뮬레이션 결과 */}
|
||||||
|
{containmentResult && containmentResult.totalParticles > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
{/* 전체 효율 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '16px', background: 'rgba(6,182,212,0.05)',
|
||||||
|
border: '1px solid rgba(6,182,212,0.3)', borderRadius: 'var(--rM)', textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '28px', fontWeight: 700, color: 'var(--cyan)', fontFamily: 'var(--fM)' }}>
|
||||||
|
{containmentResult.overallEfficiency}%
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '10px', color: 'var(--t3)', fontFamily: 'var(--fK)', marginTop: '2px' }}>
|
||||||
|
전체 차단 효율
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 차단/통과 카운트 */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }}>
|
||||||
|
<div style={{ padding: '10px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '16px', fontWeight: 700, color: 'var(--green)', fontFamily: 'var(--fM)' }}>
|
||||||
|
{containmentResult.blockedParticles}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}>차단 입자</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '10px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '16px', fontWeight: 700, color: 'var(--red)', fontFamily: 'var(--fM)' }}>
|
||||||
|
{containmentResult.passedParticles}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}>통과 입자</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 효율 바 */}
|
||||||
|
<div className="boom-eff-bar">
|
||||||
|
<div className="boom-eff-fill" style={{
|
||||||
|
width: `${containmentResult.overallEfficiency}%`,
|
||||||
|
background: containmentResult.overallEfficiency >= 80 ? 'var(--green)' : containmentResult.overallEfficiency >= 50 ? 'var(--orange)' : 'var(--red)'
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 라인별 분석 */}
|
||||||
|
<div>
|
||||||
|
<h4 style={{ fontSize: '10px', fontWeight: 700, color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: '6px' }}>
|
||||||
|
라인별 차단 분석
|
||||||
|
</h4>
|
||||||
|
{containmentResult.perLineResults.map((r) => (
|
||||||
|
<div key={r.boomLineId} style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '6px 8px', marginBottom: '4px',
|
||||||
|
background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)',
|
||||||
|
fontSize: '9px', fontFamily: 'var(--fK)'
|
||||||
|
}}>
|
||||||
|
<span style={{ color: 'var(--t2)', flex: 1 }}>{r.boomLineName}</span>
|
||||||
|
<span style={{ fontWeight: 700, color: r.efficiency >= 50 ? 'var(--green)' : 'var(--orange)', fontFamily: 'var(--fM)', marginLeft: '8px' }}>
|
||||||
|
{r.blocked}차단 / {r.efficiency}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 배치된 방어선 카드 (AI/수동 공통 표시) */}
|
||||||
|
{boomPlacementTab !== 'simulation' && boomLines.length > 0 && boomPlacementTab === 'ai' && (
|
||||||
|
<>
|
||||||
|
{boomLines.map((line, idx) => {
|
||||||
|
const priorityColor = line.priority === 'CRITICAL' ? 'var(--red)' : line.priority === 'HIGH' ? 'var(--orange)' : 'var(--yellow)'
|
||||||
|
const priorityLabel = line.priority === 'CRITICAL' ? '긴급' : line.priority === 'HIGH' ? '중요' : '보통'
|
||||||
|
return (
|
||||||
|
<div key={line.id} style={{
|
||||||
|
padding: '10px', background: 'var(--bg0)', border: '1px solid var(--bd)',
|
||||||
|
borderLeft: `3px solid ${priorityColor}`, borderRadius: 'var(--rS)'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||||
|
<span style={{ fontSize: '11px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)' }}>
|
||||||
|
🛡 {idx + 1}차 방어선 ({line.type})
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 6px', fontSize: '8px', fontWeight: 700, fontFamily: 'var(--fK)',
|
||||||
|
background: `${priorityColor}20`, border: `1px solid ${priorityColor}`,
|
||||||
|
borderRadius: '3px', color: priorityColor
|
||||||
|
}}>
|
||||||
|
{priorityLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px', marginBottom: '6px' }}>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}>길이</span>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fM)' }}>
|
||||||
|
{line.length.toFixed(0)}m
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}>각도</span>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fM)' }}>
|
||||||
|
{line.angle.toFixed(0)}°
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: line.efficiency >= 80 ? 'var(--green)' : 'var(--orange)' }} />
|
||||||
|
<span style={{ fontSize: '9px', fontWeight: 600, color: line.efficiency >= 80 ? 'var(--green)' : 'var(--orange)', fontFamily: 'var(--fK)' }}>
|
||||||
|
차단 효율 {line.efficiency}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OilBoomSection
|
||||||
@ -0,0 +1,426 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { decimalToDMS } from '@common/utils/coordinates'
|
||||||
|
import { ComboBox } from '@common/components/ui/ComboBox'
|
||||||
|
import { ALL_MODELS } from './OilSpillView'
|
||||||
|
import type { PredictionModel } from './OilSpillView'
|
||||||
|
|
||||||
|
interface PredictionInputSectionProps {
|
||||||
|
expanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
incidentCoord: { lon: number; lat: number }
|
||||||
|
onCoordChange: (coord: { lon: number; lat: number }) => void
|
||||||
|
onMapSelectClick: () => void
|
||||||
|
onRunSimulation: () => void
|
||||||
|
isRunningSimulation: boolean
|
||||||
|
selectedModels: Set<PredictionModel>
|
||||||
|
onModelsChange: (models: Set<PredictionModel>) => void
|
||||||
|
predictionTime: number
|
||||||
|
onPredictionTimeChange: (time: number) => void
|
||||||
|
spillType: string
|
||||||
|
onSpillTypeChange: (type: string) => void
|
||||||
|
oilType: string
|
||||||
|
onOilTypeChange: (type: string) => void
|
||||||
|
spillAmount: number
|
||||||
|
onSpillAmountChange: (amount: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PredictionInputSection = ({
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
incidentCoord,
|
||||||
|
onCoordChange,
|
||||||
|
onMapSelectClick,
|
||||||
|
onRunSimulation,
|
||||||
|
isRunningSimulation,
|
||||||
|
selectedModels,
|
||||||
|
onModelsChange,
|
||||||
|
predictionTime,
|
||||||
|
onPredictionTimeChange,
|
||||||
|
spillType,
|
||||||
|
onSpillTypeChange,
|
||||||
|
oilType,
|
||||||
|
onOilTypeChange,
|
||||||
|
spillAmount,
|
||||||
|
onSpillAmountChange,
|
||||||
|
}: PredictionInputSectionProps) => {
|
||||||
|
const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct')
|
||||||
|
const [uploadedImage, setUploadedImage] = useState<string | null>(null)
|
||||||
|
const [uploadedFileName, setUploadedFileName] = useState<string>('')
|
||||||
|
|
||||||
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
setUploadedFileName(file.name)
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (event) => {
|
||||||
|
setUploadedImage(event.target?.result as string)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeUploadedImage = () => {
|
||||||
|
setUploadedImage(null)
|
||||||
|
setUploadedFileName('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border">
|
||||||
|
<div
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]"
|
||||||
|
>
|
||||||
|
<h3 className="text-[13px] font-bold text-text-2 font-korean">
|
||||||
|
예측정보 입력
|
||||||
|
</h3>
|
||||||
|
<span className="text-[10px] text-text-3">
|
||||||
|
{expanded ? '▼' : '▶'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-4 pb-4" style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
|
{/* Input Mode Selection */}
|
||||||
|
<div style={{ display: 'flex', gap: '10px', alignItems: 'center', fontSize: '11px', color: 'var(--t1)', fontFamily: 'var(--fK)' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '3px', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="prdType"
|
||||||
|
checked={inputMode === 'direct'}
|
||||||
|
onChange={() => setInputMode('direct')}
|
||||||
|
style={{ accentColor: 'var(--cyan)', margin: 0, width: '11px', height: '11px' }}
|
||||||
|
/>
|
||||||
|
직접 입력
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '3px', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="prdType"
|
||||||
|
checked={inputMode === 'upload'}
|
||||||
|
onChange={() => setInputMode('upload')}
|
||||||
|
style={{ accentColor: 'var(--cyan)', margin: 0, width: '11px', height: '11px' }}
|
||||||
|
/>
|
||||||
|
이미지 업로드
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Direct Input Mode */}
|
||||||
|
{inputMode === 'direct' && (
|
||||||
|
<>
|
||||||
|
<input className="prd-i" placeholder="사고명 직접 입력" />
|
||||||
|
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Image Upload Mode */}
|
||||||
|
{inputMode === 'upload' && (
|
||||||
|
<>
|
||||||
|
<input className="prd-i" placeholder="여수 유조선 충돌" />
|
||||||
|
<ComboBox
|
||||||
|
className="prd-i"
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '여수 유조선 충돌 (INC-0042)' },
|
||||||
|
{ value: 'INC-0042', label: '여수 유조선 충돌 (INC-0042)' }
|
||||||
|
]}
|
||||||
|
placeholder="사고 선택"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Upload Success Message */}
|
||||||
|
{uploadedImage && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '6px 8px',
|
||||||
|
background: 'rgba(34,197,94,0.1)',
|
||||||
|
border: '1px solid rgba(34,197,94,0.3)',
|
||||||
|
borderRadius: 'var(--rS)',
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#22c55e',
|
||||||
|
fontFamily: 'var(--fK)',
|
||||||
|
fontWeight: 600
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '12px' }}>✓</span>
|
||||||
|
내 이미지가 업로드됨
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File Upload Area */}
|
||||||
|
{!uploadedImage ? (
|
||||||
|
<label style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '20px',
|
||||||
|
background: 'var(--bg0)',
|
||||||
|
border: '2px dashed var(--bd)',
|
||||||
|
borderRadius: 'var(--rS)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: '0.15s',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--t3)',
|
||||||
|
fontFamily: 'var(--fK)'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--cyan)'
|
||||||
|
e.currentTarget.style.background = 'rgba(6,182,212,0.05)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--bd)'
|
||||||
|
e.currentTarget.style.background = 'var(--bg0)'
|
||||||
|
}}>
|
||||||
|
📁 이미지 파일을 선택하세요
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '8px 10px',
|
||||||
|
background: 'var(--bg0)',
|
||||||
|
border: '1px solid var(--bd)',
|
||||||
|
borderRadius: 'var(--rS)',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontFamily: 'var(--fM)'
|
||||||
|
}}>
|
||||||
|
<span style={{ color: 'var(--t2)' }}>📄 {uploadedFileName || 'example_plot_0.gif'}</span>
|
||||||
|
<button
|
||||||
|
onClick={removeUploadedImage}
|
||||||
|
style={{
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: '10px',
|
||||||
|
color: 'var(--t3)',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: '0.15s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--red)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--t3)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dropdowns */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px' }}>
|
||||||
|
<ComboBox
|
||||||
|
className="prd-i"
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '유출회사' },
|
||||||
|
{ value: 'company1', label: '회사A' },
|
||||||
|
{ value: 'company2', label: '회사B' }
|
||||||
|
]}
|
||||||
|
placeholder="유출회사"
|
||||||
|
/>
|
||||||
|
<ComboBox
|
||||||
|
className="prd-i"
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '예상시각' },
|
||||||
|
{ value: '09:00', label: '09:00' },
|
||||||
|
{ value: '12:00', label: '12:00' }
|
||||||
|
]}
|
||||||
|
placeholder="예상시각"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Coordinates + Map Button */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: '4px', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
className="prd-i"
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
value={incidentCoord?.lat ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
|
||||||
|
onCoordChange({ ...incidentCoord, lat: isNaN(value) ? 0 : value })
|
||||||
|
}}
|
||||||
|
placeholder="위도°"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="prd-i"
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
value={incidentCoord?.lon ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
|
||||||
|
onCoordChange({ ...incidentCoord, lon: isNaN(value) ? 0 : value })
|
||||||
|
}}
|
||||||
|
placeholder="경도°"
|
||||||
|
/>
|
||||||
|
<button className="prd-map-btn" onClick={onMapSelectClick}>📍 지도</button>
|
||||||
|
</div>
|
||||||
|
{/* 도분초 표시 */}
|
||||||
|
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '9px',
|
||||||
|
color: 'var(--t3)',
|
||||||
|
fontFamily: 'var(--fM)',
|
||||||
|
padding: '4px 8px',
|
||||||
|
background: 'var(--bg0)',
|
||||||
|
borderRadius: 'var(--rS)',
|
||||||
|
border: '1px solid var(--bd)'
|
||||||
|
}}>
|
||||||
|
{decimalToDMS(incidentCoord.lat, true)} / {decimalToDMS(incidentCoord.lon, false)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Oil Type + Oil Kind */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px' }}>
|
||||||
|
<ComboBox
|
||||||
|
className="prd-i"
|
||||||
|
value={spillType}
|
||||||
|
onChange={onSpillTypeChange}
|
||||||
|
options={[
|
||||||
|
{ value: '연속', label: '연속' },
|
||||||
|
{ value: '비연속', label: '비연속' },
|
||||||
|
{ value: '순간 유출', label: '순간 유출' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ComboBox
|
||||||
|
className="prd-i"
|
||||||
|
value={oilType}
|
||||||
|
onChange={onOilTypeChange}
|
||||||
|
options={[
|
||||||
|
{ value: '벙커C유', label: '벙커C유' },
|
||||||
|
{ value: '경유', label: '경유' },
|
||||||
|
{ value: '원유', label: '원유' },
|
||||||
|
{ value: '중유', label: '중유' },
|
||||||
|
{ value: '등유', label: '등유' },
|
||||||
|
{ value: '휘발유', label: '휘발유' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Volume + Unit + Duration */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 65px 1fr', gap: '4px', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
className="prd-i"
|
||||||
|
placeholder="유출량"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
value={spillAmount}
|
||||||
|
onChange={(e) => onSpillAmountChange(parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
<ComboBox
|
||||||
|
className="prd-i"
|
||||||
|
value="kL"
|
||||||
|
onChange={() => {}}
|
||||||
|
options={[
|
||||||
|
{ value: 'kL', label: 'kL' },
|
||||||
|
{ value: 'ton', label: 'Ton' },
|
||||||
|
{ value: 'barrel', label: '배럴' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ComboBox
|
||||||
|
className="prd-i"
|
||||||
|
value={predictionTime}
|
||||||
|
onChange={(v) => onPredictionTimeChange(parseInt(v))}
|
||||||
|
options={[
|
||||||
|
{ value: '6', label: '6시간' },
|
||||||
|
{ value: '12', label: '12시간' },
|
||||||
|
{ value: '24', label: '24시간' },
|
||||||
|
{ value: '48', label: '48시간' },
|
||||||
|
{ value: '72', label: '72시간' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image Analysis Note (Upload Mode Only) */}
|
||||||
|
{inputMode === 'upload' && uploadedImage && (
|
||||||
|
<div style={{
|
||||||
|
padding: '8px',
|
||||||
|
background: 'rgba(59,130,246,0.08)',
|
||||||
|
border: '1px solid rgba(59,130,246,0.2)',
|
||||||
|
borderRadius: 'var(--rS)',
|
||||||
|
fontSize: '9px',
|
||||||
|
color: 'var(--t3)',
|
||||||
|
fontFamily: 'var(--fK)',
|
||||||
|
lineHeight: '1.4'
|
||||||
|
}}>
|
||||||
|
📊 이미지 내 확산경로를 분석하였습니다. 각 방제요소 가이드 참고하세요.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div style={{ height: '1px', background: 'var(--bd)', margin: '2px 0' }} />
|
||||||
|
|
||||||
|
{/* Model Selection (다중 선택) */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px' }}>
|
||||||
|
{([
|
||||||
|
{ id: 'KOSPS' as PredictionModel, color: 'var(--cyan)' },
|
||||||
|
{ id: 'POSEIDON' as PredictionModel, color: 'var(--red)' },
|
||||||
|
{ id: 'OpenDrift' as PredictionModel, color: 'var(--blue)' },
|
||||||
|
] as const).map(m => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className={`prd-mc ${selectedModels.has(m.id) ? 'on' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
const next = new Set(selectedModels)
|
||||||
|
if (next.has(m.id)) {
|
||||||
|
next.delete(m.id)
|
||||||
|
} else {
|
||||||
|
next.add(m.id)
|
||||||
|
}
|
||||||
|
onModelsChange(next)
|
||||||
|
}}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<span className="prd-md" style={{ background: m.color }} />
|
||||||
|
{m.id}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className={`prd-mc ${selectedModels.size === ALL_MODELS.length ? 'on' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedModels.size === ALL_MODELS.length) {
|
||||||
|
onModelsChange(new Set(['KOSPS']))
|
||||||
|
} else {
|
||||||
|
onModelsChange(new Set(ALL_MODELS))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<span className="prd-md" style={{ background: 'var(--purple)' }} />
|
||||||
|
앙상블
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Run Button */}
|
||||||
|
<button
|
||||||
|
className="prd-btn pri"
|
||||||
|
style={{ padding: '7px', fontSize: '11px', marginTop: '2px' }}
|
||||||
|
onClick={onRunSimulation}
|
||||||
|
disabled={isRunningSimulation}
|
||||||
|
>
|
||||||
|
{isRunningSimulation ? '⏳ 실행 중...' : '🔬 확산예측 실행'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PredictionInputSection
|
||||||
49
frontend/src/tabs/prediction/components/leftPanelTypes.ts
Normal file
49
frontend/src/tabs/prediction/components/leftPanelTypes.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import type { PredictionModel } from './OilSpillView'
|
||||||
|
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
|
||||||
|
import type { Analysis } from './AnalysisListTable'
|
||||||
|
|
||||||
|
export interface LeftPanelProps {
|
||||||
|
selectedAnalysis?: Analysis | null
|
||||||
|
enabledLayers: Set<string>
|
||||||
|
onToggleLayer: (layerId: string, enabled: boolean) => void
|
||||||
|
incidentCoord: { lon: number; lat: number }
|
||||||
|
onCoordChange: (coord: { lon: number; lat: number }) => void
|
||||||
|
onMapSelectClick: () => void
|
||||||
|
onRunSimulation: () => void
|
||||||
|
isRunningSimulation: boolean
|
||||||
|
selectedModels: Set<PredictionModel>
|
||||||
|
onModelsChange: (models: Set<PredictionModel>) => void
|
||||||
|
predictionTime: number
|
||||||
|
onPredictionTimeChange: (time: number) => void
|
||||||
|
spillType: string
|
||||||
|
onSpillTypeChange: (type: string) => void
|
||||||
|
oilType: string
|
||||||
|
onOilTypeChange: (type: string) => void
|
||||||
|
spillAmount: number
|
||||||
|
onSpillAmountChange: (amount: number) => void
|
||||||
|
// 오일펜스 배치 관련
|
||||||
|
boomLines: BoomLine[]
|
||||||
|
onBoomLinesChange: (lines: BoomLine[]) => void
|
||||||
|
oilTrajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>
|
||||||
|
algorithmSettings: AlgorithmSettings
|
||||||
|
onAlgorithmSettingsChange: (settings: AlgorithmSettings) => void
|
||||||
|
isDrawingBoom: boolean
|
||||||
|
onDrawingBoomChange: (drawing: boolean) => void
|
||||||
|
drawingPoints: BoomLineCoord[]
|
||||||
|
onDrawingPointsChange: (points: BoomLineCoord[]) => void
|
||||||
|
containmentResult: ContainmentResult | null
|
||||||
|
onContainmentResultChange: (result: ContainmentResult | null) => void
|
||||||
|
// 레이어 스타일
|
||||||
|
layerOpacity: number
|
||||||
|
onLayerOpacityChange: (val: number) => void
|
||||||
|
layerBrightness: number
|
||||||
|
onLayerBrightnessChange: (val: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpandedSections {
|
||||||
|
predictionInput: boolean
|
||||||
|
incident: boolean
|
||||||
|
impactResources: boolean
|
||||||
|
infoLayer: boolean
|
||||||
|
oilBoom: boolean
|
||||||
|
}
|
||||||
532
frontend/src/tabs/reports/components/ReportGenerator.tsx
Normal file
532
frontend/src/tabs/reports/components/ReportGenerator.tsx
Normal file
@ -0,0 +1,532 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
createEmptyReport,
|
||||||
|
saveReportToStorage,
|
||||||
|
} from './OilSpillReportTemplate';
|
||||||
|
import { consumeReportGenCategory } from '@common/hooks/useSubMenu';
|
||||||
|
import {
|
||||||
|
CATEGORIES,
|
||||||
|
sampleOilData,
|
||||||
|
sampleHnsData,
|
||||||
|
sampleRescueData,
|
||||||
|
type ReportCategory,
|
||||||
|
type ReportSection,
|
||||||
|
} from './reportTypes';
|
||||||
|
import { exportAsPDF } from './reportUtils';
|
||||||
|
|
||||||
|
interface ReportGeneratorProps {
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||||
|
const [activeCat, setActiveCat] = useState<ReportCategory>(() => {
|
||||||
|
const hint = consumeReportGenCategory()
|
||||||
|
return (hint === 0 || hint === 1 || hint === 2) ? hint : 0
|
||||||
|
})
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState(0)
|
||||||
|
const [sectionsMap, setSectionsMap] = useState<Record<number, ReportSection[]>>(() => ({
|
||||||
|
0: CATEGORIES[0].sections.map(s => ({ ...s })),
|
||||||
|
1: CATEGORIES[1].sections.map(s => ({ ...s })),
|
||||||
|
2: CATEGORIES[2].sections.map(s => ({ ...s })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 외부에서 카테고리 힌트가 변경되면 반영
|
||||||
|
useEffect(() => {
|
||||||
|
const hint = consumeReportGenCategory()
|
||||||
|
if (hint === 0 || hint === 1 || hint === 2) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setActiveCat(hint)
|
||||||
|
setSelectedTemplate(0)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const cat = CATEGORIES[activeCat]
|
||||||
|
const sections = sectionsMap[activeCat]
|
||||||
|
const activeSections = sections.filter(s => s.checked)
|
||||||
|
|
||||||
|
const toggleSection = (id: string) => {
|
||||||
|
setSectionsMap(prev => ({
|
||||||
|
...prev,
|
||||||
|
[activeCat]: prev[activeCat].map(s => s.id === id ? { ...s, checked: !s.checked } : s),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const report = createEmptyReport()
|
||||||
|
report.reportType = activeCat === 0 ? '예측보고서' : activeCat === 1 ? '종합보고서' : '초기보고서'
|
||||||
|
report.analysisCategory = activeCat === 0 ? '유출유 확산예측' : activeCat === 1 ? 'HNS 대기확산' : '긴급구난'
|
||||||
|
report.title = cat.reportName
|
||||||
|
report.status = '완료'
|
||||||
|
report.author = '시스템 자동생성'
|
||||||
|
if (activeCat === 0) {
|
||||||
|
report.incident.pollutant = sampleOilData.pollution.oilType
|
||||||
|
report.incident.spillAmount = sampleOilData.pollution.spillAmount
|
||||||
|
}
|
||||||
|
saveReportToStorage(report)
|
||||||
|
onSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
const sectionHTML = activeSections.map(sec => {
|
||||||
|
return `<h3 style="color:${cat.color === 'var(--cyan)' ? '#06b6d4' : cat.color === 'var(--orange)' ? '#f97316' : '#ef4444'};font-size:14px;margin:20px 0 8px;">${sec.icon} ${sec.title}</h3><p style="font-size:12px;color:#666;">${sec.desc}</p>`
|
||||||
|
}).join('')
|
||||||
|
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${cat.reportName}</title><style>@page{size:A4;margin:20mm}body{font-family:'맑은 고딕',sans-serif;color:#1a1a1a;max-width:800px;margin:0 auto;padding:40px}</style></head><body><div style="text-align:center;margin-bottom:30px"><h1 style="font-size:20px;margin:0">해양환경 위기대응 통합지원시스템</h1><h2 style="font-size:16px;color:#0891b2;margin:8px 0">${cat.reportName}</h2></div>${sectionHTML}</body></html>`
|
||||||
|
exportAsPDF(html, cat.reportName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full w-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-border bg-bg-1">
|
||||||
|
<h2 className="text-[16px] font-bold text-text-1 font-korean">보고서 생성</h2>
|
||||||
|
<p className="text-[11px] text-text-3 font-korean mt-1">보고서 유형을 선택하고 포함할 섹션을 구성하여 보고서를 생성합니다.</p>
|
||||||
|
|
||||||
|
{/* 3 카테고리 카드 */}
|
||||||
|
<div style={{ display: 'flex', gap: '14px', marginTop: '16px' }}>
|
||||||
|
{CATEGORIES.map((c, i) => {
|
||||||
|
const isActive = activeCat === i
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => { setActiveCat(i as ReportCategory); setSelectedTemplate(0) }}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '14px 16px', borderRadius: '10px', cursor: 'pointer',
|
||||||
|
textAlign: 'center', transition: '0.2s',
|
||||||
|
border: `1px solid ${isActive ? c.borderColor : 'var(--bd)'}`,
|
||||||
|
background: isActive ? c.bgActive : 'var(--bg3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '22px', marginBottom: '4px' }}>{c.icon}</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px', fontWeight: 700, fontFamily: 'var(--fK)',
|
||||||
|
color: isActive ? c.color : 'var(--t3)',
|
||||||
|
}}>
|
||||||
|
{c.label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '9px', color: 'var(--t3)', fontFamily: 'var(--fK)', marginTop: '2px' }}>
|
||||||
|
{c.desc}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Left Sidebar - Template + Sections */}
|
||||||
|
<div className="w-[250px] min-w-[250px] border-r border-border bg-bg-1 flex flex-col overflow-y-auto shrink-0">
|
||||||
|
{/* 템플릿 선택 */}
|
||||||
|
<div className="px-4 py-3 border-b border-border">
|
||||||
|
<h3 className="text-[11px] font-bold text-text-2 font-korean flex items-center gap-2">📄 보고서 템플릿</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 px-3 py-2 border-b border-border">
|
||||||
|
{cat.templates.map((tmpl, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setSelectedTemplate(i)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2.5 rounded-lg transition-all text-left"
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${selectedTemplate === i ? cat.borderColor : 'var(--bd)'}`,
|
||||||
|
background: selectedTemplate === i ? cat.bgActive : 'var(--bg2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '14px' }}>{tmpl.icon}</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '11px', fontWeight: 600, fontFamily: 'var(--fK)',
|
||||||
|
color: selectedTemplate === i ? cat.color : 'var(--t2)',
|
||||||
|
}}>
|
||||||
|
{tmpl.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 섹션 체크 */}
|
||||||
|
<div className="px-4 py-3 border-b border-border">
|
||||||
|
<h3 className="text-[11px] font-bold text-text-2 font-korean flex items-center gap-2">📋 포함 섹션</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 p-3">
|
||||||
|
{sections.map(sec => (
|
||||||
|
<button
|
||||||
|
key={sec.id}
|
||||||
|
onClick={() => toggleSection(sec.id)}
|
||||||
|
className="flex items-start gap-2.5 p-2.5 rounded-lg border transition-all text-left"
|
||||||
|
style={{
|
||||||
|
borderColor: sec.checked ? cat.borderColor : 'var(--bd)',
|
||||||
|
background: sec.checked ? cat.bgActive : 'var(--bg2)',
|
||||||
|
opacity: sec.checked ? 1 : 0.55,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: '18px', height: '18px', borderRadius: '4px', flexShrink: 0, marginTop: '1px',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '10px',
|
||||||
|
background: sec.checked ? cat.color : 'var(--bg3)',
|
||||||
|
color: sec.checked ? '#fff' : 'transparent',
|
||||||
|
border: sec.checked ? 'none' : '1px solid var(--bd)',
|
||||||
|
}}>
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<span style={{ fontSize: '12px' }}>{sec.icon}</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)',
|
||||||
|
color: sec.checked ? 'var(--t1)' : 'var(--t3)',
|
||||||
|
}}>
|
||||||
|
{sec.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: '9px', color: 'var(--t3)', fontFamily: 'var(--fK)', marginTop: '2px' }}>
|
||||||
|
{sec.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right - Report Preview */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Preview Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-b border-border bg-bg-1">
|
||||||
|
<h3 className="text-[13px] font-bold text-text-1 font-korean flex items-center gap-2">
|
||||||
|
📄 보고서 미리보기
|
||||||
|
<span style={{
|
||||||
|
fontSize: '10px', fontWeight: 600, padding: '2px 8px', borderRadius: '4px',
|
||||||
|
background: cat.bgActive, color: cat.color, fontFamily: 'var(--fK)',
|
||||||
|
}}>
|
||||||
|
{cat.templates[selectedTemplate].label}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="px-3 py-1.5 text-[11px] font-semibold rounded transition-all font-korean flex items-center gap-1.5"
|
||||||
|
style={{ background: cat.bgActive, border: `1px solid ${cat.borderColor}`, color: cat.color }}
|
||||||
|
>
|
||||||
|
📥 다운로드
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="px-3 py-1.5 text-[11px] font-semibold rounded border border-border bg-bg-2 text-text-1 hover:bg-bg-hover transition-all font-korean flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
💾 저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
{/* Report Header */}
|
||||||
|
<div className="rounded-lg border border-border p-8 mb-6" style={{ background: 'var(--bg2)' }}>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-[10px] text-text-3 font-korean mb-2">해양환경 위기대응 통합지원시스템</p>
|
||||||
|
<h2 className="text-[20px] font-bold text-text-1 font-korean mb-2">{cat.reportName}</h2>
|
||||||
|
<p className="text-[12px] font-korean" style={{ color: cat.color }}>{cat.templates[selectedTemplate].label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dynamic Sections */}
|
||||||
|
{activeSections.map(sec => (
|
||||||
|
<div key={sec.id} className="rounded-lg border border-border mb-4 overflow-hidden" style={{ background: 'var(--bg2)' }}>
|
||||||
|
<div className="px-5 py-3 border-b border-border">
|
||||||
|
<h4 className="text-[13px] font-bold text-text-1 font-korean flex items-center gap-2">
|
||||||
|
{sec.icon} {sec.title}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
{/* ── 유출유 확산예측 섹션들 ── */}
|
||||||
|
{sec.id === 'oil-spread' && (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-[140px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean mb-4">
|
||||||
|
[확산예측 지도 - 범위 조절 작업]
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: 'KOSPS', value: sampleOilData.spread.kosps, color: '#06b6d4' },
|
||||||
|
{ label: 'OpenDrift', value: sampleOilData.spread.openDrift, color: '#ef4444' },
|
||||||
|
{ label: 'POSEIDON', value: sampleOilData.spread.poseidon, color: '#f97316' },
|
||||||
|
].map((m, i) => (
|
||||||
|
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
|
||||||
|
<p className="text-[10px] text-text-3 font-korean mb-1">{m.label}</p>
|
||||||
|
<p className="text-[20px] font-bold font-mono" style={{ color: m.color }}>{m.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{sec.id === 'oil-pollution' && (
|
||||||
|
<table className="w-full table-fixed" style={{ borderCollapse: 'collapse' }}>
|
||||||
|
<colgroup><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /></colgroup>
|
||||||
|
<tbody>
|
||||||
|
{[
|
||||||
|
['유출량', sampleOilData.pollution.spillAmount, '풍화량', sampleOilData.pollution.weathered],
|
||||||
|
['해상잔유량', sampleOilData.pollution.seaRemain, '오염해역면적', sampleOilData.pollution.pollutionArea],
|
||||||
|
['연안부착량', sampleOilData.pollution.coastAttach, '오염해안길이', sampleOilData.pollution.coastLength],
|
||||||
|
].map((row, i) => (
|
||||||
|
<tr key={i} className="border-b border-border">
|
||||||
|
<td className="px-4 py-3 text-[11px] text-text-3 font-korean bg-[rgba(255,255,255,0.02)]">{row[0]}</td>
|
||||||
|
<td className="px-4 py-3 text-[12px] text-text-1 font-mono font-semibold text-right">{row[1]}</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] text-text-3 font-korean bg-[rgba(255,255,255,0.02)]">{row[2]}</td>
|
||||||
|
<td className="px-4 py-3 text-[12px] text-text-1 font-mono font-semibold text-right">{row[3]}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
{sec.id === 'oil-sensitive' && (
|
||||||
|
<>
|
||||||
|
<p className="text-[11px] text-text-3 font-korean mb-3">반경 10 NM 기준</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{sampleOilData.sensitive.map((item, i) => (
|
||||||
|
<span key={i} className="px-3 py-1.5 text-[11px] font-semibold rounded-md bg-bg-3 border border-border text-text-2 font-korean">{item.label}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{sec.id === 'oil-coastal' && (
|
||||||
|
<p className="text-[12px] text-text-2 font-korean">
|
||||||
|
최초 부착시간: <span className="font-semibold text-text-1">{sampleOilData.coastal.firstTime}</span>
|
||||||
|
{' / '}
|
||||||
|
부착 해안길이: <span className="font-semibold text-text-1">{sampleOilData.coastal.coastLength}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{sec.id === 'oil-defense' && (
|
||||||
|
<div className="text-[12px] text-text-3 font-korean">
|
||||||
|
<p className="mb-2">방제자원 배치 계획에 따른 전략을 수립합니다.</p>
|
||||||
|
<div className="w-full h-[100px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean">
|
||||||
|
[방제자원 배치 지도]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sec.id === 'oil-tide' && (
|
||||||
|
<p className="text-[12px] text-text-2 font-korean">
|
||||||
|
고조: <span className="font-semibold text-text-1">{sampleOilData.tide.highTide1}</span>
|
||||||
|
{' / '}저조: <span className="font-semibold text-text-1">{sampleOilData.tide.lowTide}</span>
|
||||||
|
{' / '}고조: <span className="font-semibold text-text-1">{sampleOilData.tide.highTide2}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── HNS 대기확산 섹션들 ── */}
|
||||||
|
{sec.id === 'hns-atm' && (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-[140px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean mb-4">
|
||||||
|
[대기확산 예측 지도]
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: 'ALOHA', value: sampleHnsData.atm.aloha, color: '#f97316' },
|
||||||
|
{ label: 'WRF-Chem', value: sampleHnsData.atm.wrfChem, color: '#22c55e' },
|
||||||
|
].map((m, i) => (
|
||||||
|
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
|
||||||
|
<p className="text-[10px] text-text-3 font-korean mb-1">{m.label} 최대 확산거리</p>
|
||||||
|
<p className="text-[20px] font-bold font-mono" style={{ color: m.color }}>{m.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{sec.id === 'hns-hazard' && (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: 'ERPG-2 구역', value: sampleHnsData.hazard.erpg2, color: '#f97316', desc: '건강 영향' },
|
||||||
|
{ label: 'ERPG-3 구역', value: sampleHnsData.hazard.erpg3, color: '#ef4444', desc: '생명 위협' },
|
||||||
|
{ label: '대피 권고 범위', value: sampleHnsData.hazard.evacuation, color: '#a855f7', desc: '안전거리' },
|
||||||
|
].map((h, i) => (
|
||||||
|
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
|
||||||
|
<p className="text-[9px] font-korean mb-1" style={{ color: h.color, fontWeight: 700 }}>{h.label}</p>
|
||||||
|
<p className="text-[18px] font-bold font-mono" style={{ color: h.color }}>{h.value}</p>
|
||||||
|
<p className="text-[8px] text-text-3 font-korean mt-1">{h.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sec.id === 'hns-substance' && (
|
||||||
|
<div className="grid grid-cols-2 gap-2" style={{ fontSize: '11px' }}>
|
||||||
|
{[
|
||||||
|
{ k: '물질명', v: sampleHnsData.substance.name },
|
||||||
|
{ k: 'UN번호', v: sampleHnsData.substance.un },
|
||||||
|
{ k: 'CAS번호', v: sampleHnsData.substance.cas },
|
||||||
|
{ k: '위험등급', v: sampleHnsData.substance.class },
|
||||||
|
].map((r, i) => (
|
||||||
|
<div key={i} className="flex justify-between px-3 py-2 bg-bg-1 rounded border border-border">
|
||||||
|
<span className="text-text-3 font-korean">{r.k}</span>
|
||||||
|
<span className="text-text-1 font-semibold font-mono">{r.v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="col-span-2 flex justify-between px-3 py-2 bg-bg-1 rounded border border-border" style={{ borderColor: 'rgba(239,68,68,0.3)' }}>
|
||||||
|
<span className="text-text-3 font-korean">독성기준</span>
|
||||||
|
<span style={{ color: 'var(--red)', fontWeight: 600, fontFamily: 'var(--fM)', fontSize: '10px' }}>{sampleHnsData.substance.toxicity}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sec.id === 'hns-ppe' && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{sampleHnsData.ppe.map((item, i) => (
|
||||||
|
<span key={i} className="px-3 py-1.5 text-[11px] font-semibold rounded-md border text-text-2 font-korean" style={{ background: 'rgba(249,115,22,0.06)', borderColor: 'rgba(249,115,22,0.2)' }}>
|
||||||
|
🛡 {item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sec.id === 'hns-facility' && (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: '인근 학교', value: `${sampleHnsData.facility.schools}개소`, icon: '🏫' },
|
||||||
|
{ label: '의료시설', value: `${sampleHnsData.facility.hospitals}개소`, icon: '🏥' },
|
||||||
|
{ label: '주변 인구', value: sampleHnsData.facility.population, icon: '👥' },
|
||||||
|
].map((f, i) => (
|
||||||
|
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
|
||||||
|
<div style={{ fontSize: '18px', marginBottom: '4px' }}>{f.icon}</div>
|
||||||
|
<p className="text-[14px] font-bold text-text-1 font-mono">{f.value}</p>
|
||||||
|
<p className="text-[9px] text-text-3 font-korean mt-1">{f.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sec.id === 'hns-3d' && (
|
||||||
|
<div className="w-full h-[160px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean">
|
||||||
|
[3D 농도 분포 시각화]
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sec.id === 'hns-weather' && (
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: '풍향', value: 'NE 42°', icon: '🌬' },
|
||||||
|
{ label: '풍속', value: '5.2 m/s', icon: '💨' },
|
||||||
|
{ label: '대기안정도', value: 'D (중립)', icon: '🌡' },
|
||||||
|
{ label: '기온', value: '8.5°C', icon: '☀️' },
|
||||||
|
].map((w, i) => (
|
||||||
|
<div key={i} className="bg-bg-1 border border-border rounded-lg p-3 text-center">
|
||||||
|
<div style={{ fontSize: '16px', marginBottom: '2px' }}>{w.icon}</div>
|
||||||
|
<p className="text-[13px] font-bold text-text-1 font-mono">{w.value}</p>
|
||||||
|
<p className="text-[8px] text-text-3 font-korean mt-1">{w.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 긴급구난 섹션들 ── */}
|
||||||
|
{sec.id === 'rescue-safety' && (
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: 'GM (복원력)', value: sampleRescueData.safety.gm, color: '#f97316' },
|
||||||
|
{ label: '경사각 (Heel)', value: sampleRescueData.safety.heel, color: '#ef4444' },
|
||||||
|
{ label: '트림 (Trim)', value: sampleRescueData.safety.trim, color: '#06b6d4' },
|
||||||
|
{ label: '안전 상태', value: sampleRescueData.safety.status, color: '#f97316' },
|
||||||
|
].map((s, i) => (
|
||||||
|
<div key={i} className="bg-bg-1 border border-border rounded-lg p-3 text-center">
|
||||||
|
<p className="text-[9px] text-text-3 font-korean mb-1">{s.label}</p>
|
||||||
|
<p className="text-[16px] font-bold font-mono" style={{ color: s.color }}>{s.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sec.id === 'rescue-timeline' && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{[
|
||||||
|
{ time: '06:28', event: '충돌 발생 — ORIENTAL GLORY ↔ HAI FENG 168', color: '#ef4444' },
|
||||||
|
{ time: '06:30', event: 'No.1P 탱크 파공, 벙커C유 유출 개시', color: '#f97316' },
|
||||||
|
{ time: '06:35', event: 'VHF Ch.16 조난통신, 해경 출동 요청', color: '#eab308' },
|
||||||
|
{ time: '07:15', event: '해경 3009함 현장 도착, 방제 개시', color: '#06b6d4' },
|
||||||
|
].map((e, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 px-3 py-2 bg-bg-1 rounded border border-border">
|
||||||
|
<span className="font-mono text-[11px] font-bold" style={{ color: e.color, minWidth: '40px' }}>{e.time}</span>
|
||||||
|
<span className="text-[11px] text-text-2 font-korean">{e.event}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sec.id === 'rescue-casualty' && (
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: '총원', value: sampleRescueData.casualty.total, color: 'var(--t1)' },
|
||||||
|
{ label: '구조완료', value: sampleRescueData.casualty.rescued, color: '#22c55e' },
|
||||||
|
{ label: '실종', value: sampleRescueData.casualty.missing, color: '#ef4444' },
|
||||||
|
{ label: '부상', value: sampleRescueData.casualty.injured, color: '#f97316' },
|
||||||
|
].map((c, i) => (
|
||||||
|
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
|
||||||
|
<p className="text-[9px] text-text-3 font-korean mb-1">{c.label}</p>
|
||||||
|
<p className="text-[24px] font-bold font-mono" style={{ color: c.color }}>{c.value}</p>
|
||||||
|
<p className="text-[8px] text-text-3 font-korean mt-0.5">명</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sec.id === 'rescue-resource' && (
|
||||||
|
<table className="w-full" style={{ borderCollapse: 'collapse', fontSize: '11px' }}>
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th className="px-3 py-2 text-left text-text-3 font-korean">유형</th>
|
||||||
|
<th className="px-3 py-2 text-left text-text-3 font-korean">선박/장비명</th>
|
||||||
|
<th className="px-3 py-2 text-center text-text-3 font-korean">도착예정</th>
|
||||||
|
<th className="px-3 py-2 text-center text-text-3 font-korean">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sampleRescueData.resources.map((r, i) => (
|
||||||
|
<tr key={i} className="border-b border-border">
|
||||||
|
<td className="px-3 py-2 text-text-2 font-korean">{r.type}</td>
|
||||||
|
<td className="px-3 py-2 text-text-1 font-mono font-semibold">{r.name}</td>
|
||||||
|
<td className="px-3 py-2 text-text-2 text-center font-mono">{r.eta}</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<span className="px-2 py-0.5 rounded text-[10px] font-semibold font-korean" style={{
|
||||||
|
background: r.status === '투입중' ? 'rgba(34,197,94,0.15)' : r.status === '이동중' ? 'rgba(249,115,22,0.15)' : 'rgba(138,150,168,0.15)',
|
||||||
|
color: r.status === '투입중' ? '#22c55e' : r.status === '이동중' ? '#f97316' : '#8a96a8',
|
||||||
|
}}>
|
||||||
|
{r.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
{sec.id === 'rescue-grounding' && (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: '좌초 위험도', value: sampleRescueData.grounding.risk, color: '#ef4444' },
|
||||||
|
{ label: '최근 천해', value: sampleRescueData.grounding.nearestShallow, color: '#f97316' },
|
||||||
|
{ label: '현재 수심', value: sampleRescueData.grounding.depth, color: '#06b6d4' },
|
||||||
|
].map((g, i) => (
|
||||||
|
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
|
||||||
|
<p className="text-[9px] text-text-3 font-korean mb-1">{g.label}</p>
|
||||||
|
<p className="text-[14px] font-bold font-mono" style={{ color: g.color }}>{g.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sec.id === 'rescue-weather' && (
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: '파고', value: '1.5 m', icon: '🌊' },
|
||||||
|
{ label: '풍속', value: '5.2 m/s', icon: '🌬' },
|
||||||
|
{ label: '조류', value: '1.2 kts NE', icon: '🌀' },
|
||||||
|
{ label: '시정', value: '8 km', icon: '👁' },
|
||||||
|
].map((w, i) => (
|
||||||
|
<div key={i} className="bg-bg-1 border border-border rounded-lg p-3 text-center">
|
||||||
|
<div style={{ fontSize: '16px', marginBottom: '2px' }}>{w.icon}</div>
|
||||||
|
<p className="text-[13px] font-bold text-text-1 font-mono">{w.value}</p>
|
||||||
|
<p className="text-[8px] text-text-3 font-korean mt-1">{w.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{activeSections.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-text-3">
|
||||||
|
<div className="text-4xl mb-4">📋</div>
|
||||||
|
<p className="text-sm font-korean">왼쪽에서 보고서에 포함할 섹션을 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReportGenerator;
|
||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
301
frontend/src/tabs/reports/components/TemplateFormEditor.tsx
Normal file
301
frontend/src/tabs/reports/components/TemplateFormEditor.tsx
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
createEmptyReport,
|
||||||
|
saveReportToStorage,
|
||||||
|
type ReportType,
|
||||||
|
type Jurisdiction,
|
||||||
|
} from './OilSpillReportTemplate';
|
||||||
|
import { templateTypes } from './reportTypes';
|
||||||
|
import { generateReportHTML, exportAsPDF, exportAsHWP } from './reportUtils';
|
||||||
|
|
||||||
|
interface TemplateFormEditorProps {
|
||||||
|
onSave: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||||
|
const [selectedType, setSelectedType] = useState<ReportType>('초기보고서')
|
||||||
|
const [formData, setFormData] = useState<Record<string, string>>({})
|
||||||
|
const [reportMeta, setReportMeta] = useState(() => {
|
||||||
|
const now = new Date()
|
||||||
|
return {
|
||||||
|
title: '',
|
||||||
|
author: '',
|
||||||
|
jurisdiction: '남해청' as Jurisdiction,
|
||||||
|
writeTime: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const [autoSave, setAutoSave] = useState(false)
|
||||||
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
|
|
||||||
|
const template = templateTypes.find(t => t.id === selectedType)!
|
||||||
|
|
||||||
|
const getVal = (key: string) => {
|
||||||
|
if (key === 'author') return reportMeta.author
|
||||||
|
if (key === 'incident.writeTime') return reportMeta.writeTime
|
||||||
|
return formData[key] || ''
|
||||||
|
}
|
||||||
|
const setVal = (key: string, val: string) => {
|
||||||
|
if (key === 'author') { setReportMeta(p => ({ ...p, author: val })); return }
|
||||||
|
if (key === 'incident.writeTime') { setReportMeta(p => ({ ...p, writeTime: val })); return }
|
||||||
|
setFormData(p => ({ ...p, [key]: val }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const report = createEmptyReport()
|
||||||
|
report.reportType = selectedType
|
||||||
|
report.jurisdiction = reportMeta.jurisdiction
|
||||||
|
report.author = reportMeta.author
|
||||||
|
report.title = formData['incident.name'] || `${selectedType} ${reportMeta.writeTime}`
|
||||||
|
report.status = '완료'
|
||||||
|
report.incident.writeTime = reportMeta.writeTime
|
||||||
|
// Map all incident fields
|
||||||
|
const incFields = ['name', 'occurTime', 'location', 'shipName', 'accidentType', 'pollutant', 'spillAmount', 'lat', 'lon', 'depth', 'seabed'] as const
|
||||||
|
incFields.forEach(f => {
|
||||||
|
const val = formData[`incident.${f}`]
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
if (val) (report.incident as any)[f] = val
|
||||||
|
})
|
||||||
|
report.analysis = formData['spreadAnalysis'] || formData['initialResponse'] || formData['responseStatus'] || formData['responseDetail'] || ''
|
||||||
|
saveReportToStorage(report)
|
||||||
|
onSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
const doExport = (format: 'pdf' | 'hwp') => {
|
||||||
|
const html = generateReportHTML(
|
||||||
|
template.label,
|
||||||
|
{ writeTime: reportMeta.writeTime, author: reportMeta.author, jurisdiction: reportMeta.jurisdiction },
|
||||||
|
template.sections,
|
||||||
|
getVal
|
||||||
|
)
|
||||||
|
const filename = formData['incident.name'] || `${template.label}_${reportMeta.writeTime.replace(/[\s:]/g, '_')}`
|
||||||
|
if (format === 'pdf') exportAsPDF(html, filename)
|
||||||
|
else exportAsHWP(html, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full">
|
||||||
|
{/* Left Sidebar - Template Selection */}
|
||||||
|
<div className="w-60 min-w-[240px] border-r border-border bg-bg-1 flex flex-col py-4 px-3 gap-2 overflow-y-auto shrink-0">
|
||||||
|
<div className="px-1 mb-2">
|
||||||
|
<h3 className="text-[13px] font-bold text-text-1 font-korean">표준보고서 템플릿 선택</h3>
|
||||||
|
<p className="text-[9px] text-text-3 font-korean mt-1">템플릿을 선택하면 오른쪽에 작성 양식이 표시됩니다.</p>
|
||||||
|
</div>
|
||||||
|
{templateTypes.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setSelectedType(t.id)}
|
||||||
|
className={`flex flex-col items-start p-3 rounded-lg border transition-all text-left ${
|
||||||
|
selectedType === t.id
|
||||||
|
? 'border-primary-cyan bg-[rgba(6,182,212,0.08)]'
|
||||||
|
: 'border-border bg-bg-2 hover:border-border-light'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-lg mb-1">{t.icon}</span>
|
||||||
|
<span className={`text-[12px] font-bold font-korean ${selectedType === t.id ? 'text-primary-cyan' : 'text-text-1'}`}>{t.label}</span>
|
||||||
|
<span className="text-[9px] text-text-3 font-korean mt-0.5">{t.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right - Form */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Form Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-b border-border bg-bg-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-lg">{template.icon}</span>
|
||||||
|
<span className="text-[15px] font-bold text-text-1 font-korean">{template.label}</span>
|
||||||
|
<span className="px-2 py-0.5 text-[9px] font-semibold rounded font-korean" style={{ background: 'rgba(6,182,212,0.15)', color: '#06b6d4' }}>작성중</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-[10px] text-text-3 font-korean">자동저장:</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setAutoSave(!autoSave)}
|
||||||
|
className={`relative w-9 h-[18px] rounded-full transition-all ${autoSave ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-[2px] w-3.5 h-3.5 rounded-full bg-white shadow transition-all ${autoSave ? 'left-[18px]' : 'left-[2px]'}`} />
|
||||||
|
</button>
|
||||||
|
<span className="text-[10px] font-semibold font-korean" style={{ color: autoSave ? '#06b6d4' : 'var(--t3)' }}>{autoSave ? 'ON' : 'OFF'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
{template.sections.map((section, sIdx) => (
|
||||||
|
<div key={sIdx} className="mb-6 w-full">
|
||||||
|
<h4 className="text-[13px] font-bold font-korean mb-3" style={{ color: '#06b6d4' }}>{section.title}</h4>
|
||||||
|
<table className="w-full table-fixed border-collapse">
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: '180px' }} />
|
||||||
|
<col />
|
||||||
|
</colgroup>
|
||||||
|
<tbody>
|
||||||
|
{section.fields.map((field, fIdx) => (
|
||||||
|
<tr key={fIdx} className="border-b border-border">
|
||||||
|
{field.label ? (
|
||||||
|
<>
|
||||||
|
<td className="px-4 py-3 text-[11px] font-semibold text-text-3 font-korean bg-[rgba(255,255,255,0.03)] align-middle">
|
||||||
|
{field.label}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 align-middle">
|
||||||
|
{field.type === 'text' && (
|
||||||
|
<input
|
||||||
|
value={getVal(field.key)}
|
||||||
|
onChange={e => setVal(field.key, e.target.value)}
|
||||||
|
placeholder={`${field.label} 입력`}
|
||||||
|
className="w-full bg-transparent text-[12px] text-text-1 font-korean outline-none placeholder-text-3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{field.type === 'checkbox-group' && field.options && (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{field.options.map(opt => (
|
||||||
|
<label key={opt} className="flex items-center gap-1.5 text-[11px] text-text-2 font-korean cursor-pointer">
|
||||||
|
<input type="checkbox" className="accent-[#06b6d4] w-3.5 h-3.5" />
|
||||||
|
{opt}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<td colSpan={2} className="px-4 py-3">
|
||||||
|
<textarea
|
||||||
|
value={getVal(field.key)}
|
||||||
|
onChange={e => setVal(field.key, e.target.value)}
|
||||||
|
placeholder="내용을 입력하세요..."
|
||||||
|
className="w-full min-h-[120px] bg-bg-2 border border-border rounded-md px-3 py-2 text-[12px] text-text-1 font-korean outline-none placeholder-text-3 resize-y focus:border-primary-cyan"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Action Bar */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-t border-border bg-bg-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => doExport('pdf')} className="px-3 py-2 text-[11px] font-semibold rounded bg-status-red text-white hover:opacity-90 transition-all">PDF</button>
|
||||||
|
<button onClick={() => doExport('hwp')} className="px-3 py-2 text-[11px] font-semibold rounded bg-[#2563eb] text-white hover:opacity-90 transition-all">HWP</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="px-4 py-2 text-[11px] font-semibold rounded text-text-3 hover:text-text-1 transition-all font-korean"
|
||||||
|
>
|
||||||
|
임시저장
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPreview(true)}
|
||||||
|
className="px-4 py-2 text-[11px] font-semibold rounded border border-primary-cyan text-primary-cyan hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
미리보기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="px-5 py-2 text-[11px] font-semibold rounded bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean flex items-center gap-1"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Modal */}
|
||||||
|
{showPreview && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.6)' }}>
|
||||||
|
<div className="bg-bg-0 border border-border rounded-xl shadow-2xl flex flex-col" style={{ width: 'calc(100vw - 48px)', height: 'calc(100vh - 48px)' }}>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-lg">{template.icon}</span>
|
||||||
|
<span className="text-[15px] font-bold text-text-1 font-korean">{template.label}</span>
|
||||||
|
<span className="px-2 py-0.5 text-[9px] font-semibold rounded font-korean" style={{ background: 'rgba(6,182,212,0.15)', color: '#06b6d4' }}>미리보기</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPreview(false)}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-text-3 hover:text-text-1 hover:bg-bg-2 transition-all text-lg"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body - Report Preview */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
<div className="w-full">
|
||||||
|
{/* Report Title */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-[18px] font-bold text-text-1 font-korean mb-1">해양오염방제지원시스템</h2>
|
||||||
|
<h3 className="text-[15px] font-semibold font-korean" style={{ color: '#06b6d4' }}>
|
||||||
|
{formData['incident.name'] || template.label}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-text-3 font-korean mt-2">
|
||||||
|
작성일시: {reportMeta.writeTime} | 작성자: {reportMeta.author || '-'} | 관할: {reportMeta.jurisdiction}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sections */}
|
||||||
|
{template.sections.map((section, sIdx) => (
|
||||||
|
<div key={sIdx} className="mb-5">
|
||||||
|
<h4 className="text-[13px] font-bold font-korean mb-2 px-2 py-1.5 rounded" style={{ color: '#06b6d4', background: 'rgba(6,182,212,0.06)' }}>
|
||||||
|
{section.title}
|
||||||
|
</h4>
|
||||||
|
<table className="w-full table-fixed border-collapse border border-border">
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: '200px' }} />
|
||||||
|
<col />
|
||||||
|
</colgroup>
|
||||||
|
<tbody>
|
||||||
|
{section.fields.map((field, fIdx) => {
|
||||||
|
const val = getVal(field.key)
|
||||||
|
return field.label ? (
|
||||||
|
<tr key={fIdx} className="border-b border-border">
|
||||||
|
<td className="px-4 py-2.5 text-[11px] font-semibold text-text-3 font-korean bg-[rgba(255,255,255,0.03)] border-r border-border align-middle">
|
||||||
|
{field.label}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-[12px] text-text-1 font-korean align-middle">
|
||||||
|
{val || <span className="text-text-3">-</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
<tr key={fIdx} className="border-b border-border">
|
||||||
|
<td colSpan={2} className="px-4 py-3 text-[12px] text-text-1 font-korean whitespace-pre-wrap">
|
||||||
|
{val || <span className="text-text-3">내용 없음</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className="flex items-center justify-end gap-3 px-6 py-3 border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPreview(false)}
|
||||||
|
className="px-4 py-2 text-[11px] font-semibold rounded text-text-3 hover:text-text-1 transition-all font-korean"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowPreview(false); handleSave() }}
|
||||||
|
className="px-5 py-2 text-[11px] font-semibold rounded bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TemplateFormEditor;
|
||||||
331
frontend/src/tabs/reports/components/reportTypes.ts
Normal file
331
frontend/src/tabs/reports/components/reportTypes.ts
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
import type { ReportType } from './OilSpillReportTemplate';
|
||||||
|
|
||||||
|
// ─── Template definitions ────────────────────────────────
|
||||||
|
export interface TemplateType {
|
||||||
|
id: ReportType
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
desc: string
|
||||||
|
sections: { title: string; fields: { key: string; label: string; type: 'text' | 'textarea' | 'date' | 'checkbox-group'; options?: string[] }[] }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const templateTypes: TemplateType[] = [
|
||||||
|
{
|
||||||
|
id: '초기보고서', icon: '📋', label: '초기보고서', desc: '사고 발생 직후 초기 상황 보고',
|
||||||
|
sections: [
|
||||||
|
{ title: '1. 기본정보', fields: [
|
||||||
|
{ key: 'incident.writeTime', label: '보고일시', type: 'text' },
|
||||||
|
{ key: 'author', label: '작성자', type: 'text' },
|
||||||
|
{ key: 'targets', label: '보고대상', type: 'checkbox-group', options: ['본청', '지방청', '유관기서'] },
|
||||||
|
]},
|
||||||
|
{ title: '2. 사고개요', fields: [
|
||||||
|
{ key: 'incident.name', label: '사고명', type: 'text' },
|
||||||
|
{ key: 'incident.occurTime', label: '발생일시', type: 'text' },
|
||||||
|
{ key: 'incident.location', label: '발생위치', type: 'text' },
|
||||||
|
{ key: 'incident.shipName', label: '사고선박', type: 'text' },
|
||||||
|
{ key: 'incident.accidentType', label: '사고유형', type: 'text' },
|
||||||
|
]},
|
||||||
|
{ title: '3. 유출현황', fields: [
|
||||||
|
{ key: 'incident.pollutant', label: '유출유종', type: 'text' },
|
||||||
|
{ key: 'incident.spillAmount', label: '유출량', type: 'text' },
|
||||||
|
{ key: 'spillPattern', label: '유출형태', type: 'text' },
|
||||||
|
{ key: 'spreadStatus', label: '확산현황', type: 'text' },
|
||||||
|
]},
|
||||||
|
{ title: '4. 초동조치 사항', fields: [
|
||||||
|
{ key: 'initialResponse', label: '', type: 'textarea' },
|
||||||
|
]},
|
||||||
|
{ title: '5. 향후 대응계획', fields: [
|
||||||
|
{ key: 'futurePlan', label: '', type: 'textarea' },
|
||||||
|
]},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '지휘부 보고', icon: '📊', label: '지휘부 보고', desc: '지휘부 보고용 요약 문서',
|
||||||
|
sections: [
|
||||||
|
{ title: '1. 기본정보', fields: [
|
||||||
|
{ key: 'incident.writeTime', label: '보고일시', type: 'text' },
|
||||||
|
{ key: 'author', label: '작성자', type: 'text' },
|
||||||
|
]},
|
||||||
|
{ title: '2. 사고 요약', fields: [
|
||||||
|
{ key: 'incident.name', label: '사고명', type: 'text' },
|
||||||
|
{ key: 'incident.occurTime', label: '발생일시', type: 'text' },
|
||||||
|
{ key: 'incident.location', label: '발생위치', type: 'text' },
|
||||||
|
{ key: 'incident.pollutant', label: '유출유종', type: 'text' },
|
||||||
|
{ key: 'incident.spillAmount', label: '유출량', type: 'text' },
|
||||||
|
]},
|
||||||
|
{ title: '3. 대응현황', fields: [
|
||||||
|
{ key: 'responseStatus', label: '', type: 'textarea' },
|
||||||
|
]},
|
||||||
|
{ title: '4. 건의사항', fields: [
|
||||||
|
{ key: 'suggestions', label: '', type: 'textarea' },
|
||||||
|
]},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '예측보고서', icon: '📈', label: '예측보고서', desc: '확산예측 결과 및 민감자원 분석',
|
||||||
|
sections: [
|
||||||
|
{ title: '1. 기본정보', fields: [
|
||||||
|
{ key: 'incident.writeTime', label: '보고일시', type: 'text' },
|
||||||
|
{ key: 'author', label: '작성자', type: 'text' },
|
||||||
|
]},
|
||||||
|
{ title: '2. 사고개요', fields: [
|
||||||
|
{ key: 'incident.name', label: '사고명', type: 'text' },
|
||||||
|
{ key: 'incident.occurTime', label: '발생일시', type: 'text' },
|
||||||
|
{ key: 'incident.location', label: '발생위치', type: 'text' },
|
||||||
|
{ key: 'incident.pollutant', label: '유출유종', type: 'text' },
|
||||||
|
{ key: 'incident.spillAmount', label: '유출량', type: 'text' },
|
||||||
|
]},
|
||||||
|
{ title: '3. 해양기상 현황', fields: [
|
||||||
|
{ key: 'weatherSummary', label: '', type: 'textarea' },
|
||||||
|
]},
|
||||||
|
{ title: '4. 확산예측 결과', fields: [
|
||||||
|
{ key: 'spreadResult', label: '', type: 'textarea' },
|
||||||
|
]},
|
||||||
|
{ title: '5. 민감자원 영향', fields: [
|
||||||
|
{ key: 'sensitiveImpact', label: '', type: 'textarea' },
|
||||||
|
]},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '종합보고서', icon: '📑', label: '종합보고서', desc: '전체 사고 대응 종합 보고 문서',
|
||||||
|
sections: [
|
||||||
|
{ title: '1. 기본정보', fields: [
|
||||||
|
{ key: 'incident.writeTime', label: '보고일시', type: 'text' },
|
||||||
|
{ key: 'author', label: '작성자', type: 'text' },
|
||||||
|
]},
|
||||||
|
{ title: '2. 사고개요', fields: [
|
||||||
|
{ key: 'incident.name', label: '사고명', type: 'text' },
|
||||||
|
{ key: 'incident.occurTime', label: '발생일시', type: 'text' },
|
||||||
|
{ key: 'incident.location', label: '발생위치', type: 'text' },
|
||||||
|
{ key: 'incident.shipName', label: '사고선박', type: 'text' },
|
||||||
|
{ key: 'incident.accidentType', label: '사고유형', type: 'text' },
|
||||||
|
]},
|
||||||
|
{ title: '3. 유출 및 확산현황', fields: [
|
||||||
|
{ key: 'incident.pollutant', label: '유출유종', type: 'text' },
|
||||||
|
{ key: 'incident.spillAmount', label: '유출량', type: 'text' },
|
||||||
|
{ key: 'spreadSummary', label: '확산현황', type: 'textarea' },
|
||||||
|
]},
|
||||||
|
{ title: '4. 대응현황', fields: [
|
||||||
|
{ key: 'responseDetail', label: '', type: 'textarea' },
|
||||||
|
]},
|
||||||
|
{ title: '5. 피해현황', fields: [
|
||||||
|
{ key: 'damageReport', label: '', type: 'textarea' },
|
||||||
|
]},
|
||||||
|
{ title: '6. 향후계획', fields: [
|
||||||
|
{ key: 'futurePlanDetail', label: '', type: 'textarea' },
|
||||||
|
]},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '유출유 보고', icon: '🛢️', label: '유출유 보고', desc: '유류오염사고 대응지원 상황도',
|
||||||
|
sections: [
|
||||||
|
// Page 1: 사고 정보
|
||||||
|
{ title: '1. 사고 정보', fields: [
|
||||||
|
{ key: 'incident.name', label: '사고명', type: 'text' },
|
||||||
|
{ key: 'incident.writeTime', label: '작성시간', type: 'text' },
|
||||||
|
{ key: 'incident.shipName', label: '선명(시설명)', type: 'text' },
|
||||||
|
{ key: 'agent', label: '제원', type: 'text' },
|
||||||
|
{ key: 'incident.location', label: '사고위치', type: 'text' },
|
||||||
|
{ key: 'incident.lat', label: '좌표(위도)', type: 'text' },
|
||||||
|
{ key: 'incident.lon', label: '좌표(경도)', type: 'text' },
|
||||||
|
{ key: 'incident.occurTime', label: '발생시각', type: 'text' },
|
||||||
|
{ key: 'incident.accidentType', label: '사고유형', type: 'text' },
|
||||||
|
{ key: 'incident.pollutant', label: '오염물질', type: 'text' },
|
||||||
|
{ key: 'incident.spillAmount', label: '유출 추정량(㎘)', type: 'text' },
|
||||||
|
{ key: 'incident.depth', label: '수심', type: 'text' },
|
||||||
|
{ key: 'incident.seabed', label: '저질', type: 'text' },
|
||||||
|
]},
|
||||||
|
// Page 2: 해양기상정보
|
||||||
|
{ title: '2. 해양기상정보 - 조석정보', fields: [
|
||||||
|
{ key: 'tideDate', label: '일자', type: 'text' },
|
||||||
|
{ key: 'tideName', label: '물때', type: 'text' },
|
||||||
|
{ key: 'lowTide1', label: '저조(1차)', type: 'text' },
|
||||||
|
{ key: 'highTide1', label: '고조(1차)', type: 'text' },
|
||||||
|
{ key: 'lowTide2', label: '저조(2차)', type: 'text' },
|
||||||
|
{ key: 'highTide2', label: '고조(2차)', type: 'text' },
|
||||||
|
]},
|
||||||
|
{ title: '2. 해양기상정보 - 기상예보', fields: [
|
||||||
|
{ key: 'weatherTime', label: '기상 예측시간', type: 'text' },
|
||||||
|
{ key: 'sunrise', label: '일출', type: 'text' },
|
||||||
|
{ key: 'sunset', label: '일몰', type: 'text' },
|
||||||
|
{ key: 'windDir', label: '풍향', type: 'text' },
|
||||||
|
{ key: 'windSpeed', label: '풍속(m/s)', type: 'text' },
|
||||||
|
{ key: 'currentDir', label: '유향', type: 'text' },
|
||||||
|
{ key: 'currentSpeed', label: '유속(knot)', type: 'text' },
|
||||||
|
{ key: 'waveHeight', label: '파고(m)', type: 'text' },
|
||||||
|
]},
|
||||||
|
// Page 3: 유출유 확산예측
|
||||||
|
{ title: '3. 유출유 확산예측 - 시간별 상세정보', fields: [
|
||||||
|
{ key: 'spread3h_weathered', label: '3시간 풍화량(kL)', type: 'text' },
|
||||||
|
{ key: 'spread3h_seaRemain', label: '3시간 해상잔존량(kL)', type: 'text' },
|
||||||
|
{ key: 'spread3h_coastAttach', label: '3시간 연안부착량(kL)', type: 'text' },
|
||||||
|
{ key: 'spread3h_area', label: '3시간 오염해역면적(km²)', type: 'text' },
|
||||||
|
{ key: 'spread6h_weathered', label: '6시간 풍화량(kL)', type: 'text' },
|
||||||
|
{ key: 'spread6h_seaRemain', label: '6시간 해상잔존량(kL)', type: 'text' },
|
||||||
|
{ key: 'spread6h_coastAttach', label: '6시간 연안부착량(kL)', type: 'text' },
|
||||||
|
{ key: 'spread6h_area', label: '6시간 오염해역면적(km²)', type: 'text' },
|
||||||
|
]},
|
||||||
|
{ title: '3. 분석', fields: [
|
||||||
|
{ key: 'spreadAnalysis', label: '', type: 'textarea' },
|
||||||
|
]},
|
||||||
|
// Page 4-5: 민감자원
|
||||||
|
{ title: '4. 민감자원 - 양식장 분포(10km 내)', fields: [
|
||||||
|
{ key: 'aquaculture', label: '', type: 'textarea' },
|
||||||
|
]},
|
||||||
|
{ title: '4. 민감자원 - 해수욕장/수산시장/해수취수시설', fields: [
|
||||||
|
{ key: 'beaches', label: '', type: 'textarea' },
|
||||||
|
]},
|
||||||
|
// Page 5: 해안선/생물종
|
||||||
|
{ title: '4. 해안선(ESI) 분포', fields: [
|
||||||
|
{ key: 'esi1_vertical', label: 'ESI 1 수직암반(km)', type: 'text' },
|
||||||
|
{ key: 'esi2_horizontal', label: 'ESI 2 수평암반(km)', type: 'text' },
|
||||||
|
{ key: 'esi3_finesand', label: 'ESI 3 세립질 모래(km)', type: 'text' },
|
||||||
|
{ key: 'esi4_coarsesand', label: 'ESI 4 조립질 모래(km)', type: 'text' },
|
||||||
|
{ key: 'esi5_sandgravel', label: 'ESI 5 모래·자갈(km)', type: 'text' },
|
||||||
|
{ key: 'esi6a_gravel', label: 'ESI 6A 자갈(km)', type: 'text' },
|
||||||
|
{ key: 'esi6b_riprap', label: 'ESI 6B 투과성 사석(km)', type: 'text' },
|
||||||
|
{ key: 'esi7_semiclosed', label: 'ESI 7 반폐쇄성 해안(km)', type: 'text' },
|
||||||
|
{ key: 'esi8a_mudflat', label: 'ESI 8A 갯벌(km)', type: 'text' },
|
||||||
|
{ key: 'esi8b_saltmarsh', label: 'ESI 8B 염습지(km)', type: 'text' },
|
||||||
|
]},
|
||||||
|
{ title: '4. 생물종(보호종) / 서식지 분포', fields: [
|
||||||
|
{ key: 'bioSpecies', label: '', type: 'textarea' },
|
||||||
|
]},
|
||||||
|
// Page 6: 통합민감도
|
||||||
|
{ title: '4. 통합민감도 평가', fields: [
|
||||||
|
{ key: 'sens_veryHigh', label: '매우 높음(km²)', type: 'text' },
|
||||||
|
{ key: 'sens_high', label: '높음(km²)', type: 'text' },
|
||||||
|
{ key: 'sens_medium', label: '보통(km²)', type: 'text' },
|
||||||
|
{ key: 'sens_low', label: '낮음(km²)', type: 'text' },
|
||||||
|
]},
|
||||||
|
// Page 7: 방제전략
|
||||||
|
{ title: '5. 방제전략 - 방제자원 배치 현황(반경 30km)', fields: [
|
||||||
|
{ key: 'defenseShips', label: '', type: 'textarea' },
|
||||||
|
]},
|
||||||
|
{ title: '5. 기타 장비', fields: [
|
||||||
|
{ key: 'otherEquipment', label: '', type: 'textarea' },
|
||||||
|
]},
|
||||||
|
// Page 8: 동원결과
|
||||||
|
{ title: '6. 방제선/자원 동원 결과', fields: [
|
||||||
|
{ key: 'oilRecovery', label: '', type: 'textarea' },
|
||||||
|
]},
|
||||||
|
{ title: '6. 동원 방제선 내역', fields: [
|
||||||
|
{ key: 'totalSpill', label: '유출량(kL)', type: 'text' },
|
||||||
|
{ key: 'totalWeathered', label: '누적풍화량(kL)', type: 'text' },
|
||||||
|
{ key: 'totalRecovered', label: '누적회수량(kL)', type: 'text' },
|
||||||
|
{ key: 'totalSeaRemain', label: '누적해상잔존량(kL)', type: 'text' },
|
||||||
|
{ key: 'totalCoastAttach', label: '누적연안부착량(kL)', type: 'text' },
|
||||||
|
]},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── Report Generator types & data ────────────────────────
|
||||||
|
export interface ReportSection {
|
||||||
|
id: string
|
||||||
|
icon: string
|
||||||
|
title: string
|
||||||
|
desc: string
|
||||||
|
checked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportCategory = 0 | 1 | 2
|
||||||
|
|
||||||
|
export interface CategoryDef {
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
desc: string
|
||||||
|
color: string
|
||||||
|
borderColor: string
|
||||||
|
bgActive: string
|
||||||
|
reportName: string
|
||||||
|
templates: { icon: string; label: string }[]
|
||||||
|
sections: ReportSection[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CATEGORIES: CategoryDef[] = [
|
||||||
|
{
|
||||||
|
icon: '🛢', label: '유출유 확산예측', desc: 'KOSPS · OpenDrift · POSEIDON',
|
||||||
|
color: 'var(--cyan)', borderColor: 'rgba(6,182,212,0.4)', bgActive: 'rgba(6,182,212,0.08)',
|
||||||
|
reportName: '유출유 확산예측 보고서',
|
||||||
|
templates: [
|
||||||
|
{ icon: '🔬', label: '예측보고서' },
|
||||||
|
{ icon: '📋', label: '초기보고서' },
|
||||||
|
{ icon: '📊', label: '지휘부 보고' },
|
||||||
|
{ icon: '📑', label: '종합보고서' },
|
||||||
|
],
|
||||||
|
sections: [
|
||||||
|
{ id: 'oil-spread', icon: '🌊', title: '유출유 확산예측 결과', desc: 'KOSPS/OpenDrift/POSEIDON 예측 결과 및 비교', checked: true },
|
||||||
|
{ id: 'oil-pollution', icon: '📊', title: '오염종합상황', desc: '유출량, 풍화량, 해상잔유량, 오염면적', checked: true },
|
||||||
|
{ id: 'oil-sensitive', icon: '🐟', title: '민감자원 현황', desc: '어장정보, 양식업, 환경생태 자원', checked: true },
|
||||||
|
{ id: 'oil-coastal', icon: '🏖', title: '해안부착 현황', desc: '해안 부착 위치, 시간, 오염 길이', checked: true },
|
||||||
|
{ id: 'oil-defense', icon: '🛡', title: '방제전략·자원배치', desc: '방제방법, 방제자원 배치 계획', checked: true },
|
||||||
|
{ id: 'oil-tide', icon: '🌊', title: '조석·기상정보', desc: '사고 해역 조석·기상 현황', checked: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🧪', label: 'HNS 대기확산', desc: 'ALOHA · WRF-Chem',
|
||||||
|
color: 'var(--orange)', borderColor: 'rgba(249,115,22,0.4)', bgActive: 'rgba(249,115,22,0.08)',
|
||||||
|
reportName: 'HNS 대기확산 예측보고서',
|
||||||
|
templates: [
|
||||||
|
{ icon: '🧪', label: 'HNS 예측보고서' },
|
||||||
|
{ icon: '📋', label: '초기보고서' },
|
||||||
|
{ icon: '📊', label: '지휘부 보고' },
|
||||||
|
{ icon: '🆘', label: 'EmS 대응보고' },
|
||||||
|
],
|
||||||
|
sections: [
|
||||||
|
{ id: 'hns-atm', icon: '💨', title: '대기확산 예측 결과', desc: 'ALOHA/WRF-Chem 모델 확산 결과', checked: true },
|
||||||
|
{ id: 'hns-hazard', icon: '⚠️', title: '위험구역·방제거리', desc: 'ERPGs 기준 위험구역 범위', checked: true },
|
||||||
|
{ id: 'hns-substance', icon: '🧬', title: 'HNS 물질정보', desc: '물질 특성, 독성, UN번호', checked: true },
|
||||||
|
{ id: 'hns-ppe', icon: '🛡', title: 'PPE·대응장비', desc: '개인보호장비, 대응장비 배치', checked: true },
|
||||||
|
{ id: 'hns-facility', icon: '🏫', title: '영향시설·대피현황', desc: '주변 시설, 인구, 대피 계획', checked: true },
|
||||||
|
{ id: 'hns-3d', icon: '📐', title: '3D 공간분포', desc: '수직·수평 농도 분포', checked: false },
|
||||||
|
{ id: 'hns-weather', icon: '🌊', title: '기상·해상 조건', desc: '풍향·풍속, 대기안정도', checked: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🚨', label: '긴급구난', desc: '복원성 · 좌초위험 분석',
|
||||||
|
color: 'var(--red)', borderColor: 'rgba(239,68,68,0.4)', bgActive: 'rgba(239,68,68,0.08)',
|
||||||
|
reportName: '긴급구난 상황보고서',
|
||||||
|
templates: [
|
||||||
|
{ icon: '🚨', label: '긴급구난 상황보고' },
|
||||||
|
{ icon: '📋', label: '초기보고서' },
|
||||||
|
{ icon: '📊', label: '지휘부 보고' },
|
||||||
|
{ icon: '📑', label: '종합보고서' },
|
||||||
|
],
|
||||||
|
sections: [
|
||||||
|
{ id: 'rescue-safety', icon: '🚢', title: '선박 안전성 평가', desc: 'GM, 경사각, 트림 분석', checked: true },
|
||||||
|
{ id: 'rescue-timeline', icon: '⚡', title: '사고 유형·경과', desc: '사고 유형별 타임라인', checked: true },
|
||||||
|
{ id: 'rescue-casualty', icon: '👥', title: '인명현황', desc: '인원 현황, 구조 상태', checked: true },
|
||||||
|
{ id: 'rescue-resource', icon: '🛟', title: '구난 자원 현황', desc: '예인선, 헬기, 구난 장비 배치', checked: true },
|
||||||
|
{ id: 'rescue-grounding', icon: '🗺', title: '좌초위험 해역', desc: '좌초 위험 구역 분석', checked: true },
|
||||||
|
{ id: 'rescue-weather', icon: '🌊', title: '기상·해상 조건', desc: '파고, 풍속, 조류 현황', checked: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 카테고리별 샘플 데이터
|
||||||
|
export const sampleOilData = {
|
||||||
|
spread: { kosps: '10.2 km', openDrift: '25.1 km', poseidon: '9.1 km' },
|
||||||
|
pollution: { spillAmount: '1.00 kℓ', weathered: '0.09 kℓ', seaRemain: '0.23 kℓ', pollutionArea: '3.93 km²', coastAttach: '0.67 kℓ', coastLength: '0.45 km', oilType: 'BUNKER_C' },
|
||||||
|
sensitive: [{ label: '마을어업 131ha' }, { label: '복합양식업 35ha' }, { label: '어류등양식업 36ha' }, { label: '갯벌 4,013ha' }],
|
||||||
|
coastal: { firstTime: '14:30', coastLength: '0.45 km' },
|
||||||
|
tide: { highTide1: '06:24 (1.82m)', lowTide: '12:51 (0.34m)', highTide2: '18:47 (1.95m)' },
|
||||||
|
}
|
||||||
|
export const sampleHnsData = {
|
||||||
|
substance: { name: '벤젠 (Benzene)', un: 'UN 1114', cas: '71-43-2', class: '3류 인화성 액체', toxicity: 'ERPG-2: 50 ppm / ERPG-3: 150 ppm' },
|
||||||
|
hazard: { erpg2: '1.8 km', erpg3: '0.6 km', evacuation: '2.5 km' },
|
||||||
|
atm: { aloha: '2.1 km', wrfChem: '1.8 km' },
|
||||||
|
ppe: ['Level B 화학복', 'SCBA 공기호흡기', '내화학장갑', '화학보호장화'],
|
||||||
|
facility: { schools: 2, hospitals: 1, population: '약 12,400명' },
|
||||||
|
}
|
||||||
|
export const sampleRescueData = {
|
||||||
|
safety: { gm: '0.82 m', heel: '8.5°', trim: '1.2 m (선미)', status: '주의' },
|
||||||
|
casualty: { total: 25, rescued: 22, missing: 2, injured: 1 },
|
||||||
|
resources: [
|
||||||
|
{ type: '예인선', name: '해양1호', eta: '현장 도착', status: '투입중' },
|
||||||
|
{ type: '구난선', name: 'SMIT Salvage', eta: '02-10 14:00', status: '이동중' },
|
||||||
|
{ type: '헬기', name: '해경 B-512', eta: '30분', status: '대기중' },
|
||||||
|
],
|
||||||
|
grounding: { risk: '높음', nearestShallow: '1.2 NM (SE방향)', depth: '12.5 m' },
|
||||||
|
}
|
||||||
89
frontend/src/tabs/reports/components/reportUtils.ts
Normal file
89
frontend/src/tabs/reports/components/reportUtils.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { sanitizeHtml } from '@common/utils/sanitize';
|
||||||
|
import type { OilSpillReportData } from './OilSpillReportTemplate';
|
||||||
|
|
||||||
|
// ─── Report Export Helpers ──────────────────────────────
|
||||||
|
export function generateReportHTML(
|
||||||
|
templateLabel: string,
|
||||||
|
meta: { writeTime: string; author: string; jurisdiction: string },
|
||||||
|
sections: { title: string; fields: { key: string; label: string }[] }[],
|
||||||
|
getVal: (key: string) => string
|
||||||
|
) {
|
||||||
|
const rows = sections.map(section => {
|
||||||
|
const fieldRows = section.fields.map(f => {
|
||||||
|
if (f.label) {
|
||||||
|
return `<tr><td style="background:#f0f4f8;padding:8px 12px;border:1px solid #d1d5db;font-weight:600;width:200px;font-size:12px;">${f.label}</td><td style="padding:8px 12px;border:1px solid #d1d5db;font-size:12px;">${getVal(f.key) || '-'}</td></tr>`
|
||||||
|
}
|
||||||
|
return `<tr><td colspan="2" style="padding:8px 12px;border:1px solid #d1d5db;font-size:12px;white-space:pre-wrap;">${getVal(f.key) || '-'}</td></tr>`
|
||||||
|
}).join('')
|
||||||
|
return `<h3 style="color:#0891b2;font-size:14px;margin:20px 0 8px;">${section.title}</h3><table style="width:100%;border-collapse:collapse;">${fieldRows}</table>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${templateLabel}</title>
|
||||||
|
<style>@page{size:A4;margin:20mm}body{font-family:'맑은 고딕','Malgun Gothic',sans-serif;color:#1a1a1a;max-width:800px;margin:0 auto;padding:40px}</style>
|
||||||
|
</head><body>
|
||||||
|
<div style="text-align:center;margin-bottom:30px">
|
||||||
|
<h1 style="font-size:20px;margin:0">해양오염방제지원시스템</h1>
|
||||||
|
<h2 style="font-size:16px;color:#0891b2;margin:8px 0">${templateLabel}</h2>
|
||||||
|
<p style="font-size:11px;color:#666">작성일시: ${meta.writeTime} | 작성자: ${meta.author || '-'} | 관할: ${meta.jurisdiction}</p>
|
||||||
|
</div>${rows}</body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
export function exportAsPDF(html: string, _filename: string) {
|
||||||
|
const sanitizedHtml = sanitizeHtml(html)
|
||||||
|
const blob = new Blob([sanitizedHtml], { type: 'text/html; charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const win = window.open(url, '_blank')
|
||||||
|
if (win) {
|
||||||
|
win.addEventListener('afterprint', () => URL.revokeObjectURL(url))
|
||||||
|
setTimeout(() => win.print(), 500)
|
||||||
|
}
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 30000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportAsHWP(html: string, filename: string) {
|
||||||
|
const blob = new Blob([html], { type: 'application/msword;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${filename}.doc`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ViewState =
|
||||||
|
| { screen: 'list' }
|
||||||
|
| { screen: 'templates' }
|
||||||
|
| { screen: 'generate' }
|
||||||
|
| { screen: 'view'; data: OilSpillReportData }
|
||||||
|
| { screen: 'edit'; data: OilSpillReportData }
|
||||||
|
|
||||||
|
export const typeColors: Record<string, { bg: string; text: string }> = {
|
||||||
|
'초기보고서': { bg: 'rgba(6,182,212,0.15)', text: '#06b6d4' },
|
||||||
|
'지휘부 보고': { bg: 'rgba(168,85,247,0.15)', text: '#a855f7' },
|
||||||
|
'예측보고서': { bg: 'rgba(59,130,246,0.15)', text: '#3b82f6' },
|
||||||
|
'종합보고서': { bg: 'rgba(249,115,22,0.15)', text: '#f97316' },
|
||||||
|
'유출유 보고': { bg: 'rgba(234,179,8,0.15)', text: '#eab308' },
|
||||||
|
}
|
||||||
|
export const statusColors: Record<string, { bg: string; text: string }> = {
|
||||||
|
'완료': { bg: 'rgba(34,197,94,0.15)', text: '#22c55e' },
|
||||||
|
'수행중': { bg: 'rgba(249,115,22,0.15)', text: '#f97316' },
|
||||||
|
'테스트': { bg: 'rgba(138,150,168,0.15)', text: '#8a96a8' },
|
||||||
|
}
|
||||||
|
export const analysisCatColors: Record<string, { bg: string; text: string; icon: string }> = {
|
||||||
|
'유출유 확산예측': { bg: 'rgba(6,182,212,0.12)', text: '#06b6d4', icon: '🛢' },
|
||||||
|
'HNS 대기확산': { bg: 'rgba(249,115,22,0.12)', text: '#f97316', icon: '🧪' },
|
||||||
|
'긴급구난': { bg: 'rgba(239,68,68,0.12)', text: '#ef4444', icon: '🚨' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferAnalysisCategory(report: OilSpillReportData): string {
|
||||||
|
if (report.analysisCategory) return report.analysisCategory
|
||||||
|
const t = (report.title || '').toLowerCase()
|
||||||
|
const rt = report.reportType || ''
|
||||||
|
if (t.includes('hns') || t.includes('대기확산') || t.includes('화학') || t.includes('aloha')) return 'HNS 대기확산'
|
||||||
|
if (t.includes('구난') || t.includes('구조') || t.includes('긴급') || t.includes('salvage') || t.includes('rescue')) return '긴급구난'
|
||||||
|
if (t.includes('유출유') || t.includes('확산예측') || t.includes('민감자원') || t.includes('유출사고') || t.includes('오염') || t.includes('방제') || rt === '유출유 보고' || rt === '예측보고서') return '유출유 확산예측'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
155
frontend/src/tabs/scat/components/ScatLeftPanel.tsx
Normal file
155
frontend/src/tabs/scat/components/ScatLeftPanel.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import type { ScatSegment } from './scatTypes'
|
||||||
|
import { esiColor, sensColor, statusColor, esiLevel, scatAreas, scatDetailData } from './scatConstants'
|
||||||
|
|
||||||
|
interface ScatLeftPanelProps {
|
||||||
|
segments: ScatSegment[]
|
||||||
|
selectedSeg: ScatSegment
|
||||||
|
onSelectSeg: (s: ScatSegment) => void
|
||||||
|
onOpenPopup: (idx: number) => void
|
||||||
|
jurisdictionFilter: string
|
||||||
|
onJurisdictionChange: (v: string) => void
|
||||||
|
areaFilter: string
|
||||||
|
onAreaChange: (v: string) => void
|
||||||
|
phaseFilter: string
|
||||||
|
onPhaseChange: (v: string) => void
|
||||||
|
statusFilter: string
|
||||||
|
onStatusChange: (v: string) => void
|
||||||
|
searchTerm: string
|
||||||
|
onSearchChange: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScatLeftPanel({
|
||||||
|
segments,
|
||||||
|
selectedSeg,
|
||||||
|
onSelectSeg,
|
||||||
|
onOpenPopup,
|
||||||
|
jurisdictionFilter,
|
||||||
|
onJurisdictionChange,
|
||||||
|
areaFilter,
|
||||||
|
onAreaChange,
|
||||||
|
phaseFilter,
|
||||||
|
onPhaseChange,
|
||||||
|
statusFilter,
|
||||||
|
onStatusChange,
|
||||||
|
searchTerm,
|
||||||
|
onSearchChange,
|
||||||
|
}: ScatLeftPanelProps) {
|
||||||
|
const filtered = segments.filter(s => {
|
||||||
|
if (areaFilter !== '전체' && !s.area.includes(areaFilter.replace('서귀포시 ', '').replace('제주시 ', '').replace(' 해안', ''))) return false
|
||||||
|
if (statusFilter !== '전체' && s.status !== statusFilter) return false
|
||||||
|
if (searchTerm && !s.code.includes(searchTerm) && !s.name.includes(searchTerm)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[340px] min-w-[340px] bg-bg-1 border-r border-border flex flex-col overflow-hidden">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="p-3.5 border-b border-border">
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-text-1 mb-3">
|
||||||
|
<span className="w-[3px] h-2.5 bg-status-green rounded-sm" />
|
||||||
|
해안 조사 구역
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2.5">
|
||||||
|
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">관할 해경</label>
|
||||||
|
<select value={jurisdictionFilter} onChange={e => onJurisdictionChange(e.target.value)} className="prd-i w-full">
|
||||||
|
<option>전체 (제주도)</option>
|
||||||
|
<option>서귀포해양경비안전서</option>
|
||||||
|
<option>제주해양경비안전서</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2.5">
|
||||||
|
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">해안 구역</label>
|
||||||
|
<select value={areaFilter} onChange={e => onAreaChange(e.target.value)} className="prd-i w-full">
|
||||||
|
<option>전체</option>
|
||||||
|
{scatAreas.map(a => (
|
||||||
|
<option key={a.code}>{a.jurisdiction === '서귀포' ? '서귀포시' : '제주시'} {a.area} 해안</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2.5">
|
||||||
|
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">조사 단계</label>
|
||||||
|
<select value={phaseFilter} onChange={e => onPhaseChange(e.target.value)} className="prd-i w-full">
|
||||||
|
<option>Pre-SCAT (사전조사)</option>
|
||||||
|
<option>SCAT (사고 시 조사)</option>
|
||||||
|
<option>Post-SCAT (사후 확인)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1.5 mt-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="🔍 구간 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => onSearchChange(e.target.value)}
|
||||||
|
className="prd-i flex-1"
|
||||||
|
/>
|
||||||
|
<select value={statusFilter} onChange={e => onStatusChange(e.target.value)} className="prd-i w-[70px]">
|
||||||
|
<option>전체</option>
|
||||||
|
<option>완료</option>
|
||||||
|
<option>진행중</option>
|
||||||
|
<option>미조사</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Segment List */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden p-3.5 pt-2">
|
||||||
|
<div className="flex items-center justify-between text-[10px] font-bold uppercase tracking-wider text-text-1 mb-2.5">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="w-[3px] h-2.5 bg-status-green rounded-sm" />
|
||||||
|
해안 구간 목록
|
||||||
|
</span>
|
||||||
|
<span className="text-primary-cyan font-mono text-[10px]">총 {filtered.length}개 구간</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto scrollbar-thin flex flex-col gap-1.5">
|
||||||
|
{filtered.map(seg => {
|
||||||
|
const lvl = esiLevel(seg.esiNum)
|
||||||
|
const borderColor = lvl === 'h' ? 'border-l-status-red' : lvl === 'm' ? 'border-l-status-orange' : 'border-l-status-green'
|
||||||
|
const isSelected = selectedSeg.id === seg.id
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={seg.id}
|
||||||
|
onClick={() => { onSelectSeg(seg); onOpenPopup(seg.id % scatDetailData.length) }}
|
||||||
|
className={`bg-bg-3 border border-border rounded-sm p-2.5 px-3 cursor-pointer transition-all border-l-4 ${borderColor} ${
|
||||||
|
isSelected ? 'border-status-green bg-[rgba(34,197,94,0.05)]' : 'hover:border-border-light hover:bg-bg-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="text-[10px] font-semibold font-korean flex items-center gap-1.5">
|
||||||
|
📍 {seg.code} {seg.area}
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px] font-bold px-1.5 py-0.5 rounded-lg text-white" style={{ background: esiColor(seg.esiNum) }}>
|
||||||
|
ESI {seg.esi}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||||
|
<div className="flex justify-between text-[11px]">
|
||||||
|
<span className="text-text-2 font-korean">유형</span>
|
||||||
|
<span className="text-text-1 font-medium font-mono text-[11px]">{seg.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[11px]">
|
||||||
|
<span className="text-text-2 font-korean">길이</span>
|
||||||
|
<span className="text-text-1 font-medium font-mono text-[11px]">{seg.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[11px]">
|
||||||
|
<span className="text-text-2 font-korean">민감</span>
|
||||||
|
<span className="font-medium font-mono text-[11px]" style={{ color: sensColor[seg.sensitivity] }}>{seg.sensitivity}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[11px]">
|
||||||
|
<span className="text-text-2 font-korean">현황</span>
|
||||||
|
<span className="font-medium font-mono text-[11px]" style={{ color: statusColor[seg.status] }}>{seg.status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScatLeftPanel
|
||||||
276
frontend/src/tabs/scat/components/ScatMap.tsx
Normal file
276
frontend/src/tabs/scat/components/ScatMap.tsx
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import L from 'leaflet'
|
||||||
|
import 'leaflet/dist/leaflet.css'
|
||||||
|
import type { ScatSegment } from './scatTypes'
|
||||||
|
import { esiColor, jejuCoastCoords, scatDetailData } from './scatConstants'
|
||||||
|
|
||||||
|
interface ScatMapProps {
|
||||||
|
segments: ScatSegment[]
|
||||||
|
selectedSeg: ScatSegment
|
||||||
|
onSelectSeg: (s: ScatSegment) => void
|
||||||
|
onOpenPopup: (idx: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScatMap({
|
||||||
|
segments,
|
||||||
|
selectedSeg,
|
||||||
|
onSelectSeg,
|
||||||
|
onOpenPopup,
|
||||||
|
}: ScatMapProps) {
|
||||||
|
const mapContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const mapRef = useRef<L.Map | null>(null)
|
||||||
|
const markersRef = useRef<L.LayerGroup | null>(null)
|
||||||
|
const [zoom, setZoom] = useState(10)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapContainerRef.current || mapRef.current) return
|
||||||
|
|
||||||
|
const map = L.map(mapContainerRef.current, {
|
||||||
|
center: [33.38, 126.55],
|
||||||
|
zoom: 10,
|
||||||
|
zoomControl: false,
|
||||||
|
attributionControl: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(map)
|
||||||
|
|
||||||
|
L.control.zoom({ position: 'bottomright' }).addTo(map)
|
||||||
|
L.control.attribution({ position: 'bottomleft' }).addAttribution(
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>'
|
||||||
|
).addTo(map)
|
||||||
|
|
||||||
|
map.on('zoomend', () => setZoom(map.getZoom()))
|
||||||
|
|
||||||
|
mapRef.current = map
|
||||||
|
markersRef.current = L.layerGroup().addTo(map)
|
||||||
|
|
||||||
|
setTimeout(() => map.invalidateSize(), 100)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.remove()
|
||||||
|
mapRef.current = null
|
||||||
|
markersRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current || !markersRef.current) return
|
||||||
|
markersRef.current.clearLayers()
|
||||||
|
|
||||||
|
// 줌 기반 스케일 계수 (zoom 10=아일랜드뷰 → 14+=클로즈업)
|
||||||
|
const zScale = Math.max(0, (zoom - 9)) / 5 // 0 at z9, 1 at z14
|
||||||
|
const polyWeight = 1 + zScale * 4 // 1 ~ 5
|
||||||
|
const selPolyWeight = 2 + zScale * 5 // 2 ~ 7
|
||||||
|
const glowWeight = 4 + zScale * 14 // 4 ~ 18
|
||||||
|
const halfLenScale = 0.15 + zScale * 0.85 // 0.15 ~ 1.0
|
||||||
|
const markerSize = Math.round(6 + zScale * 16) // 6px ~ 22px
|
||||||
|
const markerBorder = zoom >= 13 ? 2 : 1
|
||||||
|
const markerFontSize = Math.round(4 + zScale * 6) // 4px ~ 10px
|
||||||
|
const showStatusMarker = zoom >= 11
|
||||||
|
const showStatusText = zoom >= 13
|
||||||
|
|
||||||
|
// 제주도 해안선 레퍼런스 라인
|
||||||
|
const coastline = L.polyline(jejuCoastCoords as [number, number][], {
|
||||||
|
color: 'rgba(6, 182, 212, 0.18)',
|
||||||
|
weight: 1.5,
|
||||||
|
dashArray: '8, 6',
|
||||||
|
})
|
||||||
|
markersRef.current.addLayer(coastline)
|
||||||
|
|
||||||
|
segments.forEach(seg => {
|
||||||
|
const isSelected = selectedSeg.id === seg.id
|
||||||
|
const color = esiColor(seg.esiNum)
|
||||||
|
|
||||||
|
// 해안선 방향 계산 (세그먼트 폴리라인 각도 결정)
|
||||||
|
const coastIdx = seg.id % (jejuCoastCoords.length - 1)
|
||||||
|
const [clat1, clng1] = jejuCoastCoords[coastIdx]
|
||||||
|
const [clat2, clng2] = jejuCoastCoords[(coastIdx + 1) % jejuCoastCoords.length]
|
||||||
|
|
||||||
|
const dlat = clat2 - clat1
|
||||||
|
const dlng = clng2 - clng1
|
||||||
|
const dist = Math.sqrt(dlat * dlat + dlng * dlng)
|
||||||
|
const nDlat = dist > 0 ? dlat / dist : 0
|
||||||
|
const nDlng = dist > 0 ? dlng / dist : 1
|
||||||
|
|
||||||
|
// 구간 길이를 위경도 단위로 변환 (줌 레벨에 따라 스케일링)
|
||||||
|
const halfLen = Math.min(seg.lengthM / 200000, 0.012) * halfLenScale
|
||||||
|
|
||||||
|
// 해안선 방향을 따라 폴리라인 좌표 생성
|
||||||
|
const segCoords: [number, number][] = [
|
||||||
|
[seg.lat - nDlat * halfLen, seg.lng - nDlng * halfLen],
|
||||||
|
[seg.lat, seg.lng],
|
||||||
|
[seg.lat + nDlat * halfLen, seg.lng + nDlng * halfLen],
|
||||||
|
]
|
||||||
|
|
||||||
|
// 선택된 구간 글로우 효과
|
||||||
|
if (isSelected) {
|
||||||
|
const glow = L.polyline(segCoords, {
|
||||||
|
color: '#22c55e',
|
||||||
|
weight: glowWeight,
|
||||||
|
opacity: 0.15,
|
||||||
|
lineCap: 'round',
|
||||||
|
})
|
||||||
|
markersRef.current!.addLayer(glow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESI 색상 구간 폴리라인
|
||||||
|
const polyline = L.polyline(segCoords, {
|
||||||
|
color: isSelected ? '#22c55e' : color,
|
||||||
|
weight: isSelected ? selPolyWeight : polyWeight,
|
||||||
|
opacity: isSelected ? 0.95 : 0.7,
|
||||||
|
lineCap: 'round',
|
||||||
|
lineJoin: 'round',
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusIcon = seg.status === '완료' ? '✓' : seg.status === '진행중' ? '⏳' : '—'
|
||||||
|
|
||||||
|
polyline.bindTooltip(
|
||||||
|
`<div style="text-align:center;font-family:'Noto Sans KR',sans-serif;">
|
||||||
|
<div style="font-weight:700;font-size:11px;">${seg.code} ${seg.area}</div>
|
||||||
|
<div style="font-size:10px;opacity:0.7;">ESI ${seg.esi} · ${seg.length} · ${statusIcon} ${seg.status}</div>
|
||||||
|
</div>`,
|
||||||
|
{
|
||||||
|
permanent: isSelected,
|
||||||
|
direction: 'top',
|
||||||
|
offset: [0, -10],
|
||||||
|
className: 'scat-map-tooltip',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
polyline.on('click', () => {
|
||||||
|
onSelectSeg(seg)
|
||||||
|
onOpenPopup(seg.id % scatDetailData.length)
|
||||||
|
})
|
||||||
|
markersRef.current!.addLayer(polyline)
|
||||||
|
|
||||||
|
// 조사 상태 마커 (DivIcon) — 줌 레벨에 따라 표시/크기 조절
|
||||||
|
if (showStatusMarker) {
|
||||||
|
const stColor = seg.status === '완료' ? '#22c55e' : seg.status === '진행중' ? '#eab308' : '#64748b'
|
||||||
|
const stBg = seg.status === '완료' ? 'rgba(34,197,94,0.2)' : seg.status === '진행중' ? 'rgba(234,179,8,0.2)' : 'rgba(100,116,139,0.2)'
|
||||||
|
const stText = seg.status === '완료' ? '✓' : seg.status === '진행중' ? '⏳' : '—'
|
||||||
|
const half = Math.round(markerSize / 2)
|
||||||
|
|
||||||
|
const statusMarker = L.marker([seg.lat, seg.lng], {
|
||||||
|
icon: L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="width:${markerSize}px;height:${markerSize}px;border-radius:50%;background:${stBg};border:${markerBorder}px solid ${stColor};display:flex;align-items:center;justify-content:center;font-size:${markerFontSize}px;color:${stColor};transform:translate(-${half}px,-${half}px);backdrop-filter:blur(4px);box-shadow:0 0 ${Math.round(markerSize / 3)}px ${stBg}">${showStatusText ? stText : ''}</div>`,
|
||||||
|
iconSize: [0, 0],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
statusMarker.on('click', () => {
|
||||||
|
onSelectSeg(seg)
|
||||||
|
onOpenPopup(seg.id % scatDetailData.length)
|
||||||
|
})
|
||||||
|
markersRef.current!.addLayer(statusMarker)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [segments, selectedSeg, onSelectSeg, onOpenPopup, zoom])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current) return
|
||||||
|
mapRef.current.flyTo([selectedSeg.lat, selectedSeg.lng], 12, { duration: 0.6 })
|
||||||
|
}, [selectedSeg])
|
||||||
|
|
||||||
|
const doneCount = segments.filter(s => s.status === '완료').length
|
||||||
|
const progCount = segments.filter(s => s.status === '진행중').length
|
||||||
|
const totalLen = segments.reduce((a, s) => a + s.lengthM, 0)
|
||||||
|
const doneLen = segments.filter(s => s.status === '완료').reduce((a, s) => a + s.lengthM, 0)
|
||||||
|
const highSens = segments.filter(s => s.sensitivity === '최상' || s.sensitivity === '상').reduce((a, s) => a + s.lengthM, 0)
|
||||||
|
const donePct = Math.round(doneCount / segments.length * 100)
|
||||||
|
const progPct = Math.round(progCount / segments.length * 100)
|
||||||
|
const notPct = 100 - donePct - progPct
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<style>{`
|
||||||
|
.scat-map-tooltip {
|
||||||
|
background: rgba(15,21,36,0.92) !important;
|
||||||
|
border: 1px solid rgba(30,42,66,0.8) !important;
|
||||||
|
color: #e4e8f1 !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
padding: 4px 8px !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.4) !important;
|
||||||
|
}
|
||||||
|
.scat-map-tooltip::before {
|
||||||
|
border-top-color: rgba(15,21,36,0.92) !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div ref={mapContainerRef} className="w-full h-full" />
|
||||||
|
|
||||||
|
{/* Status chips */}
|
||||||
|
<div className="absolute top-3.5 left-3.5 flex gap-2 z-[1000]">
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-full text-[11px] text-text-2 font-korean">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-status-green shadow-[0_0_6px_var(--green)]" />
|
||||||
|
Pre-SCAT 사전조사
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-full text-[11px] text-text-2 font-korean">
|
||||||
|
제주도 — 해양경비안전서 관할 해안 · {segments.length}개 구간
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right info cards */}
|
||||||
|
<div className="absolute top-3.5 right-3.5 w-[260px] flex flex-col gap-2 z-[1000] max-h-[calc(100%-100px)] overflow-y-auto scrollbar-thin">
|
||||||
|
{/* ESI Legend */}
|
||||||
|
<div className="bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-wider text-text-3 mb-2.5">ESI 민감도 분류 범례</div>
|
||||||
|
{[
|
||||||
|
{ esi: 'ESI 10', label: '갯벌·습지·맹그로브', color: '#991b1b' },
|
||||||
|
{ esi: 'ESI 9', label: '쉘터 갯벌', color: '#b91c1c' },
|
||||||
|
{ esi: 'ESI 8', label: '쉘터 암반 해안', color: '#dc2626' },
|
||||||
|
{ esi: 'ESI 7', label: '노출 갯벌', color: '#ef4444' },
|
||||||
|
{ esi: 'ESI 6', label: '자갈·혼합 해안', color: '#f97316' },
|
||||||
|
{ esi: 'ESI 5', label: '혼합 모래/자갈', color: '#fb923c' },
|
||||||
|
{ esi: 'ESI 3-4', label: '모래 해안', color: '#facc15' },
|
||||||
|
{ esi: 'ESI 1-2', label: '암반·인공 구조물', color: '#4ade80' },
|
||||||
|
].map((item, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 py-1 text-[11px]">
|
||||||
|
<span className="w-3.5 h-1.5 rounded-sm flex-shrink-0" style={{ background: item.color }} />
|
||||||
|
<span className="text-text-2 font-korean">{item.label}</span>
|
||||||
|
<span className="ml-auto font-mono text-[10px] text-text-1">{item.esi}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-wider text-text-3 mb-2.5">조사 진행률</div>
|
||||||
|
<div className="flex gap-0.5 h-3 rounded overflow-hidden mb-1">
|
||||||
|
<div className="h-full transition-all duration-500" style={{ width: `${donePct}%`, background: 'var(--green)' }} />
|
||||||
|
<div className="h-full transition-all duration-500" style={{ width: `${progPct}%`, background: 'var(--orange)' }} />
|
||||||
|
<div className="h-full transition-all duration-500" style={{ width: `${notPct}%`, background: 'var(--bd)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-1">
|
||||||
|
<span className="text-[9px] font-mono" style={{ color: 'var(--green)' }}>완료 {donePct}%</span>
|
||||||
|
<span className="text-[9px] font-mono" style={{ color: 'var(--orange)' }}>진행 {progPct}%</span>
|
||||||
|
<span className="text-[9px] font-mono text-text-3">미조사 {notPct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2.5">
|
||||||
|
{[
|
||||||
|
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
|
||||||
|
['조사 완료', `${(doneLen / 1000).toFixed(1)} km`, 'var(--green)'],
|
||||||
|
['고민감 구간', `${(highSens / 1000).toFixed(1)} km`, 'var(--red)'],
|
||||||
|
['방제 우선 구간', `${segments.filter(s => s.sensitivity === '최상').length}개`, 'var(--orange)'],
|
||||||
|
].map(([label, val, color], i) => (
|
||||||
|
<div key={i} className="flex justify-between py-1.5 border-b border-[rgba(30,42,74,0.3)] last:border-b-0 text-[11px]">
|
||||||
|
<span className="text-text-2 font-korean">{label}</span>
|
||||||
|
<span className="font-mono font-medium text-[11px]" style={{ color: color || undefined }}>{val}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Coordinates */}
|
||||||
|
<div className="absolute bottom-3 left-3 z-[1000] bg-[rgba(18,25,41,0.85)] backdrop-blur-xl border border-border rounded-sm px-3 py-1.5 font-mono text-[11px] text-text-2 flex gap-3.5">
|
||||||
|
<span>위도 <span className="text-status-green font-medium">{selectedSeg.lat.toFixed(4)}°N</span></span>
|
||||||
|
<span>경도 <span className="text-status-green font-medium">{selectedSeg.lng.toFixed(4)}°E</span></span>
|
||||||
|
<span>축척 <span className="text-status-green font-medium">1:25,000</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScatMap
|
||||||
326
frontend/src/tabs/scat/components/ScatPopup.tsx
Normal file
326
frontend/src/tabs/scat/components/ScatPopup.tsx
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import L from 'leaflet'
|
||||||
|
import 'leaflet/dist/leaflet.css'
|
||||||
|
import type { ScatDetail } from './scatTypes'
|
||||||
|
|
||||||
|
// ═══ Popup Map (Leaflet) ═══
|
||||||
|
|
||||||
|
function PopupMap({ lat, lng, esi, esiCol, code, name }: { lat: number; lng: number; esi: string; esiCol: string; code: string; name: string }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const mapRef = useRef<L.Map | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
// 이전 맵 제거
|
||||||
|
if (mapRef.current) { mapRef.current.remove(); mapRef.current = null }
|
||||||
|
|
||||||
|
const map = L.map(containerRef.current, {
|
||||||
|
center: [lat, lng],
|
||||||
|
zoom: 15,
|
||||||
|
zoomControl: false,
|
||||||
|
attributionControl: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 }).addTo(map)
|
||||||
|
L.control.zoom({ position: 'topright' }).addTo(map)
|
||||||
|
|
||||||
|
// 해안 구간 라인 (시뮬레이션)
|
||||||
|
const segLine: [number, number][] = [
|
||||||
|
[lat - 0.002, lng - 0.004],
|
||||||
|
[lat - 0.001, lng - 0.002],
|
||||||
|
[lat, lng],
|
||||||
|
[lat + 0.001, lng + 0.002],
|
||||||
|
[lat + 0.002, lng + 0.004],
|
||||||
|
]
|
||||||
|
L.polyline(segLine, { color: esiCol, weight: 5, opacity: 0.8 }).addTo(map)
|
||||||
|
|
||||||
|
// 조사 경로 라인
|
||||||
|
const surveyRoute: [number, number][] = [
|
||||||
|
[lat - 0.0015, lng - 0.003],
|
||||||
|
[lat - 0.0005, lng - 0.001],
|
||||||
|
[lat + 0.0005, lng + 0.001],
|
||||||
|
[lat + 0.0015, lng + 0.003],
|
||||||
|
]
|
||||||
|
L.polyline(surveyRoute, { color: '#3b82f6', weight: 2, opacity: 0.6, dashArray: '6, 4' }).addTo(map)
|
||||||
|
|
||||||
|
// 메인 마커
|
||||||
|
L.circleMarker([lat, lng], {
|
||||||
|
radius: 10, fillColor: esiCol, color: '#fff', weight: 2, fillOpacity: 0.9,
|
||||||
|
}).bindTooltip(
|
||||||
|
`<div style="text-align:center;font-family:'Noto Sans KR',sans-serif;">
|
||||||
|
<div style="font-weight:700;font-size:11px;">${code} ${name}</div>
|
||||||
|
<div style="font-size:10px;opacity:0.7;">ESI ${esi}</div>
|
||||||
|
</div>`,
|
||||||
|
{ permanent: true, direction: 'top', offset: [0, -12], className: 'scat-map-tooltip' }
|
||||||
|
).addTo(map)
|
||||||
|
|
||||||
|
// 접근 포인트
|
||||||
|
L.circleMarker([lat - 0.0015, lng - 0.003], {
|
||||||
|
radius: 6, fillColor: '#eab308', color: '#eab308', weight: 1, fillOpacity: 0.7,
|
||||||
|
}).bindTooltip('접근 포인트', { direction: 'bottom', className: 'scat-map-tooltip' }).addTo(map)
|
||||||
|
|
||||||
|
mapRef.current = map
|
||||||
|
return () => { map.remove(); mapRef.current = null }
|
||||||
|
}, [lat, lng, esi, esiCol, code, name])
|
||||||
|
|
||||||
|
return <div ref={containerRef} className="w-full h-full" />
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ SCAT Popup Modal ═══
|
||||||
|
|
||||||
|
interface ScatPopupProps {
|
||||||
|
data: ScatDetail | null
|
||||||
|
segCode: string
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScatPopup({
|
||||||
|
data,
|
||||||
|
segCode,
|
||||||
|
onClose,
|
||||||
|
}: ScatPopupProps) {
|
||||||
|
const [popTab, setPopTab] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||||
|
document.addEventListener('keydown', handler)
|
||||||
|
return () => document.removeEventListener('keydown', handler)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
if (!data) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-[rgba(5,8,18,0.75)] backdrop-blur-md z-[9999] flex items-center justify-center" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="w-[92%] max-w-[1200px] h-[90vh] bg-bg-1 border border-border rounded-xl shadow-[0_24px_64px_rgba(0,0,0,0.5)] flex flex-col overflow-hidden"
|
||||||
|
style={{ animation: 'spIn 0.3s ease' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
@keyframes spIn { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="text-[13px] font-bold text-status-green font-mono px-2.5 py-1 bg-[rgba(34,197,94,0.1)] border border-[rgba(34,197,94,0.25)] rounded-sm">{data.code}</span>
|
||||||
|
<span className="text-base font-bold font-korean">{data.name}</span>
|
||||||
|
<span className="text-[11px] font-bold px-2.5 py-0.5 rounded-xl text-white" style={{ background: data.esiColor }}>ESI {data.esi}</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="w-9 h-9 rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-[rgba(239,68,68,0.15)] hover:text-status-red hover:border-[rgba(239,68,68,0.3)] transition-colors text-lg">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-border px-6 flex-shrink-0">
|
||||||
|
{['해안정보', '조사 이력'].map((label, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setPopTab(i)}
|
||||||
|
className={`px-5 py-3 text-xs font-semibold font-korean border-b-2 transition-colors cursor-pointer ${
|
||||||
|
popTab === i ? 'text-status-green border-status-green' : 'text-text-3 border-transparent hover:text-text-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
{popTab === 0 && (
|
||||||
|
<div className="flex h-full overflow-hidden">
|
||||||
|
{/* Left column */}
|
||||||
|
<div className="flex-1 overflow-y-auto border-r border-border p-5 px-6 scrollbar-thin">
|
||||||
|
{/* 해안 조사 사진 */}
|
||||||
|
<div className="w-full bg-bg-0 border border-border rounded-md mb-4 relative overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={`/scat-photos/${segCode}-1.png`}
|
||||||
|
alt={`${segCode} 해안 조사 사진`}
|
||||||
|
className="w-full h-auto object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget
|
||||||
|
target.style.display = 'none'
|
||||||
|
const fallback = target.nextElementSibling as HTMLElement
|
||||||
|
if (fallback) fallback.style.display = 'flex'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="w-full aspect-video flex-col items-center justify-center text-text-3 text-xs font-korean hidden">
|
||||||
|
<span className="text-[40px]">📷</span>
|
||||||
|
<span>사진 없음</span>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-2 left-2 px-2 py-0.5 bg-[rgba(10,14,26,0.8)] border border-[rgba(255,255,255,0.1)] rounded text-[10px] font-bold text-white font-mono backdrop-blur-sm">
|
||||||
|
{segCode}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Survey Info */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
|
||||||
|
🏖 해안 기본정보
|
||||||
|
</div>
|
||||||
|
{[
|
||||||
|
['유형', data.type, ''],
|
||||||
|
['기질', data.substrate, data.esiColor === '#dc2626' || data.esiColor === '#991b1b' ? 'text-status-red' : data.esiColor === '#f97316' ? 'text-status-orange' : ''],
|
||||||
|
['구간 길이', data.length, ''],
|
||||||
|
['민감도', data.sensitivity, data.sensitivity === '상' || data.sensitivity === '최상' ? 'text-status-red' : data.sensitivity === '중' ? 'text-status-orange' : 'text-status-green'],
|
||||||
|
['조사 상태', data.status, data.status === '완료' ? 'text-status-green' : data.status === '진행중' ? 'text-status-orange' : ''],
|
||||||
|
['접근성', data.access, ''],
|
||||||
|
['접근 포인트', data.accessPt, ''],
|
||||||
|
].map(([k, v, cls], i) => (
|
||||||
|
<div key={i} className="flex justify-between py-1.5 text-xs border-b border-[rgba(255,255,255,0.06)] last:border-b-0">
|
||||||
|
<span className="text-text-2 font-korean">{k}</span>
|
||||||
|
<span className={`text-white font-semibold font-korean ${cls}`}>{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sensitive Resources */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
|
||||||
|
🌿 민감 자원
|
||||||
|
</div>
|
||||||
|
{data.sensitive.map((s, i) => (
|
||||||
|
<div key={i} className="flex justify-between py-1.5 text-xs border-b border-[rgba(255,255,255,0.06)] last:border-b-0">
|
||||||
|
<span className="text-text-2 font-korean">{s.t}</span>
|
||||||
|
<span className="text-white font-semibold font-korean">{s.v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cleanup Methods */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
|
||||||
|
🧹 방제 방법
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{data.cleanup.map((c, i) => (
|
||||||
|
<span key={i} className="inline-flex items-center gap-1 px-2 py-0.5 bg-[rgba(255,255,255,0.06)] border border-[rgba(255,255,255,0.10)] rounded text-[10px] text-text-1 font-medium font-korean">{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End Criteria */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
|
||||||
|
✅ 방제 종료 기준
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2.5 bg-[rgba(234,179,8,0.06)] border border-[rgba(234,179,8,0.15)] rounded-sm text-[11px] text-text-2 leading-[1.7] font-korean">
|
||||||
|
{data.endCriteria.map((e, i) => (
|
||||||
|
<div key={i} className="pl-3.5 relative mb-1">
|
||||||
|
<span className="absolute left-0 text-status-yellow">•</span>
|
||||||
|
{e}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
|
||||||
|
📝 참고사항
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2.5 bg-[rgba(234,179,8,0.06)] border border-[rgba(234,179,8,0.15)] rounded-sm text-[11px] text-text-2 leading-[1.7] font-korean">
|
||||||
|
{data.notes.map((n, i) => (
|
||||||
|
<div key={i} className="pl-3.5 relative mb-1">
|
||||||
|
<span className="absolute left-0 text-status-yellow">•</span>
|
||||||
|
{n}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column - Satellite map */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-5 px-6 scrollbar-thin">
|
||||||
|
{/* Leaflet Map */}
|
||||||
|
<div className="w-full aspect-[4/3] bg-bg-0 border border-border rounded-md mb-4 overflow-hidden relative">
|
||||||
|
<PopupMap lat={data.lat} lng={data.lng} esi={data.esi} esiCol={data.esiColor} code={data.code} name={data.name} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-4">
|
||||||
|
{[
|
||||||
|
{ color: '#dc2626', label: 'ESI 고위험 구간' },
|
||||||
|
{ color: '#f97316', label: 'ESI 중위험 구간' },
|
||||||
|
{ color: '#22c55e', label: 'ESI 저위험 구간' },
|
||||||
|
{ color: '#3b82f6', label: '조사 경로' },
|
||||||
|
{ color: '#eab308', label: '접근 포인트' },
|
||||||
|
].map((item, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1.5 text-[10px] text-text-2 font-korean">
|
||||||
|
<span className="w-5 h-1 rounded-sm" style={{ background: item.color }} />
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Coordinates */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
|
||||||
|
📍 좌표 정보
|
||||||
|
</div>
|
||||||
|
{[
|
||||||
|
['시작점 위도', `${data.lat.toFixed(4)}°N`],
|
||||||
|
['시작점 경도', `${data.lng.toFixed(4)}°E`],
|
||||||
|
['끝점 위도', `${(data.lat + 0.005).toFixed(4)}°N`],
|
||||||
|
['끝점 경도', `${(data.lng + 0.008).toFixed(4)}°E`],
|
||||||
|
].map(([k, v], i) => (
|
||||||
|
<div key={i} className="flex justify-between py-1.5 text-xs border-b border-[rgba(255,255,255,0.03)] last:border-b-0">
|
||||||
|
<span className="text-text-3 font-korean">{k}</span>
|
||||||
|
<span className="text-status-green font-mono font-medium">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Survey parameters */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
|
||||||
|
⚙ 조사 파라미터
|
||||||
|
</div>
|
||||||
|
{[
|
||||||
|
['조사 일시', '2026-01-15 10:30'],
|
||||||
|
['조사팀', '제주해경 방제과'],
|
||||||
|
['기상 상태', '맑음, 풍속 3.2m/s'],
|
||||||
|
['조위', '중조 (TP +1.2m)'],
|
||||||
|
['파고', '0.5-1.0m'],
|
||||||
|
['수온', '14.2°C'],
|
||||||
|
].map(([k, v], i) => (
|
||||||
|
<div key={i} className="flex justify-between py-1.5 text-xs border-b border-[rgba(255,255,255,0.03)] last:border-b-0">
|
||||||
|
<span className="text-text-3 font-korean">{k}</span>
|
||||||
|
<span className="text-text-1 font-medium font-korean">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{popTab === 1 && (
|
||||||
|
<div className="p-6 overflow-y-auto h-full scrollbar-thin">
|
||||||
|
<div className="text-sm font-bold font-korean mb-4">{data.code} {data.name} — 조사 이력</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{[
|
||||||
|
{ date: '2026-01-15', team: '제주해경 방제과', type: 'Pre-SCAT', status: '완료', note: '초기 사전조사 실시. ESI 확인.' },
|
||||||
|
].map((h, i) => (
|
||||||
|
<div key={i} className="bg-bg-3 border border-border rounded-md p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-bold font-mono">{h.date}</span>
|
||||||
|
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-lg ${
|
||||||
|
h.type === 'Pre-SCAT' ? 'bg-[rgba(34,197,94,0.15)] text-status-green' : 'bg-[rgba(59,130,246,0.15)] text-primary-blue'
|
||||||
|
}`}>
|
||||||
|
{h.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-text-2 font-korean mb-1">조사팀: {h.team}</div>
|
||||||
|
<div className="text-[11px] text-text-3 font-korean">{h.note}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScatPopup
|
||||||
144
frontend/src/tabs/scat/components/ScatTimeline.tsx
Normal file
144
frontend/src/tabs/scat/components/ScatTimeline.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import type { ScatSegment } from './scatTypes'
|
||||||
|
|
||||||
|
interface ScatTimelineProps {
|
||||||
|
segments: ScatSegment[]
|
||||||
|
currentIdx: number
|
||||||
|
onSeek: (idx: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScatTimeline({
|
||||||
|
segments,
|
||||||
|
currentIdx,
|
||||||
|
onSeek,
|
||||||
|
}: ScatTimelineProps) {
|
||||||
|
const [playing, setPlaying] = useState(false)
|
||||||
|
const [speed, setSpeed] = useState(1)
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
const total = Math.min(segments.length, 12)
|
||||||
|
const displaySegs = segments.slice(0, total)
|
||||||
|
const pct = ((currentIdx + 1) / total) * 100
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current)
|
||||||
|
intervalRef.current = null
|
||||||
|
}
|
||||||
|
setPlaying(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const play = useCallback(() => {
|
||||||
|
stop()
|
||||||
|
setPlaying(true)
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
onSeek(-1) // signal to advance
|
||||||
|
}, 800 / speed)
|
||||||
|
}, [speed, stop, onSeek])
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (playing) stop()
|
||||||
|
else play()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playing && intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current)
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
onSeek(-1)
|
||||||
|
}, 800 / speed)
|
||||||
|
}
|
||||||
|
}, [speed, playing, onSeek])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const cycleSpeed = () => {
|
||||||
|
const speeds = [1, 2, 4]
|
||||||
|
setSpeed(s => speeds[(speeds.indexOf(s) + 1) % speeds.length])
|
||||||
|
}
|
||||||
|
|
||||||
|
const doneCount = segments.filter(s => s.status === '완료').length
|
||||||
|
const progCount = segments.filter(s => s.status === '진행중').length
|
||||||
|
const totalLen = segments.reduce((a, s) => a + s.lengthM, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-[72px] bg-[rgba(15,21,36,0.95)] backdrop-blur-2xl border-t border-border flex items-center px-5 gap-4 z-[40]">
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex gap-1 flex-shrink-0">
|
||||||
|
<button onClick={() => onSeek(0)} className="w-[34px] h-[34px] rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-bg-hover text-sm">⏮</button>
|
||||||
|
<button onClick={() => onSeek(Math.max(0, currentIdx - 1))} className="w-[34px] h-[34px] rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-bg-hover text-sm">◀</button>
|
||||||
|
<button onClick={togglePlay} className={`w-[34px] h-[34px] rounded-sm border flex items-center justify-center cursor-pointer text-sm ${playing ? 'bg-status-green text-black border-status-green' : 'border-border bg-bg-3 text-text-2 hover:bg-bg-hover'}`}>
|
||||||
|
{playing ? '⏸' : '▶'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onSeek(Math.min(total - 1, currentIdx + 1))} className="w-[34px] h-[34px] rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-bg-hover text-sm">▶</button>
|
||||||
|
<button onClick={() => onSeek(total - 1)} className="w-[34px] h-[34px] rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-bg-hover text-sm">⏭</button>
|
||||||
|
<div className="w-2" />
|
||||||
|
<button onClick={cycleSpeed} className="w-[34px] h-[34px] rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-bg-hover text-xs font-mono font-bold">{speed}×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="flex-1 flex flex-col gap-1.5">
|
||||||
|
<div className="flex justify-between px-1">
|
||||||
|
{displaySegs.map((s, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`text-[10px] font-mono cursor-pointer ${i === currentIdx ? 'text-status-green font-semibold' : 'text-text-3'}`}
|
||||||
|
onClick={() => onSeek(i)}
|
||||||
|
>
|
||||||
|
{s.code}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="relative h-6 flex items-center">
|
||||||
|
<div className="w-full h-1 bg-border rounded relative">
|
||||||
|
<div className="absolute top-0 left-0 h-full rounded transition-all duration-300" style={{ width: `${pct}%`, background: 'linear-gradient(90deg, var(--green), #4ade80)' }} />
|
||||||
|
{/* Markers */}
|
||||||
|
{displaySegs.map((s, i) => {
|
||||||
|
const x = ((i + 0.5) / total) * 100
|
||||||
|
return (
|
||||||
|
<div key={i} className="absolute w-0.5 bg-border-light" style={{ left: `${x}%`, top: -3, height: i % 3 === 0 ? 14 : 10 }}>
|
||||||
|
{s.status === '완료' && (
|
||||||
|
<span className="absolute -top-[18px] left-1/2 -translate-x-1/2 text-[10px] cursor-pointer" style={{ filter: 'drop-shadow(0 0 4px rgba(34,197,94,0.5))' }}>✅</span>
|
||||||
|
)}
|
||||||
|
{s.status === '진행중' && (
|
||||||
|
<span className="absolute -top-[18px] left-1/2 -translate-x-1/2 text-[10px]">⏳</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* Thumb */}
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-status-green border-[3px] border-bg-0 rounded-full cursor-grab shadow-[0_0_10px_rgba(34,197,94,0.4)] z-[2] transition-all duration-300"
|
||||||
|
style={{ left: `${pct}%`, transform: `translate(-50%, -50%)` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[210px]">
|
||||||
|
<span className="text-sm font-semibold text-status-green font-mono">
|
||||||
|
구간 {displaySegs[currentIdx]?.code || 'S-001'} / {total}개
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-3.5">
|
||||||
|
<span className="flex items-center gap-1.5 text-[11px]">
|
||||||
|
<span className="text-text-2 font-korean">완료</span>
|
||||||
|
<span className="text-text-1 font-semibold font-mono" style={{ color: 'var(--green)' }}>{doneCount}/{segments.length}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5 text-[11px]">
|
||||||
|
<span className="text-text-2 font-korean">진행중</span>
|
||||||
|
<span className="text-text-1 font-semibold font-mono" style={{ color: 'var(--orange)' }}>{progCount}/{segments.length}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5 text-[11px]">
|
||||||
|
<span className="text-text-2 font-korean">총 해안선</span>
|
||||||
|
<span className="text-text-1 font-semibold font-mono">{(totalLen / 1000).toFixed(1)} km</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScatTimeline
|
||||||
387
frontend/src/tabs/scat/components/scatConstants.ts
Normal file
387
frontend/src/tabs/scat/components/scatConstants.ts
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
import type { ScatSegment, ScatDetail } from './scatTypes'
|
||||||
|
|
||||||
|
// ═══ ESI 색상 ═══
|
||||||
|
|
||||||
|
export const esiColor = (n: number): string => {
|
||||||
|
if (n >= 10) return '#991b1b'
|
||||||
|
if (n >= 9) return '#b91c1c'
|
||||||
|
if (n >= 8) return '#dc2626'
|
||||||
|
if (n >= 7) return '#ef4444'
|
||||||
|
if (n >= 6) return '#f97316'
|
||||||
|
if (n >= 5) return '#fb923c'
|
||||||
|
if (n >= 4) return '#facc15'
|
||||||
|
if (n >= 3) return '#a3e635'
|
||||||
|
if (n >= 2) return '#22c55e'
|
||||||
|
return '#4ade80'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sensColor: Record<string, string> = { '최상': 'var(--red)', '상': 'var(--red)', '중': 'var(--orange)', '하': 'var(--green)' }
|
||||||
|
export const statusColor: Record<string, string> = { '완료': 'var(--green)', '진행중': 'var(--orange)', '미조사': 'var(--t3)' }
|
||||||
|
export const esiLevel = (n: number) => n >= 8 ? 'h' : n >= 5 ? 'm' : 'l'
|
||||||
|
|
||||||
|
// ═══ Mock Data ═══
|
||||||
|
|
||||||
|
// --- 서귀포시 (서귀포해양경비안전서 관할) ---
|
||||||
|
export const sgAreas = [
|
||||||
|
{ area: '성산읍', code: 'SGSS', cnt: 99, villages: ['시흥리', '오조리', '성산리', '고성리', '온평리', '신산리', '삼달리', '신풍리', '신천리'], jurisdiction: '서귀포' },
|
||||||
|
{ area: '표선면', code: 'SGPS', cnt: 41, villages: ['하천리', '표선리', '세화리'], jurisdiction: '서귀포' },
|
||||||
|
{ area: '남원읍', code: 'SGNW', cnt: 73, villages: ['신흥리', '태흥리', '남원리', '위미리', '신례리'], jurisdiction: '서귀포' },
|
||||||
|
{ area: '하효동·보목동', code: 'SGHY', cnt: 8, villages: ['하효동', '보목동'], jurisdiction: '서귀포' },
|
||||||
|
{ area: '토평동·동흥동', code: 'SGTP', cnt: 12, villages: ['토평동', '동흥동'], jurisdiction: '서귀포' },
|
||||||
|
{ area: '서귀동·서홍동', code: 'SGSG', cnt: 20, villages: ['서귀동', '서홍동'], jurisdiction: '서귀포' },
|
||||||
|
{ area: '호근동·법환동', code: 'SGHG', cnt: 6, villages: ['호근동', '서호동', '법환동'], jurisdiction: '서귀포' },
|
||||||
|
{ area: '강정동', code: 'SGGJ', cnt: 21, villages: ['강정동'], jurisdiction: '서귀포' },
|
||||||
|
{ area: '월평동·대포동', code: 'SGWP', cnt: 4, villages: ['월평동', '하원동', '대포동'], jurisdiction: '서귀포' },
|
||||||
|
{ area: '중문동', code: 'SGJM', cnt: 8, villages: ['중문동'], jurisdiction: '서귀포' },
|
||||||
|
{ area: '색달동·하예동', code: 'SGSE', cnt: 8, villages: ['색달동', '하예동'], jurisdiction: '서귀포' },
|
||||||
|
{ area: '안덕면', code: 'SGAD', cnt: 38, villages: ['감산리', '사계리', '덕수리', '창천리', '대평리', '화순리'], jurisdiction: '서귀포' },
|
||||||
|
{ area: '대정읍', code: 'SGDJ', cnt: 79, villages: ['상모리', '하모리', '영락리', '인성리', '보성리', '무릉리', '신도리'], jurisdiction: '서귀포' },
|
||||||
|
]
|
||||||
|
// --- 제주시 (제주해양경비안전서 관할) ---
|
||||||
|
export const jjAreas = [
|
||||||
|
{ area: '한경면', code: 'JJHG', cnt: 81, villages: ['고산리', '금등리', '두모리', '신창리', '용수리', '판포리'], jurisdiction: '제주' },
|
||||||
|
{ area: '한림읍', code: 'JJHL', cnt: 87, villages: ['귀덕리', '금능리', '수원리', '옹포리', '월령리', '한림리', '한수리', '협재리'], jurisdiction: '제주' },
|
||||||
|
{ area: '애월읍', code: 'JJAW', cnt: 89, villages: ['고내리', '곽지리', '구엄리', '금성리', '신엄리', '애월리', '하귀1리', '하귀2리'], jurisdiction: '제주' },
|
||||||
|
{ area: '외도이동', code: 'JJOD', cnt: 19, villages: ['외도이동'], jurisdiction: '제주' },
|
||||||
|
{ area: '내도동', code: 'JJND', cnt: 7, villages: ['내도동'], jurisdiction: '제주' },
|
||||||
|
{ area: '이호일동', code: 'JJIH', cnt: 20, villages: ['이호일동'], jurisdiction: '제주' },
|
||||||
|
{ area: '도두동', code: 'JJDD', cnt: 17, villages: ['도두일동', '도두이동'], jurisdiction: '제주' },
|
||||||
|
{ area: '용담동', code: 'JJYD', cnt: 19, villages: ['용담삼동', '용담이동', '용담일동'], jurisdiction: '제주' },
|
||||||
|
{ area: '삼도이동', code: 'JJSD', cnt: 2, villages: ['삼도2동'], jurisdiction: '제주' },
|
||||||
|
{ area: '건입동', code: 'JJGI', cnt: 26, villages: ['건입동'], jurisdiction: '제주' },
|
||||||
|
{ area: '화북일동', code: 'JJHB', cnt: 23, villages: ['화북일동'], jurisdiction: '제주' },
|
||||||
|
{ area: '삼양삼동', code: 'JJYN', cnt: 19, villages: ['삼양삼동', '삼양이동', '삼양일동'], jurisdiction: '제주' },
|
||||||
|
{ area: '삼양일동', code: 'JJSY', cnt: 24, villages: ['삼양이동', '삼양일동'], jurisdiction: '제주' },
|
||||||
|
{ area: '조천읍', code: 'JJJC', cnt: 95, villages: ['북촌리', '신촌리', '신흥리', '조천리', '함덕리'], jurisdiction: '제주' },
|
||||||
|
{ area: '구좌읍', code: 'JJGJ', cnt: 147, villages: ['김녕리', '동복리', '상도리', '월정리', '종달리', '평대리', '하도리', '한동리', '행원리'], jurisdiction: '제주' },
|
||||||
|
]
|
||||||
|
export const scatAreas = [...sgAreas, ...jjAreas]
|
||||||
|
|
||||||
|
export const scatSubstrates = ['투과성 인공호안', '수직호안', '모래', '모래자갈혼합', '자갈·왕자갈', '수평암반', '수직암반']
|
||||||
|
export const substrateESI: Record<string, { esi: string; n: number }> = {
|
||||||
|
'투과성 인공호안': { esi: '6B', n: 6 }, '수직호안': { esi: '1B', n: 1 },
|
||||||
|
'모래': { esi: '3A', n: 3 }, '모래자갈혼합': { esi: '5', n: 5 },
|
||||||
|
'자갈·왕자갈': { esi: '6A', n: 6 }, '수평암반': { esi: '8A', n: 8 }, '수직암반': { esi: '1A', n: 1 },
|
||||||
|
}
|
||||||
|
export const scatTagSets = [['🦪 양식장'], ['🏖 해수욕장'], ['⛵ 항구'], ['🪸 산호'], ['🌿 보호구역'], ['🐢 생태보전'], ['🏛 문화재'], ['⛰ 해안절벽'], ['🔧 인공구조물'], ['🌊 올레길']]
|
||||||
|
const sensFromESI = (n: number): ScatSegment['sensitivity'] => n >= 9 ? '최상' : n >= 7 ? '상' : n >= 5 ? '중' : '하'
|
||||||
|
const statusArr: ScatSegment['status'][] = ['완료', '완료', '완료', '완료', '진행중', '미조사']
|
||||||
|
|
||||||
|
// 지역별 좌표 범위 (제주도 전체 해안)
|
||||||
|
export const areaCoords: Record<string, { latC: number; lngC: number; latR: number; lngR: number }> = {
|
||||||
|
// 서귀포시 (남부 해안)
|
||||||
|
SGSS: { latC: 33.39, lngC: 126.89, latR: 0.07, lngR: 0.05 },
|
||||||
|
SGPS: { latC: 33.33, lngC: 126.81, latR: 0.03, lngR: 0.04 },
|
||||||
|
SGNW: { latC: 33.26, lngC: 126.63, latR: 0.02, lngR: 0.05 },
|
||||||
|
SGHY: { latC: 33.245, lngC: 126.59, latR: 0.005, lngR: 0.02 },
|
||||||
|
SGTP: { latC: 33.245, lngC: 126.555, latR: 0.005, lngR: 0.015 },
|
||||||
|
SGSG: { latC: 33.245, lngC: 126.53, latR: 0.005, lngR: 0.015 },
|
||||||
|
SGHG: { latC: 33.245, lngC: 126.50, latR: 0.005, lngR: 0.02 },
|
||||||
|
SGGJ: { latC: 33.245, lngC: 126.45, latR: 0.005, lngR: 0.03 },
|
||||||
|
SGWP: { latC: 33.245, lngC: 126.40, latR: 0.005, lngR: 0.02 },
|
||||||
|
SGJM: { latC: 33.245, lngC: 126.37, latR: 0.005, lngR: 0.015 },
|
||||||
|
SGSE: { latC: 33.245, lngC: 126.34, latR: 0.005, lngR: 0.015 },
|
||||||
|
SGAD: { latC: 33.24, lngC: 126.29, latR: 0.01, lngR: 0.035 },
|
||||||
|
SGDJ: { latC: 33.25, lngC: 126.21, latR: 0.035, lngR: 0.05 },
|
||||||
|
// 제주시 (북부 해안)
|
||||||
|
JJHG: { latC: 33.31, lngC: 126.19, latR: 0.04, lngR: 0.04 },
|
||||||
|
JJHL: { latC: 33.39, lngC: 126.26, latR: 0.04, lngR: 0.05 },
|
||||||
|
JJAW: { latC: 33.46, lngC: 126.35, latR: 0.04, lngR: 0.06 },
|
||||||
|
JJOD: { latC: 33.505, lngC: 126.43, latR: 0.005, lngR: 0.015 },
|
||||||
|
JJND: { latC: 33.505, lngC: 126.44, latR: 0.003, lngR: 0.008 },
|
||||||
|
JJIH: { latC: 33.50, lngC: 126.46, latR: 0.005, lngR: 0.012 },
|
||||||
|
JJDD: { latC: 33.51, lngC: 126.49, latR: 0.005, lngR: 0.012 },
|
||||||
|
JJYD: { latC: 33.515, lngC: 126.52, latR: 0.005, lngR: 0.015 },
|
||||||
|
JJSD: { latC: 33.515, lngC: 126.525, latR: 0.003, lngR: 0.005 },
|
||||||
|
JJGI: { latC: 33.52, lngC: 126.545, latR: 0.005, lngR: 0.015 },
|
||||||
|
JJHB: { latC: 33.52, lngC: 126.565, latR: 0.005, lngR: 0.012 },
|
||||||
|
JJYN: { latC: 33.52, lngC: 126.585, latR: 0.005, lngR: 0.012 },
|
||||||
|
JJSY: { latC: 33.52, lngC: 126.59, latR: 0.005, lngR: 0.012 },
|
||||||
|
JJJC: { latC: 33.535, lngC: 126.64, latR: 0.015, lngR: 0.04 },
|
||||||
|
JJGJ: { latC: 33.53, lngC: 126.78, latR: 0.03, lngR: 0.10 },
|
||||||
|
}
|
||||||
|
|
||||||
|
// 제주도 전체 해안선 좌표 (시계방향: 대정읍→서귀포→성산→조천→구좌→한경)
|
||||||
|
export const jejuCoastCoords: [number, number][] = [
|
||||||
|
// 서부 (대정읍~한경면)
|
||||||
|
[33.2800, 126.1600], [33.2600, 126.1800], [33.2400, 126.2000],
|
||||||
|
// 남부 (서귀포시 해안: 대정→안덕→중문→강정→서귀→남원→표선→성산)
|
||||||
|
[33.2300, 126.2300], [33.2350, 126.2600], [33.2400, 126.2900], [33.2450, 126.3200],
|
||||||
|
[33.2470, 126.3500], [33.2460, 126.3700], [33.2450, 126.4000], [33.2440, 126.4300],
|
||||||
|
[33.2430, 126.4600], [33.2420, 126.4900], [33.2410, 126.5100], [33.2400, 126.5300],
|
||||||
|
[33.2400, 126.5500], [33.2410, 126.5700], [33.2430, 126.5900], [33.2450, 126.6200],
|
||||||
|
[33.2500, 126.6600], [33.2600, 126.7000], [33.2800, 126.7400], [33.3100, 126.7800],
|
||||||
|
[33.3300, 126.8200], [33.3600, 126.8400], [33.3900, 126.8600], [33.4200, 126.8800],
|
||||||
|
[33.4400, 126.9000], [33.4530, 126.9100], [33.4580, 126.9200], [33.4610, 126.9310],
|
||||||
|
// 동부 (성산~구좌)
|
||||||
|
[33.4700, 126.9200], [33.4900, 126.9100], [33.5100, 126.8700],
|
||||||
|
[33.5200, 126.8500], [33.5350, 126.8200], [33.5450, 126.7900],
|
||||||
|
// 북부 (제주시 해안: 구좌→조천→건입→이호→애월→한림→한경)
|
||||||
|
[33.5500, 126.7600], [33.5500, 126.7300], [33.5450, 126.7000],
|
||||||
|
[33.5400, 126.6800], [33.5350, 126.6600], [33.5300, 126.6400], [33.5250, 126.6200],
|
||||||
|
[33.5200, 126.6000], [33.5200, 126.5800], [33.5200, 126.5600], [33.5180, 126.5400],
|
||||||
|
[33.5160, 126.5200], [33.5140, 126.5000], [33.5120, 126.4800], [33.5100, 126.4600],
|
||||||
|
[33.5050, 126.4400], [33.5000, 126.4200], [33.4950, 126.4000], [33.4850, 126.3800],
|
||||||
|
[33.4700, 126.3500], [33.4550, 126.3300], [33.4400, 126.3100], [33.4200, 126.2900],
|
||||||
|
[33.4000, 126.2700], [33.3800, 126.2500], [33.3600, 126.2350], [33.3400, 126.2200],
|
||||||
|
[33.3200, 126.2050], [33.3100, 126.1900], [33.3000, 126.1750], [33.2930, 126.1620],
|
||||||
|
]
|
||||||
|
|
||||||
|
function seededRandom(seed: number) {
|
||||||
|
const x = Math.sin(seed) * 10000
|
||||||
|
return x - Math.floor(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateSegments = (): ScatSegment[] => {
|
||||||
|
const segs: ScatSegment[] = []
|
||||||
|
let idx = 0
|
||||||
|
scatAreas.forEach(a => {
|
||||||
|
const ac = areaCoords[a.code]
|
||||||
|
for (let i = 0; i < a.cnt; i++) {
|
||||||
|
const seed = idx * 137 + 42
|
||||||
|
const village = a.villages[Math.floor(seededRandom(seed) * a.villages.length)]
|
||||||
|
const substrate = scatSubstrates[Math.floor(seededRandom(seed + 1) * scatSubstrates.length)]
|
||||||
|
const { esi: esiStr, n: esiNum } = substrateESI[substrate]
|
||||||
|
const lengthM = Math.floor(seededRandom(seed + 3) * 900) + 100
|
||||||
|
// 지역 좌표 범위 내 분포
|
||||||
|
const progress = a.cnt > 1 ? i / (a.cnt - 1) : 0.5
|
||||||
|
const lat = ac.latC + (progress - 0.5) * ac.latR * 2 + (seededRandom(seed + 6) - 0.5) * 0.003
|
||||||
|
const lng = ac.lngC + (progress - 0.5) * ac.lngR * 2 + (seededRandom(seed + 7) - 0.5) * 0.003
|
||||||
|
segs.push({
|
||||||
|
id: idx,
|
||||||
|
code: `${a.code}-${i + 1}`,
|
||||||
|
area: a.area,
|
||||||
|
name: `${village} 해안`,
|
||||||
|
type: substrate,
|
||||||
|
esi: esiStr,
|
||||||
|
esiNum,
|
||||||
|
length: `${lengthM.toLocaleString()}.0 m`,
|
||||||
|
lengthM,
|
||||||
|
sensitivity: sensFromESI(esiNum),
|
||||||
|
status: statusArr[Math.floor(seededRandom(seed + 5) * statusArr.length)],
|
||||||
|
lat, lng,
|
||||||
|
tags: scatTagSets[Math.floor(seededRandom(seed + 8) * scatTagSets.length)],
|
||||||
|
jurisdiction: a.jurisdiction,
|
||||||
|
})
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return segs
|
||||||
|
}
|
||||||
|
|
||||||
|
export const allSegments = generateSegments()
|
||||||
|
|
||||||
|
export const scatDetailData: ScatDetail[] = [
|
||||||
|
// ═══ 서귀포시 (남부 해안) ═══
|
||||||
|
// SGSS-1: 성산읍 시흥리 — 투과성 인공호안
|
||||||
|
{
|
||||||
|
code: 'SGSS-1', name: '서귀포시 성산읍 시흥리', esi: '6B', esiColor: '#f97316', lat: 33.4610, lng: 126.9310,
|
||||||
|
type: '폐쇄형', substrate: '투과성 인공호안', length: '846.4m', sensitivity: '중', status: '완료',
|
||||||
|
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 성산읍 시흥리 1270-1, 12-64',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '올레길1코스, 파래양식장' }, { t: '생물자원', v: '없음' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||||
|
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
||||||
|
},
|
||||||
|
// SGSS-6: 성산읍 시흥리 — 모래
|
||||||
|
{
|
||||||
|
code: 'SGSS-6', name: '서귀포시 성산읍 시흥리', esi: '3A', esiColor: '#a3e635', lat: 33.4580, lng: 126.9200,
|
||||||
|
type: '폐쇄형', substrate: '모래', length: '131.3m', sensitivity: '하', status: '완료',
|
||||||
|
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 성산읍 시흥리 1',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '숙박시설, 조가비박물관' }, { t: '생물자원', v: '없음' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
||||||
|
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대(upper swash zone)의 유류제거에 집중'],
|
||||||
|
},
|
||||||
|
// SGSS-10: 성산읍 오조리 — 수평암반
|
||||||
|
{
|
||||||
|
code: 'SGSS-10', name: '서귀포시 성산읍 오조리', esi: '8A', esiColor: '#dc2626', lat: 33.4500, lng: 126.9050,
|
||||||
|
type: '개방형', substrate: '수평암반', length: '433.6m', sensitivity: '상', status: '완료',
|
||||||
|
access: '도보로 접근 가능, 인근구획에서 접근', accessPt: '서귀포시 성산읍 오조리 391',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '교육시설(성산고등학교)' }, { t: '생물자원', v: '없음' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 30% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||||
|
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
|
||||||
|
},
|
||||||
|
// SGPS-6: 표선면 표선리 — 모래 (표선해수욕장)
|
||||||
|
{
|
||||||
|
code: 'SGPS-6', name: '서귀포시 표선면 표선리', esi: '3A', esiColor: '#a3e635', lat: 33.3270, lng: 126.8320,
|
||||||
|
type: '폐쇄형', substrate: '모래', length: '827.9m', sensitivity: '하', status: '완료',
|
||||||
|
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 표선면 표선리 464-1',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '표선해수욕장, 올레길3코스, 숙박시설, 민가' }, { t: '생물자원', v: '없음' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
||||||
|
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대의 유류제거에 집중'],
|
||||||
|
},
|
||||||
|
// SGNW-5: 남원읍 태흥리 — 수평암반
|
||||||
|
{
|
||||||
|
code: 'SGNW-5', name: '서귀포시 남원읍 태흥리', esi: '8A', esiColor: '#dc2626', lat: 33.2510, lng: 126.6650,
|
||||||
|
type: '개방형', substrate: '수평암반', length: '432.8m', sensitivity: '상', status: '완료',
|
||||||
|
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 남원읍 태흥리 7',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '육상양식장' }, { t: '생물자원', v: '없음' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 30% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||||
|
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
|
||||||
|
},
|
||||||
|
// SGNW-12: 남원읍 태흥리 — 모래자갈혼합
|
||||||
|
{
|
||||||
|
code: 'SGNW-12', name: '서귀포시 남원읍 태흥리', esi: '5', esiColor: '#fb923c', lat: 33.2480, lng: 126.6400,
|
||||||
|
type: '폐쇄형', substrate: '모래자갈혼합', length: '237.3m', sensitivity: '중', status: '진행중',
|
||||||
|
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 남원읍 태흥리 364-2',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '올레길4코스, 민가' }, { t: '생물자원', v: '없음' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압세척', '범람(저수세정, Flooding)'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
||||||
|
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능'],
|
||||||
|
},
|
||||||
|
// SGTP-5: 서귀동 — 투과성 인공호안 (서귀포항)
|
||||||
|
{
|
||||||
|
code: 'SGTP-5', name: '서귀포시 서귀동', esi: '6B', esiColor: '#f97316', lat: 33.2400, lng: 126.5550,
|
||||||
|
type: '개방형', substrate: '투과성 인공호안', length: '701.6m', sensitivity: '중', status: '완료',
|
||||||
|
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 서귀동 758-5',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '서귀포항' }, { t: '생물자원', v: '없음' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||||
|
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
||||||
|
},
|
||||||
|
// SGGJ-5: 강정동 — 수직호안
|
||||||
|
{
|
||||||
|
code: 'SGGJ-5', name: '서귀포시 강정동', esi: '1B', esiColor: '#4ade80', lat: 33.2430, lng: 126.4500,
|
||||||
|
type: '폐쇄형', substrate: '수직호안', length: '380.0m', sensitivity: '하', status: '완료',
|
||||||
|
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 강정동 산1',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '강정항' }, { t: '생물자원', v: '없음' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||||
|
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적'],
|
||||||
|
},
|
||||||
|
// SGAD-5: 안덕면 감산리 — 수직호안 (대평항)
|
||||||
|
{
|
||||||
|
code: 'SGAD-5', name: '서귀포시 안덕면 감산리', esi: '1B', esiColor: '#4ade80', lat: 33.2400, lng: 126.2950,
|
||||||
|
type: '폐쇄형', substrate: '수직호안', length: '246.9m', sensitivity: '하', status: '완료',
|
||||||
|
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 안덕면 감산리 982-1',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '대평항' }, { t: '생물자원', v: '없음' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||||
|
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
||||||
|
},
|
||||||
|
// SGAD-7: 안덕면 감산리 — 자갈·왕자갈
|
||||||
|
{
|
||||||
|
code: 'SGAD-7', name: '서귀포시 안덕면 감산리', esi: '6A', esiColor: '#f97316', lat: 33.2380, lng: 126.2850,
|
||||||
|
type: '폐쇄형', substrate: '자갈·왕자갈', length: '154.2m', sensitivity: '중', status: '진행중',
|
||||||
|
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 안덕면 감산리 985',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '올레길8코스(해안로), 민가' }, { t: '생물자원', v: '없음' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압세척', '범람(저수세정, Flooding)', '자갈세척'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
||||||
|
notes: ['파도가 덮치는 지점 상부에 고인기름은 신속히 제거', '저압세척은 퇴적물로부터 표착유를 재부유시키며, 부유기름은 흡착재로 회수'],
|
||||||
|
},
|
||||||
|
// SGDJ-5: 대정읍 상모리 — 수직호안 (산이수동항)
|
||||||
|
{
|
||||||
|
code: 'SGDJ-5', name: '서귀포시 대정읍 상모리', esi: '1B', esiColor: '#4ade80', lat: 33.2300, lng: 126.2350,
|
||||||
|
type: '개방형', substrate: '수직호안', length: '202.0m', sensitivity: '하', status: '완료',
|
||||||
|
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 대정읍 상모리 133',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '산이수동항' }, { t: '생물자원', v: '마라해안군립공원' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||||
|
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
||||||
|
},
|
||||||
|
// SGDJ-7: 대정읍 상모리 — 모래 (송악산)
|
||||||
|
{
|
||||||
|
code: 'SGDJ-7', name: '서귀포시 대정읍 상모리', esi: '3A', esiColor: '#a3e635', lat: 33.2280, lng: 126.2280,
|
||||||
|
type: '개방형', substrate: '모래', length: '179.6m', sensitivity: '하', status: '미조사',
|
||||||
|
access: '도보로 접근 가능', accessPt: '서귀포시 대정읍 상모리 179-3',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '송악산' }, { t: '생물자원', v: '마라해안군립공원' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '퇴적물 갈기/파도세척'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
||||||
|
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대의 유류제거에 집중'],
|
||||||
|
},
|
||||||
|
// SGDJ-8: 대정읍 상모리 — 수직암반 (송악산)
|
||||||
|
{
|
||||||
|
code: 'SGDJ-8', name: '서귀포시 대정읍 상모리', esi: '1A', esiColor: '#4ade80', lat: 33.2260, lng: 126.2200,
|
||||||
|
type: '개방형', substrate: '수직암반', length: '585.1m', sensitivity: '하', status: '완료',
|
||||||
|
access: '선박을 이용하여 접근', accessPt: '서귀포시 대정읍 상모리 179-3',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '송악산' }, { t: '생물자원', v: '마라해안군립공원' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 20% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||||
|
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
|
||||||
|
},
|
||||||
|
// ═══ 제주시 (북부 해안) ═══
|
||||||
|
// JJHG-1: 한경면 고산리 — 수평암반
|
||||||
|
{
|
||||||
|
code: 'JJHG-1', name: '제주시 한경면 고산리', esi: '8A', esiColor: '#dc2626', lat: 33.2930, lng: 126.1620,
|
||||||
|
type: '개방형', substrate: '수평암반', length: '306.0m', sensitivity: '상', status: '완료',
|
||||||
|
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 한경면 고산리 3987',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '육상양식장(도로 주변 농사구역)' }, { t: '생물자원', v: '없음' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 30% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||||
|
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이', '부분적으로 모래와 암반이 형성되어 있음'],
|
||||||
|
},
|
||||||
|
// JJHG-8: 한경면 고산리 — 투과성 인공호안 (차귀도항)
|
||||||
|
{
|
||||||
|
code: 'JJHG-8', name: '제주시 한경면 고산리', esi: '6B', esiColor: '#f97316', lat: 33.3100, lng: 126.1750,
|
||||||
|
type: '개방형', substrate: '투과성 인공호안', length: '201.8m', sensitivity: '중', status: '완료',
|
||||||
|
access: '차량으로 접근 가능, 소형선박 이용 방제작업 가능', accessPt: '제주시 한경면 고산리 3616-10',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '차귀도항, 잠수함 매표소' }, { t: '생물자원', v: '없음' }],
|
||||||
|
cleanup: ['수작업(흡착제,걸레)에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||||
|
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
||||||
|
},
|
||||||
|
// JJHL-4: 한림읍 월령리 — 모래
|
||||||
|
{
|
||||||
|
code: 'JJHL-4', name: '제주시 한림읍 월령리', esi: '3A', esiColor: '#a3e635', lat: 33.3900, lng: 126.2400,
|
||||||
|
type: '폐쇄형', substrate: '모래', length: '100.2m', sensitivity: '하', status: '완료',
|
||||||
|
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 한림읍 월령리 3855',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '월령항, 숙박시설' }, { t: '생물자원', v: '없음' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
||||||
|
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대(upper swash zone)의 유류제거에 집중'],
|
||||||
|
},
|
||||||
|
// JJAW-8: 애월읍 곽지리 — 모래 (곽지해수욕장)
|
||||||
|
{
|
||||||
|
code: 'JJAW-8', name: '제주시 애월읍 곽지리', esi: '3A', esiColor: '#a3e635', lat: 33.4700, lng: 126.3400,
|
||||||
|
type: '개방형', substrate: '모래', length: '573.6m', sensitivity: '하', status: '완료',
|
||||||
|
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 애월읍 곽지리 3855',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '곽지해수욕장, 캠핑장' }, { t: '생물자원', v: '없음' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '퇴적물 갈기/파도세척'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
||||||
|
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대의 유류제거에 집중'],
|
||||||
|
},
|
||||||
|
// JJGI-3: 건입동 — 수직호안 (제주항)
|
||||||
|
{
|
||||||
|
code: 'JJGI-3', name: '제주시 건입동', esi: '1B', esiColor: '#4ade80', lat: 33.5200, lng: 126.5450,
|
||||||
|
type: '폐쇄형', substrate: '수직호안', length: '365.8m', sensitivity: '하', status: '완료',
|
||||||
|
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 건입동 3855',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '제주항, 제주조선' }, { t: '생물자원', v: '없음' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||||
|
notes: ['유출유는 호안의 거친 표면에 쉽게 표착될 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
||||||
|
},
|
||||||
|
// JJJC-4: 조천읍 신촌리 — 자갈·왕자갈
|
||||||
|
{
|
||||||
|
code: 'JJJC-4', name: '제주시 조천읍 신촌리', esi: '6A', esiColor: '#f97316', lat: 33.5380, lng: 126.6400,
|
||||||
|
type: '폐쇄형', substrate: '자갈·왕자갈', length: '360.4m', sensitivity: '중', status: '진행중',
|
||||||
|
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 조천읍 신촌리 3855',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '정치망어장(전면 270m)' }, { t: '생물자원', v: '폐류 서식지' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압세척', '범람(저수세정, Flooding)', '자갈세척'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
||||||
|
notes: ['파도가 덮치는 지점 상부에 고인기름은 신속히 제거', '저압세척은 퇴적물로부터 표착유를 재부유시키며, 부유기름은 흡착재로 회수'],
|
||||||
|
},
|
||||||
|
// JJGJ-2: 구좌읍 동복리 — 투과성 인공호안
|
||||||
|
{
|
||||||
|
code: 'JJGJ-2', name: '제주시 구좌읍 동복리', esi: '6B', esiColor: '#f97316', lat: 33.5500, lng: 126.7300,
|
||||||
|
type: '폐쇄형', substrate: '투과성 인공호안', length: '219.2m', sensitivity: '중', status: '완료',
|
||||||
|
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 구좌읍 동복리 3855',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '접안시설' }, { t: '생물자원', v: '없음' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 20% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||||
|
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
||||||
|
},
|
||||||
|
// JJGJ-3: 구좌읍 동복리 — 수평암반
|
||||||
|
{
|
||||||
|
code: 'JJGJ-3', name: '제주시 구좌읍 동복리', esi: '8A', esiColor: '#dc2626', lat: 33.5480, lng: 126.7350,
|
||||||
|
type: '개방형', substrate: '수평암반', length: '197.4m', sensitivity: '상', status: '미조사',
|
||||||
|
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 구좌읍 동복리 3855',
|
||||||
|
sensitive: [{ t: '사회·경제적', v: '산책로, 민가' }, { t: '생물자원', v: '없음' }],
|
||||||
|
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
|
||||||
|
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 20% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||||
|
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
|
||||||
|
},
|
||||||
|
]
|
||||||
37
frontend/src/tabs/scat/components/scatTypes.ts
Normal file
37
frontend/src/tabs/scat/components/scatTypes.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
export interface ScatSegment {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
area: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
esi: string
|
||||||
|
esiNum: number
|
||||||
|
length: string
|
||||||
|
lengthM: number
|
||||||
|
sensitivity: '최상' | '상' | '중' | '하'
|
||||||
|
status: '완료' | '진행중' | '미조사'
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
tags: string[]
|
||||||
|
jurisdiction: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScatDetail {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
esi: string
|
||||||
|
esiColor: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
type: string
|
||||||
|
substrate: string
|
||||||
|
length: string
|
||||||
|
sensitivity: string
|
||||||
|
status: string
|
||||||
|
access: string
|
||||||
|
accessPt: string
|
||||||
|
sensitive: { t: string; v: string }[]
|
||||||
|
cleanup: string[]
|
||||||
|
endCriteria: string[]
|
||||||
|
notes: string[]
|
||||||
|
}
|
||||||
불러오는 중...
Reference in New Issue
Block a user