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

This commit is contained in:
GUARDiA 2026-06-21 22:06:51 +09:00
parent c18a6c2c51
commit 7fc31fe082
14 changed files with 1388 additions and 1 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<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>

View File

@ -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>

View File

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

View File

@ -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;

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

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

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

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

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

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

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

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