release: Phase 1~5 리팩토링 통합 릴리즈 #26

병합
htlee develop 에서 main 로 14 commits 를 머지했습니다 2026-02-28 18:44:26 +09:00
64개의 변경된 파일13106개의 추가작업 그리고 9757개의 파일을 삭제
Showing only changes of commit aa2b6f0221 - Show all commits

파일 보기

@ -1,11 +1,13 @@
import type { Request, Response, NextFunction } from 'express'
import { verifyToken, getTokenFromCookie } from './jwtProvider.js'
import type { JwtPayload } from './jwtProvider.js'
import { getUserInfo } from './authService.js'
declare global {
namespace Express {
interface Request {
user?: JwtPayload
resolvedPermissions?: Record<string, string[]>
}
}
}
@ -43,3 +45,43 @@ export function requireRole(...roles: string[]) {
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 { signToken, setTokenCookie } from './jwtProvider.js'
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 SALT_ROUNDS = 10
@ -24,7 +26,7 @@ interface AuthUserInfo {
rank: string | null
org: { sn: number; name: string; abbr: string } | null
roles: string[]
permissions: string[]
permissions: Record<string, string[]>
}
export async function login(
@ -127,9 +129,9 @@ export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
const row = userResult.rows[0]
// 역할 조회
// 역할 조회 (ROLE_SN + ROLE_CD)
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
JOIN AUTH_ROLE r ON ur.ROLE_SN = r.ROLE_SN
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 roleSns = rolesResult.rows.map((r: { role_sn: number }) => r.role_sn)
// 권한 조회 (역할 기반)
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]
)
// 트리 기반 resolved permissions (리소스 × 오퍼레이션)
let permissions: Record<string, string[]>
try {
const treeNodes = await getPermTreeNodes()
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 {
id: row.user_id,

파일 보기

@ -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

파일 보기

@ -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
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 const authPool = wingPool
export async function testAuthDbConnection(): Promise<boolean> {
try {
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
}
return testWingDbConnection()
}
export { authPool }

파일 보기

@ -2,19 +2,30 @@ import pg from 'pg'
const { Pool } = pg
// ============================================================
// wing DB 통합 Pool (wing 스키마 + auth 스키마)
// - wing 스키마: 운영 데이터 (LAYER, BOARD_POST 등)
// - auth 스키마: 인증/인가 데이터 (AUTH_USER, AUTH_ROLE 등)
// - public 스키마: PostGIS 시스템 테이블만 유지
// ============================================================
const wingPool = new Pool({
host: process.env.WING_DB_HOST || 'localhost',
port: Number(process.env.WING_DB_PORT) || 5432,
database: process.env.WING_DB_NAME || 'wing',
user: process.env.WING_DB_USER || 'wing',
password: process.env.WING_DB_PASSWORD || 'Wing2026',
max: 10,
host: process.env.DB_HOST || process.env.WING_DB_HOST || 'localhost',
port: Number(process.env.DB_PORT || process.env.WING_DB_PORT) || 5432,
database: process.env.DB_NAME || process.env.WING_DB_NAME || 'wing',
user: process.env.DB_USER || process.env.WING_DB_USER || 'wing',
password: process.env.DB_PASSWORD || process.env.WING_DB_PASSWORD || 'Wing2026',
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
})
// 연결 시 search_path 자동 설정 (public 미사용)
wingPool.on('connect', (client) => {
client.query('SET search_path = wing, auth, public')
})
wingPool.on('error', (err) => {
console.error('[wingDb] 예기치 않은 연결 오류:', err.message)
console.error('[db] 예기치 않은 연결 오류:', err.message)
})
export async function testWingDbConnection(): Promise<boolean> {
@ -22,10 +33,10 @@ export async function testWingDbConnection(): Promise<boolean> {
const client = await wingPool.connect()
await client.query('SELECT 1')
client.release()
console.log('[wingDb] wing 데이터베이스 연결 성공')
console.log('[db] wing 데이터베이스 연결 성공 (wing + auth 스키마)')
return true
} catch (err) {
console.warn('[wingDb] wing 데이터베이스 연결 실패:', (err as Error).message)
console.warn('[db] wing 데이터베이스 연결 실패:', (err as Error).message)
return false
}
}

파일 보기

@ -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 { requireAuth, requireRole } from '../auth/authMiddleware.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()
router.use(requireAuth)
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
router.get('/', async (_req, res) => {
try {
@ -76,6 +87,7 @@ router.delete('/:id', async (req, res) => {
})
// PUT /api/roles/:id/permissions
// 요청: { permissions: [{ resourceCode, operationCode, granted }] }
router.put('/:id/permissions', async (req, res) => {
try {
const roleSn = Number(req.params.id)
@ -86,6 +98,13 @@ router.put('/:id/permissions', async (req, res) => {
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)
res.json({ success: true })
} catch (err) {

파일 보기

@ -1,13 +1,34 @@
import { authPool } from '../db/authDb.js'
import { AuthError } from '../auth/authService.js'
const PERM_RESOURCE_CODES = [
'prediction', 'hns', 'rescue', 'reports', 'aerial',
'assets', 'scat', 'incidents', 'board', 'weather', 'admin',
] as const
import { type PermTreeNode, buildPermTree, type PermTreeResponse } from './permResolver.js'
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 {
sn: number
code: string
@ -17,6 +38,7 @@ interface RoleWithPermissions {
permissions: Array<{
sn: number
resourceCode: string
operationCode: string
granted: boolean
}>
}
@ -42,8 +64,8 @@ export async function listRolesWithPermissions(): Promise<RoleWithPermissions[]>
for (const row of rolesResult.rows) {
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]
)
@ -53,9 +75,12 @@ export async function listRolesWithPermissions(): Promise<RoleWithPermissions[]>
name: row.name,
description: row.description,
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,
resourceCode: p.resource_code,
operationCode: p.operation_code,
granted: p.granted === 'Y',
})),
})
@ -94,17 +119,20 @@ export async function createRole(input: CreateRoleInput): Promise<RoleWithPermis
)
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(
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES ($1, $2, $3)',
[row.sn, rsrc, 'N']
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES ($1, $2, $3, $4)',
[row.sn, rsrc, 'READ', 'N']
)
}
await client.query('COMMIT')
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]
)
@ -114,9 +142,12 @@ export async function createRole(input: CreateRoleInput): Promise<RoleWithPermis
name: row.name,
description: row.description,
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,
resourceCode: p.resource_code,
operationCode: p.operation_code,
granted: p.granted === 'Y',
})),
}
@ -177,23 +208,23 @@ export async function deleteRole(roleSn: number): Promise<void> {
export async function updatePermissions(
roleSn: number,
permissions: Array<{ resourceCode: string; granted: boolean }>
permissions: Array<{ resourceCode: string; operationCode: string; granted: boolean }>
): Promise<void> {
for (const perm of permissions) {
const existing = await authPool.query(
'SELECT PERM_SN FROM AUTH_PERM WHERE ROLE_SN = $1 AND RSRC_CD = $2',
[roleSn, perm.resourceCode]
'SELECT PERM_SN FROM AUTH_PERM WHERE ROLE_SN = $1 AND RSRC_CD = $2 AND OPER_CD = $3',
[roleSn, perm.resourceCode, perm.operationCode]
)
if (existing.rows.length > 0) {
await authPool.query(
'UPDATE AUTH_PERM SET GRANT_YN = $1 WHERE ROLE_SN = $2 AND RSRC_CD = $3',
[perm.granted ? 'Y' : 'N', roleSn, perm.resourceCode]
'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.operationCode]
)
} else {
await authPool.query(
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES ($1, $2, $3)',
[roleSn, perm.resourceCode, perm.granted ? 'Y' : 'N']
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES ($1, $2, $3, $4)',
[roleSn, perm.resourceCode, perm.operationCode, perm.granted ? 'Y' : 'N']
)
}
}

파일 보기

@ -4,7 +4,6 @@ import cors from 'cors'
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import cookieParser from 'cookie-parser'
import { testAuthDbConnection } from './db/authDb.js'
import { testWingDbConnection } from './db/wingDb.js'
import layersRouter from './routes/layers.js'
import simulationRouter from './routes/simulation.js'
@ -14,6 +13,7 @@ import roleRouter from './roles/roleRouter.js'
import settingsRouter from './settings/settingsRouter.js'
import menuRouter from './menus/menuRouter.js'
import auditRouter from './audit/auditRouter.js'
import boardRouter from './board/boardRouter.js'
import hnsRouter from './hns/hnsRouter.js'
import {
sanitizeBody,
@ -49,6 +49,7 @@ app.use(helmet({
}
},
crossOriginEmbedderPolicy: false, // API 서버이므로 비활성
crossOriginResourcePolicy: { policy: 'cross-origin' }, // sendBeacon cross-origin 허용
}))
// 2. 서버 정보 제거 (공격자에게 기술 스택 노출 방지)
@ -136,6 +137,7 @@ app.use('/api/menus', menuRouter)
app.use('/api/audit', auditRouter)
// API 라우트 — 업무
app.use('/api/board', boardRouter)
app.use('/api/layers', layersRouter)
app.use('/api/simulation', simulationLimiter, simulationRouter)
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 () => {
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
// wing DB (운영 데이터) 연결 확인
await testWingDbConnection()
// wing_auth DB (인증 데이터) 연결 확인
const connected = await testAuthDbConnection()
// wing DB 연결 확인 (wing + auth 스키마 통합)
const connected = await testWingDbConnection()
if (connected) {
// SETTING_VAL VARCHAR(500) → TEXT 마이그레이션 (메뉴 설정 JSON 확장 대응)
try {
const { authPool } = await import('./db/authDb.js')
await authPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`)
console.log('[migration] SETTING_VAL → TEXT 변환 완료')
const { wingPool } = await import('./db/wingDb.js')
await wingPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`)
} catch {
// 이미 TEXT이거나 권한 없으면 무시
}

파일 보기

@ -134,18 +134,21 @@ CREATE TABLE AUTH_PERM (
PERM_SN SERIAL NOT NULL,
ROLE_SN INTEGER NOT NULL,
RSRC_CD VARCHAR(50) NOT NULL,
OPER_CD VARCHAR(20) NOT NULL,
GRANT_YN CHAR(1) NOT NULL DEFAULT 'Y',
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
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 UK_AUTH_PERM UNIQUE (ROLE_SN, RSRC_CD),
CONSTRAINT CK_AUTH_PERM_GRANT CHECK (GRANT_YN IN ('Y','N'))
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_OPER CHECK (OPER_CD IN ('READ','CREATE','UPDATE','DELETE','MANAGE','EXPORT'))
);
COMMENT ON TABLE AUTH_PERM IS '역할별권한';
COMMENT ON COLUMN AUTH_PERM.PERM_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.OPER_CD IS '오퍼레이션코드 (READ, CREATE, UPDATE, DELETE, MANAGE, EXPORT)';
COMMENT ON COLUMN AUTH_PERM.GRANT_YN IS '부여여부 (Y:허용, N:거부)';
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 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_OPER ON AUTH_PERM (OPER_CD);
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_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): 모든 탭 접근
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
(1, 'prediction', 'Y'), (1, 'hns', 'Y'), (1, 'rescue', 'Y'),
(1, 'reports', 'Y'), (1, 'aerial', 'Y'), (1, 'assets', 'Y'),
(1, 'scat', 'Y'), (1, 'incidents', 'Y'), (1, 'board', 'Y'),
(1, 'weather', 'Y'), (1, 'admin', 'Y');
-- ADMIN (ROLE_SN=1): 모든 탭 × 모든 오퍼레이션 허용
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(1, 'prediction', 'READ', 'Y'), (1, 'prediction', 'CREATE', 'Y'), (1, 'prediction', 'UPDATE', 'Y'), (1, 'prediction', 'DELETE', 'Y'),
(1, 'hns', 'READ', 'Y'), (1, 'hns', 'CREATE', 'Y'), (1, 'hns', 'UPDATE', 'Y'), (1, 'hns', 'DELETE', 'Y'),
(1, 'rescue', 'READ', 'Y'), (1, 'rescue', 'CREATE', 'Y'), (1, 'rescue', 'UPDATE', 'Y'), (1, 'rescue', 'DELETE', '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 탭 제외
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
(2, 'prediction', 'Y'), (2, 'hns', 'Y'), (2, 'rescue', 'Y'),
(2, 'reports', 'Y'), (2, 'aerial', 'Y'), (2, 'assets', 'Y'),
(2, 'scat', 'Y'), (2, 'incidents', 'Y'), (2, 'board', 'Y'),
(2, 'weather', 'Y'), (2, 'admin', 'N');
-- MANAGER (ROLE_SN=2): admin 탭 제외, RCUD 허용
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(2, 'prediction', 'READ', 'Y'), (2, 'prediction', 'CREATE', 'Y'), (2, 'prediction', 'UPDATE', 'Y'), (2, 'prediction', 'DELETE', 'Y'),
(2, 'hns', 'READ', 'Y'), (2, 'hns', 'CREATE', 'Y'), (2, 'hns', 'UPDATE', 'Y'), (2, 'hns', 'DELETE', 'Y'),
(2, 'rescue', 'READ', 'Y'), (2, 'rescue', 'CREATE', 'Y'), (2, 'rescue', 'UPDATE', 'Y'), (2, 'rescue', 'DELETE', 'Y'),
(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 탭 제외
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
(3, 'prediction', 'Y'), (3, 'hns', 'Y'), (3, 'rescue', 'Y'),
(3, 'reports', 'Y'), (3, 'aerial', 'Y'), (3, 'assets', 'N'),
(3, 'scat', 'Y'), (3, 'incidents', 'Y'), (3, 'board', 'Y'),
(3, 'weather', 'Y'), (3, 'admin', 'N');
-- USER (ROLE_SN=3): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(3, 'prediction', 'READ', 'Y'), (3, 'prediction', 'CREATE', 'Y'), (3, 'prediction', 'UPDATE', 'Y'),
(3, 'hns', 'READ', 'Y'), (3, 'hns', 'CREATE', 'Y'), (3, 'hns', 'UPDATE', 'Y'),
(3, 'rescue', 'READ', 'Y'), (3, 'rescue', 'CREATE', 'Y'), (3, 'rescue', 'UPDATE', 'Y'),
(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 제외
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
(4, 'prediction', 'Y'), (4, 'hns', 'Y'), (4, 'rescue', 'Y'),
(4, 'reports', 'N'), (4, 'aerial', 'Y'), (4, 'assets', 'N'),
(4, 'scat', 'N'), (4, 'incidents', 'Y'), (4, 'board', 'Y'),
(4, 'weather', 'Y'), (4, 'admin', 'N');
-- VIEWER (ROLE_SN=4): 제한적 탭의 READ만 허용
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(4, 'prediction', 'READ', 'Y'),
(4, 'hns', 'READ', 'Y'),
(4, 'rescue', 'READ', 'Y'),
(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');
-- ============================================================

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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`로 상태를 관리합니다.
### 권한 모델: 리소스 × 오퍼레이션 (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
// backend/src/auth/authMiddleware.ts
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
import { requireAuth, requireRole, requirePermission } from '../auth/authMiddleware.js'
// 인증만 필요한 라우트
router.use(requireAuth)
// 특정 역할 필요
// 역할 기반 (관리 API용)
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)
`requireAuth` 통과 후 `req.user`에 담기는 정보:
```typescript
@ -36,25 +94,21 @@ interface JwtPayload {
}
```
#### 라우터 패턴
#### 라우터 패턴 (CRUD 구조)
```typescript
// backend/src/[모듈]/[모듈]Router.ts
import { Router } from 'express'
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
const router = Router()
router.use(requireAuth)
router.get('/', async (req, res) => {
try {
const userId = req.user!.sub
// 비즈니스 로직...
res.json(result)
} catch (err) {
console.error('[모듈] 오류:', err)
res.status(500).json({ error: '처리 중 오류가 발생했습니다.' })
}
})
// 리소스별 CRUD 엔드포인트
router.post('/list', requirePermission('module:sub', 'READ'), listHandler)
router.post('/detail', requirePermission('module:sub', 'READ'), detailHandler)
router.post('/create', requirePermission('module:sub', 'CREATE'), createHandler)
router.post('/update', requirePermission('module:sub', 'UPDATE'), updateHandler)
router.post('/delete', requirePermission('module:sub', 'DELETE'), deleteHandler)
export default router
```
@ -63,32 +117,36 @@ export default router
#### authStore (Zustand)
```typescript
// frontend/src/store/authStore.ts
import { useAuthStore } from '../store/authStore'
import { useAuthStore } from '@common/store/authStore'
// 컴포넌트 내에서 사용
const { user, isAuthenticated, hasPermission, logout } = useAuthStore()
// 사용자 정보
user?.id // UUID
user?.name // 이름
user?.roles // ['ADMIN', 'USER']
user?.permissions // { 'prediction': ['READ','CREATE','UPDATE','DELETE'], ... }
// 권한 확인 (탭 ID 기준)
hasPermission('prediction') // true/false
hasPermission('admin') // true/false
// 권한 확인 (리소스 × 오퍼레이션)
hasPermission('prediction') // READ 확인 (기본값)
hasPermission('prediction', 'READ') // 명시적 READ 확인
hasPermission('board:notice', 'CREATE') // 공지사항 생성 권한
hasPermission('board:notice', 'DELETE') // 공지사항 삭제 권한
// 하위 호환: operation 생략 시 'READ' 기본값
hasPermission('admin') // === hasPermission('admin', 'READ')
```
#### API 클라이언트
```typescript
// frontend/src/services/api.ts
import { api } from './api'
import { api } from '@common/services/api'
// withCredentials: true 설정으로 JWT 쿠키 자동 포함
const response = await api.get('/your-endpoint')
const response = await api.post('/your-endpoint', data)
const response = await api.post('/your-endpoint/list', params)
const response = await api.post('/your-endpoint/create', data)
// 401 응답 시 자동 로그아웃 처리 (인터셉터)
// 403 응답 시 권한 부족 (requirePermission 미들웨어)
```
---
@ -103,13 +161,15 @@ const response = await api.post('/your-endpoint', data)
```typescript
// frontend/src/App.tsx (자동 적용, 수정 불필요)
import { API_BASE_URL } from '@common/services/api'
useEffect(() => {
if (!isAuthenticated) return
const blob = new Blob(
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
{ type: 'text/plain' }
)
navigator.sendBeacon('/api/audit/log', blob)
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
}, [activeMainTab, isAuthenticated])
```
@ -117,12 +177,13 @@ useEffect(() => {
특정 작업에 대해 명시적으로 감사 로그를 기록하려면:
```typescript
// 프론트엔드에서 sendBeacon 사용
import { API_BASE_URL } from '@common/services/api'
const blob = new Blob(
[JSON.stringify({ action: 'ADMIN_ACTION', detail: '사용자 승인' })],
{ type: 'text/plain' }
)
navigator.sendBeacon('/api/audit/log', blob)
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
```
### 감사 로그 테이블 구조 (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/[모듈명]/` 디렉토리 생성
2. `[모듈명]Service.ts` — 비즈니스 로직 (DB 쿼리)
3. `[모듈명]Router.ts` — Express 라우터 (입력 검증, 에러 처리)
3. `[모듈명]Router.ts` — Express 라우터 (CRUD 엔드포인트 + requirePermission)
4. `backend/src/server.ts`에 라우터 등록:
```typescript
import newRouter from './[모듈명]/[모듈명]Router.js'
app.use('/api/[경로]', newRouter)
```
5. DB 테이블 필요 시 `database/auth_init.sql`에 DDL 추가
6. 리소스 코드를 `AUTH_PERM_TREE`에 등록 (마이그레이션 SQL)
### DB 접근
```typescript
@ -306,20 +420,30 @@ const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1'
```
frontend/src/
├── services/api.ts Axios 인스턴스 + 인터셉터
├── services/authApi.ts 인증/사용자/역할/설정/메뉴/감사로그 API
├── store/authStore.ts 인증 상태 (Zustand)
├── store/menuStore.ts 메뉴 상태 (Zustand)
└── App.tsx 탭 라우팅 + 감사 로그 자동 기록
├── common/
│ ├── services/api.ts Axios 인스턴스 + API_BASE_URL + 인터셉터
│ ├── services/authApi.ts 인증/사용자/역할/설정/메뉴/감사로그 API
│ ├── store/authStore.ts 인증 상태 + hasPermission (Zustand)
│ ├── store/menuStore.ts 메뉴 상태 (Zustand)
│ └── hooks/ useSubMenu, useFeatureTracking 등
├── tabs/ 탭별 패키지 (11개)
└── App.tsx 탭 라우팅 + 감사 로그 자동 기록
backend/src/
├── auth/ 인증 (JWT, OAuth, 미들웨어)
├── auth/ 인증 (JWT, OAuth, 미들웨어, requirePermission)
├── users/ 사용자 관리
├── roles/ 역할/권한 관리
├── roles/ 역할/권한 관리 (permResolver, roleService)
├── settings/ 시스템 설정
├── menus/ 메뉴 설정
├── audit/ 감사 로그
├── db/ DB 연결 (authDb, database)
├── db/ DB 연결 (authDb, wingDb)
├── middleware/ 보안 미들웨어
└── 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

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -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;

파일 보기

@ -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 type { MainTab } from '../types/navigation'
import { useAuthStore } from '@common/store/authStore'
import { API_BASE_URL } from '@common/services/api'
interface SubMenuItem {
id: string
@ -91,6 +93,8 @@ function subscribe(listener: () => void) {
export function useSubMenu(mainTab: MainTab) {
const [activeSubTab, setActiveSubTabLocal] = useState(subMenuState[mainTab])
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const hasPermission = useAuthStore((s) => s.hasPermission)
useEffect(() => {
const unsubscribe = subscribe(() => {
@ -103,10 +107,27 @@ export function useSubMenu(mainTab: MainTab) {
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 {
activeSubTab,
setActiveSubTab,
subMenuConfig: subMenuConfigs[mainTab]
subMenuConfig: filteredConfig,
}
}

파일 보기

@ -1,6 +1,6 @@
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({
baseURL: API_BASE_URL,

파일 보기

@ -7,7 +7,7 @@ export interface AuthUser {
rank: string | null
org: { sn: number; name: string; abbr: string } | null
roles: string[]
permissions: string[]
permissions: Record<string, string[]>
}
interface LoginResponse {
@ -117,6 +117,7 @@ export interface RoleWithPermissions {
permissions: Array<{
sn: number
resourceCode: string
operationCode: string
granted: boolean
}>
}
@ -126,9 +127,26 @@ export async function fetchRoles(): Promise<RoleWithPermissions[]> {
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(
roleSn: number,
permissions: Array<{ resourceCode: string; granted: boolean }>
permissions: Array<{ resourceCode: string; operationCode: string; granted: boolean }>
): Promise<void> {
await api.put(`/roles/${roleSn}/permissions`, { permissions })
}

파일 보기

@ -12,7 +12,7 @@ interface AuthState {
googleLogin: (credential: string) => Promise<void>
logout: () => Promise<void>
checkSession: () => Promise<void>
hasPermission: (resource: string) => boolean
hasPermission: (resource: string, operation?: string) => boolean
clearError: () => void
}
@ -70,10 +70,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
},
hasPermission: (resource: string) => {
hasPermission: (resource: string, operation?: string) => {
const { user } = get()
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 }),

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -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

파일 보기

@ -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

파일 보기

@ -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

파일 보기

@ -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

파일 보기

@ -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

파일 보기

@ -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

파일 보기

@ -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>
)
}

파일 보기

@ -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>
)
}

파일 보기

@ -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>
)
}

파일 보기

@ -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>
)
}

파일 보기

@ -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:1014:24', res: '0.5m', cloud: '≤10%', note: '최우선 추천', color: '#a855f7' },
{ sat: 'Pléiades Neo', time: '오늘 14:3814:52', res: '0.3m', cloud: '≤15%', note: '초고해상도', color: '#06b6d4' },
{ sat: 'Sentinel-1 SAR', time: '오늘 16:5517: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.152.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>
)
}

파일 보기

@ -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>
)
}

파일 보기

@ -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

파일 보기

@ -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(
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <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

파일 보기

@ -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:272286, 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.1116, 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

파일 보기

@ -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

파일 보기

@ -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

파일 보기

@ -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 },
]

파일 보기

@ -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
category: string
title: string
author: string
date: string
views: number
isNotice?: boolean
}
// 카테고리 코드 ↔ 표시명 매핑
const CATEGORY_MAP: Record<string, string> = {
NOTICE: '공지사항',
DATA: '자료실',
QNA: 'Q&A',
MANUAL: '해경매뉴얼',
};
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 {
onPostClick: (id: number) => void
onWriteClick: () => void
onPostClick: (id: number) => 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) {
const [searchTerm, setSearchTerm] = useState('')
const [selectedCategory, setSelectedCategory] = useState<string>('전체')
const hasPermission = useAuthStore((s) => s.hasPermission);
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 matchesCategory = selectedCategory === '전체' || post.category === selectedCategory
const matchesSearch =
post.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
post.author.toLowerCase().includes(searchTerm.toLowerCase())
return matchesCategory && matchesSearch
})
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
// 카테고리별 서브리소스 권한 확인 (전체 선택 시 board CREATE)
const canWrite = selectedCategory
? hasPermission(`board:${selectedCategory.toLowerCase()}`, 'CREATE')
: 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 (
<div className="flex flex-col h-full bg-bg-0">
{/* 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 gap-4">
{/* Category Filters */}
<div className="flex gap-2">
{categories.map((category) => (
{CATEGORY_FILTER.map((cat) => (
<button
key={category}
onClick={() => setSelectedCategory(category)}
key={cat.label}
onClick={() => handleCategoryChange(cat.code)}
className={`px-4 py-2 text-sm font-semibold rounded transition-all ${
selectedCategory === category
selectedCategory === cat.code
? 'bg-primary-cyan text-bg-0'
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1'
}`}
>
{category}
{cat.label}
</button>
))}
</div>
</div>
<div className="flex items-center gap-3">
{/* Search Input */}
<input
type="text"
placeholder="제목, 작성자 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
value={searchInput}
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"
/>
{/* Write Button */}
<button
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"
>
<span></span>
<span></span>
</button>
{canWrite && (
<button
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"
>
<span>+</span>
<span></span>
</button>
)}
</div>
</div>
{/* Board List Table */}
<div className="flex-1 overflow-auto px-8 py-6">
<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>
{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 && (
{loading ? (
<div className="text-center py-20">
<p className="text-text-3 text-sm"> .</p>
<p className="text-text-3 text-sm"> ...</p>
</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>
{/* Pagination */}
<div className="flex items-center justify-center gap-2 px-8 py-4 border-t border-border bg-bg-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">
</button>
<button className="px-3 py-1.5 text-sm rounded bg-primary-cyan text-bg-0 font-semibold">
1
</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 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">
3
</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">
</button>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 px-8 py-4 border-t border-border bg-bg-1">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 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>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<button
key={p}
onClick={() => setPage(p)}
className={`px-3 py-1.5 text-sm rounded ${
page === p
? 'bg-primary-cyan text-bg-0 font-semibold'
: '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>
)
);
}

파일 보기

@ -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}`);
}

파일 보기

@ -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

파일 보기

@ -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

파일 보기

@ -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
}

파일 보기

@ -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

파일 보기

@ -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;

파일 보기

@ -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' },
}

파일 보기

@ -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

파일 보기

@ -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

파일 보기

@ -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(
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <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

파일 보기

@ -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

파일 보기

@ -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

파일 보기

@ -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: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
},
]

파일 보기

@ -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[]
}