3000 lines
141 KiB
HTML
3000 lines
141 KiB
HTML
<!--
|
||
* [변경 이력 (Auto-Generated by AI)]
|
||
* - 수정일시: 2026-06-15 11:40:00
|
||
* - 수정원인: 폴더별 권한 관리 트리 노드의 data_permission 기준 정렬, 수치 표시 및 트리 뎁스 꼬임 오류 조치
|
||
* - 수정내용:
|
||
* 1) renderFolderTree 함수에서 sibling 노드 간 data_permission 우선순위(1->4->8->0) 및 이름으로 정렬하고 우측 수치 렌더링.
|
||
* 2) folders 트리 구성 전 data_depth 오름차순으로 사전 정렬하여 부모-자식 노드 구성 순서 오류 핫픽스 적용.
|
||
-->
|
||
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>PM_ver4 통합 관리자 어플리케이션 (Admin Panel)</title>
|
||
<!-- Socket.io Client -->
|
||
<script src="/socket.io.js"></script>
|
||
<!-- Google Fonts - Inter & Noto Sans KR -->
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||
|
||
<style>
|
||
:root {
|
||
--bg-color: #f4f7f6;
|
||
--sidebar-bg: #142e29; /* Deep Teal-Green */
|
||
--sidebar-active: #1e5149; /* Primary Forest Green (pm_ver4 lv5) */
|
||
--card-bg: #ffffff;
|
||
--border: #d2dcdb; /* Light Green Gray (pm_ver4 lv1) */
|
||
--text-main: #141e1d; /* Very Dark Forest (pm_ver4 lv9) */
|
||
--text-muted: #4b746d; /* Muted Green (pm_ver4 lv4) */
|
||
--text-light: #a5b9b6; /* Soft Gray-Green (pm_ver4 lv2) */
|
||
--primary: #1e5149; /* Primary (pm_ver4 lv5) */
|
||
--primary-hover: #193833; /* Darker Primary (pm_ver4 lv7) */
|
||
--primary-soft: #e9eeed; /* Soft Teal-Gray (pm_ver4 lv0) */
|
||
--success: #4db251; /* pm_ver4 Green */
|
||
--success-soft: #eef8ee; /* pm_ver4 Light Green */
|
||
--warning: #ff9800; /* pm_ver4 Orange */
|
||
--warning-soft: #fff5e6; /* pm_ver4 Light Orange */
|
||
--radius-lg: 6px;
|
||
--radius-md: 4px;
|
||
--radius-sm: 2px;
|
||
--shadow: 0 4px 6px -1px rgb(20 30 29 / 0.08), 0 2px 4px -2px rgb(20 30 29 / 0.08);
|
||
--transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
body {
|
||
font-family: 'Pretendard Variable', 'Pretendard', 'Inter', 'Noto Sans KR', sans-serif;
|
||
background-color: var(--bg-color);
|
||
color: var(--text-main);
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 1. Sidebar LNB */
|
||
.sidebar {
|
||
width: 260px;
|
||
background-color: var(--sidebar-bg);
|
||
color: #ffffff;
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.sidebar-brand {
|
||
padding: 24px 20px;
|
||
font-size: 1.15rem;
|
||
font-weight: 700;
|
||
letter-spacing: 0.5px;
|
||
border-bottom: 1px solid #1b443d; /* pm_ver4 lv6 */
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
background: linear-gradient(135deg, #1e5149 0%, #142e29 100%);
|
||
}
|
||
|
||
.sidebar-brand span.logo-highlight {
|
||
color: #4db251; /* pm_ver4 green accent */
|
||
}
|
||
|
||
.sidebar-menu {
|
||
flex: 1;
|
||
padding: 20px 12px;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 25px;
|
||
}
|
||
|
||
.menu-category {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.menu-category-title {
|
||
font-size: 0.75rem;
|
||
font-weight: 700;
|
||
color: #64748b;
|
||
text-transform: uppercase;
|
||
padding-left: 8px;
|
||
margin-bottom: 4px;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.menu-item {
|
||
padding: 10px 14px;
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
color: #cbd5e1;
|
||
border-radius: var(--radius-md);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
transition: var(--transition);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.menu-item:hover {
|
||
background-color: rgba(255, 255, 255, 0.05);
|
||
color: #ffffff;
|
||
}
|
||
|
||
.menu-item.active {
|
||
background-color: var(--primary);
|
||
color: #ffffff;
|
||
font-weight: 600;
|
||
box-shadow: 0 4px 12px rgba(30, 81, 73, 0.25);
|
||
}
|
||
|
||
/* 2. Main Content Area */
|
||
.main-wrapper {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
background-color: var(--bg-color);
|
||
}
|
||
|
||
.main-header {
|
||
height: 70px;
|
||
background-color: #ffffff;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 0 30px;
|
||
flex-shrink: 0;
|
||
box-shadow: 0 1px 2px rgb(0 0 0 / 0.02);
|
||
}
|
||
|
||
.main-header .page-title h2 {
|
||
margin: 0;
|
||
font-size: 1.25rem;
|
||
font-weight: 700;
|
||
color: var(--text-main);
|
||
}
|
||
|
||
.user-profile {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.user-profile .avatar {
|
||
width: 36px;
|
||
height: 36px;
|
||
background-color: var(--primary-soft);
|
||
color: var(--primary);
|
||
border-radius: 50%;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
font-weight: 700;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.user-profile .info {
|
||
font-size: 0.85rem;
|
||
text-align: right;
|
||
}
|
||
|
||
.user-profile .info .name {
|
||
font-weight: 600;
|
||
color: var(--text-main);
|
||
}
|
||
|
||
.user-profile .info .role {
|
||
font-size: 0.75rem;
|
||
color: var(--text-light);
|
||
}
|
||
|
||
.content-area {
|
||
flex: 1;
|
||
padding: 30px;
|
||
overflow-y: auto;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.tab-content {
|
||
display: none;
|
||
animation: tabFadeIn 0.3s ease-out;
|
||
flex-direction: column;
|
||
gap: 25px;
|
||
}
|
||
|
||
.tab-content.active {
|
||
display: flex;
|
||
}
|
||
|
||
@keyframes tabFadeIn {
|
||
from { opacity: 0; transform: translateY(8px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
/* 3. Cards, Forms, Tables Widgets */
|
||
.card {
|
||
background-color: var(--card-bg);
|
||
border-radius: var(--radius-lg);
|
||
border: 1px solid var(--border);
|
||
box-shadow: var(--shadow);
|
||
padding: 24px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
border-bottom: 1px solid var(--border);
|
||
padding-bottom: 14px;
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 1.05rem;
|
||
font-weight: 700;
|
||
margin: 0;
|
||
color: var(--text-main);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.grid-2col {
|
||
display: grid;
|
||
grid-template-columns: 1.25fr 1fr;
|
||
gap: 24px;
|
||
}
|
||
|
||
.grid-2col-even {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1.25fr;
|
||
gap: 24px;
|
||
}
|
||
|
||
.grid-3col {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 20px;
|
||
}
|
||
|
||
@media (max-width: 1024px) {
|
||
.grid-2col, .grid-2col-even, .grid-3col {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
/* KPI Card */
|
||
.kpi-card {
|
||
background-color: var(--card-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.kpi-icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: var(--radius-md);
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
font-size: 1.5rem;
|
||
}
|
||
|
||
.kpi-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.kpi-label {
|
||
font-size: 0.8rem;
|
||
color: var(--text-muted);
|
||
margin-bottom: 4px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.kpi-value {
|
||
font-size: 1.4rem;
|
||
font-weight: 700;
|
||
color: var(--text-main);
|
||
}
|
||
|
||
/* Forms styling */
|
||
.form-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.form-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.form-group label {
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.text-input, .select-input, .date-input {
|
||
width: 100%;
|
||
padding: 10px 14px;
|
||
border-radius: var(--radius-md);
|
||
border: 1px solid var(--border);
|
||
font-size: 0.9rem;
|
||
background-color: #f8fafc;
|
||
box-sizing: border-box;
|
||
outline: none;
|
||
transition: var(--transition);
|
||
font-family: inherit;
|
||
}
|
||
|
||
.text-input:focus, .select-input:focus, .date-input:focus {
|
||
border-color: var(--primary);
|
||
background-color: #ffffff;
|
||
box-shadow: 0 0 0 3px var(--primary-soft);
|
||
}
|
||
|
||
/* Buttons styling */
|
||
.btn {
|
||
padding: 10px 20px;
|
||
border-radius: var(--radius-md);
|
||
font-size: 0.875rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
border: none;
|
||
transition: var(--transition);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
outline: none;
|
||
}
|
||
|
||
.btn-primary {
|
||
background-color: var(--primary);
|
||
color: #ffffff;
|
||
box-shadow: 0 2px 6px rgba(30, 81, 73, 0.15);
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
background-color: var(--primary-hover);
|
||
}
|
||
|
||
.btn-secondary {
|
||
background-color: #ffffff;
|
||
color: var(--text-muted);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.btn-secondary:hover {
|
||
background-color: #f1f5f9;
|
||
}
|
||
|
||
.btn-danger {
|
||
background-color: #ef4444;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.btn-danger:hover {
|
||
background-color: #dc2626;
|
||
}
|
||
|
||
.btn-sm {
|
||
padding: 6px 12px;
|
||
font-size: 0.8rem;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
/* Tables styling */
|
||
.table-wrapper {
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-md);
|
||
overflow: hidden;
|
||
background-color: #ffffff;
|
||
}
|
||
|
||
table.admin-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 0.875rem;
|
||
text-align: left;
|
||
}
|
||
|
||
table.admin-table th {
|
||
background-color: #f8fafc;
|
||
color: var(--text-muted);
|
||
font-weight: 600;
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
table.admin-table td {
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
color: var(--text-muted);
|
||
vertical-align: middle;
|
||
}
|
||
|
||
table.admin-table tr:last-child td {
|
||
border-bottom: none;
|
||
}
|
||
|
||
table.admin-table tr.clickable {
|
||
cursor: pointer;
|
||
}
|
||
|
||
table.admin-table tr.clickable:hover td {
|
||
background-color: var(--primary-soft);
|
||
}
|
||
|
||
table.admin-table tr.selected td {
|
||
background-color: var(--primary-soft) !important;
|
||
color: var(--primary-hover);
|
||
}
|
||
|
||
/* Badges */
|
||
.badge {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.badge.active { background-color: var(--success-soft); color: var(--success); }
|
||
.badge.inactive { background-color: #f1f5f9; color: var(--text-light); }
|
||
.badge.danger { background-color: #fee2e2; color: #ef4444; }
|
||
.badge.warning { background-color: var(--warning-soft); color: var(--warning); }
|
||
|
||
.progress-bar-container {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.progress-bar-bg {
|
||
flex: 1;
|
||
height: 8px;
|
||
background-color: #e2e8f0;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.progress-bar-fill {
|
||
height: 100%;
|
||
background-color: var(--primary);
|
||
border-radius: 4px;
|
||
transition: width 0.4s ease;
|
||
}
|
||
|
||
.action-btns {
|
||
display: flex;
|
||
gap: 6px;
|
||
}
|
||
|
||
.form-title-wrap {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
/* 4. Modal CSS */
|
||
.modal-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background-color: rgba(20, 30, 29, 0.6);
|
||
backdrop-filter: blur(4px);
|
||
display: none;
|
||
justify-content: center;
|
||
align-items: center;
|
||
z-index: 1000;
|
||
animation: modalFadeIn 0.2s ease-out;
|
||
}
|
||
|
||
@keyframes modalFadeIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
.modal-card {
|
||
width: 100%;
|
||
max-width: 500px;
|
||
background-color: #ffffff;
|
||
border-radius: var(--radius-lg);
|
||
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||
border: 1px solid var(--border);
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
animation: modalSlideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||
}
|
||
|
||
@keyframes modalSlideUp {
|
||
from { transform: translateY(30px); opacity: 0; }
|
||
to { transform: translateY(0); opacity: 1; }
|
||
}
|
||
|
||
.modal-header {
|
||
padding: 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
background-color: #f8fafc;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.modal-header h3 {
|
||
margin: 0;
|
||
font-size: 1.1rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.modal-close {
|
||
background: none;
|
||
border: none;
|
||
font-size: 1.25rem;
|
||
cursor: pointer;
|
||
color: var(--text-light);
|
||
}
|
||
|
||
.modal-close:hover {
|
||
color: var(--text-main);
|
||
}
|
||
|
||
.modal-body {
|
||
padding: 20px;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.modal-footer {
|
||
padding: 16px 20px;
|
||
border-top: 1px solid var(--border);
|
||
background-color: #f8fafc;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
}
|
||
|
||
/* Checkbox list style inside modal */
|
||
.check-list-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
cursor: pointer;
|
||
transition: var(--transition);
|
||
}
|
||
|
||
.check-list-item:hover {
|
||
background-color: #f8fafc;
|
||
}
|
||
|
||
.check-list-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.check-list-item input[type="checkbox"] {
|
||
width: 18px;
|
||
height: 18px;
|
||
accent-color: var(--primary);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.check-list-item .user-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.check-list-item .user-info-name {
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.check-list-item .user-info-desc {
|
||
font-size: 0.75rem;
|
||
color: var(--text-light);
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- 1. Sidebar LNB -->
|
||
<div class="sidebar">
|
||
<div class="sidebar-brand">
|
||
📁 PM_ver4 <span class="logo-highlight">Admin</span>
|
||
</div>
|
||
|
||
<div class="sidebar-menu">
|
||
<div class="menu-category">
|
||
<div class="menu-category-title">Dashboards</div>
|
||
<a class="menu-item active" id="menu-dashboard" onclick="switchTab('dashboard')">📊 종합 용량 및 접속자</a>
|
||
</div>
|
||
|
||
<div class="menu-category">
|
||
<div class="menu-category-title">프로젝트 관리</div>
|
||
<a class="menu-item" id="menu-project-mgmt" onclick="switchTab('project-mgmt')">🏗️ 프로젝트 관리</a>
|
||
<a class="menu-item" id="menu-banner-notice" onclick="switchTab('banner-notice')">📢 실시간 배너 공지</a>
|
||
</div>
|
||
|
||
<div class="menu-category">
|
||
<div class="menu-category-title">사용자 및 권한</div>
|
||
<a class="menu-item" id="menu-user-mgmt" onclick="switchTab('user-mgmt')">👥 사용자 관리</a>
|
||
<a class="menu-item" id="menu-folder-permission" onclick="switchTab('folder-permission')">📂 폴더별 권한 관리</a>
|
||
</div>
|
||
|
||
<div class="menu-category">
|
||
<div class="menu-category-title">시스템 감사 및 환경</div>
|
||
<a class="menu-item" id="menu-audit-logs" onclick="switchTab('audit-logs')">🔎 활동 로그 조회</a>
|
||
<a class="menu-item" id="menu-delete-policy" onclick="switchTab('delete-policy')">⚙️ 보관 및 삭제 정책 설정</a>
|
||
<a class="menu-item" id="menu-code-mgmt" onclick="switchTab('code-mgmt')">🔑 공통 코드 관리</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 2. Main Wrapper -->
|
||
<div class="main-wrapper">
|
||
<!-- Header -->
|
||
<div class="main-header">
|
||
<div class="page-title">
|
||
<h2 id="headerTitle">📊 종합 용량 및 접속자 현황</h2>
|
||
</div>
|
||
|
||
<div class="user-profile">
|
||
<div class="info">
|
||
<div class="name" id="profileName">관리자님</div>
|
||
<div class="role" id="profileRole">Super Administrator</div>
|
||
</div>
|
||
<div class="avatar" id="profileAvatar">AD</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Content Area -->
|
||
<div class="content-area">
|
||
|
||
<!-- ================= TAB: DASHBOARD ================= -->
|
||
<div id="tab-dashboard" class="tab-content active">
|
||
<div class="grid-3col">
|
||
<div class="kpi-card">
|
||
<div class="kpi-icon" style="background-color: var(--primary-soft); color: var(--primary);">💾</div>
|
||
<div class="kpi-info">
|
||
<span class="kpi-label">전체 사용 용량</span>
|
||
<span class="kpi-value" id="kpi-storage">9.70 GB / 20 GB</span>
|
||
</div>
|
||
</div>
|
||
<div class="kpi-card">
|
||
<div class="kpi-icon" style="background-color: #ecfdf5; color: var(--success);">📡</div>
|
||
<div class="kpi-info">
|
||
<span class="kpi-label">실시간 접속자</span>
|
||
<span class="kpi-value">3 명</span>
|
||
</div>
|
||
</div>
|
||
<div class="kpi-card">
|
||
<div class="kpi-icon" style="background-color: #fef3c7; color: var(--warning);">⚡</div>
|
||
<div class="kpi-info">
|
||
<span class="kpi-label">대기 중인 압축작업</span>
|
||
<span class="kpi-value">0 건</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid-2col">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title">💾 현장(프로젝트)별 스토리지 사용 현황</h3>
|
||
</div>
|
||
<div id="dashboard-progress-bars" style="display: flex; flex-direction: column; gap: 16px;"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title">📡 실시간 소켓 접속 현황</h3>
|
||
</div>
|
||
<div class="table-wrapper">
|
||
<table class="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 50px;">NO</th>
|
||
<th>사용자 ID</th>
|
||
<th>접속 IP</th>
|
||
<th>현재 조회 경로</th>
|
||
<th>작업</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td>1</td>
|
||
<td><strong>test_user</strong></td>
|
||
<td>127.0.0.1</td>
|
||
<td>/PM_TEST_01/01_설계도서</td>
|
||
<td><button class="btn btn-danger btn-sm" onclick="alert('test_user 소켓 연결 강제 종료(Kick)')">강제퇴장</button></td>
|
||
</tr>
|
||
<tr>
|
||
<td>2</td>
|
||
<td><strong>admin</strong></td>
|
||
<td>192.168.1.100</td>
|
||
<td>/admin/queues</td>
|
||
<td>-</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= TAB: PROJECT CRUD ================= -->
|
||
<div id="tab-project-mgmt" class="tab-content">
|
||
<div class="grid-2col">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title">🏗️ 프로젝트 목록 (Read)</h3>
|
||
<button class="btn btn-primary btn-sm" onclick="openProjectModal('create')">➕ 신규 프로젝트 등록</button>
|
||
</div>
|
||
<div class="table-wrapper" style="max-height: 550px; overflow-y: auto;">
|
||
<table class="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 50px;">NO</th>
|
||
<th>ID</th>
|
||
<th>현장명</th>
|
||
<th>카테고리</th>
|
||
<th>용량 제한</th>
|
||
<th>상태</th>
|
||
<th>관리</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="project-list-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right Side: Assigned User List (Merged Permission Management) -->
|
||
<div class="card" style="gap: 20px;">
|
||
<div class="card-header" style="flex-wrap: wrap; gap: 10px;">
|
||
<div>
|
||
<h3 class="card-title" id="assigned-project-title">🌉 프로젝트 선택 대기 중</h3>
|
||
<p style="margin: 4px 0 0 0; font-size: 0.8rem; color: var(--text-muted);" id="assigned-project-desc">좌측에서 프로젝트를 선택해 주세요.</p>
|
||
</div>
|
||
<button class="btn btn-primary btn-sm" id="btn-show-assign-modal" onclick="openAssignModal()" disabled>➕ 사용자 권한 배정</button>
|
||
</div>
|
||
|
||
<div>
|
||
<h4 style="margin: 0 0 10px 0; font-size: 0.95rem; color: var(--text-main); display: flex; align-items: center; gap: 6px;">
|
||
🔑 참여 권한 사용자 목록 <span class="badge active" id="assigned-user-count" style="font-size: 0.75rem;">0명</span>
|
||
</h4>
|
||
<div class="table-wrapper" style="max-height: 400px; overflow-y: auto;">
|
||
<table class="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 50px;">NO</th>
|
||
<th>사용자 ID</th>
|
||
<th>이름</th>
|
||
<th>부서/직급</th>
|
||
<th>권한 등급</th>
|
||
<th>작업</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="assigned-user-list-body">
|
||
<tr>
|
||
<td colspan="6" style="text-align: center; color: var(--text-light); padding: 40px 0;">프로젝트를 선택하시면 배정된 사용자 목록이 여기에 표시됩니다.</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= TAB: BANNER NOTICE ================= -->
|
||
<div id="tab-banner-notice" class="tab-content">
|
||
<!-- Banner Input Form -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title">📢 실시간 배너 공지사항 등록</h3>
|
||
</div>
|
||
<form class="config-form" style="gap: 20px;" onsubmit="handleBannerSubmit(event)">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="banner-project-select">공지 대상 프로젝트</label>
|
||
<select class="select-input" id="banner-project-select"></select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="banner-reg-date">등록일</label>
|
||
<input type="date" class="date-input" id="banner-reg-date" required>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="banner-start-date">공지 시작일</label>
|
||
<input type="date" class="date-input" id="banner-start-date" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="banner-end-date">공지 종료일</label>
|
||
<input type="date" class="date-input" id="banner-end-date" required>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="banner-text">배너 공지 내용</label>
|
||
<input type="text" class="text-input" id="banner-text" required placeholder="자막 형태로 흐르게 띄워질 공지 문구 내용입니다.">
|
||
</div>
|
||
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
||
<button class="btn btn-secondary" type="reset" onclick="renderBannerTab()">초기화</button>
|
||
<button class="btn btn-primary" type="submit">실시간 공지 배포 (송출)</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Banner History List (1. 요구사항 반영) -->
|
||
<div class="card" style="margin-top: 10px;">
|
||
<div class="card-header">
|
||
<h3 class="card-title">📋 배너 공지 송출 및 예약 이력 목록 (History List)</h3>
|
||
</div>
|
||
<!-- 검색조건 추가 -->
|
||
<div class="form-row" style="background-color: var(--primary-soft); padding: 16px; border-radius: var(--radius-md); gap: 16px; align-items: flex-end;">
|
||
<div class="form-group">
|
||
<label for="search-banner-status">송출 상태</label>
|
||
<select class="select-input" id="search-banner-status" style="background-color: #ffffff;">
|
||
<option value="all">전체 상태</option>
|
||
<option value="active">송출중</option>
|
||
<option value="scheduled">예약됨</option>
|
||
<option value="expired">기간 만료</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="search-banner-from">등록일 시작 (From)</label>
|
||
<input type="date" class="date-input" id="search-banner-from" style="background-color: #ffffff;">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="search-banner-to">등록일 종료 (To)</label>
|
||
<input type="date" class="date-input" id="search-banner-to" style="background-color: #ffffff;">
|
||
</div>
|
||
<button class="btn btn-primary" onclick="searchBannerHistory()" style="height: 42px;">🔍 검색</button>
|
||
</div>
|
||
<div class="table-wrapper">
|
||
<table class="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 50px;">NO</th>
|
||
<th>등록일</th>
|
||
<th>대상 프로젝트</th>
|
||
<th>공지 사항 내용</th>
|
||
<th>공지 시작일</th>
|
||
<th>공지 종료일</th>
|
||
<th>송출 상태</th>
|
||
<th>작업</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="banner-history-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= TAB: USER CRUD ================= -->
|
||
<div id="tab-user-mgmt" class="tab-content">
|
||
<div class="grid-2col">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title">👥 사용자 계정 목록 (Read)</h3>
|
||
<button class="btn btn-primary btn-sm" onclick="openUserModal('create')">➕ 신규 사용자 등록</button>
|
||
</div>
|
||
<div class="table-wrapper" style="max-height: 550px; overflow-y: auto;">
|
||
<table class="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 50px;">NO</th>
|
||
<th>아이디</th>
|
||
<th>이름</th>
|
||
<th>소속/직급</th>
|
||
<th>그룹</th>
|
||
<th>상태</th>
|
||
<th>관리</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="user-list-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right Side: Selected User's Permission Projects List -->
|
||
<div class="card" style="gap: 20px;">
|
||
<div class="card-header">
|
||
<div>
|
||
<h3 class="card-title" id="user-permission-title">🔑 권한부여 프로젝트 목록</h3>
|
||
<p style="margin: 4px 0 0 0; font-size: 0.8rem; color: var(--text-muted);" id="user-permission-desc">사용자 목록에서 사용자를 선택해 주세요.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="table-wrapper" style="max-height: 480px; overflow-y: auto;">
|
||
<table class="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 50px;">NO</th>
|
||
<th>프로젝트 ID</th>
|
||
<th>프로젝트명</th>
|
||
<th>권한 등급</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="user-permission-list-body">
|
||
<tr>
|
||
<td colspan="4" style="text-align: center; color: var(--text-light); padding: 40px 0;">사용자를 선택하시면 권한이 부여된 프로젝트 목록이 표시됩니다.</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= TAB: FOLDER LEVEL PERMISSIONS ================= -->
|
||
<div id="tab-folder-permission" class="tab-content">
|
||
<div class="grid-2col">
|
||
<!-- Left Side: Project Selector & Folder Tree -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title">📂 폴더 구조 (1~3단계 제한)</h3>
|
||
</div>
|
||
<div style="display: flex; align-items: center; gap: 10px;">
|
||
<label style="font-weight: 600; min-width: 80px;">프로젝트:</label>
|
||
<select class="select-input" id="folder-perm-project-select" onchange="loadFolderStructure()" style="flex: 1; padding: 8px; border: 1px solid var(--border); border-radius: var(--radius-md);"></select>
|
||
</div>
|
||
<div id="folder-tree-container" class="table-wrapper" style="max-height: 500px; overflow-y: auto; padding: 10px; border: 1px solid var(--border); border-radius: var(--radius-md); background: #fafafa; min-height: 300px;">
|
||
<div style="color: var(--text-light); text-align: center; padding: 40px 0;">프로젝트를 선택하시면 폴더 트리가 여기에 로드됩니다.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right Side: Folder Specific User Permissions -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<div>
|
||
<h3 class="card-title" id="folder-perm-detail-title">👥 폴더별 사용자 권한 설정</h3>
|
||
<p style="margin: 4px 0 0 0; font-size: 0.8rem; color: var(--text-muted);" id="folder-perm-detail-desc">좌측 트리에서 폴더를 선택해 주세요.</p>
|
||
</div>
|
||
</div>
|
||
<div class="table-wrapper" style="max-height: 520px; overflow-y: auto;">
|
||
<table class="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th>사용자 ID</th>
|
||
<th>이름</th>
|
||
<th>부서/직급</th>
|
||
<th>프로젝트 기본권한</th>
|
||
<th>폴더 권한 등급</th>
|
||
<th>설정</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="folder-perm-user-body">
|
||
<tr>
|
||
<td colspan="6" style="text-align: center; color: var(--text-light); padding: 40px 0;">폴더를 선택하시면 사용자의 개별 권한 지정 목록이 여기에 표시됩니다.</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= TAB: AUDIT LOGS ================= -->
|
||
<div id="tab-audit-logs" class="tab-content">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title">🔎 시스템 활동 로그 조회 (tb_log)</h3>
|
||
</div>
|
||
<div class="form-row">
|
||
<input type="text" class="text-input" id="search-log-user" placeholder="사용자 ID 검색...">
|
||
<input type="text" class="text-input" id="search-log-project" placeholder="프로젝트명 검색...">
|
||
<input type="text" class="text-input" id="filter-log-action" placeholder="조작 액션 검색...">
|
||
<button class="btn btn-secondary" onclick="renderAuditLogs()">활동 로그 필터링</button>
|
||
</div>
|
||
<div class="table-wrapper">
|
||
<table class="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 50px;">NO</th>
|
||
<th>일시</th>
|
||
<th>프로젝트</th>
|
||
<th>사용자 ID</th>
|
||
<th>IP</th>
|
||
<th>조작 액션</th>
|
||
<th>조작 대상 경로</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="audit-log-body">
|
||
<tr>
|
||
<td colspan="7" style="text-align: center; color: var(--text-light); padding: 40px 0;">활동 로그 데이터를 조회 중입니다...</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= TAB: DELETE POLICY ================= -->
|
||
<div id="tab-delete-policy" class="tab-content">
|
||
<div class="grid-2col">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title">⚙️ 자동 삭제 및 보관 정책 구성</h3>
|
||
</div>
|
||
<form class="config-form" id="policy-form" style="display: flex; flex-direction: column; gap: 20px;" onsubmit="submitPolicyForm(event)">
|
||
<div class="form-group">
|
||
<label>설정 대상</label>
|
||
<input type="text" class="text-input" value="전체 현장 공통 (GLOBAL)" disabled style="font-weight: 600; background-color: var(--primary-soft); color: var(--primary);">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>정책 활성화 토글</label>
|
||
<select class="select-input" id="policy-active" onchange="updatePolicySummary()">
|
||
<option value="true">활성화 (Active)</option>
|
||
<option value="false">일시 중지 (Inactive)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>최소 유지 파일 개수 기준</label>
|
||
<input type="number" class="text-input" id="policy-limit-file-count" required min="1" max="1000" oninput="updatePolicySummary()">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>자동 삭제 제한 기간 (일)</label>
|
||
<input type="number" class="text-input" id="policy-limit-days" required min="1" max="365" oninput="updatePolicySummary()">
|
||
</div>
|
||
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
||
<button class="btn btn-secondary" type="button" onclick="resetPolicyConfig()">기본값 초기화</button>
|
||
<button class="btn btn-primary" type="submit">변경 설정 저장</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<div style="display: flex; flex-direction: column; gap: 20px;">
|
||
<div class="card" style="background-color: var(--primary-soft); border: 1px dashed var(--primary); padding: 24px;">
|
||
<h4 style="margin: 0; color: var(--primary); font-size: 1rem;">📌 보존 정책 실시간 요약</h4>
|
||
<p style="font-size: 0.9rem; line-height: 1.6; color: var(--text-main); margin: 10px 0 0 0;" id="policy-summary"></p>
|
||
</div>
|
||
<div class="card" style="background-color: var(--warning-soft); border: 1px solid #fde68a; padding: 20px;">
|
||
<h4 style="margin: 0; color: #78350f; font-size: 0.95rem;">⚠️ 주의사항</h4>
|
||
<p style="font-size: 0.8rem; line-height: 1.5; color: #78350f; margin: 8px 0 0 0;">
|
||
해당 설정을 저장하면 기존 프론트엔드 코드(pageRenderer.js)의 하드코딩 기준값을 대체하여, 데이터베이스의 <code>tb_system_policy</code> 테이블에 동적으로 저장되며 시스템 전체 정기 배치 청소 스케줄링에 실시간 적용됩니다.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-top: 10px;">
|
||
<div class="card-header">
|
||
<h3 class="card-title">🕒 정기 스케줄러 자동 삭제 처리 이력 (Auto-Clean Logs)</h3>
|
||
</div>
|
||
<div class="table-wrapper">
|
||
<table class="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 50px;">NO</th>
|
||
<th>자동 처리 일자</th>
|
||
<th>프로젝트 ID</th>
|
||
<th>삭제 처리 폴더 경로</th>
|
||
<th>적용 기준 (파일 수 / 보존일)</th>
|
||
<th>처리 결과</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="auto-delete-history-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= TAB: CODE MGMT ================= -->
|
||
<div id="tab-code-mgmt" class="tab-content" style="gap: 20px;">
|
||
<!-- Top: Code Master (대분류) -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title">🗂️ 대분류 코드 마스터 (code_master)</h3>
|
||
<button class="btn btn-primary btn-sm" onclick="openCodeMasterModal('create')">➕ 대분류 등록</button>
|
||
</div>
|
||
<div class="table-wrapper" style="max-height: 300px; overflow-y: auto;">
|
||
<table class="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 50px;">NO</th>
|
||
<th>대분류 코드</th>
|
||
<th>대분류 코드명</th>
|
||
<th style="width: 70px;">사용</th>
|
||
<th>관리</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="code-master-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bottom: Code Detail (소분류) -->
|
||
<div class="card" style="gap: 20px;">
|
||
<div class="card-header" style="flex-wrap: wrap; gap: 10px;">
|
||
<div>
|
||
<h3 class="card-title" id="code-detail-title">📑 세부 코드 목록 (code_detail)</h3>
|
||
<p style="margin: 4px 0 0 0; font-size: 0.8rem; color: var(--text-muted);" id="code-detail-desc">상단에서 대분류 코드를 선택해 주세요.</p>
|
||
</div>
|
||
<button class="btn btn-primary btn-sm" id="btn-show-code-detail-modal" onclick="openCodeDetailModal('create')" disabled>➕ 세부코드 등록</button>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="table-wrapper" style="max-height: 300px; overflow-y: auto;">
|
||
<table class="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 50px;">NO</th>
|
||
<th>소분류 코드</th>
|
||
<th>조합 코드 (base_code)</th>
|
||
<th>코드 명칭</th>
|
||
<th style="width: 60px;">정렬</th>
|
||
<th style="width: 70px;">사용</th>
|
||
<th>관리</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="code-detail-body">
|
||
<tr>
|
||
<td colspan="7" style="text-align: center; color: var(--text-light); padding: 40px 0;">상단에서 대분류 코드를 선택하시면 세부 코드 목록이 표시됩니다.</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 프로젝트 등록/수정 모달 Overlay -->
|
||
<div class="modal-overlay" id="projectModalOverlay">
|
||
<div class="modal-card" style="max-width: 550px;">
|
||
<div class="modal-header">
|
||
<h3 id="project-modal-title">➕ 신규 프로젝트 등록</h3>
|
||
<button class="modal-close" onclick="closeProjectModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form class="config-form" id="project-form" style="display: flex; flex-direction: column; gap: 16px;" onsubmit="handleProjectSubmit(event)">
|
||
<div class="form-group">
|
||
<label for="form-project-id">프로젝트 ID (수정불가)</label>
|
||
<input type="text" class="text-input" id="form-project-id" required placeholder="예: PM_TEST_03">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="form-project-nm">프로젝트 명</label>
|
||
<input type="text" class="text-input" id="form-project-nm" required placeholder="예: 부산 외곽 터널 건설 공구">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="form-project-short">단축명 (Short Name)</label>
|
||
<input type="text" class="text-input" id="form-project-short" placeholder="예: 부산터널">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="form-project-category">카테고리</label>
|
||
<select class="select-input" id="form-project-category">
|
||
<option value="tdc" selected>TDC (tdc)</option>
|
||
<option value="gpd">GPD (gpd)</option>
|
||
<option value="bimproject">BIM 프로젝트 (bimproject)</option>
|
||
<option value="overseas">해외 프로젝트 (overseas)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="form-project-storage">스토리지 제한 (GB)</label>
|
||
<input type="number" class="text-input" id="form-project-storage" value="10" min="1" max="1000">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="form-project-active">운영 상태</label>
|
||
<select class="select-input" id="form-project-active">
|
||
<option value="true" selected>활성화 (Active)</option>
|
||
<option value="false">일시잠금 (Inactive)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 10px;">
|
||
<button class="btn btn-secondary" type="button" onclick="closeProjectModal()">취소</button>
|
||
<button class="btn btn-primary" type="submit" id="project-submit-btn">등록 하기</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 사용자 등록/수정 모달 Overlay -->
|
||
<div class="modal-overlay" id="userModalOverlay">
|
||
<div class="modal-card" style="max-width: 550px;">
|
||
<div class="modal-header">
|
||
<h3 id="user-modal-title">➕ 신규 사용자 등록</h3>
|
||
<button class="modal-close" onclick="closeUserModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form class="config-form" id="user-form" style="display: flex; flex-direction: column; gap: 16px;" onsubmit="handleUserSubmit(event)">
|
||
<div class="form-group">
|
||
<label for="form-user-id">사용자 ID (수정불가)</label>
|
||
<input type="text" class="text-input" id="form-user-id" required placeholder="예: user_id">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="form-user-pw">비밀번호</label>
|
||
<input type="password" class="text-input" id="form-user-pw" required placeholder="••••••••">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="form-user-nm">이름</label>
|
||
<input type="text" class="text-input" id="form-user-nm" required placeholder="예: 홍길동">
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="form-user-company">회사명</label>
|
||
<input type="text" class="text-input" id="form-user-company" placeholder="예: 한맥기술">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="form-user-dept">부서</label>
|
||
<input type="text" class="text-input" id="form-user-dept" placeholder="예: 개발본부">
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="form-user-position">직급</label>
|
||
<input type="text" class="text-input" id="form-user-position" placeholder="예: 부장">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="form-user-group">권한 그룹</label>
|
||
<select class="select-input" id="form-user-group">
|
||
<option value="worker" selected>일반 (worker)</option>
|
||
<option value="dev">개발자 (dev)</option>
|
||
<option value="super">관리자 (super)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="form-user-resigned">재직 상태</label>
|
||
<select class="select-input" id="form-user-resigned">
|
||
<option value="false" selected>재직 중 (Active)</option>
|
||
<option value="true">퇴직/잠금 (Locked)</option>
|
||
</select>
|
||
</div>
|
||
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 10px;">
|
||
<button class="btn btn-secondary" type="button" onclick="closeUserModal()">취소</button>
|
||
<button class="btn btn-primary" type="submit" id="user-submit-btn">등록 하기</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 3. Modal Overlay for User Assignment (2. 요구사항 팝업 구현) -->
|
||
<div class="modal-overlay" id="assignModalOverlay">
|
||
<div class="modal-card" style="max-width: 500px;">
|
||
<div class="modal-header">
|
||
<h3 id="modal-project-title">👤 사용자 권한 배정 추가</h3>
|
||
<button class="modal-close" onclick="closeAssignModal()">×</button>
|
||
</div>
|
||
<form id="assign-form" onsubmit="submitAssignForm(event)">
|
||
<div class="modal-body">
|
||
<div class="form-group" style="margin-bottom: 15px;">
|
||
<label for="assign-default-level">기본 배정 권한 등급</label>
|
||
<select class="select-input" id="assign-default-level" style="width: 100%;">
|
||
<option value="7">Worker</option>
|
||
<option value="191">Sub-Master</option>
|
||
<option value="15">Security-Worker</option>
|
||
<option value="1">Viewer</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label style="display: block; margin-bottom: 6px;">배정 대상 미참여 사용자 선택</label>
|
||
<div id="assign-user-list-container" style="max-height: 250px; overflow-y: auto; border: 1px solid #d2dcdb; padding: 10px; border-radius: 4px; display: flex; flex-direction: column; gap: 8px; box-sizing: border-box;">
|
||
<!-- Checkboxes of unassigned users rendered here -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary btn-sm" onclick="closeAssignModal()">취소</button>
|
||
<button type="submit" class="btn btn-primary btn-sm">배정 완료</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 대분류 코드 등록/수정 모달 Overlay -->
|
||
<div class="modal-overlay" id="codeMasterModalOverlay">
|
||
<div class="modal-card" style="max-width: 550px;">
|
||
<div class="modal-header">
|
||
<h3 id="code-master-modal-title">➕ 신규 대분류 등록</h3>
|
||
<button class="modal-close" onclick="closeCodeMasterModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form class="config-form" id="code-master-form" style="display: flex; flex-direction: column; gap: 16px;" onsubmit="handleCodeMasterSubmit(event)">
|
||
<div class="form-group">
|
||
<label for="form-master-code">대분류 코드 (수정불가)</label>
|
||
<input type="text" class="text-input" id="form-master-code" required placeholder="예: PROJECT_CATEGORY">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="form-master-name">대분류 코드명</label>
|
||
<input type="text" class="text-input" id="form-master-name" required placeholder="예: 프로젝트 카테고리">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="form-master-useyn">사용 여부</label>
|
||
<select class="select-input" id="form-master-useyn">
|
||
<option value="Y" selected>사용 (Y)</option>
|
||
<option value="N">미사용 (N)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="form-master-rmk">설명 (비고)</label>
|
||
<input type="text" class="text-input" id="form-master-rmk" placeholder="대분류 코드 설명 입력">
|
||
</div>
|
||
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 10px;">
|
||
<button class="btn btn-secondary" type="button" onclick="closeCodeMasterModal()">취소</button>
|
||
<button class="btn btn-primary" type="submit" id="code-master-submit-btn">등록 하기</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 세부 코드 등록/수정 모달 Overlay -->
|
||
<div class="modal-overlay" id="codeDetailModalOverlay">
|
||
<div class="modal-card" style="max-width: 550px;">
|
||
<div class="modal-header">
|
||
<h3 id="code-detail-modal-title">➕ 신규 세부코드 등록</h3>
|
||
<button class="modal-close" onclick="closeCodeDetailModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form class="config-form" id="code-detail-form" style="display: flex; flex-direction: column; gap: 16px;" onsubmit="handleCodeDetailSubmit(event)">
|
||
<div class="form-group">
|
||
<label for="form-detail-maincode">대분류 코드 (고정)</label>
|
||
<input type="text" class="text-input" id="form-detail-maincode" disabled>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="form-detail-subcode">소분류 코드 (수정불가)</label>
|
||
<input type="text" class="text-input" id="form-detail-subcode" required placeholder="예: tdc">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="form-detail-name">코드 명칭 (한글)</label>
|
||
<input type="text" class="text-input" id="form-detail-name" required placeholder="예: TDC 사업부">
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="form-detail-sort">정렬 순서</label>
|
||
<input type="number" class="text-input" id="form-detail-sort" value="1" min="1" max="1000">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="form-detail-useyn">사용 여부</label>
|
||
<select class="select-input" id="form-detail-useyn">
|
||
<option value="Y" selected>사용 (Y)</option>
|
||
<option value="N">미사용 (N)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="form-detail-rmk">설명 (비고)</label>
|
||
<input type="text" class="text-input" id="form-detail-rmk" placeholder="세부 코드 설명 입력">
|
||
</div>
|
||
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 10px;">
|
||
<button class="btn btn-secondary" type="button" onclick="closeCodeDetailModal()">취소</button>
|
||
<button class="btn btn-primary" type="submit" id="code-detail-submit-btn">등록 하기</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- UI Interaction Scripts -->
|
||
|
||
<!-- UI Interaction & Real API Connection Scripts -->
|
||
<script>
|
||
// --- 1. 전역 상태 변수 및 설정 ---
|
||
let selectedProjectId = null;
|
||
let selectedUserId = null;
|
||
let selectedCodeMasterId = null;
|
||
let socket = null;
|
||
|
||
// AJAX API Fetch 공통 함수
|
||
async function fetchAPI(url, options = {}) {
|
||
try {
|
||
const response = await fetch(url, options);
|
||
const data = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(data.error || 'API 요청 처리 실패');
|
||
}
|
||
return data;
|
||
} catch (err) {
|
||
alert(`⚠️ 에러 발생: ${err.message}`);
|
||
console.error(err);
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
// 로그인 사용자 프로필 로드 함수
|
||
async function loadUserProfile() {
|
||
try {
|
||
const statusRes = await fetchAPI('/auth/status');
|
||
if (statusRes.loggedIn && statusRes.user) {
|
||
const u = statusRes.user;
|
||
const nameEl = document.getElementById('profileName');
|
||
const roleEl = document.getElementById('profileRole');
|
||
const avatarEl = document.getElementById('profileAvatar');
|
||
|
||
if (nameEl) nameEl.innerText = `${u.user_nm || u.user_id}님`;
|
||
if (roleEl) {
|
||
let roleName = '일반 사용자';
|
||
if (u.group === 'USER_GROUP_super' || u.group === 'super') {
|
||
roleName = '최고 관리자';
|
||
} else if (u.group === 'dev') {
|
||
roleName = '개발자';
|
||
}
|
||
roleEl.innerText = roleName;
|
||
}
|
||
if (avatarEl && u.user_nm) {
|
||
avatarEl.innerText = u.user_nm.substring(0, 2).toUpperCase();
|
||
} else if (avatarEl && u.user_id) {
|
||
avatarEl.innerText = u.user_id.substring(0, 2).toUpperCase();
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("Load user profile error:", err);
|
||
}
|
||
}
|
||
|
||
// 바이트 단위 포맷팅 함수
|
||
function formatBytes(bytes, decimals = 2) {
|
||
if (bytes === 0 || !bytes) return '0 Bytes';
|
||
const k = 1024;
|
||
const dm = decimals < 0 ? 0 : decimals;
|
||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||
}
|
||
|
||
// --- 2. 탭 전환 제어 ---
|
||
function switchTab(tabId) {
|
||
// LNB active 클래스 해제 및 설정
|
||
document.querySelectorAll('.menu-item').forEach(item => {
|
||
item.classList.remove('active');
|
||
});
|
||
const activeMenu = document.getElementById(`menu-${tabId}`);
|
||
if (activeMenu) activeMenu.classList.add('active');
|
||
|
||
// 탭 콘텐츠 active 클래스 해제 및 설정
|
||
document.querySelectorAll('.tab-content').forEach(content => {
|
||
content.classList.remove('active');
|
||
});
|
||
const activeTab = document.getElementById(`tab-${tabId}`);
|
||
if (activeTab) activeTab.classList.add('active');
|
||
|
||
// 헤더 타이틀 변경
|
||
const titles = {
|
||
'dashboard': '📊 종합 용량 및 접속자 현황',
|
||
'project-mgmt': '🏗️ 프로젝트 관리',
|
||
'banner-notice': '📢 실시간 배너 공지',
|
||
'user-mgmt': '👥 사용자 관리',
|
||
'folder-permission': '📂 폴더별 권한 관리',
|
||
'audit-logs': '🔎 활동 로그 조회',
|
||
'delete-policy': '⚙️ 보관 및 삭제 정책 설정',
|
||
'code-mgmt': '🔑 공통 코드 관리'
|
||
};
|
||
document.getElementById('headerTitle').innerText = titles[tabId] || '관리자 대시보드';
|
||
|
||
// 각 탭별 렌더링 호출
|
||
if (tabId === 'dashboard') renderDashboard();
|
||
else if (tabId === 'project-mgmt') renderProjects();
|
||
else if (tabId === 'banner-notice') renderBanners();
|
||
else if (tabId === 'user-mgmt') renderUsers();
|
||
else if (tabId === 'folder-permission') initFolderPermissionTab();
|
||
else if (tabId === 'audit-logs') renderAuditLogs();
|
||
else if (tabId === 'delete-policy') renderDeletePolicy();
|
||
else if (tabId === 'code-mgmt') renderCommonCodes();
|
||
}
|
||
|
||
// --- 3. 종합 대시보드 탭 (Dashboard) ---
|
||
async function renderDashboard() {
|
||
try {
|
||
// 1) 프로젝트별 사용 현황 및 게이지 바 Fetch
|
||
const projects = await fetchAPI('/api/admin/projects');
|
||
const pBarContainer = document.getElementById('dashboard-progress-bars');
|
||
pBarContainer.innerHTML = '';
|
||
|
||
let totalUsed = 0;
|
||
let totalLimit = 0;
|
||
|
||
projects.forEach(p => {
|
||
const storageLimit = Number(p.storage_byte) || 0;
|
||
const usedStorage = Number(p.used_bytes) || 0;
|
||
const fileCount = Number(p.file_count) || 0;
|
||
totalUsed += usedStorage;
|
||
totalLimit += storageLimit;
|
||
|
||
const percent = storageLimit > 0 ? ((usedStorage / storageLimit) * 100).toFixed(1) : 0;
|
||
|
||
const barHtml = `
|
||
<div class="progress-bar-container">
|
||
<span style="font-size: 0.85rem; font-weight: 600; width: 140px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">
|
||
${p.project_nm}
|
||
</span>
|
||
<div class="progress-bar-bg">
|
||
<div class="progress-bar-fill" style="width: ${percent}%;"></div>
|
||
</div>
|
||
<span style="font-size: 0.8rem; font-weight: 500; width: 220px; text-align: right; color: var(--text-muted);">
|
||
${formatBytes(usedStorage)} / ${formatBytes(storageLimit)} (${percent}%) [${fileCount}건]
|
||
</span>
|
||
</div>
|
||
`;
|
||
pBarContainer.insertAdjacentHTML('beforeend', barHtml);
|
||
});
|
||
|
||
document.getElementById('kpi-storage').innerText = `${formatBytes(totalUsed)} / ${formatBytes(totalLimit)}`;
|
||
|
||
// 2) 실시간 소켓 접속자 연동
|
||
initSocketConnection();
|
||
} catch (err) {
|
||
console.error("renderDashboard Error:", err);
|
||
}
|
||
}
|
||
|
||
// Socket.io 연동 초기화
|
||
function initSocketConnection() {
|
||
if (socket) return;
|
||
|
||
socket = io(); // 소켓 연결
|
||
|
||
// 접속 세션 싱크 처리
|
||
socket.emit('getUsers');
|
||
|
||
socket.on('getUsers', (connectedUsers) => {
|
||
renderConnectedUsersTable(connectedUsers);
|
||
});
|
||
}
|
||
|
||
function renderConnectedUsersTable(connectedUsers) {
|
||
const tableBody = document.querySelector('#tab-dashboard table.admin-table tbody');
|
||
tableBody.innerHTML = '';
|
||
|
||
const userKeys = Object.keys(connectedUsers);
|
||
// 대시보드 실시간 접속자 KPI 갱신
|
||
document.querySelectorAll('.kpi-card')[1].querySelector('.kpi-value').innerText = `${userKeys.length} 명`;
|
||
|
||
if (userKeys.length === 0) {
|
||
tableBody.innerHTML = `<tr><td colspan="5" style="text-align: center; padding: 20px; color: var(--text-light);">실시간 접속자 정보가 없습니다.</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
userKeys.forEach((socketId, idx) => {
|
||
const u = connectedUsers[socketId];
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td>${idx + 1}</td>
|
||
<td><strong>${u.user_id || 'unknown'}</strong> (${u.user_nm || '미상'})</td>
|
||
<td>${u.user_ip || '-'}</td>
|
||
<td><code>${u.curPath || '/'}</code></td>
|
||
<td>
|
||
<button class="btn btn-danger btn-sm" onclick="kickUser('${socketId}')">강제퇴장</button>
|
||
</td>
|
||
`;
|
||
tableBody.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
function kickUser(socketId) {
|
||
if (confirm("대상 사용자의 접속 세션을 즉시 해제(강제퇴장)시키겠습니까?")) {
|
||
socket.emit('forcedLogout', { targetClientId: socketId });
|
||
alert('퇴장 명령을 전송했습니다.');
|
||
socket.emit('getUsers'); // 즉시 새로고침 요청
|
||
}
|
||
}
|
||
|
||
// --- 4. 프로젝트 관리 탭 (Projects & Merged Permissions) ---
|
||
async function renderProjects() {
|
||
try {
|
||
const projects = await fetchAPI('/api/admin/projects');
|
||
const body = document.getElementById('project-list-body');
|
||
body.innerHTML = '';
|
||
|
||
projects.forEach((p, idx) => {
|
||
const tr = document.createElement('tr');
|
||
tr.className = 'clickable' + (selectedProjectId === p.project_id ? ' selected' : '');
|
||
tr.onclick = () => selectProject(p.project_id, p.project_nm);
|
||
|
||
tr.innerHTML = `
|
||
<td>${idx + 1}</td>
|
||
<td><strong>${p.project_id}</strong></td>
|
||
<td>${p.project_nm}</td>
|
||
<td>${p.category_nm || p.category || '-'}</td>
|
||
<td>${p.storage_byte ? (Number(p.storage_byte) / (1024*1024*1024)).toFixed(0) + ' GB' : '0 GB'}</td>
|
||
<td><span class="badge ${p.is_active ? 'active' : 'inactive'}">${p.is_active ? '활성' : '비활성'}</span></td>
|
||
<td>
|
||
<div class="action-btns" onclick="event.stopPropagation();">
|
||
<button class="btn btn-secondary btn-sm" onclick="openProjectModal('edit', '${p.project_id}')">수정</button>
|
||
<button class="btn btn-danger btn-sm" onclick="deleteProject('${p.project_id}')">삭제</button>
|
||
</div>
|
||
</td>
|
||
`;
|
||
body.appendChild(tr);
|
||
});
|
||
|
||
if (selectedProjectId) {
|
||
const selectedProj = projects.find(p => p.project_id === selectedProjectId);
|
||
if (selectedProj) selectProject(selectedProj.project_id, selectedProj.project_nm);
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
async function selectProject(projectId, projectNm) {
|
||
selectedProjectId = projectId;
|
||
|
||
// 테이블 active 행 하이라이트 반영
|
||
document.querySelectorAll('#project-list-body tr').forEach(tr => {
|
||
tr.classList.remove('selected');
|
||
});
|
||
const activeTr = Array.from(document.querySelectorAll('#project-list-body tr')).find(tr => tr.innerHTML.includes(`<strong>${projectId}</strong>`));
|
||
if (activeTr) activeTr.classList.add('selected');
|
||
|
||
document.getElementById('assigned-project-title').innerText = `🏗️ ${projectNm}`;
|
||
document.getElementById('assigned-project-desc').innerText = `ID: ${projectId} - 현장의 참여 멤버 권한을 통제합니다.`;
|
||
document.getElementById('btn-show-assign-modal').removeAttribute('disabled');
|
||
|
||
// 멤버 목록 렌더링
|
||
renderAssignedUsers(projectId);
|
||
}
|
||
|
||
async function renderAssignedUsers(projectId) {
|
||
try {
|
||
const members = await fetchAPI(`/api/admin/permissions/project/${projectId}`);
|
||
const body = document.getElementById('assigned-user-list-body');
|
||
body.innerHTML = '';
|
||
document.getElementById('assigned-user-count').innerText = `${members.length}명`;
|
||
|
||
if (members.length === 0) {
|
||
body.innerHTML = `<tr><td colspan="6" style="text-align: center; color: var(--text-light); padding: 40px 0;">이 현장에 소속된 멤버가 없습니다. 우측 상단 '사용자 권한 배정'을 눌러 추가하세요.</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
// 등급 인라인 셀렉터 맵
|
||
const levMap = [
|
||
{ value: 191, label: 'Sub-Master' },
|
||
{ value: 15, label: 'Security-Worker' },
|
||
{ value: 7, label: 'Worker' },
|
||
{ value: 1, label: 'Viewer' }
|
||
];
|
||
|
||
members.forEach((m, idx) => {
|
||
const tr = document.createElement('tr');
|
||
|
||
let selectHtml = `<select class="select-input" style="padding: 4px 8px; width: 140px;" onchange="updateMemberLevel('${projectId}', '${m.user_id}', this.value)">`;
|
||
levMap.forEach(opt => {
|
||
selectHtml += `<option value="${opt.value}" ${m.lev === opt.value ? 'selected' : ''}>${opt.label}</option>`;
|
||
});
|
||
selectHtml += `</select>`;
|
||
|
||
tr.innerHTML = `
|
||
<td>${idx + 1}</td>
|
||
<td><strong>${m.user_id}</strong></td>
|
||
<td>${m.user_nm}</td>
|
||
<td>${m.company || '-'} / ${m.dept || m.position || '-'}</td>
|
||
<td>${selectHtml}</td>
|
||
<td>
|
||
<button class="btn btn-danger btn-sm" onclick="removeMember('${projectId}', '${m.user_id}')">배정제외</button>
|
||
</td>
|
||
`;
|
||
body.appendChild(tr);
|
||
});
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
async function updateMemberLevel(projectId, userId, lev) {
|
||
try {
|
||
await fetchAPI('/api/admin/permissions/update', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ project_id: projectId, user_id: userId, lev: Number(lev) })
|
||
});
|
||
alert('권한 등급이 수정되었습니다.');
|
||
renderAssignedUsers(projectId);
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
async function removeMember(projectId, userId) {
|
||
if (confirm(`사용자 [${userId}]를 이 현장 배정에서 제외하시겠습니까?`)) {
|
||
try {
|
||
await fetchAPI('/api/admin/permissions/remove', {
|
||
method: 'DELETE',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ project_id: projectId, user_id: userId })
|
||
});
|
||
alert('현장 멤버 배정에서 제외되었습니다.');
|
||
renderAssignedUsers(projectId);
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 사용자 다중 배정 모달 연동
|
||
async function openAssignModal() {
|
||
if (!selectedProjectId) return;
|
||
const modal = document.getElementById('assignModalOverlay');
|
||
modal.style.display = 'flex';
|
||
|
||
const userListContainer = document.getElementById('assign-user-list-container');
|
||
userListContainer.innerHTML = '로딩 중...';
|
||
|
||
try {
|
||
// 1) 전체 사용자 조회
|
||
const allUsers = await fetchAPI('/api/admin/users');
|
||
// 2) 이미 배정된 사용자 조회
|
||
const assigned = await fetchAPI(`/api/admin/permissions/project/${selectedProjectId}`);
|
||
const assignedIds = assigned.map(m => m.user_id);
|
||
|
||
// 3) 미배정 유저 필터링
|
||
const unassigned = allUsers.filter(u => !assignedIds.includes(u.user_id) && !u.is_resigned);
|
||
userListContainer.innerHTML = '';
|
||
|
||
if (unassigned.length === 0) {
|
||
userListContainer.innerHTML = `<div style="text-align: center; padding: 20px; color: var(--text-light);">모든 직원이 이미 배정 완료되었습니다.</div>`;
|
||
return;
|
||
}
|
||
|
||
unassigned.forEach(u => {
|
||
const div = document.createElement('div');
|
||
div.className = 'check-list-item';
|
||
div.innerHTML = `
|
||
<input type="checkbox" name="assignUserCheck" value="${u.user_id}">
|
||
<div class="user-info">
|
||
<span class="user-info-name">${u.user_nm} (${u.user_id})</span>
|
||
<span class="user-info-desc">${u.company || '-'} / ${u.dept || ''} ${u.position || ''}</span>
|
||
</div>
|
||
`;
|
||
div.onclick = (e) => {
|
||
if (e.target.tagName !== 'INPUT') {
|
||
const chk = div.querySelector('input');
|
||
chk.checked = !chk.checked;
|
||
}
|
||
};
|
||
userListContainer.appendChild(div);
|
||
});
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
function closeAssignModal() {
|
||
document.getElementById('assignModalOverlay').style.display = 'none';
|
||
}
|
||
|
||
async function submitAssignForm(event) {
|
||
event.preventDefault();
|
||
const checked = Array.from(document.querySelectorAll('input[name="assignUserCheck"]:checked')).map(chk => chk.value);
|
||
if (checked.length === 0) {
|
||
alert('배정할 직원을 1명 이상 선택해 주세요.');
|
||
return;
|
||
}
|
||
|
||
const defaultLev = Number(document.getElementById('assign-default-level').value);
|
||
const userPayload = checked.map(uid => ({ user_id: uid, lev: defaultLev }));
|
||
|
||
try {
|
||
await fetchAPI('/api/admin/permissions/assign', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ project_id: selectedProjectId, users: userPayload })
|
||
});
|
||
alert('사용자가 프로젝트에 배정되었습니다.');
|
||
closeAssignModal();
|
||
renderAssignedUsers(selectedProjectId);
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
// 프로젝트 등록/수정 팝업 핸들러
|
||
async function openProjectModal(mode, projectId = null) {
|
||
const modal = document.getElementById('projectModalOverlay');
|
||
modal.style.display = 'flex';
|
||
|
||
const categorySelect = document.getElementById('form-project-category');
|
||
categorySelect.innerHTML = '<option value="">로딩중...</option>';
|
||
try {
|
||
const categories = await fetchAPI('/api/admin/common-codes/details/PROJECT_CATEGORY');
|
||
categorySelect.innerHTML = '<option value="">-- 카테고리 선택 --</option>';
|
||
categories.forEach(cd => {
|
||
categorySelect.insertAdjacentHTML('beforeend', `<option value="${cd.sub_code}">${cd.code_nm}</option>`);
|
||
});
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
|
||
const form = document.getElementById('project-form');
|
||
form.reset();
|
||
|
||
if (mode === 'create') {
|
||
document.getElementById('project-modal-title').innerText = '➕ 신규 프로젝트 등록';
|
||
document.getElementById('form-project-id').removeAttribute('readonly');
|
||
document.getElementById('form-project-id').disabled = false;
|
||
document.getElementById('project-submit-btn').innerText = '등록 하기';
|
||
form.onsubmit = submitCreateProject;
|
||
} else {
|
||
document.getElementById('project-modal-title').innerText = '📝 프로젝트 상세 정보 수정';
|
||
document.getElementById('form-project-id').setAttribute('readonly', 'true');
|
||
document.getElementById('form-project-id').disabled = true;
|
||
document.getElementById('project-submit-btn').innerText = '수정 하기';
|
||
|
||
try {
|
||
const projects = await fetchAPI('/api/admin/projects');
|
||
const p = projects.find(item => item.project_id === projectId);
|
||
if (p) {
|
||
document.getElementById('form-project-id').value = p.project_id;
|
||
document.getElementById('form-project-nm').value = p.project_nm;
|
||
document.getElementById('form-project-short').value = p.short_nm || '';
|
||
document.getElementById('form-project-category').value = p.category || '';
|
||
document.getElementById('form-project-storage').value = p.storage_byte ? (Number(p.storage_byte) / (1024*1024*1024)).toFixed(0) : 10;
|
||
document.getElementById('form-project-active').value = p.is_active ? 'true' : 'false';
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
|
||
form.onsubmit = (e) => submitEditProject(e, projectId);
|
||
}
|
||
}
|
||
|
||
function closeProjectModal() {
|
||
document.getElementById('projectModalOverlay').style.display = 'none';
|
||
}
|
||
|
||
async function submitCreateProject(event) {
|
||
event.preventDefault();
|
||
const payload = {
|
||
project_id: document.getElementById('form-project-id').value.trim(),
|
||
project_nm: document.getElementById('form-project-nm').value.trim(),
|
||
short_nm: document.getElementById('form-project-short').value.trim(),
|
||
category: document.getElementById('form-project-category').value,
|
||
limit_storage: Number(document.getElementById('form-project-storage').value),
|
||
is_active: document.getElementById('form-project-active').value === 'true'
|
||
};
|
||
|
||
try {
|
||
await fetchAPI('/api/admin/projects', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
alert('프로젝트가 성공적으로 등록되었습니다.');
|
||
closeProjectModal();
|
||
renderProjects();
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
async function submitEditProject(event, projectId) {
|
||
event.preventDefault();
|
||
const payload = {
|
||
project_nm: document.getElementById('form-project-nm').value.trim(),
|
||
short_nm: document.getElementById('form-project-short').value.trim(),
|
||
category: document.getElementById('form-project-category').value,
|
||
limit_storage: Number(document.getElementById('form-project-storage').value),
|
||
is_active: document.getElementById('form-project-active').value === 'true'
|
||
};
|
||
|
||
try {
|
||
await fetchAPI(`/api/admin/projects/${projectId}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
alert('프로젝트 정보가 수정되었습니다.');
|
||
closeProjectModal();
|
||
renderProjects();
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
async function deleteProject(projectId) {
|
||
if (confirm(`🚨 프로젝트 [${projectId}]를 삭제하시겠습니까?\n관련 사용 이력 정보가 있을 시 삭제가 차단됩니다.`)) {
|
||
try {
|
||
await fetchAPI(`/api/admin/projects/${projectId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
alert('프로젝트가 성공적으로 삭제되었습니다.');
|
||
if (selectedProjectId === projectId) {
|
||
selectedProjectId = null;
|
||
document.getElementById('assigned-project-title').innerText = '🌉 프로젝트 선택 대기 중';
|
||
document.getElementById('assigned-project-desc').innerText = '좌측에서 프로젝트를 선택해 주세요.';
|
||
document.getElementById('assigned-user-list-body').innerHTML = `<tr><td colspan="6" style="text-align: center; color: var(--text-light); padding: 40px 0;">프로젝트를 선택하시면 배정된 사용자 목록이 여기에 표시됩니다.</td></tr>`;
|
||
document.getElementById('btn-show-assign-modal').setAttribute('disabled', 'true');
|
||
}
|
||
renderProjects();
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- 5. 실시간 배너 공지 (Banner Notice) ---
|
||
async function syncProjectDropdowns() {
|
||
try {
|
||
const projects = await fetchAPI('/api/admin/projects');
|
||
// 배너 등록용 셀렉터 동기화
|
||
const bannerSelect = document.getElementById('banner-project-select');
|
||
if (bannerSelect) {
|
||
bannerSelect.innerHTML = '<option value="all">전체 현장 (all)</option>';
|
||
projects.forEach(p => {
|
||
bannerSelect.insertAdjacentHTML('beforeend', `<option value="${p.project_id}">${p.project_nm}</option>`);
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
async function renderBanners() {
|
||
const statusFilter = document.getElementById('search-banner-status').value;
|
||
const fromDate = document.getElementById('search-banner-from').value;
|
||
const toDate = document.getElementById('search-banner-to').value;
|
||
|
||
let queryUrl = '/api/admin/banners?';
|
||
if (statusFilter) queryUrl += `status=${statusFilter}&`;
|
||
if (fromDate) queryUrl += `fromDate=${fromDate}&`;
|
||
if (toDate) queryUrl += `toDate=${toDate}`;
|
||
|
||
try {
|
||
const banners = await fetchAPI(queryUrl);
|
||
const body = document.getElementById('banner-history-body');
|
||
body.innerHTML = '';
|
||
|
||
if (banners.length === 0) {
|
||
body.innerHTML = `<tr><td colspan="8" style="text-align: center; color: var(--text-light); padding: 24px 0;">조회된 배너 공지 이력이 없습니다.</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
banners.forEach((b, idx) => {
|
||
const tr = document.createElement('tr');
|
||
|
||
let actionHtml = '-';
|
||
if (b.status_code === 'NOTICE_STATUS_active' || b.status_code === 'NOTICE_STATUS_scheduled') {
|
||
actionHtml = `<button class="btn btn-danger btn-sm" onclick="stopBanner('${b.banner_id}')">송출중지</button>`;
|
||
} else if (b.status_code === 'NOTICE_STATUS_expired') {
|
||
actionHtml = `<span style="color: var(--text-light); font-weight: 500;">중지 완료</span>`;
|
||
}
|
||
|
||
tr.innerHTML = `
|
||
<td>${idx + 1}</td>
|
||
<td>${b.reg_date ? b.reg_date.split('T')[0] : '-'}</td>
|
||
<td><strong>${b.project_id || '전체 (all)'}</strong> ${b.project_nm ? '(' + b.project_nm + ')' : ''}</td>
|
||
<td style="max-width: 250px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">${b.notice_text}</td>
|
||
<td>${b.start_date ? b.start_date.split('T')[0] : '-'}</td>
|
||
<td>${b.end_date ? b.end_date.split('T')[0] : '-'}</td>
|
||
<td><span class="badge ${b.status_code === 'NOTICE_STATUS_active' ? 'active' : b.status_code === 'NOTICE_STATUS_scheduled' ? 'warning' : 'inactive'}">${b.status_nm || b.status_code}</span></td>
|
||
<td>${actionHtml}</td>
|
||
`;
|
||
body.appendChild(tr);
|
||
});
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
async function submitBannerForm(event) {
|
||
event.preventDefault();
|
||
const payload = {
|
||
project_id: document.getElementById('banner-project-select').value,
|
||
start_date: document.getElementById('banner-start-date').value,
|
||
end_date: document.getElementById('banner-end-date').value,
|
||
notice_text: document.getElementById('banner-text').value.trim()
|
||
};
|
||
|
||
if (!payload.start_date || !payload.end_date || !payload.notice_text) {
|
||
alert('시작일, 종료일, 자막 내용은 필수입니다.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await fetchAPI('/api/admin/banners', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
alert('공지 배너가 정상적으로 송출 등록되었습니다.');
|
||
document.getElementById('banner-text').value = '';
|
||
renderBanners();
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
async function stopBanner(bannerId) {
|
||
if (confirm('선택하신 배너 공지의 송출을 즉시 중지 처리(Expired)하시겠습니까?')) {
|
||
try {
|
||
await fetchAPI(`/api/admin/banners/stop/${bannerId}`, {
|
||
method: 'PUT'
|
||
});
|
||
alert('송출 중지가 완료되었습니다.');
|
||
renderBanners();
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- 6. 사용자 관리 탭 (User Management) ---
|
||
async function syncUserDropdowns() {
|
||
try {
|
||
const groupSelect = document.getElementById('form-user-group');
|
||
if (groupSelect) {
|
||
const groups = await fetchAPI('/api/admin/common-codes/details/USER_GROUP');
|
||
groupSelect.innerHTML = '<option value="">-- 권한 그룹 선택 --</option>';
|
||
groups.forEach(g => {
|
||
groupSelect.insertAdjacentHTML('beforeend', `<option value="${g.base_code}">${g.code_nm}</option>`);
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
async function renderUsers() {
|
||
try {
|
||
const users = await fetchAPI('/api/admin/users');
|
||
const body = document.getElementById('user-list-body');
|
||
body.innerHTML = '';
|
||
|
||
users.forEach((u, idx) => {
|
||
const tr = document.createElement('tr');
|
||
tr.className = 'clickable' + (selectedUserId === u.user_id ? ' selected' : '');
|
||
tr.onclick = () => selectUser(u.user_id, u.user_nm);
|
||
|
||
tr.innerHTML = `
|
||
<td>${idx + 1}</td>
|
||
<td><strong>${u.user_id}</strong></td>
|
||
<td>${u.user_nm}</td>
|
||
<td>${u.company || '-'} / ${u.dept || ''} ${u.position || ''}</td>
|
||
<td>${u.group_nm || u.group || '-'}</td>
|
||
<td><span class="badge ${u.is_resigned ? 'danger' : 'active'}">${u.is_resigned ? '퇴직/잠금' : '재직'}</span></td>
|
||
<td>
|
||
<div class="action-btns" onclick="event.stopPropagation();">
|
||
<button class="btn btn-secondary btn-sm" onclick="openUserModal('edit', '${u.user_id}')">수정</button>
|
||
<button class="btn btn-danger btn-sm" onclick="deleteUser('${u.user_id}')">삭제</button>
|
||
</div>
|
||
</td>
|
||
`;
|
||
body.appendChild(tr);
|
||
});
|
||
|
||
if (selectedUserId) {
|
||
const selectedU = users.find(u => u.user_id === selectedUserId);
|
||
if (selectedU) selectUser(selectedU.user_id, selectedU.user_nm);
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
async function selectUser(userId, userNm) {
|
||
selectedUserId = userId;
|
||
|
||
// 행 하이라이트 동기화
|
||
document.querySelectorAll('#user-list-body tr').forEach(tr => {
|
||
tr.classList.remove('selected');
|
||
});
|
||
const activeTr = Array.from(document.querySelectorAll('#user-list-body tr')).find(tr => tr.innerHTML.includes(`<strong>${userId}</strong>`));
|
||
if (activeTr) activeTr.classList.add('selected');
|
||
|
||
document.getElementById('user-permission-title').innerText = `👥 ${userNm} (${userId}) 참여 프로젝트`;
|
||
document.getElementById('user-permission-desc').innerText = `해당 사용자가 권한을 받아 참여 중인 프로젝트 현장 목록입니다.`;
|
||
|
||
// 참여 프로젝트 렌더링
|
||
try {
|
||
const projects = await fetchAPI(`/api/admin/users/${userId}/permissions`);
|
||
const body = document.getElementById('user-permission-list-body');
|
||
body.innerHTML = '';
|
||
|
||
if (projects.length === 0) {
|
||
body.innerHTML = `<tr><td colspan="4" style="text-align: center; color: var(--text-light); padding: 30px 0;">배정된 프로젝트가 없습니다. '프로젝트 관리' 탭에서 멤버 배정을 진행해 주세요.</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
const levNameMap = { 255: 'Owner / Admin (255)', 191: 'Sub-Master (191)', 15: 'Security-Worker (15)', 7: 'Worker (7)', 1: 'Viewer (1)' };
|
||
|
||
projects.forEach((p, idx) => {
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td>${idx + 1}</td>
|
||
<td><strong>${p.project_id}</strong></td>
|
||
<td>${p.project_nm}</td>
|
||
<td><span class="badge active">${levNameMap[p.lev] || p.lev || '일반'}</span></td>
|
||
`;
|
||
body.appendChild(tr);
|
||
});
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
async function openUserModal(mode, userId = null) {
|
||
const modal = document.getElementById('userModalOverlay');
|
||
modal.style.display = 'flex';
|
||
|
||
await syncUserDropdowns();
|
||
const form = document.getElementById('user-form');
|
||
form.reset();
|
||
|
||
// 비밀번호 행 제어
|
||
const pwRow = document.getElementById('form-user-pw').closest('.form-group');
|
||
|
||
if (mode === 'create') {
|
||
document.getElementById('user-modal-title').innerText = '👥 신규 사용자 계정 등록';
|
||
document.getElementById('form-user-id').removeAttribute('readonly');
|
||
document.getElementById('form-user-id').disabled = false;
|
||
document.getElementById('form-user-pw').required = true;
|
||
pwRow.style.display = 'flex'; // PW 보이기
|
||
document.getElementById('user-submit-btn').innerText = '등록 하기';
|
||
form.onsubmit = submitCreateUser;
|
||
} else {
|
||
document.getElementById('user-modal-title').innerText = '📝 사용자 정보 수정';
|
||
document.getElementById('form-user-id').setAttribute('readonly', 'true');
|
||
document.getElementById('form-user-id').disabled = true;
|
||
|
||
// 패스워드 입력칸 숨기기 및 필수조건 해제
|
||
document.getElementById('form-user-pw').required = false;
|
||
pwRow.style.display = 'none';
|
||
|
||
document.getElementById('user-submit-btn').innerText = '수정 하기';
|
||
|
||
try {
|
||
const users = await fetchAPI('/api/admin/users');
|
||
const u = users.find(item => item.user_id === userId);
|
||
if (u) {
|
||
document.getElementById('form-user-id').value = u.user_id;
|
||
document.getElementById('form-user-pw').value = '';
|
||
document.getElementById('form-user-nm').value = u.user_nm;
|
||
document.getElementById('form-user-company').value = u.company || '';
|
||
document.getElementById('form-user-dept').value = u.dept || '';
|
||
document.getElementById('form-user-position').value = u.position || '';
|
||
document.getElementById('form-user-group').value = u.group || 'worker';
|
||
document.getElementById('form-user-resigned').value = u.is_resigned ? 'true' : 'false';
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
|
||
form.onsubmit = (e) => submitEditUser(e, userId);
|
||
}
|
||
}
|
||
|
||
async function submitCreateUser(event) {
|
||
event.preventDefault();
|
||
const payload = {
|
||
user_id: document.getElementById('form-user-id').value.trim(),
|
||
user_pw: document.getElementById('form-user-pw').value,
|
||
user_nm: document.getElementById('form-user-nm').value.trim(),
|
||
company: document.getElementById('form-user-company').value.trim(),
|
||
dept: document.getElementById('form-user-dept').value.trim(),
|
||
position: document.getElementById('form-user-position').value.trim(),
|
||
group: document.getElementById('form-user-group').value,
|
||
is_resigned: document.getElementById('form-user-resigned').value === 'true'
|
||
};
|
||
|
||
try {
|
||
await fetchAPI('/api/admin/users', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
alert('신규 사용자가 등록되었습니다.');
|
||
closeUserModal();
|
||
renderUsers();
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
function closeUserModal() {
|
||
document.getElementById('userModalOverlay').style.display = 'none';
|
||
}
|
||
|
||
async function submitEditUser(event, userId) {
|
||
event.preventDefault();
|
||
const payload = {
|
||
user_nm: document.getElementById('form-user-nm').value.trim(),
|
||
company: document.getElementById('form-user-company').value.trim(),
|
||
dept: document.getElementById('form-user-dept').value.trim(),
|
||
position: document.getElementById('form-user-position').value.trim(),
|
||
group: document.getElementById('form-user-group').value,
|
||
is_resigned: document.getElementById('form-user-resigned').value === 'true'
|
||
};
|
||
|
||
try {
|
||
await fetchAPI(`/api/admin/users/${userId}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
alert('사용자 정보가 수정되었습니다.');
|
||
closeUserModal();
|
||
renderUsers();
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
async function deleteUser(userId) {
|
||
if (confirm(`🚨 사용자 [${userId}]를 삭제하시겠습니까?\n프로젝트 현장 배정 정보가 아직 남아있을 경우 삭제가 차단됩니다.`)) {
|
||
try {
|
||
await fetchAPI(`/api/admin/users/${userId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
alert('사용자 계정 삭제되었습니다.');
|
||
if (selectedUserId === userId) {
|
||
selectedUserId = null;
|
||
document.getElementById('user-permission-title').innerText = '🔑 권한부여 프로젝트 목록';
|
||
document.getElementById('user-permission-desc').innerText = '좌측에서 사용자를 선택해 주세요.';
|
||
document.getElementById('user-permission-list-body').innerHTML = `<tr><td colspan="4" style="text-align: center; color: var(--text-light); padding: 30px 0;">사용자를 선택하시면 참여 중인 프로젝트 현장 목록이 표시됩니다.</td></tr>`;
|
||
}
|
||
renderUsers();
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- 7. 활동 로그 조회 탭 (Activity Logs) ---
|
||
async function renderAuditLogs() {
|
||
const userId = document.getElementById('search-log-user').value.trim();
|
||
const projectNm = document.getElementById('search-log-project').value.trim();
|
||
const action = document.getElementById('filter-log-action').value.trim();
|
||
|
||
const params = new URLSearchParams();
|
||
if (userId) params.append('user_id', userId);
|
||
if (projectNm) params.append('project_nm', projectNm);
|
||
if (action) params.append('activity', action);
|
||
|
||
let queryUrl = `/api/admin/audit-logs?${params.toString()}`;
|
||
|
||
try {
|
||
const logs = await fetchAPI(queryUrl);
|
||
const body = document.getElementById('audit-log-body');
|
||
body.innerHTML = '';
|
||
|
||
if (logs.length === 0) {
|
||
body.innerHTML = `<tr><td colspan="7" style="text-align: center; color: var(--text-light); padding: 24px 0;">조회된 활동 로그 내역이 없습니다.</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
logs.forEach((log, idx) => {
|
||
const tr = document.createElement('tr');
|
||
|
||
let cleanPath = log.clean_path || '-';
|
||
if (Array.isArray(log.criteria_info)) {
|
||
cleanPath = '/' + log.criteria_info.filter(p => p).join('/');
|
||
}
|
||
|
||
tr.innerHTML = `
|
||
<td>${idx + 1}</td>
|
||
<td>${log.clean_date ? log.clean_date.replace('T', ' ').substring(0, 19) : '-'}</td>
|
||
<td><strong>${log.project_nm || log.project_id || '-'}</strong></td>
|
||
<td>${log.user_id || '-'}</td>
|
||
<td>${log.user_ip || '-'}</td>
|
||
<td><span class="badge ${log.clean_path?.includes('delete') ? 'danger' : 'active'}" style="background-color: ${log.clean_path?.includes('delete') ? '#fee2e2' : '#eef8ee'}; color: ${log.clean_path?.includes('delete') ? '#ef4444' : '#4db251'};">${log.clean_path || '-'}</span></td>
|
||
<td><code style="word-break: break-all;">${cleanPath}</code></td>
|
||
`;
|
||
body.appendChild(tr);
|
||
});
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
// --- 8. 글로벌 삭제 정책 설정 탭 (Policies) ---
|
||
async function renderDeletePolicy() {
|
||
try {
|
||
const policy = await fetchAPI('/api/admin/system-policy');
|
||
|
||
document.getElementById('policy-active').value = policy.is_active ? 'true' : 'false';
|
||
document.getElementById('policy-limit-file-count').value = policy.limit_file_count;
|
||
document.getElementById('policy-limit-days').value = policy.limit_days;
|
||
|
||
updatePolicySummary();
|
||
|
||
const logs = await fetchAPI('/api/admin/system-policy/logs');
|
||
const body = document.getElementById('auto-delete-history-body');
|
||
body.innerHTML = '';
|
||
|
||
if (logs.length === 0) {
|
||
body.innerHTML = `<tr><td colspan="6" style="text-align: center; color: var(--text-light); padding: 24px 0;">자동 삭제 정기 실행 이력이 없습니다.</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
logs.forEach((l, idx) => {
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td>${idx + 1}</td>
|
||
<td>${l.clean_date ? l.clean_date.replace('T', ' ').substring(0, 19) : '-'}</td>
|
||
<td><strong>${l.project_id}</strong></td>
|
||
<td style="max-width: 250px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">${l.clean_path}</td>
|
||
<td>${l.criteria_info}</td>
|
||
<td><span class="badge ${l.result_status === 'SUCCESS' ? 'active' : 'danger'}">${l.result_status}</span></td>
|
||
`;
|
||
body.appendChild(tr);
|
||
});
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
function resetPolicyConfig() {
|
||
document.getElementById('policy-active').value = 'false';
|
||
document.getElementById('policy-limit-file-count').value = 100;
|
||
document.getElementById('policy-limit-days').value = 30;
|
||
updatePolicySummary();
|
||
}
|
||
|
||
function updatePolicySummary() {
|
||
const isActive = document.getElementById('policy-active').value === 'true';
|
||
const limitFileCount = document.getElementById('policy-limit-file-count').value;
|
||
const limitDays = document.getElementById('policy-limit-days').value;
|
||
|
||
const summaryEl = document.getElementById('policy-summary');
|
||
if (isActive) {
|
||
summaryEl.innerHTML = `<span style="color: var(--success); font-weight: 700;">[활성화 상태]</span> 현재 전체 공통 설정에 따라, 각 현장의 보관 파일 수가 <strong>${limitFileCount}개 미만</strong>이고 <strong>${limitDays}일</strong>이 지나면 자동 삭제 배치 스케줄러가 정기 작동합니다.`;
|
||
} else {
|
||
summaryEl.innerHTML = `<span style="color: var(--text-light); font-weight: 700;">[비활성화 상태]</span> 자동 삭제 배치가 구동되지 않습니다. (파일 자동 청소 방지 상태)`;
|
||
}
|
||
}
|
||
|
||
async function submitPolicyForm(event) {
|
||
event.preventDefault();
|
||
const payload = {
|
||
is_active: document.getElementById('policy-active').value === 'true',
|
||
limit_file_count: Number(document.getElementById('policy-limit-file-count').value),
|
||
limit_days: Number(document.getElementById('policy-limit-days').value)
|
||
};
|
||
|
||
try {
|
||
await fetchAPI('/api/admin/system-policy/update', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
alert('글로벌 보관 및 삭제 설정 정책이 업데이트되었습니다.');
|
||
renderDeletePolicy();
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
// --- 9. 공통 코드 관리 탭 (Common Codes) ---
|
||
async function renderCommonCodes() {
|
||
try {
|
||
const masters = await fetchAPI('/api/admin/common-codes/masters');
|
||
const body = document.getElementById('code-master-body');
|
||
body.innerHTML = '';
|
||
|
||
masters.forEach((m, idx) => {
|
||
const tr = document.createElement('tr');
|
||
tr.className = 'clickable' + (selectedCodeMasterId === m.main_code ? ' selected' : '');
|
||
tr.onclick = () => selectCodeMaster(m.main_code);
|
||
|
||
tr.innerHTML = `
|
||
<td>${idx + 1}</td>
|
||
<td><strong>${m.main_code}</strong></td>
|
||
<td>${m.main_code_nm}</td>
|
||
<td><span class="badge ${m.use_yn === 'Y' ? 'active' : 'inactive'}">${m.use_yn === 'Y' ? '사용' : '미사용'}</span></td>
|
||
<td>
|
||
<div class="action-btns" onclick="event.stopPropagation();">
|
||
<button class="btn btn-secondary btn-sm" onclick="openCodeMasterModal('edit', '${m.main_code}')">수정</button>
|
||
<button class="btn btn-danger btn-sm" onclick="deleteCodeMaster('${m.main_code}')">삭제</button>
|
||
</div>
|
||
</td>
|
||
`;
|
||
body.appendChild(tr);
|
||
});
|
||
|
||
if (selectedCodeMasterId) {
|
||
selectCodeMaster(selectedCodeMasterId);
|
||
} else {
|
||
document.getElementById('code-detail-title').innerText = '🔑 대분류 코드 선택 대기 중';
|
||
document.getElementById('code-detail-desc').innerText = '상단에서 대분류 코드를 선택해 주세요.';
|
||
document.getElementById('btn-show-code-detail-modal').setAttribute('disabled', 'true');
|
||
document.getElementById('code-detail-body').innerHTML = `<tr><td colspan="7" style="text-align: center; color: var(--text-light); padding: 30px 0;">상단에서 마스터 코드를 선택하시면 세부 소분류 코드 목록이 여기에 바인딩됩니다.</td></tr>`;
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
async function selectCodeMaster(mainCode) {
|
||
selectedCodeMasterId = mainCode;
|
||
|
||
// 행 하이라이트 갱신
|
||
document.querySelectorAll('#code-master-body tr').forEach(tr => {
|
||
tr.classList.remove('selected');
|
||
});
|
||
const activeTr = Array.from(document.querySelectorAll('#code-master-body tr')).find(tr => tr.innerHTML.includes(`<strong>${mainCode}</strong>`));
|
||
if (activeTr) activeTr.classList.add('selected');
|
||
|
||
document.getElementById('code-detail-title').innerText = `📑 세부 코드 목록 (대분류: ${mainCode})`;
|
||
document.getElementById('code-detail-desc').innerText = `선택된 대분류 [${mainCode}]의 하위 소분류 코드를 관리합니다.`;
|
||
document.getElementById('btn-show-code-detail-modal').removeAttribute('disabled');
|
||
|
||
// 소분류 렌더링 호출
|
||
renderCodeDetails(mainCode);
|
||
}
|
||
|
||
async function renderCodeDetails(mainCode) {
|
||
try {
|
||
const details = await fetchAPI(`/api/admin/common-codes/details/${mainCode}`);
|
||
const body = document.getElementById('code-detail-body');
|
||
body.innerHTML = '';
|
||
|
||
if (details.length === 0) {
|
||
body.innerHTML = `<tr><td colspan="7" style="text-align: center; color: var(--text-light); padding: 30px 0;">이 대분류 하위에 기속된 세부 소분류 코드가 없습니다.</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
details.forEach((d, idx) => {
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td>${idx + 1}</td>
|
||
<td><strong>${d.sub_code}</strong></td>
|
||
<td><code>${d.base_code}</code></td>
|
||
<td>${d.code_nm}</td>
|
||
<td>${d.sort_ord}</td>
|
||
<td><span class="badge ${d.use_yn === 'Y' ? 'active' : 'inactive'}">${d.use_yn === 'Y' ? '사용' : '미사용'}</span></td>
|
||
<td>
|
||
<div class="action-btns">
|
||
<button class="btn btn-secondary btn-sm" onclick="openCodeDetailModal('edit', '${d.sub_code}')">수정</button>
|
||
<button class="btn btn-danger btn-sm" onclick="deleteCodeDetail('${d.sub_code}')">삭제</button>
|
||
</div>
|
||
</td>
|
||
`;
|
||
body.appendChild(tr);
|
||
});
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
// 대분류 모달
|
||
async function openCodeMasterModal(mode, mainCode = null) {
|
||
const modal = document.getElementById('codeMasterModalOverlay');
|
||
modal.style.display = 'flex';
|
||
|
||
const form = document.getElementById('code-master-form');
|
||
form.reset();
|
||
|
||
if (mode === 'create') {
|
||
document.getElementById('code-master-modal-title').innerText = '🔑 신규 마스터 대분류 등록';
|
||
document.getElementById('form-master-code').removeAttribute('readonly');
|
||
document.getElementById('form-master-code').disabled = false;
|
||
document.getElementById('code-master-submit-btn').innerText = '등록 하기';
|
||
form.onsubmit = submitCreateCodeMaster;
|
||
} else {
|
||
document.getElementById('code-master-modal-title').innerText = '📝 대분류 코드 수정';
|
||
document.getElementById('form-master-code').setAttribute('readonly', 'true');
|
||
document.getElementById('form-master-code').disabled = true;
|
||
document.getElementById('code-master-submit-btn').innerText = '수정 하기';
|
||
|
||
try {
|
||
const masters = await fetchAPI('/api/admin/common-codes/masters');
|
||
const m = masters.find(item => item.main_code === mainCode);
|
||
if (m) {
|
||
document.getElementById('form-master-code').value = m.main_code;
|
||
document.getElementById('form-master-name').value = m.main_code_nm;
|
||
document.getElementById('form-master-useyn').value = m.use_yn;
|
||
document.getElementById('form-master-rmk').value = m.rmk || '';
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
|
||
form.onsubmit = (e) => submitEditCodeMaster(e, mainCode);
|
||
}
|
||
}
|
||
|
||
function closeCodeMasterModal() {
|
||
document.getElementById('codeMasterModalOverlay').style.display = 'none';
|
||
}
|
||
|
||
async function submitCreateCodeMaster(event) {
|
||
event.preventDefault();
|
||
const payload = {
|
||
main_code: document.getElementById('form-master-code').value.trim(),
|
||
main_code_nm: document.getElementById('form-master-name').value.trim(),
|
||
use_yn: document.getElementById('form-master-useyn').value,
|
||
rmk: document.getElementById('form-master-rmk').value.trim()
|
||
};
|
||
|
||
try {
|
||
await fetchAPI('/api/admin/common-codes/masters', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
alert('대분류 코드가 등록되었습니다.');
|
||
closeCodeMasterModal();
|
||
renderCommonCodes();
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
async function submitEditCodeMaster(event, mainCode) {
|
||
event.preventDefault();
|
||
const payload = {
|
||
main_code_nm: document.getElementById('form-master-name').value.trim(),
|
||
use_yn: document.getElementById('form-master-useyn').value,
|
||
rmk: document.getElementById('form-master-rmk').value.trim()
|
||
};
|
||
|
||
try {
|
||
await fetchAPI(`/api/admin/common-codes/masters/${mainCode}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
alert('대분류 코드 정보가 수정되었습니다.');
|
||
closeCodeMasterModal();
|
||
renderCommonCodes();
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
async function deleteCodeMaster(mainCode) {
|
||
if (confirm(`🚨 대분류 코드 [${mainCode}]를 삭제하시겠습니까?\n하위에 기속된 세부 소분류 코드가 존재할 시 삭제가 차단됩니다.`)) {
|
||
try {
|
||
await fetchAPI(`/api/admin/common-codes/masters/${mainCode}`, {
|
||
method: 'DELETE'
|
||
});
|
||
alert('대분류 코드가 정상적으로 삭제되었습니다.');
|
||
if (selectedCodeMasterId === mainCode) {
|
||
selectedCodeMasterId = null;
|
||
}
|
||
renderCommonCodes();
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 소분류 모달
|
||
async function openCodeDetailModal(mode, subCode = null) {
|
||
if (!selectedCodeMasterId) {
|
||
alert('상단 대분류 코드를 먼저 선택해 주세요.');
|
||
return;
|
||
}
|
||
|
||
const modal = document.getElementById('codeDetailModalOverlay');
|
||
modal.style.display = 'flex';
|
||
|
||
const form = document.getElementById('code-detail-form');
|
||
form.reset();
|
||
|
||
// 부모 대분류 코드 명시
|
||
document.getElementById('form-detail-maincode').value = selectedCodeMasterId;
|
||
|
||
if (mode === 'create') {
|
||
document.getElementById('code-detail-modal-title').innerText = '🔑 신규 세부 소분류 코드 등록';
|
||
document.getElementById('form-detail-subcode').removeAttribute('readonly');
|
||
document.getElementById('form-detail-subcode').disabled = false;
|
||
document.getElementById('code-detail-submit-btn').innerText = '등록 하기';
|
||
form.onsubmit = submitCreateCodeDetail;
|
||
} else {
|
||
document.getElementById('code-detail-modal-title').innerText = '📝 세부 소분류 코드 수정';
|
||
document.getElementById('form-detail-subcode').setAttribute('readonly', 'true');
|
||
document.getElementById('form-detail-subcode').disabled = true;
|
||
document.getElementById('code-detail-submit-btn').innerText = '수정 하기';
|
||
|
||
try {
|
||
const details = await fetchAPI(`/api/admin/common-codes/details/${selectedCodeMasterId}`);
|
||
const d = details.find(item => item.sub_code === subCode);
|
||
if (d) {
|
||
document.getElementById('form-detail-subcode').value = d.sub_code;
|
||
document.getElementById('form-detail-name').value = d.code_nm;
|
||
document.getElementById('form-detail-sort').value = d.sort_ord;
|
||
document.getElementById('form-detail-useyn').value = d.use_yn;
|
||
document.getElementById('form-detail-rmk').value = d.rmk || '';
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
|
||
form.onsubmit = (e) => submitEditCodeDetail(e, selectedCodeMasterId, subCode);
|
||
}
|
||
}
|
||
|
||
function closeCodeDetailModal() {
|
||
document.getElementById('codeDetailModalOverlay').style.display = 'none';
|
||
}
|
||
|
||
async function submitCreateCodeDetail(event) {
|
||
event.preventDefault();
|
||
const payload = {
|
||
main_code: selectedCodeMasterId,
|
||
sub_code: document.getElementById('form-detail-subcode').value.trim(),
|
||
code_nm: document.getElementById('form-detail-name').value.trim(),
|
||
sort_ord: Number(document.getElementById('form-detail-sort').value),
|
||
use_yn: document.getElementById('form-detail-useyn').value,
|
||
rmk: document.getElementById('form-detail-rmk').value.trim()
|
||
};
|
||
|
||
try {
|
||
await fetchAPI('/api/admin/common-codes/details', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
alert('세부 소분류 코드가 성공적으로 등록되었습니다.');
|
||
closeCodeDetailModal();
|
||
renderCodeDetails(selectedCodeMasterId);
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
async function submitEditCodeDetail(event, mainCode, subCode) {
|
||
event.preventDefault();
|
||
const payload = {
|
||
code_nm: document.getElementById('form-detail-name').value.trim(),
|
||
sort_ord: Number(document.getElementById('form-detail-sort').value),
|
||
use_yn: document.getElementById('form-detail-useyn').value,
|
||
rmk: document.getElementById('form-detail-rmk').value.trim()
|
||
};
|
||
|
||
try {
|
||
await fetchAPI(`/api/admin/common-codes/details/${mainCode}/${subCode}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
alert('세부 소분류 코드가 수정되었습니다.');
|
||
closeCodeDetailModal();
|
||
renderCodeDetails(mainCode);
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
async function deleteCodeDetail(subCode) {
|
||
if (confirm(`세부 소분류 코드 [${subCode}]를 삭제하시겠습니까?`)) {
|
||
try {
|
||
await fetchAPI(`/api/admin/common-codes/details/${selectedCodeMasterId}/${subCode}`, {
|
||
method: 'DELETE'
|
||
});
|
||
alert('세부 코드가 삭제되었습니다.');
|
||
renderCodeDetails(selectedCodeMasterId);
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- 9. 폴더별 권한 관리 탭 (Folder Permission) ---
|
||
let selectedFolderPermProjectId = null;
|
||
let selectedFolderPermPathKey = null;
|
||
let folderPermDataCache = {
|
||
folders: [],
|
||
folderPermissions: [],
|
||
users: []
|
||
};
|
||
|
||
async function initFolderPermissionTab() {
|
||
const selectEl = document.getElementById('folder-perm-project-select');
|
||
selectEl.innerHTML = '<option value="">-- 프로젝트 선택 --</option>';
|
||
|
||
try {
|
||
const projects = await fetchAPI('/api/admin/projects');
|
||
projects.forEach(p => {
|
||
const option = document.createElement('option');
|
||
option.value = p.project_id;
|
||
option.innerText = `[${p.project_id}] ${p.project_nm || p.task_nm_kr}`;
|
||
selectEl.appendChild(option);
|
||
});
|
||
|
||
selectedFolderPermProjectId = null;
|
||
selectedFolderPermPathKey = null;
|
||
document.getElementById('folder-tree-container').innerHTML = `
|
||
<div style="color: var(--text-light); text-align: center; padding: 40px 0;">프로젝트를 선택하시면 폴더 트리가 여기에 로드됩니다.</div>
|
||
`;
|
||
resetFolderUserPermissionTable();
|
||
} catch (err) {
|
||
console.error("initFolderPermissionTab error:", err);
|
||
}
|
||
}
|
||
|
||
async function loadFolderStructure() {
|
||
const selectEl = document.getElementById('folder-perm-project-select');
|
||
const projectId = selectEl.value;
|
||
selectedFolderPermProjectId = projectId;
|
||
selectedFolderPermPathKey = null;
|
||
resetFolderUserPermissionTable();
|
||
|
||
if (!projectId) {
|
||
document.getElementById('folder-tree-container').innerHTML = `
|
||
<div style="color: var(--text-light); text-align: center; padding: 40px 0;">프로젝트를 선택하시면 폴더 트리가 여기에 로드됩니다.</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
document.getElementById('folder-tree-container').innerHTML = `
|
||
<div style="color: var(--text-muted); text-align: center; padding: 40px 0;">폴더 데이터를 불러오는 중...</div>
|
||
`;
|
||
|
||
try {
|
||
const data = await fetchAPI(`/api/admin/permissions/folders/${projectId}`);
|
||
folderPermDataCache = data;
|
||
renderFolderTree();
|
||
} catch (err) {
|
||
console.error(err);
|
||
document.getElementById('folder-tree-container').innerHTML = `
|
||
<div style="color: red; text-align: center; padding: 40px 0;">데이터 로드 실패: ${err.message}</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
function renderFolderTree() {
|
||
const container = document.getElementById('folder-tree-container');
|
||
container.innerHTML = '';
|
||
|
||
const folders = folderPermDataCache.folders || [];
|
||
const folderPerms = folderPermDataCache.folderPermissions || [];
|
||
|
||
// 1. Build tree structure to preserve parent-child relation while sorting siblings
|
||
const depth1Map = {};
|
||
const depth2Map = {};
|
||
const rootNodes = [];
|
||
|
||
// data_depth 기준 오름차순 정렬하여 부모 노드가 맵에 먼저 등록되도록 보장
|
||
const sortedByDepthFolders = [...folders].sort((a, b) => Number(a.data_depth) - Number(b.data_depth));
|
||
|
||
sortedByDepthFolders.forEach(f => {
|
||
const node = {
|
||
folder: f,
|
||
children: [],
|
||
name: f.data_depth === 1 ? f.path1 : (f.data_depth === 2 ? f.path2 : f.path3),
|
||
pathKey: f.data_depth === 1 ? f.path1 : (f.data_depth === 2 ? `${f.path1}/${f.path2}` : `${f.path1}/${f.path2}/${f.path3}`)
|
||
};
|
||
|
||
if (f.data_depth === 1) {
|
||
depth1Map[f.path1] = node;
|
||
rootNodes.push(node);
|
||
} else if (f.data_depth === 2) {
|
||
depth2Map[`${f.path1}/${f.path2}`] = node;
|
||
const parent = depth1Map[f.path1];
|
||
if (parent) parent.children.push(node);
|
||
else rootNodes.push(node);
|
||
} else if (f.data_depth === 3) {
|
||
const parent = depth2Map[`${f.path1}/${f.path2}`];
|
||
if (parent) parent.children.push(node);
|
||
else rootNodes.push(node);
|
||
}
|
||
});
|
||
|
||
// 2. Sort siblings by data_permission (1 -> 4 -> 8 -> 0) then name
|
||
function getSortWeight(perm) {
|
||
const p = Number(perm);
|
||
if (p === 1) return 1; // 상속폴더 (Viewer)
|
||
if (p === 4) return 2; // 일반 (Worker)
|
||
if (p === 8) return 3; // 보안 (Security)
|
||
if (p === 0) return 4; // 관리 (Sub-Master)
|
||
return 5;
|
||
}
|
||
|
||
function sortNodes(nodeList) {
|
||
nodeList.sort((a, b) => {
|
||
const wa = getSortWeight(a.folder.data_permission);
|
||
const wb = getSortWeight(b.folder.data_permission);
|
||
if (wa !== wb) {
|
||
return wa - wb;
|
||
}
|
||
return a.name.localeCompare(b.name, 'ko');
|
||
});
|
||
|
||
nodeList.forEach(node => {
|
||
if (node.children.length > 0) {
|
||
sortNodes(node.children);
|
||
}
|
||
});
|
||
}
|
||
|
||
sortNodes(rootNodes);
|
||
|
||
// 3. Preorder traversal to flatten tree
|
||
const sortedFolders = [];
|
||
function traverse(nodeList) {
|
||
nodeList.forEach(node => {
|
||
sortedFolders.push(node.folder);
|
||
if (node.children.length > 0) {
|
||
traverse(node.children);
|
||
}
|
||
});
|
||
}
|
||
traverse(rootNodes);
|
||
|
||
if (sortedFolders.length === 0) {
|
||
container.innerHTML = `<div style="color: var(--text-muted); text-align: center; padding: 40px 0;">생성된 폴더가 없습니다.</div>`;
|
||
return;
|
||
}
|
||
|
||
const treeWrapper = document.createElement('div');
|
||
treeWrapper.style.display = 'flex';
|
||
treeWrapper.style.flexDirection = 'column';
|
||
treeWrapper.style.gap = '4px';
|
||
|
||
sortedFolders.forEach(f => {
|
||
let pathKey = f.path1;
|
||
if (f.data_depth === 2) pathKey = `${f.path1}/${f.path2}`;
|
||
else if (f.data_depth === 3) pathKey = `${f.path1}/${f.path2}/${f.path3}`;
|
||
|
||
const folderName = f.data_depth === 1 ? f.path1 : (f.data_depth === 2 ? f.path2 : f.path3);
|
||
const isInherited = !folderPerms.some(p => p.folder_path_key === pathKey);
|
||
|
||
const itemDiv = document.createElement('div');
|
||
itemDiv.style.display = 'flex';
|
||
itemDiv.style.alignItems = 'center';
|
||
itemDiv.style.padding = '8px 12px';
|
||
itemDiv.style.borderRadius = 'var(--radius-md)';
|
||
itemDiv.style.cursor = 'pointer';
|
||
itemDiv.style.marginLeft = `${(f.data_depth - 1) * 20}px`;
|
||
itemDiv.style.background = selectedFolderPermPathKey === pathKey ? 'var(--primary-soft)' : 'transparent';
|
||
itemDiv.style.border = selectedFolderPermPathKey === pathKey ? '1px solid var(--border)' : '1px solid transparent';
|
||
itemDiv.style.transition = 'var(--transition)';
|
||
|
||
itemDiv.innerHTML = `
|
||
<span style="margin-right: 6px;">📂</span>
|
||
<span style="font-size: 0.9rem; font-weight: 500; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${folderName}</span>
|
||
<span style="font-size: 0.75rem; color: ${isInherited ? '#4db251' : '#ff9800'}; border: 1px solid ${isInherited ? '#4db251' : '#ff9800'}; padding: 1px 5px; border-radius: var(--radius-sm); margin-left: 8px; flex-shrink: 0; font-weight: 600;">${isInherited ? '상속' : '설정'}</span>
|
||
<span style="font-size: 0.75rem; color: var(--text-muted); padding: 2px 6px; background: rgba(0,0,0,0.05); border-radius: var(--radius-sm); margin-left: 6px; flex-shrink: 0;">${f.data_depth}단계</span>
|
||
<span style="font-size: 0.85rem; color: var(--text-muted); margin-left: auto; padding-left: 10px; flex-shrink: 0; font-weight: 500;">${f.data_permission ?? 0}</span>
|
||
`;
|
||
|
||
itemDiv.onclick = () => {
|
||
selectedFolderPermPathKey = pathKey;
|
||
renderFolderTree();
|
||
renderFolderUserPermissions(pathKey);
|
||
};
|
||
|
||
treeWrapper.appendChild(itemDiv);
|
||
});
|
||
|
||
container.appendChild(treeWrapper);
|
||
}
|
||
|
||
function resetFolderUserPermissionTable() {
|
||
document.getElementById('folder-perm-detail-title').innerText = '👥 폴더별 사용자 권한 설정';
|
||
document.getElementById('folder-perm-detail-desc').innerText = '좌측 트리에서 폴더를 선택해 주세요.';
|
||
document.getElementById('folder-perm-user-body').innerHTML = `
|
||
<tr>
|
||
<td colspan="6" style="text-align: center; color: var(--text-light); padding: 40px 0;">폴더를 선택하시면 사용자의 개별 권한 지정 목록이 여기에 표시됩니다.</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
|
||
function renderFolderUserPermissions(pathKey) {
|
||
document.getElementById('folder-perm-detail-title').innerText = `📂 [${pathKey}] 권한 설정`;
|
||
document.getElementById('folder-perm-detail-desc').innerText = '이 폴더에 대한 사용자의 접근 등급을 설정합니다. (미설정 시 프로젝트 기본 권한 적용)';
|
||
|
||
const tbody = document.getElementById('folder-perm-user-body');
|
||
tbody.innerHTML = '';
|
||
|
||
const users = folderPermDataCache.users || [];
|
||
const folderPerms = folderPermDataCache.folderPermissions || [];
|
||
|
||
if (users.length === 0) {
|
||
tbody.innerHTML = `<tr><td colspan="6" style="text-align: center; color: var(--text-muted); padding: 20px 0;">이 프로젝트에 참여 중인 사용자가 없습니다.</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
users.forEach(u => {
|
||
const fp = folderPerms.find(p => p.folder_path_key === pathKey && p.user_id === u.user_id);
|
||
const currentLev = fp ? fp.lev : null;
|
||
|
||
const tr = document.createElement('tr');
|
||
|
||
const projectLevName = getPermissionLabel(u.project_lev);
|
||
const folderLevName = currentLev !== null ? getPermissionLabel(currentLev) : '상속 (Inherited)';
|
||
|
||
tr.innerHTML = `
|
||
<td><strong>${u.user_id}</strong></td>
|
||
<td>${u.user_nm}</td>
|
||
<td>${u.company} / ${u.dept} ${u.position}</td>
|
||
<td><span class="badge active" style="background: #e2e8f0; color: #475569;">${projectLevName}</span></td>
|
||
<td><span class="badge ${currentLev !== null ? (currentLev === 0 ? 'inactive' : 'active') : ''}">${folderLevName}</span></td>
|
||
<td>
|
||
<select class="select-input select-folder-lev" data-userid="${u.user_id}" style="padding: 4px; font-size: 0.85rem; border: 1px solid var(--border); border-radius: var(--radius-sm);">
|
||
<option value="inherit" ${currentLev === null ? 'selected' : ''}>상속 (Inherited)</option>
|
||
<option value="0" ${currentLev === 0 ? 'selected' : ''}>접근 차단 (Block)</option>
|
||
<option value="1" ${currentLev === 1 ? 'selected' : ''}>참관자 (Viewer)</option>
|
||
<option value="7" ${currentLev === 7 ? 'selected' : ''}>일반참여자 (Worker)</option>
|
||
<option value="191" ${currentLev === 191 ? 'selected' : ''}>부관리자 (Sub-Master)</option>
|
||
</select>
|
||
<button class="btn btn-primary btn-sm" onclick="saveFolderUserPermission('${u.user_id}', this)" style="padding: 3px 8px; font-size: 0.8rem; margin-left: 6px;">적용</button>
|
||
</td>
|
||
`;
|
||
|
||
tbody.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
function getPermissionLabel(lev) {
|
||
if (lev === 255) return '관리자 (Admin)';
|
||
if (lev === 191) return '부관리자 (Sub-Master)';
|
||
if (lev === 8) return '보안참여자 (Security)';
|
||
if (lev === 7) return '일반참여자 (Worker)';
|
||
if (lev === 4) return '일반참여자 (Worker)';
|
||
if (lev === 1) return '참관자 (Viewer)';
|
||
if (lev === 0) return '접근 차단 (Block)';
|
||
return `레벨 ${lev}`;
|
||
}
|
||
|
||
async function saveFolderUserPermission(userId, btnEl) {
|
||
const selectEl = btnEl.previousElementSibling;
|
||
const value = selectEl.value;
|
||
|
||
try {
|
||
if (value === 'inherit') {
|
||
await fetchAPI('/api/admin/permissions/folders/remove', {
|
||
method: 'DELETE',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
project_id: selectedFolderPermProjectId,
|
||
folder_path_key: selectedFolderPermPathKey,
|
||
user_id: userId
|
||
})
|
||
});
|
||
alert(`${userId}님의 폴더 권한이 제거되었으며, 프로젝트 기본 권한으로 초기화되었습니다.`);
|
||
} else {
|
||
await fetchAPI('/api/admin/permissions/folders/assign', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
project_id: selectedFolderPermProjectId,
|
||
folder_path_key: selectedFolderPermPathKey,
|
||
user_id: userId,
|
||
lev: Number(value)
|
||
})
|
||
});
|
||
alert(`${userId}님의 폴더 권한이 [${getPermissionLabel(Number(value))}] 등급으로 지정되었습니다.`);
|
||
}
|
||
|
||
const updatedData = await fetchAPI(`/api/admin/permissions/folders/${selectedFolderPermProjectId}`);
|
||
folderPermDataCache = updatedData;
|
||
renderFolderUserPermissions(selectedFolderPermPathKey);
|
||
} catch (err) {
|
||
console.error(err);
|
||
alert(`⚠️ 권한 저장에 실패하였습니다: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
// --- 10. 초기 로딩 ---
|
||
window.onload = function() {
|
||
loadUserProfile();
|
||
renderDashboard();
|
||
syncProjectDropdowns();
|
||
syncUserDropdowns();
|
||
};
|
||
</script>
|
||
</body>
|
||
</html>
|