feat: UIMS 권한관리(시스템관리) 관리자 React 화면 추가
This commit is contained in:
parent
13d1f53eed
commit
7c8dd8ea66
File diff suppressed because one or more lines are too long
437
backend/src/main/resources/static/assets/index-D1lnwkoJ.js
Normal file
437
backend/src/main/resources/static/assets/index-D1lnwkoJ.js
Normal file
File diff suppressed because one or more lines are too long
@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>zioinfo-esn — ESL 통합 관리 플랫폼</title>
|
<title>zioinfo-esn — ESL 통합 관리 플랫폼</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<script type="module" crossorigin src="/assets/index-CYbjlaIi.js"></script>
|
<script type="module" crossorigin src="/assets/index-D1lnwkoJ.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Bpv8yNZf.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CshJ1aNP.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -24,6 +24,12 @@ 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'
|
||||||
|
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()
|
||||||
|
|
||||||
@ -55,6 +61,13 @@ export default function App() {
|
|||||||
<Route path="/schedules" element={<ScheduleCalendar />} />
|
<Route path="/schedules" element={<ScheduleCalendar />} />
|
||||||
<Route path="/messages" element={<MessageBox />} />
|
<Route path="/messages" element={<MessageBox />} />
|
||||||
<Route path="/work-stats" element={<StatsPivot />} />
|
<Route path="/work-stats" element={<StatsPivot />} />
|
||||||
|
{/* 시스템관리(권한) — UIWS system 이식. 백엔드 RBAC 403 + 사이드바 가드 이중 방어. */}
|
||||||
|
<Route path="/system/roles" element={<RoleManagement />} />
|
||||||
|
<Route path="/system/role-menus" element={<RoleMenuPermission />} />
|
||||||
|
<Route path="/system/codes" element={<CodeManagement />} />
|
||||||
|
<Route path="/system/menus" element={<MenuManagement />} />
|
||||||
|
<Route path="/system/depts" element={<DeptManagement />} />
|
||||||
|
<Route path="/system/companies" element={<CompanyManagement />} />
|
||||||
<Route path="/mobile-app" element={<MobileApp />} />
|
<Route path="/mobile-app" element={<MobileApp />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
|||||||
77
frontend/src/api/system.ts
Normal file
77
frontend/src/api/system.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import api from './client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UIWS system(권한관리) 모듈 API 클라이언트 — guardia-esn 백엔드(com.zioinfo.esn.uiws.system) 실측 계약.
|
||||||
|
* 기존 ESN axios 인스턴스(client.ts: baseURL='', esn_token JWT 인터셉터) 재사용.
|
||||||
|
*
|
||||||
|
* 응답 봉투: { success, message, data }. 호출부는 res.data.data 로 페이로드 접근.
|
||||||
|
* 목록 엔드포인트 data 페이로드는 PageResponse = { content, total, page, size } (flat array 아님).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PageResponse<T> {
|
||||||
|
content: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이지네이션 헬퍼 — PageResponse 의 total/size 로 totalPages 계산(백엔드는 totalPages 미제공).
|
||||||
|
export const totalPages = (p: { total: number; size: number } | undefined | null): number =>
|
||||||
|
p && p.size > 0 ? Math.ceil(p.total / p.size) : 0
|
||||||
|
|
||||||
|
// ── 2.1 권한(roles) — base /api/system/roles ────────────────────────────────
|
||||||
|
export const roleList = (keyword?: string, page = 0, size = 20) =>
|
||||||
|
api.get('/api/system/roles', { params: { keyword: keyword || undefined, page, size } })
|
||||||
|
export const roleGet = (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 } })
|
||||||
|
|
||||||
|
// ── 2.5 메뉴생성(role-menus) — 권한별 메뉴매핑 ─────────────────────────────────
|
||||||
|
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 })
|
||||||
|
|
||||||
|
// ── 2.7 코드(codes) — 그룹+값 복합. 값은 그룹 단위로 전체 치환(per-code 엔드포인트 없음) ──
|
||||||
|
export const codeGroupList = (keyword?: string, page = 0, size = 20) =>
|
||||||
|
api.get('/api/system/codes', { params: { keyword: keyword || undefined, page, size } })
|
||||||
|
export const codeGroupGet = (grpCd: string) =>
|
||||||
|
api.get(`/api/system/codes/${encodeURIComponent(grpCd)}`)
|
||||||
|
export const codeGroupValues = (grpCd: string) =>
|
||||||
|
api.get(`/api/system/codes/group/${encodeURIComponent(grpCd)}/values`)
|
||||||
|
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)}`)
|
||||||
|
|
||||||
|
// ── 2.9 메뉴(menus) — base /api/system/menus ────────────────────────────────
|
||||||
|
export const menuList = (page = 0, size = 100) =>
|
||||||
|
api.get('/api/system/menus', { params: { page, size } })
|
||||||
|
export const menuSearch = (keyword?: string) =>
|
||||||
|
api.get('/api/system/menus/search', { params: { keyword: keyword || undefined } })
|
||||||
|
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 } })
|
||||||
|
|
||||||
|
// ── 2.3 부서(depts) — base /api/system/depts ────────────────────────────────
|
||||||
|
export const deptList = (keyword?: string, page = 0, size = 100) =>
|
||||||
|
api.get('/api/system/depts', { params: { keyword: keyword || undefined, page, size } })
|
||||||
|
export const deptTree = () => api.get('/api/system/depts/tree')
|
||||||
|
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)}`)
|
||||||
|
|
||||||
|
// ── 2.4 거래처(companies) — base /api/system/companies ───────────────────────
|
||||||
|
export const companyList = (keyword?: string, page = 0, size = 20) =>
|
||||||
|
api.get('/api/system/companies', { params: { keyword: keyword || undefined, 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 } })
|
||||||
@ -3,7 +3,8 @@ import {
|
|||||||
LayoutDashboard, Store, FileText, RefreshCw,
|
LayoutDashboard, Store, FileText, RefreshCw,
|
||||||
Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain,
|
Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain,
|
||||||
Link2, ListOrdered, NotebookPen, CalendarDays, Mail, BarChart3,
|
Link2, ListOrdered, NotebookPen, CalendarDays, Mail, BarChart3,
|
||||||
ScrollText, Settings, Smartphone, Sparkles
|
ScrollText, Settings, Smartphone, Sparkles,
|
||||||
|
ShieldCheck, KeyRound, ListTree, Network, FolderTree, Briefcase
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
@ -31,7 +32,18 @@ const nav = [
|
|||||||
{ to: '/mobile-app', icon: Smartphone, label: '모바일 앱 설치' },
|
{ to: '/mobile-app', icon: Smartphone, label: '모바일 앱 설치' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 시스템관리(권한) — UIWS system 이식. 관리자(ADMIN) 전용 노출. 백엔드 RBAC 403 이 1차 방어.
|
||||||
|
const systemNav = [
|
||||||
|
{ to: '/system/roles', icon: ShieldCheck, label: '권한 관리' },
|
||||||
|
{ to: '/system/role-menus', icon: KeyRound, label: '역할-메뉴 권한' },
|
||||||
|
{ to: '/system/menus', icon: ListTree, label: '메뉴 관리' },
|
||||||
|
{ to: '/system/codes', icon: Network, label: '공통코드 관리' },
|
||||||
|
{ to: '/system/depts', icon: FolderTree, label: '부서 관리' },
|
||||||
|
{ to: '/system/companies', icon: Briefcase, label: '거래처 관리' },
|
||||||
|
]
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
|
const isAdmin = (localStorage.getItem('esn_role') || '') === 'ADMIN'
|
||||||
return (
|
return (
|
||||||
<aside className="w-56 bg-panel flex flex-col border-r border-edge flex-shrink-0">
|
<aside className="w-56 bg-panel flex flex-col border-r border-edge flex-shrink-0">
|
||||||
<div className="h-14 flex items-center px-4 border-b border-edge">
|
<div className="h-14 flex items-center px-4 border-b border-edge">
|
||||||
@ -55,6 +67,30 @@ export default function Sidebar() {
|
|||||||
{label}
|
{label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<div className="mt-3 px-4 pt-3 pb-1 border-t border-edge text-[10px] font-semibold tracking-wide text-gray-500 uppercase">
|
||||||
|
시스템관리 (권한)
|
||||||
|
</div>
|
||||||
|
{systemNav.map(({ to, icon: Icon, label }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-brand/15 text-brand border-r-2 border-brand'
|
||||||
|
: 'text-gray-400 hover:text-white hover:bg-edge/50'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="p-4 border-t border-edge text-xs text-gray-500">
|
<div className="p-4 border-t border-edge text-xs text-gray-500">
|
||||||
zioinfo-esn v1.0
|
zioinfo-esn v1.0
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import './theme/theme.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
267
frontend/src/pages/uiws/system/CodeManagement.tsx
Normal file
267
frontend/src/pages/uiws/system/CodeManagement.tsx
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
codeGroupList, codeGroupGet, createCodeGroup, updateCodeGroup, deleteCodeGroup, totalPages,
|
||||||
|
} from '../../../api/system'
|
||||||
|
import {
|
||||||
|
PageHeader, Panel, SearchBar, Input, Button, DataGrid, Pagination, Modal, FormField, Spinner, YnBadge, type Column,
|
||||||
|
} from '../../../components/uiws/ui'
|
||||||
|
|
||||||
|
interface CodeGrp extends Record<string, unknown> { grpCd: string; grpNm: string; useYn: string }
|
||||||
|
interface Code extends Record<string, unknown> { grpCd: string; codeVal: string; codeNm: string; sortOrd: number; useYn: string }
|
||||||
|
interface CodeGrpDetail { grpCd: string; grpNm: string; useYn: string; values: Code[] }
|
||||||
|
|
||||||
|
function friendlyError(e: any): string {
|
||||||
|
if (e?.response?.status === 403) return '권한이 없습니다 (관리자 전용).'
|
||||||
|
if (e?.response?.status === 409) return '이미 존재하는 코드그룹입니다.'
|
||||||
|
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공통코드 관리 — guardia-esn 계약: 코드값은 그룹 단위로 전체 치환(per-code 엔드포인트 없음).
|
||||||
|
* 그룹 선택 → 상세(values[]) 조회. 값 추가/수정/삭제는 로컬 values 배열을 변경한 뒤
|
||||||
|
* 그룹 PUT(updateCodeGroup)으로 전체 치환 저장한다.
|
||||||
|
*/
|
||||||
|
export default function CodeManagement() {
|
||||||
|
const [groups, setGroups] = useState<CodeGrp[]>([])
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [pages, setPages] = useState(0)
|
||||||
|
const [activeGrp, setActiveGrp] = useState<string>('')
|
||||||
|
const [detail, setDetail] = useState<CodeGrpDetail | null>(null)
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [loadingGrp, setLoadingGrp] = useState(false)
|
||||||
|
const [loadingCode, setLoadingCode] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [grpForm, setGrpForm] = useState<{ open: boolean; edit: CodeGrp | null }>({ open: false, edit: null })
|
||||||
|
const [codeForm, setCodeForm] = useState<{ open: boolean; edit: Code | null }>({ open: false, edit: null })
|
||||||
|
|
||||||
|
const loadGroups = async (kw = keyword, p = page) => {
|
||||||
|
setLoadingGrp(true); setErr('')
|
||||||
|
try {
|
||||||
|
const res = await codeGroupList(kw || undefined, p)
|
||||||
|
const data = res.data.data
|
||||||
|
const list: CodeGrp[] = data?.content ?? []
|
||||||
|
setGroups(list)
|
||||||
|
setPages(totalPages(data))
|
||||||
|
if ((!activeGrp || !list.some(g => g.grpCd === activeGrp)) && list.length > 0) setActiveGrp(list[0].grpCd)
|
||||||
|
else if (list.length === 0) { setActiveGrp(''); setDetail(null) }
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoadingGrp(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { loadGroups(keyword, page) }, [page])
|
||||||
|
|
||||||
|
const loadDetail = async (grp = activeGrp) => {
|
||||||
|
if (!grp) { setDetail(null); return }
|
||||||
|
setLoadingCode(true)
|
||||||
|
try { const res = await codeGroupGet(grp); setDetail(res.data.data) }
|
||||||
|
catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoadingCode(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { loadDetail() }, [activeGrp])
|
||||||
|
|
||||||
|
const search = () => { if (page === 0) loadGroups(keyword, 0); else setPage(0) }
|
||||||
|
|
||||||
|
const removeGroup = async (grpCd: string) => {
|
||||||
|
if (!window.confirm(`코드그룹 '${grpCd}' 와 하위 코드를 삭제하시겠습니까?`)) return
|
||||||
|
try {
|
||||||
|
await deleteCodeGroup(grpCd)
|
||||||
|
if (activeGrp === grpCd) { setActiveGrp(''); setDetail(null) }
|
||||||
|
await loadGroups()
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 값 전체 치환 PUT — 그룹 메타는 detail 기준 유지.
|
||||||
|
const persistValues = async (values: Code[]) => {
|
||||||
|
if (!detail) return
|
||||||
|
const body = {
|
||||||
|
grpCd: detail.grpCd, grpNm: detail.grpNm, useYn: detail.useYn,
|
||||||
|
values: values.map(v => ({ grpCd: detail.grpCd, codeVal: v.codeVal, codeNm: v.codeNm, sortOrd: v.sortOrd ?? 0, useYn: v.useYn })),
|
||||||
|
}
|
||||||
|
await updateCodeGroup(detail.grpCd, body)
|
||||||
|
await loadDetail(detail.grpCd)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCode = async (c: Code) => {
|
||||||
|
if (!detail) return
|
||||||
|
if (!window.confirm(`코드 '${c.codeNm}' 를 삭제하시겠습니까?`)) return
|
||||||
|
try { await persistValues((detail.values ?? []).filter(v => v.codeVal !== c.codeVal)) }
|
||||||
|
catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCode = async (edited: Code, original: Code | null) => {
|
||||||
|
if (!detail) return
|
||||||
|
const cur = detail.values ?? []
|
||||||
|
let next: Code[]
|
||||||
|
if (original) next = cur.map(v => v.codeVal === original.codeVal ? edited : v)
|
||||||
|
else {
|
||||||
|
if (cur.some(v => v.codeVal === edited.codeVal)) throw { response: { status: 409 } }
|
||||||
|
next = [...cur, edited]
|
||||||
|
}
|
||||||
|
await persistValues(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeColumns: Column<Code>[] = [
|
||||||
|
{ key: 'codeVal', header: '코드값', width: 150 },
|
||||||
|
{ 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="미사용" /> },
|
||||||
|
{ key: 'act', header: '', width: 60, align: 'center', render: r => <Button variant="ghost" onClick={() => removeCode(r)}>삭제</Button> },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="uiws-scope">
|
||||||
|
<PageHeader title="공통코드 관리" subtitle="시스템관리(권한) · 코드그룹/코드값 CRUD"
|
||||||
|
actions={<Button onClick={() => setGrpForm({ 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={search}>검색</Button>
|
||||||
|
</SearchBar>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '300px 1fr', gap: 16, alignItems: 'start' }}>
|
||||||
|
<div>
|
||||||
|
<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={() => setGrpForm({ open: true, edit: g })}>수정</Button>
|
||||||
|
<Button variant="ghost" onClick={() => removeGroup(g.grpCd)}>삭제</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
<Pagination page={page} totalPages={pages} onChange={setPage} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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={() => setCodeForm({ open: true, edit: null })} disabled={!detail}>+ 코드값 등록</Button>
|
||||||
|
</div>
|
||||||
|
{loadingCode ? <Spinner /> : (
|
||||||
|
<DataGrid columns={codeColumns} rows={detail?.values ?? []} rowKey={r => `${r.grpCd}:${r.codeVal}`}
|
||||||
|
onRowClick={r => setCodeForm({ open: true, edit: r })} empty={activeGrp ? '코드값이 없습니다.' : '코드그룹을 선택하세요.'} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{grpForm.open && (
|
||||||
|
<CodeGroupForm grp={grpForm.edit} onClose={() => setGrpForm({ open: false, edit: null })}
|
||||||
|
onSaved={() => { setGrpForm({ open: false, edit: null }); loadGroups(keyword) }} />
|
||||||
|
)}
|
||||||
|
{codeForm.open && detail && (
|
||||||
|
<CodeForm grpCd={detail.grpCd} code={codeForm.edit}
|
||||||
|
onClose={() => setCodeForm({ open: false, edit: null })}
|
||||||
|
onSave={saveCode}
|
||||||
|
onSaved={() => setCodeForm({ open: false, edit: null })} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectYn({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
|
return (
|
||||||
|
<select value={value} onChange={e => onChange(e.target.value)} style={selStyle}>
|
||||||
|
<option value="Y">사용</option>
|
||||||
|
<option value="N">미사용</option>
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeGroupForm({ grp, onClose, onSaved }: { grp: CodeGrp | null; onClose: () => void; onSaved: () => void }) {
|
||||||
|
const editing = !!grp
|
||||||
|
const [grpCd, setGrpCd] = useState(grp?.grpCd ?? '')
|
||||||
|
const [grpNm, setGrpNm] = useState(grp?.grpNm ?? '')
|
||||||
|
const [useYn, setUseYn] = useState(grp?.useYn ?? 'Y')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setErr('')
|
||||||
|
if (!editing && !grpCd.trim()) { setErr('그룹코드는 필수입니다.'); return }
|
||||||
|
if (!grpNm.trim()) { setErr('그룹명은 필수입니다.'); return }
|
||||||
|
try {
|
||||||
|
// 등록 시 values 빈 배열. 수정 시 백엔드가 values 전체 치환하므로, 빈 배열 전송을 피하기 위해
|
||||||
|
// 상세를 다시 조회해 기존 값을 보존한 뒤 메타만 변경한다.
|
||||||
|
if (editing) {
|
||||||
|
const cur = await codeGroupGet(grpCd)
|
||||||
|
const detail: CodeGrpDetail = cur.data.data
|
||||||
|
await updateCodeGroup(grpCd, { grpCd, grpNm, useYn, values: detail.values ?? [] })
|
||||||
|
} else {
|
||||||
|
await createCodeGroup({ grpCd, grpNm, useYn, values: [] })
|
||||||
|
}
|
||||||
|
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="그룹코드"><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>
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeForm({ grpCd, code, onClose, onSave, onSaved }: {
|
||||||
|
grpCd: string; code: Code | null; onClose: () => void
|
||||||
|
onSave: (edited: Code, original: Code | null) => Promise<void>; onSaved: () => void
|
||||||
|
}) {
|
||||||
|
const editing = !!code
|
||||||
|
const [codeVal, setCodeVal] = useState(code?.codeVal ?? '')
|
||||||
|
const [codeNm, setCodeNm] = useState(code?.codeNm ?? '')
|
||||||
|
const [sortOrd, setSortOrd] = useState(String(code?.sortOrd ?? 0))
|
||||||
|
const [useYn, setUseYn] = useState(code?.useYn ?? 'Y')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setErr('')
|
||||||
|
if (!editing && !codeVal.trim()) { setErr('코드값은 필수입니다.'); return }
|
||||||
|
if (!codeNm.trim()) { setErr('코드명은 필수입니다.'); return }
|
||||||
|
try {
|
||||||
|
await onSave({ grpCd, codeVal, codeNm, sortOrd: Number(sortOrd) || 0, useYn }, code)
|
||||||
|
onSaved()
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.response?.status === 409) setErr('이미 존재하는 코드값입니다.')
|
||||||
|
else setErr(friendlyError(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={editing ? '코드값 수정' : '코드값 등록'} onClose={onClose}
|
||||||
|
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
||||||
|
<FormField label="그룹코드"><Input value={grpCd} onChange={() => {}} style={{ width: '100%', opacity: 0.6 }} /></FormField>
|
||||||
|
<FormField label="코드값"><Input value={codeVal} onChange={setCodeVal} style={{ width: '100%', opacity: editing ? 0.6 : 1 }} /></FormField>
|
||||||
|
<FormField label="코드명"><Input value={codeNm} onChange={setCodeNm} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="정렬순서"><Input type="number" value={sortOrd} onChange={setSortOrd} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="사용 여부"><SelectYn value={useYn} onChange={setUseYn} /></FormField>
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
117
frontend/src/pages/uiws/system/CompanyManagement.tsx
Normal file
117
frontend/src/pages/uiws/system/CompanyManagement.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { companyList, createCompany, updateCompany, deleteCompanies, totalPages } from '../../../api/system'
|
||||||
|
import {
|
||||||
|
PageHeader, SearchBar, Input, Button, DataGrid, Pagination, 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 '권한이 없습니다 (관리자 전용).'
|
||||||
|
if (e?.response?.status === 409) return '이미 존재하는 거래처ID입니다.'
|
||||||
|
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompanyManagement() {
|
||||||
|
const [rows, setRows] = useState<Company[]>([])
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [pages, setPages] = useState(0)
|
||||||
|
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 = keyword, p = page) => {
|
||||||
|
setLoading(true); setErr('')
|
||||||
|
try {
|
||||||
|
const res = await companyList(kw || undefined, p)
|
||||||
|
const data = res.data.data
|
||||||
|
setRows(data?.content ?? [])
|
||||||
|
setPages(totalPages(data))
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { load(keyword, page) }, [page])
|
||||||
|
|
||||||
|
const search = () => { if (page === 0) load(keyword, 0); else setPage(0) }
|
||||||
|
|
||||||
|
const remove = async (c: Company) => {
|
||||||
|
if (!window.confirm(`거래처 '${c.companyNm}' 를 삭제하시겠습니까?`)) return
|
||||||
|
try { await deleteCompanies([c.companyId]); await load() } 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={search}>검색</Button>
|
||||||
|
</SearchBar>
|
||||||
|
|
||||||
|
{loading ? <Spinner /> : (
|
||||||
|
<>
|
||||||
|
<DataGrid columns={columns} rows={rows} rowKey={r => r.companyId}
|
||||||
|
onRowClick={r => setForm({ open: true, edit: r })} empty="등록된 거래처가 없습니다." />
|
||||||
|
<Pagination page={page} totalPages={pages} onChange={setPage} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.open && (
|
||||||
|
<CompanyForm company={form.edit} onClose={() => setForm({ open: false, edit: null })}
|
||||||
|
onSaved={() => { setForm({ open: false, edit: null }); load() }} />
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
frontend/src/pages/uiws/system/DeptManagement.tsx
Normal file
142
frontend/src/pages/uiws/system/DeptManagement.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { deptList, createDept, updateDept, deleteDept } from '../../../api/system'
|
||||||
|
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 '권한이 없습니다 (관리자 전용).'
|
||||||
|
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 })
|
||||||
|
|
||||||
|
// 전체 부서(100건)를 받아 클라이언트에서 트리 정렬·필터.
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true); setErr('')
|
||||||
|
try { const res = await deptList(); setRows(res.data.data?.content ?? []) }
|
||||||
|
catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
|
const ordered = useMemo(() => {
|
||||||
|
const kw = keyword.trim().toLowerCase()
|
||||||
|
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 kw ? out.filter(d => (d.deptNm ?? '').toLowerCase().includes(kw) || (d.deptId ?? '').toLowerCase().includes(kw)) : out
|
||||||
|
}, [rows, keyword])
|
||||||
|
|
||||||
|
const remove = async (d: Dept) => {
|
||||||
|
if (!window.confirm(`부서 '${d.deptNm}' 를 삭제하시겠습니까?`)) return
|
||||||
|
try { await deleteDept(d.deptId); await load() } 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={() => setKeyword(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() }} />
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
156
frontend/src/pages/uiws/system/MenuManagement.tsx
Normal file
156
frontend/src/pages/uiws/system/MenuManagement.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { menuList, createMenu, updateMenu, deleteMenus } from '../../../api/system'
|
||||||
|
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 '권한이 없습니다 (관리자 전용).'
|
||||||
|
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 })
|
||||||
|
|
||||||
|
// 전체 트리(100건)를 한 번에 받아 클라이언트에서 정렬·들여쓰기·검색 필터.
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true); setErr('')
|
||||||
|
try { const res = await menuList(); setRows(res.data.data?.content ?? []) }
|
||||||
|
catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
|
// 2-depth: 부모 → 자식 순으로 정렬해 들여쓰기 표시. keyword 로 필터(부모도 함께 표시).
|
||||||
|
const ordered = useMemo(() => {
|
||||||
|
const kw = keyword.trim().toLowerCase()
|
||||||
|
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 }))
|
||||||
|
let mapped = out.map(o => ({ ...o.menu, _depth: o.depth })) as (Menu & { _depth: number })[]
|
||||||
|
if (kw) mapped = mapped.filter(m => (m.menuNm ?? '').toLowerCase().includes(kw) || (m.menuId ?? '').toLowerCase().includes(kw))
|
||||||
|
return mapped
|
||||||
|
}, [rows, keyword])
|
||||||
|
|
||||||
|
const remove = async (m: Menu) => {
|
||||||
|
if (!window.confirm(`메뉴 '${m.menuNm}' 를 삭제하시겠습니까?`)) return
|
||||||
|
try { await deleteMenus([m.menuId]); await load() }
|
||||||
|
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={() => setKeyword(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() }} />
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
frontend/src/pages/uiws/system/RoleManagement.tsx
Normal file
140
frontend/src/pages/uiws/system/RoleManagement.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
roleList, createRole, updateRole, deleteRoles, totalPages,
|
||||||
|
} from '../../../api/system'
|
||||||
|
import {
|
||||||
|
PageHeader, SearchBar, Input, Button, DataGrid, Pagination, 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 '권한이 없습니다 (관리자 전용).'
|
||||||
|
if (e?.response?.status === 409) return '이미 존재하는 권한ID입니다.'
|
||||||
|
return e?.response?.data?.message || '요청을 처리하지 못했습니다. 잠시 후 다시 시도하세요.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoleManagement() {
|
||||||
|
const [rows, setRows] = useState<RoleRow[]>([])
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [pages, setPages] = useState(0)
|
||||||
|
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, p = page) => {
|
||||||
|
setLoading(true); setErr('')
|
||||||
|
try {
|
||||||
|
const res = await roleList(kw || undefined, p)
|
||||||
|
const data = res.data.data
|
||||||
|
setRows(data?.content ?? [])
|
||||||
|
setPages(totalPages(data))
|
||||||
|
setSelected(new Set())
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { load(keyword, page) }, [page])
|
||||||
|
|
||||||
|
const search = () => { if (page === 0) load(keyword, 0); else setPage(0) }
|
||||||
|
|
||||||
|
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={search}>검색</Button>
|
||||||
|
</SearchBar>
|
||||||
|
|
||||||
|
{loading ? <Spinner /> : (
|
||||||
|
<>
|
||||||
|
<DataGrid columns={columns} rows={rows} rowKey={r => r.roleId}
|
||||||
|
onRowClick={r => { setEdit(r); setShowForm(true) }} empty="등록된 권한이 없습니다." />
|
||||||
|
<Pagination page={page} totalPages={pages} onChange={setPage} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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/system'
|
||||||
|
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 '권한이 없습니다 (관리자 전용).'
|
||||||
|
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 => {
|
||||||
|
// 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