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

This commit is contained in:
GUARDiA 2026-06-21 22:06:38 +09:00
parent e9044d42bb
commit f5a6fe0c95
14 changed files with 1594 additions and 513 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GUARDiA MES — AI 제조실행시스템</title> <title>GUARDiA MES — AI 제조실행시스템</title>
<script type="module" crossorigin src="/assets/index-DOZiCYTH.js"></script> <script type="module" crossorigin src="/assets/index-Bjvlf-Mv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-i_K9xe48.css"> <link rel="stylesheet" crossorigin href="/assets/index-D6J_SNUk.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -45,6 +45,13 @@ import UiwsWorklog from './pages/uiws/WorklogList'
import UiwsSchedule from './pages/uiws/ScheduleCalendar' import UiwsSchedule from './pages/uiws/ScheduleCalendar'
import UiwsMessage from './pages/uiws/MessageBox' import UiwsMessage from './pages/uiws/MessageBox'
import UiwsStats from './pages/uiws/StatsPivot' import UiwsStats from './pages/uiws/StatsPivot'
// 시스템관리(권한) (UIWS 이식) — 역할/역할-메뉴권한/공통코드/메뉴/부서/거래처
import SysRoles from './pages/uiws/system/RoleManagement'
import SysRoleMenu from './pages/uiws/system/RoleMenuPermission'
import SysCodes from './pages/uiws/system/CodeManagement'
import SysMenus from './pages/uiws/system/MenuManagement'
import SysDepts from './pages/uiws/system/DeptManagement'
import SysCompanies from './pages/uiws/system/CompanyManagement'
const qc = new QueryClient() const qc = new QueryClient()
@ -97,6 +104,25 @@ export default function App() {
<Route path="/uiws/schedule" element={<UiwsSchedule />} /> <Route path="/uiws/schedule" element={<UiwsSchedule />} />
<Route path="/uiws/message" element={<UiwsMessage />} /> <Route path="/uiws/message" element={<UiwsMessage />} />
<Route path="/uiws/stats" element={<UiwsStats />} /> <Route path="/uiws/stats" element={<UiwsStats />} />
{/* 시스템관리(권한) (UIWS 이식) — SUPERADMIN 전용, 기존 라우트 보존·추가만 */}
<Route path="/uiws/system/roles" element={
<ProtectedRoute min="SUPERADMIN"><SysRoles /></ProtectedRoute>
} />
<Route path="/uiws/system/role-menus" element={
<ProtectedRoute min="SUPERADMIN"><SysRoleMenu /></ProtectedRoute>
} />
<Route path="/uiws/system/codes" element={
<ProtectedRoute min="SUPERADMIN"><SysCodes /></ProtectedRoute>
} />
<Route path="/uiws/system/menus" element={
<ProtectedRoute min="SUPERADMIN"><SysMenus /></ProtectedRoute>
} />
<Route path="/uiws/system/depts" element={
<ProtectedRoute min="SUPERADMIN"><SysDepts /></ProtectedRoute>
} />
<Route path="/uiws/system/companies" element={
<ProtectedRoute min="SUPERADMIN"><SysCompanies /></ProtectedRoute>
} />
{/* 관리자 */} {/* 관리자 */}
<Route path="/admin/users" element={ <Route path="/admin/users" element={
<ProtectedRoute min="SUPERADMIN"><UserManagement /></ProtectedRoute> <ProtectedRoute min="SUPERADMIN"><UserManagement /></ProtectedRoute>

View File

@ -73,3 +73,52 @@ export const confirmWorklogComment = (cmtId: number) => api.post(`/api/worklogs/
// ── 통계(stats) // ── 통계(stats)
export const personalWorkStats = (params: Record<string, unknown>) => api.get('/api/stats/personal-work', { params }) export const personalWorkStats = (params: Record<string, unknown>) => api.get('/api/stats/personal-work', { params })
export const companyWorkStats = (params: Record<string, unknown>) => api.get('/api/stats/company-work', { params }) export const companyWorkStats = (params: Record<string, unknown>) => api.get('/api/stats/company-work', { params })
// ── 시스템관리(권한) — com.zioinfo.mes.uiws.system, base /api/system/*
// MES 백엔드 계약은 CMS 차용분과 동일(실측): 목록 PageResponse(data.content), codes 는 그룹+값 일괄,
// roles/menus/companies 삭제는 { ids } 본문, depts 삭제는 path, role-menus 엔드포인트 존재.
// SUPERADMIN 전용(403). 응답 봉투 {success,message,data} → 호출부는 res.data.data 접근.
// 역할(roles) — 목록(paged)/상세/등록/수정/다중삭제
export const roleList = (keyword?: string, page = 0, size = 100) =>
api.get('/api/system/roles', { params: { ...(keyword ? { keyword } : {}), page, size } })
export const roleDetail = (id: string) => api.get(`/api/system/roles/${encodeURIComponent(id)}`)
export const createRole = (body: object) => api.post('/api/system/roles', body)
export const updateRole = (id: string, body: object) => api.put(`/api/system/roles/${encodeURIComponent(id)}`, body)
export const deleteRoles = (ids: string[]) => api.delete('/api/system/roles', { data: { ids } })
// 역할-메뉴 권한(role-menus) — 역할목록(paged) / 권한별 메뉴매핑 조회·저장
export const roleMenuRoles = (page = 0, size = 100) => api.get('/api/system/role-menus', { params: { page, size } })
export const roleMenuMapping = (roleId: string) => api.get(`/api/system/roles/${encodeURIComponent(roleId)}/menus`)
export const saveRoleMenuMapping = (roleId: string, menus: object[]) =>
api.put(`/api/system/roles/${encodeURIComponent(roleId)}/menus`, { menus })
// 공통코드(codes) — 그룹목록(paged)/그룹상세(values 포함)/그룹+값 일괄 CRUD
export const codeGroupList = (keyword?: string, page = 0, size = 100) =>
api.get('/api/system/codes', { params: { ...(keyword ? { keyword } : {}), page, size } })
export const codeGroupDetail = (grpCd: string) => api.get(`/api/system/codes/${encodeURIComponent(grpCd)}`)
export const createCodeGroup = (body: object) => api.post('/api/system/codes', body)
export const updateCodeGroup = (grpCd: string, body: object) =>
api.put(`/api/system/codes/${encodeURIComponent(grpCd)}`, body)
export const deleteCodeGroup = (grpCd: string) => api.delete(`/api/system/codes/${encodeURIComponent(grpCd)}`)
// 메뉴(menus) — 목록(paged)/검색/등록/수정/다중삭제
export const menuList = (page = 0, size = 200) => api.get('/api/system/menus', { params: { page, size } })
export const menuSearch = (keyword?: string) => api.get('/api/system/menus/search', { params: keyword ? { keyword } : {} })
export const createMenu = (body: object) => api.post('/api/system/menus', body)
export const updateMenu = (id: string, body: object) => api.put(`/api/system/menus/${encodeURIComponent(id)}`, body)
export const deleteMenus = (ids: string[]) => api.delete('/api/system/menus', { data: { ids } })
// 부서(depts) — 목록(paged)/CRUD/단건삭제(path)
export const deptList = (keyword?: string, page = 0, size = 200) =>
api.get('/api/system/depts', { params: { ...(keyword ? { keyword } : {}), page, size } })
export const createDept = (body: object) => api.post('/api/system/depts', body)
export const updateDept = (id: string, body: object) => api.put(`/api/system/depts/${encodeURIComponent(id)}`, body)
export const deleteDept = (id: string) => api.delete(`/api/system/depts/${encodeURIComponent(id)}`)
// 거래처(companies) — 목록(paged)/CRUD/다중삭제
export const companyList = (keyword?: string, page = 0, size = 200) =>
api.get('/api/system/companies', { params: { ...(keyword ? { keyword } : {}), page, size } })
export const createCompany = (body: object) => api.post('/api/system/companies', body)
export const updateCompany = (id: string, body: object) => api.put(`/api/system/companies/${encodeURIComponent(id)}`, body)
export const deleteCompanies = (ids: string[]) => api.delete('/api/system/companies', { data: { ids } })

View File

@ -6,6 +6,7 @@ import {
ScanSearch, Ruler, AlertTriangle, ListChecks, LineChart, FileBadge, Package, ListTree, ScanSearch, Ruler, AlertTriangle, ListChecks, LineChart, FileBadge, Package, ListTree,
Route as RouteIcon, Building2, Warehouse, BarChart3, Sparkles, ShieldCheck, ScrollText, Route as RouteIcon, Building2, Warehouse, BarChart3, Sparkles, ShieldCheck, ScrollText,
Settings, Cpu, NotebookPen, CalendarDays, Mail, PieChart, Smartphone, Settings, Cpu, NotebookPen, CalendarDays, Mail, PieChart, Smartphone,
KeyRound, ShieldHalf, ListTodo, Menu as MenuIcon, Users2, Contact,
} from 'lucide-react' } from 'lucide-react'
import { currentRole, hasRole } from './rbac' import { currentRole, hasRole } from './rbac'
import ThemeToggle from './uiws/ThemeToggle' import ThemeToggle from './uiws/ThemeToggle'
@ -59,6 +60,15 @@ const uiwsLinks: Link[] = [
{ to: '/uiws/message', label: '쪽지', icon: Mail }, { to: '/uiws/message', label: '쪽지', icon: Mail },
{ to: '/uiws/stats', label: '업무통계', icon: PieChart }, { to: '/uiws/stats', label: '업무통계', icon: PieChart },
] ]
// 시스템관리(권한) (UIWS 이식) — SUPERADMIN 전용
const sysLinks: Link[] = [
{ to: '/uiws/system/roles', label: '권한 관리', icon: KeyRound, min: 'SUPERADMIN' },
{ to: '/uiws/system/role-menus', label: '역할-메뉴 권한', icon: ShieldHalf, min: 'SUPERADMIN' },
{ to: '/uiws/system/codes', label: '공통코드', icon: ListTodo, min: 'SUPERADMIN' },
{ to: '/uiws/system/menus', label: '메뉴 관리', icon: MenuIcon, min: 'SUPERADMIN' },
{ to: '/uiws/system/depts', label: '부서 관리', icon: Users2, min: 'SUPERADMIN' },
{ to: '/uiws/system/companies', label: '거래처 관리', icon: Contact, min: 'SUPERADMIN' },
]
const adminLinks: Link[] = [ const adminLinks: Link[] = [
{ to: '/admin/users', label: '사용자/권한', icon: ShieldCheck, min: 'SUPERADMIN' }, { to: '/admin/users', label: '사용자/권한', icon: ShieldCheck, min: 'SUPERADMIN' },
{ to: '/admin/audit', label: '감사 로그', icon: ScrollText, min: 'MANAGER' }, { to: '/admin/audit', label: '감사 로그', icon: ScrollText, min: 'MANAGER' },
@ -106,6 +116,7 @@ export default function Sidebar() {
return () => { window.clearInterval(t); window.removeEventListener('storage', sync) } return () => { window.clearInterval(t); window.removeEventListener('storage', sync) }
}, []) }, [])
const visibleSys = sysLinks.filter(l => hasRole(l.min || 'VIEWER', role))
const visibleAdmin = adminLinks.filter(l => hasRole(l.min || 'VIEWER', role)) const visibleAdmin = adminLinks.filter(l => hasRole(l.min || 'VIEWER', role))
return ( return (
@ -128,6 +139,11 @@ export default function Sidebar() {
<div className="border-t border-edge mt-3"> <div className="border-t border-edge mt-3">
<Section title="모바일" links={mobileLinks} role={role} /> <Section title="모바일" links={mobileLinks} role={role} />
</div> </div>
{visibleSys.length > 0 && (
<div className="border-t border-edge mt-3">
<Section title="시스템관리(권한)" links={visibleSys} role={role} />
</div>
)}
{visibleAdmin.length > 0 && ( {visibleAdmin.length > 0 && (
<div className="border-t border-edge mt-3"> <div className="border-t border-edge mt-3">
<Section title="관리자" links={visibleAdmin} role={role} /> <Section title="관리자" links={visibleAdmin} role={role} />

View File

@ -0,0 +1,237 @@
import { useEffect, useState } from 'react'
import {
codeGroupList, codeGroupDetail, createCodeGroup, updateCodeGroup, deleteCodeGroup,
} from '../../../api/uiws'
import {
PageHeader, Panel, SearchBar, Input, Button, DataGrid, Modal, FormField, Spinner, YnBadge, type Column,
} from '../../../components/uiws/ui'
interface CodeGrp extends Record<string, unknown> { grpCd: string; grpNm: string; useYn: string }
interface CodeValue extends Record<string, unknown> { grpCd?: string; codeVal: string; codeNm: string; sortOrd: number; useYn: string }
interface CodeGrpDetail extends CodeGrp { values: CodeValue[] }
function friendlyError(e: any): string {
if (e?.response?.status === 403) return '권한이 없습니다 (SUPERADMIN 전용).'
if (e?.response?.status === 409) return '이미 존재하는 코드입니다.'
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
}
export default function CodeManagement() {
const [groups, setGroups] = useState<CodeGrp[]>([])
const [activeGrp, setActiveGrp] = useState<string>('')
const [detail, setDetail] = useState<CodeGrpDetail | null>(null)
const [keyword, setKeyword] = useState('')
const [loadingGrp, setLoadingGrp] = useState(false)
const [loadingDetail, setLoadingDetail] = useState(false)
const [err, setErr] = useState('')
const [form, setForm] = useState<{ open: boolean; edit: CodeGrpDetail | null }>({ open: false, edit: null })
const loadGroups = async (kw = '') => {
setLoadingGrp(true); setErr('')
try {
const res = await codeGroupList(kw || undefined)
// CMS 백엔드 그룹목록은 PageResponse → data.content
const list: CodeGrp[] = res.data.data?.content ?? []
setGroups(list)
if (!activeGrp && list.length > 0) setActiveGrp(list[0].grpCd)
} catch (e) { setErr(friendlyError(e)) }
finally { setLoadingGrp(false) }
}
useEffect(() => { loadGroups('') }, [])
const loadDetail = async (grp = activeGrp) => {
if (!grp) { setDetail(null); return }
setLoadingDetail(true)
try {
const res = await codeGroupDetail(grp)
const d = res.data.data
setDetail(d ? { ...d, values: d.values ?? [] } : null)
} catch (e) { setErr(friendlyError(e)) }
finally { setLoadingDetail(false) }
}
useEffect(() => { loadDetail() }, [activeGrp])
const removeGroup = async (grpCd: string) => {
if (!window.confirm(`코드그룹 '${grpCd}' 와 하위 코드를 삭제하시겠습니까?`)) return
try {
await deleteCodeGroup(grpCd)
if (activeGrp === grpCd) { setActiveGrp(''); setDetail(null) }
await loadGroups(keyword)
} catch (e) { setErr(friendlyError(e)) }
}
const codeColumns: Column<CodeValue>[] = [
{ key: 'codeVal', header: '코드값', width: 170 },
{ key: 'codeNm', header: '코드명' },
{ key: 'sortOrd', header: '정렬', width: 70, align: 'center' },
{ key: 'useYn', header: '사용', width: 80, align: 'center', render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
]
return (
<div className="uiws-scope">
<PageHeader title="공통코드 관리" subtitle="시스템관리(권한) · 코드그룹/코드값 일괄 CRUD"
actions={<Button onClick={() => setForm({ open: true, edit: null })}>+ </Button>} />
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginBottom: 12 }}>{err}</div>}
<SearchBar>
<Input value={keyword} onChange={setKeyword} placeholder="코드그룹 검색" style={{ minWidth: 220 }} />
<Button variant="ghost" onClick={() => loadGroups(keyword)}></Button>
</SearchBar>
<div style={{ display: 'grid', gridTemplateColumns: '300px 1fr', gap: 16, alignItems: 'start' }}>
<Panel style={{ padding: 0 }}>
<div style={{ padding: '12px 16px', fontSize: 12, fontWeight: 600, color: 'var(--uiws-text-muted)', borderBottom: '1px solid var(--uiws-border)' }}>
</div>
{loadingGrp ? <Spinner /> : (
<div>
{groups.length === 0 && <div style={{ padding: 20, fontSize: 13, color: 'var(--uiws-text-faint)' }}> .</div>}
{groups.map(g => (
<div key={g.grpCd} onClick={() => setActiveGrp(g.grpCd)}
style={{
padding: '11px 16px', cursor: 'pointer', fontSize: 13, display: 'flex', justifyContent: 'space-between', alignItems: 'center',
borderLeft: `3px solid ${activeGrp === g.grpCd ? 'var(--uiws-primary)' : 'transparent'}`,
background: activeGrp === g.grpCd ? 'var(--uiws-primary-soft)' : 'transparent',
}}>
<div>
<div style={{ fontWeight: 600, color: activeGrp === g.grpCd ? 'var(--uiws-primary)' : 'var(--uiws-text)' }}>{g.grpNm}</div>
<div style={{ fontSize: 11, color: 'var(--uiws-text-muted)' }}>{g.grpCd}</div>
</div>
<div style={{ display: 'flex', gap: 6 }} onClick={e => e.stopPropagation()}>
<Button variant="ghost" onClick={() => removeGroup(g.grpCd)}></Button>
</div>
</div>
))}
</div>
)}
</Panel>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--uiws-text)' }}>
{activeGrp && <span style={{ color: 'var(--uiws-text-muted)', fontWeight: 400 }}>({activeGrp})</span>}
</div>
<Button onClick={() => detail && setForm({ open: true, edit: detail })} disabled={!detail}>· </Button>
</div>
{loadingDetail ? <Spinner /> : (
<DataGrid columns={codeColumns} rows={detail?.values ?? []} rowKey={r => `${activeGrp}:${r.codeVal}`}
empty={activeGrp ? '코드값이 없습니다.' : '코드그룹을 선택하세요.'} />
)}
</div>
</div>
{form.open && (
<CodeGroupForm grp={form.edit} onClose={() => setForm({ open: false, edit: null })}
onSaved={async (grpCd) => {
setForm({ open: false, edit: null })
await loadGroups(keyword)
if (grpCd) { setActiveGrp(grpCd); await loadDetail(grpCd) }
else await loadDetail()
}} />
)}
</div>
)
}
function SelectYn({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<select value={value} onChange={e => onChange(e.target.value)}
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, fontSize: 13,
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)' }}>
<option value="Y"></option>
<option value="N"></option>
</select>
)
}
function CodeGroupForm({ grp, onClose, onSaved }: { grp: CodeGrpDetail | null; onClose: () => void; onSaved: (grpCd: string) => void }) {
const editing = !!grp
const [grpCd, setGrpCd] = useState(grp?.grpCd ?? '')
const [grpNm, setGrpNm] = useState(grp?.grpNm ?? '')
const [useYn, setUseYn] = useState(grp?.useYn ?? 'Y')
const [values, setValues] = useState<CodeValue[]>(grp?.values ? grp.values.map(v => ({ ...v })) : [])
const [err, setErr] = useState('')
const addValue = () => setValues(prev => [...prev, { codeVal: '', codeNm: '', sortOrd: prev.length + 1, useYn: 'Y' }])
const updateValue = (idx: number, field: keyof CodeValue, val: string | number) =>
setValues(prev => prev.map((v, i) => (i === idx ? { ...v, [field]: val } : v)))
const removeValue = (idx: number) => setValues(prev => prev.filter((_, i) => i !== idx))
const save = async () => {
setErr('')
if (!editing && !grpCd.trim()) { setErr('그룹코드는 필수입니다.'); return }
if (!grpNm.trim()) { setErr('그룹명은 필수입니다.'); return }
for (const v of values) {
if (!v.codeVal.trim() || !v.codeNm.trim()) { setErr('코드값과 코드명은 필수입니다.'); return }
}
try {
const body = {
grpCd, grpNm, useYn,
values: values.map(v => ({ grpCd, codeVal: v.codeVal, codeNm: v.codeNm, sortOrd: Number(v.sortOrd) || 0, useYn: v.useYn })),
}
editing ? await updateCodeGroup(grpCd, body) : await createCodeGroup(body)
onSaved(grpCd)
} catch (e) { setErr(friendlyError(e)) }
}
const cellInput: React.CSSProperties = {
width: '100%', padding: '6px 8px', borderRadius: 6, fontSize: 12,
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)',
}
return (
<Modal title={editing ? '코드그룹·코드값 편집' : '코드그룹 등록'} onClose={onClose}
footer={<><Button variant="ghost" onClick={onClose}></Button><Button onClick={save}></Button></>}>
<FormField label="그룹코드"><Input value={grpCd} onChange={setGrpCd} style={{ width: '100%', opacity: editing ? 0.6 : 1 }} /></FormField>
<FormField label="그룹명"><Input value={grpNm} onChange={setGrpNm} style={{ width: '100%' }} /></FormField>
<FormField label="사용 여부"><SelectYn value={useYn} onChange={setUseYn} /></FormField>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 8, marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--uiws-text-muted)' }}> ({values.length})</div>
<Button variant="ghost" onClick={addValue}>+ </Button>
</div>
<div style={{ border: '1px solid var(--uiws-border)', borderRadius: 8, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, color: 'var(--uiws-text)' }}>
<thead>
<tr style={{ background: 'var(--uiws-surface-2)' }}>
<th style={{ padding: '7px 8px', textAlign: 'left', color: 'var(--uiws-text-muted)', fontWeight: 600 }}></th>
<th style={{ padding: '7px 8px', textAlign: 'left', color: 'var(--uiws-text-muted)', fontWeight: 600 }}></th>
<th style={{ padding: '7px 8px', width: 64, color: 'var(--uiws-text-muted)', fontWeight: 600 }}></th>
<th style={{ padding: '7px 8px', width: 76, color: 'var(--uiws-text-muted)', fontWeight: 600 }}></th>
<th style={{ padding: '7px 8px', width: 44 }} />
</tr>
</thead>
<tbody>
{values.length === 0 ? (
<tr><td colSpan={5} style={{ padding: 16, textAlign: 'center', color: 'var(--uiws-text-faint)' }}> .</td></tr>
) : values.map((v, idx) => (
<tr key={idx} style={{ borderTop: '1px solid var(--uiws-border)' }}>
<td style={{ padding: '5px 8px' }}>
<input value={v.codeVal} onChange={e => updateValue(idx, 'codeVal', e.target.value)} style={cellInput} />
</td>
<td style={{ padding: '5px 8px' }}>
<input value={v.codeNm} onChange={e => updateValue(idx, 'codeNm', e.target.value)} style={cellInput} />
</td>
<td style={{ padding: '5px 8px' }}>
<input type="number" value={String(v.sortOrd)} onChange={e => updateValue(idx, 'sortOrd', Number(e.target.value) || 0)} style={cellInput} />
</td>
<td style={{ padding: '5px 8px' }}>
<select value={v.useYn} onChange={e => updateValue(idx, 'useYn', e.target.value)} style={cellInput}>
<option value="Y"></option>
<option value="N"></option>
</select>
</td>
<td style={{ padding: '5px 8px', textAlign: 'center' }}>
<button onClick={() => removeValue(idx)}
style={{ background: 'none', border: 'none', color: 'var(--uiws-danger)', cursor: 'pointer', fontSize: 16 }}>×</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginTop: 10 }}>{err}</div>}
</Modal>
)
}

View File

@ -0,0 +1,110 @@
import { useEffect, useState } from 'react'
import { companyList, createCompany, updateCompany, deleteCompanies } from '../../../api/uiws'
import {
PageHeader, SearchBar, Input, Button, DataGrid, Modal, FormField, Spinner, YnBadge, type Column,
} from '../../../components/uiws/ui'
interface Company extends Record<string, unknown> {
companyId: string; companyNm: string; bizNo: string | null; useYn: string
}
function friendlyError(e: any): string {
if (e?.response?.status === 403) return '권한이 없습니다 (SUPERADMIN 전용).'
if (e?.response?.status === 409) return '이미 존재하는 거래처ID입니다.'
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
}
export default function CompanyManagement() {
const [rows, setRows] = useState<Company[]>([])
const [keyword, setKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [err, setErr] = useState('')
const [form, setForm] = useState<{ open: boolean; edit: Company | null }>({ open: false, edit: null })
const load = async (kw = '') => {
setLoading(true); setErr('')
try {
const res = await companyList(kw || undefined)
// CMS 백엔드 거래처목록은 PageResponse → data.content
setRows(res.data.data?.content ?? [])
}
catch (e) { setErr(friendlyError(e)) }
finally { setLoading(false) }
}
useEffect(() => { load('') }, [])
const remove = async (c: Company) => {
if (!window.confirm(`거래처 '${c.companyNm}' 를 삭제하시겠습니까?`)) return
try { await deleteCompanies([c.companyId]); await load(keyword) } catch (e) { setErr(friendlyError(e)) }
}
const columns: Column<Company>[] = [
{ key: 'companyId', header: '거래처ID', width: 180 },
{ key: 'companyNm', header: '거래처명' },
{ key: 'bizNo', header: '사업자번호', width: 180, render: r => r.bizNo ?? '-' },
{ key: 'useYn', header: '사용', width: 80, align: 'center', render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
{ key: 'act', header: '', width: 60, align: 'center', render: r => <Button variant="ghost" onClick={() => remove(r)}></Button> },
]
return (
<div className="uiws-scope">
<PageHeader title="거래처 관리" subtitle="시스템관리(권한) · 거래처(근무처) 등록/수정/삭제"
actions={<Button onClick={() => setForm({ open: true, edit: null })}>+ </Button>} />
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginBottom: 12 }}>{err}</div>}
<SearchBar>
<Input value={keyword} onChange={setKeyword} placeholder="거래처명 검색" style={{ minWidth: 220 }} />
<Button variant="ghost" onClick={() => load(keyword)}></Button>
</SearchBar>
{loading ? <Spinner /> : (
<DataGrid columns={columns} rows={rows} rowKey={r => r.companyId}
onRowClick={r => setForm({ open: true, edit: r })} empty="등록된 거래처가 없습니다." />
)}
{form.open && (
<CompanyForm company={form.edit} onClose={() => setForm({ open: false, edit: null })}
onSaved={() => { setForm({ open: false, edit: null }); load(keyword) }} />
)}
</div>
)
}
function CompanyForm({ company, onClose, onSaved }: { company: Company | null; onClose: () => void; onSaved: () => void }) {
const editing = !!company
const [companyId, setCompanyId] = useState(company?.companyId ?? '')
const [companyNm, setCompanyNm] = useState(company?.companyNm ?? '')
const [bizNo, setBizNo] = useState(company?.bizNo ?? '')
const [useYn, setUseYn] = useState(company?.useYn ?? 'Y')
const [err, setErr] = useState('')
const save = async () => {
setErr('')
if (!editing && !companyId.trim()) { setErr('거래처ID는 필수입니다.'); return }
if (!companyNm.trim()) { setErr('거래처명은 필수입니다.'); return }
try {
const body = { companyId, companyNm, bizNo: bizNo || null, useYn }
editing ? await updateCompany(companyId, body) : await createCompany(body)
onSaved()
} catch (e) { setErr(friendlyError(e)) }
}
return (
<Modal title={editing ? '거래처 수정' : '거래처 등록'} onClose={onClose}
footer={<><Button variant="ghost" onClick={onClose}></Button><Button onClick={save}></Button></>}>
<FormField label="거래처ID"><Input value={companyId} onChange={setCompanyId} style={{ width: '100%', opacity: editing ? 0.6 : 1 }} /></FormField>
<FormField label="거래처명"><Input value={companyNm} onChange={setCompanyNm} style={{ width: '100%' }} /></FormField>
<FormField label="사업자번호"><Input value={bizNo ?? ''} onChange={setBizNo} style={{ width: '100%' }} /></FormField>
<FormField label="사용 여부">
<select value={useYn} onChange={e => setUseYn(e.target.value)}
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, fontSize: 13,
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)' }}>
<option value="Y"></option>
<option value="N"></option>
</select>
</FormField>
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
</Modal>
)
}

View File

@ -0,0 +1,144 @@
import { useEffect, useMemo, useState } from 'react'
import { deptList, createDept, updateDept, deleteDept } from '../../../api/uiws'
import {
PageHeader, SearchBar, Input, Button, DataGrid, Modal, FormField, Spinner, YnBadge, type Column,
} from '../../../components/uiws/ui'
interface Dept extends Record<string, unknown> {
deptId: string; deptNm: string; parentDeptId: string | null; sortOrd: number; useYn: string
}
function friendlyError(e: any): string {
if (e?.response?.status === 403) return '권한이 없습니다 (SUPERADMIN 전용).'
if (e?.response?.status === 409) return '이미 존재하는 부서ID입니다.'
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
}
export default function DeptManagement() {
const [rows, setRows] = useState<Dept[]>([])
const [keyword, setKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [err, setErr] = useState('')
const [form, setForm] = useState<{ open: boolean; edit: Dept | null }>({ open: false, edit: null })
const load = async (kw = '') => {
setLoading(true); setErr('')
try {
const res = await deptList(kw || undefined)
// CMS 백엔드 부서목록은 PageResponse → data.content
setRows(res.data.data?.content ?? [])
}
catch (e) { setErr(friendlyError(e)) }
finally { setLoading(false) }
}
useEffect(() => { load('') }, [])
const ordered = useMemo(() => {
const roots = rows.filter(r => !r.parentDeptId).sort((a, b) => (a.sortOrd ?? 0) - (b.sortOrd ?? 0))
const out: (Dept & { _depth: number })[] = []
for (const root of roots) {
out.push({ ...root, _depth: 0 })
rows.filter(r => r.parentDeptId === root.deptId)
.sort((a, b) => (a.sortOrd ?? 0) - (b.sortOrd ?? 0))
.forEach(c => out.push({ ...c, _depth: 1 }))
}
const seen = new Set(out.map(o => o.deptId))
rows.filter(r => !seen.has(r.deptId)).forEach(m => out.push({ ...m, _depth: 0 }))
return out
}, [rows])
const remove = async (d: Dept) => {
if (!window.confirm(`부서 '${d.deptNm}' 를 삭제하시겠습니까?`)) return
try { await deleteDept(d.deptId); await load(keyword) } catch (e) { setErr(friendlyError(e)) }
}
const columns: Column<Dept & { _depth: number }>[] = [
{
key: 'deptNm', header: '부서명',
render: r => (
<span style={{ paddingLeft: r._depth * 22 }}>
{r._depth > 0 && <span style={{ color: 'var(--uiws-text-faint)' }}> </span>}
{r.deptNm} <span style={{ color: 'var(--uiws-text-muted)', fontSize: 12 }}>({r.deptId})</span>
</span>
),
},
{ key: 'sortOrd', header: '정렬', width: 70, align: 'center' },
{ key: 'useYn', header: '사용', width: 80, align: 'center', render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
{ key: 'act', header: '', width: 60, align: 'center', render: r => <Button variant="ghost" onClick={() => remove(r)}></Button> },
]
return (
<div className="uiws-scope">
<PageHeader title="부서 관리" subtitle="시스템관리(권한) · 부서 트리(2-depth) 등록/수정/삭제"
actions={<Button onClick={() => setForm({ open: true, edit: null })}>+ </Button>} />
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginBottom: 12 }}>{err}</div>}
<SearchBar>
<Input value={keyword} onChange={setKeyword} placeholder="부서명 검색" style={{ minWidth: 220 }} />
<Button variant="ghost" onClick={() => load(keyword)}></Button>
</SearchBar>
{loading ? <Spinner /> : (
<DataGrid columns={columns} rows={ordered} rowKey={r => r.deptId}
onRowClick={r => setForm({ open: true, edit: r })} empty="등록된 부서가 없습니다." />
)}
{form.open && (
<DeptForm dept={form.edit} parents={rows.filter(r => !r.parentDeptId)}
onClose={() => setForm({ open: false, edit: null })}
onSaved={() => { setForm({ open: false, edit: null }); load(keyword) }} />
)}
</div>
)
}
function DeptForm({ dept, parents, onClose, onSaved }: {
dept: Dept | null; parents: Dept[]; onClose: () => void; onSaved: () => void
}) {
const editing = !!dept
const [deptId, setDeptId] = useState(dept?.deptId ?? '')
const [deptNm, setDeptNm] = useState(dept?.deptNm ?? '')
const [parentDeptId, setParentDeptId] = useState(dept?.parentDeptId ?? '')
const [sortOrd, setSortOrd] = useState(String(dept?.sortOrd ?? 0))
const [useYn, setUseYn] = useState(dept?.useYn ?? 'Y')
const [err, setErr] = useState('')
const selStyle = {
width: '100%', padding: '9px 12px', borderRadius: 8, fontSize: 13,
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)',
} as const
const save = async () => {
setErr('')
if (!editing && !deptId.trim()) { setErr('부서ID는 필수입니다.'); return }
if (!deptNm.trim()) { setErr('부서명은 필수입니다.'); return }
try {
const body = { deptId, deptNm, parentDeptId: parentDeptId || null, sortOrd: Number(sortOrd) || 0, useYn }
editing ? await updateDept(deptId, body) : await createDept(body)
onSaved()
} catch (e) { setErr(friendlyError(e)) }
}
return (
<Modal title={editing ? '부서 수정' : '부서 등록'} onClose={onClose}
footer={<><Button variant="ghost" onClick={onClose}></Button><Button onClick={save}></Button></>}>
<FormField label="부서ID"><Input value={deptId} onChange={setDeptId} style={{ width: '100%', opacity: editing ? 0.6 : 1 }} /></FormField>
<FormField label="부서명"><Input value={deptNm} onChange={setDeptNm} style={{ width: '100%' }} /></FormField>
<FormField label="상위 부서">
<select value={parentDeptId ?? ''} onChange={e => setParentDeptId(e.target.value)} style={selStyle}>
<option value="">()</option>
{parents.filter(p => p.deptId !== deptId).map(p => <option key={p.deptId} value={p.deptId}>{p.deptNm} ({p.deptId})</option>)}
</select>
</FormField>
<FormField label="정렬순서"><Input type="number" value={sortOrd} onChange={setSortOrd} style={{ width: '100%' }} /></FormField>
<FormField label="사용 여부">
<select value={useYn} onChange={e => setUseYn(e.target.value)} style={selStyle}>
<option value="Y"></option>
<option value="N"></option>
</select>
</FormField>
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
</Modal>
)
}

View File

@ -0,0 +1,162 @@
import { useEffect, useMemo, useState } from 'react'
import { menuList, menuSearch, createMenu, updateMenu, deleteMenus } from '../../../api/uiws'
import {
PageHeader, SearchBar, Input, Button, DataGrid, Modal, FormField, Spinner, YnBadge, type Column,
} from '../../../components/uiws/ui'
interface Menu extends Record<string, unknown> {
menuId: string; menuNm: string; parentMenuId: string | null; programId: string | null
menuUrl: string | null; sortOrd: number; useYn: string
}
function friendlyError(e: any): string {
if (e?.response?.status === 403) return '권한이 없습니다 (SUPERADMIN 전용).'
if (e?.response?.status === 409) return '이미 존재하는 메뉴ID입니다.'
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
}
export default function MenuManagement() {
const [rows, setRows] = useState<Menu[]>([])
const [keyword, setKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [err, setErr] = useState('')
const [form, setForm] = useState<{ open: boolean; edit: Menu | null }>({ open: false, edit: null })
const load = async (kw = '') => {
setLoading(true); setErr('')
try {
// 키워드 없으면 전체(paged) 목록, 있으면 검색 엔드포인트(List)
if (kw) {
const res = await menuSearch(kw)
setRows(res.data.data ?? [])
} else {
const res = await menuList()
setRows(res.data.data?.content ?? [])
}
}
catch (e) { setErr(friendlyError(e)) }
finally { setLoading(false) }
}
useEffect(() => { load('') }, [])
// 2-depth: 부모 → 자식 순으로 정렬해 들여쓰기 표시.
const ordered = useMemo(() => {
const roots = rows.filter(r => !r.parentMenuId).sort((a, b) => (a.sortOrd ?? 0) - (b.sortOrd ?? 0))
const out: { menu: Menu; depth: number }[] = []
for (const root of roots) {
out.push({ menu: root, depth: 0 })
rows.filter(r => r.parentMenuId === root.menuId)
.sort((a, b) => (a.sortOrd ?? 0) - (b.sortOrd ?? 0))
.forEach(c => out.push({ menu: c, depth: 1 }))
}
// 부모가 목록에 없는 고아 항목도 표시
const seen = new Set(out.map(o => o.menu.menuId))
rows.filter(r => !seen.has(r.menuId)).forEach(m => out.push({ menu: m, depth: 0 }))
return out.map(o => ({ ...o.menu, _depth: o.depth })) as (Menu & { _depth: number })[]
}, [rows])
const remove = async (m: Menu) => {
if (!window.confirm(`메뉴 '${m.menuNm}' 를 삭제하시겠습니까?`)) return
try { await deleteMenus([m.menuId]); await load(keyword) }
catch (e) { setErr(friendlyError(e)) }
}
const columns: Column<Menu & { _depth: number }>[] = [
{
key: 'menuNm', header: '메뉴명',
render: r => (
<span style={{ paddingLeft: r._depth * 22 }}>
{r._depth > 0 && <span style={{ color: 'var(--uiws-text-faint)' }}> </span>}
{r.menuNm} <span style={{ color: 'var(--uiws-text-muted)', fontSize: 12 }}>({r.menuId})</span>
</span>
),
},
{ key: 'menuUrl', header: 'URL', width: 220, render: r => r.menuUrl ?? '-' },
{ key: 'programId', header: '프로그램', width: 140, render: r => r.programId ?? '-' },
{ key: 'sortOrd', header: '정렬', width: 70, align: 'center' },
{ key: 'useYn', header: '사용', width: 80, align: 'center', render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
{ key: 'act', header: '', width: 60, align: 'center', render: r => <Button variant="ghost" onClick={() => remove(r)}></Button> },
]
return (
<div className="uiws-scope">
<PageHeader title="메뉴 관리" subtitle="시스템관리(권한) · 메뉴 트리(2-depth) 등록/수정/삭제"
actions={<Button onClick={() => setForm({ open: true, edit: null })}>+ </Button>} />
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginBottom: 12 }}>{err}</div>}
<SearchBar>
<Input value={keyword} onChange={setKeyword} placeholder="메뉴명 검색" style={{ minWidth: 220 }} />
<Button variant="ghost" onClick={() => load(keyword)}></Button>
</SearchBar>
{loading ? <Spinner /> : (
<DataGrid columns={columns} rows={ordered} rowKey={r => r.menuId}
onRowClick={r => setForm({ open: true, edit: r })} empty="등록된 메뉴가 없습니다." />
)}
{form.open && (
<MenuForm menu={form.edit} parents={rows.filter(r => !r.parentMenuId)}
onClose={() => setForm({ open: false, edit: null })}
onSaved={() => { setForm({ open: false, edit: null }); load(keyword) }} />
)}
</div>
)
}
function MenuForm({ menu, parents, onClose, onSaved }: {
menu: Menu | null; parents: Menu[]; onClose: () => void; onSaved: () => void
}) {
const editing = !!menu
const [menuId, setMenuId] = useState(menu?.menuId ?? '')
const [menuNm, setMenuNm] = useState(menu?.menuNm ?? '')
const [parentMenuId, setParentMenuId] = useState(menu?.parentMenuId ?? '')
const [programId, setProgramId] = useState(menu?.programId ?? '')
const [menuUrl, setMenuUrl] = useState(menu?.menuUrl ?? '')
const [sortOrd, setSortOrd] = useState(String(menu?.sortOrd ?? 0))
const [useYn, setUseYn] = useState(menu?.useYn ?? 'Y')
const [err, setErr] = useState('')
const save = async () => {
setErr('')
if (!editing && !menuId.trim()) { setErr('메뉴ID는 필수입니다.'); return }
if (!menuNm.trim()) { setErr('메뉴명은 필수입니다.'); return }
try {
const body = {
menuId, menuNm, parentMenuId: parentMenuId || null, programId: programId || null,
menuUrl: menuUrl || null, sortOrd: Number(sortOrd) || 0, useYn,
}
editing ? await updateMenu(menuId, body) : await createMenu(body)
onSaved()
} catch (e) { setErr(friendlyError(e)) }
}
const selStyle = {
width: '100%', padding: '9px 12px', borderRadius: 8, fontSize: 13,
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)',
} as const
return (
<Modal title={editing ? '메뉴 수정' : '메뉴 등록'} onClose={onClose}
footer={<><Button variant="ghost" onClick={onClose}></Button><Button onClick={save}></Button></>}>
<FormField label="메뉴ID"><Input value={menuId} onChange={setMenuId} style={{ width: '100%', opacity: editing ? 0.6 : 1 }} /></FormField>
<FormField label="메뉴명"><Input value={menuNm} onChange={setMenuNm} style={{ width: '100%' }} /></FormField>
<FormField label="상위 메뉴">
<select value={parentMenuId ?? ''} onChange={e => setParentMenuId(e.target.value)} style={selStyle}>
<option value="">()</option>
{parents.filter(p => p.menuId !== menuId).map(p => <option key={p.menuId} value={p.menuId}>{p.menuNm} ({p.menuId})</option>)}
</select>
</FormField>
<FormField label="프로그램ID"><Input value={programId ?? ''} onChange={setProgramId} style={{ width: '100%' }} /></FormField>
<FormField label="메뉴 URL"><Input value={menuUrl ?? ''} onChange={setMenuUrl} style={{ width: '100%' }} /></FormField>
<FormField label="정렬순서"><Input type="number" value={sortOrd} onChange={setSortOrd} style={{ width: '100%' }} /></FormField>
<FormField label="사용 여부">
<select value={useYn} onChange={e => setUseYn(e.target.value)} style={selStyle}>
<option value="Y"></option>
<option value="N"></option>
</select>
</FormField>
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
</Modal>
)
}

View File

@ -0,0 +1,132 @@
import { useEffect, useState } from 'react'
import {
roleList, createRole, updateRole, deleteRoles,
} from '../../../api/uiws'
import {
PageHeader, SearchBar, Input, Button, DataGrid, Modal, FormField, Spinner, YnBadge, type Column,
} from '../../../components/uiws/ui'
interface RoleRow extends Record<string, unknown> {
roleId: string; roleNm: string; roleDesc: string; useYn: string
}
function friendlyError(e: any): string {
if (e?.response?.status === 403) return '권한이 없습니다 (SUPERADMIN 전용).'
if (e?.response?.status === 409) return '이미 존재하는 권한ID입니다.'
return e?.response?.data?.message || '요청을 처리하지 못했습니다. 잠시 후 다시 시도하세요.'
}
export default function RoleManagement() {
const [rows, setRows] = useState<RoleRow[]>([])
const [keyword, setKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [err, setErr] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [edit, setEdit] = useState<RoleRow | null>(null)
const [showForm, setShowForm] = useState(false)
const load = async (kw = keyword) => {
setLoading(true); setErr('')
try {
const res = await roleList(kw || undefined)
// CMS 백엔드 목록은 PageResponse → data.content
setRows(res.data.data?.content ?? [])
setSelected(new Set())
} catch (e) { setErr(friendlyError(e)) }
finally { setLoading(false) }
}
useEffect(() => { load('') }, [])
const toggleSel = (id: string) =>
setSelected(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n })
const removeSelected = async () => {
if (selected.size === 0) return
if (!window.confirm(`선택한 권한 ${selected.size}건을 삭제하시겠습니까?`)) return
try { await deleteRoles([...selected]); await load() }
catch (e) { setErr(friendlyError(e)) }
}
const columns: Column<RoleRow>[] = [
{
key: 'sel', header: '', width: 44, align: 'center',
render: r => (
<input type="checkbox" checked={selected.has(r.roleId)}
onClick={e => e.stopPropagation()} onChange={() => toggleSel(r.roleId)} />
),
},
{ key: 'roleId', header: '권한ID', width: 180 },
{ key: 'roleNm', header: '권한명' },
{ key: 'roleDesc', header: '설명' },
{ key: 'useYn', header: '사용', width: 80, align: 'center', render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
]
return (
<div className="uiws-scope">
<PageHeader title="권한 관리" subtitle="시스템관리(권한) · 역할(Role) 등록/수정/삭제 — RBAC"
actions={
<>
<Button variant="danger" onClick={removeSelected} disabled={selected.size === 0}> </Button>
<Button onClick={() => { setEdit(null); setShowForm(true) }}>+ </Button>
</>
} />
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginBottom: 12 }}>{err}</div>}
<SearchBar>
<Input value={keyword} onChange={setKeyword} placeholder="권한ID/권한명 검색" style={{ minWidth: 240 }} />
<Button variant="ghost" onClick={() => load()}></Button>
</SearchBar>
{loading ? <Spinner /> : (
<DataGrid columns={columns} rows={rows} rowKey={r => r.roleId}
onRowClick={r => { setEdit(r); setShowForm(true) }} empty="등록된 권한이 없습니다." />
)}
{showForm && (
<RoleForm role={edit} onClose={() => setShowForm(false)}
onSaved={() => { setShowForm(false); load() }} />
)}
</div>
)
}
function RoleForm({ role, onClose, onSaved }: { role: RoleRow | null; onClose: () => void; onSaved: () => void }) {
const editing = !!role
const [roleId, setRoleId] = useState(role?.roleId ?? '')
const [roleNm, setRoleNm] = useState(role?.roleNm ?? '')
const [roleDesc, setRoleDesc] = useState(role?.roleDesc ?? '')
const [useYn, setUseYn] = useState(role?.useYn ?? 'Y')
const [err, setErr] = useState('')
const save = async () => {
setErr('')
if (!roleNm.trim()) { setErr('권한명은 필수입니다.'); return }
if (!editing && !roleId.trim()) { setErr('권한ID는 필수입니다.'); return }
try {
const body = { roleId, roleNm, roleDesc, useYn }
editing ? await updateRole(roleId, body) : await createRole(body)
onSaved()
} catch (e) { setErr(friendlyError(e)) }
}
return (
<Modal title={editing ? '권한 수정' : '권한 등록'} onClose={onClose}
footer={<><Button variant="ghost" onClick={onClose}></Button><Button onClick={save}></Button></>}>
<FormField label="권한ID">
<Input value={roleId} onChange={setRoleId} style={{ width: '100%', opacity: editing ? 0.6 : 1 }} />
</FormField>
<FormField label="권한명"><Input value={roleNm} onChange={setRoleNm} style={{ width: '100%' }} /></FormField>
<FormField label="설명"><Input value={roleDesc} onChange={setRoleDesc} style={{ width: '100%' }} /></FormField>
<FormField label="사용 여부">
<select value={useYn} onChange={e => setUseYn(e.target.value)}
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, fontSize: 13,
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)' }}>
<option value="Y"></option>
<option value="N"></option>
</select>
</FormField>
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
</Modal>
)
}

View File

@ -0,0 +1,145 @@
import { useEffect, useState } from 'react'
import { roleMenuRoles, roleMenuMapping, saveRoleMenuMapping } from '../../../api/uiws'
import { PageHeader, Panel, Button, Spinner } from '../../../components/uiws/ui'
interface Role { roleId: string; roleNm: string; useYn: string }
interface RoleMenuRow { menuId: string; menuNm: string; readYn: string; writeYn: string }
function friendlyError(e: any): string {
if (e?.response?.status === 403) return '권한이 없습니다 (SUPERADMIN 전용).'
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
}
export default function RoleMenuPermission() {
const [roles, setRoles] = useState<Role[]>([])
const [activeRole, setActiveRole] = useState<string>('')
const [rows, setRows] = useState<RoleMenuRow[]>([])
const [loadingRoles, setLoadingRoles] = useState(false)
const [loadingMap, setLoadingMap] = useState(false)
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
const [err, setErr] = useState('')
useEffect(() => {
setLoadingRoles(true)
roleMenuRoles()
.then(r => {
// CMS 백엔드 role-menus 목록은 PageResponse → data.content
const list: Role[] = r.data.data?.content ?? []
setRoles(list)
if (list.length > 0) setActiveRole(list[0].roleId)
})
.catch(e => setErr(friendlyError(e)))
.finally(() => setLoadingRoles(false))
}, [])
useEffect(() => {
if (!activeRole) return
setLoadingMap(true); setMsg(''); setErr('')
roleMenuMapping(activeRole)
.then(r => setRows(r.data.data ?? []))
.catch(e => setErr(friendlyError(e)))
.finally(() => setLoadingMap(false))
}, [activeRole])
// 쓰기를 켜면 읽기도 자동 ON.
const toggle = (menuId: string, field: 'readYn' | 'writeYn') =>
setRows(prev => prev.map(r => {
if (r.menuId !== menuId) return r
const next = r[field] === 'Y' ? 'N' : 'Y'
if (field === 'writeYn' && next === 'Y') return { ...r, writeYn: 'Y', readYn: 'Y' }
if (field === 'readYn' && next === 'N') return { ...r, readYn: 'N', writeYn: 'N' }
return { ...r, [field]: next }
}))
const checkAll = (field: 'readYn' | 'writeYn', on: boolean) =>
setRows(prev => prev.map(r =>
on && field === 'writeYn' ? { ...r, writeYn: 'Y', readYn: 'Y' }
: !on && field === 'readYn' ? { ...r, readYn: 'N', writeYn: 'N' }
: { ...r, [field]: on ? 'Y' : 'N' }))
const save = async () => {
setSaving(true); setMsg(''); setErr('')
try {
await saveRoleMenuMapping(activeRole, rows)
setMsg('메뉴 권한 매핑이 저장되었습니다.')
} catch (e) { setErr(friendlyError(e)) }
finally { setSaving(false) }
}
return (
<div className="uiws-scope">
<PageHeader title="역할-메뉴 권한" subtitle="시스템관리(권한) · 역할별 메뉴 접근(읽기/쓰기) 매핑 — RBAC 핵심"
actions={<Button onClick={save} disabled={saving || !activeRole}>{saving ? '저장 중...' : '매핑 저장'}</Button>} />
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginBottom: 12 }}>{err}</div>}
{msg && <div style={{ color: 'var(--uiws-success)', fontSize: 13, marginBottom: 12 }}>{msg}</div>}
<div style={{ display: 'grid', gridTemplateColumns: '260px 1fr', gap: 16, alignItems: 'start' }}>
<Panel style={{ padding: 0 }}>
<div style={{ padding: '12px 16px', fontSize: 12, fontWeight: 600, color: 'var(--uiws-text-muted)', borderBottom: '1px solid var(--uiws-border)' }}>
</div>
{loadingRoles ? <Spinner /> : (
<div>
{roles.length === 0 && <div style={{ padding: 20, fontSize: 13, color: 'var(--uiws-text-faint)' }}> .</div>}
{roles.map(role => (
<div key={role.roleId} onClick={() => setActiveRole(role.roleId)}
style={{
padding: '11px 16px', cursor: 'pointer', fontSize: 13,
borderLeft: `3px solid ${activeRole === role.roleId ? 'var(--uiws-primary)' : 'transparent'}`,
background: activeRole === role.roleId ? 'var(--uiws-primary-soft)' : 'transparent',
color: activeRole === role.roleId ? 'var(--uiws-primary)' : 'var(--uiws-text)',
}}>
<div style={{ fontWeight: 600 }}>{role.roleNm}</div>
<div style={{ fontSize: 11, color: 'var(--uiws-text-muted)' }}>{role.roleId}</div>
</div>
))}
</div>
)}
</Panel>
<Panel style={{ padding: 0, overflow: 'hidden' }}>
{loadingMap ? <Spinner /> : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13, color: 'var(--uiws-text)' }}>
<thead>
<tr style={{ background: 'var(--uiws-surface-2)' }}>
<th style={{ textAlign: 'left', padding: '11px 14px', color: 'var(--uiws-text-muted)', fontWeight: 600, fontSize: 12, borderBottom: '1px solid var(--uiws-border)' }}></th>
<th style={{ width: 120, padding: '8px 14px', color: 'var(--uiws-text-muted)', fontSize: 12, borderBottom: '1px solid var(--uiws-border)' }}>
<br />
<label style={{ fontWeight: 400, fontSize: 11 }}>
<input type="checkbox" onChange={e => checkAll('readYn', e.target.checked)} />
</label>
</th>
<th style={{ width: 120, padding: '8px 14px', color: 'var(--uiws-text-muted)', fontSize: 12, borderBottom: '1px solid var(--uiws-border)' }}>
<br />
<label style={{ fontWeight: 400, fontSize: 11 }}>
<input type="checkbox" onChange={e => checkAll('writeYn', e.target.checked)} />
</label>
</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={3} style={{ padding: 28, textAlign: 'center', color: 'var(--uiws-text-faint)' }}> .</td></tr>
) : rows.map(r => (
<tr key={r.menuId} style={{ borderBottom: '1px solid var(--uiws-border)' }}>
<td style={{ padding: '11px 14px' }}>
{r.menuNm} <span style={{ color: 'var(--uiws-text-muted)', fontSize: 12 }}>({r.menuId})</span>
</td>
<td style={{ padding: '11px 14px', textAlign: 'center' }}>
<input type="checkbox" checked={r.readYn === 'Y'} onChange={() => toggle(r.menuId, 'readYn')} />
</td>
<td style={{ padding: '11px 14px', textAlign: 'center' }}>
<input type="checkbox" checked={r.writeYn === 'Y'} onChange={() => toggle(r.menuId, 'writeYn')} />
</td>
</tr>
))}
</tbody>
</table>
)}
</Panel>
</div>
</div>
)
}