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

This commit is contained in:
GUARDiA 2026-06-21 22:05:53 +09:00
parent 26fe206018
commit e215908f7f
14 changed files with 1617 additions and 542 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

@ -15,8 +15,8 @@
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Playfair+Display:wght@500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
<script type="module" crossorigin src="/assets/index-DVbshYmF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-xTI20zKg.css">
<script type="module" crossorigin src="/assets/index-_1lL5Le4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B51ISNUZ.css">
</head>
<body>
<div id="root"></div>

View File

@ -49,6 +49,13 @@ import UiwsWorklog from './pages/uiws/WorklogList'
import UiwsSchedule from './pages/uiws/ScheduleCalendar'
import UiwsMessage from './pages/uiws/MessageBox'
import UiwsStats from './pages/uiws/StatsPivot'
// UIWS 시스템관리(권한 RBAC) — 관리자 영역 (ADMIN 전용)
import UiwsRole from './pages/uiws/system/RoleManagement'
import UiwsRoleMenu from './pages/uiws/system/RoleMenuPermission'
import UiwsCode from './pages/uiws/system/CodeManagement'
import UiwsMenu from './pages/uiws/system/MenuManagement'
import UiwsDept from './pages/uiws/system/DeptManagement'
import UiwsCompany from './pages/uiws/system/CompanyManagement'
const qc = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 30_000 } },
@ -109,6 +116,13 @@ export default function App() {
<Route path="uiws/schedule" element={<UiwsLayout><UiwsSchedule /></UiwsLayout>} />
<Route path="uiws/message" element={<UiwsLayout><UiwsMessage /></UiwsLayout>} />
<Route path="uiws/stats" element={<UiwsLayout><UiwsStats /></UiwsLayout>} />
{/* UIWS 시스템관리(권한 RBAC) — ADMIN 전용(사이드바 가드 + 백엔드 권한) */}
<Route path="uiws/system/roles" element={<UiwsLayout><UiwsRole /></UiwsLayout>} />
<Route path="uiws/system/role-menus" element={<UiwsLayout><UiwsRoleMenu /></UiwsLayout>} />
<Route path="uiws/system/codes" element={<UiwsLayout><UiwsCode /></UiwsLayout>} />
<Route path="uiws/system/menus" element={<UiwsLayout><UiwsMenu /></UiwsLayout>} />
<Route path="uiws/system/depts" element={<UiwsLayout><UiwsDept /></UiwsLayout>} />
<Route path="uiws/system/companies" element={<UiwsLayout><UiwsCompany /></UiwsLayout>} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />

View File

@ -4,6 +4,7 @@ import {
LayoutDashboard, Store, Flower2, Boxes, ShoppingBag, Users, Crown, Megaphone,
Repeat, CalendarClock, BarChart3, UserCog, ScrollText, Settings, LogOut, UserCircle, ArrowLeftRight, Smartphone,
ClipboardList, CalendarDays, Mail, PieChart, Sparkles,
ShieldCheck, KeySquare, ListTree, Menu as MenuIcon, Building2, Briefcase,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { getMe } from '../api/client'
@ -40,6 +41,15 @@ const uiwsLinks = [
{ to: '/admin/uiws/message', label: '쪽지 Message', icon: Mail, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/uiws/stats', label: '업무통계 Stats', icon: PieChart, roles: ['ADMIN', 'MANAGER'] },
]
// UIWS 시스템관리(권한 RBAC) — SuperAdmin(ADMIN) 전용. 고객앱/기존 admin 보존, 백엔드 권한과 이중 가드.
const sysLinks = [
{ to: '/admin/uiws/system/roles', label: '권한 관리', icon: ShieldCheck, roles: ['ADMIN'] },
{ to: '/admin/uiws/system/role-menus', label: '역할-메뉴 권한', icon: KeySquare, roles: ['ADMIN'] },
{ to: '/admin/uiws/system/codes', label: '공통코드', icon: ListTree, roles: ['ADMIN'] },
{ to: '/admin/uiws/system/menus', label: '메뉴 관리', icon: MenuIcon, roles: ['ADMIN'] },
{ to: '/admin/uiws/system/depts', label: '부서 관리', icon: Building2, roles: ['ADMIN'] },
{ to: '/admin/uiws/system/companies', label: '거래처 관리', icon: Briefcase, roles: ['ADMIN'] },
]
const linkClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 px-5 py-2.5 text-sm transition-colors ${isActive ? 'bg-card text-brand border-r-2 border-brand' : 'text-slate-300 hover:bg-card/60'}`
@ -71,6 +81,7 @@ export default function AdminLayout() {
const visibleAdmin = adminLinks.filter(l => l.roles.includes(role))
const visibleAi = aiLinks.filter(l => !role || l.roles.includes(role))
const visibleUiws = uiwsLinks.filter(l => !role || l.roles.includes(role))
const visibleSys = sysLinks.filter(l => l.roles.includes(role)) // ADMIN 전용(role 비어있으면 비표시)
const logout = () => {
localStorage.removeItem('mall_admin_token'); localStorage.removeItem('mall_role'); localStorage.removeItem('mall_admin_user')
@ -98,6 +109,10 @@ export default function AdminLayout() {
<div className="px-5 pt-4 pb-1.5 text-[10px] font-semibold tracking-wider text-slate-500 uppercase border-t border-edge mt-3"> (UIWS)</div>
{visibleUiws.map(({ to, label, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{label}</NavLink>)}
</>)}
{visibleSys.length > 0 && (<>
<div className="px-5 pt-4 pb-1.5 text-[10px] font-semibold tracking-wider text-slate-500 uppercase border-t border-edge mt-3"> ()</div>
{visibleSys.map(({ to, label, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{label}</NavLink>)}
</>)}
</nav>
<div className="p-4 text-[11px] text-slate-500 border-t border-edge">{t('admin.onPremiseTag')}</div>
</aside>

View File

@ -75,3 +75,60 @@ export const confirmWorklogComment = (cmtId: number) => api.post(`/api/worklogs/
// ── 통계(stats)
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 })
// ── 시스템관리(권한 RBAC · 공통코드 · 메뉴 · 부서 · 거래처) ───────────────────
// base /api/system/... · 응답 봉투 { success, message, data }.
// ★ Mall 백엔드 실측 계약(맹목 복사 금지):
// - 목록(roles/menus/depts/companies/codes-groups)은 PageResponse { content,total,page,size }.
// - 코드값은 그룹단위 저장(CodeGrpSaveDto.values[]). per-codeVal 엔드포인트 없음.
// - 코드 base 는 /api/system/codes (그룹), 그룹상세 GET /{grpCd} 에 values[] 동봉.
// - role-menus: GET /api/system/role-menus (PageResponse<role>) · roles/{id}/menus GET/PUT.
// - 삭제: roles/menus/companies = DELETE 본문 {ids} / depts·codes = path 단건.
// 페이지/단건 응답 타입(프론트 소비용)
export interface PageResponseT<T> { content: T[]; total: number; page: number; size: number }
// 역할(roles) — base /api/system/roles
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)
export const roleMenuRoles = (page = 0, size = 200) => 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) — 그룹 목록/상세(values 동봉)/그룹 단위 CRUD
export const codeGroupList = (keyword?: string, page = 0, size = 200) =>
api.get('/api/system/codes', { params: { ...(keyword ? { keyword } : {}), page, size } })
export const codeGroupDetail = (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 saveCodeGroup = (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) — 트리목록(PageResponse)/검색팝업/CRUD(삭제 본문 {ids})
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) — 목록(PageResponse)/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) — 목록(PageResponse)/CRUD(삭제 본문 {ids})
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

@ -0,0 +1,238 @@
import { useEffect, useState } from 'react'
import {
codeGroupList, codeGroupDetail, saveCodeGroup, 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 CodeVal extends Record<string, unknown> { grpCd: string; codeVal: string; codeNm: string; sortOrd: number; useYn: string }
interface CodeGrpDetail { grpCd: string; grpNm: string; useYn: string; values: CodeVal[] }
function friendlyError(e: any): string {
if (e?.response?.status === 403) return '권한이 없습니다 (관리자 전용).'
if (e?.response?.status === 409) return '이미 존재하는 코드그룹입니다.'
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
}
/**
* Mall 계약: 코드값은 (CodeGrpSaveDto.values[]).
* per-codeVal CRUD .
* 좌측: 코드그룹 (PageResponse content) / 우측: 선택 ( values).
*/
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)
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); setDetail(res.data.data ?? 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()
} catch (e) { setErr(friendlyError(e)) }
}
const openEdit = async (grpCd: string) => {
try { const res = await codeGroupDetail(grpCd); setForm({ open: true, edit: res.data.data ?? null }) }
catch (e) { setErr(friendlyError(e)) }
}
const codeColumns: Column<CodeVal>[] = [
{ key: 'codeVal', header: '코드값', width: 160 },
{ 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={() => openEdit(g.grpCd)}></Button>
<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={() => activeGrp && openEdit(activeGrp)} disabled={!activeGrp}> </Button>
</div>
{loadingDetail ? <Spinner /> : (
<DataGrid columns={codeColumns} rows={detail?.values ?? []} rowKey={r => `${r.grpCd}:${r.codeVal}`}
empty={activeGrp ? '코드값이 없습니다.' : '코드그룹을 선택하세요.'} />
)}
</div>
</div>
{form.open && (
<CodeGroupForm detail={form.edit} onClose={() => setForm({ open: false, edit: null })}
onSaved={(grpCd) => {
setForm({ open: false, edit: null })
setActiveGrp(grpCd)
loadGroups(keyword); loadDetail(grpCd)
}} />
)}
</div>
)
}
function SelectYn({ value, onChange, style }: { value: string; onChange: (v: string) => void; style?: React.CSSProperties }) {
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)', ...style }}>
<option value="Y"></option>
<option value="N"></option>
</select>
)
}
interface EditableVal { codeVal: string; codeNm: string; sortOrd: number; useYn: string }
function CodeGroupForm({ detail, onClose, onSaved }: {
detail: CodeGrpDetail | null; onClose: () => void; onSaved: (grpCd: string) => void
}) {
const editing = !!detail
const [grpCd, setGrpCd] = useState(detail?.grpCd ?? '')
const [grpNm, setGrpNm] = useState(detail?.grpNm ?? '')
const [useYn, setUseYn] = useState(detail?.useYn ?? 'Y')
const [values, setValues] = useState<EditableVal[]>(
(detail?.values ?? []).map(v => ({ codeVal: v.codeVal, codeNm: v.codeNm, sortOrd: v.sortOrd ?? 0, useYn: v.useYn ?? 'Y' })))
const [err, setErr] = useState('')
const setVal = (i: number, patch: Partial<EditableVal>) =>
setValues(prev => prev.map((v, idx) => idx === i ? { ...v, ...patch } : v))
const addRow = () => setValues(prev => [...prev, { codeVal: '', codeNm: '', sortOrd: prev.length, useYn: 'Y' }])
const delRow = (i: number) => setValues(prev => prev.filter((_, idx) => idx !== i))
const save = async () => {
setErr('')
if (!editing && !grpCd.trim()) { setErr('그룹코드는 필수입니다.'); return }
if (!grpNm.trim()) { setErr('그룹명은 필수입니다.'); return }
for (const v of values) {
if (!v.codeVal.trim()) { setErr('코드값은 비어 있을 수 없습니다.'); return }
if (!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 saveCodeGroup(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', margin: '8px 0 6px' }}>
<div style={{ fontSize: 12, color: 'var(--uiws-text-muted)' }}> ({values.length})</div>
<Button variant="ghost" onClick={addRow}>+ </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', fontSize: 11, color: 'var(--uiws-text-muted)' }}></th>
<th style={{ padding: '7px 8px', textAlign: 'left', fontSize: 11, color: 'var(--uiws-text-muted)' }}></th>
<th style={{ padding: '7px 8px', width: 64, fontSize: 11, color: 'var(--uiws-text-muted)' }}></th>
<th style={{ padding: '7px 8px', width: 80, fontSize: 11, color: 'var(--uiws-text-muted)' }}></th>
<th style={{ padding: '7px 8px', width: 44 }}></th>
</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, i) => (
<tr key={i} style={{ borderTop: '1px solid var(--uiws-border)' }}>
<td style={{ padding: '5px 8px' }}><input value={v.codeVal} onChange={e => setVal(i, { codeVal: e.target.value })} style={cellInput} /></td>
<td style={{ padding: '5px 8px' }}><input value={v.codeNm} onChange={e => setVal(i, { codeNm: e.target.value })} style={cellInput} /></td>
<td style={{ padding: '5px 8px' }}><input type="number" value={String(v.sortOrd)} onChange={e => setVal(i, { sortOrd: Number(e.target.value) || 0 })} style={cellInput} /></td>
<td style={{ padding: '5px 8px' }}><SelectYn value={v.useYn} onChange={vv => setVal(i, { useYn: vv })} style={{ padding: '6px 8px', fontSize: 12 }} /></td>
<td style={{ padding: '5px 8px', textAlign: 'center' }}>
<button onClick={() => delRow(i)} title="행 삭제"
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,127 @@
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 '권한이 없습니다 (관리자 전용).'
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 [selected, setSelected] = useState<Set<string>>(new Set())
const [form, setForm] = useState<{ open: boolean; edit: Company | null }>({ open: false, edit: null })
// Mall 계약: 거래처 목록은 PageResponse → content. 삭제는 본문 {ids}.
const load = async (kw = '') => {
setLoading(true); setErr('')
try {
const res = await companyList(kw || undefined)
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 deleteCompanies([...selected]); await load() } catch (e) { setErr(friendlyError(e)) }
}
const columns: Column<Company>[] = [
{
key: 'sel', header: '', width: 44, align: 'center',
render: r => (
<input type="checkbox" checked={selected.has(r.companyId)}
onClick={e => e.stopPropagation()} onChange={() => toggleSel(r.companyId)} />
),
},
{ 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="미사용" /> },
]
return (
<div className="uiws-scope">
<PageHeader title="거래처 관리" subtitle="시스템관리(권한) · 거래처(근무처) 등록/수정/삭제"
actions={
<>
<Button variant="danger" onClick={removeSelected} disabled={selected.size === 0}> </Button>
<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,141 @@
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 '권한이 없습니다 (관리자 전용).'
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 })
// Mall 계약: 부서 목록은 PageResponse → content. 삭제는 path 단건.
const load = async (kw = '') => {
setLoading(true); setErr('')
try { const res = await deptList(kw || undefined); 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() } 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,163 @@
import { useEffect, useMemo, useState } from 'react'
import { menuList, 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 '권한이 없습니다 (관리자 전용).'
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 })
// Mall 계약: 메뉴 목록은 PageResponse(트리 전체) → content. 검색은 클라이언트 필터.
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() }, [])
const filtered = useMemo(() => {
const kw = keyword.trim().toLowerCase()
if (!kw) return rows
const hit = (m: Menu) => m.menuNm.toLowerCase().includes(kw) || m.menuId.toLowerCase().includes(kw)
const hitIds = new Set(rows.filter(hit).map(r => r.menuId))
// 매칭 행 + 그 부모를 함께 노출(트리 컨텍스트 유지)
rows.forEach(r => { if (hitIds.has(r.menuId) && r.parentMenuId) hitIds.add(r.parentMenuId) })
return rows.filter(r => hitIds.has(r.menuId))
}, [rows, keyword])
// 2-depth: 부모 → 자식 순으로 정렬해 들여쓰기 표시.
const ordered = useMemo(() => {
const roots = filtered.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 })
filtered.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))
filtered.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 })[]
}, [filtered])
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={() => { /* 클라이언트 필터(즉시 반영) */ }}></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,130 @@
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 '권한이 없습니다 (관리자 전용).'
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)
// Mall 계약: 목록은 PageResponse { content, total, page, size }
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 '권한이 없습니다 (관리자 전용).'
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 => {
// Mall 계약: PageResponse<RoleDto> → 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>
)
}