feat(admin): 관리자 화면 고도화 — 사용자/권한/게시판/선박신호 패널

- UsersPanel: 테이블+페이징+등록모달+상세모달(비밀번호초기화/잠금해제)
- PermissionsPanel: 사용자별 역할 할당 탭 추가
- BoardMgmtPanel: 공지사항/게시판/QNA 관리자 일괄 삭제
- VesselSignalPanel: VTS/VTS-AIS/V-PASS/E-NAVI/S&P AIS 타임라인 모니터링
- AdminSidebar/AdminPlaceholder/adminMenuConfig 신규
- 권한 미들웨어 부모 리소스 fallback 로직 추가
- 조직 목록 API, 관리자 삭제 API 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nan Kyung Lee 2026-03-06 16:30:55 +09:00
부모 476b6b99ac
커밋 ce80e620c1
20개의 변경된 파일2540개의 추가작업 그리고 514개의 파일을 삭제

파일 보기

@ -72,11 +72,17 @@ export function requirePermission(resource: string, operation: string = 'READ')
req.resolvedPermissions = userInfo.permissions
}
const allowedOps = req.resolvedPermissions[resource]
// 정확한 리소스 매칭 → 부모 리소스 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: '접근 권한이 없습니다.' })
} catch (err) {

파일 보기

@ -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]
)
try {
permissions = await flatPermissionsFallback(userId)
} catch {
console.error('[auth] 권한 조회 fallback 실패, 빈 권한 반환')
permissions = {}
for (const p of permsResult.rows) {
permissions[p.rsrc_cd] = ['READ']
}
}

파일 보기

@ -2,7 +2,7 @@ import { Router } from 'express'
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
import { 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])

파일 보기

@ -255,6 +255,7 @@ CREATE INDEX IDX_AUDIT_LOG_DTM ON AUTH_AUDIT_LOG (REQ_DTM);
-- ============================================================
INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN) VALUES
('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

파일 보기

@ -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<number | ''>(() => {
const defaultOrg = allOrgs.find(o => o.orgNm === '기동방제과')
return defaultOrg ? defaultOrg.orgSn : ''
})
const [email, setEmail] = useState('')
const [roleSns, setRoleSns] = useState<number[]>([])
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-bg-1 border border-border rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="text-sm font-bold text-text-1 font-korean"> </h2>
<button
onClick={onClose}
className="text-text-3 hover:text-text-1 transition-colors"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{/* 폼 */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-4">
{/* 계정 */}
<div>
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
<span className="text-red-400">*</span>
</label>
<input
type="text"
value={account}
onChange={e => 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"
/>
</div>
{/* 비밀번호 */}
<div>
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
<span className="text-red-400">*</span>
</label>
<input
type="password"
value={password}
onChange={e => 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"
/>
</div>
{/* 사용자명 */}
<div>
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
<span className="text-red-400">*</span>
</label>
<input
type="text"
value={name}
onChange={e => 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"
/>
</div>
{/* 직급 */}
<div>
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
</label>
<input
type="text"
value={rank}
onChange={e => 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"
/>
</div>
{/* 소속 */}
<div>
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
</label>
<select
value={orgSn}
onChange={e => setOrgSn(e.target.value !== '' ? Number(e.target.value) : '')}
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
>
<option value=""> </option>
{allOrgs.map(org => (
<option key={org.orgSn} value={org.orgSn}>
{org.orgNm}{org.orgAbbrNm ? ` (${org.orgAbbrNm})` : ''}
</option>
))}
</select>
</div>
{/* 이메일 */}
<div>
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
</label>
<input
type="email"
value={email}
onChange={e => 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"
/>
</div>
{/* 역할 */}
<div>
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
</label>
<div className="bg-bg-2 border border-border rounded-md p-2 space-y-1 max-h-[120px] overflow-y-auto">
{allRoles.length === 0 ? (
<p className="text-[10px] text-text-3 font-korean px-1 py-1"> </p>
) : allRoles.map((role, idx) => {
const color = getRoleColor(role.code, idx)
return (
<label
key={role.sn}
className="flex items-center gap-2 px-2 py-1.5 hover:bg-[rgba(255,255,255,0.04)] rounded cursor-pointer"
>
<input
type="checkbox"
checked={roleSns.includes(role.sn)}
onChange={() => toggleRole(role.sn)}
style={{ accentColor: color }}
/>
<span className="text-xs font-korean" style={{ color }}>{role.name}</span>
<span className="text-[10px] text-text-3 font-mono">{role.code}</span>
</label>
)
})}
</div>
</div>
{/* 에러 메시지 */}
{error && (
<p className="text-[11px] text-red-400 font-korean">{error}</p>
)}
</div>
{/* 푸터 */}
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-xs border border-border text-text-2 rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
>
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
>
{submitting ? '등록 중...' : '등록'}
</button>
</div>
</form>
</div>
</div>
)
}
// ─── 사용자 상세/수정 모달 ────────────────────────────────────
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<number | ''>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-bg-1 border border-border rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div>
<h2 className="text-sm font-bold text-text-1 font-korean"> </h2>
<p className="text-[10px] text-text-3 font-mono mt-0.5">{user.account}</p>
</div>
<button onClick={onClose} className="text-text-3 hover:text-text-1 transition-colors">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-5">
{/* 기본 정보 수정 */}
<div>
<h3 className="text-[11px] font-semibold text-text-2 font-korean mb-3"> </h3>
<div className="space-y-3">
<div>
<label className="block text-[10px] text-text-3 font-korean mb-1"></label>
<input
type="text"
value={name}
onChange={e => 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"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] text-text-3 font-korean mb-1"></label>
<input
type="text"
value={rank}
onChange={e => 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"
/>
</div>
<div>
<label className="block text-[10px] text-text-3 font-korean mb-1"></label>
<select
value={orgSn}
onChange={e => setOrgSn(e.target.value !== '' ? Number(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"
>
<option value=""> </option>
{allOrgs.map(org => (
<option key={org.orgSn} value={org.orgSn}>
{org.orgNm}{org.orgAbbrNm ? ` (${org.orgAbbrNm})` : ''}
</option>
))}
</select>
</div>
</div>
<button
onClick={handleSaveInfo}
disabled={saving || !name.trim()}
className="px-4 py-1.5 text-[11px] font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
>
{saving ? '저장 중...' : '정보 저장'}
</button>
</div>
</div>
{/* 구분선 */}
<div className="border-t border-border" />
{/* 비밀번호 초기화 */}
<div>
<h3 className="text-[11px] font-semibold text-text-2 font-korean mb-3"> </h3>
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="block text-[10px] text-text-3 font-korean mb-1"> </label>
<input
type="password"
value={newPassword}
onChange={e => 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"
/>
</div>
<button
onClick={handleResetPassword}
disabled={resetPwLoading || !newPassword.trim()}
className="px-4 py-1.5 text-[11px] font-semibold rounded-md border border-yellow-400 text-yellow-400 hover:bg-[rgba(250,204,21,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
>
{resetPwLoading ? '초기화 중...' : resetPwDone ? '초기화 완료' : '비밀번호 초기화'}
</button>
<button
onClick={handleUnlock}
disabled={unlockLoading || user.status !== 'LOCKED'}
className="px-4 py-1.5 text-[11px] font-semibold rounded-md border border-green-400 text-green-400 hover:bg-[rgba(74,222,128,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
title={user.status !== 'LOCKED' ? '잠금 상태가 아닙니다' : ''}
>
{unlockLoading ? '해제 중...' : '패스워드잠금해제'}
</button>
</div>
<p className="text-[9px] text-text-3 font-korean mt-1.5"> .</p>
</div>
{/* 구분선 */}
<div className="border-t border-border" />
{/* 계정 잠금 해제 */}
<div>
<h3 className="text-[11px] font-semibold text-text-2 font-korean mb-2"> </h3>
<div className="flex items-center justify-between bg-bg-2 border border-border rounded-md px-4 py-3">
<div>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1.5 text-[11px] font-semibold font-korean ${(statusLabels[user.status] || statusLabels.INACTIVE).color}`}>
<span className={`w-1.5 h-1.5 rounded-full ${(statusLabels[user.status] || statusLabels.INACTIVE).dot}`} />
{(statusLabels[user.status] || statusLabels.INACTIVE).label}
</span>
{user.failCount > 0 && (
<span className="text-[10px] text-red-400 font-korean">
( {user.failCount})
</span>
)}
</div>
{user.status === 'LOCKED' && (
<p className="text-[9px] text-text-3 font-korean mt-1">
5
</p>
)}
</div>
{user.status === 'LOCKED' && (
<button
onClick={handleUnlock}
disabled={unlockLoading}
className="px-4 py-1.5 text-[11px] font-semibold rounded-md border border-green-400 text-green-400 hover:bg-[rgba(74,222,128,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
>
{unlockLoading ? '해제 중...' : '잠금 해제'}
</button>
)}
</div>
</div>
{/* 기타 정보 (읽기 전용) */}
<div>
<h3 className="text-[11px] font-semibold text-text-2 font-korean mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-[10px] font-korean">
<div className="bg-bg-2 border border-border rounded px-3 py-2">
<span className="text-text-3">: </span>
<span className="text-text-2 font-mono">{user.email || '-'}</span>
</div>
<div className="bg-bg-2 border border-border rounded px-3 py-2">
<span className="text-text-3">OAuth: </span>
<span className="text-text-2">{user.oauthProvider || '-'}</span>
</div>
<div className="bg-bg-2 border border-border rounded px-3 py-2">
<span className="text-text-3"> : </span>
<span className="text-text-2">{user.lastLogin ? formatDate(user.lastLogin) : '-'}</span>
</div>
<div className="bg-bg-2 border border-border rounded px-3 py-2">
<span className="text-text-3">: </span>
<span className="text-text-2">{formatDate(user.regDtm)}</span>
</div>
</div>
</div>
{/* 메시지 */}
{message && (
<div className={`px-3 py-2 text-[11px] rounded-md font-korean ${
message.type === 'success'
? 'text-green-400 bg-[rgba(74,222,128,0.08)] border border-[rgba(74,222,128,0.2)]'
: 'text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)]'
}`}>
{message.text}
</div>
)}
</div>
{/* 푸터 */}
<div className="flex items-center justify-end px-6 py-3 border-t border-border">
<button
onClick={onClose}
className="px-4 py-2 text-xs border border-border text-text-2 rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
>
</button>
</div>
</div>
</div>
)
}
// ─── 사용자 관리 패널 ─────────────────────────────────────────
function UsersPanel() {
const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [orgFilter, setOrgFilter] = useState<string>('')
const [users, setUsers] = useState<UserListItem[]>([])
const [loading, setLoading] = useState(true)
const [allRoles, setAllRoles] = useState<RoleWithPermissions[]>([])
const [allOrgs, setAllOrgs] = useState<OrgItem[]>([])
const [roleEditUserId, setRoleEditUserId] = useState<string | null>(null)
const [selectedRoleSns, setSelectedRoleSns] = useState<number[]>([])
const [showRegisterModal, setShowRegisterModal] = useState(false)
const [detailUser, setDetailUser] = useState<UserListItem | null>(null)
const [currentPage, setCurrentPage] = useState(1)
const roleDropdownRef = useRef<HTMLDivElement>(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,23 +635,17 @@ 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 (
<>
<div className="flex flex-col h-full">
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<div>
<h1 className="text-lg font-bold text-text-1 font-korean"> </h1>
<p className="text-xs text-text-3 mt-1 font-korean"> {users.length}</p>
<p className="text-xs text-text-3 mt-1 font-korean"> {filteredUsers.length}</p>
</div>
{pendingCount > 0 && (
<span className="px-2.5 py-1 text-[10px] font-bold rounded-full bg-[rgba(250,204,21,0.15)] text-yellow-400 border border-[rgba(250,204,21,0.3)] animate-pulse font-korean">
@ -145,9 +654,23 @@ function UsersPanel() {
)}
</div>
<div className="flex items-center gap-3">
{/* 소속 필터 */}
<select
value={orgFilter}
onChange={e => { setOrgFilter(e.target.value); setCurrentPage(1) }}
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
>
<option value=""> </option>
{allOrgs.map(org => (
<option key={org.orgSn} value={String(org.orgSn)}>
{org.orgAbbrNm || org.orgNm}
</option>
))}
</select>
{/* 상태 필터 */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
onChange={e => setStatusFilter(e.target.value)}
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
>
<option value=""> </option>
@ -157,53 +680,98 @@ function UsersPanel() {
<option value="INACTIVE"></option>
<option value="REJECTED"></option>
</select>
{/* 텍스트 검색 */}
<input
type="text"
placeholder="이름, 계정 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onChange={e => setSearchTerm(e.target.value)}
className="w-56 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
/>
<button className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean">
+
<button
onClick={() => setShowRegisterModal(true)}
className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
>
+
</button>
</div>
</div>
{/* 테이블 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean"> ...</div>
<div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">
...
</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-border bg-bg-1">
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"> </th>
<th className="px-6 py-3 text-right text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-10"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-mono">ID</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-4 py-3 text-right text-[11px] font-semibold text-text-3 font-korean"></th>
</tr>
</thead>
<tbody>
{users.map((user) => {
{pagedUsers.length === 0 ? (
<tr>
<td colSpan={9} className="px-6 py-10 text-center text-xs text-text-3 font-korean">
.
</td>
</tr>
) : pagedUsers.map((user, idx) => {
const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE
const rowNum = (currentPage - 1) * PAGE_SIZE + idx + 1
return (
<tr key={user.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
<td className="px-6 py-3 text-[12px] text-text-1 font-semibold font-korean">{user.name}</td>
<td className="px-6 py-3 text-[12px] text-text-2 font-mono">{user.account}</td>
<td className="px-6 py-3 text-[12px] text-text-2 font-korean">{user.orgAbbr || '-'}</td>
<td className="px-6 py-3">
<tr
key={user.id}
className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors"
>
{/* 번호 */}
<td className="px-4 py-3 text-[11px] text-text-3 font-mono text-center">{rowNum}</td>
{/* ID(account) */}
<td className="px-4 py-3 text-[12px] text-text-2 font-mono">{user.account}</td>
{/* 사용자명 */}
<td className="px-4 py-3">
<button
onClick={() => setDetailUser(user)}
className="text-[12px] text-primary-cyan font-semibold font-korean hover:underline"
>
{user.name}
</button>
</td>
{/* 직급 */}
<td className="px-4 py-3 text-[12px] text-text-2 font-korean">{user.rank || '-'}</td>
{/* 소속 */}
<td className="px-4 py-3 text-[12px] text-text-2 font-korean">
{user.orgAbbr || user.orgName || '-'}
</td>
{/* 이메일 */}
<td className="px-4 py-3 text-[11px] text-text-3 font-mono">
{user.email || '-'}
</td>
{/* 역할 (인라인 편집) */}
<td className="px-4 py-3">
<div className="relative">
<div
className="flex flex-wrap gap-1 cursor-pointer"
onClick={() => handleOpenRoleEdit(user)}
title="클릭하여 역할 변경"
>
{user.roles.length > 0 ? user.roles.map((roleCode, idx) => {
const color = getRoleColor(roleCode, idx)
{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 (
<span
@ -212,7 +780,7 @@ function UsersPanel() {
style={{
background: `${color}20`,
color: color,
border: `1px solid ${color}40`
border: `1px solid ${color}40`,
}}
>
{roleName}
@ -222,7 +790,10 @@ function UsersPanel() {
<span className="text-[10px] text-text-3 font-korean"> </span>
)}
<span className="text-[10px] text-text-3 ml-0.5">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</span>
</div>
{roleEditUserId === user.id && (
@ -231,10 +802,13 @@ function UsersPanel() {
className="absolute z-20 top-full left-0 mt-1 p-2 bg-bg-1 border border-border rounded-lg shadow-lg min-w-[200px]"
>
<div className="text-[10px] text-text-3 font-korean font-semibold mb-1.5 px-1"> </div>
{allRoles.map((role, idx) => {
const color = getRoleColor(role.code, idx)
{allRoles.map((role, roleIdx) => {
const color = getRoleColor(role.code, roleIdx)
return (
<label key={role.sn} className="flex items-center gap-2 px-2 py-1.5 hover:bg-[rgba(255,255,255,0.04)] rounded cursor-pointer">
<label
key={role.sn}
className="flex items-center gap-2 px-2 py-1.5 hover:bg-[rgba(255,255,255,0.04)] rounded cursor-pointer"
>
<input
type="checkbox"
checked={selectedRoleSns.includes(role.sn)}
@ -265,34 +839,17 @@ function UsersPanel() {
)}
</div>
</td>
<td className="px-6 py-3">
{user.oauthProvider ? (
<span
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded-md font-mono"
style={{ background: 'rgba(66,133,244,0.15)', color: '#4285F4', border: '1px solid rgba(66,133,244,0.3)' }}
title={user.email || undefined}
>
<svg width="10" height="10" viewBox="0 0 48 48"><path fill="#4285F4" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#34A853" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59A14.5 14.5 0 019.5 24c0-1.59.28-3.14.76-4.59l-7.98-6.19A23.99 23.99 0 000 24c0 3.77.9 7.35 2.56 10.54l7.97-5.95z"/><path fill="#EA4335" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 5.95C6.51 42.62 14.62 48 24 48z"/></svg>
Google
</span>
) : (
<span
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded-md font-korean"
style={{ background: 'rgba(148,163,184,0.15)', color: 'var(--t3)', border: '1px solid rgba(148,163,184,0.2)' }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
ID/PW
</span>
)}
</td>
<td className="px-6 py-3">
{/* 승인상태 */}
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 text-[10px] font-semibold font-korean ${statusInfo.color}`}>
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot}`} />
{statusInfo.label}
</span>
</td>
<td className="px-6 py-3 text-[11px] text-text-3 font-mono">{formatDate(user.lastLogin)}</td>
<td className="px-6 py-3 text-right">
{/* 관리 */}
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
{user.status === 'PENDING' && (
<>
@ -343,7 +900,84 @@ function UsersPanel() {
</table>
)}
</div>
{/* 페이지네이션 */}
{!loading && totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-3 border-t border-border bg-bg-1">
<span className="text-[11px] text-text-3 font-korean">
{(currentPage - 1) * PAGE_SIZE + 1}{Math.min(currentPage * PAGE_SIZE, totalCount)} / {totalCount}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-2.5 py-1 text-[11px] border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
>
</button>
{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 === '...' ? (
<span key={`ellipsis-${i}`} className="px-2 text-[11px] text-text-3"></span>
) : (
<button
key={item}
onClick={() => setCurrentPage(item as number)}
className="px-2.5 py-1 text-[11px] border rounded transition-all font-mono"
style={
currentPage === item
? { borderColor: 'var(--cyan)', color: 'var(--cyan)', background: 'rgba(6,182,212,0.1)' }
: { borderColor: 'var(--border)', color: 'var(--t3)' }
}
>
{item}
</button>
)
)}
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-2.5 py-1 text-[11px] border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
>
</button>
</div>
</div>
)}
</div>
{/* 사용자 등록 모달 */}
{showRegisterModal && (
<RegisterModal
allRoles={allRoles}
allOrgs={allOrgs}
onClose={() => setShowRegisterModal(false)}
onSuccess={loadUsers}
/>
)}
{/* 사용자 상세/수정 모달 */}
{detailUser && (
<UserDetailModal
user={detailUser}
allRoles={allRoles}
allOrgs={allOrgs}
onClose={() => setDetailUser(null)}
onUpdated={() => {
loadUsers()
// 최신 정보로 모달 갱신을 위해 닫지 않음
}}
/>
)}
</>
)
}

파일 보기

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