feat: UIMS 권한관리(시스템관리) 관리자 React 화면 추가
This commit is contained in:
parent
c327889a18
commit
0b95ef46fa
562
backend/src/main/resources/static/assets/index-B9c1ITWA.js
Normal file
562
backend/src/main/resources/static/assets/index-B9c1ITWA.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>GUARDiA CMS — AI 콘텐츠 관리</title>
|
<title>GUARDiA CMS — AI 콘텐츠 관리</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BLbMq7Xd.js"></script>
|
<script type="module" crossorigin src="/assets/index-B9c1ITWA.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CaipB8s8.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DQCKibZ6.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -30,6 +30,13 @@ import WorklogList from './pages/uiws/WorklogList'
|
|||||||
import ScheduleCalendar from './pages/uiws/ScheduleCalendar'
|
import ScheduleCalendar from './pages/uiws/ScheduleCalendar'
|
||||||
import MessageBox from './pages/uiws/MessageBox'
|
import MessageBox from './pages/uiws/MessageBox'
|
||||||
import StatsPivot from './pages/uiws/StatsPivot'
|
import StatsPivot from './pages/uiws/StatsPivot'
|
||||||
|
// UIWS 시스템관리(권한) — RBAC/공통코드/메뉴/부서/거래처 (SUPERADMIN 전용)
|
||||||
|
import RoleManagement from './pages/uiws/system/RoleManagement'
|
||||||
|
import RoleMenuPermission from './pages/uiws/system/RoleMenuPermission'
|
||||||
|
import CodeManagement from './pages/uiws/system/CodeManagement'
|
||||||
|
import MenuManagement from './pages/uiws/system/MenuManagement'
|
||||||
|
import DeptManagement from './pages/uiws/system/DeptManagement'
|
||||||
|
import CompanyManagement from './pages/uiws/system/CompanyManagement'
|
||||||
|
|
||||||
const qc = new QueryClient()
|
const qc = new QueryClient()
|
||||||
|
|
||||||
@ -74,6 +81,26 @@ export default function App() {
|
|||||||
<Route path="/uiws/schedule" element={<UiwsLayout><ScheduleCalendar /></UiwsLayout>} />
|
<Route path="/uiws/schedule" element={<UiwsLayout><ScheduleCalendar /></UiwsLayout>} />
|
||||||
<Route path="/uiws/message" element={<UiwsLayout><MessageBox /></UiwsLayout>} />
|
<Route path="/uiws/message" element={<UiwsLayout><MessageBox /></UiwsLayout>} />
|
||||||
<Route path="/uiws/stats" element={<UiwsLayout><StatsPivot /></UiwsLayout>} />
|
<Route path="/uiws/stats" element={<UiwsLayout><StatsPivot /></UiwsLayout>} />
|
||||||
|
|
||||||
|
{/* 시스템관리(권한) — UIWS RBAC/공통코드/메뉴/부서/거래처. /api/system/** = SUPERADMIN 전용 */}
|
||||||
|
<Route path="/uiws/system/roles" element={
|
||||||
|
<ProtectedRoute min="SUPERADMIN"><UiwsLayout><RoleManagement /></UiwsLayout></ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/uiws/system/role-menus" element={
|
||||||
|
<ProtectedRoute min="SUPERADMIN"><UiwsLayout><RoleMenuPermission /></UiwsLayout></ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/uiws/system/codes" element={
|
||||||
|
<ProtectedRoute min="SUPERADMIN"><UiwsLayout><CodeManagement /></UiwsLayout></ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/uiws/system/menus" element={
|
||||||
|
<ProtectedRoute min="SUPERADMIN"><UiwsLayout><MenuManagement /></UiwsLayout></ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/uiws/system/depts" element={
|
||||||
|
<ProtectedRoute min="SUPERADMIN"><UiwsLayout><DeptManagement /></UiwsLayout></ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/uiws/system/companies" element={
|
||||||
|
<ProtectedRoute min="SUPERADMIN"><UiwsLayout><CompanyManagement /></UiwsLayout></ProtectedRoute>
|
||||||
|
} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@ -73,3 +73,52 @@ export const confirmWorklogComment = (cmtId: number) => api.post(`/api/worklogs/
|
|||||||
// ── 통계(stats)
|
// ── 통계(stats)
|
||||||
export const personalWorkStats = (params: Record<string, unknown>) => api.get('/api/stats/personal-work', { params })
|
export const personalWorkStats = (params: Record<string, unknown>) => api.get('/api/stats/personal-work', { params })
|
||||||
export const companyWorkStats = (params: Record<string, unknown>) => api.get('/api/stats/company-work', { params })
|
export const companyWorkStats = (params: Record<string, unknown>) => api.get('/api/stats/company-work', { params })
|
||||||
|
|
||||||
|
// ── 시스템관리(권한관리 RBAC · 공통코드 · 메뉴 · 부서 · 거래처) ───────────────
|
||||||
|
// base /api/system/... · SUPERADMIN 전용. 응답 봉투 { success, message, data }.
|
||||||
|
// 목록 API 는 data 가 PageResponse { content, total, page, size } — 호출부는 res.data.data.content 접근.
|
||||||
|
// CMS 백엔드 계약은 OCR 과 다름(codes 는 그룹+값 일괄 저장, roles/menus/companies 삭제는 { ids } 본문, depts 삭제는 path).
|
||||||
|
|
||||||
|
// 역할(roles) — 목록(paged)/상세/등록/수정/다중삭제
|
||||||
|
export const roleList = (keyword?: string, page = 0, size = 100) =>
|
||||||
|
api.get('/api/system/roles', { params: { ...(keyword ? { keyword } : {}), page, size } })
|
||||||
|
export const roleDetail = (id: string) => api.get(`/api/system/roles/${encodeURIComponent(id)}`)
|
||||||
|
export const createRole = (body: object) => api.post('/api/system/roles', body)
|
||||||
|
export const updateRole = (id: string, body: object) => api.put(`/api/system/roles/${encodeURIComponent(id)}`, body)
|
||||||
|
export const deleteRoles = (ids: string[]) => api.delete('/api/system/roles', { data: { ids } })
|
||||||
|
|
||||||
|
// 역할-메뉴 권한(role-menus) — 역할목록(paged) / 권한별 메뉴매핑 조회·저장
|
||||||
|
export const roleMenuRoles = (page = 0, size = 100) => api.get('/api/system/role-menus', { params: { page, size } })
|
||||||
|
export const roleMenuMapping = (roleId: string) => api.get(`/api/system/roles/${encodeURIComponent(roleId)}/menus`)
|
||||||
|
export const saveRoleMenuMapping = (roleId: string, menus: object[]) =>
|
||||||
|
api.put(`/api/system/roles/${encodeURIComponent(roleId)}/menus`, { menus })
|
||||||
|
|
||||||
|
// 공통코드(codes) — 그룹목록(paged)/그룹상세(values 포함)/그룹+값 일괄 CRUD
|
||||||
|
export const codeGroupList = (keyword?: string, page = 0, size = 100) =>
|
||||||
|
api.get('/api/system/codes', { params: { ...(keyword ? { keyword } : {}), page, size } })
|
||||||
|
export const codeGroupDetail = (grpCd: string) => api.get(`/api/system/codes/${encodeURIComponent(grpCd)}`)
|
||||||
|
export const createCodeGroup = (body: object) => api.post('/api/system/codes', body)
|
||||||
|
export const updateCodeGroup = (grpCd: string, body: object) =>
|
||||||
|
api.put(`/api/system/codes/${encodeURIComponent(grpCd)}`, body)
|
||||||
|
export const deleteCodeGroup = (grpCd: string) => api.delete(`/api/system/codes/${encodeURIComponent(grpCd)}`)
|
||||||
|
|
||||||
|
// 메뉴(menus) — 목록(paged)/검색/등록/수정/다중삭제
|
||||||
|
export const menuList = (page = 0, size = 200) => api.get('/api/system/menus', { params: { page, size } })
|
||||||
|
export const menuSearch = (keyword?: string) => api.get('/api/system/menus/search', { params: keyword ? { keyword } : {} })
|
||||||
|
export const createMenu = (body: object) => api.post('/api/system/menus', body)
|
||||||
|
export const updateMenu = (id: string, body: object) => api.put(`/api/system/menus/${encodeURIComponent(id)}`, body)
|
||||||
|
export const deleteMenus = (ids: string[]) => api.delete('/api/system/menus', { data: { ids } })
|
||||||
|
|
||||||
|
// 부서(depts) — 목록(paged)/CRUD/단건삭제(path)
|
||||||
|
export const deptList = (keyword?: string, page = 0, size = 200) =>
|
||||||
|
api.get('/api/system/depts', { params: { ...(keyword ? { keyword } : {}), page, size } })
|
||||||
|
export const createDept = (body: object) => api.post('/api/system/depts', body)
|
||||||
|
export const updateDept = (id: string, body: object) => api.put(`/api/system/depts/${encodeURIComponent(id)}`, body)
|
||||||
|
export const deleteDept = (id: string) => api.delete(`/api/system/depts/${encodeURIComponent(id)}`)
|
||||||
|
|
||||||
|
// 거래처(companies) — 목록(paged)/CRUD/다중삭제
|
||||||
|
export const companyList = (keyword?: string, page = 0, size = 200) =>
|
||||||
|
api.get('/api/system/companies', { params: { ...(keyword ? { keyword } : {}), page, size } })
|
||||||
|
export const createCompany = (body: object) => api.post('/api/system/companies', body)
|
||||||
|
export const updateCompany = (id: string, body: object) => api.put(`/api/system/companies/${encodeURIComponent(id)}`, body)
|
||||||
|
export const deleteCompanies = (ids: string[]) => api.delete('/api/system/companies', { data: { ids } })
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
Languages, Search, FileInput, MessageSquareWarning, Share2, UsersRound,
|
Languages, Search, FileInput, MessageSquareWarning, Share2, UsersRound,
|
||||||
Sparkles, BarChart3, ShieldCheck, ScrollText, Settings, FileStack,
|
Sparkles, BarChart3, ShieldCheck, ScrollText, Settings, FileStack,
|
||||||
BookOpen, CalendarDays, Mail, Smartphone, Cpu,
|
BookOpen, CalendarDays, Mail, Smartphone, Cpu,
|
||||||
|
KeyRound, ListChecks, Hash, SquareMenu, Network, Building2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { currentRole, hasRole } from './rbac'
|
import { currentRole, hasRole } from './rbac'
|
||||||
import ThemeToggle from './uiws/ThemeToggle'
|
import ThemeToggle from './uiws/ThemeToggle'
|
||||||
@ -45,6 +46,15 @@ const uiwsLinks: Link[] = [
|
|||||||
{ to: '/uiws/message', label: '쪽지', icon: Mail },
|
{ to: '/uiws/message', label: '쪽지', icon: Mail },
|
||||||
{ to: '/uiws/stats', label: '업무통계', icon: BarChart3 },
|
{ to: '/uiws/stats', label: '업무통계', icon: BarChart3 },
|
||||||
]
|
]
|
||||||
|
// 시스템관리(권한) — UIWS RBAC/공통코드/메뉴/부서/거래처 (SUPERADMIN 전용)
|
||||||
|
const systemLinks: Link[] = [
|
||||||
|
{ to: '/uiws/system/roles', label: '권한 관리', icon: KeyRound, min: 'SUPERADMIN' },
|
||||||
|
{ to: '/uiws/system/role-menus', label: '역할-메뉴 권한', icon: ListChecks, min: 'SUPERADMIN' },
|
||||||
|
{ to: '/uiws/system/codes', label: '공통코드', icon: Hash, min: 'SUPERADMIN' },
|
||||||
|
{ to: '/uiws/system/menus', label: '메뉴 관리', icon: SquareMenu, min: 'SUPERADMIN' },
|
||||||
|
{ to: '/uiws/system/depts', label: '부서 관리', icon: Network, min: 'SUPERADMIN' },
|
||||||
|
{ to: '/uiws/system/companies', label: '거래처 관리', icon: Building2, min: 'SUPERADMIN' },
|
||||||
|
]
|
||||||
// 통합 메신저 앱(읽기전용 QR 설치) — 인증 사용자 전체
|
// 통합 메신저 앱(읽기전용 QR 설치) — 인증 사용자 전체
|
||||||
const appLinks: Link[] = [
|
const appLinks: Link[] = [
|
||||||
{ to: '/admin/mobile-app', label: '모바일 앱 설치', icon: Smartphone },
|
{ to: '/admin/mobile-app', label: '모바일 앱 설치', icon: Smartphone },
|
||||||
@ -86,6 +96,7 @@ export default function Sidebar() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const visibleAdmin = adminLinks.filter(l => hasRole(l.min || 'VIEWER', role))
|
const visibleAdmin = adminLinks.filter(l => hasRole(l.min || 'VIEWER', role))
|
||||||
|
const visibleSystem = systemLinks.filter(l => hasRole(l.min || 'VIEWER', role))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-60 bg-panel border-r border-edge flex flex-col">
|
<aside className="w-60 bg-panel border-r border-edge flex flex-col">
|
||||||
@ -103,6 +114,11 @@ export default function Sidebar() {
|
|||||||
<div className="border-t border-edge mt-3">
|
<div className="border-t border-edge mt-3">
|
||||||
<Section title="업무(UIWS)" links={uiwsLinks} />
|
<Section title="업무(UIWS)" links={uiwsLinks} />
|
||||||
</div>
|
</div>
|
||||||
|
{visibleSystem.length > 0 && (
|
||||||
|
<div className="border-t border-edge mt-3">
|
||||||
|
<Section title="시스템관리(권한)" links={visibleSystem} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="border-t border-edge mt-3">
|
<div className="border-t border-edge mt-3">
|
||||||
<Section title="모바일" links={appLinks} />
|
<Section title="모바일" links={appLinks} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
237
frontend/src/pages/uiws/system/CodeManagement.tsx
Normal file
237
frontend/src/pages/uiws/system/CodeManagement.tsx
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
codeGroupList, codeGroupDetail, createCodeGroup, updateCodeGroup, deleteCodeGroup,
|
||||||
|
} from '../../../api/uiws'
|
||||||
|
import {
|
||||||
|
PageHeader, Panel, SearchBar, Input, Button, DataGrid, Modal, FormField, Spinner, YnBadge, type Column,
|
||||||
|
} from '../../../components/uiws/ui'
|
||||||
|
|
||||||
|
interface CodeGrp extends Record<string, unknown> { grpCd: string; grpNm: string; useYn: string }
|
||||||
|
interface CodeValue extends Record<string, unknown> { grpCd?: string; codeVal: string; codeNm: string; sortOrd: number; useYn: string }
|
||||||
|
interface CodeGrpDetail extends CodeGrp { values: CodeValue[] }
|
||||||
|
|
||||||
|
function friendlyError(e: any): string {
|
||||||
|
if (e?.response?.status === 403) return '권한이 없습니다 (SUPERADMIN 전용).'
|
||||||
|
if (e?.response?.status === 409) return '이미 존재하는 코드입니다.'
|
||||||
|
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CodeManagement() {
|
||||||
|
const [groups, setGroups] = useState<CodeGrp[]>([])
|
||||||
|
const [activeGrp, setActiveGrp] = useState<string>('')
|
||||||
|
const [detail, setDetail] = useState<CodeGrpDetail | null>(null)
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [loadingGrp, setLoadingGrp] = useState(false)
|
||||||
|
const [loadingDetail, setLoadingDetail] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [form, setForm] = useState<{ open: boolean; edit: CodeGrpDetail | null }>({ open: false, edit: null })
|
||||||
|
|
||||||
|
const loadGroups = async (kw = '') => {
|
||||||
|
setLoadingGrp(true); setErr('')
|
||||||
|
try {
|
||||||
|
const res = await codeGroupList(kw || undefined)
|
||||||
|
// CMS 백엔드 그룹목록은 PageResponse → data.content
|
||||||
|
const list: CodeGrp[] = res.data.data?.content ?? []
|
||||||
|
setGroups(list)
|
||||||
|
if (!activeGrp && list.length > 0) setActiveGrp(list[0].grpCd)
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoadingGrp(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { loadGroups('') }, [])
|
||||||
|
|
||||||
|
const loadDetail = async (grp = activeGrp) => {
|
||||||
|
if (!grp) { setDetail(null); return }
|
||||||
|
setLoadingDetail(true)
|
||||||
|
try {
|
||||||
|
const res = await codeGroupDetail(grp)
|
||||||
|
const d = res.data.data
|
||||||
|
setDetail(d ? { ...d, values: d.values ?? [] } : null)
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoadingDetail(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { loadDetail() }, [activeGrp])
|
||||||
|
|
||||||
|
const removeGroup = async (grpCd: string) => {
|
||||||
|
if (!window.confirm(`코드그룹 '${grpCd}' 와 하위 코드를 삭제하시겠습니까?`)) return
|
||||||
|
try {
|
||||||
|
await deleteCodeGroup(grpCd)
|
||||||
|
if (activeGrp === grpCd) { setActiveGrp(''); setDetail(null) }
|
||||||
|
await loadGroups(keyword)
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeColumns: Column<CodeValue>[] = [
|
||||||
|
{ key: 'codeVal', header: '코드값', width: 170 },
|
||||||
|
{ key: 'codeNm', header: '코드명' },
|
||||||
|
{ key: 'sortOrd', header: '정렬', width: 70, align: 'center' },
|
||||||
|
{ key: 'useYn', header: '사용', width: 80, align: 'center', render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="uiws-scope">
|
||||||
|
<PageHeader title="공통코드 관리" subtitle="시스템관리(권한) · 코드그룹/코드값 일괄 CRUD"
|
||||||
|
actions={<Button onClick={() => setForm({ open: true, edit: null })}>+ 코드그룹 등록</Button>} />
|
||||||
|
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginBottom: 12 }}>{err}</div>}
|
||||||
|
|
||||||
|
<SearchBar>
|
||||||
|
<Input value={keyword} onChange={setKeyword} placeholder="코드그룹 검색" style={{ minWidth: 220 }} />
|
||||||
|
<Button variant="ghost" onClick={() => loadGroups(keyword)}>검색</Button>
|
||||||
|
</SearchBar>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '300px 1fr', gap: 16, alignItems: 'start' }}>
|
||||||
|
<Panel style={{ padding: 0 }}>
|
||||||
|
<div style={{ padding: '12px 16px', fontSize: 12, fontWeight: 600, color: 'var(--uiws-text-muted)', borderBottom: '1px solid var(--uiws-border)' }}>
|
||||||
|
코드그룹
|
||||||
|
</div>
|
||||||
|
{loadingGrp ? <Spinner /> : (
|
||||||
|
<div>
|
||||||
|
{groups.length === 0 && <div style={{ padding: 20, fontSize: 13, color: 'var(--uiws-text-faint)' }}>코드그룹이 없습니다.</div>}
|
||||||
|
{groups.map(g => (
|
||||||
|
<div key={g.grpCd} onClick={() => setActiveGrp(g.grpCd)}
|
||||||
|
style={{
|
||||||
|
padding: '11px 16px', cursor: 'pointer', fontSize: 13, display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
borderLeft: `3px solid ${activeGrp === g.grpCd ? 'var(--uiws-primary)' : 'transparent'}`,
|
||||||
|
background: activeGrp === g.grpCd ? 'var(--uiws-primary-soft)' : 'transparent',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, color: activeGrp === g.grpCd ? 'var(--uiws-primary)' : 'var(--uiws-text)' }}>{g.grpNm}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--uiws-text-muted)' }}>{g.grpCd}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<Button variant="ghost" onClick={() => removeGroup(g.grpCd)}>삭제</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--uiws-text)' }}>
|
||||||
|
코드값 {activeGrp && <span style={{ color: 'var(--uiws-text-muted)', fontWeight: 400 }}>({activeGrp})</span>}
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => detail && setForm({ open: true, edit: detail })} disabled={!detail}>그룹·코드값 편집</Button>
|
||||||
|
</div>
|
||||||
|
{loadingDetail ? <Spinner /> : (
|
||||||
|
<DataGrid columns={codeColumns} rows={detail?.values ?? []} rowKey={r => `${activeGrp}:${r.codeVal}`}
|
||||||
|
empty={activeGrp ? '코드값이 없습니다.' : '코드그룹을 선택하세요.'} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.open && (
|
||||||
|
<CodeGroupForm grp={form.edit} onClose={() => setForm({ open: false, edit: null })}
|
||||||
|
onSaved={async (grpCd) => {
|
||||||
|
setForm({ open: false, edit: null })
|
||||||
|
await loadGroups(keyword)
|
||||||
|
if (grpCd) { setActiveGrp(grpCd); await loadDetail(grpCd) }
|
||||||
|
else await loadDetail()
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectYn({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
|
return (
|
||||||
|
<select value={value} onChange={e => onChange(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, fontSize: 13,
|
||||||
|
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)' }}>
|
||||||
|
<option value="Y">사용</option>
|
||||||
|
<option value="N">미사용</option>
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeGroupForm({ grp, onClose, onSaved }: { grp: CodeGrpDetail | null; onClose: () => void; onSaved: (grpCd: string) => void }) {
|
||||||
|
const editing = !!grp
|
||||||
|
const [grpCd, setGrpCd] = useState(grp?.grpCd ?? '')
|
||||||
|
const [grpNm, setGrpNm] = useState(grp?.grpNm ?? '')
|
||||||
|
const [useYn, setUseYn] = useState(grp?.useYn ?? 'Y')
|
||||||
|
const [values, setValues] = useState<CodeValue[]>(grp?.values ? grp.values.map(v => ({ ...v })) : [])
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const addValue = () => setValues(prev => [...prev, { codeVal: '', codeNm: '', sortOrd: prev.length + 1, useYn: 'Y' }])
|
||||||
|
const updateValue = (idx: number, field: keyof CodeValue, val: string | number) =>
|
||||||
|
setValues(prev => prev.map((v, i) => (i === idx ? { ...v, [field]: val } : v)))
|
||||||
|
const removeValue = (idx: number) => setValues(prev => prev.filter((_, i) => i !== idx))
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setErr('')
|
||||||
|
if (!editing && !grpCd.trim()) { setErr('그룹코드는 필수입니다.'); return }
|
||||||
|
if (!grpNm.trim()) { setErr('그룹명은 필수입니다.'); return }
|
||||||
|
for (const v of values) {
|
||||||
|
if (!v.codeVal.trim() || !v.codeNm.trim()) { setErr('코드값과 코드명은 필수입니다.'); return }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
grpCd, grpNm, useYn,
|
||||||
|
values: values.map(v => ({ grpCd, codeVal: v.codeVal, codeNm: v.codeNm, sortOrd: Number(v.sortOrd) || 0, useYn: v.useYn })),
|
||||||
|
}
|
||||||
|
editing ? await updateCodeGroup(grpCd, body) : await createCodeGroup(body)
|
||||||
|
onSaved(grpCd)
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const cellInput: React.CSSProperties = {
|
||||||
|
width: '100%', padding: '6px 8px', borderRadius: 6, fontSize: 12,
|
||||||
|
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={editing ? '코드그룹·코드값 편집' : '코드그룹 등록'} onClose={onClose}
|
||||||
|
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
||||||
|
<FormField label="그룹코드"><Input value={grpCd} onChange={setGrpCd} style={{ width: '100%', opacity: editing ? 0.6 : 1 }} /></FormField>
|
||||||
|
<FormField label="그룹명"><Input value={grpNm} onChange={setGrpNm} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="사용 여부"><SelectYn value={useYn} onChange={setUseYn} /></FormField>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 8, marginBottom: 8 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--uiws-text-muted)' }}>코드값 ({values.length})</div>
|
||||||
|
<Button variant="ghost" onClick={addValue}>+ 코드값 추가</Button>
|
||||||
|
</div>
|
||||||
|
<div style={{ border: '1px solid var(--uiws-border)', borderRadius: 8, overflow: 'hidden' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, color: 'var(--uiws-text)' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: 'var(--uiws-surface-2)' }}>
|
||||||
|
<th style={{ padding: '7px 8px', textAlign: 'left', color: 'var(--uiws-text-muted)', fontWeight: 600 }}>코드값</th>
|
||||||
|
<th style={{ padding: '7px 8px', textAlign: 'left', color: 'var(--uiws-text-muted)', fontWeight: 600 }}>코드명</th>
|
||||||
|
<th style={{ padding: '7px 8px', width: 64, color: 'var(--uiws-text-muted)', fontWeight: 600 }}>정렬</th>
|
||||||
|
<th style={{ padding: '7px 8px', width: 76, color: 'var(--uiws-text-muted)', fontWeight: 600 }}>사용</th>
|
||||||
|
<th style={{ padding: '7px 8px', width: 44 }} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{values.length === 0 ? (
|
||||||
|
<tr><td colSpan={5} style={{ padding: 16, textAlign: 'center', color: 'var(--uiws-text-faint)' }}>코드값이 없습니다.</td></tr>
|
||||||
|
) : values.map((v, idx) => (
|
||||||
|
<tr key={idx} style={{ borderTop: '1px solid var(--uiws-border)' }}>
|
||||||
|
<td style={{ padding: '5px 8px' }}>
|
||||||
|
<input value={v.codeVal} onChange={e => updateValue(idx, 'codeVal', e.target.value)} style={cellInput} />
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '5px 8px' }}>
|
||||||
|
<input value={v.codeNm} onChange={e => updateValue(idx, 'codeNm', e.target.value)} style={cellInput} />
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '5px 8px' }}>
|
||||||
|
<input type="number" value={String(v.sortOrd)} onChange={e => updateValue(idx, 'sortOrd', Number(e.target.value) || 0)} style={cellInput} />
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '5px 8px' }}>
|
||||||
|
<select value={v.useYn} onChange={e => updateValue(idx, 'useYn', e.target.value)} style={cellInput}>
|
||||||
|
<option value="Y">사용</option>
|
||||||
|
<option value="N">미사용</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '5px 8px', textAlign: 'center' }}>
|
||||||
|
<button onClick={() => removeValue(idx)}
|
||||||
|
style={{ background: 'none', border: 'none', color: 'var(--uiws-danger)', cursor: 'pointer', fontSize: 16 }}>×</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginTop: 10 }}>{err}</div>}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
frontend/src/pages/uiws/system/CompanyManagement.tsx
Normal file
110
frontend/src/pages/uiws/system/CompanyManagement.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { companyList, createCompany, updateCompany, deleteCompanies } from '../../../api/uiws'
|
||||||
|
import {
|
||||||
|
PageHeader, SearchBar, Input, Button, DataGrid, Modal, FormField, Spinner, YnBadge, type Column,
|
||||||
|
} from '../../../components/uiws/ui'
|
||||||
|
|
||||||
|
interface Company extends Record<string, unknown> {
|
||||||
|
companyId: string; companyNm: string; bizNo: string | null; useYn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlyError(e: any): string {
|
||||||
|
if (e?.response?.status === 403) return '권한이 없습니다 (SUPERADMIN 전용).'
|
||||||
|
if (e?.response?.status === 409) return '이미 존재하는 거래처ID입니다.'
|
||||||
|
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompanyManagement() {
|
||||||
|
const [rows, setRows] = useState<Company[]>([])
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [form, setForm] = useState<{ open: boolean; edit: Company | null }>({ open: false, edit: null })
|
||||||
|
|
||||||
|
const load = async (kw = '') => {
|
||||||
|
setLoading(true); setErr('')
|
||||||
|
try {
|
||||||
|
const res = await companyList(kw || undefined)
|
||||||
|
// CMS 백엔드 거래처목록은 PageResponse → data.content
|
||||||
|
setRows(res.data.data?.content ?? [])
|
||||||
|
}
|
||||||
|
catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { load('') }, [])
|
||||||
|
|
||||||
|
const remove = async (c: Company) => {
|
||||||
|
if (!window.confirm(`거래처 '${c.companyNm}' 를 삭제하시겠습니까?`)) return
|
||||||
|
try { await deleteCompanies([c.companyId]); await load(keyword) } catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<Company>[] = [
|
||||||
|
{ key: 'companyId', header: '거래처ID', width: 180 },
|
||||||
|
{ key: 'companyNm', header: '거래처명' },
|
||||||
|
{ key: 'bizNo', header: '사업자번호', width: 180, render: r => r.bizNo ?? '-' },
|
||||||
|
{ key: 'useYn', header: '사용', width: 80, align: 'center', render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
|
||||||
|
{ key: 'act', header: '', width: 60, align: 'center', render: r => <Button variant="ghost" onClick={() => remove(r)}>삭제</Button> },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="uiws-scope">
|
||||||
|
<PageHeader title="거래처 관리" subtitle="시스템관리(권한) · 거래처(근무처) 등록/수정/삭제"
|
||||||
|
actions={<Button onClick={() => setForm({ open: true, edit: null })}>+ 거래처 등록</Button>} />
|
||||||
|
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginBottom: 12 }}>{err}</div>}
|
||||||
|
|
||||||
|
<SearchBar>
|
||||||
|
<Input value={keyword} onChange={setKeyword} placeholder="거래처명 검색" style={{ minWidth: 220 }} />
|
||||||
|
<Button variant="ghost" onClick={() => load(keyword)}>검색</Button>
|
||||||
|
</SearchBar>
|
||||||
|
|
||||||
|
{loading ? <Spinner /> : (
|
||||||
|
<DataGrid columns={columns} rows={rows} rowKey={r => r.companyId}
|
||||||
|
onRowClick={r => setForm({ open: true, edit: r })} empty="등록된 거래처가 없습니다." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.open && (
|
||||||
|
<CompanyForm company={form.edit} onClose={() => setForm({ open: false, edit: null })}
|
||||||
|
onSaved={() => { setForm({ open: false, edit: null }); load(keyword) }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompanyForm({ company, onClose, onSaved }: { company: Company | null; onClose: () => void; onSaved: () => void }) {
|
||||||
|
const editing = !!company
|
||||||
|
const [companyId, setCompanyId] = useState(company?.companyId ?? '')
|
||||||
|
const [companyNm, setCompanyNm] = useState(company?.companyNm ?? '')
|
||||||
|
const [bizNo, setBizNo] = useState(company?.bizNo ?? '')
|
||||||
|
const [useYn, setUseYn] = useState(company?.useYn ?? 'Y')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setErr('')
|
||||||
|
if (!editing && !companyId.trim()) { setErr('거래처ID는 필수입니다.'); return }
|
||||||
|
if (!companyNm.trim()) { setErr('거래처명은 필수입니다.'); return }
|
||||||
|
try {
|
||||||
|
const body = { companyId, companyNm, bizNo: bizNo || null, useYn }
|
||||||
|
editing ? await updateCompany(companyId, body) : await createCompany(body)
|
||||||
|
onSaved()
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={editing ? '거래처 수정' : '거래처 등록'} onClose={onClose}
|
||||||
|
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
||||||
|
<FormField label="거래처ID"><Input value={companyId} onChange={setCompanyId} style={{ width: '100%', opacity: editing ? 0.6 : 1 }} /></FormField>
|
||||||
|
<FormField label="거래처명"><Input value={companyNm} onChange={setCompanyNm} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="사업자번호"><Input value={bizNo ?? ''} onChange={setBizNo} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="사용 여부">
|
||||||
|
<select value={useYn} onChange={e => setUseYn(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, fontSize: 13,
|
||||||
|
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)' }}>
|
||||||
|
<option value="Y">사용</option>
|
||||||
|
<option value="N">미사용</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
144
frontend/src/pages/uiws/system/DeptManagement.tsx
Normal file
144
frontend/src/pages/uiws/system/DeptManagement.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { deptList, createDept, updateDept, deleteDept } from '../../../api/uiws'
|
||||||
|
import {
|
||||||
|
PageHeader, SearchBar, Input, Button, DataGrid, Modal, FormField, Spinner, YnBadge, type Column,
|
||||||
|
} from '../../../components/uiws/ui'
|
||||||
|
|
||||||
|
interface Dept extends Record<string, unknown> {
|
||||||
|
deptId: string; deptNm: string; parentDeptId: string | null; sortOrd: number; useYn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlyError(e: any): string {
|
||||||
|
if (e?.response?.status === 403) return '권한이 없습니다 (SUPERADMIN 전용).'
|
||||||
|
if (e?.response?.status === 409) return '이미 존재하는 부서ID입니다.'
|
||||||
|
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeptManagement() {
|
||||||
|
const [rows, setRows] = useState<Dept[]>([])
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [form, setForm] = useState<{ open: boolean; edit: Dept | null }>({ open: false, edit: null })
|
||||||
|
|
||||||
|
const load = async (kw = '') => {
|
||||||
|
setLoading(true); setErr('')
|
||||||
|
try {
|
||||||
|
const res = await deptList(kw || undefined)
|
||||||
|
// CMS 백엔드 부서목록은 PageResponse → data.content
|
||||||
|
setRows(res.data.data?.content ?? [])
|
||||||
|
}
|
||||||
|
catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { load('') }, [])
|
||||||
|
|
||||||
|
const ordered = useMemo(() => {
|
||||||
|
const roots = rows.filter(r => !r.parentDeptId).sort((a, b) => (a.sortOrd ?? 0) - (b.sortOrd ?? 0))
|
||||||
|
const out: (Dept & { _depth: number })[] = []
|
||||||
|
for (const root of roots) {
|
||||||
|
out.push({ ...root, _depth: 0 })
|
||||||
|
rows.filter(r => r.parentDeptId === root.deptId)
|
||||||
|
.sort((a, b) => (a.sortOrd ?? 0) - (b.sortOrd ?? 0))
|
||||||
|
.forEach(c => out.push({ ...c, _depth: 1 }))
|
||||||
|
}
|
||||||
|
const seen = new Set(out.map(o => o.deptId))
|
||||||
|
rows.filter(r => !seen.has(r.deptId)).forEach(m => out.push({ ...m, _depth: 0 }))
|
||||||
|
return out
|
||||||
|
}, [rows])
|
||||||
|
|
||||||
|
const remove = async (d: Dept) => {
|
||||||
|
if (!window.confirm(`부서 '${d.deptNm}' 를 삭제하시겠습니까?`)) return
|
||||||
|
try { await deleteDept(d.deptId); await load(keyword) } catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<Dept & { _depth: number }>[] = [
|
||||||
|
{
|
||||||
|
key: 'deptNm', header: '부서명',
|
||||||
|
render: r => (
|
||||||
|
<span style={{ paddingLeft: r._depth * 22 }}>
|
||||||
|
{r._depth > 0 && <span style={{ color: 'var(--uiws-text-faint)' }}>└ </span>}
|
||||||
|
{r.deptNm} <span style={{ color: 'var(--uiws-text-muted)', fontSize: 12 }}>({r.deptId})</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'sortOrd', header: '정렬', width: 70, align: 'center' },
|
||||||
|
{ key: 'useYn', header: '사용', width: 80, align: 'center', render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
|
||||||
|
{ key: 'act', header: '', width: 60, align: 'center', render: r => <Button variant="ghost" onClick={() => remove(r)}>삭제</Button> },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="uiws-scope">
|
||||||
|
<PageHeader title="부서 관리" subtitle="시스템관리(권한) · 부서 트리(2-depth) 등록/수정/삭제"
|
||||||
|
actions={<Button onClick={() => setForm({ open: true, edit: null })}>+ 부서 등록</Button>} />
|
||||||
|
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginBottom: 12 }}>{err}</div>}
|
||||||
|
|
||||||
|
<SearchBar>
|
||||||
|
<Input value={keyword} onChange={setKeyword} placeholder="부서명 검색" style={{ minWidth: 220 }} />
|
||||||
|
<Button variant="ghost" onClick={() => load(keyword)}>검색</Button>
|
||||||
|
</SearchBar>
|
||||||
|
|
||||||
|
{loading ? <Spinner /> : (
|
||||||
|
<DataGrid columns={columns} rows={ordered} rowKey={r => r.deptId}
|
||||||
|
onRowClick={r => setForm({ open: true, edit: r })} empty="등록된 부서가 없습니다." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.open && (
|
||||||
|
<DeptForm dept={form.edit} parents={rows.filter(r => !r.parentDeptId)}
|
||||||
|
onClose={() => setForm({ open: false, edit: null })}
|
||||||
|
onSaved={() => { setForm({ open: false, edit: null }); load(keyword) }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeptForm({ dept, parents, onClose, onSaved }: {
|
||||||
|
dept: Dept | null; parents: Dept[]; onClose: () => void; onSaved: () => void
|
||||||
|
}) {
|
||||||
|
const editing = !!dept
|
||||||
|
const [deptId, setDeptId] = useState(dept?.deptId ?? '')
|
||||||
|
const [deptNm, setDeptNm] = useState(dept?.deptNm ?? '')
|
||||||
|
const [parentDeptId, setParentDeptId] = useState(dept?.parentDeptId ?? '')
|
||||||
|
const [sortOrd, setSortOrd] = useState(String(dept?.sortOrd ?? 0))
|
||||||
|
const [useYn, setUseYn] = useState(dept?.useYn ?? 'Y')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const selStyle = {
|
||||||
|
width: '100%', padding: '9px 12px', borderRadius: 8, fontSize: 13,
|
||||||
|
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setErr('')
|
||||||
|
if (!editing && !deptId.trim()) { setErr('부서ID는 필수입니다.'); return }
|
||||||
|
if (!deptNm.trim()) { setErr('부서명은 필수입니다.'); return }
|
||||||
|
try {
|
||||||
|
const body = { deptId, deptNm, parentDeptId: parentDeptId || null, sortOrd: Number(sortOrd) || 0, useYn }
|
||||||
|
editing ? await updateDept(deptId, body) : await createDept(body)
|
||||||
|
onSaved()
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={editing ? '부서 수정' : '부서 등록'} onClose={onClose}
|
||||||
|
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
||||||
|
<FormField label="부서ID"><Input value={deptId} onChange={setDeptId} style={{ width: '100%', opacity: editing ? 0.6 : 1 }} /></FormField>
|
||||||
|
<FormField label="부서명"><Input value={deptNm} onChange={setDeptNm} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="상위 부서">
|
||||||
|
<select value={parentDeptId ?? ''} onChange={e => setParentDeptId(e.target.value)} style={selStyle}>
|
||||||
|
<option value="">(최상위)</option>
|
||||||
|
{parents.filter(p => p.deptId !== deptId).map(p => <option key={p.deptId} value={p.deptId}>{p.deptNm} ({p.deptId})</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="정렬순서"><Input type="number" value={sortOrd} onChange={setSortOrd} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="사용 여부">
|
||||||
|
<select value={useYn} onChange={e => setUseYn(e.target.value)} style={selStyle}>
|
||||||
|
<option value="Y">사용</option>
|
||||||
|
<option value="N">미사용</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
162
frontend/src/pages/uiws/system/MenuManagement.tsx
Normal file
162
frontend/src/pages/uiws/system/MenuManagement.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { menuList, menuSearch, createMenu, updateMenu, deleteMenus } from '../../../api/uiws'
|
||||||
|
import {
|
||||||
|
PageHeader, SearchBar, Input, Button, DataGrid, Modal, FormField, Spinner, YnBadge, type Column,
|
||||||
|
} from '../../../components/uiws/ui'
|
||||||
|
|
||||||
|
interface Menu extends Record<string, unknown> {
|
||||||
|
menuId: string; menuNm: string; parentMenuId: string | null; programId: string | null
|
||||||
|
menuUrl: string | null; sortOrd: number; useYn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlyError(e: any): string {
|
||||||
|
if (e?.response?.status === 403) return '권한이 없습니다 (SUPERADMIN 전용).'
|
||||||
|
if (e?.response?.status === 409) return '이미 존재하는 메뉴ID입니다.'
|
||||||
|
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MenuManagement() {
|
||||||
|
const [rows, setRows] = useState<Menu[]>([])
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [form, setForm] = useState<{ open: boolean; edit: Menu | null }>({ open: false, edit: null })
|
||||||
|
|
||||||
|
const load = async (kw = '') => {
|
||||||
|
setLoading(true); setErr('')
|
||||||
|
try {
|
||||||
|
// 키워드 없으면 전체(paged) 목록, 있으면 검색 엔드포인트(List)
|
||||||
|
if (kw) {
|
||||||
|
const res = await menuSearch(kw)
|
||||||
|
setRows(res.data.data ?? [])
|
||||||
|
} else {
|
||||||
|
const res = await menuList()
|
||||||
|
setRows(res.data.data?.content ?? [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { load('') }, [])
|
||||||
|
|
||||||
|
// 2-depth: 부모 → 자식 순으로 정렬해 들여쓰기 표시.
|
||||||
|
const ordered = useMemo(() => {
|
||||||
|
const roots = rows.filter(r => !r.parentMenuId).sort((a, b) => (a.sortOrd ?? 0) - (b.sortOrd ?? 0))
|
||||||
|
const out: { menu: Menu; depth: number }[] = []
|
||||||
|
for (const root of roots) {
|
||||||
|
out.push({ menu: root, depth: 0 })
|
||||||
|
rows.filter(r => r.parentMenuId === root.menuId)
|
||||||
|
.sort((a, b) => (a.sortOrd ?? 0) - (b.sortOrd ?? 0))
|
||||||
|
.forEach(c => out.push({ menu: c, depth: 1 }))
|
||||||
|
}
|
||||||
|
// 부모가 목록에 없는 고아 항목도 표시
|
||||||
|
const seen = new Set(out.map(o => o.menu.menuId))
|
||||||
|
rows.filter(r => !seen.has(r.menuId)).forEach(m => out.push({ menu: m, depth: 0 }))
|
||||||
|
return out.map(o => ({ ...o.menu, _depth: o.depth })) as (Menu & { _depth: number })[]
|
||||||
|
}, [rows])
|
||||||
|
|
||||||
|
const remove = async (m: Menu) => {
|
||||||
|
if (!window.confirm(`메뉴 '${m.menuNm}' 를 삭제하시겠습니까?`)) return
|
||||||
|
try { await deleteMenus([m.menuId]); await load(keyword) }
|
||||||
|
catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<Menu & { _depth: number }>[] = [
|
||||||
|
{
|
||||||
|
key: 'menuNm', header: '메뉴명',
|
||||||
|
render: r => (
|
||||||
|
<span style={{ paddingLeft: r._depth * 22 }}>
|
||||||
|
{r._depth > 0 && <span style={{ color: 'var(--uiws-text-faint)' }}>└ </span>}
|
||||||
|
{r.menuNm} <span style={{ color: 'var(--uiws-text-muted)', fontSize: 12 }}>({r.menuId})</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'menuUrl', header: 'URL', width: 220, render: r => r.menuUrl ?? '-' },
|
||||||
|
{ key: 'programId', header: '프로그램', width: 140, render: r => r.programId ?? '-' },
|
||||||
|
{ key: 'sortOrd', header: '정렬', width: 70, align: 'center' },
|
||||||
|
{ key: 'useYn', header: '사용', width: 80, align: 'center', render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
|
||||||
|
{ key: 'act', header: '', width: 60, align: 'center', render: r => <Button variant="ghost" onClick={() => remove(r)}>삭제</Button> },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="uiws-scope">
|
||||||
|
<PageHeader title="메뉴 관리" subtitle="시스템관리(권한) · 메뉴 트리(2-depth) 등록/수정/삭제"
|
||||||
|
actions={<Button onClick={() => setForm({ open: true, edit: null })}>+ 메뉴 등록</Button>} />
|
||||||
|
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginBottom: 12 }}>{err}</div>}
|
||||||
|
|
||||||
|
<SearchBar>
|
||||||
|
<Input value={keyword} onChange={setKeyword} placeholder="메뉴명 검색" style={{ minWidth: 220 }} />
|
||||||
|
<Button variant="ghost" onClick={() => load(keyword)}>검색</Button>
|
||||||
|
</SearchBar>
|
||||||
|
|
||||||
|
{loading ? <Spinner /> : (
|
||||||
|
<DataGrid columns={columns} rows={ordered} rowKey={r => r.menuId}
|
||||||
|
onRowClick={r => setForm({ open: true, edit: r })} empty="등록된 메뉴가 없습니다." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.open && (
|
||||||
|
<MenuForm menu={form.edit} parents={rows.filter(r => !r.parentMenuId)}
|
||||||
|
onClose={() => setForm({ open: false, edit: null })}
|
||||||
|
onSaved={() => { setForm({ open: false, edit: null }); load(keyword) }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuForm({ menu, parents, onClose, onSaved }: {
|
||||||
|
menu: Menu | null; parents: Menu[]; onClose: () => void; onSaved: () => void
|
||||||
|
}) {
|
||||||
|
const editing = !!menu
|
||||||
|
const [menuId, setMenuId] = useState(menu?.menuId ?? '')
|
||||||
|
const [menuNm, setMenuNm] = useState(menu?.menuNm ?? '')
|
||||||
|
const [parentMenuId, setParentMenuId] = useState(menu?.parentMenuId ?? '')
|
||||||
|
const [programId, setProgramId] = useState(menu?.programId ?? '')
|
||||||
|
const [menuUrl, setMenuUrl] = useState(menu?.menuUrl ?? '')
|
||||||
|
const [sortOrd, setSortOrd] = useState(String(menu?.sortOrd ?? 0))
|
||||||
|
const [useYn, setUseYn] = useState(menu?.useYn ?? 'Y')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setErr('')
|
||||||
|
if (!editing && !menuId.trim()) { setErr('메뉴ID는 필수입니다.'); return }
|
||||||
|
if (!menuNm.trim()) { setErr('메뉴명은 필수입니다.'); return }
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
menuId, menuNm, parentMenuId: parentMenuId || null, programId: programId || null,
|
||||||
|
menuUrl: menuUrl || null, sortOrd: Number(sortOrd) || 0, useYn,
|
||||||
|
}
|
||||||
|
editing ? await updateMenu(menuId, body) : await createMenu(body)
|
||||||
|
onSaved()
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const selStyle = {
|
||||||
|
width: '100%', padding: '9px 12px', borderRadius: 8, fontSize: 13,
|
||||||
|
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={editing ? '메뉴 수정' : '메뉴 등록'} onClose={onClose}
|
||||||
|
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
||||||
|
<FormField label="메뉴ID"><Input value={menuId} onChange={setMenuId} style={{ width: '100%', opacity: editing ? 0.6 : 1 }} /></FormField>
|
||||||
|
<FormField label="메뉴명"><Input value={menuNm} onChange={setMenuNm} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="상위 메뉴">
|
||||||
|
<select value={parentMenuId ?? ''} onChange={e => setParentMenuId(e.target.value)} style={selStyle}>
|
||||||
|
<option value="">(최상위)</option>
|
||||||
|
{parents.filter(p => p.menuId !== menuId).map(p => <option key={p.menuId} value={p.menuId}>{p.menuNm} ({p.menuId})</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="프로그램ID"><Input value={programId ?? ''} onChange={setProgramId} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="메뉴 URL"><Input value={menuUrl ?? ''} onChange={setMenuUrl} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="정렬순서"><Input type="number" value={sortOrd} onChange={setSortOrd} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="사용 여부">
|
||||||
|
<select value={useYn} onChange={e => setUseYn(e.target.value)} style={selStyle}>
|
||||||
|
<option value="Y">사용</option>
|
||||||
|
<option value="N">미사용</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
132
frontend/src/pages/uiws/system/RoleManagement.tsx
Normal file
132
frontend/src/pages/uiws/system/RoleManagement.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
roleList, createRole, updateRole, deleteRoles,
|
||||||
|
} from '../../../api/uiws'
|
||||||
|
import {
|
||||||
|
PageHeader, SearchBar, Input, Button, DataGrid, Modal, FormField, Spinner, YnBadge, type Column,
|
||||||
|
} from '../../../components/uiws/ui'
|
||||||
|
|
||||||
|
interface RoleRow extends Record<string, unknown> {
|
||||||
|
roleId: string; roleNm: string; roleDesc: string; useYn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlyError(e: any): string {
|
||||||
|
if (e?.response?.status === 403) return '권한이 없습니다 (SUPERADMIN 전용).'
|
||||||
|
if (e?.response?.status === 409) return '이미 존재하는 권한ID입니다.'
|
||||||
|
return e?.response?.data?.message || '요청을 처리하지 못했습니다. 잠시 후 다시 시도하세요.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoleManagement() {
|
||||||
|
const [rows, setRows] = useState<RoleRow[]>([])
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
const [edit, setEdit] = useState<RoleRow | null>(null)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
|
||||||
|
const load = async (kw = keyword) => {
|
||||||
|
setLoading(true); setErr('')
|
||||||
|
try {
|
||||||
|
const res = await roleList(kw || undefined)
|
||||||
|
// CMS 백엔드 목록은 PageResponse → data.content
|
||||||
|
setRows(res.data.data?.content ?? [])
|
||||||
|
setSelected(new Set())
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { load('') }, [])
|
||||||
|
|
||||||
|
const toggleSel = (id: string) =>
|
||||||
|
setSelected(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n })
|
||||||
|
|
||||||
|
const removeSelected = async () => {
|
||||||
|
if (selected.size === 0) return
|
||||||
|
if (!window.confirm(`선택한 권한 ${selected.size}건을 삭제하시겠습니까?`)) return
|
||||||
|
try { await deleteRoles([...selected]); await load() }
|
||||||
|
catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<RoleRow>[] = [
|
||||||
|
{
|
||||||
|
key: 'sel', header: '', width: 44, align: 'center',
|
||||||
|
render: r => (
|
||||||
|
<input type="checkbox" checked={selected.has(r.roleId)}
|
||||||
|
onClick={e => e.stopPropagation()} onChange={() => toggleSel(r.roleId)} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'roleId', header: '권한ID', width: 180 },
|
||||||
|
{ key: 'roleNm', header: '권한명' },
|
||||||
|
{ key: 'roleDesc', header: '설명' },
|
||||||
|
{ key: 'useYn', header: '사용', width: 80, align: 'center', render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="uiws-scope">
|
||||||
|
<PageHeader title="권한 관리" subtitle="시스템관리(권한) · 역할(Role) 등록/수정/삭제 — RBAC"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button variant="danger" onClick={removeSelected} disabled={selected.size === 0}>선택 삭제</Button>
|
||||||
|
<Button onClick={() => { setEdit(null); setShowForm(true) }}>+ 권한 등록</Button>
|
||||||
|
</>
|
||||||
|
} />
|
||||||
|
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginBottom: 12 }}>{err}</div>}
|
||||||
|
|
||||||
|
<SearchBar>
|
||||||
|
<Input value={keyword} onChange={setKeyword} placeholder="권한ID/권한명 검색" style={{ minWidth: 240 }} />
|
||||||
|
<Button variant="ghost" onClick={() => load()}>검색</Button>
|
||||||
|
</SearchBar>
|
||||||
|
|
||||||
|
{loading ? <Spinner /> : (
|
||||||
|
<DataGrid columns={columns} rows={rows} rowKey={r => r.roleId}
|
||||||
|
onRowClick={r => { setEdit(r); setShowForm(true) }} empty="등록된 권한이 없습니다." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<RoleForm role={edit} onClose={() => setShowForm(false)}
|
||||||
|
onSaved={() => { setShowForm(false); load() }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleForm({ role, onClose, onSaved }: { role: RoleRow | null; onClose: () => void; onSaved: () => void }) {
|
||||||
|
const editing = !!role
|
||||||
|
const [roleId, setRoleId] = useState(role?.roleId ?? '')
|
||||||
|
const [roleNm, setRoleNm] = useState(role?.roleNm ?? '')
|
||||||
|
const [roleDesc, setRoleDesc] = useState(role?.roleDesc ?? '')
|
||||||
|
const [useYn, setUseYn] = useState(role?.useYn ?? 'Y')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setErr('')
|
||||||
|
if (!roleNm.trim()) { setErr('권한명은 필수입니다.'); return }
|
||||||
|
if (!editing && !roleId.trim()) { setErr('권한ID는 필수입니다.'); return }
|
||||||
|
try {
|
||||||
|
const body = { roleId, roleNm, roleDesc, useYn }
|
||||||
|
editing ? await updateRole(roleId, body) : await createRole(body)
|
||||||
|
onSaved()
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={editing ? '권한 수정' : '권한 등록'} onClose={onClose}
|
||||||
|
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
||||||
|
<FormField label="권한ID">
|
||||||
|
<Input value={roleId} onChange={setRoleId} style={{ width: '100%', opacity: editing ? 0.6 : 1 }} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="권한명"><Input value={roleNm} onChange={setRoleNm} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="설명"><Input value={roleDesc} onChange={setRoleDesc} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="사용 여부">
|
||||||
|
<select value={useYn} onChange={e => setUseYn(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, fontSize: 13,
|
||||||
|
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)' }}>
|
||||||
|
<option value="Y">사용</option>
|
||||||
|
<option value="N">미사용</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
145
frontend/src/pages/uiws/system/RoleMenuPermission.tsx
Normal file
145
frontend/src/pages/uiws/system/RoleMenuPermission.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { roleMenuRoles, roleMenuMapping, saveRoleMenuMapping } from '../../../api/uiws'
|
||||||
|
import { PageHeader, Panel, Button, Spinner } from '../../../components/uiws/ui'
|
||||||
|
|
||||||
|
interface Role { roleId: string; roleNm: string; useYn: string }
|
||||||
|
interface RoleMenuRow { menuId: string; menuNm: string; readYn: string; writeYn: string }
|
||||||
|
|
||||||
|
function friendlyError(e: any): string {
|
||||||
|
if (e?.response?.status === 403) return '권한이 없습니다 (SUPERADMIN 전용).'
|
||||||
|
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoleMenuPermission() {
|
||||||
|
const [roles, setRoles] = useState<Role[]>([])
|
||||||
|
const [activeRole, setActiveRole] = useState<string>('')
|
||||||
|
const [rows, setRows] = useState<RoleMenuRow[]>([])
|
||||||
|
const [loadingRoles, setLoadingRoles] = useState(false)
|
||||||
|
const [loadingMap, setLoadingMap] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadingRoles(true)
|
||||||
|
roleMenuRoles()
|
||||||
|
.then(r => {
|
||||||
|
// CMS 백엔드 role-menus 목록은 PageResponse → data.content
|
||||||
|
const list: Role[] = r.data.data?.content ?? []
|
||||||
|
setRoles(list)
|
||||||
|
if (list.length > 0) setActiveRole(list[0].roleId)
|
||||||
|
})
|
||||||
|
.catch(e => setErr(friendlyError(e)))
|
||||||
|
.finally(() => setLoadingRoles(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeRole) return
|
||||||
|
setLoadingMap(true); setMsg(''); setErr('')
|
||||||
|
roleMenuMapping(activeRole)
|
||||||
|
.then(r => setRows(r.data.data ?? []))
|
||||||
|
.catch(e => setErr(friendlyError(e)))
|
||||||
|
.finally(() => setLoadingMap(false))
|
||||||
|
}, [activeRole])
|
||||||
|
|
||||||
|
// 쓰기를 켜면 읽기도 자동 ON.
|
||||||
|
const toggle = (menuId: string, field: 'readYn' | 'writeYn') =>
|
||||||
|
setRows(prev => prev.map(r => {
|
||||||
|
if (r.menuId !== menuId) return r
|
||||||
|
const next = r[field] === 'Y' ? 'N' : 'Y'
|
||||||
|
if (field === 'writeYn' && next === 'Y') return { ...r, writeYn: 'Y', readYn: 'Y' }
|
||||||
|
if (field === 'readYn' && next === 'N') return { ...r, readYn: 'N', writeYn: 'N' }
|
||||||
|
return { ...r, [field]: next }
|
||||||
|
}))
|
||||||
|
|
||||||
|
const checkAll = (field: 'readYn' | 'writeYn', on: boolean) =>
|
||||||
|
setRows(prev => prev.map(r =>
|
||||||
|
on && field === 'writeYn' ? { ...r, writeYn: 'Y', readYn: 'Y' }
|
||||||
|
: !on && field === 'readYn' ? { ...r, readYn: 'N', writeYn: 'N' }
|
||||||
|
: { ...r, [field]: on ? 'Y' : 'N' }))
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true); setMsg(''); setErr('')
|
||||||
|
try {
|
||||||
|
await saveRoleMenuMapping(activeRole, rows)
|
||||||
|
setMsg('메뉴 권한 매핑이 저장되었습니다.')
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="uiws-scope">
|
||||||
|
<PageHeader title="역할-메뉴 권한" subtitle="시스템관리(권한) · 역할별 메뉴 접근(읽기/쓰기) 매핑 — RBAC 핵심"
|
||||||
|
actions={<Button onClick={save} disabled={saving || !activeRole}>{saving ? '저장 중...' : '매핑 저장'}</Button>} />
|
||||||
|
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginBottom: 12 }}>{err}</div>}
|
||||||
|
{msg && <div style={{ color: 'var(--uiws-success)', fontSize: 13, marginBottom: 12 }}>{msg}</div>}
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '260px 1fr', gap: 16, alignItems: 'start' }}>
|
||||||
|
<Panel style={{ padding: 0 }}>
|
||||||
|
<div style={{ padding: '12px 16px', fontSize: 12, fontWeight: 600, color: 'var(--uiws-text-muted)', borderBottom: '1px solid var(--uiws-border)' }}>
|
||||||
|
역할 목록
|
||||||
|
</div>
|
||||||
|
{loadingRoles ? <Spinner /> : (
|
||||||
|
<div>
|
||||||
|
{roles.length === 0 && <div style={{ padding: 20, fontSize: 13, color: 'var(--uiws-text-faint)' }}>역할이 없습니다.</div>}
|
||||||
|
{roles.map(role => (
|
||||||
|
<div key={role.roleId} onClick={() => setActiveRole(role.roleId)}
|
||||||
|
style={{
|
||||||
|
padding: '11px 16px', cursor: 'pointer', fontSize: 13,
|
||||||
|
borderLeft: `3px solid ${activeRole === role.roleId ? 'var(--uiws-primary)' : 'transparent'}`,
|
||||||
|
background: activeRole === role.roleId ? 'var(--uiws-primary-soft)' : 'transparent',
|
||||||
|
color: activeRole === role.roleId ? 'var(--uiws-primary)' : 'var(--uiws-text)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 600 }}>{role.roleNm}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--uiws-text-muted)' }}>{role.roleId}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel style={{ padding: 0, overflow: 'hidden' }}>
|
||||||
|
{loadingMap ? <Spinner /> : (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13, color: 'var(--uiws-text)' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: 'var(--uiws-surface-2)' }}>
|
||||||
|
<th style={{ textAlign: 'left', padding: '11px 14px', color: 'var(--uiws-text-muted)', fontWeight: 600, fontSize: 12, borderBottom: '1px solid var(--uiws-border)' }}>메뉴</th>
|
||||||
|
<th style={{ width: 120, padding: '8px 14px', color: 'var(--uiws-text-muted)', fontSize: 12, borderBottom: '1px solid var(--uiws-border)' }}>
|
||||||
|
읽기<br />
|
||||||
|
<label style={{ fontWeight: 400, fontSize: 11 }}>
|
||||||
|
<input type="checkbox" onChange={e => checkAll('readYn', e.target.checked)} /> 전체
|
||||||
|
</label>
|
||||||
|
</th>
|
||||||
|
<th style={{ width: 120, padding: '8px 14px', color: 'var(--uiws-text-muted)', fontSize: 12, borderBottom: '1px solid var(--uiws-border)' }}>
|
||||||
|
쓰기<br />
|
||||||
|
<label style={{ fontWeight: 400, fontSize: 11 }}>
|
||||||
|
<input type="checkbox" onChange={e => checkAll('writeYn', e.target.checked)} /> 전체
|
||||||
|
</label>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr><td colSpan={3} style={{ padding: 28, textAlign: 'center', color: 'var(--uiws-text-faint)' }}>매핑 가능한 메뉴가 없습니다.</td></tr>
|
||||||
|
) : rows.map(r => (
|
||||||
|
<tr key={r.menuId} style={{ borderBottom: '1px solid var(--uiws-border)' }}>
|
||||||
|
<td style={{ padding: '11px 14px' }}>
|
||||||
|
{r.menuNm} <span style={{ color: 'var(--uiws-text-muted)', fontSize: 12 }}>({r.menuId})</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '11px 14px', textAlign: 'center' }}>
|
||||||
|
<input type="checkbox" checked={r.readYn === 'Y'} onChange={() => toggle(r.menuId, 'readYn')} />
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '11px 14px', textAlign: 'center' }}>
|
||||||
|
<input type="checkbox" checked={r.writeYn === 'Y'} onChange={() => toggle(r.menuId, 'writeYn')} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user