feat: 보고서 지도캡처 + 드론/CCTV/확산예측 UI 기능 개선 #91

병합
jhkang feature/function_develop 에서 develop 로 27 commits 를 머지했습니다 2026-03-16 18:30:04 +09:00
20개의 변경된 파일2540개의 추가작업 그리고 514개의 파일을 삭제
Showing only changes of commit ce80e620c1 - Show all commits

파일 보기

@ -72,10 +72,16 @@ export function requirePermission(resource: string, operation: string = 'READ')
req.resolvedPermissions = userInfo.permissions
}
const allowedOps = req.resolvedPermissions[resource]
if (allowedOps && allowedOps.includes(operation)) {
next()
return
// 정확한 리소스 매칭 → 부모 리소스 fallback (board:notice → board)
let cursor: string | undefined = resource
while (cursor) {
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: '접근 권한이 없습니다.' })

파일 보기

@ -112,6 +112,23 @@ export async function login(
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> {
const userResult = await authPool.query(
`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)
} 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']
}
permissions = await flatPermissionsFallback(userId)
}
} 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']
try {
permissions = await flatPermissionsFallback(userId)
} catch {
console.error('[auth] 권한 조회 fallback 실패, 빈 권한 반환')
permissions = {}
}
}

파일 보기

@ -2,7 +2,7 @@ import { Router } from 'express'
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
import { AuthError } from '../auth/authService.js'
import {
listPosts, getPost, createPost, updatePost, deletePost,
listPosts, getPost, createPost, updatePost, deletePost, adminDeletePost,
listManuals, createManual, updateManual, deleteManual, incrementManualDownload,
} 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

파일 보기

@ -398,3 +398,18 @@ export async function deletePost(postSn: number, requesterId: string): Promise<v
[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,
approveUser,
rejectUser,
listOrgs,
} from './userService.js'
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

파일 보기

@ -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> {
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. 초기 데이터: 역할
-- ============================================================
INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN) VALUES
('ADMIN', '관리자', '시스템 전체 관리 권한', 'N'),
('MANAGER', '운영자', '운영 및 사용자 관리 권한', 'N'),
('USER', '일반사용자', '기본 업무 기능 접근 권한', 'Y'),
('VIEWER', '뷰어', '조회 전용 접근 권한', 'N');
('ADMIN', '관리자', '시스템 전체 관리 권한', 'N'),
('HQ_CLEANUP', '본청방제과', '본청 방제 업무 관리 권한', 'N'),
('MANAGER', '운영자', '운영 및 사용자 관리 권한', '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, '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
(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'),
@ -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, '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, 'board', 'READ', 'Y'), (2, 'board', 'CREATE', 'Y'), (2, 'board', 'UPDATE', 'Y'),
(2, 'weather', 'READ', 'Y'), (2, 'weather', 'CREATE', 'Y'),
(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
(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, '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', 'DELETE', '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', 'DELETE', 'Y'),
(3, 'aerial', 'READ', 'Y'), (3, 'aerial', 'CREATE', 'Y'), (3, 'aerial', 'UPDATE', 'Y'), (3, 'aerial', 'DELETE', 'Y'),
(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', 'DELETE', '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', 'DELETE', 'Y'),
(3, 'weather', 'READ', 'Y'), (3, 'weather', 'CREATE', 'Y'), (3, 'weather', 'UPDATE', 'Y'), (3, 'weather', 'DELETE', 'Y'),
(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
(4, 'prediction', 'READ', 'Y'),
(4, 'hns', 'READ', 'Y'),
(4, 'rescue', 'READ', 'Y'),
(4, 'reports', 'READ', 'N'),
(4, 'aerial', 'READ', 'Y'),
(4, 'prediction', 'READ', 'Y'), (4, 'prediction', 'CREATE', 'Y'), (4, 'prediction', 'UPDATE', 'Y'),
(4, 'hns', 'READ', 'Y'), (4, 'hns', 'CREATE', 'Y'), (4, 'hns', 'UPDATE', 'Y'),
(4, 'rescue', 'READ', 'Y'), (4, 'rescue', 'CREATE', 'Y'), (4, 'rescue', 'UPDATE', 'Y'),
(4, 'reports', 'READ', 'Y'), (4, 'reports', 'CREATE', 'Y'), (4, 'reports', 'UPDATE', 'Y'),
(4, 'aerial', 'READ', 'Y'), (4, 'aerial', 'CREATE', 'Y'), (4, 'aerial', 'UPDATE', 'Y'),
(4, 'assets', 'READ', 'N'),
(4, 'scat', 'READ', 'N'),
(4, 'incidents', 'READ', 'Y'),
(4, 'board', 'READ', 'Y'),
(4, 'scat', 'READ', 'Y'), (4, 'scat', 'CREATE', 'Y'), (4, 'scat', 'UPDATE', 'Y'),
(4, 'incidents', 'READ', 'Y'), (4, 'incidents', 'CREATE', 'Y'), (4, 'incidents', 'UPDATE', 'Y'),
(4, 'board', 'READ', 'Y'), (4, 'board', 'CREATE', 'Y'), (4, 'board', 'UPDATE', 'Y'),
(4, 'weather', 'READ', 'Y'),
(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. 초기 데이터: 조직

파일 보기

@ -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: '📘' }
],
weather: null,
admin: [
{ id: 'users', label: '사용자 관리', icon: '👥' },
{ id: 'permissions', label: '사용자 권한 관리', icon: '🔐' },
{ id: 'menus', label: '메뉴 관리', icon: '📑' },
{ id: 'settings', label: '시스템 설정', icon: '⚙️' }
]
admin: null // 관리자 화면은 자체 사이드바 사용 (AdminSidebar.tsx)
}
// 전역 상태 관리 (간단한 방식)

파일 보기

@ -107,6 +107,20 @@ export async function assignRolesApi(id: string, roleSns: number[]): Promise<voi
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 전용)
export interface RoleWithPermissions {
sn: number

파일 보기

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

파일 보기

@ -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 UsersPanel from './UsersPanel'
import PermissionsPanel from './PermissionsPanel'
import MenusPanel from './MenusPanel'
import SettingsPanel from './SettingsPanel'
import { useState } from 'react';
import AdminSidebar from './AdminSidebar';
import AdminPlaceholder from './AdminPlaceholder';
import { findMenuLabel } from './adminMenuConfig';
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() {
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 (
<div className="flex flex-1 overflow-hidden bg-bg-0">
<AdminSidebar activeMenu={activeMenu} onSelect={setActiveMenu} />
<div className="flex-1 flex flex-col overflow-hidden">
{activeSubTab === 'users' && <UsersPanel />}
{activeSubTab === 'permissions' && <PermissionsPanel />}
{activeSubTab === 'menus' && <MenusPanel />}
{activeSubTab === 'settings' && <SettingsPanel />}
{renderContent()}
</div>
</div>
)
);
}

파일 보기

@ -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"
>
&lt;
</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"
>
&gt;
</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

파일 보기

@ -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> = {
ADMIN: 'var(--red)',
HQ_CLEANUP: '#34d399',
MANAGER: 'var(--orange)',
USER: 'var(--cyan)',
VIEWER: 'var(--t3)',

파일 보기

@ -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}`);
}
/** 관리자 전용 삭제 — 소유자 검증 없음 */
export async function adminDeleteBoardPost(sn: number): Promise<void> {
await api.post('/board/admin-delete', { sn });
}
// ============================================================
// 매뉴얼 API
// ============================================================