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

This commit is contained in:
GUARDiA 2026-06-21 22:07:09 +09:00
parent 337c8beffa
commit ccfdfb6a3e
15 changed files with 1406 additions and 120 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>zioinfo-esn — ESL 통합 관리 플랫폼</title>
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<script type="module" crossorigin src="/assets/index-C74GP7J-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CJ_-BW0p.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -19,12 +19,19 @@ import TenantAdmin from './pages/TenantAdmin'
import AiAnalysis from './pages/AiAnalysis'
import TagBindingList from './pages/TagBindingList'
import UpdateQueueList from './pages/UpdateQueueList'
import MobileApp from './pages/MobileApp'
// UIWS(UIMS) 이식: 업무일지·일정·쪽지·통계
import WorklogList from './pages/uiws/WorklogList'
import ScheduleCalendar from './pages/uiws/ScheduleCalendar'
import MessageBox from './pages/uiws/MessageBox'
import StatsPivot from './pages/uiws/StatsPivot'
import SystemRoles from './pages/uiws/SystemRoles'
// UIWS system(권한관리) 이식: 역할/역할-메뉴/공통코드/메뉴/부서/거래처 (관리자 전용)
import RoleManagement from './pages/uiws/system/RoleManagement'
import RoleMenuManagement from './pages/uiws/system/RoleMenuManagement'
import CodeManagement from './pages/uiws/system/CodeManagement'
import MenuManagement from './pages/uiws/system/MenuManagement'
import DeptManagement from './pages/uiws/system/DeptManagement'
import CompanyManagement from './pages/uiws/system/CompanyManagement'
const qc = new QueryClient()
@ -53,12 +60,20 @@ export default function App() {
<Route path="/users" element={<UserList />} />
<Route path="/tenants" element={<TenantAdmin />} />
<Route path="/ai" element={<AiAnalysis />} />
{/* 통합 메신저 앱 다운로드 QR (ITSM 중앙 APK 저장소 공개 엔드포인트 재사용·읽기전용) */}
<Route path="/mobile-app" element={<MobileApp />} />
{/* UIWS(UIMS) 이식: 공통 업무협업 레이어 */}
<Route path="/worklogs" element={<WorklogList />} />
<Route path="/schedules" element={<ScheduleCalendar />} />
<Route path="/messages" element={<MessageBox />} />
<Route path="/work-stats" element={<StatsPivot />} />
<Route path="/system/roles" element={<SystemRoles />} />
{/* UIWS 시스템관리(권한) — 관리자 전용(AdminGate + 백엔드 403) */}
<Route path="/system/roles" element={<RoleManagement />} />
<Route path="/system/role-menus" element={<RoleMenuManagement />} />
<Route path="/system/codes" element={<CodeManagement />} />
<Route path="/system/menus" element={<MenuManagement />} />
<Route path="/system/depts" element={<DeptManagement />} />
<Route path="/system/companies" element={<CompanyManagement />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>

View File

@ -72,9 +72,50 @@ export const findId = (body: { displayName: string; email: string }) =>
export const resetPassword = (body: { username: string; email: string }) =>
api.post('/api/auth/reset-password', body)
// ── system(권한관리) — 역할/메뉴 (RBAC: 조회 인증, 변경 ADMIN/MANAGER)
// ── system(권한관리) — RBAC: 조회 인증, 변경 ADMIN/MANAGER (백엔드 com.zioinfo.esn.uiws.system, /api/system/*)
// 응답 봉투 { success, message, data }. 목록은 data=PageResponse{content,total,page,size}. 호출부 res.data.data.* 접근.
// 2.1 권한(roles) — keyword/page/size, 다중삭제(본문 ids)
export const roleList = (params: Record<string, unknown>) => api.get('/api/system/roles', { params })
export const roleCreate = (body: object) => api.post('/api/system/roles', body)
export const roleGet = (id: string) => api.get(`/api/system/roles/${id}`)
export const roleUpdate = (id: string, body: object) => api.put(`/api/system/roles/${id}`, body)
export const roleDelete = (ids: string[]) => api.delete('/api/system/roles', { data: { ids } })
// 2.5 메뉴생성(role-menus) — 권한별 메뉴 읽기/쓰기 매핑
export const roleMenuRoles = (params: Record<string, unknown>) => api.get('/api/system/role-menus', { params })
export const roleMenusGet = (roleId: string) => api.get(`/api/system/roles/${roleId}/menus`)
export const roleMenusSave = (roleId: string, menus: object[]) => api.put(`/api/system/roles/${roleId}/menus`, { menus })
// 2.7 코드(codes) — 그룹목록/상세/CRUD + 드롭다운값
export const codeList = (params: Record<string, unknown>) => api.get('/api/system/codes', { params })
export const codeGroupGet = (grpCd: string) => api.get(`/api/system/codes/${grpCd}`)
export const codeValues = (grpCd: string) => api.get(`/api/system/codes/group/${grpCd}/values`)
export const codeCreate = (body: object) => api.post('/api/system/codes', body)
export const codeUpdate = (grpCd: string, body: object) => api.put(`/api/system/codes/${grpCd}`, body)
export const codeDelete = (grpCd: string) => api.delete(`/api/system/codes/${grpCd}`)
// 2.9 메뉴(menus) — 목록/검색/CRUD/다중삭제(본문 ids)
export const menuList = (params: Record<string, unknown>) => api.get('/api/system/menus', { params })
export const menuGet = (id: string) => api.get(`/api/system/menus/${id}`)
export const menuCreate = (body: object) => api.post('/api/system/menus', body)
export const menuUpdate = (id: string, body: object) => api.put(`/api/system/menus/${id}`, body)
export const menuDelete = (ids: string[]) => api.delete('/api/system/menus', { data: { ids } })
// 2.8 프로그램(programs) — 메뉴 연결용
export const programList = (params: Record<string, unknown>) => api.get('/api/system/programs', { params })
// 2.3 부서(depts) — 목록/검색/CRUD(개별삭제 path)
export const deptList = (params: Record<string, unknown>) => api.get('/api/system/depts', { params })
export const deptTree = () => api.get('/api/system/depts/tree')
export const deptGet = (id: string) => api.get(`/api/system/depts/${id}`)
export const deptCreate = (body: object) => api.post('/api/system/depts', body)
export const deptUpdate = (id: string, body: object) => api.put(`/api/system/depts/${id}`, body)
export const deptDelete = (id: string) => api.delete(`/api/system/depts/${id}`)
// 2.4 거래처(companies) — 목록/검색/CRUD/다중삭제(본문 ids)
export const companyList = (params: Record<string, unknown>) => api.get('/api/system/companies', { params })
export const companyGet = (id: string) => api.get(`/api/system/companies/${id}`)
export const companyCreate = (body: object) => api.post('/api/system/companies', body)
export const companyUpdate = (id: string, body: object) => api.put(`/api/system/companies/${id}`, body)
export const companyDelete = (ids: string[]) => api.delete('/api/system/companies', { data: { ids } })

View File

@ -2,7 +2,8 @@ import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, Store, FileText, RefreshCw,
Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain,
Link2, ListOrdered, BookOpen, CalendarDays, Mail, BarChart3, ShieldCheck
Link2, ListOrdered, BookOpen, CalendarDays, Mail, BarChart3, ShieldCheck, Smartphone,
KeyRound, Tags, Menu as MenuIcon, Network, Briefcase
} from 'lucide-react'
const nav = [
@ -25,10 +26,30 @@ const nav = [
{ to: '/schedules', icon: CalendarDays, label: '일정 관리' },
{ to: '/messages', icon: Mail, label: '쪽지' },
{ to: '/work-stats', icon: BarChart3, label: '업무 통계' },
{ to: '/system/roles',icon: ShieldCheck, label: '역할/권한' },
{ to: '/mobile-app', icon: Smartphone, label: '모바일 앱' },
]
// UIWS system(권한관리) — 관리자(ADMIN/MANAGER) 전용. ESL/업무모듈 메뉴는 위 nav에 보존.
const sysNav = [
{ to: '/system/roles', icon: ShieldCheck, label: '역할/권한' },
{ to: '/system/role-menus', icon: KeyRound, label: '메뉴 권한' },
{ to: '/system/menus', icon: MenuIcon, label: '메뉴 관리' },
{ to: '/system/codes', icon: Tags, label: '공통코드' },
{ to: '/system/depts', icon: Network, label: '부서 관리' },
{ to: '/system/companies', icon: Briefcase, label: '거래처 관리' },
]
const linkClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
isActive
? 'bg-brand/15 text-brand border-r-2 border-brand'
: 'text-gray-400 hover:text-white hover:bg-edge/50'
}`
export default function Sidebar() {
const role = localStorage.getItem('esn_role')
const isAdmin = role === 'ADMIN' || role === 'MANAGER'
return (
<aside className="w-56 bg-panel flex flex-col border-r border-edge flex-shrink-0">
<div className="h-14 flex items-center px-4 border-b border-edge">
@ -37,21 +58,25 @@ export default function Sidebar() {
</div>
<nav className="flex-1 overflow-y-auto py-3">
{nav.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
isActive
? 'bg-brand/15 text-brand border-r-2 border-brand'
: 'text-gray-400 hover:text-white hover:bg-edge/50'
}`
}
>
<NavLink key={to} to={to} className={linkClass}>
<Icon size={16} />
{label}
</NavLink>
))}
{isAdmin && (
<>
<div className="px-4 pt-4 pb-1 text-[11px] font-semibold uppercase tracking-wider text-gray-500">
()
</div>
{sysNav.map(({ to, icon: Icon, label }) => (
<NavLink key={to} to={to} className={linkClass}>
<Icon size={16} />
{label}
</NavLink>
))}
</>
)}
</nav>
<div className="p-4 border-t border-edge text-xs text-gray-500">
zioinfo-esn v1.0

View File

@ -0,0 +1,124 @@
import { useEffect, useState } from 'react'
import { Smartphone, Download, Copy, Check, ExternalLink, RefreshCw } from 'lucide-react'
// 중앙 APK 저장소(ITSM)의 공개 엔드포인트 — 인증 불필요·CORS 허용·읽기전용.
// 앱 업로드/버전 관리는 GUARDiA Manager에서 일원화. 외부 API는 ITSM 공개 도메인 1곳만 사용.
// :8443은 외부 비공개 → itsm 서브도메인(nginx 443) 사용. 응답 내 URL의 :8443도 동일 치환.
const ITSM_APP_BASE = 'https://itsm.zioinfo.co.kr'
const fixUrl = (u?: string) => u?.replace(/https?:\/\/zioinfo\.co\.kr:8443/g, ITSM_APP_BASE)
interface AppInfo {
has_version: boolean
app_name?: string
version?: string
platform?: string
file_size_mb?: number
release_notes?: string
download_count?: number
qr_url?: string
landing_url?: string
download_url?: string
updated_at?: string
}
export default function MobileApp() {
const [info, setInfo] = useState<AppInfo | null>(null)
const [loading, setLoading] = useState(true)
const [err, setErr] = useState('')
const [copied, setCopied] = useState(false)
const load = () => {
setLoading(true); setErr('')
fetch(`${ITSM_APP_BASE}/api/app/public-latest`)
.then(r => r.json())
.then((d: AppInfo) => setInfo({ ...d, qr_url: fixUrl(d.qr_url), landing_url: fixUrl(d.landing_url), download_url: fixUrl(d.download_url) }))
.catch(() => setErr('버전 정보를 불러올 수 없습니다. (중앙 앱 저장소 연결 실패)'))
.finally(() => setLoading(false))
}
useEffect(() => { load() }, [])
const copyLink = async () => {
if (!info?.landing_url) return
try {
await navigator.clipboard.writeText(info.landing_url)
setCopied(true); setTimeout(() => setCopied(false), 2000)
} catch { /* clipboard 권한 없을 때 무시 */ }
}
return (
<div>
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2">
<Smartphone className="text-brand" size={22} />
<h1 className="text-xl font-semibold text-white">GUARDiA </h1>
</div>
<button onClick={load} className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-card border border-edge text-sm text-gray-300 hover:text-brand">
<RefreshCw size={15} />
</button>
</div>
<p className="text-sm text-gray-400 mb-6">
QR <span className="text-brand font-semibold">GUARDiA Messenger</span> .
, · GUARDiA Manager에서 .
</p>
{loading && <div className="text-gray-400 text-sm"> </div>}
{err && <div className="bg-card border border-edge rounded-lg p-6 text-red-400 text-sm">{err}</div>}
{!loading && !err && info && !info.has_version && (
<div className="bg-card border border-edge rounded-lg p-8 text-center text-gray-400">
<Smartphone size={36} className="mx-auto mb-3 text-gray-600" />
.<br />
<span className="text-xs">GUARDiA Manager에서 APK를 QR이 .</span>
</div>
)}
{!loading && !err && info?.has_version && (
<div className="bg-card border border-edge rounded-lg p-6 grid md:grid-cols-[200px_1fr] gap-6 items-start">
<div className="bg-white rounded-xl p-3 flex items-center justify-center">
{info.qr_url ? <img src={info.qr_url} alt="앱 설치 QR" className="w-44 h-44" /> : <Smartphone size={64} className="text-gray-400" />}
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg font-bold text-white">{info.app_name || 'GUARDiA Messenger'}</span>
<span className="px-2 py-0.5 rounded-md bg-brand text-white text-xs font-semibold">v{info.version}</span>
</div>
<div className="text-xs text-gray-400 mb-4">
{info.platform}{info.file_size_mb ? ` · ${info.file_size_mb}MB` : ''}
{info.download_count != null && ` · 다운로드 ${info.download_count}`}
</div>
{info.release_notes && (
<div className="mb-4">
<div className="text-[11px] font-semibold text-gray-500 uppercase tracking-wider mb-1"> </div>
<div className="text-sm text-gray-300 whitespace-pre-line bg-ink border border-edge rounded-lg p-3 max-h-32 overflow-auto">
{info.release_notes}
</div>
</div>
)}
<div className="flex flex-wrap gap-2">
{info.landing_url && (
<a href={info.landing_url} target="_blank" rel="noreferrer"
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-brand text-white text-sm font-semibold">
<ExternalLink size={15} />
</a>
)}
{info.download_url && (
<a href={info.download_url} target="_blank" rel="noreferrer"
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-card border border-edge text-sm text-gray-300 hover:text-brand">
<Download size={15} /> APK
</a>
)}
<button onClick={copyLink}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-card border border-edge text-sm text-gray-300 hover:text-brand">
{copied ? <Check size={15} className="text-green-400" /> : <Copy size={15} />}
{copied ? '복사됨' : '다운로드 링크 복사'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -1,104 +0,0 @@
import { useEffect, useState } from 'react'
import { roleList, roleCreate, roleDelete } from '../../api/uiws'
import { PageHeader, Panel, Button, Modal, FormField, Input, Spinner } from '../../components/uiws/ui'
/**
* /(Role) (UIWS system , /api/system/roles).
* , / ADMIN/MANAGER( RBAC + 403 ).
* UIWS (.uiws-scope --uiws-*) / .
*/
type Role = { roleId: string; roleNm: string; roleDesc: string; useYn: string }
export default function SystemRoles() {
const [rows, setRows] = useState<Role[]>([])
const [loading, setLoading] = useState(false)
const [open, setOpen] = useState(false)
const [form, setForm] = useState({ roleId: '', roleNm: '', roleDesc: '', useYn: 'Y' })
const [err, setErr] = useState('')
async function load() {
setLoading(true)
try {
const res = await roleList({ page: 0, size: 100 })
setRows(res.data?.data?.content || [])
} catch {
setRows([])
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
async function save() {
setErr('')
if (!form.roleId.trim() || !form.roleNm.trim()) { setErr('역할 ID와 이름은 필수입니다.'); return }
try {
await roleCreate(form)
setOpen(false)
setForm({ roleId: '', roleNm: '', roleDesc: '', useYn: 'Y' })
load()
} catch {
setErr('저장 실패: 권한이 없거나 중복된 역할입니다.')
}
}
async function remove(id: string) {
if (!confirm(`역할 '${id}' 을(를) 삭제할까요?`)) return
try { await roleDelete([id]); load() } catch { alert('삭제 실패(권한 또는 참조 중).') }
}
return (
<div className="uiws-scope">
<PageHeader title="역할/권한 관리" subtitle="시스템 권한(Role) 정의 — RBAC"
actions={<Button onClick={() => { setErr(''); setOpen(true) }}> </Button>} />
<Panel>
{loading ? <Spinner /> : (
<table className="w-full text-sm">
<thead>
<tr style={{ color: 'var(--uiws-text-muted)' }} className="text-left">
<th className="py-2 px-2"> ID</th>
<th className="py-2 px-2"></th>
<th className="py-2 px-2"></th>
<th className="py-2 px-2"></th>
<th className="py-2 px-2 text-right"></th>
</tr>
</thead>
<tbody>
{rows.map(r => (
<tr key={r.roleId} style={{ borderTop: '1px solid var(--uiws-border)' }}>
<td className="py-2 px-2 font-mono">{r.roleId}</td>
<td className="py-2 px-2">{r.roleNm}</td>
<td className="py-2 px-2">{r.roleDesc}</td>
<td className="py-2 px-2">{r.useYn}</td>
<td className="py-2 px-2 text-right">
<button onClick={() => remove(r.roleId)} className="text-xs" style={{ color: 'var(--uiws-danger)' }}></button>
</td>
</tr>
))}
{rows.length === 0 && (
<tr><td colSpan={5} className="py-6 text-center" style={{ color: 'var(--uiws-text-faint)' }}> .</td></tr>
)}
</tbody>
</table>
)}
</Panel>
{open && (
<Modal title="역할 추가" onClose={() => setOpen(false)}>
<div className="space-y-3">
<FormField label="역할 ID"><Input value={form.roleId} onChange={v => setForm({ ...form, roleId: v })} placeholder="예: AUDITOR" /></FormField>
<FormField label="이름"><Input value={form.roleNm} onChange={v => setForm({ ...form, roleNm: v })} placeholder="예: 감사자" /></FormField>
<FormField label="설명"><Input value={form.roleDesc} onChange={v => setForm({ ...form, roleDesc: v })} /></FormField>
<FormField label="사용 여부"><Input value={form.useYn} onChange={v => setForm({ ...form, useYn: v })} placeholder="Y / N" /></FormField>
{err && <p className="text-xs" style={{ color: 'var(--uiws-danger)' }}>{err}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setOpen(false)}></Button>
<Button onClick={save}></Button>
</div>
</div>
</Modal>
)}
</div>
)
}

View File

@ -0,0 +1,18 @@
import { type ReactNode } from 'react'
/**
* () UI ( RBAC 403 ).
* esn_role(localStorage) . TenantAdmin.tsx ADMIN .
* UIWS (/ ).
*/
export default function AdminGate({ children }: { children: ReactNode }) {
const role = localStorage.getItem('esn_role')
if (role !== 'ADMIN' && role !== 'MANAGER') {
return (
<div className="uiws-scope" style={{ textAlign: 'center', padding: '64px 0' }}>
<p style={{ color: 'var(--uiws-text-muted)', fontSize: 14 }}> .</p>
</div>
)
}
return <>{children}</>
}

View File

@ -0,0 +1,158 @@
import { useEffect, useState } from 'react'
import { codeList, codeGroupGet, codeCreate, codeUpdate, codeDelete } from '../../../api/uiws'
import { PageHeader, SearchBar, Panel, Button, Modal, FormField, Input, Spinner, Pagination, YnBadge, DataGrid, type Column } from '../../../components/uiws/ui'
import AdminGate from './AdminGate'
/**
* 2.7 () /api/system/codes. (PageResponse) + (+ ) .
* (values ). RBAC ADMIN/MANAGER(403 + AdminGate). .
*/
type CodeGrp = { grpCd: string; grpNm: string; useYn: string }
type CodeValue = { grpCd: string; codeVal: string; codeNm: string; sortOrd: number | null; useYn: string }
type CodeGrpDetail = CodeGrp & { values: CodeValue[] }
const PSIZE = 20
function friendlyError(e: any): string {
if (e?.response?.status === 403) return '권한이 없습니다 (관리자 전용).'
if (e?.response?.status === 409) return '이미 존재하는 코드 그룹입니다.'
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
}
export default function CodeManagement() {
const [rows, setRows] = useState<CodeGrp[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(0)
const [kw, setKw] = useState('')
const [loading, setLoading] = useState(false)
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState(false)
const [form, setForm] = useState<CodeGrpDetail>({ grpCd: '', grpNm: '', useYn: 'Y', values: [] })
const [err, setErr] = useState('')
async function load(p = page, keyword = kw) {
setLoading(true)
try {
const res = await codeList({ keyword: keyword || undefined, page: p, size: PSIZE })
setRows(res.data?.data?.content || [])
setTotal(res.data?.data?.total || 0)
} catch { setRows([]); setTotal(0) } finally { setLoading(false) }
}
useEffect(() => { load(0, '') }, [])
function openCreate() {
setForm({ grpCd: '', grpNm: '', useYn: 'Y', values: [] }); setEditing(false); setErr(''); setOpen(true)
}
async function openEdit(g: CodeGrp) {
setErr('')
try {
const res = await codeGroupGet(g.grpCd)
const d = res.data?.data
setForm({ grpCd: d.grpCd, grpNm: d.grpNm, useYn: d.useYn, values: d.values || [] })
setEditing(true); setOpen(true)
} catch (e) { alert(friendlyError(e)) }
}
function addValue() {
setForm(f => ({ ...f, values: [...f.values, { grpCd: f.grpCd, codeVal: '', codeNm: '', sortOrd: f.values.length + 1, useYn: 'Y' }] }))
}
function setVal(i: number, patch: Partial<CodeValue>) {
setForm(f => ({ ...f, values: f.values.map((v, idx) => idx === i ? { ...v, ...patch } : v) }))
}
function removeValue(i: number) {
setForm(f => ({ ...f, values: f.values.filter((_, idx) => idx !== i) }))
}
async function save() {
setErr('')
if (!form.grpCd.trim() || !form.grpNm.trim()) { setErr('그룹 코드와 이름은 필수입니다.'); return }
const body = { ...form, values: form.values.map(v => ({ ...v, grpCd: form.grpCd })) }
try {
if (editing) await codeUpdate(form.grpCd, body)
else await codeCreate(body)
setOpen(false); load()
} catch (e) { setErr(friendlyError(e)) }
}
async function remove(grpCd: string) {
if (!confirm(`코드 그룹 '${grpCd}' 을(를) 삭제할까요?`)) return
try { await codeDelete(grpCd); load() } catch (e) { alert(friendlyError(e)) }
}
const totalPages = Math.max(1, Math.ceil(total / PSIZE))
const columns: Column<CodeGrp & Record<string, unknown>>[] = [
{ key: 'grpCd', header: '그룹 코드', render: r => <span className="font-mono">{r.grpCd}</span> },
{ key: 'grpNm', header: '그룹명' },
{ key: 'useYn', header: '사용', width: 80, render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
{ key: 'act', header: '', width: 64, align: 'right', render: r => (
<button onClick={e => { e.stopPropagation(); remove(r.grpCd) }} className="text-xs" style={{ color: 'var(--uiws-danger)' }}></button>
) },
]
return (
<AdminGate>
<div className="uiws-scope">
<PageHeader title="공통코드 관리" subtitle="코드 그룹 및 코드값 정의"
actions={<Button onClick={openCreate}> </Button>} />
<SearchBar>
<Input value={kw} onChange={setKw} placeholder="그룹코드/그룹명 검색" style={{ flex: 1, minWidth: 220 }} />
<Button onClick={() => { setPage(0); load(0, kw) }}></Button>
</SearchBar>
{loading ? <Spinner /> : (
<>
<DataGrid columns={columns} rows={rows as (CodeGrp & Record<string, unknown>)[]} rowKey={r => r.grpCd}
onRowClick={r => openEdit(r)} empty="등록된 코드 그룹이 없습니다." />
<Pagination page={page} totalPages={totalPages} onChange={p => { setPage(p); load(p) }} />
</>
)}
{open && (
<Modal title={editing ? '코드 그룹 수정' : '코드 그룹 추가'} onClose={() => setOpen(false)}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<FormField label="그룹 코드"><Input value={form.grpCd} onChange={v => setForm({ ...form, grpCd: v })} placeholder="예: ALARM_TYPE" style={{ width: '100%' }} /></FormField>
<FormField label="그룹명"><Input value={form.grpNm} onChange={v => setForm({ ...form, grpNm: v })} style={{ width: '100%' }} /></FormField>
</div>
<FormField label="사용 여부"><Input value={form.useYn} onChange={v => setForm({ ...form, useYn: v })} placeholder="Y / N" /></FormField>
<div style={{ marginTop: 8, marginBottom: 6, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, color: 'var(--uiws-text-muted)' }}></span>
<Button variant="ghost" onClick={addValue}>+ </Button>
</div>
<div style={{ border: '1px solid var(--uiws-border)', borderRadius: 8, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: 'var(--uiws-surface-2)', color: 'var(--uiws-text-muted)' }}>
<th style={{ padding: '7px 8px', textAlign: 'left' }}></th>
<th style={{ padding: '7px 8px', textAlign: 'left' }}></th>
<th style={{ padding: '7px 8px', textAlign: 'left', width: 60 }}></th>
<th style={{ padding: '7px 8px', textAlign: 'left', width: 50 }}></th>
<th style={{ width: 36 }} />
</tr>
</thead>
<tbody>
{form.values.length === 0 ? (
<tr><td colSpan={5} style={{ padding: 14, textAlign: 'center', color: 'var(--uiws-text-faint)' }}> .</td></tr>
) : form.values.map((v, i) => (
<tr key={i} style={{ borderTop: '1px solid var(--uiws-border)' }}>
<td style={{ padding: 4 }}><Input value={v.codeVal} onChange={x => setVal(i, { codeVal: x })} style={{ width: '100%', padding: '6px 8px' }} /></td>
<td style={{ padding: 4 }}><Input value={v.codeNm} onChange={x => setVal(i, { codeNm: x })} style={{ width: '100%', padding: '6px 8px' }} /></td>
<td style={{ padding: 4 }}><Input type="number" value={String(v.sortOrd ?? '')} onChange={x => setVal(i, { sortOrd: x === '' ? null : Number(x) })} style={{ width: '100%', padding: '6px 8px' }} /></td>
<td style={{ padding: 4 }}><Input value={v.useYn} onChange={x => setVal(i, { useYn: x })} style={{ width: '100%', padding: '6px 8px' }} /></td>
<td style={{ textAlign: 'center' }}><button onClick={() => removeValue(i)} style={{ background: 'none', border: 'none', color: 'var(--uiws-danger)', cursor: 'pointer' }}>×</button></td>
</tr>
))}
</tbody>
</table>
</div>
{err && <p className="text-xs" style={{ color: 'var(--uiws-danger)', marginTop: 8 }}>{err}</p>}
<div className="flex justify-end gap-2 pt-3">
<Button variant="ghost" onClick={() => setOpen(false)}></Button>
<Button onClick={save}></Button>
</div>
</Modal>
)}
</div>
</AdminGate>
)
}

View File

@ -0,0 +1,112 @@
import { useEffect, useState } from 'react'
import { companyList, companyCreate, companyUpdate, companyDelete } from '../../../api/uiws'
import { PageHeader, SearchBar, Button, Modal, FormField, Input, Spinner, Pagination, YnBadge, DataGrid, type Column } from '../../../components/uiws/ui'
import AdminGate from './AdminGate'
/**
* 2.4 /api/system/companies. (PageResponse)////( ids).
* RBAC ADMIN/MANAGER(403 + AdminGate). .
*/
type Company = { companyId: string; companyNm: string; bizNo: string; useYn: string }
const empty: Company = { companyId: '', companyNm: '', bizNo: '', useYn: 'Y' }
const PSIZE = 20
function friendlyError(e: any): string {
if (e?.response?.status === 403) return '권한이 없습니다 (관리자 전용).'
if (e?.response?.status === 409) return '이미 존재하는 거래처입니다.'
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
}
export default function CompanyManagement() {
const [rows, setRows] = useState<Company[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(0)
const [kw, setKw] = useState('')
const [loading, setLoading] = useState(false)
const [sel, setSel] = useState<Set<string>>(new Set())
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState(false)
const [form, setForm] = useState<Company>(empty)
const [err, setErr] = useState('')
async function load(p = page, keyword = kw) {
setLoading(true)
try {
const res = await companyList({ keyword: keyword || undefined, page: p, size: PSIZE })
setRows(res.data?.data?.content || [])
setTotal(res.data?.data?.total || 0)
setSel(new Set())
} catch { setRows([]); setTotal(0) } finally { setLoading(false) }
}
useEffect(() => { load(0, '') }, [])
function openCreate() { setForm(empty); setEditing(false); setErr(''); setOpen(true) }
function openEdit(c: Company) { setForm({ ...c }); setEditing(true); setErr(''); setOpen(true) }
async function save() {
setErr('')
if (!form.companyNm.trim()) { setErr('거래처명은 필수입니다.'); return }
try {
if (editing) await companyUpdate(form.companyId, form)
else await companyCreate(form)
setOpen(false); load()
} catch (e) { setErr(friendlyError(e)) }
}
async function removeSelected() {
if (sel.size === 0) return
if (!confirm(`선택한 ${sel.size}개 거래처를 삭제할까요?`)) return
try { await companyDelete([...sel]); load() } catch (e) { alert(friendlyError(e)) }
}
const toggle = (id: string) => setSel(p => { const n = new Set(p); n.has(id) ? n.delete(id) : n.add(id); return n })
const totalPages = Math.max(1, Math.ceil(total / PSIZE))
const columns: Column<Company & Record<string, unknown>>[] = [
{ key: 'sel', header: '', width: 36, render: r => (
<input type="checkbox" checked={sel.has(r.companyId)} onChange={() => toggle(r.companyId)} onClick={e => e.stopPropagation()} />
) },
{ key: 'companyId', header: '거래처 ID', render: r => <span className="font-mono">{r.companyId}</span> },
{ key: 'companyNm', header: '거래처명' },
{ key: 'bizNo', header: '사업자번호' },
{ key: 'useYn', header: '사용', width: 80, render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
]
return (
<AdminGate>
<div className="uiws-scope">
<PageHeader title="거래처 관리" subtitle="거래처(협력사) 마스터"
actions={<>
<Button variant="danger" onClick={removeSelected} disabled={sel.size === 0}> ({sel.size})</Button>
<Button onClick={openCreate}> </Button>
</>} />
<SearchBar>
<Input value={kw} onChange={setKw} placeholder="거래처명/사업자번호 검색" style={{ flex: 1, minWidth: 220 }} />
<Button onClick={() => { setPage(0); load(0, kw) }}></Button>
</SearchBar>
{loading ? <Spinner /> : (
<>
<DataGrid columns={columns} rows={rows as (Company & Record<string, unknown>)[]} rowKey={r => r.companyId}
onRowClick={r => openEdit(r)} empty="등록된 거래처가 없습니다." />
<Pagination page={page} totalPages={totalPages} onChange={p => { setPage(p); load(p) }} />
</>
)}
{open && (
<Modal title={editing ? '거래처 수정' : '거래처 추가'} onClose={() => setOpen(false)}>
<FormField label="거래처 ID"><Input value={form.companyId} onChange={v => setForm({ ...form, companyId: v })} placeholder="비우면 자동 생성" style={{ width: '100%' }} /></FormField>
<FormField label="거래처명"><Input value={form.companyNm} onChange={v => setForm({ ...form, companyNm: v })} style={{ width: '100%' }} /></FormField>
<FormField label="사업자번호"><Input value={form.bizNo} onChange={v => setForm({ ...form, bizNo: v })} style={{ width: '100%' }} /></FormField>
<FormField label="사용 여부"><Input value={form.useYn} onChange={v => setForm({ ...form, useYn: v })} placeholder="Y / N" /></FormField>
{err && <p className="text-xs" style={{ color: 'var(--uiws-danger)' }}>{err}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setOpen(false)}></Button>
<Button onClick={save}></Button>
</div>
</Modal>
)}
</div>
</AdminGate>
)
}

View File

@ -0,0 +1,106 @@
import { useEffect, useState } from 'react'
import { deptList, deptCreate, deptUpdate, deptDelete } from '../../../api/uiws'
import { PageHeader, SearchBar, Button, Modal, FormField, Input, Spinner, Pagination, YnBadge, DataGrid, type Column } from '../../../components/uiws/ui'
import AdminGate from './AdminGate'
/**
* 2.3 /api/system/depts. (PageResponse)////(path /{id}).
* RBAC ADMIN/MANAGER(403 + AdminGate). .
*/
type Dept = { deptId: string; deptNm: string; parentDeptId: string; sortOrd: number | null; useYn: string }
const empty: Dept = { deptId: '', deptNm: '', parentDeptId: '', sortOrd: null, useYn: 'Y' }
const PSIZE = 20
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 [total, setTotal] = useState(0)
const [page, setPage] = useState(0)
const [kw, setKw] = useState('')
const [loading, setLoading] = useState(false)
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState(false)
const [form, setForm] = useState<Dept>(empty)
const [err, setErr] = useState('')
async function load(p = page, keyword = kw) {
setLoading(true)
try {
const res = await deptList({ keyword: keyword || undefined, page: p, size: PSIZE })
setRows(res.data?.data?.content || [])
setTotal(res.data?.data?.total || 0)
} catch { setRows([]); setTotal(0) } finally { setLoading(false) }
}
useEffect(() => { load(0, '') }, [])
function openCreate() { setForm(empty); setEditing(false); setErr(''); setOpen(true) }
function openEdit(d: Dept) { setForm({ ...d }); setEditing(true); setErr(''); setOpen(true) }
async function save() {
setErr('')
if (!form.deptNm.trim()) { setErr('부서명은 필수입니다.'); return }
try {
if (editing) await deptUpdate(form.deptId, form)
else await deptCreate(form)
setOpen(false); load()
} catch (e) { setErr(friendlyError(e)) }
}
async function remove(id: string) {
if (!confirm(`부서 '${id}' 을(를) 삭제할까요?`)) return
try { await deptDelete(id); load() } catch (e) { alert(friendlyError(e)) }
}
const totalPages = Math.max(1, Math.ceil(total / PSIZE))
const columns: Column<Dept & Record<string, unknown>>[] = [
{ key: 'deptId', header: '부서 ID', render: r => <span className="font-mono">{r.deptId}</span> },
{ key: 'deptNm', header: '부서명' },
{ key: 'parentDeptId', header: '상위 부서' },
{ key: 'sortOrd', header: '순서', width: 60 },
{ key: 'useYn', header: '사용', width: 80, render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
{ key: 'act', header: '', width: 64, align: 'right', render: r => (
<button onClick={e => { e.stopPropagation(); remove(r.deptId) }} className="text-xs" style={{ color: 'var(--uiws-danger)' }}></button>
) },
]
return (
<AdminGate>
<div className="uiws-scope">
<PageHeader title="부서 관리" subtitle="조직 부서 계층/정렬"
actions={<Button onClick={openCreate}> </Button>} />
<SearchBar>
<Input value={kw} onChange={setKw} placeholder="부서명 검색" style={{ flex: 1, minWidth: 220 }} />
<Button onClick={() => { setPage(0); load(0, kw) }}></Button>
</SearchBar>
{loading ? <Spinner /> : (
<>
<DataGrid columns={columns} rows={rows as (Dept & Record<string, unknown>)[]} rowKey={r => r.deptId}
onRowClick={r => openEdit(r)} empty="등록된 부서가 없습니다." />
<Pagination page={page} totalPages={totalPages} onChange={p => { setPage(p); load(p) }} />
</>
)}
{open && (
<Modal title={editing ? '부서 수정' : '부서 추가'} onClose={() => setOpen(false)}>
<FormField label="부서 ID (수정 불가)"><Input value={form.deptId} onChange={v => setForm({ ...form, deptId: v })} placeholder="비우면 자동 생성" style={{ width: '100%' }} /></FormField>
<FormField label="부서명"><Input value={form.deptNm} onChange={v => setForm({ ...form, deptNm: v })} style={{ width: '100%' }} /></FormField>
<FormField label="상위 부서 ID"><Input value={form.parentDeptId} onChange={v => setForm({ ...form, parentDeptId: v })} style={{ width: '100%' }} /></FormField>
<FormField label="정렬 순서"><Input type="number" value={String(form.sortOrd ?? '')} onChange={v => setForm({ ...form, sortOrd: v === '' ? null : Number(v) })} style={{ width: '100%' }} /></FormField>
<FormField label="사용 여부"><Input value={form.useYn} onChange={v => setForm({ ...form, useYn: v })} placeholder="Y / N" /></FormField>
{err && <p className="text-xs" style={{ color: 'var(--uiws-danger)' }}>{err}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setOpen(false)}></Button>
<Button onClick={save}></Button>
</div>
</Modal>
)}
</div>
</AdminGate>
)
}

View File

@ -0,0 +1,125 @@
import { useEffect, useState } from 'react'
import { menuList, menuCreate, menuUpdate, menuDelete, programList } from '../../../api/uiws'
import { PageHeader, Panel, Button, Modal, FormField, Input, Spinner, Pagination, YnBadge, DataGrid, type Column } from '../../../components/uiws/ui'
import AdminGate from './AdminGate'
/**
* 2.9 /api/system/menus. (PageResponse, size 100)///( ids).
* (2.8) select. RBAC ADMIN/MANAGER(403 + AdminGate). .
*/
type Menu = { menuId: string; menuNm: string; parentMenuId: string; programId: string; menuUrl: string; sortOrd: number | null; useYn: string }
type Program = { programId: string; programNm: string }
const empty: Menu = { menuId: '', menuNm: '', parentMenuId: '', programId: '', menuUrl: '', sortOrd: null, useYn: 'Y' }
const PSIZE = 100
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 [total, setTotal] = useState(0)
const [page, setPage] = useState(0)
const [programs, setPrograms] = useState<Program[]>([])
const [loading, setLoading] = useState(false)
const [sel, setSel] = useState<Set<string>>(new Set())
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState(false)
const [form, setForm] = useState<Menu>(empty)
const [err, setErr] = useState('')
async function load(p = page) {
setLoading(true)
try {
const res = await menuList({ page: p, size: PSIZE })
setRows(res.data?.data?.content || [])
setTotal(res.data?.data?.total || 0)
setSel(new Set())
} catch { setRows([]); setTotal(0) } finally { setLoading(false) }
}
async function loadPrograms() {
try { const res = await programList({ page: 0, size: 200 }); setPrograms(res.data?.data?.content || []) } catch { setPrograms([]) }
}
useEffect(() => { load(0); loadPrograms() }, [])
function openCreate() { setForm(empty); setEditing(false); setErr(''); setOpen(true) }
function openEdit(m: Menu) { setForm({ ...m }); setEditing(true); setErr(''); setOpen(true) }
async function save() {
setErr('')
if (!form.menuId.trim() || !form.menuNm.trim()) { setErr('메뉴 ID와 이름은 필수입니다.'); return }
try {
if (editing) await menuUpdate(form.menuId, form)
else await menuCreate(form)
setOpen(false); load()
} catch (e) { setErr(friendlyError(e)) }
}
async function removeSelected() {
if (sel.size === 0) return
if (!confirm(`선택한 ${sel.size}개 메뉴를 삭제할까요?`)) return
try { await menuDelete([...sel]); load() } catch (e) { alert(friendlyError(e)) }
}
const toggle = (id: string) => setSel(p => { const n = new Set(p); n.has(id) ? n.delete(id) : n.add(id); return n })
const totalPages = Math.max(1, Math.ceil(total / PSIZE))
const columns: Column<Menu & Record<string, unknown>>[] = [
{ key: 'sel', header: '', width: 36, render: r => (
<input type="checkbox" checked={sel.has(r.menuId)} onChange={() => toggle(r.menuId)} onClick={e => e.stopPropagation()} />
) },
{ key: 'menuId', header: '메뉴 ID', render: r => <span className="font-mono">{r.menuId}</span> },
{ key: 'menuNm', header: '메뉴명' },
{ key: 'parentMenuId', header: '상위' },
{ key: 'programId', header: '프로그램' },
{ key: 'menuUrl', header: 'URL' },
{ key: 'sortOrd', header: '순서', width: 60 },
{ key: 'useYn', header: '사용', width: 80, render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
]
return (
<AdminGate>
<div className="uiws-scope">
<PageHeader title="메뉴 관리" subtitle="시스템 메뉴 트리/프로그램 연결"
actions={<>
<Button variant="danger" onClick={removeSelected} disabled={sel.size === 0}> ({sel.size})</Button>
<Button onClick={openCreate}> </Button>
</>} />
{loading ? <Spinner /> : (
<>
<DataGrid columns={columns} rows={rows as (Menu & Record<string, unknown>)[]} rowKey={r => r.menuId}
onRowClick={r => openEdit(r)} empty="등록된 메뉴가 없습니다." />
<Pagination page={page} totalPages={totalPages} onChange={p => { setPage(p); load(p) }} />
</>
)}
{open && (
<Modal title={editing ? '메뉴 수정' : '메뉴 추가'} onClose={() => setOpen(false)}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<FormField label="메뉴 ID"><Input value={form.menuId} onChange={v => setForm({ ...form, menuId: v })} style={{ width: '100%' }} /></FormField>
<FormField label="메뉴명"><Input value={form.menuNm} onChange={v => setForm({ ...form, menuNm: v })} style={{ width: '100%' }} /></FormField>
<FormField label="상위 메뉴 ID"><Input value={form.parentMenuId} onChange={v => setForm({ ...form, parentMenuId: v })} style={{ width: '100%' }} /></FormField>
<FormField label="프로그램">
<select value={form.programId} onChange={e => setForm({ ...form, programId: 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="">( )</option>
{programs.map(p => <option key={p.programId} value={p.programId}>{p.programNm} ({p.programId})</option>)}
</select>
</FormField>
<FormField label="메뉴 URL"><Input value={form.menuUrl} onChange={v => setForm({ ...form, menuUrl: v })} style={{ width: '100%' }} /></FormField>
<FormField label="정렬 순서"><Input type="number" value={String(form.sortOrd ?? '')} onChange={v => setForm({ ...form, sortOrd: v === '' ? null : Number(v) })} style={{ width: '100%' }} /></FormField>
</div>
<FormField label="사용 여부"><Input value={form.useYn} onChange={v => setForm({ ...form, useYn: v })} placeholder="Y / N" /></FormField>
{err && <p className="text-xs" style={{ color: 'var(--uiws-danger)' }}>{err}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setOpen(false)}></Button>
<Button onClick={save}></Button>
</div>
</Modal>
)}
</div>
</AdminGate>
)
}

View File

@ -0,0 +1,115 @@
import { useEffect, useState } from 'react'
import { roleList, roleCreate, roleUpdate, roleDelete } from '../../../api/uiws'
import { PageHeader, SearchBar, Button, Modal, FormField, Input, Spinner, Pagination, YnBadge, DataGrid, type Column } from '../../../components/uiws/ui'
import AdminGate from './AdminGate'
/**
* 2.1 (Role) /api/system/roles. (PageResponse)····.
* RBAC: 조회 , ADMIN/MANAGER( 403 + AdminGate ). .
*/
type Role = { roleId: string; roleNm: string; roleDesc: string; useYn: string }
const empty: Role = { roleId: '', roleNm: '', roleDesc: '', useYn: 'Y' }
const PSIZE = 20
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<Role[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(0)
const [kw, setKw] = useState('')
const [loading, setLoading] = useState(false)
const [sel, setSel] = useState<Set<string>>(new Set())
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState(false)
const [form, setForm] = useState<Role>(empty)
const [err, setErr] = useState('')
async function load(p = page, keyword = kw) {
setLoading(true)
try {
const res = await roleList({ keyword: keyword || undefined, page: p, size: PSIZE })
setRows(res.data?.data?.content || [])
setTotal(res.data?.data?.total || 0)
setSel(new Set())
} catch { setRows([]); setTotal(0) } finally { setLoading(false) }
}
useEffect(() => { load(0, '') }, [])
function openCreate() { setForm(empty); setEditing(false); setErr(''); setOpen(true) }
function openEdit(r: Role) { setForm({ ...r }); setEditing(true); setErr(''); setOpen(true) }
async function save() {
setErr('')
if (!form.roleId.trim() || !form.roleNm.trim()) { setErr('역할 ID와 이름은 필수입니다.'); return }
try {
if (editing) await roleUpdate(form.roleId, form)
else await roleCreate(form)
setOpen(false); load()
} catch (e) { setErr(friendlyError(e)) }
}
async function removeSelected() {
if (sel.size === 0) return
if (!confirm(`선택한 ${sel.size}개 역할을 삭제할까요?`)) return
try { await roleDelete([...sel]); load() } catch (e) { alert(friendlyError(e)) }
}
const toggle = (id: string) => setSel(p => { const n = new Set(p); n.has(id) ? n.delete(id) : n.add(id); return n })
const totalPages = Math.max(1, Math.ceil(total / PSIZE))
const columns: Column<Role & Record<string, unknown>>[] = [
{ key: 'sel', header: '', width: 36, render: r => (
<input type="checkbox" checked={sel.has(r.roleId)} onChange={() => toggle(r.roleId)} onClick={e => e.stopPropagation()} />
) },
{ key: 'roleId', header: '역할 ID', render: r => <span className="font-mono">{r.roleId}</span> },
{ key: 'roleNm', header: '이름' },
{ key: 'roleDesc', header: '설명' },
{ key: 'useYn', header: '사용', width: 80, render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
]
return (
<AdminGate>
<div className="uiws-scope">
<PageHeader title="역할/권한 관리" subtitle="시스템 권한(Role) 정의 — RBAC"
actions={<>
<Button variant="danger" onClick={removeSelected} disabled={sel.size === 0}> ({sel.size})</Button>
<Button onClick={openCreate}> </Button>
</>} />
<SearchBar>
<Input value={kw} onChange={setKw} placeholder="역할명/설명 검색" style={{ flex: 1, minWidth: 220 }} />
<Button onClick={() => { setPage(0); load(0, kw) }}></Button>
</SearchBar>
{loading ? <Spinner /> : (
<>
<DataGrid columns={columns} rows={rows as (Role & Record<string, unknown>)[]} rowKey={r => r.roleId}
onRowClick={r => openEdit(r)} empty="등록된 역할이 없습니다." />
<Pagination page={page} totalPages={totalPages} onChange={p => { setPage(p); load(p) }} />
</>
)}
{open && (
<Modal title={editing ? '역할 수정' : '역할 추가'} onClose={() => setOpen(false)}>
<FormField label="역할 ID">
<Input value={form.roleId} onChange={v => setForm({ ...form, roleId: v })} placeholder="예: AUDITOR" style={{ width: '100%' }} />
</FormField>
<FormField label="이름"><Input value={form.roleNm} onChange={v => setForm({ ...form, roleNm: v })} placeholder="예: 감사자" style={{ width: '100%' }} /></FormField>
<FormField label="설명"><Input value={form.roleDesc} onChange={v => setForm({ ...form, roleDesc: v })} style={{ width: '100%' }} /></FormField>
<FormField label="사용 여부"><Input value={form.useYn} onChange={v => setForm({ ...form, useYn: v })} placeholder="Y / N" style={{ width: '100%' }} /></FormField>
{err && <p className="text-xs" style={{ color: 'var(--uiws-danger)' }}>{err}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setOpen(false)}></Button>
<Button onClick={save}></Button>
</div>
</Modal>
)}
</div>
</AdminGate>
)
}

View File

@ -0,0 +1,119 @@
import { useEffect, useState } from 'react'
import { roleMenuRoles, roleMenusGet, roleMenusSave } from '../../../api/uiws'
import { PageHeader, Panel, Button, Spinner } from '../../../components/uiws/ui'
import AdminGate from './AdminGate'
/**
* 2.5 (role-menus) /api/system/role-menus(), /roles/{id}/menus(/ ·).
* (readYn)/(writeYn) (PUT ).
* RBAC ADMIN/MANAGER(403 + AdminGate). .
*/
type Role = { roleId: string; roleNm: string; roleDesc: string; useYn: string }
type RoleMenu = { 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 RoleMenuManagement() {
const [roles, setRoles] = useState<Role[]>([])
const [active, setActive] = useState<string>('')
const [menus, setMenus] = useState<RoleMenu[]>([])
const [loadingRoles, setLoadingRoles] = useState(false)
const [loadingMenus, setLoadingMenus] = useState(false)
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
async function loadRoles() {
setLoadingRoles(true)
try {
const res = await roleMenuRoles({ page: 0, size: 100 })
const list: Role[] = res.data?.data?.content || []
setRoles(list)
if (list.length && !active) selectRole(list[0].roleId)
} catch { setRoles([]) } finally { setLoadingRoles(false) }
}
useEffect(() => { loadRoles() }, [])
async function selectRole(roleId: string) {
setActive(roleId); setMsg(''); setLoadingMenus(true)
try {
const res = await roleMenusGet(roleId)
setMenus(res.data?.data || [])
} catch { setMenus([]) } finally { setLoadingMenus(false) }
}
function toggle(menuId: string, field: 'readYn' | 'writeYn') {
setMenus(prev => prev.map(m => {
if (m.menuId !== menuId) return m
const next = { ...m, [field]: m[field] === 'Y' ? 'N' : 'Y' }
// 쓰기 권한이면 읽기는 자동 ON (정합성)
if (field === 'writeYn' && next.writeYn === 'Y') next.readYn = 'Y'
if (field === 'readYn' && next.readYn === 'N') next.writeYn = 'N'
return next
}))
}
async function save() {
if (!active) return
setSaving(true); setMsg('')
try {
await roleMenusSave(active, menus)
setMsg('저장되었습니다.')
} catch (e) { setMsg(friendlyError(e)) } finally { setSaving(false) }
}
return (
<AdminGate>
<div className="uiws-scope">
<PageHeader title="메뉴 권한(권한별 메뉴 매핑)" subtitle="역할별 메뉴 읽기/쓰기 권한 부여"
actions={<Button onClick={save} disabled={!active || saving}>{saving ? '저장 중...' : '저장'}</Button>} />
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 16 }}>
<Panel style={{ padding: 12 }}>
<div style={{ fontSize: 12, color: 'var(--uiws-text-muted)', padding: '4px 6px 10px' }}> </div>
{loadingRoles ? <Spinner /> : roles.length === 0 ? (
<div style={{ padding: 16, fontSize: 13, color: 'var(--uiws-text-faint)' }}> .</div>
) : roles.map(r => (
<button key={r.roleId} onClick={() => selectRole(r.roleId)}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '9px 10px', borderRadius: 8,
fontSize: 13, marginBottom: 2, border: 'none', cursor: 'pointer',
background: active === r.roleId ? 'var(--uiws-row-hover)' : 'transparent',
color: active === r.roleId ? 'var(--uiws-primary)' : 'var(--uiws-text)',
fontWeight: active === r.roleId ? 600 : 400 }}>
{r.roleNm} <span style={{ color: 'var(--uiws-text-faint)', fontFamily: 'monospace', fontSize: 11 }}>({r.roleId})</span>
</button>
))}
</Panel>
<Panel style={{ padding: 0, overflow: 'hidden' }}>
{loadingMenus ? <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)', fontSize: 12, fontWeight: 600, borderBottom: '1px solid var(--uiws-border)' }}></th>
<th style={{ textAlign: 'center', padding: '11px 14px', color: 'var(--uiws-text-muted)', fontSize: 12, fontWeight: 600, borderBottom: '1px solid var(--uiws-border)', width: 90 }}></th>
<th style={{ textAlign: 'center', padding: '11px 14px', color: 'var(--uiws-text-muted)', fontSize: 12, fontWeight: 600, borderBottom: '1px solid var(--uiws-border)', width: 90 }}></th>
</tr>
</thead>
<tbody>
{menus.length === 0 ? (
<tr><td colSpan={3} style={{ padding: 28, textAlign: 'center', color: 'var(--uiws-text-faint)' }}> .</td></tr>
) : menus.map(m => (
<tr key={m.menuId} style={{ borderBottom: '1px solid var(--uiws-border)' }}>
<td style={{ padding: '10px 14px' }}>{m.menuNm} <span style={{ color: 'var(--uiws-text-faint)', fontFamily: 'monospace', fontSize: 11 }}>{m.menuId}</span></td>
<td style={{ textAlign: 'center' }}><input type="checkbox" checked={m.readYn === 'Y'} onChange={() => toggle(m.menuId, 'readYn')} /></td>
<td style={{ textAlign: 'center' }}><input type="checkbox" checked={m.writeYn === 'Y'} onChange={() => toggle(m.menuId, 'writeYn')} /></td>
</tr>
))}
</tbody>
</table>
)}
</Panel>
</div>
{msg && <p style={{ marginTop: 12, fontSize: 13, color: 'var(--uiws-text-muted)' }}>{msg}</p>}
</div>
</AdminGate>
)
}