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:
부모
b52d8097b0
커밋
fd48e755f2
13
backend/package-lock.json
generated
13
backend/package-lock.json
generated
@ -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,
|
||||||
|
|||||||
35
backend/src/menus/menuRouter.ts
Normal file
35
backend/src/menus/menuRouter.ts
Normal file
@ -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 배열)');
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
29
frontend/package-lock.json
generated
29
frontend/package-lock.json
generated
@ -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
|
||||||
|
}
|
||||||
|
|||||||
28
frontend/src/store/menuStore.ts
Normal file
28
frontend/src/store/menuStore.ts
Normal file
@ -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 })
|
||||||
|
},
|
||||||
|
}))
|
||||||
불러오는 중...
Reference in New Issue
Block a user