feat: UIMS 권한관리(시스템관리) 관리자 React 화면 추가
This commit is contained in:
parent
337c8beffa
commit
ccfdfb6a3e
417
backend/src/main/resources/static/assets/index-C74GP7J-.js
Normal file
417
backend/src/main/resources/static/assets/index-C74GP7J-.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
14
backend/src/main/resources/static/index.html
Normal file
14
backend/src/main/resources/static/index.html
Normal 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>
|
||||||
@ -19,12 +19,19 @@ import TenantAdmin from './pages/TenantAdmin'
|
|||||||
import AiAnalysis from './pages/AiAnalysis'
|
import AiAnalysis from './pages/AiAnalysis'
|
||||||
import TagBindingList from './pages/TagBindingList'
|
import TagBindingList from './pages/TagBindingList'
|
||||||
import UpdateQueueList from './pages/UpdateQueueList'
|
import UpdateQueueList from './pages/UpdateQueueList'
|
||||||
|
import MobileApp from './pages/MobileApp'
|
||||||
// UIWS(UIMS) 이식: 업무일지·일정·쪽지·통계
|
// UIWS(UIMS) 이식: 업무일지·일정·쪽지·통계
|
||||||
import WorklogList from './pages/uiws/WorklogList'
|
import WorklogList from './pages/uiws/WorklogList'
|
||||||
import ScheduleCalendar from './pages/uiws/ScheduleCalendar'
|
import ScheduleCalendar from './pages/uiws/ScheduleCalendar'
|
||||||
import MessageBox from './pages/uiws/MessageBox'
|
import MessageBox from './pages/uiws/MessageBox'
|
||||||
import StatsPivot from './pages/uiws/StatsPivot'
|
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()
|
const qc = new QueryClient()
|
||||||
|
|
||||||
@ -53,12 +60,20 @@ export default function App() {
|
|||||||
<Route path="/users" element={<UserList />} />
|
<Route path="/users" element={<UserList />} />
|
||||||
<Route path="/tenants" element={<TenantAdmin />} />
|
<Route path="/tenants" element={<TenantAdmin />} />
|
||||||
<Route path="/ai" element={<AiAnalysis />} />
|
<Route path="/ai" element={<AiAnalysis />} />
|
||||||
|
{/* 통합 메신저 앱 다운로드 QR (ITSM 중앙 APK 저장소 공개 엔드포인트 재사용·읽기전용) */}
|
||||||
|
<Route path="/mobile-app" element={<MobileApp />} />
|
||||||
{/* UIWS(UIMS) 이식: 공통 업무협업 레이어 */}
|
{/* UIWS(UIMS) 이식: 공통 업무협업 레이어 */}
|
||||||
<Route path="/worklogs" element={<WorklogList />} />
|
<Route path="/worklogs" element={<WorklogList />} />
|
||||||
<Route path="/schedules" element={<ScheduleCalendar />} />
|
<Route path="/schedules" element={<ScheduleCalendar />} />
|
||||||
<Route path="/messages" element={<MessageBox />} />
|
<Route path="/messages" element={<MessageBox />} />
|
||||||
<Route path="/work-stats" element={<StatsPivot />} />
|
<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>
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@ -72,9 +72,50 @@ export const findId = (body: { displayName: string; email: string }) =>
|
|||||||
export const resetPassword = (body: { username: string; email: string }) =>
|
export const resetPassword = (body: { username: string; email: string }) =>
|
||||||
api.post('/api/auth/reset-password', body)
|
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 roleList = (params: Record<string, unknown>) => api.get('/api/system/roles', { params })
|
||||||
export const roleCreate = (body: object) => api.post('/api/system/roles', body)
|
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 roleUpdate = (id: string, body: object) => api.put(`/api/system/roles/${id}`, body)
|
||||||
export const roleDelete = (ids: string[]) => api.delete('/api/system/roles', { data: { ids } })
|
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 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 } })
|
||||||
|
|||||||
@ -2,7 +2,8 @@ import { NavLink } from 'react-router-dom'
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, Store, FileText, RefreshCw,
|
LayoutDashboard, Store, FileText, RefreshCw,
|
||||||
Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain,
|
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'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
@ -25,10 +26,30 @@ const nav = [
|
|||||||
{ to: '/schedules', icon: CalendarDays, label: '일정 관리' },
|
{ to: '/schedules', icon: CalendarDays, label: '일정 관리' },
|
||||||
{ to: '/messages', icon: Mail, label: '쪽지' },
|
{ to: '/messages', icon: Mail, label: '쪽지' },
|
||||||
{ to: '/work-stats', icon: BarChart3, 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() {
|
export default function Sidebar() {
|
||||||
|
const role = localStorage.getItem('esn_role')
|
||||||
|
const isAdmin = role === 'ADMIN' || role === 'MANAGER'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-56 bg-panel flex flex-col border-r border-edge flex-shrink-0">
|
<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">
|
<div className="h-14 flex items-center px-4 border-b border-edge">
|
||||||
@ -37,21 +58,25 @@ export default function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 overflow-y-auto py-3">
|
<nav className="flex-1 overflow-y-auto py-3">
|
||||||
{nav.map(({ to, icon: Icon, label }) => (
|
{nav.map(({ to, icon: Icon, label }) => (
|
||||||
<NavLink
|
<NavLink key={to} to={to} className={linkClass}>
|
||||||
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'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon size={16} />
|
<Icon size={16} />
|
||||||
{label}
|
{label}
|
||||||
</NavLink>
|
</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>
|
</nav>
|
||||||
<div className="p-4 border-t border-edge text-xs text-gray-500">
|
<div className="p-4 border-t border-edge text-xs text-gray-500">
|
||||||
zioinfo-esn v1.0
|
zioinfo-esn v1.0
|
||||||
|
|||||||
124
frontend/src/pages/MobileApp.tsx
Normal file
124
frontend/src/pages/MobileApp.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
18
frontend/src/pages/uiws/system/AdminGate.tsx
Normal file
18
frontend/src/pages/uiws/system/AdminGate.tsx
Normal 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}</>
|
||||||
|
}
|
||||||
158
frontend/src/pages/uiws/system/CodeManagement.tsx
Normal file
158
frontend/src/pages/uiws/system/CodeManagement.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
frontend/src/pages/uiws/system/CompanyManagement.tsx
Normal file
112
frontend/src/pages/uiws/system/CompanyManagement.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
106
frontend/src/pages/uiws/system/DeptManagement.tsx
Normal file
106
frontend/src/pages/uiws/system/DeptManagement.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
125
frontend/src/pages/uiws/system/MenuManagement.tsx
Normal file
125
frontend/src/pages/uiws/system/MenuManagement.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
115
frontend/src/pages/uiws/system/RoleManagement.tsx
Normal file
115
frontend/src/pages/uiws/system/RoleManagement.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
frontend/src/pages/uiws/system/RoleMenuManagement.tsx
Normal file
119
frontend/src/pages/uiws/system/RoleMenuManagement.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user