diff --git a/backend/src/auth/authMiddleware.ts b/backend/src/auth/authMiddleware.ts index 0a2fc47..7961cb6 100644 --- a/backend/src/auth/authMiddleware.ts +++ b/backend/src/auth/authMiddleware.ts @@ -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: '접근 권한이 없습니다.' }) diff --git a/backend/src/auth/authService.ts b/backend/src/auth/authService.ts index 0d607b7..6573ec9 100644 --- a/backend/src/auth/authService.ts +++ b/backend/src/auth/authService.ts @@ -112,6 +112,23 @@ export async function login( return userInfo } +/** AUTH_PERM_TREE 없이 플랫 권한을 RSRC_CD + OPER_CD 기준으로 조회 */ +async function flatPermissionsFallback(userId: string): Promise> { + 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 = {} + 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 { 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 { 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 = {} } } diff --git a/backend/src/board/boardRouter.ts b/backend/src/board/boardRouter.ts index 39f2d39..93351d7 100644 --- a/backend/src/board/boardRouter.ts +++ b/backend/src/board/boardRouter.ts @@ -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 diff --git a/backend/src/board/boardService.ts b/backend/src/board/boardService.ts index d09fce5..2f3f284 100644 --- a/backend/src/board/boardService.ts +++ b/backend/src/board/boardService.ts @@ -398,3 +398,18 @@ export async function deletePost(postSn: number, requesterId: string): Promise { + 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] + ) +} diff --git a/backend/src/users/userRouter.ts b/backend/src/users/userRouter.ts index 0fa898e..a8a26a3 100644 --- a/backend/src/users/userRouter.ts +++ b/backend/src/users/userRouter.ts @@ -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 diff --git a/backend/src/users/userService.ts b/backend/src/users/userService.ts index 0881a66..2a34ace 100644 --- a/backend/src/users/userService.ts +++ b/backend/src/users/userService.ts @@ -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 { + 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) => ({ + 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 { await authPool.query('DELETE FROM AUTH_USER_ROLE WHERE USER_ID = $1', [userId]) diff --git a/database/auth_init.sql b/database/auth_init.sql index b99b723..eca52a0 100644 --- a/database/auth_init.sql +++ b/database/auth_init.sql @@ -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. 초기 데이터: 조직 diff --git a/database/migration/020_add_hq_cleanup_role.sql b/database/migration/020_add_hq_cleanup_role.sql new file mode 100644 index 0000000..85a401b --- /dev/null +++ b/database/migration/020_add_hq_cleanup_role.sql @@ -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 $$; diff --git a/frontend/src/common/hooks/useSubMenu.ts b/frontend/src/common/hooks/useSubMenu.ts index f053d85..6b1baae 100755 --- a/frontend/src/common/hooks/useSubMenu.ts +++ b/frontend/src/common/hooks/useSubMenu.ts @@ -60,12 +60,7 @@ const subMenuConfigs: Record = { { 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) } // 전역 상태 관리 (간단한 방식) diff --git a/frontend/src/common/services/authApi.ts b/frontend/src/common/services/authApi.ts index 9fccd40..0611d05 100644 --- a/frontend/src/common/services/authApi.ts +++ b/frontend/src/common/services/authApi.ts @@ -107,6 +107,20 @@ export async function assignRolesApi(id: string, roleSns: number[]): Promise { + const response = await api.get('/users/orgs') + return response.data +} + // 역할/권한 API (ADMIN 전용) export interface RoleWithPermissions { sn: number diff --git a/frontend/src/tabs/admin/components/AdminPlaceholder.tsx b/frontend/src/tabs/admin/components/AdminPlaceholder.tsx new file mode 100644 index 0000000..f615001 --- /dev/null +++ b/frontend/src/tabs/admin/components/AdminPlaceholder.tsx @@ -0,0 +1,14 @@ +interface AdminPlaceholderProps { + label: string; +} + +/** 미구현 관리자 메뉴 placeholder */ +const AdminPlaceholder = ({ label }: AdminPlaceholderProps) => ( +
+
🚧
+
{label}
+
해당 기능은 준비 중입니다.
+
+); + +export default AdminPlaceholder; diff --git a/frontend/src/tabs/admin/components/AdminSidebar.tsx b/frontend/src/tabs/admin/components/AdminSidebar.tsx new file mode 100644 index 0000000..59852a3 --- /dev/null +++ b/frontend/src/tabs/admin/components/AdminSidebar.tsx @@ -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>(() => { + // 초기: 첫 번째 섹션 열기 + const init = new Set(); + 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 ( + + ); + }; + + const renderGroup = (item: AdminMenuItem, depth: number) => { + const isOpen = expanded.has(item.id); + const hasActiveChild = containsActive(item); + + return ( +
+ + {isOpen && item.children && ( +
+ {item.children.map(child => renderItem(child, depth + 1))} +
+ )} +
+ ); + }; + + const renderItem = (item: AdminMenuItem, depth: number) => { + if (item.children && item.children.length > 0) { + return renderGroup(item, depth); + } + return renderLeaf(item, depth); + }; + + return ( +
+ {/* 헤더 */} +
+
+ ⚙️ 관리자 설정 +
+
+ + {/* 메뉴 목록 */} +
+ {ADMIN_MENU.map(section => { + const isOpen = expanded.has(section.id); + const hasActiveChild = containsActive(section); + + return ( +
+ {/* 섹션 헤더 */} + + + {/* 하위 메뉴 */} + {isOpen && section.children && ( +
+ {section.children.map(child => renderItem(child, 1))} +
+ )} +
+ ); + })} +
+
+ ); +}; + +/** 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; diff --git a/frontend/src/tabs/admin/components/AdminView.tsx b/frontend/src/tabs/admin/components/AdminView.tsx index 3633998..1182643 100755 --- a/frontend/src/tabs/admin/components/AdminView.tsx +++ b/frontend/src/tabs/admin/components/AdminView.tsx @@ -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 JSX.Element> = { + users: () => , + permissions: () => , + menus: () => , + settings: () => , + notice: () => , + board: () => , + qna: () => , + 'collect-vessel-signal': () => , +}; -// ─── 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 ; + }; return (
+
- {activeSubTab === 'users' && } - {activeSubTab === 'permissions' && } - {activeSubTab === 'menus' && } - {activeSubTab === 'settings' && } + {renderContent()}
- ) + ); } diff --git a/frontend/src/tabs/admin/components/BoardMgmtPanel.tsx b/frontend/src/tabs/admin/components/BoardMgmtPanel.tsx new file mode 100644 index 0000000..478221e --- /dev/null +++ b/frontend/src/tabs/admin/components/BoardMgmtPanel.tsx @@ -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 = { + 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(null); + const [loading, setLoading] = useState(false); + const [selected, setSelected] = useState>(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 ( +
+ {/* 헤더 */} +
+

게시판 관리

+ + 총 {data?.totalCount ?? 0}건 + +
+ + {/* 카테고리 탭 + 검색 */} +
+
+ {CATEGORY_TABS.map(tab => ( + + ))} +
+
+ 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" + /> + +
+
+ + {/* 액션 바 */} +
+ +
+ + {/* 테이블 */} +
+ + + + + + + + + + + + + + {loading ? ( + + + + ) : items.length === 0 ? ( + + + + ) : ( + items.map(post => ( + toggleSelect(post.sn)} + /> + )) + )} + +
+ 0 && selected.size === items.length} + onChange={toggleAll} + className="accent-blue-500" + /> + 번호분류제목작성자조회등록일
로딩 중...
게시글이 없습니다.
+
+ + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+ + {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 ( + + ); + })} + +
+ )} +
+ ); +} + +// ─── 행 컴포넌트 ─────────────────────────────────────────── +interface PostRowProps { + post: BoardPostItem; + checked: boolean; + onToggle: () => void; +} + +function PostRow({ post, checked, onToggle }: PostRowProps) { + return ( + + + + + {post.sn} + + + {CATEGORY_LABELS[post.categoryCd] ?? post.categoryCd} + + + + {post.pinnedYn === 'Y' && ( + [고정] + )} + {post.title} + + {post.authorName} + {post.viewCnt} + {formatDate(post.regDtm)} + + ); +} diff --git a/frontend/src/tabs/admin/components/PermissionsPanel.tsx b/frontend/src/tabs/admin/components/PermissionsPanel.tsx index e20ccac..4124a33 100644 --- a/frontend/src/tabs/admin/components/PermissionsPanel.tsx +++ b/frontend/src/tabs/admin/components/PermissionsPanel.tsx @@ -1,5 +1,6 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { + fetchUsers, fetchRoles, fetchPermTree, updatePermissionsApi, @@ -7,6 +8,8 @@ import { updateRoleApi, deleteRoleApi, updateRoleDefaultApi, + assignRolesApi, + type UserListItem, type RoleWithPermissions, type PermTreeNode, } from '@common/services/authApi' @@ -113,27 +116,34 @@ interface PermCellProps { state: PermState onToggle: () => void label?: string + readOnly?: boolean } -function PermCell({ state, onToggle, label }: PermCellProps) { - const isDisabled = state === 'forced-denied' +function PermCell({ state, onToggle, label, readOnly = false }: PermCellProps) { + const isDisabled = state === 'forced-denied' || readOnly - const baseClasses = 'w-7 h-7 rounded border text-xs font-bold transition-all flex items-center justify-center' + const baseClasses = 'w-5 h-5 rounded border text-[10px] 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)]` + classes = readOnly + ? `${baseClasses} bg-[rgba(6,182,212,0.2)] border-primary-cyan text-primary-cyan cursor-default` + : `${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` + classes = readOnly + ? `${baseClasses} bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] cursor-default` + : `${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` + classes = readOnly + ? `${baseClasses} bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-red-400 cursor-default` + : `${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': @@ -148,10 +158,15 @@ function PermCell({ state, onToggle, label }: PermCellProps) { disabled={isDisabled} className={classes} title={ - state === 'explicit-granted' ? `${label ?? ''} 명시적 허용 (클릭: 거부로 전환)` - : state === 'inherited-granted' ? `${label ?? ''} 부모 상속 허용 (클릭: 명시적 거부)` - : state === 'explicit-denied' ? `${label ?? ''} 명시적 거부 (클릭: 허용으로 전환)` - : `${label ?? ''} 부모 거부로 비활성` + readOnly + ? state === 'explicit-granted' ? `${label ?? ''} 허용` + : state === 'inherited-granted' ? `${label ?? ''} 상속 허용` + : state === 'explicit-denied' ? `${label ?? ''} 거부` + : `${label ?? ''} 비활성` + : state === 'explicit-granted' ? `${label ?? ''} 명시적 허용 (클릭: 거부로 전환)` + : state === 'inherited-granted' ? `${label ?? ''} 부모 상속 허용 (클릭: 명시적 거부)` + : state === 'explicit-denied' ? `${label ?? ''} 명시적 거부 (클릭: 허용으로 전환)` + : `${label ?? ''} 부모 거부로 비활성` } > {icon} @@ -166,12 +181,13 @@ interface TreeRowProps { expanded: Set onToggleExpand: (code: string) => void onTogglePerm: (code: string, oper: OperCode, currentState: PermState) => void + readOnly?: boolean } -function TreeRow({ node, stateMap, expanded, onToggleExpand, onTogglePerm }: TreeRowProps) { +function TreeRow({ node, stateMap, expanded, onToggleExpand, onTogglePerm, readOnly = false }: TreeRowProps) { const hasChildren = node.children.length > 0 const isExpanded = expanded.has(node.code) - const indent = node.level * 24 + const indent = node.level * 16 // 이 노드의 READ 상태 (CUD 비활성 판단용) const readState = stateMap.get(makeKey(node.code, 'READ')) ?? 'forced-denied' @@ -180,33 +196,30 @@ function TreeRow({ node, stateMap, expanded, onToggleExpand, onTogglePerm }: Tre return ( <> - +
{hasChildren ? ( ) : ( - + {node.level > 0 ? '├' : ''} )} - {node.icon && {node.icon}} + {node.icon && {node.icon}}
-
+
{node.name}
- {node.description && node.level === 0 && ( -
{node.description}
- )}
@@ -216,12 +229,13 @@ function TreeRow({ node, stateMap, expanded, onToggleExpand, onTogglePerm }: Tre // READ 거부 시 CUD도 강제 거부 const effectiveState = (oper !== 'READ' && readDenied) ? 'forced-denied' as PermState : state return ( - +
onTogglePerm(node.code, oper, effectiveState)} + readOnly={readOnly} />
@@ -236,14 +250,613 @@ function TreeRow({ node, stateMap, expanded, onToggleExpand, onTogglePerm }: Tre expanded={expanded} onToggleExpand={onToggleExpand} onTogglePerm={onTogglePerm} + readOnly={readOnly} /> ))} ) } +// ─── 공통 범례 컴포넌트 ────────────────────────────── +function PermLegend() { + return ( +
+ + + 허용 + + + + 상속 + + + + 거부 + + + + 비활성 + + + R=조회 C=생성 U=수정 D=삭제 + +
+ ) +} + +// ─── RolePermTab: 기존 그룹별 권한 탭 ─────────────── +interface RolePermTabProps { + roles: RoleWithPermissions[] + permTree: PermTreeNode[] + rolePerms: Map> + setRolePerms: React.Dispatch>>> + selectedRoleSn: number | null + setSelectedRoleSn: (sn: number | null) => void + dirty: boolean + saving: boolean + handleSave: () => Promise + handleToggleExpand: (code: string) => void + handleTogglePerm: (code: string, oper: OperCode, currentState: PermState) => void + expanded: Set + flatNodes: PermTreeNode[] + editingRoleSn: number | null + editRoleName: string + setEditRoleName: (name: string) => void + handleStartEditName: (role: RoleWithPermissions) => void + handleSaveRoleName: (roleSn: number) => Promise + setEditingRoleSn: (sn: number | null) => void + toggleDefault: (roleSn: number) => Promise + handleDeleteRole: (roleSn: number, roleName: string) => Promise + showCreateForm: boolean + setShowCreateForm: (show: boolean) => void + setCreateError: (err: string) => void + newRoleCode: string + setNewRoleCode: (code: string) => void + newRoleName: string + setNewRoleName: (name: string) => void + newRoleDesc: string + setNewRoleDesc: (desc: string) => void + creating: boolean + createError: string + handleCreateRole: () => Promise +} + +function RolePermTab({ + roles, + permTree, + selectedRoleSn, + setSelectedRoleSn, + dirty, + saving, + handleSave, + handleToggleExpand, + handleTogglePerm, + expanded, + flatNodes, + rolePerms, + editingRoleSn, + editRoleName, + setEditRoleName, + handleStartEditName, + handleSaveRoleName, + setEditingRoleSn, + toggleDefault, + handleDeleteRole, + showCreateForm, + setShowCreateForm, + setCreateError, + newRoleCode, + setNewRoleCode, + newRoleName, + setNewRoleName, + newRoleDesc, + setNewRoleDesc, + creating, + createError, + handleCreateRole, +}: RolePermTabProps) { + const currentStateMap = selectedRoleSn + ? buildEffectiveStates(flatNodes, rolePerms.get(selectedRoleSn) ?? new Map()) + : new Map() + + return ( + <> + {/* 헤더 액션 버튼 */} +
+ + +
+ + {/* 역할 탭 바 */} +
+ {roles.map((role, idx) => { + const color = getRoleColor(role.code, idx) + const isSelected = selectedRoleSn === role.sn + return ( +
+ + {isSelected && ( +
+ + {role.code !== 'ADMIN' && ( + + )} +
+ )} +
+ ) + })} +
+ + {/* 범례 */} + + + {/* CRUD 매트릭스 테이블 */} + {selectedRoleSn ? ( +
+ + + + + {OPER_CODES.map(oper => ( + + ))} + + + + {permTree.map(rootNode => ( + + ))} + +
기능 +
{OPER_LABELS[oper]}
+
{OPER_FULL_LABELS[oper]}
+
+
+ ) : ( +
+ 역할을 선택하세요 +
+ )} + + {/* 역할 생성 모달 */} + {showCreateForm && ( +
+
+
+

새 역할 추가

+
+
+
+ + 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" + /> +

영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)

+
+
+ + 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" + /> +
+
+ + 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" + /> +
+ {createError && ( +
+ {createError} +
+ )} +
+
+ + +
+
+
+ )} + + ) +} + +// ─── UserPermTab: 사용자별 권한 탭 ─────────────────── +interface UserPermTabProps { + roles: RoleWithPermissions[] + permTree: PermTreeNode[] + rolePerms: Map> +} + +function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) { + const [users, setUsers] = useState([]) + const [loadingUsers, setLoadingUsers] = useState(true) + const [searchQuery, setSearchQuery] = useState('') + const [showDropdown, setShowDropdown] = useState(false) + const [selectedUser, setSelectedUser] = useState(null) + const [assignedRoleSns, setAssignedRoleSns] = useState([]) + const [savingRoles, setSavingRoles] = useState(false) + const [rolesDirty, setRolesDirty] = useState(false) + const [expanded, setExpanded] = useState>(new Set()) + const dropdownRef = useRef(null) + + const flatNodes = flattenTree(permTree) + + useEffect(() => { + const loadUsers = async () => { + setLoadingUsers(true) + try { + const data = await fetchUsers() + setUsers(data) + } catch (err) { + console.error('사용자 목록 조회 실패:', err) + } finally { + setLoadingUsers(false) + } + } + loadUsers() + }, []) + + // 최상위 노드 기본 펼침 + useEffect(() => { + if (permTree.length > 0) { + setExpanded(new Set(permTree.map(n => n.code))) + } + }, [permTree]) + + // 드롭다운 외부 클릭 시 닫기 + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setShowDropdown(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const filteredUsers = users.filter(u => { + if (!searchQuery) return true + const q = searchQuery.toLowerCase() + return ( + u.name.toLowerCase().includes(q) || + u.account.toLowerCase().includes(q) || + (u.orgName?.toLowerCase().includes(q) ?? false) + ) + }) + + const handleSelectUser = (user: UserListItem) => { + setSelectedUser(user) + setSearchQuery(user.name) + setShowDropdown(false) + setAssignedRoleSns(user.roleSns ?? []) + setRolesDirty(false) + } + + const handleToggleRole = (roleSn: number) => { + setAssignedRoleSns(prev => { + const next = prev.includes(roleSn) + ? prev.filter(sn => sn !== roleSn) + : [...prev, roleSn] + return next + }) + setRolesDirty(true) + } + + const handleSaveRoles = async () => { + if (!selectedUser) return + setSavingRoles(true) + try { + await assignRolesApi(selectedUser.id, assignedRoleSns) + setRolesDirty(false) + // 로컬 users 상태 갱신 + setUsers(prev => prev.map(u => + u.id === selectedUser.id + ? { + ...u, + roleSns: assignedRoleSns, + roles: roles.filter(r => assignedRoleSns.includes(r.sn)).map(r => r.name), + } + : u + )) + setSelectedUser(prev => prev + ? { + ...prev, + roleSns: assignedRoleSns, + roles: roles.filter(r => assignedRoleSns.includes(r.sn)).map(r => r.name), + } + : null + ) + } catch (err) { + console.error('역할 저장 실패:', err) + } finally { + setSavingRoles(false) + } + } + + 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 + }) + }, []) + + // 사용자의 유효 권한: 할당된 역할들의 권한 병합 (OR 결합) + const effectiveStateMap = (() => { + if (!selectedUser || assignedRoleSns.length === 0) { + return new Map() + } + + // 각 역할의 명시적 권한 병합: 어느 역할이든 granted=true면 허용 + const mergedPerms = new Map() + for (const roleSn of assignedRoleSns) { + const perms = rolePerms.get(roleSn) + if (!perms) continue + for (const [key, granted] of perms) { + if (granted) { + mergedPerms.set(key, true) + } else if (!mergedPerms.has(key)) { + mergedPerms.set(key, false) + } + } + } + + return buildEffectiveStates(flatNodes, mergedPerms) + })() + + const noOpToggle = useCallback((_code: string, _oper: OperCode, _state: PermState): void => { + void _code; void _oper; void _state; + // 읽기 전용 — 토글 없음 + }, []) + + return ( +
+ {/* 사용자 검색/선택 */} +
+ +
+ { + setSearchQuery(e.target.value) + setShowDropdown(true) + if (selectedUser && e.target.value !== selectedUser.name) { + setSelectedUser(null) + setAssignedRoleSns([]) + setRolesDirty(false) + } + }} + onFocus={() => setShowDropdown(true)} + placeholder={loadingUsers ? '불러오는 중...' : '이름, 계정, 조직으로 검색...'} + disabled={loadingUsers} + className="w-full max-w-sm 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 disabled:opacity-50" + /> + {showDropdown && filteredUsers.length > 0 && ( +
+ {filteredUsers.map(user => ( + + ))} +
+ )} + {showDropdown && !loadingUsers && filteredUsers.length === 0 && searchQuery && ( +
+ 검색 결과 없음 +
+ )} +
+
+ + {selectedUser ? ( + <> + {/* 역할 할당 섹션 */} +
+
+ 역할 할당 + +
+
+ {roles.map((role, idx) => { + const color = getRoleColor(role.code, idx) + const isChecked = assignedRoleSns.includes(role.sn) + return ( + + ) + })} +
+
+ + {/* 유효 권한 매트릭스 (읽기 전용) */} +
+ 유효 권한 (읽기 전용) + — 할당된 역할의 권한 합산 결과 +
+ + + + {assignedRoleSns.length > 0 ? ( +
+ + + + + {OPER_CODES.map(oper => ( + + ))} + + + + {permTree.map(rootNode => ( + + ))} + +
기능 +
{OPER_LABELS[oper]}
+
{OPER_FULL_LABELS[oper]}
+
+
+ ) : ( +
+ 역할을 하나 이상 할당하면 유효 권한이 표시됩니다 +
+ )} + + ) : ( +
+ 사용자를 선택하세요 +
+ )} +
+ ) +} + // ─── 메인 PermissionsPanel ────────────────────────── +type ActiveTab = 'role' | 'user' + function PermissionsPanel() { + const [activeTab, setActiveTab] = useState('role') const [roles, setRoles] = useState([]) const [permTree, setPermTree] = useState([]) const [loading, setLoading] = useState(true) @@ -303,11 +916,6 @@ function PermissionsPanel() { // 플랫 노드 목록 const flatNodes = flattenTree(permTree) - // 선택된 역할의 effective state 계산 - const currentStateMap = selectedRoleSn - ? buildEffectiveStates(flatNodes, rolePerms.get(selectedRoleSn) ?? new Map()) - : new Map() - const handleToggleExpand = useCallback((code: string) => { setExpanded(prev => { const next = new Set(prev) @@ -448,217 +1056,78 @@ function PermissionsPanel() { return (
{/* 헤더 */} -
+
-

사용자 권한 관리

-

역할별 리소스 × CRUD 권한을 설정합니다

+

권한 관리

+

역할별 리소스 × CRUD 권한 설정

-
+ {/* 탭 전환 */} +
- +
- {/* 역할 탭 바 */} -
- {roles.map((role, idx) => { - const color = getRoleColor(role.code, idx) - const isSelected = selectedRoleSn === role.sn - return ( -
- - {isSelected && ( -
- - {role.code !== 'ADMIN' && ( - - )} -
- )} -
- ) - })} -
- - {/* 범례 */} -
- - - 명시적 허용 - - - - 상속 허용 - - - - 명시적 거부 - - - - 강제 거부 - - - R=조회 C=생성 U=수정 D=삭제 - -
- - {/* CRUD 매트릭스 테이블 */} - {selectedRoleSn ? ( -
- - - - - {OPER_CODES.map(oper => ( - - ))} - - - - {permTree.map(rootNode => ( - - ))} - -
기능 -
{OPER_LABELS[oper]}
-
{OPER_FULL_LABELS[oper]}
-
-
+ {activeTab === 'role' ? ( + ) : ( -
- 역할을 선택하세요 -
- )} - - {/* 역할 생성 모달 */} - {showCreateForm && ( -
-
-
-

새 역할 추가

-
-
-
- - 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" - /> -

영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)

-
-
- - 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" - /> -
-
- - 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" - /> -
- {createError && ( -
- {createError} -
- )} -
-
- - -
-
-
+ )}
) diff --git a/frontend/src/tabs/admin/components/UsersPanel.tsx b/frontend/src/tabs/admin/components/UsersPanel.tsx index 417c58b..7ce5958 100644 --- a/frontend/src/tabs/admin/components/UsersPanel.tsx +++ b/frontend/src/tabs/admin/components/UsersPanel.tsx @@ -2,24 +2,526 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { fetchUsers, fetchRoles, + fetchOrgs, + createUserApi, updateUserApi, + changePasswordApi, approveUserApi, rejectUserApi, assignRolesApi, type UserListItem, type RoleWithPermissions, + type OrgItem, } from '@common/services/authApi' import { getRoleColor, statusLabels } from './adminConstants' +const PAGE_SIZE = 15 + +// ─── 포맷 헬퍼 ───────────────────────────────────────────────── +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 RegisterModalProps { + allRoles: RoleWithPermissions[] + allOrgs: OrgItem[] + onClose: () => void + onSuccess: () => void +} + +function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalProps) { + const [account, setAccount] = useState('') + const [password, setPassword] = useState('') + const [name, setName] = useState('') + const [rank, setRank] = useState('') + const [orgSn, setOrgSn] = useState(() => { + const defaultOrg = allOrgs.find(o => o.orgNm === '기동방제과') + return defaultOrg ? defaultOrg.orgSn : '' + }) + const [email, setEmail] = useState('') + const [roleSns, setRoleSns] = useState([]) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const toggleRole = (sn: number) => { + setRoleSns(prev => prev.includes(sn) ? prev.filter(s => s !== sn) : [...prev, sn]) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!account.trim() || !password.trim() || !name.trim()) { + setError('계정, 비밀번호, 사용자명은 필수 항목입니다.') + return + } + setSubmitting(true) + setError(null) + try { + await createUserApi({ + account: account.trim(), + password, + name: name.trim(), + rank: rank.trim() || undefined, + orgSn: orgSn !== '' ? orgSn : undefined, + roleSns: roleSns.length > 0 ? roleSns : undefined, + }) + onSuccess() + onClose() + } catch (err) { + setError('사용자 등록에 실패했습니다.') + console.error('사용자 등록 실패:', err) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+ {/* 헤더 */} +
+

사용자 등록

+ +
+ + {/* 폼 */} +
+
+ {/* 계정 */} +
+ + setAccount(e.target.value)} + placeholder="로그인 계정 ID" + 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" + /> +
+ + {/* 비밀번호 */} +
+ + setPassword(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-mono" + /> +
+ + {/* 사용자명 */} +
+ + setName(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" + /> +
+ + {/* 직급 */} +
+ + setRank(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" + /> +
+ + {/* 소속 */} +
+ + +
+ + {/* 이메일 */} +
+ + setEmail(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-mono" + /> +
+ + {/* 역할 */} +
+ +
+ {allRoles.length === 0 ? ( +

역할 없음

+ ) : allRoles.map((role, idx) => { + const color = getRoleColor(role.code, idx) + return ( + + ) + })} +
+
+ + {/* 에러 메시지 */} + {error && ( +

{error}

+ )} +
+ + {/* 푸터 */} +
+ + +
+
+
+
+ ) +} + +// ─── 사용자 상세/수정 모달 ──────────────────────────────────── +interface UserDetailModalProps { + user: UserListItem + allRoles: RoleWithPermissions[] + allOrgs: OrgItem[] + onClose: () => void + onUpdated: () => void +} + +function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalProps) { + const [name, setName] = useState(user.name) + const [rank, setRank] = useState(user.rank || '') + const [orgSn, setOrgSn] = useState(user.orgSn ?? '') + const [saving, setSaving] = useState(false) + const [newPassword, setNewPassword] = useState('') + const [resetPwLoading, setResetPwLoading] = useState(false) + const [resetPwDone, setResetPwDone] = useState(false) + const [unlockLoading, setUnlockLoading] = useState(false) + const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null) + + const handleSaveInfo = async () => { + setSaving(true) + setMessage(null) + try { + await updateUserApi(user.id, { + name: name.trim(), + rank: rank.trim() || undefined, + orgSn: orgSn !== '' ? orgSn : null, + }) + setMessage({ text: '사용자 정보가 수정되었습니다.', type: 'success' }) + onUpdated() + } catch { + setMessage({ text: '사용자 정보 수정에 실패했습니다.', type: 'error' }) + } finally { + setSaving(false) + } + } + + const handleResetPassword = async () => { + if (!newPassword.trim()) { + setMessage({ text: '새 비밀번호를 입력하세요.', type: 'error' }) + return + } + setResetPwLoading(true) + setMessage(null) + try { + await changePasswordApi(user.id, newPassword) + setMessage({ text: '비밀번호가 초기화되었습니다.', type: 'success' }) + setResetPwDone(true) + setNewPassword('') + } catch { + setMessage({ text: '비밀번호 초기화에 실패했습니다.', type: 'error' }) + } finally { + setResetPwLoading(false) + } + } + + const handleUnlock = async () => { + setUnlockLoading(true) + setMessage(null) + try { + await updateUserApi(user.id, { status: 'ACTIVE' }) + setMessage({ text: '계정 잠금이 해제되었습니다.', type: 'success' }) + onUpdated() + } catch { + setMessage({ text: '잠금 해제에 실패했습니다.', type: 'error' }) + } finally { + setUnlockLoading(false) + } + } + + return ( +
+
+ {/* 헤더 */} +
+
+

사용자 정보

+

{user.account}

+
+ +
+ +
+ {/* 기본 정보 수정 */} +
+

기본 정보 수정

+
+
+ + setName(e.target.value)} + className="w-full px-3 py-1.5 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean" + /> +
+
+
+ + setRank(e.target.value)} + placeholder="예: 팀장" + className="w-full px-3 py-1.5 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" + /> +
+
+ + +
+
+ +
+
+ + {/* 구분선 */} +
+ + {/* 비밀번호 초기화 */} +
+

비밀번호 초기화

+
+
+ + setNewPassword(e.target.value)} + placeholder="새 비밀번호 입력" + className="w-full px-3 py-1.5 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" + /> +
+ + +
+

초기화 후 사용자에게 새 비밀번호를 전달하세요.

+
+ + {/* 구분선 */} +
+ + {/* 계정 잠금 해제 */} +
+

계정 상태

+
+
+
+ + + {(statusLabels[user.status] || statusLabels.INACTIVE).label} + + {user.failCount > 0 && ( + + (로그인 실패 {user.failCount}회) + + )} +
+ {user.status === 'LOCKED' && ( +

+ 비밀번호 5회 이상 오류로 잠금 처리됨 +

+ )} +
+ {user.status === 'LOCKED' && ( + + )} +
+
+ + {/* 기타 정보 (읽기 전용) */} +
+

기타 정보

+
+
+ 이메일: + {user.email || '-'} +
+
+ OAuth: + {user.oauthProvider || '-'} +
+
+ 최종 로그인: + {user.lastLogin ? formatDate(user.lastLogin) : '-'} +
+
+ 등록일: + {formatDate(user.regDtm)} +
+
+
+ + {/* 메시지 */} + {message && ( +
+ {message.text} +
+ )} +
+ + {/* 푸터 */} +
+ +
+
+
+ ) +} + // ─── 사용자 관리 패널 ───────────────────────────────────────── function UsersPanel() { const [searchTerm, setSearchTerm] = useState('') const [statusFilter, setStatusFilter] = useState('') + const [orgFilter, setOrgFilter] = useState('') const [users, setUsers] = useState([]) const [loading, setLoading] = useState(true) const [allRoles, setAllRoles] = useState([]) + const [allOrgs, setAllOrgs] = useState([]) const [roleEditUserId, setRoleEditUserId] = useState(null) const [selectedRoleSns, setSelectedRoleSns] = useState([]) + const [showRegisterModal, setShowRegisterModal] = useState(false) + const [detailUser, setDetailUser] = useState(null) + const [currentPage, setCurrentPage] = useState(1) const roleDropdownRef = useRef(null) const loadUsers = useCallback(async () => { @@ -27,6 +529,7 @@ function UsersPanel() { try { const data = await fetchUsers(searchTerm || undefined, statusFilter || undefined) setUsers(data) + setCurrentPage(1) } catch (err) { console.error('사용자 목록 조회 실패:', err) } finally { @@ -40,6 +543,7 @@ function UsersPanel() { useEffect(() => { fetchRoles().then(setAllRoles).catch(console.error) + fetchOrgs().then(setAllOrgs).catch(console.error) }, []) useEffect(() => { @@ -54,6 +558,17 @@ function UsersPanel() { return () => document.removeEventListener('mousedown', handleClickOutside) }, [roleEditUserId]) + // ─── 필터링 (org 클라이언트 사이드) ─────────────────────────── + const filteredUsers = orgFilter + ? users.filter(u => String(u.orgSn) === orgFilter) + : users + + // ─── 페이지네이션 ────────────────────────────────────────────── + const totalCount = filteredUsers.length + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) + const pagedUsers = filteredUsers.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE) + + // ─── 액션 핸들러 ────────────────────────────────────────────── const handleUnlock = async (userId: string) => { try { await updateUserApi(userId, { status: 'ACTIVE' }) @@ -120,230 +635,349 @@ function UsersPanel() { } } - 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 ( -
-
-
-
-

사용자 관리

-

총 {users.length}명

+ <> +
+ {/* 헤더 */} +
+
+
+

사용자 관리

+

총 {filteredUsers.length}명

+
+ {pendingCount > 0 && ( + + 승인대기 {pendingCount}명 + + )} +
+
+ {/* 소속 필터 */} + + {/* 상태 필터 */} + + {/* 텍스트 검색 */} + 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" + /> +
- {pendingCount > 0 && ( - - 승인대기 {pendingCount}명 - - )}
-
- - 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" - /> - -
-
-
- {loading ? ( -
불러오는 중...
- ) : ( - - - - - - - - - - - - - - - {users.map((user) => { - const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE - return ( - - - - - - - - - + {/* 번호 */} + + + {/* ID(account) */} + + + {/* 사용자명 */} + + + {/* 직급 */} + + + {/* 소속 */} + + + {/* 이메일 */} + + + {/* 역할 (인라인 편집) */} + + + {/* 승인상태 */} + + + {/* 관리 */} + + + ) + })} + +
이름계정소속역할인증상태최근 로그인관리
{user.name}{user.account}{user.orgAbbr || '-'} -
-
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 ( - - {roleName} - - ) - }) : ( - 역할 없음 - )} - - - -
- {roleEditUserId === user.id && ( -
-
역할 선택
- {allRoles.map((role, idx) => { - const color = getRoleColor(role.code, idx) - return ( - - ) - })} -
- - -
-
- )} -
-
- {user.oauthProvider ? ( - - - Google - - ) : ( - - - ID/PW - - )} - - - - {statusInfo.label} - - {formatDate(user.lastLogin)} -
- {user.status === 'PENDING' && ( - <> - - - - )} - {user.status === 'LOCKED' && ( - - )} - {user.status === 'ACTIVE' && ( - - )} - {(user.status === 'INACTIVE' || user.status === 'REJECTED') && ( - - )} -
+ {/* 테이블 */} +
+ {loading ? ( +
+ 불러오는 중... +
+ ) : ( + + + + + + + + + + + + + + + + {pagedUsers.length === 0 ? ( + + - ) - })} - -
번호ID사용자명직급소속이메일역할승인상태관리
+ 조회된 사용자가 없습니다.
+ ) : pagedUsers.map((user, idx) => { + const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE + const rowNum = (currentPage - 1) * PAGE_SIZE + idx + 1 + return ( +
{rowNum}{user.account} + + {user.rank || '-'} + {user.orgAbbr || user.orgName || '-'} + + {user.email || '-'} + +
+
handleOpenRoleEdit(user)} + title="클릭하여 역할 변경" + > + {user.roles.length > 0 ? user.roles.map((roleCode, roleIdx) => { + const color = getRoleColor(roleCode, roleIdx) + const roleName = allRoles.find(r => r.code === roleCode)?.name || roleCode + return ( + + {roleName} + + ) + }) : ( + 역할 없음 + )} + + + + + + +
+ {roleEditUserId === user.id && ( +
+
역할 선택
+ {allRoles.map((role, roleIdx) => { + const color = getRoleColor(role.code, roleIdx) + return ( + + ) + })} +
+ + +
+
+ )} +
+
+ + + {statusInfo.label} + + +
+ {user.status === 'PENDING' && ( + <> + + + + )} + {user.status === 'LOCKED' && ( + + )} + {user.status === 'ACTIVE' && ( + + )} + {(user.status === 'INACTIVE' || user.status === 'REJECTED') && ( + + )} +
+
+ )} +
+ + {/* 페이지네이션 */} + {!loading && totalPages > 1 && ( +
+ + {(currentPage - 1) * PAGE_SIZE + 1}–{Math.min(currentPage * PAGE_SIZE, totalCount)} / {totalCount}명 + +
+ + {Array.from({ length: totalPages }, (_, i) => i + 1) + .filter(p => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2) + .reduce<(number | '...')[]>((acc, p, i, arr) => { + if (i > 0 && typeof arr[i - 1] === 'number' && (p as number) - (arr[i - 1] as number) > 1) { + acc.push('...') + } + acc.push(p) + return acc + }, []) + .map((item, i) => + item === '...' ? ( + + ) : ( + + ) + )} + +
+
)}
-
+ + {/* 사용자 등록 모달 */} + {showRegisterModal && ( + setShowRegisterModal(false)} + onSuccess={loadUsers} + /> + )} + + {/* 사용자 상세/수정 모달 */} + {detailUser && ( + setDetailUser(null)} + onUpdated={() => { + loadUsers() + // 최신 정보로 모달 갱신을 위해 닫지 않음 + }} + /> + )} + ) } diff --git a/frontend/src/tabs/admin/components/VesselSignalPanel.tsx b/frontend/src/tabs/admin/components/VesselSignalPanel.tsx new file mode 100644 index 0000000..df6f586 --- /dev/null +++ b/frontend/src/tabs/admin/components/VesselSignalPanel.tsx @@ -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; +} + +// ─── 상수 ────────────────────────────────────────────────── +const SOURCE_COLORS: Record = { + VTS: '#3b82f6', + 'VTS-AIS': '#a855f7', + 'V-PASS': '#22c55e', + 'E-NAVI': '#f97316', + 'S&P AIS': '#ec4899', +}; + +const STATUS_COLOR: Record = { + 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; + 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 ( +
+ {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 ( +
+ ); + })} +
+ ); +} + +// ─── 메인 패널 ───────────────────────────────────────────── +export default function VesselSignalPanel() { + const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10)); + const [slots, setSlots] = useState([]); + 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 ( +
+ {/* 헤더 */} +
+

선박신호 수신 현황

+
+ setDate(e.target.value)} + className="px-2 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-1" + /> + +
+
+ + {/* 메인 콘텐츠 */} +
+ {loading ? ( +
+ 로딩 중... +
+ ) : ( +
+ {/* 좌측: 소스 라벨 고정 열 */} +
+ {/* 시간축 높이 맞춤 빈칸 */} +
+ {SIGNAL_SOURCES.map(src => { + const c = SOURCE_COLORS[src]; + const st = stats.find(s => s.src === src)!; + return ( +
+ {src} + {st.rate}% +
+ ); + })} +
+ + {/* 우측: 시간축 + 타임라인 바 */} +
+ {/* 시간 축 (상단) */} +
+ {HOURS.map(h => ( + + {String(h).padStart(2, '0')}시 + + ))} + + 24시 + +
+ + {/* 소스별 타임라인 바 */} + {SIGNAL_SOURCES.map(src => ( +
+ +
+ ))} +
+
+ )} +
+ +
+ ); +} diff --git a/frontend/src/tabs/admin/components/adminConstants.ts b/frontend/src/tabs/admin/components/adminConstants.ts index b27ed50..b45ac41 100644 --- a/frontend/src/tabs/admin/components/adminConstants.ts +++ b/frontend/src/tabs/admin/components/adminConstants.ts @@ -1,5 +1,6 @@ export const DEFAULT_ROLE_COLORS: Record = { ADMIN: 'var(--red)', + HQ_CLEANUP: '#34d399', MANAGER: 'var(--orange)', USER: 'var(--cyan)', VIEWER: 'var(--t3)', diff --git a/frontend/src/tabs/admin/components/adminMenuConfig.ts b/frontend/src/tabs/admin/components/adminMenuConfig.ts new file mode 100644 index 0000000..892447c --- /dev/null +++ b/frontend/src/tabs/admin/components/adminMenuConfig.ts @@ -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; +} diff --git a/frontend/src/tabs/board/services/boardApi.ts b/frontend/src/tabs/board/services/boardApi.ts index 2d86afb..5eaddaf 100644 --- a/frontend/src/tabs/board/services/boardApi.ts +++ b/frontend/src/tabs/board/services/boardApi.ts @@ -74,6 +74,11 @@ export async function deleteBoardPost(sn: number): Promise { await api.delete(`/board/${sn}`); } +/** 관리자 전용 삭제 — 소유자 검증 없음 */ +export async function adminDeleteBoardPost(sn: number): Promise { + await api.post('/board/admin-delete', { sn }); +} + // ============================================================ // 매뉴얼 API // ============================================================