feat: UIMS 권한관리(시스템관리) 관리자 React 화면 추가
This commit is contained in:
parent
26fe206018
commit
e215908f7f
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
584
backend/src/main/resources/static/assets/index-_1lL5Le4.js
Normal file
584
backend/src/main/resources/static/assets/index-_1lL5Le4.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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>
|
||||
|
||||
@ -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 />} />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 } })
|
||||
|
||||
238
frontend/src/pages/uiws/system/CodeManagement.tsx
Normal file
238
frontend/src/pages/uiws/system/CodeManagement.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
127
frontend/src/pages/uiws/system/CompanyManagement.tsx
Normal file
127
frontend/src/pages/uiws/system/CompanyManagement.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
frontend/src/pages/uiws/system/DeptManagement.tsx
Normal file
141
frontend/src/pages/uiws/system/DeptManagement.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
163
frontend/src/pages/uiws/system/MenuManagement.tsx
Normal file
163
frontend/src/pages/uiws/system/MenuManagement.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
130
frontend/src/pages/uiws/system/RoleManagement.tsx
Normal file
130
frontend/src/pages/uiws/system/RoleManagement.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
145
frontend/src/pages/uiws/system/RoleMenuPermission.tsx
Normal file
145
frontend/src/pages/uiws/system/RoleMenuPermission.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { roleMenuRoles, roleMenuMapping, saveRoleMenuMapping } from '../../../api/uiws'
|
||||
import { PageHeader, Panel, Button, Spinner } from '../../../components/uiws/ui'
|
||||
|
||||
interface Role { roleId: string; roleNm: string; useYn: string }
|
||||
interface RoleMenuRow { menuId: string; menuNm: string; readYn: string; writeYn: string }
|
||||
|
||||
function friendlyError(e: any): string {
|
||||
if (e?.response?.status === 403) return '권한이 없습니다 (관리자 전용).'
|
||||
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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user