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