feat(admin): 메뉴 관리 기능 구현 (DB 단일 소스, 이모지 피커)

- 메뉴 활성/비활성, 순서, 라벨, 아이콘을 DB(AUTH_SETTING)에서 관리
- GET/PUT /api/menus 엔드포인트 추가
- Zustand menuStore로 메뉴 설정 전역 상태 관리
- TopBar: DB 메뉴 설정 기반 동적 탭 렌더링 (ALL_TABS 하드코딩 제거)
- AdminView MenusPanel: API 연동, 이모지 피커(@emoji-mart) 통합
- SETTING_VAL 컬럼 VARCHAR(500) → TEXT 마이그레이션
- dotenv 추가로 .env 파일 자동 로딩
- wing_auth DB 비밀번호 기본값 수정 (JDBC 호환)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-02-28 02:21:36 +09:00
부모 b52d8097b0
커밋 fd48e755f2
14개의 변경된 파일442개의 추가작업 그리고 103개의 파일을 삭제

파일 보기

@ -12,6 +12,7 @@
"better-sqlite3": "^11.9.1", "better-sqlite3": "^11.9.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.3.1",
"express": "^4.21.2", "express": "^4.21.2",
"express-rate-limit": "^8.2.1", "express-rate-limit": "^8.2.1",
"google-auth-library": "^10.6.1", "google-auth-library": "^10.6.1",
@ -1128,6 +1129,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

파일 보기

@ -13,6 +13,7 @@
"better-sqlite3": "^11.9.1", "better-sqlite3": "^11.9.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.3.1",
"express": "^4.21.2", "express": "^4.21.2",
"express-rate-limit": "^8.2.1", "express-rate-limit": "^8.2.1",
"google-auth-library": "^10.6.1", "google-auth-library": "^10.6.1",

파일 보기

@ -7,7 +7,7 @@ const authPool = new Pool({
port: Number(process.env.AUTH_DB_PORT) || 5432, port: Number(process.env.AUTH_DB_PORT) || 5432,
database: process.env.AUTH_DB_NAME || 'wing_auth', database: process.env.AUTH_DB_NAME || 'wing_auth',
user: process.env.AUTH_DB_USER || 'wing_auth', user: process.env.AUTH_DB_USER || 'wing_auth',
password: process.env.AUTH_DB_PASSWORD || 'WingAuth!2026', password: process.env.AUTH_DB_PASSWORD || 'WingAuth2026',
max: 10, max: 10,
idleTimeoutMillis: 30000, idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000, connectionTimeoutMillis: 5000,

파일 보기

@ -0,0 +1,35 @@
import { Router } from 'express'
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
import { getMenuConfig, updateMenuConfig } from '../settings/settingsService.js'
const router = Router()
// GET /api/menus — 메뉴 설정 조회 (인증된 모든 사용자)
router.get('/', requireAuth, async (_req, res) => {
try {
const config = await getMenuConfig()
res.json(config)
} catch (err) {
console.error('[menus] 조회 오류:', err)
res.status(500).json({ error: '메뉴 설정 조회 중 오류가 발생했습니다.' })
}
})
// PUT /api/menus — 메뉴 설정 수정 (ADMIN 전용)
router.put('/', requireAuth, requireRole('ADMIN'), async (req, res) => {
try {
const { menus } = req.body
if (!Array.isArray(menus)) {
res.status(400).json({ error: '메뉴 설정 형식이 올바르지 않습니다.' })
return
}
const updated = await updateMenuConfig(menus)
res.json(updated)
} catch (err) {
const message = err instanceof Error ? err.message : '메뉴 설정 수정 중 오류가 발생했습니다.'
console.error('[menus] 수정 오류:', err)
res.status(400).json({ error: message })
}
})
export default router

파일 보기

@ -1,3 +1,4 @@
import 'dotenv/config'
import express from 'express' import express from 'express'
import cors from 'cors' import cors from 'cors'
import helmet from 'helmet' import helmet from 'helmet'
@ -11,6 +12,7 @@ import authRouter from './auth/authRouter.js'
import userRouter from './users/userRouter.js' import userRouter from './users/userRouter.js'
import roleRouter from './roles/roleRouter.js' import roleRouter from './roles/roleRouter.js'
import settingsRouter from './settings/settingsRouter.js' import settingsRouter from './settings/settingsRouter.js'
import menuRouter from './menus/menuRouter.js'
import { import {
sanitizeBody, sanitizeBody,
sanitizeQuery, sanitizeQuery,
@ -132,6 +134,7 @@ app.use('/api/auth', authRouter)
app.use('/api/users', userRouter) app.use('/api/users', userRouter)
app.use('/api/roles', roleRouter) app.use('/api/roles', roleRouter)
app.use('/api/settings', settingsRouter) app.use('/api/settings', settingsRouter)
app.use('/api/menus', menuRouter)
// API 라우트 — 업무 // API 라우트 — 업무
app.use('/api/layers', layersRouter) app.use('/api/layers', layersRouter)
@ -170,5 +173,15 @@ app.use((err: Error, _req: express.Request, res: express.Response, _next: expres
// ============================================================ // ============================================================
app.listen(PORT, async () => { app.listen(PORT, async () => {
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`) console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
await testAuthDbConnection() const connected = await testAuthDbConnection()
if (connected) {
// SETTING_VAL VARCHAR(500) → TEXT 마이그레이션 (메뉴 설정 JSON 확장 대응)
try {
const { authPool } = await import('./db/authDb.js')
await authPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`)
console.log('[migration] SETTING_VAL → TEXT 변환 완료')
} catch {
// 이미 TEXT이거나 권한 없으면 무시
}
}
}) })

파일 보기

@ -72,6 +72,88 @@ export async function updateOAuthSettings(settings: {
} }
} }
// ─── 메뉴 설정 ──────────────────────────────────────────────
export interface MenuConfigItem {
id: string
label: string
icon: string
enabled: boolean
order: number
}
const DEFAULT_MENU_CONFIG: MenuConfigItem[] = [
{ id: 'prediction', label: '유출유 확산예측', icon: '🛢️', enabled: true, order: 1 },
{ id: 'hns', label: 'HNS·대기확산', icon: '🧪', enabled: true, order: 2 },
{ id: 'rescue', label: '긴급구난', icon: '🚨', enabled: true, order: 3 },
{ id: 'reports', label: '보고자료', icon: '📊', enabled: true, order: 4 },
{ id: 'aerial', label: '항공탐색', icon: '✈️', enabled: true, order: 5 },
{ id: 'assets', label: '방제자산 관리', icon: '🚢', enabled: true, order: 6 },
{ id: 'scat', label: '해안평가', icon: '🏖️', enabled: true, order: 7 },
{ id: 'board', label: '게시판', icon: '📌', enabled: true, order: 8 },
{ id: 'weather', label: '기상정보', icon: '⛅', enabled: true, order: 9 },
{ id: 'incidents', label: '통합조회', icon: '🔍', enabled: true, order: 10 },
]
const VALID_MENU_IDS = DEFAULT_MENU_CONFIG.map(m => m.id)
export async function getMenuConfig(): Promise<MenuConfigItem[]> {
const val = await getSetting('menu.config')
if (!val) return DEFAULT_MENU_CONFIG
try {
const parsed = JSON.parse(val) as MenuConfigItem[]
const defaultMap = new Map(DEFAULT_MENU_CONFIG.map(m => [m.id, m]))
return parsed
.filter(item => VALID_MENU_IDS.includes(item.id))
.map(item => {
const defaults = defaultMap.get(item.id)!
return {
id: item.id,
label: item.label || defaults.label,
icon: item.icon || defaults.icon,
enabled: item.enabled,
order: item.order,
}
})
.sort((a, b) => a.order - b.order)
} catch {
return DEFAULT_MENU_CONFIG
}
}
export async function updateMenuConfig(
config: MenuConfigItem[]
): Promise<MenuConfigItem[]> {
const filtered = config.filter(item => VALID_MENU_IDS.includes(item.id))
if (filtered.length !== VALID_MENU_IDS.length) {
throw new Error('모든 메뉴 항목이 포함되어야 합니다.')
}
const ids = new Set(filtered.map(item => item.id))
if (ids.size !== filtered.length) {
throw new Error('중복된 메뉴 ID가 있습니다.')
}
const defaultMap = new Map(DEFAULT_MENU_CONFIG.map(m => [m.id, m]))
const normalized = filtered.map((item, idx) => {
const defaults = defaultMap.get(item.id)!
return {
id: item.id,
label: item.label || defaults.label,
icon: item.icon || defaults.icon,
enabled: Boolean(item.enabled),
order: idx + 1,
}
})
await setSetting('menu.config', JSON.stringify(normalized))
return normalized
}
export async function getAllSettings(): Promise<SettingItem[]> { export async function getAllSettings(): Promise<SettingItem[]> {
const result = await authPool.query<SettingRow>( const result = await authPool.query<SettingRow>(
'SELECT SETTING_KEY, SETTING_VAL, SETTING_DC FROM AUTH_SETTING ORDER BY SETTING_KEY' 'SELECT SETTING_KEY, SETTING_VAL, SETTING_DC FROM AUTH_SETTING ORDER BY SETTING_KEY'

파일 보기

@ -179,7 +179,7 @@ COMMENT ON COLUMN AUTH_LOGIN_HIST.SUCCESS_YN IS '성공여부 (Y:성공, N:실
-- ============================================================ -- ============================================================
CREATE TABLE AUTH_SETTING ( CREATE TABLE AUTH_SETTING (
SETTING_KEY VARCHAR(100) NOT NULL, SETTING_KEY VARCHAR(100) NOT NULL,
SETTING_VAL VARCHAR(500) NOT NULL, SETTING_VAL TEXT NOT NULL,
SETTING_DC VARCHAR(200), SETTING_DC VARCHAR(200),
MDFCN_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), MDFCN_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT PK_AUTH_SETTING PRIMARY KEY (SETTING_KEY) CONSTRAINT PK_AUTH_SETTING PRIMARY KEY (SETTING_KEY)
@ -281,7 +281,8 @@ SELECT USER_ID, 1 FROM AUTH_USER WHERE USER_ACNT = 'admin';
INSERT INTO AUTH_SETTING (SETTING_KEY, SETTING_VAL, SETTING_DC) VALUES INSERT INTO AUTH_SETTING (SETTING_KEY, SETTING_VAL, SETTING_DC) VALUES
('registration.auto-approve', 'true', '신규 사용자 자동 승인 여부 (true: 즉시 ACTIVE, false: PENDING 대기)'), ('registration.auto-approve', 'true', '신규 사용자 자동 승인 여부 (true: 즉시 ACTIVE, false: PENDING 대기)'),
('registration.default-role', 'true', '신규 사용자에게 기본 역할(DFLT_YN=Y) 자동 할당 여부'), ('registration.default-role', 'true', '신규 사용자에게 기본 역할(DFLT_YN=Y) 자동 할당 여부'),
('oauth.auto-approve-domains', 'gcsc.co.kr', 'OAuth 자동 승인 도메인 (쉼표 구분)'); ('oauth.auto-approve-domains', 'gcsc.co.kr', 'OAuth 자동 승인 도메인 (쉼표 구분)'),
('menu.config', '[{"id":"prediction","label":"유출유 확산예측","icon":"🛢️","enabled":true,"order":1},{"id":"hns","label":"HNS·대기확산","icon":"🧪","enabled":true,"order":2},{"id":"rescue","label":"긴급구난","icon":"🚨","enabled":true,"order":3},{"id":"reports","label":"보고자료","icon":"📊","enabled":true,"order":4},{"id":"aerial","label":"항공탐색","icon":"✈️","enabled":true,"order":5},{"id":"assets","label":"방제자산 관리","icon":"🚢","enabled":true,"order":6},{"id":"scat","label":"해안평가","icon":"🏖️","enabled":true,"order":7},{"id":"board","label":"게시판","icon":"📌","enabled":true,"order":8},{"id":"weather","label":"기상정보","icon":"⛅","enabled":true,"order":9},{"id":"incidents","label":"통합조회","icon":"🔍","enabled":true,"order":10}]', '메뉴 표시 여부 및 순서 설정 (JSON 배열)');
-- ============================================================ -- ============================================================

파일 보기

@ -8,9 +8,12 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@react-oauth/google": "^0.13.4", "@react-oauth/google": "^0.13.4",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"axios": "^1.13.5", "axios": "^1.13.5",
"emoji-mart": "^5.6.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.564.0", "lucide-react": "^0.564.0",
"ol": "^10.8.0", "ol": "^10.8.0",
@ -334,6 +337,22 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@emoji-mart/data": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz",
"integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==",
"license": "MIT"
},
"node_modules/@emoji-mart/react": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@emoji-mart/react/-/react-1.1.1.tgz",
"integrity": "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==",
"license": "MIT",
"peerDependencies": {
"emoji-mart": "^5.2",
"react": "^16.8 || ^17 || ^18"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3", "version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@ -1585,7 +1604,7 @@
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -2344,7 +2363,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
@ -2421,6 +2440,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/emoji-mart": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
"integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
"license": "MIT"
},
"node_modules/engine.io-client": { "node_modules/engine.io-client": {
"version": "6.6.4", "version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",

파일 보기

@ -10,9 +10,12 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@react-oauth/google": "^0.13.4", "@react-oauth/google": "^0.13.4",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"axios": "^1.13.5", "axios": "^1.13.5",
"emoji-mart": "^5.6.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.564.0", "lucide-react": "^0.564.0",
"ol": "^10.8.0", "ol": "^10.8.0",

파일 보기

@ -4,6 +4,7 @@ import { MainLayout } from './components/layout/MainLayout'
import { LoginPage } from './components/auth/LoginPage' import { LoginPage } from './components/auth/LoginPage'
import { registerMainTabSwitcher } from './hooks/useSubMenu' import { registerMainTabSwitcher } from './hooks/useSubMenu'
import { useAuthStore } from './store/authStore' import { useAuthStore } from './store/authStore'
import { useMenuStore } from './store/menuStore'
import { OilSpillView } from './components/views/OilSpillView' import { OilSpillView } from './components/views/OilSpillView'
import { ReportsView } from './components/views/ReportsView' import { ReportsView } from './components/views/ReportsView'
import { HNSView } from './components/views/HNSView' import { HNSView } from './components/views/HNSView'
@ -23,11 +24,18 @@ const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || ''
function App() { function App() {
const [activeMainTab, setActiveMainTab] = useState<MainTab>('prediction') const [activeMainTab, setActiveMainTab] = useState<MainTab>('prediction')
const { isAuthenticated, isLoading, checkSession } = useAuthStore() const { isAuthenticated, isLoading, checkSession } = useAuthStore()
const { loadMenuConfig } = useMenuStore()
useEffect(() => { useEffect(() => {
checkSession() checkSession()
}, [checkSession]) }, [checkSession])
useEffect(() => {
if (isAuthenticated) {
loadMenuConfig()
}
}, [isAuthenticated, loadMenuConfig])
useEffect(() => { useEffect(() => {
registerMainTabSwitcher(setActiveMainTab) registerMainTabSwitcher(setActiveMainTab)
}, []) }, [])

파일 보기

@ -1,25 +1,7 @@
import { useState, useRef, useEffect, useMemo } from 'react' import { useState, useRef, useEffect, useMemo } from 'react'
import type { MainTab } from '../../App' import type { MainTab } from '../../App'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { useMenuStore } from '../../store/menuStore'
interface Tab {
id: MainTab
label: string
icon: string
}
const ALL_TABS: Tab[] = [
{ id: 'prediction', label: '유출유 확산예측', icon: '🛢️' },
{ id: 'hns', label: 'HNS·대기확산', icon: '🧪' },
{ id: 'rescue', label: '긴급구난', icon: '🚨' },
{ id: 'reports', label: '보고자료', icon: '📊' },
{ id: 'aerial', label: '항공탐색', icon: '✈️' },
{ id: 'assets', label: '방제자산 관리', icon: '🚢' },
{ id: 'scat', label: '해안평가', icon: '🏖️' },
{ id: 'board', label: '게시판', icon: '📌' },
{ id: 'weather', label: '기상정보', icon: '⛅' },
{ id: 'incidents', label: '통합조회', icon: '🔍' },
]
interface TopBarProps { interface TopBarProps {
activeTab: MainTab activeTab: MainTab
@ -31,11 +13,15 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
const [mapToggles, setMapToggles] = useState({ s57: true, s101: false, threeD: false, satellite: false }) const [mapToggles, setMapToggles] = useState({ s57: true, s101: false, threeD: false, satellite: false })
const quickMenuRef = useRef<HTMLDivElement>(null) const quickMenuRef = useRef<HTMLDivElement>(null)
const { hasPermission, user, logout } = useAuthStore() const { hasPermission, user, logout } = useAuthStore()
const { menuConfig, isLoaded } = useMenuStore()
const tabs = useMemo( const tabs = useMemo(() => {
() => ALL_TABS.filter((tab) => hasPermission(tab.id)), if (!isLoaded || menuConfig.length === 0) return []
[hasPermission, user?.permissions]
) return menuConfig
.filter((m) => m.enabled && hasPermission(m.id))
.sort((a, b) => a.order - b.order)
}, [hasPermission, user?.permissions, menuConfig, isLoaded])
useEffect(() => { useEffect(() => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
@ -66,7 +52,7 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
return ( return (
<button <button
key={tab.id} key={tab.id}
onClick={() => onTabChange(tab.id)} onClick={() => onTabChange(tab.id as MainTab)}
title={tab.label} title={tab.label}
className={` className={`
px-2.5 xl:px-4 py-2 rounded-sm text-[13px] transition-all duration-200 px-2.5 xl:px-4 py-2 rounded-sm text-[13px] transition-all duration-200

파일 보기

@ -1,5 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { useSubMenu } from '../../hooks/useSubMenu' import { useSubMenu } from '../../hooks/useSubMenu'
import data from '@emoji-mart/data'
import EmojiPicker from '@emoji-mart/react'
import { import {
fetchUsers, fetchUsers,
fetchRoles, fetchRoles,
@ -16,11 +18,15 @@ import {
updateRegistrationSettingsApi, updateRegistrationSettingsApi,
fetchOAuthSettings, fetchOAuthSettings,
updateOAuthSettingsApi, updateOAuthSettingsApi,
fetchMenuConfig,
updateMenuConfigApi,
type UserListItem, type UserListItem,
type RoleWithPermissions, type RoleWithPermissions,
type RegistrationSettings, type RegistrationSettings,
type OAuthSettings, type OAuthSettings,
type MenuConfigItem,
} from '../../services/authApi' } from '../../services/authApi'
import { useMenuStore } from '../../store/menuStore'
const DEFAULT_ROLE_COLORS: Record<string, string> = { const DEFAULT_ROLE_COLORS: Record<string, string> = {
ADMIN: 'var(--red)', ADMIN: 'var(--red)',
@ -45,18 +51,6 @@ const statusLabels: Record<string, { label: string; color: string; dot: string }
REJECTED: { label: '거절됨', color: 'text-red-300', dot: 'bg-red-300' }, REJECTED: { label: '거절됨', color: 'text-red-300', dot: 'bg-red-300' },
} }
const mockMenus = [
{ id: 'prediction', label: '유출유 확산예측', enabled: true, order: 1 },
{ id: 'hns', label: 'HNS·대기확산', enabled: true, order: 2 },
{ id: 'rescue', label: '긴급구난', enabled: true, order: 3 },
{ id: 'reports', label: '보고자료', enabled: true, order: 4 },
{ id: 'aerial', label: '항공탐색', enabled: true, order: 5 },
{ id: 'assets', label: '방제자산 관리', enabled: true, order: 6 },
{ id: 'scat', label: 'Pre-SCAT', enabled: false, order: 7 },
{ id: 'incidents', label: '사고조회', enabled: true, order: 8 },
{ id: 'board', label: '게시판', enabled: true, order: 9 },
{ id: 'weather', label: '기상정보', enabled: true, order: 10 },
]
// ─── 사용자 관리 패널 ───────────────────────────────────────── // ─── 사용자 관리 패널 ─────────────────────────────────────────
function UsersPanel() { function UsersPanel() {
@ -727,88 +721,219 @@ function PermissionsPanel() {
// ─── 메뉴 관리 패널 ───────────────────────────────────────── // ─── 메뉴 관리 패널 ─────────────────────────────────────────
function MenusPanel() { function MenusPanel() {
const [menus, setMenus] = useState(mockMenus) const [menus, setMenus] = useState<MenuConfigItem[]>([])
const [originalMenus, setOriginalMenus] = useState<MenuConfigItem[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [emojiPickerId, setEmojiPickerId] = useState<string | null>(null)
const emojiPickerRef = useRef<HTMLDivElement>(null)
const { setMenuConfig } = useMenuStore()
const hasChanges = JSON.stringify(menus) !== JSON.stringify(originalMenus)
const loadMenus = useCallback(async () => {
setLoading(true)
try {
const config = await fetchMenuConfig()
setMenus(config)
setOriginalMenus(config)
} catch (err) {
console.error('메뉴 설정 조회 실패:', err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadMenus()
}, [loadMenus])
useEffect(() => {
if (!emojiPickerId) return
const handler = (e: MouseEvent) => {
if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target as Node)) {
setEmojiPickerId(null)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [emojiPickerId])
const toggleMenu = (id: string) => { const toggleMenu = (id: string) => {
setMenus(prev => prev.map(m => m.id === id ? { ...m, enabled: !m.enabled } : m)) setMenus(prev => prev.map(m => m.id === id ? { ...m, enabled: !m.enabled } : m))
} }
const updateMenuField = (id: string, field: 'label' | 'icon', value: string) => {
setMenus(prev => prev.map(m => m.id === id ? { ...m, [field]: value } : m))
}
const handleEmojiSelect = (emoji: { native: string }) => {
if (emojiPickerId) {
updateMenuField(emojiPickerId, 'icon', emoji.native)
setEmojiPickerId(null)
}
}
const moveMenu = (idx: number, direction: -1 | 1) => {
const targetIdx = idx + direction
if (targetIdx < 0 || targetIdx >= menus.length) return
setMenus(prev => {
const arr = [...prev]
;[arr[idx], arr[targetIdx]] = [arr[targetIdx], arr[idx]]
return arr.map((m, i) => ({ ...m, order: i + 1 }))
})
}
const handleSave = async () => {
setSaving(true)
try {
const updated = await updateMenuConfigApi(menus)
setMenus(updated)
setOriginalMenus(updated)
setMenuConfig(updated)
} catch (err) {
console.error('메뉴 설정 저장 실패:', err)
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-text-3 text-sm font-korean"> ...</div>
</div>
)
}
return ( return (
<div className="flex flex-col h-full"> <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 justify-between px-6 py-4 border-b border-border">
<div> <div>
<h1 className="text-lg font-bold text-text-1 font-korean"> </h1> <h1 className="text-lg font-bold text-text-1 font-korean"> </h1>
<p className="text-xs text-text-3 mt-1 font-korean"> </p> <p className="text-xs text-text-3 mt-1 font-korean"> , , , </p>
</div> </div>
<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={handleSave}
disabled={!hasChanges || saving}
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean ${
hasChanges && !saving
? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
: 'bg-bg-3 text-text-3 cursor-not-allowed'
}`}
>
{saving ? '저장 중...' : '변경사항 저장'}
</button> </button>
</div> </div>
<div className="flex-1 overflow-auto px-6 py-4"> <div className="flex-1 overflow-auto px-6 py-4">
<div className="flex flex-col gap-2 max-w-[600px]"> <div className="flex flex-col gap-2 max-w-[700px]">
{menus.map((menu, idx) => ( {menus.map((menu, idx) => {
<div const isEditing = editingId === menu.id
key={menu.id} return (
className={`flex items-center justify-between px-4 py-3 rounded-md border transition-all ${ <div
menu.enabled key={menu.id}
? 'bg-bg-1 border-border' className={`flex items-center justify-between px-4 py-3 rounded-md border transition-all ${
: 'bg-bg-0 border-border opacity-50' menu.enabled
}`} ? 'bg-bg-1 border-border'
> : 'bg-bg-0 border-border opacity-50'
<div className="flex items-center gap-4"> }`}
<span className="text-text-3 text-xs font-mono w-6 text-center">{idx + 1}</span> >
<div> <div className="flex items-center gap-3 flex-1 min-w-0">
<div className={`text-[13px] font-semibold font-korean ${menu.enabled ? 'text-text-1' : 'text-text-3'}`}> <span className="text-text-3 text-xs font-mono w-6 text-center shrink-0">{idx + 1}</span>
{menu.label} {isEditing ? (
</div> <>
<div className="text-[10px] text-text-3 font-mono">{menu.id}</div> <div className="relative shrink-0">
<button
onClick={() => setEmojiPickerId(emojiPickerId === menu.id ? null : menu.id)}
className="w-10 h-10 text-[20px] bg-bg-2 border border-border rounded flex items-center justify-center hover:border-primary-cyan transition-all"
title="아이콘 변경"
>
{menu.icon}
</button>
{emojiPickerId === menu.id && (
<div ref={emojiPickerRef} className="absolute top-12 left-0 z-[300]">
<EmojiPicker
data={data}
onEmojiSelect={handleEmojiSelect}
theme="dark"
locale="kr"
previewPosition="none"
skinTonePosition="search"
perLine={8}
/>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<input
type="text"
value={menu.label}
onChange={(e) => updateMenuField(menu.id, 'label', e.target.value)}
className="w-full h-8 text-[13px] font-semibold font-korean bg-bg-2 border border-border rounded px-2 text-text-1 focus:border-primary-cyan focus:outline-none"
/>
<div className="text-[10px] text-text-3 font-mono mt-0.5">{menu.id}</div>
</div>
<button
onClick={() => { setEditingId(null); setEmojiPickerId(null) }}
className="shrink-0 px-2 py-1 text-[10px] font-semibold text-primary-cyan border border-primary-cyan rounded hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean"
>
</button>
</>
) : (
<>
<span className="text-[16px] shrink-0">{menu.icon}</span>
<div className="flex-1 min-w-0">
<div className={`text-[13px] font-semibold font-korean ${menu.enabled ? 'text-text-1' : 'text-text-3'}`}>
{menu.label}
</div>
<div className="text-[10px] text-text-3 font-mono">{menu.id}</div>
</div>
<button
onClick={() => setEditingId(menu.id)}
className="shrink-0 w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-[11px] flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all"
title="라벨/아이콘 편집"
>
</button>
</>
)}
</div> </div>
</div> <div className="flex items-center gap-3 ml-3 shrink-0">
<div className="flex items-center gap-3"> <button
<button onClick={() => toggleMenu(menu.id)}
onClick={() => toggleMenu(menu.id)} className={`relative w-10 h-5 rounded-full transition-all ${
className={`relative w-10 h-5 rounded-full transition-all ${ menu.enabled ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
menu.enabled ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
menu.enabled ? 'left-[22px]' : 'left-0.5'
}`} }`}
/>
</button>
<div className="flex gap-1">
<button
onClick={() => {
if (idx === 0) return
setMenus(prev => {
const arr = [...prev]
;[arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]]
return arr.map((m, i) => ({ ...m, order: i + 1 }))
})
}}
className="w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-xs flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all"
> >
<span
</button> className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
<button menu.enabled ? 'left-[22px]' : 'left-0.5'
onClick={() => { }`}
if (idx === menus.length - 1) return />
setMenus(prev => {
const arr = [...prev]
;[arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]]
return arr.map((m, i) => ({ ...m, order: i + 1 }))
})
}}
className="w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-xs flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all"
>
</button> </button>
<div className="flex gap-1">
<button
onClick={() => moveMenu(idx, -1)}
disabled={idx === 0}
className="w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-xs flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
>
</button>
<button
onClick={() => moveMenu(idx, 1)}
disabled={idx === menus.length - 1}
className="w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-xs flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
>
</button>
</div>
</div> </div>
</div> </div>
</div> )
))} })}
</div> </div>
</div> </div>
</div> </div>

파일 보기

@ -188,3 +188,22 @@ export async function updateOAuthSettingsApi(
const response = await api.put<OAuthSettings>('/settings/oauth', settings) const response = await api.put<OAuthSettings>('/settings/oauth', settings)
return response.data return response.data
} }
// 메뉴 설정 API
export interface MenuConfigItem {
id: string
label: string
icon: string
enabled: boolean
order: number
}
export async function fetchMenuConfig(): Promise<MenuConfigItem[]> {
const response = await api.get<MenuConfigItem[]>('/menus')
return response.data
}
export async function updateMenuConfigApi(menus: MenuConfigItem[]): Promise<MenuConfigItem[]> {
const response = await api.put<MenuConfigItem[]>('/menus', { menus })
return response.data
}

파일 보기

@ -0,0 +1,28 @@
import { create } from 'zustand'
import { fetchMenuConfig } from '../services/authApi'
import type { MenuConfigItem } from '../services/authApi'
interface MenuState {
menuConfig: MenuConfigItem[]
isLoaded: boolean
loadMenuConfig: () => Promise<void>
setMenuConfig: (config: MenuConfigItem[]) => void
}
export const useMenuStore = create<MenuState>((set) => ({
menuConfig: [],
isLoaded: false,
loadMenuConfig: async () => {
try {
const config = await fetchMenuConfig()
set({ menuConfig: config, isLoaded: true })
} catch {
set({ isLoaded: true })
}
},
setMenuConfig: (config: MenuConfigItem[]) => {
set({ menuConfig: config })
},
}))