feat(admin): 관리자 화면 고도화 — 사용자/권한/게시판/선박신호 패널
- UsersPanel: 테이블+페이징+등록모달+상세모달(비밀번호초기화/잠금해제) - PermissionsPanel: 사용자별 역할 할당 탭 추가 - BoardMgmtPanel: 공지사항/게시판/QNA 관리자 일괄 삭제 - VesselSignalPanel: VTS/VTS-AIS/V-PASS/E-NAVI/S&P AIS 타임라인 모니터링 - AdminSidebar/AdminPlaceholder/adminMenuConfig 신규 - 권한 미들웨어 부모 리소스 fallback 로직 추가 - 조직 목록 API, 관리자 삭제 API 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
476b6b99ac
커밋
ce80e620c1
@ -72,10 +72,16 @@ export function requirePermission(resource: string, operation: string = 'READ')
|
|||||||
req.resolvedPermissions = userInfo.permissions
|
req.resolvedPermissions = userInfo.permissions
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedOps = req.resolvedPermissions[resource]
|
// 정확한 리소스 매칭 → 부모 리소스 fallback (board:notice → board)
|
||||||
if (allowedOps && allowedOps.includes(operation)) {
|
let cursor: string | undefined = resource
|
||||||
next()
|
while (cursor) {
|
||||||
return
|
const allowedOps = req.resolvedPermissions[cursor]
|
||||||
|
if (allowedOps && allowedOps.includes(operation)) {
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const colonIdx = cursor.lastIndexOf(':')
|
||||||
|
cursor = colonIdx > 0 ? cursor.substring(0, colonIdx) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(403).json({ error: '접근 권한이 없습니다.' })
|
res.status(403).json({ error: '접근 권한이 없습니다.' })
|
||||||
|
|||||||
@ -112,6 +112,23 @@ export async function login(
|
|||||||
return userInfo
|
return userInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** AUTH_PERM_TREE 없이 플랫 권한을 RSRC_CD + OPER_CD 기준으로 조회 */
|
||||||
|
async function flatPermissionsFallback(userId: string): Promise<Record<string, string[]>> {
|
||||||
|
const permsResult = await authPool.query(
|
||||||
|
`SELECT DISTINCT p.RSRC_CD as rsrc_cd, p.OPER_CD as oper_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]
|
||||||
|
)
|
||||||
|
const perms: Record<string, string[]> = {}
|
||||||
|
for (const p of permsResult.rows) {
|
||||||
|
if (!perms[p.rsrc_cd]) perms[p.rsrc_cd] = []
|
||||||
|
if (!perms[p.rsrc_cd].includes(p.oper_cd)) perms[p.rsrc_cd].push(p.oper_cd)
|
||||||
|
}
|
||||||
|
return perms
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
|
export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
|
||||||
const userResult = await authPool.query(
|
const userResult = await authPool.query(
|
||||||
`SELECT u.USER_ID as user_id, u.USER_ACNT as user_acnt, u.USER_NM as user_nm,
|
`SELECT u.USER_ID as user_id, u.USER_ACNT as user_acnt, u.USER_NM as user_nm,
|
||||||
@ -170,30 +187,15 @@ export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
|
|||||||
permissions = grantedSetToRecord(granted)
|
permissions = grantedSetToRecord(granted)
|
||||||
} else {
|
} else {
|
||||||
// AUTH_PERM_TREE 미존재 (마이그레이션 전) → 기존 플랫 방식 fallback
|
// AUTH_PERM_TREE 미존재 (마이그레이션 전) → 기존 플랫 방식 fallback
|
||||||
const permsResult = await authPool.query(
|
permissions = await flatPermissionsFallback(userId)
|
||||||
`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 {
|
} catch {
|
||||||
// AUTH_PERM_TREE 테이블 미존재 시 fallback
|
// AUTH_PERM_TREE 테이블 미존재 시 fallback
|
||||||
const permsResult = await authPool.query(
|
try {
|
||||||
`SELECT DISTINCT p.RSRC_CD as rsrc_cd
|
permissions = await flatPermissionsFallback(userId)
|
||||||
FROM AUTH_PERM p
|
} catch {
|
||||||
JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN
|
console.error('[auth] 권한 조회 fallback 실패, 빈 권한 반환')
|
||||||
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`,
|
permissions = {}
|
||||||
[userId]
|
|
||||||
)
|
|
||||||
permissions = {}
|
|
||||||
for (const p of permsResult.rows) {
|
|
||||||
permissions[p.rsrc_cd] = ['READ']
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Router } from 'express'
|
|||||||
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
||||||
import { AuthError } from '../auth/authService.js'
|
import { AuthError } from '../auth/authService.js'
|
||||||
import {
|
import {
|
||||||
listPosts, getPost, createPost, updatePost, deletePost,
|
listPosts, getPost, createPost, updatePost, deletePost, adminDeletePost,
|
||||||
listManuals, createManual, updateManual, deleteManual, incrementManualDownload,
|
listManuals, createManual, updateManual, deleteManual, incrementManualDownload,
|
||||||
} from './boardService.js'
|
} from './boardService.js'
|
||||||
|
|
||||||
@ -209,4 +209,22 @@ router.delete('/:sn', requireAuth, requirePermission('board', 'DELETE'), async (
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// POST /api/board/admin-delete — 관리자 전용 게시글 삭제 (소유자 검증 없음)
|
||||||
|
router.post('/admin-delete', requireAuth, requirePermission('admin', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sn } = req.body
|
||||||
|
const postSn = typeof sn === 'number' ? sn : parseInt(sn, 10)
|
||||||
|
if (isNaN(postSn)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await adminDeletePost(postSn)
|
||||||
|
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
|
export default router
|
||||||
|
|||||||
@ -398,3 +398,18 @@ export async function deletePost(postSn: number, requesterId: string): Promise<v
|
|||||||
[postSn]
|
[postSn]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 관리자 전용 삭제 — 소유자 검증 없이 논리 삭제 */
|
||||||
|
export async function adminDeletePost(postSn: number): Promise<void> {
|
||||||
|
const existing = await wingPool.query(
|
||||||
|
`SELECT POST_SN FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`,
|
||||||
|
[postSn]
|
||||||
|
)
|
||||||
|
if (existing.rows.length === 0) {
|
||||||
|
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
|
||||||
|
}
|
||||||
|
await wingPool.query(
|
||||||
|
`UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`,
|
||||||
|
[postSn]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
assignRoles,
|
assignRoles,
|
||||||
approveUser,
|
approveUser,
|
||||||
rejectUser,
|
rejectUser,
|
||||||
|
listOrgs,
|
||||||
} from './userService.js'
|
} from './userService.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
@ -145,4 +146,15 @@ router.put('/:id/roles', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// GET /api/users/orgs — 조직 목록
|
||||||
|
router.get('/orgs', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const orgs = await listOrgs()
|
||||||
|
res.json(orgs)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[users] 조직 목록 오류:', err)
|
||||||
|
res.status(500).json({ error: '조직 목록 조회 중 오류가 발생했습니다.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@ -293,6 +293,32 @@ export async function changePassword(userId: string, newPassword: string): Promi
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 조직 목록 조회 ──
|
||||||
|
|
||||||
|
interface OrgItem {
|
||||||
|
orgSn: number
|
||||||
|
orgNm: string
|
||||||
|
orgAbbrNm: string | null
|
||||||
|
orgTpCd: string
|
||||||
|
upperOrgSn: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOrgs(): Promise<OrgItem[]> {
|
||||||
|
const { rows } = await authPool.query(
|
||||||
|
`SELECT ORG_SN, ORG_NM, ORG_ABBR_NM, ORG_TP_CD, UPPER_ORG_SN
|
||||||
|
FROM AUTH_ORG
|
||||||
|
WHERE USE_YN = 'Y'
|
||||||
|
ORDER BY ORG_SN`
|
||||||
|
)
|
||||||
|
return rows.map((r: Record<string, unknown>) => ({
|
||||||
|
orgSn: r.org_sn as number,
|
||||||
|
orgNm: r.org_nm as string,
|
||||||
|
orgAbbrNm: r.org_abbr_nm as string | null,
|
||||||
|
orgTpCd: r.org_tp_cd as string,
|
||||||
|
upperOrgSn: r.upper_org_sn as number | null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export async function assignRoles(userId: string, roleSns: number[]): Promise<void> {
|
export async function assignRoles(userId: string, roleSns: number[]): Promise<void> {
|
||||||
await authPool.query('DELETE FROM AUTH_USER_ROLE WHERE USER_ID = $1', [userId])
|
await authPool.query('DELETE FROM AUTH_USER_ROLE WHERE USER_ID = $1', [userId])
|
||||||
|
|
||||||
|
|||||||
@ -254,10 +254,11 @@ CREATE INDEX IDX_AUDIT_LOG_DTM ON AUTH_AUDIT_LOG (REQ_DTM);
|
|||||||
-- 10. 초기 데이터: 역할
|
-- 10. 초기 데이터: 역할
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN) VALUES
|
INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN) VALUES
|
||||||
('ADMIN', '관리자', '시스템 전체 관리 권한', 'N'),
|
('ADMIN', '관리자', '시스템 전체 관리 권한', 'N'),
|
||||||
('MANAGER', '운영자', '운영 및 사용자 관리 권한', 'N'),
|
('HQ_CLEANUP', '본청방제과', '본청 방제 업무 관리 권한', 'N'),
|
||||||
('USER', '일반사용자', '기본 업무 기능 접근 권한', 'Y'),
|
('MANAGER', '운영자', '운영 및 사용자 관리 권한', 'N'),
|
||||||
('VIEWER', '뷰어', '조회 전용 접근 권한', 'N');
|
('USER', '일반사용자', '기본 업무 기능 접근 권한', 'Y'),
|
||||||
|
('VIEWER', '뷰어', '조회 전용 접근 권한', 'N');
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
@ -279,7 +280,7 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
|||||||
(1, 'weather', 'READ', 'Y'), (1, 'weather', 'CREATE', 'Y'), (1, 'weather', 'UPDATE', 'Y'), (1, 'weather', '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');
|
(1, 'admin', 'READ', 'Y'), (1, 'admin', 'CREATE', 'Y'), (1, 'admin', 'UPDATE', 'Y'), (1, 'admin', 'DELETE', 'Y');
|
||||||
|
|
||||||
-- MANAGER (ROLE_SN=2): admin 탭 제외, RCUD 허용
|
-- HQ_CLEANUP (ROLE_SN=2): 방제 관련 탭 RCUD + 기타 탭 READ/CREATE, admin 제외
|
||||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
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, '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, 'hns', 'READ', 'Y'), (2, 'hns', 'CREATE', 'Y'), (2, 'hns', 'UPDATE', 'Y'), (2, 'hns', 'DELETE', 'Y'),
|
||||||
@ -289,38 +290,52 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
|||||||
(2, 'assets', 'READ', 'Y'), (2, 'assets', 'CREATE', 'Y'), (2, 'assets', 'UPDATE', 'Y'), (2, 'assets', '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, '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, '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, 'board', 'READ', 'Y'), (2, 'board', 'CREATE', 'Y'), (2, 'board', 'UPDATE', 'Y'),
|
||||||
(2, 'weather', 'READ', 'Y'), (2, 'weather', 'CREATE', 'Y'), (2, 'weather', 'UPDATE', 'Y'), (2, 'weather', 'DELETE', 'Y'),
|
(2, 'weather', 'READ', 'Y'), (2, 'weather', 'CREATE', 'Y'),
|
||||||
(2, 'admin', 'READ', 'N');
|
(2, 'admin', 'READ', 'N');
|
||||||
|
|
||||||
-- USER (ROLE_SN=3): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음
|
-- MANAGER (ROLE_SN=3): admin 탭 제외, RCUD 허용
|
||||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
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, 'prediction', 'READ', 'Y'), (3, 'prediction', 'CREATE', 'Y'), (3, 'prediction', 'UPDATE', 'Y'), (3, 'prediction', 'DELETE', 'Y'),
|
||||||
(3, 'hns', 'READ', 'Y'), (3, 'hns', 'CREATE', 'Y'), (3, 'hns', 'UPDATE', 'Y'),
|
(3, 'hns', 'READ', 'Y'), (3, 'hns', 'CREATE', 'Y'), (3, 'hns', 'UPDATE', 'Y'), (3, 'hns', 'DELETE', 'Y'),
|
||||||
(3, 'rescue', 'READ', 'Y'), (3, 'rescue', 'CREATE', 'Y'), (3, 'rescue', 'UPDATE', 'Y'),
|
(3, 'rescue', 'READ', 'Y'), (3, 'rescue', 'CREATE', 'Y'), (3, 'rescue', 'UPDATE', 'Y'), (3, 'rescue', 'DELETE', 'Y'),
|
||||||
(3, 'reports', 'READ', 'Y'), (3, 'reports', 'CREATE', 'Y'), (3, 'reports', 'UPDATE', 'Y'),
|
(3, 'reports', 'READ', 'Y'), (3, 'reports', 'CREATE', 'Y'), (3, 'reports', 'UPDATE', 'Y'), (3, 'reports', 'DELETE', 'Y'),
|
||||||
(3, 'aerial', 'READ', 'Y'), (3, 'aerial', 'CREATE', 'Y'), (3, 'aerial', 'UPDATE', 'Y'),
|
(3, 'aerial', 'READ', 'Y'), (3, 'aerial', 'CREATE', 'Y'), (3, 'aerial', 'UPDATE', 'Y'), (3, 'aerial', 'DELETE', 'Y'),
|
||||||
(3, 'assets', 'READ', 'N'),
|
(3, 'assets', 'READ', 'Y'), (3, 'assets', 'CREATE', 'Y'), (3, 'assets', 'UPDATE', 'Y'), (3, 'assets', 'DELETE', 'Y'),
|
||||||
(3, 'scat', 'READ', 'Y'), (3, 'scat', 'CREATE', 'Y'), (3, 'scat', 'UPDATE', 'Y'),
|
(3, 'scat', 'READ', 'Y'), (3, 'scat', 'CREATE', 'Y'), (3, 'scat', 'UPDATE', 'Y'), (3, 'scat', 'DELETE', 'Y'),
|
||||||
(3, 'incidents', 'READ', 'Y'), (3, 'incidents', 'CREATE', 'Y'), (3, 'incidents', 'UPDATE', 'Y'),
|
(3, 'incidents', 'READ', 'Y'), (3, 'incidents', 'CREATE', 'Y'), (3, 'incidents', 'UPDATE', 'Y'), (3, 'incidents', 'DELETE', 'Y'),
|
||||||
(3, 'board', 'READ', 'Y'), (3, 'board', 'CREATE', 'Y'), (3, 'board', 'UPDATE', 'Y'),
|
(3, 'board', 'READ', 'Y'), (3, 'board', 'CREATE', 'Y'), (3, 'board', 'UPDATE', 'Y'), (3, 'board', 'DELETE', 'Y'),
|
||||||
(3, 'weather', 'READ', 'Y'),
|
(3, 'weather', 'READ', 'Y'), (3, 'weather', 'CREATE', 'Y'), (3, 'weather', 'UPDATE', 'Y'), (3, 'weather', 'DELETE', 'Y'),
|
||||||
(3, 'admin', 'READ', 'N');
|
(3, 'admin', 'READ', 'N');
|
||||||
|
|
||||||
-- VIEWER (ROLE_SN=4): 제한적 탭의 READ만 허용
|
-- USER (ROLE_SN=4): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음
|
||||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||||
(4, 'prediction', 'READ', 'Y'),
|
(4, 'prediction', 'READ', 'Y'), (4, 'prediction', 'CREATE', 'Y'), (4, 'prediction', 'UPDATE', 'Y'),
|
||||||
(4, 'hns', 'READ', 'Y'),
|
(4, 'hns', 'READ', 'Y'), (4, 'hns', 'CREATE', 'Y'), (4, 'hns', 'UPDATE', 'Y'),
|
||||||
(4, 'rescue', 'READ', 'Y'),
|
(4, 'rescue', 'READ', 'Y'), (4, 'rescue', 'CREATE', 'Y'), (4, 'rescue', 'UPDATE', 'Y'),
|
||||||
(4, 'reports', 'READ', 'N'),
|
(4, 'reports', 'READ', 'Y'), (4, 'reports', 'CREATE', 'Y'), (4, 'reports', 'UPDATE', 'Y'),
|
||||||
(4, 'aerial', 'READ', 'Y'),
|
(4, 'aerial', 'READ', 'Y'), (4, 'aerial', 'CREATE', 'Y'), (4, 'aerial', 'UPDATE', 'Y'),
|
||||||
(4, 'assets', 'READ', 'N'),
|
(4, 'assets', 'READ', 'N'),
|
||||||
(4, 'scat', 'READ', 'N'),
|
(4, 'scat', 'READ', 'Y'), (4, 'scat', 'CREATE', 'Y'), (4, 'scat', 'UPDATE', 'Y'),
|
||||||
(4, 'incidents', 'READ', 'Y'),
|
(4, 'incidents', 'READ', 'Y'), (4, 'incidents', 'CREATE', 'Y'), (4, 'incidents', 'UPDATE', 'Y'),
|
||||||
(4, 'board', 'READ', 'Y'),
|
(4, 'board', 'READ', 'Y'), (4, 'board', 'CREATE', 'Y'), (4, 'board', 'UPDATE', 'Y'),
|
||||||
(4, 'weather', 'READ', 'Y'),
|
(4, 'weather', 'READ', 'Y'),
|
||||||
(4, 'admin', 'READ', 'N');
|
(4, 'admin', 'READ', 'N');
|
||||||
|
|
||||||
|
-- VIEWER (ROLE_SN=5): 제한적 탭의 READ만 허용
|
||||||
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||||
|
(5, 'prediction', 'READ', 'Y'),
|
||||||
|
(5, 'hns', 'READ', 'Y'),
|
||||||
|
(5, 'rescue', 'READ', 'Y'),
|
||||||
|
(5, 'reports', 'READ', 'N'),
|
||||||
|
(5, 'aerial', 'READ', 'Y'),
|
||||||
|
(5, 'assets', 'READ', 'N'),
|
||||||
|
(5, 'scat', 'READ', 'N'),
|
||||||
|
(5, 'incidents', 'READ', 'Y'),
|
||||||
|
(5, 'board', 'READ', 'Y'),
|
||||||
|
(5, 'weather', 'READ', 'Y'),
|
||||||
|
(5, 'admin', 'READ', 'N');
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 12. 초기 데이터: 조직
|
-- 12. 초기 데이터: 조직
|
||||||
|
|||||||
32
database/migration/020_add_hq_cleanup_role.sql
Normal file
32
database/migration/020_add_hq_cleanup_role.sql
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 020: 본청방제과 역할 추가
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 역할 추가 (이미 존재하면 무시)
|
||||||
|
INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN)
|
||||||
|
SELECT 'HQ_CLEANUP', '본청방제과', '본청 방제 업무 관리 권한', 'N'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM AUTH_ROLE WHERE ROLE_CD = 'HQ_CLEANUP');
|
||||||
|
|
||||||
|
-- 본청방제과 권한 설정: 방제 관련 탭 RCUD + 기타 탭 READ/CREATE, admin 제외
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_role_sn INT;
|
||||||
|
BEGIN
|
||||||
|
SELECT ROLE_SN INTO v_role_sn FROM AUTH_ROLE WHERE ROLE_CD = 'HQ_CLEANUP';
|
||||||
|
|
||||||
|
-- 기존 권한 초기화 (재실행 안전)
|
||||||
|
DELETE FROM AUTH_PERM WHERE ROLE_SN = v_role_sn;
|
||||||
|
|
||||||
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||||
|
(v_role_sn, 'prediction', 'READ', 'Y'), (v_role_sn, 'prediction', 'CREATE', 'Y'), (v_role_sn, 'prediction', 'UPDATE', 'Y'), (v_role_sn, 'prediction', 'DELETE', 'Y'),
|
||||||
|
(v_role_sn, 'hns', 'READ', 'Y'), (v_role_sn, 'hns', 'CREATE', 'Y'), (v_role_sn, 'hns', 'UPDATE', 'Y'), (v_role_sn, 'hns', 'DELETE', 'Y'),
|
||||||
|
(v_role_sn, 'rescue', 'READ', 'Y'), (v_role_sn, 'rescue', 'CREATE', 'Y'), (v_role_sn, 'rescue', 'UPDATE', 'Y'), (v_role_sn, 'rescue', 'DELETE', 'Y'),
|
||||||
|
(v_role_sn, 'reports', 'READ', 'Y'), (v_role_sn, 'reports', 'CREATE', 'Y'), (v_role_sn, 'reports', 'UPDATE', 'Y'), (v_role_sn, 'reports', 'DELETE', 'Y'),
|
||||||
|
(v_role_sn, 'aerial', 'READ', 'Y'), (v_role_sn, 'aerial', 'CREATE', 'Y'), (v_role_sn, 'aerial', 'UPDATE', 'Y'), (v_role_sn, 'aerial', 'DELETE', 'Y'),
|
||||||
|
(v_role_sn, 'assets', 'READ', 'Y'), (v_role_sn, 'assets', 'CREATE', 'Y'), (v_role_sn, 'assets', 'UPDATE', 'Y'), (v_role_sn, 'assets', 'DELETE', 'Y'),
|
||||||
|
(v_role_sn, 'scat', 'READ', 'Y'), (v_role_sn, 'scat', 'CREATE', 'Y'), (v_role_sn, 'scat', 'UPDATE', 'Y'), (v_role_sn, 'scat', 'DELETE', 'Y'),
|
||||||
|
(v_role_sn, 'incidents', 'READ', 'Y'), (v_role_sn, 'incidents', 'CREATE', 'Y'), (v_role_sn, 'incidents', 'UPDATE', 'Y'), (v_role_sn, 'incidents', 'DELETE', 'Y'),
|
||||||
|
(v_role_sn, 'board', 'READ', 'Y'), (v_role_sn, 'board', 'CREATE', 'Y'), (v_role_sn, 'board', 'UPDATE', 'Y'),
|
||||||
|
(v_role_sn, 'weather', 'READ', 'Y'), (v_role_sn, 'weather', 'CREATE', 'Y'),
|
||||||
|
(v_role_sn, 'admin', 'READ', 'N');
|
||||||
|
END $$;
|
||||||
@ -60,12 +60,7 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
|
|||||||
{ id: 'manual', label: '해경매뉴얼', icon: '📘' }
|
{ id: 'manual', label: '해경매뉴얼', icon: '📘' }
|
||||||
],
|
],
|
||||||
weather: null,
|
weather: null,
|
||||||
admin: [
|
admin: null // 관리자 화면은 자체 사이드바 사용 (AdminSidebar.tsx)
|
||||||
{ id: 'users', label: '사용자 관리', icon: '👥' },
|
|
||||||
{ id: 'permissions', label: '사용자 권한 관리', icon: '🔐' },
|
|
||||||
{ id: 'menus', label: '메뉴 관리', icon: '📑' },
|
|
||||||
{ id: 'settings', label: '시스템 설정', icon: '⚙️' }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 전역 상태 관리 (간단한 방식)
|
// 전역 상태 관리 (간단한 방식)
|
||||||
|
|||||||
@ -107,6 +107,20 @@ export async function assignRolesApi(id: string, roleSns: number[]): Promise<voi
|
|||||||
await api.put(`/users/${id}/roles`, { roleSns })
|
await api.put(`/users/${id}/roles`, { roleSns })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 조직 목록 API
|
||||||
|
export interface OrgItem {
|
||||||
|
orgSn: number
|
||||||
|
orgNm: string
|
||||||
|
orgAbbrNm: string | null
|
||||||
|
orgTpCd: string
|
||||||
|
upperOrgSn: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOrgs(): Promise<OrgItem[]> {
|
||||||
|
const response = await api.get<OrgItem[]>('/users/orgs')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
// 역할/권한 API (ADMIN 전용)
|
// 역할/권한 API (ADMIN 전용)
|
||||||
export interface RoleWithPermissions {
|
export interface RoleWithPermissions {
|
||||||
sn: number
|
sn: number
|
||||||
|
|||||||
14
frontend/src/tabs/admin/components/AdminPlaceholder.tsx
Normal file
14
frontend/src/tabs/admin/components/AdminPlaceholder.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
interface AdminPlaceholderProps {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 미구현 관리자 메뉴 placeholder */
|
||||||
|
const AdminPlaceholder = ({ label }: AdminPlaceholderProps) => (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||||
|
<div className="text-4xl opacity-20">🚧</div>
|
||||||
|
<div className="text-sm font-korean text-text-2 font-semibold">{label}</div>
|
||||||
|
<div className="text-[11px] font-korean text-text-3">해당 기능은 준비 중입니다.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AdminPlaceholder;
|
||||||
156
frontend/src/tabs/admin/components/AdminSidebar.tsx
Normal file
156
frontend/src/tabs/admin/components/AdminSidebar.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ADMIN_MENU } from './adminMenuConfig';
|
||||||
|
import type { AdminMenuItem } from './adminMenuConfig';
|
||||||
|
|
||||||
|
interface AdminSidebarProps {
|
||||||
|
activeMenu: string;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 관리자 좌측 사이드바 — 9-섹션 아코디언 */
|
||||||
|
const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(() => {
|
||||||
|
// 초기: 첫 번째 섹션 열기
|
||||||
|
const init = new Set<string>();
|
||||||
|
if (ADMIN_MENU.length > 0) init.add(ADMIN_MENU[0].id);
|
||||||
|
return init;
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
setExpanded(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 재귀적으로 메뉴 아이템이 activeMenu를 포함하는지 확인 */
|
||||||
|
const containsActive = (item: AdminMenuItem): boolean => {
|
||||||
|
if (item.id === activeMenu) return true;
|
||||||
|
return item.children?.some(c => containsActive(c)) ?? false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLeaf = (item: AdminMenuItem, depth: number) => {
|
||||||
|
const isActive = item.id === activeMenu;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onSelect(item.id)}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-[11px] font-korean transition-colors cursor-pointer rounded-[3px]"
|
||||||
|
style={{
|
||||||
|
paddingLeft: `${12 + depth * 14}px`,
|
||||||
|
background: isActive ? 'rgba(6,182,212,.12)' : 'transparent',
|
||||||
|
color: isActive ? 'var(--cyan)' : 'var(--t2)',
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderGroup = (item: AdminMenuItem, depth: number) => {
|
||||||
|
const isOpen = expanded.has(item.id);
|
||||||
|
const hasActiveChild = containsActive(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
toggle(item.id);
|
||||||
|
// 그룹 자체에 children의 첫 leaf가 있으면 자동 선택
|
||||||
|
if (!isOpen && item.children) {
|
||||||
|
const firstLeaf = findFirstLeaf(item.children);
|
||||||
|
if (firstLeaf) onSelect(firstLeaf.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-1.5 text-[11px] font-korean transition-colors cursor-pointer rounded-[3px]"
|
||||||
|
style={{
|
||||||
|
paddingLeft: `${12 + depth * 14}px`,
|
||||||
|
color: hasActiveChild ? 'var(--cyan)' : 'var(--t2)',
|
||||||
|
fontWeight: hasActiveChild ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<span className="text-[9px] text-text-3 transition-transform" style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{isOpen && item.children && (
|
||||||
|
<div className="flex flex-col gap-px">
|
||||||
|
{item.children.map(child => renderItem(child, depth + 1))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = (item: AdminMenuItem, depth: number) => {
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
return renderGroup(item, depth);
|
||||||
|
}
|
||||||
|
return renderLeaf(item, depth);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col bg-bg-1 border-r border-border overflow-y-auto shrink-0"
|
||||||
|
style={{ width: 240, scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-bg-2 shrink-0">
|
||||||
|
<div className="text-xs font-bold text-text-1 font-korean flex items-center gap-1.5">
|
||||||
|
<span>⚙️</span> 관리자 설정
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메뉴 목록 */}
|
||||||
|
<div className="flex flex-col gap-0.5 p-2">
|
||||||
|
{ADMIN_MENU.map(section => {
|
||||||
|
const isOpen = expanded.has(section.id);
|
||||||
|
const hasActiveChild = containsActive(section);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={section.id} className="mb-0.5">
|
||||||
|
{/* 섹션 헤더 */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggle(section.id)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-[11px] font-bold font-korean transition-colors cursor-pointer"
|
||||||
|
style={{
|
||||||
|
background: hasActiveChild ? 'rgba(6,182,212,.08)' : 'transparent',
|
||||||
|
color: hasActiveChild ? 'var(--cyan)' : 'var(--t1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{section.icon}</span>
|
||||||
|
<span className="flex-1 text-left">{section.label}</span>
|
||||||
|
<span className="text-[9px] text-text-3 transition-transform" style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 하위 메뉴 */}
|
||||||
|
{isOpen && section.children && (
|
||||||
|
<div className="flex flex-col gap-px mt-0.5 ml-1">
|
||||||
|
{section.children.map(child => renderItem(child, 1))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** children 중 첫 번째 leaf 노드를 찾는다 */
|
||||||
|
function findFirstLeaf(items: AdminMenuItem[]): AdminMenuItem | null {
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.children || item.children.length === 0) return item;
|
||||||
|
const found = findFirstLeaf(item.children);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminSidebar;
|
||||||
@ -1,21 +1,42 @@
|
|||||||
import { useSubMenu } from '@common/hooks/useSubMenu'
|
import { useState } from 'react';
|
||||||
import UsersPanel from './UsersPanel'
|
import AdminSidebar from './AdminSidebar';
|
||||||
import PermissionsPanel from './PermissionsPanel'
|
import AdminPlaceholder from './AdminPlaceholder';
|
||||||
import MenusPanel from './MenusPanel'
|
import { findMenuLabel } from './adminMenuConfig';
|
||||||
import SettingsPanel from './SettingsPanel'
|
import UsersPanel from './UsersPanel';
|
||||||
|
import PermissionsPanel from './PermissionsPanel';
|
||||||
|
import MenusPanel from './MenusPanel';
|
||||||
|
import SettingsPanel from './SettingsPanel';
|
||||||
|
import BoardMgmtPanel from './BoardMgmtPanel';
|
||||||
|
import VesselSignalPanel from './VesselSignalPanel';
|
||||||
|
|
||||||
|
/** 기존 패널이 있는 메뉴 ID 매핑 */
|
||||||
|
const PANEL_MAP: Record<string, () => JSX.Element> = {
|
||||||
|
users: () => <UsersPanel />,
|
||||||
|
permissions: () => <PermissionsPanel />,
|
||||||
|
menus: () => <MenusPanel />,
|
||||||
|
settings: () => <SettingsPanel />,
|
||||||
|
notice: () => <BoardMgmtPanel initialCategory="NOTICE" />,
|
||||||
|
board: () => <BoardMgmtPanel initialCategory="DATA" />,
|
||||||
|
qna: () => <BoardMgmtPanel initialCategory="QNA" />,
|
||||||
|
'collect-vessel-signal': () => <VesselSignalPanel />,
|
||||||
|
};
|
||||||
|
|
||||||
// ─── AdminView ────────────────────────────────────────────
|
|
||||||
export function AdminView() {
|
export function AdminView() {
|
||||||
const { activeSubTab } = useSubMenu('admin')
|
const [activeMenu, setActiveMenu] = useState('users');
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
const factory = PANEL_MAP[activeMenu];
|
||||||
|
if (factory) return factory();
|
||||||
|
const label = findMenuLabel(activeMenu) ?? activeMenu;
|
||||||
|
return <AdminPlaceholder label={label} />;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 overflow-hidden bg-bg-0">
|
<div className="flex flex-1 overflow-hidden bg-bg-0">
|
||||||
|
<AdminSidebar activeMenu={activeMenu} onSelect={setActiveMenu} />
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{activeSubTab === 'users' && <UsersPanel />}
|
{renderContent()}
|
||||||
{activeSubTab === 'permissions' && <PermissionsPanel />}
|
|
||||||
{activeSubTab === 'menus' && <MenusPanel />}
|
|
||||||
{activeSubTab === 'settings' && <SettingsPanel />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
293
frontend/src/tabs/admin/components/BoardMgmtPanel.tsx
Normal file
293
frontend/src/tabs/admin/components/BoardMgmtPanel.tsx
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
fetchBoardPosts,
|
||||||
|
adminDeleteBoardPost,
|
||||||
|
type BoardPostItem,
|
||||||
|
type BoardListResponse,
|
||||||
|
} from '@tabs/board/services/boardApi';
|
||||||
|
|
||||||
|
// ─── 상수 ──────────────────────────────────────────────────
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
const CATEGORY_TABS = [
|
||||||
|
{ code: '', label: '전체' },
|
||||||
|
{ code: 'NOTICE', label: '공지사항' },
|
||||||
|
{ code: 'DATA', label: '게시판' },
|
||||||
|
{ code: 'QNA', label: 'Q&A' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
NOTICE: '공지사항',
|
||||||
|
DATA: '게시판',
|
||||||
|
QNA: 'Q&A',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null) {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return new Date(dateStr).toLocaleString('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 메인 패널 ─────────────────────────────────────────────
|
||||||
|
interface BoardMgmtPanelProps {
|
||||||
|
initialCategory?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelProps) {
|
||||||
|
const [activeCategory, setActiveCategory] = useState(initialCategory);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [data, setData] = useState<BoardListResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await fetchBoardPosts({
|
||||||
|
categoryCd: activeCategory || undefined,
|
||||||
|
search: search || undefined,
|
||||||
|
page,
|
||||||
|
size: PAGE_SIZE,
|
||||||
|
});
|
||||||
|
setData(result);
|
||||||
|
setSelected(new Set());
|
||||||
|
} catch {
|
||||||
|
console.error('게시글 목록 로드 실패');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [activeCategory, search, page]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.totalCount / PAGE_SIZE) : 0;
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
|
||||||
|
const toggleSelect = (sn: number) => {
|
||||||
|
setSelected(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(sn)) next.delete(sn);
|
||||||
|
else next.add(sn);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
if (selected.size === items.length) {
|
||||||
|
setSelected(new Set());
|
||||||
|
} else {
|
||||||
|
setSelected(new Set(items.map(i => i.sn)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (selected.size === 0) return;
|
||||||
|
if (!confirm(`선택한 ${selected.size}건의 게시글을 삭제하시겠습니까?`)) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await Promise.all([...selected].map(sn => adminDeleteBoardPost(sn)));
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
alert('삭제 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSearch(searchInput);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategoryChange = (code: string) => {
|
||||||
|
setActiveCategory(code);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-b border-border-1">
|
||||||
|
<h2 className="text-sm font-semibold text-text-1">게시판 관리</h2>
|
||||||
|
<span className="text-xs text-text-3">
|
||||||
|
총 {data?.totalCount ?? 0}건
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카테고리 탭 + 검색 */}
|
||||||
|
<div className="flex items-center gap-3 px-5 py-2 border-b border-border-1">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{CATEGORY_TABS.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.code}
|
||||||
|
onClick={() => handleCategoryChange(tab.code)}
|
||||||
|
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
||||||
|
activeCategory === tab.code
|
||||||
|
? 'bg-blue-500/20 text-blue-400 font-medium'
|
||||||
|
: 'text-text-3 hover:text-text-2 hover:bg-bg-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSearch} className="flex gap-1 ml-auto">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={e => setSearchInput(e.target.value)}
|
||||||
|
placeholder="제목/작성자 검색"
|
||||||
|
className="px-2 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-1 placeholder:text-text-4 w-48"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-2 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-2 hover:bg-bg-3"
|
||||||
|
>
|
||||||
|
검색
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 바 */}
|
||||||
|
<div className="flex items-center gap-2 px-5 py-2 border-b border-border-1">
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={selected.size === 0 || deleting}
|
||||||
|
className="px-3 py-1 text-xs rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{deleting ? '삭제 중...' : `선택 삭제 (${selected.size})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-bg-1 z-10">
|
||||||
|
<tr className="border-b border-border-1 text-text-3">
|
||||||
|
<th className="w-8 py-2 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={items.length > 0 && selected.size === items.length}
|
||||||
|
onChange={toggleAll}
|
||||||
|
className="accent-blue-500"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="w-12 py-2 text-center">번호</th>
|
||||||
|
<th className="w-20 py-2 text-center">분류</th>
|
||||||
|
<th className="py-2 text-left pl-3">제목</th>
|
||||||
|
<th className="w-24 py-2 text-center">작성자</th>
|
||||||
|
<th className="w-16 py-2 text-center">조회</th>
|
||||||
|
<th className="w-36 py-2 text-center">등록일</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="py-8 text-center text-text-3">로딩 중...</td>
|
||||||
|
</tr>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="py-8 text-center text-text-3">게시글이 없습니다.</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
items.map(post => (
|
||||||
|
<PostRow
|
||||||
|
key={post.sn}
|
||||||
|
post={post}
|
||||||
|
checked={selected.has(post.sn)}
|
||||||
|
onToggle={() => toggleSelect(post.sn)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-1 py-2 border-t border-border-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="px-2 py-1 text-xs rounded text-text-3 hover:bg-bg-2 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => {
|
||||||
|
const startPage = Math.max(1, Math.min(page - 4, totalPages - 9));
|
||||||
|
const p = startPage + i;
|
||||||
|
if (p > totalPages) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPage(p)}
|
||||||
|
className={`w-7 h-7 text-xs rounded ${
|
||||||
|
p === page ? 'bg-blue-500/20 text-blue-400 font-medium' : 'text-text-3 hover:bg-bg-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="px-2 py-1 text-xs rounded text-text-3 hover:bg-bg-2 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 행 컴포넌트 ───────────────────────────────────────────
|
||||||
|
interface PostRowProps {
|
||||||
|
post: BoardPostItem;
|
||||||
|
checked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostRow({ post, checked, onToggle }: PostRowProps) {
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-border-1 hover:bg-bg-1/50 transition-colors">
|
||||||
|
<td className="py-2 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onToggle}
|
||||||
|
className="accent-blue-500"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-center text-text-3">{post.sn}</td>
|
||||||
|
<td className="py-2 text-center">
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium ${
|
||||||
|
post.categoryCd === 'NOTICE' ? 'bg-red-500/15 text-red-400' :
|
||||||
|
post.categoryCd === 'QNA' ? 'bg-purple-500/15 text-purple-400' :
|
||||||
|
'bg-blue-500/15 text-blue-400'
|
||||||
|
}`}>
|
||||||
|
{CATEGORY_LABELS[post.categoryCd] ?? post.categoryCd}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pl-3 text-text-1 truncate max-w-[300px]">
|
||||||
|
{post.pinnedYn === 'Y' && (
|
||||||
|
<span className="text-[10px] text-orange-400 mr-1">[고정]</span>
|
||||||
|
)}
|
||||||
|
{post.title}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-center text-text-2">{post.authorName}</td>
|
||||||
|
<td className="py-2 text-center text-text-3">{post.viewCnt}</td>
|
||||||
|
<td className="py-2 text-center text-text-3">{formatDate(post.regDtm)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
204
frontend/src/tabs/admin/components/VesselSignalPanel.tsx
Normal file
204
frontend/src/tabs/admin/components/VesselSignalPanel.tsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
// ─── 타입 ──────────────────────────────────────────────────
|
||||||
|
const SIGNAL_SOURCES = ['VTS', 'VTS-AIS', 'V-PASS', 'E-NAVI', 'S&P AIS'] as const;
|
||||||
|
type SignalSource = (typeof SIGNAL_SOURCES)[number];
|
||||||
|
|
||||||
|
interface SignalSlot {
|
||||||
|
time: string; // HH:mm
|
||||||
|
sources: Record<SignalSource, { count: number; status: 'ok' | 'warn' | 'error' | 'none' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 상수 ──────────────────────────────────────────────────
|
||||||
|
const SOURCE_COLORS: Record<SignalSource, string> = {
|
||||||
|
VTS: '#3b82f6',
|
||||||
|
'VTS-AIS': '#a855f7',
|
||||||
|
'V-PASS': '#22c55e',
|
||||||
|
'E-NAVI': '#f97316',
|
||||||
|
'S&P AIS': '#ec4899',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
ok: '#22c55e',
|
||||||
|
warn: '#eab308',
|
||||||
|
error: '#ef4444',
|
||||||
|
none: 'rgba(255,255,255,0.06)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||||
|
|
||||||
|
function generateTimeSlots(date: string): SignalSlot[] {
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = date === now.toISOString().slice(0, 10);
|
||||||
|
const currentHour = isToday ? now.getHours() : 24;
|
||||||
|
const currentMin = isToday ? now.getMinutes() : 0;
|
||||||
|
|
||||||
|
const slots: SignalSlot[] = [];
|
||||||
|
for (let h = 0; h < 24; h++) {
|
||||||
|
for (let m = 0; m < 60; m += 10) {
|
||||||
|
const time = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||||
|
const isPast = h < currentHour || (h === currentHour && m <= currentMin);
|
||||||
|
|
||||||
|
const sources = {} as Record<SignalSource, { count: number; status: 'ok' | 'warn' | 'error' | 'none' }>;
|
||||||
|
for (const src of SIGNAL_SOURCES) {
|
||||||
|
if (!isPast) {
|
||||||
|
sources[src] = { count: 0, status: 'none' };
|
||||||
|
} else {
|
||||||
|
const rand = Math.random();
|
||||||
|
const count = Math.floor(Math.random() * 200) + 10;
|
||||||
|
sources[src] = {
|
||||||
|
count,
|
||||||
|
status: rand > 0.15 ? 'ok' : rand > 0.05 ? 'warn' : 'error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slots.push({ time, sources });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 타임라인 바 (10분 단위 셀) ────────────────────────────
|
||||||
|
function TimelineBar({ slots, source }: { slots: SignalSlot[]; source: SignalSource }) {
|
||||||
|
if (slots.length === 0) return null;
|
||||||
|
|
||||||
|
// 144개 슬롯을 각각 1칸씩 렌더링 (10분 = 1칸)
|
||||||
|
return (
|
||||||
|
<div className="w-full h-5 overflow-hidden flex" style={{ background: 'rgba(255,255,255,0.04)' }}>
|
||||||
|
{slots.map((slot, i) => {
|
||||||
|
const s = slot.sources[source];
|
||||||
|
const color = STATUS_COLOR[s.status] || STATUS_COLOR.none;
|
||||||
|
const statusLabel = s.status === 'ok' ? '정상' : s.status === 'warn' ? '지연' : s.status === 'error' ? '오류' : '미수신';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-full"
|
||||||
|
style={{
|
||||||
|
width: `${100 / 144}%`,
|
||||||
|
backgroundColor: color,
|
||||||
|
borderRight: '0.5px solid rgba(0,0,0,0.15)',
|
||||||
|
}}
|
||||||
|
title={`${slot.time} ${statusLabel}${s.status !== 'none' ? ` (${s.count}건)` : ''}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 메인 패널 ─────────────────────────────────────────────
|
||||||
|
export default function VesselSignalPanel() {
|
||||||
|
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
|
const [slots, setSlots] = useState<SignalSlot[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
// TODO: 실제 API 연동 시 fetch 호출로 교체
|
||||||
|
setTimeout(() => {
|
||||||
|
setSlots(generateTimeSlots(date));
|
||||||
|
setLoading(false);
|
||||||
|
}, 300);
|
||||||
|
}, [date]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => load(), 0);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// 통계 계산
|
||||||
|
const stats = SIGNAL_SOURCES.map(src => {
|
||||||
|
let total = 0, ok = 0, warn = 0, error = 0;
|
||||||
|
for (const slot of slots) {
|
||||||
|
const s = slot.sources[src];
|
||||||
|
if (s.status !== 'none') {
|
||||||
|
total++;
|
||||||
|
if (s.status === 'ok') ok++;
|
||||||
|
else if (s.status === 'warn') warn++;
|
||||||
|
else error++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { src, total, ok, warn, error, rate: total > 0 ? ((ok / total) * 100).toFixed(1) : '-' };
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-b border-border-1">
|
||||||
|
<h2 className="text-sm font-semibold text-text-1">선박신호 수신 현황</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={e => setDate(e.target.value)}
|
||||||
|
className="px-2 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={load}
|
||||||
|
className="px-3 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-2 hover:bg-bg-3"
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<span className="text-xs text-text-3">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{/* 좌측: 소스 라벨 고정 열 */}
|
||||||
|
<div className="flex-shrink-0 flex flex-col" style={{ width: 64 }}>
|
||||||
|
{/* 시간축 높이 맞춤 빈칸 */}
|
||||||
|
<div className="h-5 mb-3" />
|
||||||
|
{SIGNAL_SOURCES.map(src => {
|
||||||
|
const c = SOURCE_COLORS[src];
|
||||||
|
const st = stats.find(s => s.src === src)!;
|
||||||
|
return (
|
||||||
|
<div key={src} className="flex flex-col justify-center mb-4" style={{ height: 20 }}>
|
||||||
|
<span className="text-[12px] font-semibold leading-tight" style={{ color: c }}>{src}</span>
|
||||||
|
<span className="text-[10px] font-mono text-text-4 mt-0.5">{st.rate}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 시간축 + 타임라인 바 */}
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
|
{/* 시간 축 (상단) */}
|
||||||
|
<div className="relative h-5 mb-3">
|
||||||
|
{HOURS.map(h => (
|
||||||
|
<span
|
||||||
|
key={h}
|
||||||
|
className="absolute text-[10px] text-text-3 font-mono"
|
||||||
|
style={{ left: `${(h / 24) * 100}%`, transform: 'translateX(-50%)' }}
|
||||||
|
>
|
||||||
|
{String(h).padStart(2, '0')}시
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span
|
||||||
|
className="absolute text-[10px] text-text-3 font-mono"
|
||||||
|
style={{ right: 0 }}
|
||||||
|
>
|
||||||
|
24시
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 소스별 타임라인 바 */}
|
||||||
|
{SIGNAL_SOURCES.map(src => (
|
||||||
|
<div key={src} className="mb-4 flex items-center" style={{ height: 20 }}>
|
||||||
|
<TimelineBar slots={slots} source={src} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
export const DEFAULT_ROLE_COLORS: Record<string, string> = {
|
export const DEFAULT_ROLE_COLORS: Record<string, string> = {
|
||||||
ADMIN: 'var(--red)',
|
ADMIN: 'var(--red)',
|
||||||
|
HQ_CLEANUP: '#34d399',
|
||||||
MANAGER: 'var(--orange)',
|
MANAGER: 'var(--orange)',
|
||||||
USER: 'var(--cyan)',
|
USER: 'var(--cyan)',
|
||||||
VIEWER: 'var(--t3)',
|
VIEWER: 'var(--t3)',
|
||||||
|
|||||||
94
frontend/src/tabs/admin/components/adminMenuConfig.ts
Normal file
94
frontend/src/tabs/admin/components/adminMenuConfig.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
/** 관리자 화면 9-섹션 메뉴 트리 */
|
||||||
|
|
||||||
|
export interface AdminMenuItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
children?: AdminMenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADMIN_MENU: AdminMenuItem[] = [
|
||||||
|
{
|
||||||
|
id: 'env-settings', label: '환경설정', icon: '⚙️',
|
||||||
|
children: [
|
||||||
|
{ id: 'menus', label: '메뉴관리' },
|
||||||
|
{ id: 'settings', label: '시스템설정' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-info', label: '사용자정보', icon: '👥',
|
||||||
|
children: [
|
||||||
|
{ id: 'users', label: '사용자관리' },
|
||||||
|
{ id: 'permissions', label: '권한관리' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'board-mgmt', label: '게시판관리', icon: '📋',
|
||||||
|
children: [
|
||||||
|
{ id: 'notice', label: '공지사항' },
|
||||||
|
{ id: 'board', label: '게시판' },
|
||||||
|
{ id: 'qna', label: 'QNA' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reference', label: '기준정보', icon: '🗺️',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'map-mgmt', label: '지도관리',
|
||||||
|
children: [
|
||||||
|
{ id: 'map-vector', label: '지도벡데이터' },
|
||||||
|
{ id: 'map-layer', label: '레이어' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sensitive-map', label: '민감자원지도',
|
||||||
|
children: [
|
||||||
|
{ id: 'env-ecology', label: '환경/생태' },
|
||||||
|
{ id: 'social-economy', label: '사회/경제' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'coast-guard-assets', label: '해경자산',
|
||||||
|
children: [
|
||||||
|
{ id: 'cleanup-equip', label: '방제장비' },
|
||||||
|
{ id: 'dispersant-zone', label: '유처리제 제한구역' },
|
||||||
|
{ id: 'vessel-materials', label: '방제선 보유자재' },
|
||||||
|
{ id: 'cleanup-resource', label: '방제자원' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'external', label: '연계관리', icon: '🔗',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'collection', label: '수집자료',
|
||||||
|
children: [
|
||||||
|
{ id: 'collect-vessel-signal', label: '선박신호' },
|
||||||
|
{ id: 'collect-hr', label: '인사정보' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'monitoring', label: '연계모니터링',
|
||||||
|
children: [
|
||||||
|
{ id: 'monitor-realtime', label: '실시간 관측자료' },
|
||||||
|
{ id: 'monitor-forecast', label: '수치예측자료' },
|
||||||
|
{ id: 'monitor-vessel', label: '선박위치정보' },
|
||||||
|
{ id: 'monitor-hr', label: '인사' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 메뉴 ID로 라벨을 찾는 유틸리티 */
|
||||||
|
export function findMenuLabel(id: string, items: AdminMenuItem[] = ADMIN_MENU): string | null {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.id === id) return item.label;
|
||||||
|
if (item.children) {
|
||||||
|
const found = findMenuLabel(id, item.children);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@ -74,6 +74,11 @@ export async function deleteBoardPost(sn: number): Promise<void> {
|
|||||||
await api.delete(`/board/${sn}`);
|
await api.delete(`/board/${sn}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 관리자 전용 삭제 — 소유자 검증 없음 */
|
||||||
|
export async function adminDeleteBoardPost(sn: number): Promise<void> {
|
||||||
|
await api.post('/board/admin-delete', { sn });
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 매뉴얼 API
|
// 매뉴얼 API
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user