Files
PM_test/views/admin/dashboard.html
2026-06-12 17:14:03 +09:00

2617 lines
121 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
</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: 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 검색...">
<select class="select-input" id="filter-log-action">
<option value="">모든 조작 액션</option>
<option value="delete">파일 삭제 (Delete)</option>
<option value="move">파일 이동 (Move)</option>
<option value="download">압축 다운로드 (Zip)</option>
</select>
<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()">&times;</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()">&times;</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()">&times;</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()">&times;</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()">&times;</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': '👥 사용자 관리',
'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 === '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);
}
}
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. 감사 로그 조회 탭 (Audit Logs) ---
async function renderAuditLogs() {
const userId = document.getElementById('search-log-user').value.trim();
const action = document.getElementById('filter-log-action').value;
let queryUrl = '/api/admin/audit-logs?';
if (userId) queryUrl += `user_id=${userId}&`;
if (action) queryUrl += `activity=${action}`;
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_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);
}
}
}
// --- 10. 초기 로딩 ---
window.onload = function() {
loadUserProfile();
renderDashboard();
syncProjectDropdowns();
syncUserDropdowns();
};
</script>
</body>
</html>