feat: UIMS 권한관리(시스템관리) 관리자 React 화면 추가
This commit is contained in:
parent
c18a6c2c51
commit
7fc31fe082
File diff suppressed because one or more lines are too long
189
backend/src/main/resources/static/assets/index-DMna-z4N.js
Normal file
189
backend/src/main/resources/static/assets/index-DMna-z4N.js
Normal file
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" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>GUARDiA HRM — AI 인사관리 플랫폼</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-DMna-z4N.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-B1fHWJij.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -11,11 +11,17 @@ import RecruitmentPage from './pages/RecruitmentPage'
|
|||||||
import TrainingPage from './pages/TrainingPage'
|
import TrainingPage from './pages/TrainingPage'
|
||||||
import AiPage from './pages/AiPage'
|
import AiPage from './pages/AiPage'
|
||||||
import AdminPage from './pages/AdminPage'
|
import AdminPage from './pages/AdminPage'
|
||||||
|
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'
|
||||||
|
// UIWS system(권한관리) 이식 — 시스템관리(권한) 화면 셸(탭: 권한/역할-메뉴/공통코드/메뉴/부서/거래처)
|
||||||
|
import SystemPage from './pages/uiws/system/SystemPage'
|
||||||
|
|
||||||
|
// 시스템관리(권한)는 관리자 전용. 백엔드 /api/system/** 조회=SUPERADMIN/MANAGER, 쓰기=SUPERADMIN.
|
||||||
|
const ADMIN_ROLES = ['SUPERADMIN', 'MANAGER', 'ADMIN']
|
||||||
|
|
||||||
const MENU = [
|
const MENU = [
|
||||||
{ path: '/dashboard', label: '대시보드', icon: '📊' },
|
{ path: '/dashboard', label: '대시보드', icon: '📊' },
|
||||||
@ -33,11 +39,16 @@ const MENU = [
|
|||||||
{ path: '/work-stats', label: '업무통계', icon: '📈' },
|
{ path: '/work-stats', label: '업무통계', icon: '📈' },
|
||||||
{ path: '/ai', label: 'AI 인사분석', icon: '🤖' },
|
{ path: '/ai', label: 'AI 인사분석', icon: '🤖' },
|
||||||
{ path: '/admin', label: '시스템관리', icon: '⚙️' },
|
{ path: '/admin', label: '시스템관리', icon: '⚙️' },
|
||||||
|
// UIWS system 이식 — 권한·코드·메뉴·부서·거래처 (관리자 전용)
|
||||||
|
{ path: '/system', label: '시스템관리(권한)', icon: '🔐', adminOnly: true },
|
||||||
|
{ path: '/mobile-app', label: '모바일 앱', icon: '📱' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function Layout({ children }: { children: React.ReactNode }) {
|
function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const user = JSON.parse(localStorage.getItem('hrm_user') || '{"displayName":"관리자","role":"SUPERADMIN"}')
|
const user = JSON.parse(localStorage.getItem('hrm_user') || '{"displayName":"관리자","role":"SUPERADMIN"}')
|
||||||
|
const isAdmin = ADMIN_ROLES.includes(user.role)
|
||||||
|
const visibleMenu = MENU.filter(m => !m.adminOnly || isAdmin)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden">
|
<div className="flex h-screen overflow-hidden">
|
||||||
@ -55,7 +66,7 @@ function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-1">
|
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-1">
|
||||||
{MENU.map(m => (
|
{visibleMenu.map(m => (
|
||||||
<NavLink key={m.path} to={m.path}
|
<NavLink key={m.path} to={m.path}
|
||||||
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}>
|
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}>
|
||||||
<span className="text-base">{m.icon}</span>
|
<span className="text-base">{m.icon}</span>
|
||||||
@ -97,6 +108,15 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
|
|||||||
return <Layout>{children}</Layout>
|
return <Layout>{children}</Layout>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 관리자 전용 라우트 가드(UI 차단). 실제 인가는 백엔드 /api/system/** RBAC가 강제(403).
|
||||||
|
function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const token = localStorage.getItem('hrm_token')
|
||||||
|
if (!token) return <Navigate to="/login" replace />
|
||||||
|
const user = JSON.parse(localStorage.getItem('hrm_user') || '{"role":"SUPERADMIN"}')
|
||||||
|
if (!ADMIN_ROLES.includes(user.role)) return <Navigate to="/dashboard" replace />
|
||||||
|
return <Layout>{children}</Layout>
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@ -118,6 +138,10 @@ export default function App() {
|
|||||||
<Route path="/work-stats" element={<PrivateRoute><StatsPivot /></PrivateRoute>} />
|
<Route path="/work-stats" element={<PrivateRoute><StatsPivot /></PrivateRoute>} />
|
||||||
<Route path="/ai" element={<PrivateRoute><AiPage /></PrivateRoute>} />
|
<Route path="/ai" element={<PrivateRoute><AiPage /></PrivateRoute>} />
|
||||||
<Route path="/admin" element={<PrivateRoute><AdminPage /></PrivateRoute>} />
|
<Route path="/admin" element={<PrivateRoute><AdminPage /></PrivateRoute>} />
|
||||||
|
{/* UIWS system 이식 — 시스템관리(권한). 관리자 전용(SUPERADMIN/MANAGER/ADMIN), 그 외 대시보드로 */}
|
||||||
|
<Route path="/system" element={<AdminRoute><SystemPage /></AdminRoute>} />
|
||||||
|
{/* 통합 메신저 앱 다운로드 QR (ITSM 중앙 APK 저장소 공개 엔드포인트 재사용·읽기전용) */}
|
||||||
|
<Route path="/mobile-app" element={<PrivateRoute><MobileApp /></PrivateRoute>} />
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@ -92,3 +92,52 @@ export const confirmWorklogComment = (cmtId: number) => api.post(`/api/worklogs/
|
|||||||
// ── 통계(stats)
|
// ── 통계(stats)
|
||||||
export const personalWorkStats = (params: Record<string, unknown>) => api.get('/api/stats/personal-work', { params })
|
export const personalWorkStats = (params: Record<string, unknown>) => api.get('/api/stats/personal-work', { params })
|
||||||
export const companyWorkStats = (params: Record<string, unknown>) => api.get('/api/stats/company-work', { params })
|
export const companyWorkStats = (params: Record<string, unknown>) => api.get('/api/stats/company-work', { params })
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ── 시스템관리(권한) — com.zioinfo.hrm.uiws.system 컨트롤러 계약 정합(실측).
|
||||||
|
* 봉투: { success, message, data }. 목록은 data.content(PageResponse). 쓰기는 SUPERADMIN, 조회는 SUPERADMIN/MANAGER.
|
||||||
|
* 삭제 방식이 자원별로 다름: roles/companies/menus = 다중삭제(DELETE body {ids}),
|
||||||
|
* depts/codes = 단건삭제(DELETE /{id}|{grpCd}). 코드는 그룹 단위(values 내장).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 권한(roles) — 목록(PageResponse) / 등록 / 수정 / 다중삭제
|
||||||
|
export const roleList = (keyword?: string, page = 0, size = 100) =>
|
||||||
|
api.get('/api/system/roles', { params: { keyword, page, size } })
|
||||||
|
export const createRole = (body: object) => api.post('/api/system/roles', body)
|
||||||
|
export const updateRole = (id: string, body: object) => api.put(`/api/system/roles/${id}`, body)
|
||||||
|
export const deleteRoles = (ids: string[]) => api.delete('/api/system/roles', { data: { ids } })
|
||||||
|
|
||||||
|
// 공통코드(codes) — 그룹목록 / 그룹상세(values 내장) / 그룹 CRUD(단건삭제)
|
||||||
|
export const codeGroupList = (keyword?: string, page = 0, size = 100) =>
|
||||||
|
api.get('/api/system/codes', { params: { keyword, page, size } })
|
||||||
|
export const codeGroupDetail = (grpCd: string) => api.get(`/api/system/codes/${grpCd}`)
|
||||||
|
export const createCodeGroup = (body: object) => api.post('/api/system/codes', body)
|
||||||
|
export const updateCodeGroup = (grpCd: string, body: object) => api.put(`/api/system/codes/${grpCd}`, body)
|
||||||
|
export const deleteCodeGroup = (grpCd: string) => api.delete(`/api/system/codes/${grpCd}`)
|
||||||
|
|
||||||
|
// 메뉴(menus) — 트리목록(PageResponse) / 등록 / 수정 / 단건삭제(MenuController는 다중삭제 본문 {ids})
|
||||||
|
export const menuList = (page = 0, size = 200) => api.get('/api/system/menus', { params: { page, size } })
|
||||||
|
export const createMenu = (body: object) => api.post('/api/system/menus', body)
|
||||||
|
export const updateMenu = (id: string, body: object) => api.put(`/api/system/menus/${id}`, body)
|
||||||
|
export const deleteMenu = (id: string) => api.delete('/api/system/menus', { data: { ids: [id] } })
|
||||||
|
|
||||||
|
// 부서(depts) — 목록(PageResponse) / CRUD(단건삭제)
|
||||||
|
export const deptList = (keyword?: string, page = 0, size = 200) =>
|
||||||
|
api.get('/api/system/depts', { params: { keyword, page, size } })
|
||||||
|
export const createDept = (body: object) => api.post('/api/system/depts', body)
|
||||||
|
export const updateDept = (id: string, body: object) => api.put(`/api/system/depts/${id}`, body)
|
||||||
|
export const deleteDept = (id: string) => api.delete(`/api/system/depts/${id}`)
|
||||||
|
|
||||||
|
// 거래처(companies) — 목록(PageResponse) / 등록 / 수정 / 단건삭제(CompanyController는 다중삭제 본문 {ids})
|
||||||
|
export const companyList = (keyword?: string, page = 0, size = 200) =>
|
||||||
|
api.get('/api/system/companies', { params: { keyword, page, size } })
|
||||||
|
export const createCompany = (body: object) => api.post('/api/system/companies', body)
|
||||||
|
export const updateCompany = (id: string, body: object) => api.put(`/api/system/companies/${id}`, body)
|
||||||
|
export const deleteCompany = (id: string) => api.delete('/api/system/companies', { data: { ids: [id] } })
|
||||||
|
|
||||||
|
// 권한-메뉴 매핑(role-menus) — 권한목록 / 권한별 메뉴매핑 조회·저장 (HRM에 존재, PMS엔 없었음)
|
||||||
|
export const roleMenuRoleList = (page = 0, size = 200) =>
|
||||||
|
api.get('/api/system/role-menus', { params: { page, size } })
|
||||||
|
export const roleMenus = (roleId: string) => api.get(`/api/system/roles/${roleId}/menus`)
|
||||||
|
export const saveRoleMenus = (roleId: string, menus: object[]) =>
|
||||||
|
api.put(`/api/system/roles/${roleId}/menus`, { menus })
|
||||||
|
|||||||
@ -49,6 +49,7 @@ body {
|
|||||||
*/
|
*/
|
||||||
:root {
|
:root {
|
||||||
--uiws-surface: #ffffff;
|
--uiws-surface: #ffffff;
|
||||||
|
--uiws-surface-2: #f1f5f9;
|
||||||
--uiws-border: #e2e8f0;
|
--uiws-border: #e2e8f0;
|
||||||
--uiws-text: #1e293b;
|
--uiws-text: #1e293b;
|
||||||
--uiws-text-muted: #64748b;
|
--uiws-text-muted: #64748b;
|
||||||
@ -66,6 +67,7 @@ body {
|
|||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--uiws-surface: #1e293b;
|
--uiws-surface: #1e293b;
|
||||||
|
--uiws-surface-2: #0f172a;
|
||||||
--uiws-border: #334155;
|
--uiws-border: #334155;
|
||||||
--uiws-text: #e2e8f0;
|
--uiws-text: #e2e8f0;
|
||||||
--uiws-text-muted: #94a3b8;
|
--uiws-text-muted: #94a3b8;
|
||||||
|
|||||||
124
frontend/src/pages/MobileApp.tsx
Normal file
124
frontend/src/pages/MobileApp.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import React, { 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 className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Smartphone className="text-blue-600" size={24} />
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800">GUARDiA 통합 메신저 앱 설치</h1>
|
||||||
|
</div>
|
||||||
|
<button onClick={load} className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-slate-100 text-slate-600 text-sm hover:bg-slate-200 transition-colors">
|
||||||
|
<RefreshCw size={15} /> 새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
QR 코드를 스캔하면 <span className="text-blue-600 font-semibold">GUARDiA Messenger</span> 앱 설치 페이지로 이동합니다.
|
||||||
|
앱스토어 없이 바로 설치할 수 있으며, 앱 업로드·버전 관리는 GUARDiA Manager에서 일원화되어 있습니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading && <div className="text-slate-400 text-sm">불러오는 중…</div>}
|
||||||
|
{err && <div className="card text-rose-600 text-sm">{err}</div>}
|
||||||
|
|
||||||
|
{!loading && !err && info && !info.has_version && (
|
||||||
|
<div className="card text-center text-slate-400 py-10">
|
||||||
|
<Smartphone size={36} className="mx-auto mb-3 text-slate-300" />
|
||||||
|
아직 등록된 앱 버전이 없습니다.<br />
|
||||||
|
<span className="text-xs">GUARDiA Manager에서 APK를 업로드하면 여기에 QR이 표시됩니다.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !err && info?.has_version && (
|
||||||
|
<div className="card grid md:grid-cols-[200px_1fr] gap-6 items-start">
|
||||||
|
<div className="bg-white border border-slate-200 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-slate-300" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-lg font-bold text-slate-800">{info.app_name || 'GUARDiA Messenger'}</span>
|
||||||
|
<span className="px-2 py-0.5 rounded-md bg-blue-600 text-white text-xs font-semibold">v{info.version}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500 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-slate-400 uppercase tracking-wider mb-1">릴리즈 노트</div>
|
||||||
|
<div className="text-sm text-slate-600 whitespace-pre-line bg-slate-50 border border-slate-200 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-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors">
|
||||||
|
<ExternalLink size={15} /> 설치 페이지 열기
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{info.download_url && (
|
||||||
|
<a href={info.download_url} target="_blank" rel="noreferrer"
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-slate-100 text-slate-700 text-sm font-medium hover:bg-slate-200 transition-colors">
|
||||||
|
<Download size={15} /> APK 직접 다운로드
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button onClick={copyLink}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-slate-100 text-slate-700 text-sm font-medium hover:bg-slate-200 transition-colors">
|
||||||
|
{copied ? <Check size={15} className="text-green-600" /> : <Copy size={15} />}
|
||||||
|
{copied ? '복사됨' : '다운로드 링크 복사'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
246
frontend/src/pages/uiws/system/CodeManagement.tsx
Normal file
246
frontend/src/pages/uiws/system/CodeManagement.tsx
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
codeGroupList, codeGroupDetail, createCodeGroup, updateCodeGroup, deleteCodeGroup,
|
||||||
|
} from '../../../api/uiws'
|
||||||
|
import {
|
||||||
|
PageHeader, Panel, SearchBar, Input, Button, DataGrid, Modal, FormField, Spinner, YnBadge, type Column,
|
||||||
|
} from '../../../components/uiws/ui'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 공통코드 관리 — PMS 백엔드(com.zioinfo.pms.uiws.system CodeController) 계약 정합.
|
||||||
|
* 코드는 "그룹 단위"로 관리: 그룹 상세(CodeGrpDetailDto)에 values[]가 내장되며, 그룹 저장 시 values를 통째로 전송한다.
|
||||||
|
* (별도 per-codeVal CRUD 엔드포인트 없음 → 코드값 추가/수정/삭제는 detail 편집 후 그룹 PUT으로 반영)
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface CodeGrp extends Record<string, unknown> { grpCd: string; grpNm: string; useYn: string }
|
||||||
|
interface Code extends Record<string, unknown> { grpCd: string; codeVal: string; codeNm: string; sortOrd: number; useYn: string }
|
||||||
|
|
||||||
|
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 [groups, setGroups] = useState<CodeGrp[]>([])
|
||||||
|
const [activeGrp, setActiveGrp] = useState<string>('')
|
||||||
|
const [grpInfo, setGrpInfo] = useState<{ grpNm: string; useYn: string }>({ grpNm: '', useYn: 'Y' })
|
||||||
|
const [codes, setCodes] = useState<Code[]>([])
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [loadingGrp, setLoadingGrp] = useState(false)
|
||||||
|
const [loadingCode, setLoadingCode] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [grpForm, setGrpForm] = useState<{ open: boolean; edit: CodeGrp | null }>({ open: false, edit: null })
|
||||||
|
const [codeForm, setCodeForm] = useState<{ open: boolean; edit: Code | null }>({ open: false, edit: null })
|
||||||
|
|
||||||
|
const loadGroups = async (kw = '') => {
|
||||||
|
setLoadingGrp(true); setErr('')
|
||||||
|
try {
|
||||||
|
const res = await codeGroupList(kw || undefined)
|
||||||
|
const list: CodeGrp[] = res.data.data?.content ?? []
|
||||||
|
setGroups(list)
|
||||||
|
if (!activeGrp && list.length > 0) setActiveGrp(list[0].grpCd)
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoadingGrp(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { loadGroups('') }, [])
|
||||||
|
|
||||||
|
const loadDetail = async (grp = activeGrp) => {
|
||||||
|
if (!grp) { setCodes([]); return }
|
||||||
|
setLoadingCode(true)
|
||||||
|
try {
|
||||||
|
const res = await codeGroupDetail(grp)
|
||||||
|
const d = res.data.data
|
||||||
|
setGrpInfo({ grpNm: d?.grpNm ?? '', useYn: d?.useYn ?? 'Y' })
|
||||||
|
setCodes((d?.values ?? []).map((v: Code) => ({ ...v, grpCd: grp })))
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoadingCode(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { loadDetail() }, [activeGrp])
|
||||||
|
|
||||||
|
const removeGroup = async (grpCd: string) => {
|
||||||
|
if (!window.confirm(`코드그룹 '${grpCd}' 와 하위 코드를 삭제하시겠습니까?`)) return
|
||||||
|
try {
|
||||||
|
await deleteCodeGroup(grpCd)
|
||||||
|
if (activeGrp === grpCd) { setActiveGrp(''); setCodes([]) }
|
||||||
|
await loadGroups()
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 코드값 추가/수정: 현재 values 배열에 머지 후 그룹 PUT.
|
||||||
|
const saveCodeValue = async (val: Code, originalCodeVal: string | null) => {
|
||||||
|
const next = originalCodeVal
|
||||||
|
? codes.map(c => (c.codeVal === originalCodeVal ? val : c))
|
||||||
|
: [...codes, val]
|
||||||
|
await updateCodeGroup(activeGrp, { grpCd: activeGrp, grpNm: grpInfo.grpNm, useYn: grpInfo.useYn, values: next })
|
||||||
|
await loadDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCode = async (c: Code) => {
|
||||||
|
if (!window.confirm(`코드 '${c.codeNm}' 를 삭제하시겠습니까?`)) return
|
||||||
|
try {
|
||||||
|
const next = codes.filter(x => x.codeVal !== c.codeVal)
|
||||||
|
await updateCodeGroup(activeGrp, { grpCd: activeGrp, grpNm: grpInfo.grpNm, useYn: grpInfo.useYn, values: next })
|
||||||
|
await loadDetail()
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeColumns: Column<Code>[] = [
|
||||||
|
{ key: 'codeVal', header: '코드값', width: 150 },
|
||||||
|
{ key: 'codeNm', header: '코드명' },
|
||||||
|
{ key: 'sortOrd', header: '정렬', width: 70, align: 'center' },
|
||||||
|
{ key: 'useYn', header: '사용', width: 80, align: 'center', render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
|
||||||
|
{
|
||||||
|
key: 'act', header: '', width: 60, align: 'center',
|
||||||
|
render: r => (
|
||||||
|
<Button variant="ghost" onClick={() => removeCode(r)}>삭제</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="uiws-scope">
|
||||||
|
<PageHeader title="공통코드 관리" subtitle="시스템관리(권한) · 코드그룹/코드값 CRUD"
|
||||||
|
actions={<Button onClick={() => setGrpForm({ open: true, edit: null })}>+ 코드그룹 등록</Button>} />
|
||||||
|
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginBottom: 12 }}>{err}</div>}
|
||||||
|
|
||||||
|
<SearchBar>
|
||||||
|
<Input value={keyword} onChange={setKeyword} placeholder="코드그룹 검색" style={{ minWidth: 220 }} />
|
||||||
|
<Button variant="ghost" onClick={() => loadGroups(keyword)}>검색</Button>
|
||||||
|
</SearchBar>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '300px 1fr', gap: 16, alignItems: 'start' }}>
|
||||||
|
<Panel style={{ padding: 0 }}>
|
||||||
|
<div style={{ padding: '12px 16px', fontSize: 12, fontWeight: 600, color: 'var(--uiws-text-muted)', borderBottom: '1px solid var(--uiws-border)' }}>
|
||||||
|
코드그룹
|
||||||
|
</div>
|
||||||
|
{loadingGrp ? <Spinner /> : (
|
||||||
|
<div>
|
||||||
|
{groups.length === 0 && <div style={{ padding: 20, fontSize: 13, color: 'var(--uiws-text-faint)' }}>코드그룹이 없습니다.</div>}
|
||||||
|
{groups.map(g => (
|
||||||
|
<div key={g.grpCd} onClick={() => setActiveGrp(g.grpCd)}
|
||||||
|
style={{
|
||||||
|
padding: '11px 16px', cursor: 'pointer', fontSize: 13, display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
borderLeft: `3px solid ${activeGrp === g.grpCd ? 'var(--uiws-primary)' : 'transparent'}`,
|
||||||
|
background: activeGrp === g.grpCd ? 'var(--uiws-primary-soft)' : 'transparent',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, color: activeGrp === g.grpCd ? 'var(--uiws-primary)' : 'var(--uiws-text)' }}>{g.grpNm}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--uiws-text-muted)' }}>{g.grpCd}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<Button variant="ghost" onClick={() => setGrpForm({ open: true, edit: g })}>수정</Button>
|
||||||
|
<Button variant="ghost" onClick={() => removeGroup(g.grpCd)}>삭제</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--uiws-text)' }}>
|
||||||
|
코드값 {activeGrp && <span style={{ color: 'var(--uiws-text-muted)', fontWeight: 400 }}>({activeGrp})</span>}
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setCodeForm({ open: true, edit: null })} disabled={!activeGrp}>+ 코드값 등록</Button>
|
||||||
|
</div>
|
||||||
|
{loadingCode ? <Spinner /> : (
|
||||||
|
<DataGrid columns={codeColumns} rows={codes} rowKey={r => `${r.grpCd}:${r.codeVal}`}
|
||||||
|
onRowClick={r => setCodeForm({ open: true, edit: r })} empty={activeGrp ? '코드값이 없습니다.' : '코드그룹을 선택하세요.'} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{grpForm.open && (
|
||||||
|
<CodeGroupForm grp={grpForm.edit} onClose={() => setGrpForm({ open: false, edit: null })}
|
||||||
|
onSaved={() => { setGrpForm({ open: false, edit: null }); loadGroups(keyword) }} />
|
||||||
|
)}
|
||||||
|
{codeForm.open && activeGrp && (
|
||||||
|
<CodeForm grpCd={activeGrp} code={codeForm.edit} existing={codes}
|
||||||
|
onSave={saveCodeValue}
|
||||||
|
onClose={() => setCodeForm({ open: false, edit: null })}
|
||||||
|
onSaved={() => setCodeForm({ open: false, edit: null })} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectYn({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
|
return (
|
||||||
|
<select value={value} onChange={e => onChange(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, fontSize: 13,
|
||||||
|
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)' }}>
|
||||||
|
<option value="Y">사용</option>
|
||||||
|
<option value="N">미사용</option>
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeGroupForm({ grp, onClose, onSaved }: { grp: CodeGrp | null; onClose: () => void; onSaved: () => void }) {
|
||||||
|
const editing = !!grp
|
||||||
|
const [grpCd, setGrpCd] = useState(grp?.grpCd ?? '')
|
||||||
|
const [grpNm, setGrpNm] = useState(grp?.grpNm ?? '')
|
||||||
|
const [useYn, setUseYn] = useState(grp?.useYn ?? 'Y')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setErr('')
|
||||||
|
if (!editing && !grpCd.trim()) { setErr('그룹코드는 필수입니다.'); return }
|
||||||
|
if (!grpNm.trim()) { setErr('그룹명은 필수입니다.'); return }
|
||||||
|
try {
|
||||||
|
// 신규 그룹은 values 빈배열, 수정은 그룹 메타만 변경(코드값은 코드값 화면에서 관리).
|
||||||
|
const body = { grpCd, grpNm, useYn, values: [] as Code[] }
|
||||||
|
editing ? await updateCodeGroup(grpCd, { ...body, values: undefined as any }) : await createCodeGroup(body)
|
||||||
|
onSaved()
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={editing ? '코드그룹 수정' : '코드그룹 등록'} onClose={onClose}
|
||||||
|
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
||||||
|
<FormField label="그룹코드"><Input value={grpCd} onChange={setGrpCd} style={{ width: '100%', opacity: editing ? 0.6 : 1 }} /></FormField>
|
||||||
|
<FormField label="그룹명"><Input value={grpNm} onChange={setGrpNm} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="사용 여부"><SelectYn value={useYn} onChange={setUseYn} /></FormField>
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeForm({ grpCd, code, existing, onSave, onClose, onSaved }: {
|
||||||
|
grpCd: string; code: Code | null; existing: Code[]
|
||||||
|
onSave: (val: Code, originalCodeVal: string | null) => Promise<void>
|
||||||
|
onClose: () => void; onSaved: () => void
|
||||||
|
}) {
|
||||||
|
const editing = !!code
|
||||||
|
const [codeVal, setCodeVal] = useState(code?.codeVal ?? '')
|
||||||
|
const [codeNm, setCodeNm] = useState(code?.codeNm ?? '')
|
||||||
|
const [sortOrd, setSortOrd] = useState(String(code?.sortOrd ?? 0))
|
||||||
|
const [useYn, setUseYn] = useState(code?.useYn ?? 'Y')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setErr('')
|
||||||
|
if (!editing && !codeVal.trim()) { setErr('코드값은 필수입니다.'); return }
|
||||||
|
if (!codeNm.trim()) { setErr('코드명은 필수입니다.'); return }
|
||||||
|
if (!editing && existing.some(c => c.codeVal === codeVal.trim())) { setErr('이미 존재하는 코드값입니다.'); return }
|
||||||
|
try {
|
||||||
|
const val: Code = { grpCd, codeVal, codeNm, sortOrd: Number(sortOrd) || 0, useYn }
|
||||||
|
await onSave(val, editing ? (code as Code).codeVal : null)
|
||||||
|
onSaved()
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={editing ? '코드값 수정' : '코드값 등록'} onClose={onClose}
|
||||||
|
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
||||||
|
<FormField label="그룹코드"><Input value={grpCd} onChange={() => {}} style={{ width: '100%', opacity: 0.6 }} /></FormField>
|
||||||
|
<FormField label="코드값"><Input value={codeVal} onChange={setCodeVal} style={{ width: '100%', opacity: editing ? 0.6 : 1 }} /></FormField>
|
||||||
|
<FormField label="코드명"><Input value={codeNm} onChange={setCodeNm} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="정렬순서"><Input type="number" value={sortOrd} onChange={setSortOrd} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="사용 여부"><SelectYn value={useYn} onChange={setUseYn} /></FormField>
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
106
frontend/src/pages/uiws/system/CompanyManagement.tsx
Normal file
106
frontend/src/pages/uiws/system/CompanyManagement.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { companyList, createCompany, updateCompany, deleteCompany } from '../../../api/uiws'
|
||||||
|
import {
|
||||||
|
PageHeader, SearchBar, Input, Button, DataGrid, Modal, FormField, Spinner, YnBadge, type Column,
|
||||||
|
} from '../../../components/uiws/ui'
|
||||||
|
|
||||||
|
interface Company extends Record<string, unknown> {
|
||||||
|
companyId: string; companyNm: string; bizNo: string | null; useYn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlyError(e: any): string {
|
||||||
|
if (e?.response?.status === 403) return '권한이 없습니다 (관리자 전용).'
|
||||||
|
if (e?.response?.status === 409) return '이미 존재하는 거래처ID입니다.'
|
||||||
|
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompanyManagement() {
|
||||||
|
const [rows, setRows] = useState<Company[]>([])
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [form, setForm] = useState<{ open: boolean; edit: Company | null }>({ open: false, edit: null })
|
||||||
|
|
||||||
|
const load = async (kw = '') => {
|
||||||
|
setLoading(true); setErr('')
|
||||||
|
try { const res = await companyList(kw || undefined); setRows(res.data.data?.content ?? []) }
|
||||||
|
catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { load('') }, [])
|
||||||
|
|
||||||
|
const remove = async (c: Company) => {
|
||||||
|
if (!window.confirm(`거래처 '${c.companyNm}' 를 삭제하시겠습니까?`)) return
|
||||||
|
try { await deleteCompany(c.companyId); await load() } catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<Company>[] = [
|
||||||
|
{ key: 'companyId', header: '거래처ID', width: 180 },
|
||||||
|
{ key: 'companyNm', header: '거래처명' },
|
||||||
|
{ key: 'bizNo', header: '사업자번호', width: 180, render: r => r.bizNo ?? '-' },
|
||||||
|
{ key: 'useYn', header: '사용', width: 80, align: 'center', render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
|
||||||
|
{ key: 'act', header: '', width: 60, align: 'center', render: r => <Button variant="ghost" onClick={() => remove(r)}>삭제</Button> },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="uiws-scope">
|
||||||
|
<PageHeader title="거래처 관리" subtitle="시스템관리(권한) · 거래처(근무처) 등록/수정/삭제"
|
||||||
|
actions={<Button onClick={() => setForm({ open: true, edit: null })}>+ 거래처 등록</Button>} />
|
||||||
|
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginBottom: 12 }}>{err}</div>}
|
||||||
|
|
||||||
|
<SearchBar>
|
||||||
|
<Input value={keyword} onChange={setKeyword} placeholder="거래처명 검색" style={{ minWidth: 220 }} />
|
||||||
|
<Button variant="ghost" onClick={() => load(keyword)}>검색</Button>
|
||||||
|
</SearchBar>
|
||||||
|
|
||||||
|
{loading ? <Spinner /> : (
|
||||||
|
<DataGrid columns={columns} rows={rows} rowKey={r => r.companyId}
|
||||||
|
onRowClick={r => setForm({ open: true, edit: r })} empty="등록된 거래처가 없습니다." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.open && (
|
||||||
|
<CompanyForm company={form.edit} onClose={() => setForm({ open: false, edit: null })}
|
||||||
|
onSaved={() => { setForm({ open: false, edit: null }); load(keyword) }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompanyForm({ company, onClose, onSaved }: { company: Company | null; onClose: () => void; onSaved: () => void }) {
|
||||||
|
const editing = !!company
|
||||||
|
const [companyId, setCompanyId] = useState(company?.companyId ?? '')
|
||||||
|
const [companyNm, setCompanyNm] = useState(company?.companyNm ?? '')
|
||||||
|
const [bizNo, setBizNo] = useState(company?.bizNo ?? '')
|
||||||
|
const [useYn, setUseYn] = useState(company?.useYn ?? 'Y')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setErr('')
|
||||||
|
if (!editing && !companyId.trim()) { setErr('거래처ID는 필수입니다.'); return }
|
||||||
|
if (!companyNm.trim()) { setErr('거래처명은 필수입니다.'); return }
|
||||||
|
try {
|
||||||
|
const body = { companyId, companyNm, bizNo: bizNo || null, useYn }
|
||||||
|
editing ? await updateCompany(companyId, body) : await createCompany(body)
|
||||||
|
onSaved()
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={editing ? '거래처 수정' : '거래처 등록'} onClose={onClose}
|
||||||
|
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
||||||
|
<FormField label="거래처ID"><Input value={companyId} onChange={setCompanyId} style={{ width: '100%', opacity: editing ? 0.6 : 1 }} /></FormField>
|
||||||
|
<FormField label="거래처명"><Input value={companyNm} onChange={setCompanyNm} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="사업자번호"><Input value={bizNo} onChange={setBizNo} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="사용 여부">
|
||||||
|
<select value={useYn} onChange={e => setUseYn(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, fontSize: 13,
|
||||||
|
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)' }}>
|
||||||
|
<option value="Y">사용</option>
|
||||||
|
<option value="N">미사용</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
frontend/src/pages/uiws/system/DeptManagement.tsx
Normal file
140
frontend/src/pages/uiws/system/DeptManagement.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { deptList, createDept, updateDept, deleteDept } from '../../../api/uiws'
|
||||||
|
import {
|
||||||
|
PageHeader, SearchBar, Input, Button, DataGrid, Modal, FormField, Spinner, YnBadge, type Column,
|
||||||
|
} from '../../../components/uiws/ui'
|
||||||
|
|
||||||
|
interface Dept extends Record<string, unknown> {
|
||||||
|
deptId: string; deptNm: string; parentDeptId: string | null; sortOrd: number; useYn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlyError(e: any): string {
|
||||||
|
if (e?.response?.status === 403) return '권한이 없습니다 (관리자 전용).'
|
||||||
|
if (e?.response?.status === 409) return '이미 존재하는 부서ID입니다.'
|
||||||
|
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeptManagement() {
|
||||||
|
const [rows, setRows] = useState<Dept[]>([])
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [form, setForm] = useState<{ open: boolean; edit: Dept | null }>({ open: false, edit: null })
|
||||||
|
|
||||||
|
const load = async (kw = '') => {
|
||||||
|
setLoading(true); setErr('')
|
||||||
|
try { const res = await deptList(kw || undefined); setRows(res.data.data?.content ?? []) }
|
||||||
|
catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { load('') }, [])
|
||||||
|
|
||||||
|
const ordered = useMemo(() => {
|
||||||
|
const roots = rows.filter(r => !r.parentDeptId).sort((a, b) => (a.sortOrd ?? 0) - (b.sortOrd ?? 0))
|
||||||
|
const out: (Dept & { _depth: number })[] = []
|
||||||
|
for (const root of roots) {
|
||||||
|
out.push({ ...root, _depth: 0 })
|
||||||
|
rows.filter(r => r.parentDeptId === root.deptId)
|
||||||
|
.sort((a, b) => (a.sortOrd ?? 0) - (b.sortOrd ?? 0))
|
||||||
|
.forEach(c => out.push({ ...c, _depth: 1 }))
|
||||||
|
}
|
||||||
|
const seen = new Set(out.map(o => o.deptId))
|
||||||
|
rows.filter(r => !seen.has(r.deptId)).forEach(m => out.push({ ...m, _depth: 0 }))
|
||||||
|
return out
|
||||||
|
}, [rows])
|
||||||
|
|
||||||
|
const remove = async (d: Dept) => {
|
||||||
|
if (!window.confirm(`부서 '${d.deptNm}' 를 삭제하시겠습니까?`)) return
|
||||||
|
try { await deleteDept(d.deptId); await load() } catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<Dept & { _depth: number }>[] = [
|
||||||
|
{
|
||||||
|
key: 'deptNm', header: '부서명',
|
||||||
|
render: r => (
|
||||||
|
<span style={{ paddingLeft: r._depth * 22 }}>
|
||||||
|
{r._depth > 0 && <span style={{ color: 'var(--uiws-text-faint)' }}>└ </span>}
|
||||||
|
{r.deptNm} <span style={{ color: 'var(--uiws-text-muted)', fontSize: 12 }}>({r.deptId})</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'sortOrd', header: '정렬', width: 70, align: 'center' },
|
||||||
|
{ key: 'useYn', header: '사용', width: 80, align: 'center', render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
|
||||||
|
{ key: 'act', header: '', width: 60, align: 'center', render: r => <Button variant="ghost" onClick={() => remove(r)}>삭제</Button> },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="uiws-scope">
|
||||||
|
<PageHeader title="부서 관리" subtitle="시스템관리(권한) · 부서 트리(2-depth) 등록/수정/삭제"
|
||||||
|
actions={<Button onClick={() => setForm({ open: true, edit: null })}>+ 부서 등록</Button>} />
|
||||||
|
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginBottom: 12 }}>{err}</div>}
|
||||||
|
|
||||||
|
<SearchBar>
|
||||||
|
<Input value={keyword} onChange={setKeyword} placeholder="부서명 검색" style={{ minWidth: 220 }} />
|
||||||
|
<Button variant="ghost" onClick={() => load(keyword)}>검색</Button>
|
||||||
|
</SearchBar>
|
||||||
|
|
||||||
|
{loading ? <Spinner /> : (
|
||||||
|
<DataGrid columns={columns} rows={ordered} rowKey={r => r.deptId}
|
||||||
|
onRowClick={r => setForm({ open: true, edit: r })} empty="등록된 부서가 없습니다." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.open && (
|
||||||
|
<DeptForm dept={form.edit} parents={rows.filter(r => !r.parentDeptId)}
|
||||||
|
onClose={() => setForm({ open: false, edit: null })}
|
||||||
|
onSaved={() => { setForm({ open: false, edit: null }); load(keyword) }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeptForm({ dept, parents, onClose, onSaved }: {
|
||||||
|
dept: Dept | null; parents: Dept[]; onClose: () => void; onSaved: () => void
|
||||||
|
}) {
|
||||||
|
const editing = !!dept
|
||||||
|
const [deptId, setDeptId] = useState(dept?.deptId ?? '')
|
||||||
|
const [deptNm, setDeptNm] = useState(dept?.deptNm ?? '')
|
||||||
|
const [parentDeptId, setParentDeptId] = useState(dept?.parentDeptId ?? '')
|
||||||
|
const [sortOrd, setSortOrd] = useState(String(dept?.sortOrd ?? 0))
|
||||||
|
const [useYn, setUseYn] = useState(dept?.useYn ?? 'Y')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const selStyle = {
|
||||||
|
width: '100%', padding: '9px 12px', borderRadius: 8, fontSize: 13,
|
||||||
|
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setErr('')
|
||||||
|
if (!editing && !deptId.trim()) { setErr('부서ID는 필수입니다.'); return }
|
||||||
|
if (!deptNm.trim()) { setErr('부서명은 필수입니다.'); return }
|
||||||
|
try {
|
||||||
|
const body = { deptId, deptNm, parentDeptId: parentDeptId || null, sortOrd: Number(sortOrd) || 0, useYn }
|
||||||
|
editing ? await updateDept(deptId, body) : await createDept(body)
|
||||||
|
onSaved()
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={editing ? '부서 수정' : '부서 등록'} onClose={onClose}
|
||||||
|
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
||||||
|
<FormField label="부서ID"><Input value={deptId} onChange={setDeptId} style={{ width: '100%', opacity: editing ? 0.6 : 1 }} /></FormField>
|
||||||
|
<FormField label="부서명"><Input value={deptNm} onChange={setDeptNm} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="상위 부서">
|
||||||
|
<select value={parentDeptId} onChange={e => setParentDeptId(e.target.value)} style={selStyle}>
|
||||||
|
<option value="">(최상위)</option>
|
||||||
|
{parents.filter(p => p.deptId !== deptId).map(p => <option key={p.deptId} value={p.deptId}>{p.deptNm} ({p.deptId})</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="정렬순서"><Input type="number" value={sortOrd} onChange={setSortOrd} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="사용 여부">
|
||||||
|
<select value={useYn} onChange={e => setUseYn(e.target.value)} style={selStyle}>
|
||||||
|
<option value="Y">사용</option>
|
||||||
|
<option value="N">미사용</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
161
frontend/src/pages/uiws/system/MenuManagement.tsx
Normal file
161
frontend/src/pages/uiws/system/MenuManagement.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { menuList, createMenu, updateMenu, deleteMenu } from '../../../api/uiws'
|
||||||
|
import {
|
||||||
|
PageHeader, SearchBar, Input, Button, DataGrid, Modal, FormField, Spinner, YnBadge, type Column,
|
||||||
|
} from '../../../components/uiws/ui'
|
||||||
|
|
||||||
|
interface Menu extends Record<string, unknown> {
|
||||||
|
menuId: string; menuNm: string; parentMenuId: string | null; programId: string | null
|
||||||
|
menuUrl: string | null; sortOrd: number; useYn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlyError(e: any): string {
|
||||||
|
if (e?.response?.status === 403) return '권한이 없습니다 (관리자 전용).'
|
||||||
|
if (e?.response?.status === 409) return '이미 존재하는 메뉴ID입니다.'
|
||||||
|
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MenuManagement() {
|
||||||
|
const [rows, setRows] = useState<Menu[]>([])
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [form, setForm] = useState<{ open: boolean; edit: Menu | null }>({ open: false, edit: null })
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true); setErr('')
|
||||||
|
try { const res = await menuList(); setRows(res.data.data?.content ?? []) }
|
||||||
|
catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
|
// 검색은 클라이언트 필터(메뉴 목록은 풀로드). 부모가 매치되면 자식도 함께 보이도록 정렬 전 적용.
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const kw = keyword.trim().toLowerCase()
|
||||||
|
if (!kw) return rows
|
||||||
|
return rows.filter(r => r.menuNm.toLowerCase().includes(kw) || r.menuId.toLowerCase().includes(kw))
|
||||||
|
}, [rows, keyword])
|
||||||
|
|
||||||
|
// 2-depth: 부모 → 자식 순으로 정렬해 들여쓰기 표시.
|
||||||
|
const ordered = useMemo(() => {
|
||||||
|
const rows = filtered
|
||||||
|
const roots = rows.filter(r => !r.parentMenuId).sort((a, b) => (a.sortOrd ?? 0) - (b.sortOrd ?? 0))
|
||||||
|
const out: { menu: Menu; depth: number }[] = []
|
||||||
|
for (const root of roots) {
|
||||||
|
out.push({ menu: root, depth: 0 })
|
||||||
|
rows.filter(r => r.parentMenuId === root.menuId)
|
||||||
|
.sort((a, b) => (a.sortOrd ?? 0) - (b.sortOrd ?? 0))
|
||||||
|
.forEach(c => out.push({ menu: c, depth: 1 }))
|
||||||
|
}
|
||||||
|
// 부모가 목록에 없는 고아 항목도 표시
|
||||||
|
const seen = new Set(out.map(o => o.menu.menuId))
|
||||||
|
rows.filter(r => !seen.has(r.menuId)).forEach(m => out.push({ menu: m, depth: 0 }))
|
||||||
|
return out.map(o => ({ ...o.menu, _depth: o.depth })) as (Menu & { _depth: number })[]
|
||||||
|
}, [filtered])
|
||||||
|
|
||||||
|
const remove = async (m: Menu) => {
|
||||||
|
if (!window.confirm(`메뉴 '${m.menuNm}' 를 삭제하시겠습니까?`)) return
|
||||||
|
try { await deleteMenu(m.menuId); await load() }
|
||||||
|
catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<Menu & { _depth: number }>[] = [
|
||||||
|
{
|
||||||
|
key: 'menuNm', header: '메뉴명',
|
||||||
|
render: r => (
|
||||||
|
<span style={{ paddingLeft: r._depth * 22 }}>
|
||||||
|
{r._depth > 0 && <span style={{ color: 'var(--uiws-text-faint)' }}>└ </span>}
|
||||||
|
{r.menuNm} <span style={{ color: 'var(--uiws-text-muted)', fontSize: 12 }}>({r.menuId})</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'menuUrl', header: 'URL', width: 220, render: r => r.menuUrl ?? '-' },
|
||||||
|
{ key: 'programId', header: '프로그램', width: 140, render: r => r.programId ?? '-' },
|
||||||
|
{ key: 'sortOrd', header: '정렬', width: 70, align: 'center' },
|
||||||
|
{ key: 'useYn', header: '사용', width: 80, align: 'center', render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
|
||||||
|
{ key: 'act', header: '', width: 60, align: 'center', render: r => <Button variant="ghost" onClick={() => remove(r)}>삭제</Button> },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="uiws-scope">
|
||||||
|
<PageHeader title="메뉴 관리" subtitle="시스템관리(권한) · 메뉴 트리(2-depth) 등록/수정/삭제"
|
||||||
|
actions={<Button onClick={() => setForm({ open: true, edit: null })}>+ 메뉴 등록</Button>} />
|
||||||
|
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginBottom: 12 }}>{err}</div>}
|
||||||
|
|
||||||
|
<SearchBar>
|
||||||
|
<Input value={keyword} onChange={setKeyword} placeholder="메뉴명 검색" style={{ minWidth: 220 }} />
|
||||||
|
<Button variant="ghost" onClick={() => { /* 클라이언트 필터(keyword 상태) — 재조회 불필요 */ }}>검색</Button>
|
||||||
|
</SearchBar>
|
||||||
|
|
||||||
|
{loading ? <Spinner /> : (
|
||||||
|
<DataGrid columns={columns} rows={ordered} rowKey={r => r.menuId}
|
||||||
|
onRowClick={r => setForm({ open: true, edit: r })} empty="등록된 메뉴가 없습니다." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.open && (
|
||||||
|
<MenuForm menu={form.edit} parents={rows.filter(r => !r.parentMenuId)}
|
||||||
|
onClose={() => setForm({ open: false, edit: null })}
|
||||||
|
onSaved={() => { setForm({ open: false, edit: null }); load() }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuForm({ menu, parents, onClose, onSaved }: {
|
||||||
|
menu: Menu | null; parents: Menu[]; onClose: () => void; onSaved: () => void
|
||||||
|
}) {
|
||||||
|
const editing = !!menu
|
||||||
|
const [menuId, setMenuId] = useState(menu?.menuId ?? '')
|
||||||
|
const [menuNm, setMenuNm] = useState(menu?.menuNm ?? '')
|
||||||
|
const [parentMenuId, setParentMenuId] = useState(menu?.parentMenuId ?? '')
|
||||||
|
const [programId, setProgramId] = useState(menu?.programId ?? '')
|
||||||
|
const [menuUrl, setMenuUrl] = useState(menu?.menuUrl ?? '')
|
||||||
|
const [sortOrd, setSortOrd] = useState(String(menu?.sortOrd ?? 0))
|
||||||
|
const [useYn, setUseYn] = useState(menu?.useYn ?? 'Y')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setErr('')
|
||||||
|
if (!editing && !menuId.trim()) { setErr('메뉴ID는 필수입니다.'); return }
|
||||||
|
if (!menuNm.trim()) { setErr('메뉴명은 필수입니다.'); return }
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
menuId, menuNm, parentMenuId: parentMenuId || null, programId: programId || null,
|
||||||
|
menuUrl: menuUrl || null, sortOrd: Number(sortOrd) || 0, useYn,
|
||||||
|
}
|
||||||
|
editing ? await updateMenu(menuId, body) : await createMenu(body)
|
||||||
|
onSaved()
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const selStyle = {
|
||||||
|
width: '100%', padding: '9px 12px', borderRadius: 8, fontSize: 13,
|
||||||
|
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={editing ? '메뉴 수정' : '메뉴 등록'} onClose={onClose}
|
||||||
|
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
||||||
|
<FormField label="메뉴ID"><Input value={menuId} onChange={setMenuId} style={{ width: '100%', opacity: editing ? 0.6 : 1 }} /></FormField>
|
||||||
|
<FormField label="메뉴명"><Input value={menuNm} onChange={setMenuNm} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="상위 메뉴">
|
||||||
|
<select value={parentMenuId} onChange={e => setParentMenuId(e.target.value)} style={selStyle}>
|
||||||
|
<option value="">(최상위)</option>
|
||||||
|
{parents.filter(p => p.menuId !== menuId).map(p => <option key={p.menuId} value={p.menuId}>{p.menuNm} ({p.menuId})</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="프로그램ID"><Input value={programId} onChange={setProgramId} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="메뉴 URL"><Input value={menuUrl} onChange={setMenuUrl} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="정렬순서"><Input type="number" value={sortOrd} onChange={setSortOrd} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="사용 여부">
|
||||||
|
<select value={useYn} onChange={e => setUseYn(e.target.value)} style={selStyle}>
|
||||||
|
<option value="Y">사용</option>
|
||||||
|
<option value="N">미사용</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
131
frontend/src/pages/uiws/system/RoleManagement.tsx
Normal file
131
frontend/src/pages/uiws/system/RoleManagement.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
roleList, createRole, updateRole, deleteRoles,
|
||||||
|
} from '../../../api/uiws'
|
||||||
|
import {
|
||||||
|
PageHeader, SearchBar, Input, Button, DataGrid, Modal, FormField, Spinner, YnBadge, type Column,
|
||||||
|
} from '../../../components/uiws/ui'
|
||||||
|
|
||||||
|
interface RoleRow extends Record<string, unknown> {
|
||||||
|
roleId: string; roleNm: string; roleDesc: string; useYn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlyError(e: any): string {
|
||||||
|
if (e?.response?.status === 403) return '권한이 없습니다 (관리자 전용).'
|
||||||
|
if (e?.response?.status === 409) return '이미 존재하는 권한ID입니다.'
|
||||||
|
return e?.response?.data?.message || '요청을 처리하지 못했습니다. 잠시 후 다시 시도하세요.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoleManagement() {
|
||||||
|
const [rows, setRows] = useState<RoleRow[]>([])
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
const [edit, setEdit] = useState<RoleRow | null>(null)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
|
||||||
|
const load = async (kw = keyword) => {
|
||||||
|
setLoading(true); setErr('')
|
||||||
|
try {
|
||||||
|
const res = await roleList(kw || undefined)
|
||||||
|
setRows(res.data.data?.content ?? [])
|
||||||
|
setSelected(new Set())
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { load('') }, [])
|
||||||
|
|
||||||
|
const toggleSel = (id: string) =>
|
||||||
|
setSelected(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n })
|
||||||
|
|
||||||
|
const removeSelected = async () => {
|
||||||
|
if (selected.size === 0) return
|
||||||
|
if (!window.confirm(`선택한 권한 ${selected.size}건을 삭제하시겠습니까?`)) return
|
||||||
|
try { await deleteRoles([...selected]); await load() }
|
||||||
|
catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<RoleRow>[] = [
|
||||||
|
{
|
||||||
|
key: 'sel', header: '', width: 44, align: 'center',
|
||||||
|
render: r => (
|
||||||
|
<input type="checkbox" checked={selected.has(r.roleId)}
|
||||||
|
onClick={e => e.stopPropagation()} onChange={() => toggleSel(r.roleId)} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'roleId', header: '권한ID', width: 180 },
|
||||||
|
{ key: 'roleNm', header: '권한명' },
|
||||||
|
{ key: 'roleDesc', header: '설명' },
|
||||||
|
{ key: 'useYn', header: '사용', width: 80, align: 'center', render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="uiws-scope">
|
||||||
|
<PageHeader title="권한 관리" subtitle="시스템관리(권한) · 역할(Role) 등록/수정/삭제 — RBAC"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button variant="danger" onClick={removeSelected} disabled={selected.size === 0}>선택 삭제</Button>
|
||||||
|
<Button onClick={() => { setEdit(null); setShowForm(true) }}>+ 권한 등록</Button>
|
||||||
|
</>
|
||||||
|
} />
|
||||||
|
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginBottom: 12 }}>{err}</div>}
|
||||||
|
|
||||||
|
<SearchBar>
|
||||||
|
<Input value={keyword} onChange={setKeyword} placeholder="권한ID/권한명 검색" style={{ minWidth: 240 }} />
|
||||||
|
<Button variant="ghost" onClick={() => load()}>검색</Button>
|
||||||
|
</SearchBar>
|
||||||
|
|
||||||
|
{loading ? <Spinner /> : (
|
||||||
|
<DataGrid columns={columns} rows={rows} rowKey={r => r.roleId}
|
||||||
|
onRowClick={r => { setEdit(r); setShowForm(true) }} empty="등록된 권한이 없습니다." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<RoleForm role={edit} onClose={() => setShowForm(false)}
|
||||||
|
onSaved={() => { setShowForm(false); load() }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleForm({ role, onClose, onSaved }: { role: RoleRow | null; onClose: () => void; onSaved: () => void }) {
|
||||||
|
const editing = !!role
|
||||||
|
const [roleId, setRoleId] = useState(role?.roleId ?? '')
|
||||||
|
const [roleNm, setRoleNm] = useState(role?.roleNm ?? '')
|
||||||
|
const [roleDesc, setRoleDesc] = useState(role?.roleDesc ?? '')
|
||||||
|
const [useYn, setUseYn] = useState(role?.useYn ?? 'Y')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setErr('')
|
||||||
|
if (!roleNm.trim()) { setErr('권한명은 필수입니다.'); return }
|
||||||
|
if (!editing && !roleId.trim()) { setErr('권한ID는 필수입니다.'); return }
|
||||||
|
try {
|
||||||
|
const body = { roleId, roleNm, roleDesc, useYn }
|
||||||
|
editing ? await updateRole(roleId, body) : await createRole(body)
|
||||||
|
onSaved()
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={editing ? '권한 수정' : '권한 등록'} onClose={onClose}
|
||||||
|
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
||||||
|
<FormField label="권한ID">
|
||||||
|
<Input value={roleId} onChange={setRoleId} style={{ width: '100%', opacity: editing ? 0.6 : 1 }} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="권한명"><Input value={roleNm} onChange={setRoleNm} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="설명"><Input value={roleDesc} onChange={setRoleDesc} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="사용 여부">
|
||||||
|
<select value={useYn} onChange={e => setUseYn(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, fontSize: 13,
|
||||||
|
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)' }}>
|
||||||
|
<option value="Y">사용</option>
|
||||||
|
<option value="N">미사용</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
frontend/src/pages/uiws/system/RoleMenuManagement.tsx
Normal file
143
frontend/src/pages/uiws/system/RoleMenuManagement.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { roleMenuRoleList, roleMenus, saveRoleMenus } from '../../../api/uiws'
|
||||||
|
import { PageHeader, Panel, Button, Spinner } from '../../../components/uiws/ui'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 역할-메뉴 권한(role-menus) — HRM 백엔드(com.zioinfo.hrm.uiws.system RoleMenuController) 계약 정합.
|
||||||
|
* 좌측: 권한목록(/api/system/role-menus, PageResponse<RoleDto>).
|
||||||
|
* 우측: 권한별 메뉴매핑(/api/system/roles/{roleId}/menus, RoleMenuDto[] = {menuId,menuNm,readYn,writeYn}).
|
||||||
|
* 저장: PUT 동일 경로 {menus:[...]}. (PMS엔 이 엔드포인트가 없어 HRM 신규 화면)
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Role { roleId: string; roleNm: string; roleDesc: string; useYn: string }
|
||||||
|
interface 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 [activeRole, setActiveRole] = useState<string>('')
|
||||||
|
const [menus, setMenus] = useState<RoleMenu[]>([])
|
||||||
|
const [loadingRoles, setLoadingRoles] = useState(false)
|
||||||
|
const [loadingMenus, setLoadingMenus] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
|
||||||
|
const loadRoles = async () => {
|
||||||
|
setLoadingRoles(true); setErr('')
|
||||||
|
try {
|
||||||
|
const res = await roleMenuRoleList()
|
||||||
|
const list: Role[] = res.data.data?.content ?? []
|
||||||
|
setRoles(list)
|
||||||
|
if (!activeRole && list.length > 0) setActiveRole(list[0].roleId)
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoadingRoles(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { loadRoles() }, [])
|
||||||
|
|
||||||
|
const loadMenus = async (roleId = activeRole) => {
|
||||||
|
if (!roleId) { setMenus([]); return }
|
||||||
|
setLoadingMenus(true); setErr(''); setMsg('')
|
||||||
|
try {
|
||||||
|
const res = await roleMenus(roleId)
|
||||||
|
setMenus(res.data.data ?? [])
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setLoadingMenus(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { loadMenus() }, [activeRole])
|
||||||
|
|
||||||
|
const 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' }
|
||||||
|
// 쓰기 권한을 켜면 읽기 권한도 함께 보장(일반적 RBAC 규칙).
|
||||||
|
if (field === 'writeYn' && next.writeYn === 'Y') next.readYn = 'Y'
|
||||||
|
if (field === 'readYn' && next.readYn === 'N') next.writeYn = 'N'
|
||||||
|
return next
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!activeRole) return
|
||||||
|
setSaving(true); setErr(''); setMsg('')
|
||||||
|
try {
|
||||||
|
await saveRoleMenus(activeRole, menus)
|
||||||
|
setMsg('저장되었습니다.')
|
||||||
|
} catch (e) { setErr(friendlyError(e)) }
|
||||||
|
finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="uiws-scope">
|
||||||
|
<PageHeader title="역할-메뉴 권한" subtitle="시스템관리(권한) · 권한별 메뉴 읽기/쓰기 매핑"
|
||||||
|
actions={<Button onClick={save} disabled={saving || !activeRole}>{saving ? '저장 중...' : '권한 저장'}</Button>} />
|
||||||
|
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginBottom: 12 }}>{err}</div>}
|
||||||
|
{msg && <div style={{ color: 'var(--uiws-success)', fontSize: 13, marginBottom: 12 }}>{msg}</div>}
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '300px 1fr', gap: 16, alignItems: 'start' }}>
|
||||||
|
<Panel style={{ padding: 0 }}>
|
||||||
|
<div style={{ padding: '12px 16px', fontSize: 12, fontWeight: 600, color: 'var(--uiws-text-muted)', borderBottom: '1px solid var(--uiws-border)' }}>
|
||||||
|
권한 목록
|
||||||
|
</div>
|
||||||
|
{loadingRoles ? <Spinner /> : (
|
||||||
|
<div>
|
||||||
|
{roles.length === 0 && <div style={{ padding: 20, fontSize: 13, color: 'var(--uiws-text-faint)' }}>권한이 없습니다.</div>}
|
||||||
|
{roles.map(r => (
|
||||||
|
<div key={r.roleId} onClick={() => setActiveRole(r.roleId)}
|
||||||
|
style={{
|
||||||
|
padding: '11px 16px', cursor: 'pointer', fontSize: 13,
|
||||||
|
borderLeft: `3px solid ${activeRole === r.roleId ? 'var(--uiws-primary)' : 'transparent'}`,
|
||||||
|
background: activeRole === r.roleId ? 'var(--uiws-primary-soft)' : 'transparent',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 600, color: activeRole === r.roleId ? 'var(--uiws-primary)' : 'var(--uiws-text)' }}>{r.roleNm}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--uiws-text-muted)' }}>{r.roleId}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel style={{ padding: 0, overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '12px 16px', fontSize: 14, fontWeight: 600, color: 'var(--uiws-text)', borderBottom: '1px solid var(--uiws-border)' }}>
|
||||||
|
메뉴 권한 {activeRole && <span style={{ color: 'var(--uiws-text-muted)', fontWeight: 400, fontSize: 13 }}>({activeRole})</span>}
|
||||||
|
</div>
|
||||||
|
{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)', fontWeight: 600, fontSize: 12, borderBottom: '1px solid var(--uiws-border)' }}>메뉴</th>
|
||||||
|
<th style={{ textAlign: 'center', padding: '11px 14px', width: 90, color: 'var(--uiws-text-muted)', fontWeight: 600, fontSize: 12, borderBottom: '1px solid var(--uiws-border)' }}>읽기</th>
|
||||||
|
<th style={{ textAlign: 'center', padding: '11px 14px', width: 90, color: 'var(--uiws-text-muted)', fontWeight: 600, fontSize: 12, borderBottom: '1px solid var(--uiws-border)' }}>쓰기</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{menus.length === 0 ? (
|
||||||
|
<tr><td colSpan={3} style={{ padding: 28, textAlign: 'center', color: 'var(--uiws-text-faint)' }}>
|
||||||
|
{activeRole ? '매핑 가능한 메뉴가 없습니다.' : '권한을 선택하세요.'}
|
||||||
|
</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-muted)', fontSize: 12 }}>({m.menuId})</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px', textAlign: 'center' }}>
|
||||||
|
<input type="checkbox" checked={m.readYn === 'Y'} onChange={() => toggle(m.menuId, 'readYn')} />
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px', textAlign: 'center' }}>
|
||||||
|
<input type="checkbox" checked={m.writeYn === 'Y'} onChange={() => toggle(m.menuId, 'writeYn')} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
frontend/src/pages/uiws/system/SystemPage.tsx
Normal file
57
frontend/src/pages/uiws/system/SystemPage.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import RoleManagement from './RoleManagement'
|
||||||
|
import RoleMenuManagement from './RoleMenuManagement'
|
||||||
|
import CodeManagement from './CodeManagement'
|
||||||
|
import MenuManagement from './MenuManagement'
|
||||||
|
import DeptManagement from './DeptManagement'
|
||||||
|
import CompanyManagement from './CompanyManagement'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 시스템관리(권한) — UIWS system 모듈 이식 화면 셸(탭 컨테이너).
|
||||||
|
* 단일 라우트 /system 에서 6개 관리화면을 탭으로 전환. 색상은 var(--uiws-*) 토큰만 사용(다크/라이트 정합).
|
||||||
|
* 백엔드: com.zioinfo.hrm.uiws.system (/api/system/*). 조회 SUPERADMIN/MANAGER, 쓰기 SUPERADMIN.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type TabKey = 'roles' | 'role-menus' | 'codes' | 'menus' | 'depts' | 'companies'
|
||||||
|
|
||||||
|
const TABS: { key: TabKey; label: string }[] = [
|
||||||
|
{ key: 'roles', label: '권한 관리' },
|
||||||
|
{ key: 'role-menus', label: '역할-메뉴 권한' },
|
||||||
|
{ key: 'codes', label: '공통코드' },
|
||||||
|
{ key: 'menus', label: '메뉴 관리' },
|
||||||
|
{ key: 'depts', label: '부서 관리' },
|
||||||
|
{ key: 'companies', label: '거래처 관리' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function SystemPage() {
|
||||||
|
const [tab, setTab] = useState<TabKey>('roles')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="uiws-scope">
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 4, marginBottom: 20, flexWrap: 'wrap',
|
||||||
|
borderBottom: '1px solid var(--uiws-border)',
|
||||||
|
}}>
|
||||||
|
{TABS.map(t => (
|
||||||
|
<button key={t.key} onClick={() => setTab(t.key)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||||
|
background: 'transparent', border: 'none',
|
||||||
|
color: tab === t.key ? 'var(--uiws-primary)' : 'var(--uiws-text-muted)',
|
||||||
|
borderBottom: `2px solid ${tab === t.key ? 'var(--uiws-primary)' : 'transparent'}`,
|
||||||
|
marginBottom: -1,
|
||||||
|
}}>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'roles' && <RoleManagement />}
|
||||||
|
{tab === 'role-menus' && <RoleMenuManagement />}
|
||||||
|
{tab === 'codes' && <CodeManagement />}
|
||||||
|
{tab === 'menus' && <MenuManagement />}
|
||||||
|
{tab === 'depts' && <DeptManagement />}
|
||||||
|
{tab === 'companies' && <CompanyManagement />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user