feat: UIMS 권한관리(시스템관리) 관리자 React 화면 추가

This commit is contained in:
GUARDiA 2026-06-21 22:07:00 +09:00
parent 13d1f53eed
commit 7c8dd8ea66
13 changed files with 1535 additions and 3 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>zioinfo-esn — ESL 통합 관리 플랫폼</title>
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<script type="module" crossorigin src="/assets/index-CYbjlaIi.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bpv8yNZf.css">
<script type="module" crossorigin src="/assets/index-D1lnwkoJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CshJ1aNP.css">
</head>
<body>
<div id="root"></div>

View File

@ -24,6 +24,12 @@ import WorklogList from './pages/uiws/WorklogList'
import ScheduleCalendar from './pages/uiws/ScheduleCalendar'
import MessageBox from './pages/uiws/MessageBox'
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()
@ -55,6 +61,13 @@ export default function App() {
<Route path="/schedules" element={<ScheduleCalendar />} />
<Route path="/messages" element={<MessageBox />} />
<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>
<Route path="*" element={<Navigate to="/dashboard" replace />} />

View 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 } })

View File

@ -3,7 +3,8 @@ import {
LayoutDashboard, Store, FileText, RefreshCw,
Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain,
Link2, ListOrdered, NotebookPen, CalendarDays, Mail, BarChart3,
ScrollText, Settings, Smartphone, Sparkles
ScrollText, Settings, Smartphone, Sparkles,
ShieldCheck, KeyRound, ListTree, Network, FolderTree, Briefcase
} from 'lucide-react'
const nav = [
@ -31,7 +32,18 @@ const nav = [
{ 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() {
const isAdmin = (localStorage.getItem('esn_role') || '') === 'ADMIN'
return (
<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">
@ -55,6 +67,30 @@ export default function Sidebar() {
{label}
</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>
<div className="p-4 border-t border-edge text-xs text-gray-500">
zioinfo-esn v1.0

View File

@ -1,6 +1,7 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import './theme/theme.css'
import App from './App'
createRoot(document.getElementById('root')!).render(

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}