Files
2026-08-26 14:11:37 +07:00

1256 lines
63 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<script>
var savedTheme = localStorage.getItem('audit_theme');
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.setAttribute('data-theme', 'light');
}
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thống kê Máy chủ - VNPT</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg-main);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 20px;
position: relative;
overflow-x: hidden;
overflow-y: auto;
}
body::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background:
linear-gradient(135deg, transparent 0%, transparent 48%, rgba(33, 150, 243, 0.05) 48%, rgba(33, 150, 243, 0.05) 52%, transparent 52%, transparent 100%),
linear-gradient(45deg, transparent 0%, transparent 48%, rgba(33, 150, 243, 0.08) 48%, rgba(33, 150, 243, 0.08) 52%, transparent 52%, transparent 100%),
linear-gradient(135deg, transparent 0%, transparent 48%, rgba(33, 150, 243, 0.03) 48%, rgba(33, 150, 243, 0.03) 52%, transparent 52%, transparent 100%);
background-size: 300px 300px, 400px 400px, 500px 500px;
background-position: 0 0, 100px 100px, 200px 0;
z-index: 0;
pointer-events: none;
}
.container {
background: var(--card-bg);
border-radius: 20px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
max-width: 96%;
/* Widened for table */
width: 100%;
position: relative;
z-index: 1;
}
h1.header-title {
color: var(--primary-color);
text-align: center;
margin-bottom: 20px;
font-size: 28px;
font-weight: bold;
}
/* --- TABLE STYLES --- */
.table-container {
background: var(--card-bg);
border-radius: 12px;
box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.02);
padding: 10px;
margin-bottom: 30px;
overflow-x: auto;
border: 1px solid var(--border-color);
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th {
position: sticky;
top: 0;
z-index: 10;
background: var(--card-bg, #fff);
box-shadow: inset 0 0 0 9999px rgba(25, 118, 210, 0.1);
color: var(--primary-color);
padding: 16px 10px;
text-align: left;
font-weight: 600;
border-bottom: 2px solid #1976d2;
white-space: nowrap;
}
.table td {
padding: 16px 10px;
border-bottom: 1px solid var(--border-color-light);
color: var(--text-main);
vertical-align: middle;
}
.table tr:hover {
background: var(--table-header-bg);
}
/* --- BUTTONS --- */
.btn-detail {
padding: 8px 16px;
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: transform 0.2s, box-shadow 0.2s;
white-space: nowrap;
}
.btn-detail:hover {
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(25, 118, 210, 0.3);
}
/* --- MODAL STYLES --- */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: none;
/* hidden by default */
justify-content: center;
align-items: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.modal-content {
background: var(--card-bg);
border-radius: 20px;
padding: 30px;
width: 90%;
max-width: 1100px;
max-height: 90vh;
overflow-y: auto;
position: relative;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.close-btn {
position: absolute;
top: 20px;
right: 25px;
font-size: 30px;
font-weight: bold;
color: var(--text-muted);
cursor: pointer;
transition: color 0.3s;
line-height: 1;
}
.close-btn:hover {
color: #dc3545;
}
/* --- CHART & CARDS --- */
.chart-container {
position: relative;
height: 400px;
width: 100%;
border: 1px solid var(--border-color);
border-radius: 15px;
padding: 20px;
background: #fff;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.04);
margin-bottom: 30px;
}
.summary-cards {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.summary-card {
flex: 1;
padding: 15px;
border-radius: 12px;
background: #fff;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
text-align: center;
border-bottom: 4px solid #1976d2;
}
.summary-card .value {
font-size: 24px;
font-weight: bold;
color: var(--text-main);
margin-bottom: 5px;
}
.summary-card .label {
font-size: 14px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
}
</style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
/* --- SUB-TABS --- */
.sub-tab-bar {
display: flex;
gap: 8px;
margin-bottom: 24px;
background-color: var(--info-bg);
padding: 8px;
border-radius: 12px;
border: 1px solid #dce6ff;
}
.sub-tab-btn {
flex: 1;
padding: 12px 20px;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.25s ease;
background: transparent;
color: var(--text-main);
}
.sub-tab-btn.active {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
color: #fff;
box-shadow: 0 4px 12px rgba(25,118,210,0.3);
}
.sub-tab-btn:not(.active):hover {
background: rgba(25,118,210,0.08);
color: var(--primary-color);
}
.sub-tab-section { display: none; }
.sub-tab-section.active { display: block; }
/* --- REPORT SECTION (tab 2) --- */
.rpt-search-box {
width: 100%;
padding: 12px 16px 12px 42px;
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s;
}
.rpt-search-box:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(25,118,210,0.12); }
.rpt-search-wrap { position: relative; flex: 1; min-width: 240px; }
.rpt-search-wrap::before { content:'🔍'; position:absolute; left:13px; top:50%; transform:translateY(-50%); font-size:15px; }
.rpt-action-bar { display:flex; justify-content:space-between; align-items:center; padding:10px 14px; background: var(--table-header-bg); border-radius:8px; border: 1px solid var(--border-color); margin-bottom:10px; }
.rpt-stats { display:flex; gap:14px; font-size:13px; color: var(--text-muted); align-items:center; }
.rpt-badge { background:#e3eaff; color: var(--primary-color); padding:2px 10px; border-radius:10px; font-weight:700; font-size:12px; }
.rpt-badge.selected { background-color: var(--success-bg); color: var(--success-color); }
.rpt-table { width:100%; border-collapse:collapse; }
.rpt-table th { position: sticky; top: 0; z-index: 10; background: var(--card-bg, #fff); box-shadow: inset 0 0 0 9999px rgba(25,118,210,0.08); color: var(--primary-color); padding:13px 10px; text-align:left; font-weight:600; border-bottom:2px solid #1976d2; font-size:13px; white-space:nowrap; }
.rpt-table td { padding:12px 10px; border-bottom: 1px solid var(--border-color-light); color: var(--text-main); vertical-align:middle; font-size:13px; }
.rpt-table tbody tr:hover { background:#f4f7ff; cursor:pointer; }
.rpt-table tbody tr.rpt-selected { background:#e8f0fe; }
.rpt-chk { appearance:none; width:16px; height:16px; border:2px solid #bbb; border-radius:4px; cursor:pointer; position:relative; transition:all 0.18s; flex-shrink:0; }
.rpt-chk:checked { background:#1976d2; border-color: var(--primary-color); }
.rpt-chk:checked::after { content:'\2713'; position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); color:#fff; font-size:11px; font-weight:bold; }
.rpt-pct { display:inline-block; padding:3px 10px; border-radius:12px; font-weight:600; font-size:12px; }
.pct-g { background-color: var(--success-bg); color: var(--success-color); }
.pct-y { background-color: var(--warning-bg); color: var(--warning-color); }
.pct-r { background-color: var(--danger-bg); color: var(--danger-color); }
.btn-rpt-export { padding:10px 22px; background:linear-gradient(135deg,#2e7d32,#43a047); color:#fff; border:none; border-radius:8px; font-weight:600; font-size:14px; cursor:pointer; transition:all 0.2s; box-shadow:0 2px 6px rgba(46,125,50,0.25); }
.btn-rpt-export:hover:not(:disabled) { transform:translateY(-2px); box-shadow:0 4px 12px rgba(46,125,50,0.35); }
.btn-rpt-export:disabled { opacity:0.5; cursor:not-allowed; transform:none; box-shadow:none; }
.rpt-toast { position:fixed; bottom:28px; right:28px; padding:14px 22px; border-radius:12px; color:#fff; font-weight:500; z-index:2000; transform:translateY(80px); opacity:0; transition:all 0.4s cubic-bezier(0.175,0.885,0.32,1.275); display:flex; align-items:center; gap:10px; box-shadow:0 8px 24px rgba(0,0,0,0.18); }
.rpt-toast.show { transform:translateY(0); opacity:1; }
.rpt-toast.success { background:linear-gradient(135deg,#2e7d32,#43a047); }
.rpt-toast.error { background:linear-gradient(135deg,#c62828,#e53935); }
.rpt-toast.info { background:linear-gradient(135deg,#1565c0,#1976d2); }
.rpt-empty { text-align:center; padding:50px 20px; color: var(--text-muted); font-size:15px; }
.rpt-table-wrap { max-height:520px; overflow-y:auto; border: 1px solid var(--border-color); border-radius:10px; }
</style>
</head>
<body>
{% include 'header.html' %}
<div class="container">
<!-- Top Navigation Tabs -->
<div class="top-nav-tabs"
style="display: flex; justify-content: center; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; background: rgba(25, 118, 210, 0.05); padding: 15px; border-radius: 12px; border: 1px solid rgba(25, 118, 210, 0.1);">
<a href="{{ url_for('index') }}" class="nav-tab"
style="padding: 10px 20px; background: var(--card-bg); color: var(--text-main); text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 14px; transition: all 0.3s ease; border: 1px solid var(--border-color); box-shadow: 0 2px 5px rgba(0,0,0,0.05);">🏠 Trang chủ</a>
<a href="{{ url_for('my_files') }}" class="nav-tab"
style="padding: 10px 20px; background: var(--card-bg); color: var(--text-main); text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 14px; transition: all 0.3s ease; border: 1px solid var(--border-color); box-shadow: 0 2px 5px rgba(0,0,0,0.05);">📁 File của tôi</a>
{% if user_info and user_info.role == 'admin' %}
<a href="{{ url_for('admin_tools') }}" class="nav-tab"
style="padding: 10px 20px; background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%); color: white; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 14px; transition: all 0.3s ease; box-shadow: 0 4px 10px rgba(25, 118, 210, 0.3);">⚙️ Quản trị</a>
{% endif %}
</div>
{% if page_name == 'admin' %}
<!-- Admin Sub Navigation Tabs -->
<div class="admin-sub-tabs"
style="display: flex; justify-content: center; gap: 8px; margin-bottom: 25px; flex-wrap: wrap;">
<a href="{{ url_for('admin_tools') }}" class="sub-tab"
style="padding: 8px 16px; background-color: var(--info-bg); color: var(--primary-color); text-decoration: none; border-radius: 20px; font-weight: 600; font-size: 13px; transition: all 0.3s ease; border: 1px solid #bbdefb;">🔧 Cài đặt chung</a>
<a href="{{ url_for('admin_users_page') }}" class="sub-tab"
style="padding: 8px 16px; background-color: var(--info-bg); color: var(--primary-color); text-decoration: none; border-radius: 20px; font-weight: 600; font-size: 13px; transition: all 0.3s ease; border: 1px solid #bbdefb;">👥 Người dùng & Đơn vị</a>
<a href="{{ url_for('list_outputs') }}" class="sub-tab"
style="padding: 8px 16px; background-color: var(--info-bg); color: var(--primary-color); text-decoration: none; border-radius: 20px; font-weight: 600; font-size: 13px; transition: all 0.3s ease; border: 1px solid #bbdefb;">📂 Tất cả File</a>
<a href="{{ url_for('server_stats_page') }}" class="sub-tab"
style="padding: 8px 16px; background: #1565c0; color: white; text-decoration: none; border-radius: 20px; font-weight: 600; font-size: 13px; transition: all 0.3s ease; box-shadow: 0 2px 5px rgba(21, 101, 192, 0.3);">📊 Thống kê</a>
</div>
{% endif %}
<h1 class="header-title">📊 Thống Kê & Báo Cáo Máy Chủ</h1>
<!-- Sub-tab Switcher -->
<div class="sub-tab-bar">
<button class="sub-tab-btn active" id="btnTab1" onclick="switchTab('stats')">📊 Thống Kê Lịch Sử</button>
<button class="sub-tab-btn" id="btnTab2" onclick="switchTab('report')">📋 Báo Cáo Tổng Hợp</button>
<button class="sub-tab-btn" id="btnTab3" onclick="switchTab('billing')">💰 Thống Kê Sản Lượng</button>
</div>
<!-- ===================== SUB-TAB 1: THỐNG KÊ ===================== -->
<div class="sub-tab-section active" id="section-stats">
<!-- Toolbar Lọc & Xuất -->
<div class="filter-toolbar" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; background: var(--card-bg); padding: 15px 20px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.05); border: 1px solid var(--border-color);">
<div class="date-filters" style="display: flex; gap: 15px; align-items: center; flex-wrap: wrap;">
<div style="display: flex; align-items: center; gap: 8px;">
<label for="unitFilter1" style="font-weight: 600; color: var(--text-main);">Đơn vị:</label>
<select id="unitFilter1" onchange="applyDateFilter()" style="padding: 8px 12px; border: 1px solid var(--border-color); border-radius: 6px; font-family: inherit; font-weight: 500; cursor: pointer; max-width: 150px;">
<option value="">-- Tất cả --</option>
</select>
</div>
<div style="width: 1px; height: 25px; background: #ddd; margin: 0 5px;"></div>
<div style="display: flex; align-items: center; gap: 8px;">
<label for="quickFilter" style="font-weight: 600; color: var(--text-main);">Lọc nhanh:</label>
<select id="quickFilter" onchange="applyQuickFilter()" style="padding: 8px 12px; border: 1px solid var(--border-color); border-radius: 6px; font-family: inherit; font-weight: 500; cursor: pointer;">
<option value="custom">-- Tùy chỉnh --</option>
<option value="this_month">Tháng này</option>
<option value="last_month">Tháng trước</option>
<option value="this_quarter">Quý này</option>
<option value="last_quarter">Quý trước</option>
<option value="this_year">Năm nay</option>
<option value="last_year">Năm trước</option>
</select>
</div>
<div style="width: 1px; height: 25px; background: #ddd; margin: 0 5px;"></div>
<div style="display: flex; align-items: center; gap: 8px;">
<label for="startDate" style="font-weight: 600; color: var(--text-main);">Từ ngày:</label>
<input type="text" id="startDate" class="flatpickr-input" placeholder="Chọn ngày..." style="padding: 8px 12px; border: 1px solid var(--border-color); border-radius: 6px; width: 130px; font-family: inherit;" onchange="document.getElementById('quickFilter').value='custom'">
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<label for="endDate" style="font-weight: 600; color: var(--text-main);">Đến ngày:</label>
<input type="text" id="endDate" class="flatpickr-input" placeholder="Chọn ngày..." style="padding: 8px 12px; border: 1px solid var(--border-color); border-radius: 6px; width: 130px; font-family: inherit;" onchange="document.getElementById('quickFilter').value='custom'">
</div>
<button onclick="applyDateFilter()" style="padding: 8px 20px; background: #28a745; color: white; border: none; border-radius: 6px; font-weight: 600; cursor: pointer; transition: 0.2s;">Lọc KQ</button>
<button onclick="clearDateFilter()" style="padding: 8px 15px; background: var(--table-header-bg); color: var(--text-muted); border: 1px solid var(--border-color); border-radius: 6px; font-weight: 600; cursor: pointer; transition: 0.2s;">Xóa</button>
</div>
<div class="actions">
<button id="exportExcelBtn" onclick="exportStatsExcel()" style="padding: 10px 20px; background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%); color: white; border: none; border-radius: 6px; font-weight: 600; cursor: pointer; transition: 0.2s; box-shadow: 0 2px 5px rgba(25, 118, 210, 0.3);">📥 Xuất Báo Cáo Excel</button>
</div>
</div>
<!-- Table List of Servers -->
<div class="table-container">
<table class="table" id="serversTable">
<thead>
<tr>
<th style="text-align: center; width: 4%;">STT</th>
<th style="width: 19%;">Tên Máy Chủ</th>
<th style="width: 10%;">Đơn Vị</th>
<th style="width: 10%;">Địa Chỉ IP</th>
<th style="width: 19%;">Hệ Điều Hành</th>
<th style="text-align: center; width: 7%;">Số Lần Quét</th>
<th style="text-align: center; width: 7%;">Điểm gần nhất</th>
<th style="text-align: center; width: 8%; white-space: normal; vertical-align: middle;">Tỉ lệ Tuân thủ<br>gần nhất</th>
<th style="text-align: center; width: 8%; white-space: normal; vertical-align: middle;">Tiêu chí<br>bắt buộc</th>
<th style="text-align: center; width: 8%;">Thao Tác</th>
</tr>
</thead>
<tbody id="serversTableBody">
<!-- Populated via Javascript -->
</tbody>
</table>
</div>
</div><!-- end section-stats -->
<!-- ===================== SUB-TAB 2: BÁO CÁO TỔNG HỢP ===================== -->
<div class="sub-tab-section" id="section-report">
<!-- Tìm kiếm -->
<div style="background: var(--card-bg); border-radius:12px; box-shadow:0 4px 15px rgba(0,0,0,0.05); border: 1px solid var(--border-color); padding:20px; margin-bottom:18px;">
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<select id="unitFilterTab2" class="rpt-search-box" style="width: auto; padding-left: 10px; padding-right: 10px;" onchange="filterRptFiles(document.getElementById('rptSearchInput').value.trim())">
<option value="">-- Tất cả đơn vị --</option>
</select>
<div class="rpt-search-wrap">
<input type="text" id="rptSearchInput" class="rpt-search-box" placeholder="Tìm theo tên máy chủ, địa chỉ IP, hệ điều hành..." autocomplete="off">
</div>
<div class="rpt-stats">
<span>📁 Tổng: <span class="rpt-badge" id="rptTotal">0</span></span>
<span>🔎 Hiển thị: <span class="rpt-badge" id="rptFiltered">0</span></span>
<span>✅ Đã chọn: <span class="rpt-badge selected" id="rptSelected">0</span></span>
</div>
</div>
</div>
<!-- Action bar + bảng -->
<div style="background: var(--card-bg); border-radius:12px; box-shadow:0 4px 15px rgba(0,0,0,0.05); border: 1px solid var(--border-color); padding:20px;">
<div class="rpt-action-bar">
<div style="display:flex; align-items:center; gap:10px;">
<input type="checkbox" class="rpt-chk" id="rptSelectAll" title="Chọn tất cả">
<label for="rptSelectAll" style="color: var(--text-main); font-size:13px; cursor:pointer;">Chọn tất cả</label>
</div>
<button class="btn-rpt-export" id="btnRptExport" onclick="generateRptReport()" disabled>📥 Xuất báo cáo tổng hợp</button>
</div>
<div class="rpt-table-wrap" id="rptTableWrap">
<div class="rpt-empty" id="rptLoading">⏳ Đang tải danh sách file...</div>
<table class="rpt-table" id="rptTable" style="display:none;">
<thead>
<tr>
<th style="width:36px;"></th>
<th>Tên máy chủ</th>
<th>Đơn vị</th>
<th>Hệ điều hành</th>
<th>Địa chỉ IP</th>
<th style="text-align:center;">Tỉ lệ đạt</th>
<th style="text-align:center;">Bắt buộc</th>
<th>Ngày tạo</th>
</tr>
</thead>
<tbody id="rptTableBody"></tbody>
</table>
<div class="rpt-empty" id="rptEmpty" style="display:none;">📭 Không tìm thấy file kết quả nào.</div>
</div>
</div>
</div><!-- end section-report -->
<!-- ===================== SUB-TAB 3: THỐNG KÊ SẢN LƯỢNG ===================== -->
<div class="sub-tab-section" id="section-billing">
<div style="background: var(--card-bg); border-radius:12px; box-shadow:0 4px 15px rgba(0,0,0,0.05); border: 1px solid var(--border-color); padding:25px; margin-bottom:20px;">
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:15px; margin-bottom: 20px; border-bottom: 2px solid var(--primary-color); padding-bottom: 12px;">
<h2 style="color: var(--primary-color); font-size: 22px; font-weight: bold;">3.3. Dịch vụ kiểm định hệ thống</h2>
<div style="display:flex; gap:15px; align-items:center; flex-wrap:wrap;">
<div style="display:flex; align-items:center; gap:8px;">
<label style="font-weight:600; color:var(--text-main);">Năm thống kê:</label>
<select id="billingYearFilter" class="rpt-search-box" style="width:auto; padding:6px 12px;" onchange="renderBillingTable()">
<option value="">-- Tất cả các năm --</option>
</select>
</div>
<button class="btn-rpt-export" id="btnBillingExport" onclick="exportBillingExcel()">📥 Xuất Excel</button>
</div>
</div>
<div style="display:flex; align-items:center; gap:10px; margin-bottom: 20px; font-size:15px;">
<span style="font-weight:600; color:var(--text-main);">Đơn giá /thiết bị (VNĐ):</span>
<input type="text" id="billingUnitPrice" value="2,000,000" style="padding:6px 12px; border:1px solid var(--border-color); border-radius:6px; width:140px; font-weight:bold; color:#d32f2f; text-align:right;" oninput="onBillingPriceChange(this)">
</div>
<div class="rpt-table-wrap" style="max-height:none; overflow:visible;">
<table class="rpt-table" id="billingTable" style="width:100%; border-collapse:collapse;">
<thead id="billingTableHead"></thead>
<tbody id="billingTableBody"></tbody>
<tfoot id="billingTableFoot"></tfoot>
</table>
</div>
</div>
</div><!-- end section-billing -->
</div><!-- end container -->
<!-- Toast -->
<div class="rpt-toast" id="rptToast"></div>
<!-- MODAL POPUP CHO BIỂU ĐỒ -->
<div id="chartModal" class="modal-overlay" onclick="handleOutsideClick(event)">
<div class="modal-content">
<span class="close-btn" onclick="closeModal()" title="Đóng">&times;</span>
<h2 id="modalHostTitle"
style="color: var(--primary-color); margin-bottom: 20px; font-size: 24px; border-bottom: 2px dashed #eee; padding-bottom: 10px;">
Chi tiết Máy chủ:
</h2>
<div class="summary-cards" id="cardsArea">
<div class="summary-card" style="border-bottom-color: #28a745;">
<div class="value" id="cardPassed">-</div>
<div class="label" style="color: #28a745;">Điểm Đạt Gần Nhất</div>
</div>
<div class="summary-card" style="border-bottom-color: var(--primary-color);">
<div class="value" id="cardCompliance">-</div>
<div class="label" style="color: var(--primary-color);">Tỷ lệ Hiện Tại</div>
</div>
<div class="summary-card" style="border-bottom-color: #ff9800;">
<div class="value" id="cardFailed">-</div>
<div class="label" style="color: #e65100;">Tỉ lệ đạt Tiêu chí bắt buộc</div>
</div>
</div>
<div class="chart-container">
<canvas id="complianceChart"></canvas>
</div>
</div>
</div>
<script>
const rawHostsData = {{ hosts_data | safe }};
let hostsData = JSON.parse(JSON.stringify(rawHostsData));
let myChart = null;
// URLs cho báo cáo tổng hợp (lấy từ Jinja2 để đúng prefix)
const RPT_API_URL = "{{ url_for('api_admin_output_files') }}";
const RPT_GENERATE_URL = "{{ url_for('admin_generate_report') }}";
const BILLING_GENERATE_URL = "{{ url_for('admin_generate_billing_report') }}";
window.onload = function () {
// Khởi tạo thư viện Datepicker
flatpickr("#startDate", { dateFormat: "d-m-Y" });
flatpickr("#endDate", { dateFormat: "d-m-Y" });
// Build unit dropdown for Tab 1
const units1 = new Set();
for (const host in rawHostsData) {
const dataPoints = rawHostsData[host];
if (dataPoints && dataPoints.length > 0) {
const mappedUnit = dataPoints.slice().reverse().find(d => d.unit_name && d.unit_name !== '-')?.unit_name || dataPoints[dataPoints.length - 1].unit_name;
if (mappedUnit && mappedUnit !== '-') units1.add(mappedUnit);
}
}
const unitSelect1 = document.getElementById('unitFilter1');
Array.from(units1).sort().forEach(u => {
const opt = document.createElement('option');
opt.value = u; opt.textContent = u;
unitSelect1.appendChild(opt);
});
renderTable(hostsData);
};
function parseDateString(dateStr) {
if (!dateStr) return new Date(0);
const parts = dateStr.split(" ");
const dateParts = parts[0].split("/");
return new Date(dateParts[2], dateParts[1] - 1, dateParts[0]);
}
function parseFlatpickrDate(dateStr) {
if (!dateStr) return null;
const parts = dateStr.split("-");
return new Date(parts[2], parts[1] - 1, parts[0]);
}
function applyQuickFilter() {
const mode = document.getElementById('quickFilter').value;
if (mode === 'custom') return;
const now = new Date();
let start = new Date();
let end = new Date();
if (mode === 'this_month') {
start = new Date(now.getFullYear(), now.getMonth(), 1);
end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
} else if (mode === 'last_month') {
start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
end = new Date(now.getFullYear(), now.getMonth(), 0);
} else if (mode === 'this_quarter') {
const quarter = Math.floor(now.getMonth() / 3);
start = new Date(now.getFullYear(), quarter * 3, 1);
end = new Date(now.getFullYear(), quarter * 3 + 3, 0);
} else if (mode === 'last_quarter') {
const quarter = Math.floor(now.getMonth() / 3) - 1;
start = new Date(now.getFullYear(), quarter * 3, 1);
end = new Date(now.getFullYear(), quarter * 3 + 3, 0);
} else if (mode === 'this_year') {
start = new Date(now.getFullYear(), 0, 1);
end = new Date(now.getFullYear(), 11, 31);
} else if (mode === 'last_year') {
start = new Date(now.getFullYear() - 1, 0, 1);
end = new Date(now.getFullYear() - 1, 11, 31);
}
document.getElementById('startDate')._flatpickr.setDate(start);
document.getElementById('endDate')._flatpickr.setDate(end);
applyDateFilter();
}
function applyDateFilter() {
const startVal = document.getElementById('startDate').value;
const endVal = document.getElementById('endDate').value;
const unitVal = document.getElementById('unitFilter1').value;
if (!startVal && !endVal && !unitVal) {
hostsData = JSON.parse(JSON.stringify(rawHostsData));
renderTable(hostsData);
return;
}
const startDate = parseFlatpickrDate(startVal);
const endDate = parseFlatpickrDate(endVal);
if (endDate) endDate.setHours(23, 59, 59, 999);
let newData = {};
for (const host in rawHostsData) {
const points = rawHostsData[host];
const latest = points[points.length - 1];
const mappedUnit = points.slice().reverse().find(d => d.unit_name && d.unit_name !== '-')?.unit_name || latest.unit_name || '';
if (unitVal && mappedUnit !== unitVal) continue;
const filteredPoints = points.filter(d => {
const dDate = parseDateString(d.date);
if (startDate && dDate < startDate) return false;
if (endDate && dDate > endDate) return false;
return true;
});
if (filteredPoints.length > 0) {
newData[host] = filteredPoints;
}
}
hostsData = newData;
renderTable(hostsData);
}
function clearDateFilter() {
document.getElementById('quickFilter').value = 'custom';
document.getElementById('unitFilter1').value = '';
document.getElementById('startDate')._flatpickr.clear();
document.getElementById('endDate')._flatpickr.clear();
hostsData = JSON.parse(JSON.stringify(rawHostsData));
renderTable(hostsData);
}
function exportStatsExcel() {
const startVal = document.getElementById('startDate').value;
const endVal = document.getElementById('endDate').value;
const unitVal = document.getElementById('unitFilter1').value;
let url = '/export_server_stats?t=' + new Date().getTime();
if (startVal) url += '&start_date=' + encodeURIComponent(startVal);
if (endVal) url += '&end_date=' + encodeURIComponent(endVal);
if (unitVal) url += '&unit=' + encodeURIComponent(unitVal);
window.location.href = url;
}
// Populate the Server Data Table
function renderTable(dataObj) {
const tbody = document.getElementById('serversTableBody');
tbody.innerHTML = '';
let stt = 1;
// Render table rows dynamically from dataObj
for (const host in dataObj) {
const dataPoints = dataObj[host];
if (!dataPoints || dataPoints.length === 0) continue;
// Get highest score ever
const passes = dataPoints.map(d => d.passed);
const maxPass = Math.max(...passes);
// Get latest status (assuming chronological order from DB)
const latest = dataPoints[dataPoints.length - 1];
const mappedUnit = dataPoints.slice().reverse().find(d => d.unit_name && d.unit_name !== '-')?.unit_name || latest.unit_name || '-';
// Determine Badge colors
let compColor = '#28a745';
if (latest.compliance < 50) compColor = '#dc3545';
else if (latest.compliance < 80) compColor = '#ffc107';
let compTextColor = compColor === '#ffc107' ? '#333' : 'white';
// Mandatory Percentage flag
const mandScan = dataPoints.slice().reverse().find(d => (d.mandatory_total || 0) > 0) || latest;
let mandPassed = mandScan.mandatory_passed || 0;
let mandTotal = mandScan.mandatory_total || 0;
let mandColor = '#666'; // default if empty
if (mandTotal > 0) {
if (mandPassed >= mandTotal) {
mandColor = '#28a745'; // green
} else {
mandColor = '#dc3545'; // red
}
}
let mandatoryStr = `<span style="font-weight:bold; color:${mandColor};">${mandPassed}/${mandTotal}</span>`;
const tr = document.createElement('tr');
tr.innerHTML = `
<td style="font-weight:bold; color: var(--text-muted); text-align: center;">${stt++}</td>
<td style="font-weight:600; color: var(--primary-color); font-size: 16px;">${host}</td>
<td style="font-weight:500; color: var(--text-main);">${mappedUnit || '-'}</td>
<td style="font-size: 15px; color: var(--text-main);">${latest.ip || 'N/A'}</td>
<td>${latest.os || 'N/A'}</td>
<td style="text-align:center; font-size: 15px;">📥 ${dataPoints.length} bản</td>
<td style="text-align:center; font-weight:bold; color: var(--primary-color);">${latest.passed} / ${latest.total}</td>
<td style="text-align:center;">
<span style="background:${compColor}; color:${compTextColor}; padding: 6px 14px; border-radius: 20px; font-weight:bold; border: 1px solid rgba(0,0,0,0.1); display: inline-block;">
${latest.compliance}%
</span>
</td>
<td style="text-align:center;">${mandatoryStr}</td>
<td style="text-align:center;">
<button class="btn-detail" onclick="openModal('${host}')">📈 Xem Biểu Đồ</button>
</td>
`;
tbody.appendChild(tr);
}
// Handle Empty State
if (stt === 1) {
const tr = document.createElement('tr');
tr.innerHTML = `<td colspan="9" style="text-align:center; padding: 30px; color: var(--text-muted); font-size: 16px;">Không có dữ liệu máy chủ trong thời gian này!</td>`;
tbody.appendChild(tr);
}
}
// Modal Functionality
function openModal(hostName) {
const modal = document.getElementById('chartModal');
document.getElementById('modalHostTitle').innerHTML = `Chi tiết Máy chủ: <span style="color: #ff6b6b;">${hostName}</span>`;
modal.style.display = 'flex';
// Prevent scrolling on background
document.body.style.overflow = 'hidden';
renderChartForHost(hostName);
}
function closeModal() {
const modal = document.getElementById('chartModal');
modal.style.display = 'none';
document.body.style.overflow = 'auto'; // Restore scroll
}
function handleOutsideClick(e) {
if (e.target === document.getElementById('chartModal')) {
closeModal();
}
}
// Render specific Chart instance for the given Host inside the Modal
function renderChartForHost(selectedHost) {
const ctx = document.getElementById('complianceChart').getContext('2d');
if (myChart) {
myChart.destroy();
}
const dataPoints = hostsData[selectedHost];
if (!dataPoints) return;
const labels = dataPoints.map(item => item.date);
const complianceData = dataPoints.map(item => item.compliance);
const passedData = dataPoints.map(item => item.passed);
// Cập nhật thông số tóm tắt Latest
const latest = dataPoints[dataPoints.length - 1];
document.getElementById('cardPassed').innerText = latest.passed + ' / ' + latest.total;
document.getElementById('cardCompliance').innerText = latest.compliance + '%';
const mandLatest = dataPoints.slice().reverse().find(d => (d.mandatory_total || 0) > 0) || latest;
let mandPctStr = 'N/A';
if (mandLatest && (mandLatest.mandatory_total || 0) > 0) {
let mp = mandLatest.mandatory_passed || 0;
let mt = mandLatest.mandatory_total || 0;
let pct = (mt > 0) ? (Math.round((mp / mt) * 1000) / 10) : (mandLatest.mandatory_percentage || 0);
mandPctStr = pct + '%';
}
document.getElementById('cardFailed').innerText = mandPctStr;
const gradient = ctx.createLinearGradient(0, 0, 0, 400);
gradient.addColorStop(0, 'rgba(25, 118, 210, 0.5)');
gradient.addColorStop(1, 'rgba(25, 118, 210, 0.0)');
myChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Tỷ lệ Vượt qua Tuân thủ (%)',
data: complianceData,
borderColor: '#1976d2',
backgroundColor: gradient,
borderWidth: 3,
fill: true,
tension: 0.3,
pointBackgroundColor: '#fff',
pointBorderColor: '#1976d2',
pointBorderWidth: 2,
pointRadius: 5,
pointHoverRadius: 7
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: { position: 'top', labels: { font: { size: 14 } } },
tooltip: {
backgroundColor: 'rgba(0,0,0,0.8)',
titleFont: { size: 16 },
bodyFont: { size: 14 },
callbacks: {
afterBody: function (context) {
const index = context[0].dataIndex;
const item = dataPoints[index];
return [
'',
`Lần quét: ${item.date}`,
`Hệ điều hành: ${item.os}`,
`Pass: ${item.passed}`,
`Fail: ${item.failed}`,
`Tổng Tiêu Chí: ${item.total}`
];
}
}
}
},
scales: {
y: {
min: 0, max: 100,
ticks: { callback: function (value) { return value + '%'; } },
title: { display: true, text: 'Phần trăm Compliance (%)', font: { weight: 'bold' } }
},
x: {
title: { display: true, text: 'Trục Thời Gian Các Lần Quét', font: { weight: 'bold' } }
}
}
}
});
}
// ===== SUB-TAB SWITCHER =====
function switchTab(tab) {
document.querySelectorAll('.sub-tab-section').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.sub-tab-btn').forEach(b => b.classList.remove('active'));
const sec = document.getElementById('section-' + tab);
if (sec) sec.classList.add('active');
const btn = document.getElementById(tab === 'stats' ? 'btnTab1' : (tab === 'report' ? 'btnTab2' : 'btnTab3'));
if (btn) btn.classList.add('active');
if ((tab === 'report' || tab === 'billing') && rptAllFiles.length === 0) {
loadRptFiles().then(() => {
if (tab === 'billing') renderBillingTable();
});
} else if (tab === 'billing') {
renderBillingTable();
}
}
// ===== BÁO CÁO TỔNG HỢP LOGIC =====
let rptAllFiles = [];
let rptFilteredFiles = [];
let rptSelectedFiles = new Set();
let rptSearchTimer = null;
document.addEventListener('DOMContentLoaded', () => {
const si = document.getElementById('rptSearchInput');
if (si) si.addEventListener('input', e => {
clearTimeout(rptSearchTimer);
rptSearchTimer = setTimeout(() => filterRptFiles(e.target.value.trim()), 280);
});
const sa = document.getElementById('rptSelectAll');
if (sa) sa.addEventListener('change', onRptSelectAll);
});
async function loadRptFiles() {
document.getElementById('rptLoading').style.display = 'block';
document.getElementById('rptTable').style.display = 'none';
document.getElementById('rptEmpty').style.display = 'none';
try {
const res = await fetch(RPT_API_URL);
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
rptAllFiles = data;
// Populate unit filter for Tab 2
const units2 = new Set();
data.forEach(f => { if (f.unit_name) units2.add(f.unit_name); });
const unitSelect2 = document.getElementById('unitFilterTab2');
unitSelect2.innerHTML = '<option value="">-- Tất cả đơn vị --</option>';
Array.from(units2).sort().forEach(u => {
const opt = document.createElement('option');
opt.value = u; opt.textContent = u;
unitSelect2.appendChild(opt);
});
document.getElementById('rptTotal').textContent = data.length;
filterRptFiles(document.getElementById('rptSearchInput').value.trim());
} catch (err) {
document.getElementById('rptLoading').innerHTML = '❌ Lỗi tải dữ liệu: ' + err.message;
}
}
function filterRptFiles(q) {
const unitVal = document.getElementById('unitFilterTab2').value;
rptFilteredFiles = rptAllFiles.filter(f => {
if (unitVal && f.unit_name !== unitVal) return false;
if (!q) return true;
const ql = q.toLowerCase();
return (f.hostname||'').toLowerCase().includes(ql) ||
(f.ip_address||'').toLowerCase().includes(ql) ||
(f.os_name||'').toLowerCase().includes(ql) ||
(f.filename||'').toLowerCase().includes(ql);
});
document.getElementById('rptFiltered').textContent = rptFilteredFiles.length;
renderRptTable(rptFilteredFiles);
}
function renderRptTable(files) {
document.getElementById('rptLoading').style.display = 'none';
if (files.length === 0) {
document.getElementById('rptTable').style.display = 'none';
document.getElementById('rptEmpty').style.display = 'block';
return;
}
document.getElementById('rptTable').style.display = 'table';
document.getElementById('rptEmpty').style.display = 'none';
const tbody = document.getElementById('rptTableBody');
tbody.innerHTML = '';
files.forEach(f => {
const sel = rptSelectedFiles.has(f.filename);
const pct = f.compliance_percentage || 0;
const pctCls = pct >= 80 ? 'pct-g' : pct >= 50 ? 'pct-y' : 'pct-r';
let mp = f.mandatory_passed || 0;
let mt = f.mandatory_total || 0;
let mf = f.mandatory_failed || 0;
if (mt === 0 && f.hostname) {
const fb = rptAllFiles.find(x => x.hostname === f.hostname && (x.mandatory_total || 0) > 0);
if (fb) { mp = fb.mandatory_passed||0; mt = fb.mandatory_total||0; mf = fb.mandatory_failed||0; }
}
const mandIcon = (mt > 0 && mp >= mt) ? '✅' : (mf > 0 ? (mf > 3 ? '❌' : '⚠️') : '✅');
const mandStr = mt > 0 ? `${mandIcon} ${mp}/${mt}` : `<span style="color:#999;">0/0</span>`;
const tr = document.createElement('tr');
if (sel) tr.classList.add('rpt-selected');
tr.dataset.fn = f.filename;
tr.innerHTML = `
<td><input type="checkbox" class="rpt-chk rpt-file-chk" data-fn="${f.filename}" ${sel ? 'checked' : ''}></td>
<td style="font-weight:600;color: var(--primary-color);">${f.hostname || f.filename}</td>
<td><span style="background-color: var(--info-bg);color: var(--primary-color);padding:2px 8px;border-radius:6px;font-size:12px;font-weight:bold;">${f.unit_name || '-'}</span></td>
<td><span style="background-color: var(--info-bg);color: var(--text-main);padding:2px 8px;border-radius:6px;font-size:12px;">${f.os_name || '-'}</span></td>
<td style="font-family:monospace;color: var(--text-main);">${f.ip_address || '-'}</td>
<td style="text-align:center;"><span class="rpt-pct ${pctCls}">${pct}%</span></td>
<td style="text-align:center;">${mandStr}</td>
<td style="font-size:12px;color: var(--text-muted);">${f.file_date || '-'}</td>
`;
tr.addEventListener('click', e => {
if (e.target.type === 'checkbox') return;
const cb = tr.querySelector('.rpt-file-chk');
cb.checked = !cb.checked;
toggleRptFile(f.filename, cb.checked, tr);
});
tr.querySelector('.rpt-file-chk').addEventListener('change', e => {
toggleRptFile(f.filename, e.target.checked, tr);
});
tbody.appendChild(tr);
});
updateRptSelectAllState();
}
function toggleRptFile(fn, selected, row) {
if (selected) { rptSelectedFiles.add(fn); row.classList.add('rpt-selected'); }
else { rptSelectedFiles.delete(fn); row.classList.remove('rpt-selected'); }
updateRptUI();
}
function updateRptUI() {
document.getElementById('rptSelected').textContent = rptSelectedFiles.size;
const btn = document.getElementById('btnRptExport');
btn.disabled = rptSelectedFiles.size === 0;
btn.textContent = rptSelectedFiles.size > 0
? `📥 Xuất báo cáo (${rptSelectedFiles.size} file)`
: '📥 Xuất báo cáo tổng hợp';
updateRptSelectAllState();
}
function onRptSelectAll(e) {
document.querySelectorAll('.rpt-file-chk').forEach(cb => {
cb.checked = e.target.checked;
const row = cb.closest('tr');
if (e.target.checked) { rptSelectedFiles.add(cb.dataset.fn); row.classList.add('rpt-selected'); }
else { rptSelectedFiles.delete(cb.dataset.fn); row.classList.remove('rpt-selected'); }
});
updateRptUI();
}
function updateRptSelectAllState() {
const all = document.querySelectorAll('.rpt-file-chk');
const chk = document.querySelectorAll('.rpt-file-chk:checked');
const sa = document.getElementById('rptSelectAll');
if (!sa) return;
if (all.length === 0) { sa.checked = false; sa.indeterminate = false; }
else if (chk.length === all.length) { sa.checked = true; sa.indeterminate = false; }
else if (chk.length > 0) { sa.checked = false; sa.indeterminate = true; }
else { sa.checked = false; sa.indeterminate = false; }
}
async function generateRptReport() {
if (rptSelectedFiles.size === 0) return;
const btn = document.getElementById('btnRptExport');
btn.disabled = true;
btn.textContent = '⏳ Đang tạo báo cáo...';
try {
const res = await fetch(RPT_GENERATE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ files: Array.from(rptSelectedFiles) })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Lỗi tạo báo cáo');
}
const result = await res.json();
showRptToast('✅ Đã tạo báo cáo: ' + result.filename, 'success');
setTimeout(() => { window.location.href = result.download_url; }, 600);
btn.textContent = `✅ Đã xuất (${result.count} file)`;
setTimeout(() => { btn.disabled = false; updateRptUI(); }, 3000);
} catch (err) {
showRptToast('❌ Lỗi: ' + err.message, 'error');
btn.disabled = false;
updateRptUI();
}
}
function showRptToast(msg, type = 'info') {
const t = document.getElementById('rptToast');
t.textContent = msg;
t.className = 'rpt-toast ' + type;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 4000);
}
// ===== THỐNG KÊ SẢN LƯỢNG (BILLING) LOGIC =====
let billingItemsData = [];
function populateBillingYearFilter() {
const ys = new Set();
rptAllFiles.forEach(f => {
if (f.file_date) {
const p = f.file_date.split(' ')[0].split('/');
if (p.length === 3) ys.add(p[2]);
}
});
const sel = document.getElementById('billingYearFilter');
if (!sel) return;
const curVal = sel.value;
sel.innerHTML = '<option value="">-- Tất cả các năm --</option>';
Array.from(ys).sort().reverse().forEach(y => {
const o = document.createElement('option');
o.value = y; o.textContent = 'Năm ' + y;
sel.appendChild(o);
});
if (curVal && ys.has(curVal)) sel.value = curVal;
}
function renderBillingTable() {
populateBillingYearFilter();
const selYear = document.getElementById('billingYearFilter')?.value || '';
const mSet = new Set();
rptAllFiles.forEach(f => {
if (!f.file_date) return;
const p = f.file_date.split(' ')[0].split('/');
if (p.length !== 3) return;
if (selYear && p[2] !== selYear) return;
mSet.add(`${p[1]}/${p[2]}`);
});
let mList = Array.from(mSet).sort((a,b) => {
const [mA, yA] = a.split('/').map(Number);
const [mB, yB] = b.split('/').map(Number);
return (yA*12+mA) - (yB*12+mB);
});
if (mList.length === 0) {
const n = new Date();
mList = [String(n.getMonth()+1).padStart(2,'0') + '/' + n.getFullYear()];
}
const thead = document.getElementById('billingTableHead');
if (thead) {
thead.innerHTML = `
<tr style="background:#eaeff5;">
<th rowspan="2" style="text-align:center; width:60px; border:1px solid #bbb; vertical-align:middle;">STT</th>
<th rowspan="2" style="border:1px solid #bbb; vertical-align:middle;">Tên đơn vị</th>
<th colspan="${mList.length}" style="text-align:center; border:1px solid #bbb; background:#dce4ec;">Sản lượng Hardening theo tháng</th>
<th rowspan="2" style="text-align:right; width:120px; border:1px solid #bbb; vertical-align:middle; background:#e2edea;">Tổng cộng</th>
</tr>
<tr style="background:#f4f7f9;">
${mList.map(m => `<th style="text-align:center; border:1px solid #bbb; padding:8px; font-size:13px;">Tháng ${m}</th>`).join('')}
</tr>
`;
}
const stdUnits = ['GDS', 'Fintech', 'MyTV', 'TSC', 'VAS'];
const uSet = new Set(stdUnits);
rptAllFiles.forEach(f => {
if (f.unit_name && f.unit_name !== '-') uSet.add(f.unit_name);
});
const uList = Array.from(uSet).filter(u => u.trim().toUpperCase() !== 'ANTT').sort((a,b) => {
const iA = stdUnits.indexOf(a), iB = stdUnits.indexOf(b);
if (iA !== -1 && iB !== -1) return iA - iB;
if (iA !== -1) return -1;
if (iB !== -1) return 1;
return a.localeCompare(b);
});
const tbody = document.getElementById('billingTableBody');
if (!tbody) return;
tbody.innerHTML = '';
billingItemsData = [];
const monthlyTotals = {};
mList.forEach(m => monthlyTotals[m] = 0);
let grandTotalDev = 0;
uList.forEach((u, idx) => {
const counts = {};
let uTotal = 0;
mList.forEach(m => {
const sub = rptAllFiles.filter(f => {
if (f.unit_name !== u || !f.file_date) return false;
const p = f.file_date.split(' ')[0].split('/');
return `${p[1]}/${p[2]}` === m;
});
const devCnt = new Set(sub.map(f => (f.hostname||f.filename).toLowerCase().trim())).size;
counts[m] = devCnt;
monthlyTotals[m] += devCnt;
uTotal += devCnt;
});
grandTotalDev += uTotal;
billingItemsData.push({ stt: idx+1, unit_name: u, counts: counts, total: uTotal });
const tr = document.createElement('tr');
tr.innerHTML = `
<td style="text-align:center; border:1px solid #bbb; padding:10px;">${idx+1}</td>
<td style="font-weight:600; color:var(--primary-color); border:1px solid #bbb; padding:10px;">${u}</td>
${mList.map(m => `<td style="text-align:right; border:1px solid #bbb; padding:10px;">${counts[m] > 0 ? counts[m].toLocaleString('en-US') : ''}</td>`).join('')}
<td style="text-align:right; font-weight:bold; color:var(--text-main); border:1px solid #bbb; background:#fafbfc; padding:10px;">${uTotal > 0 ? uTotal.toLocaleString('en-US') : ''}</td>
`;
tbody.appendChild(tr);
});
const tfoot = document.getElementById('billingTableFoot');
if (tfoot) {
tfoot.innerHTML = `
<tr style="background:#f0f4f8; font-weight:bold; border-top:2px solid #888;">
<td style="border:1px solid #bbb;"></td>
<td style="border:1px solid #bbb; padding:10px;">Tổng (thiết bị)</td>
${mList.map(m => `<td style="text-align:right; color:var(--primary-color); border:1px solid #bbb; padding:10px;">${monthlyTotals[m] > 0 ? monthlyTotals[m].toLocaleString('en-US') : '0'}</td>`).join('')}
<td style="text-align:right; color:var(--primary-color); font-size:15px; border:1px solid #bbb; background:#e8f0f8; padding:10px;" id="footDevGrand">${grandTotalDev > 0 ? grandTotalDev.toLocaleString('en-US') : '0'}</td>
</tr>
<tr style="background:#e6ecf2; font-weight:bold; font-size:15px;">
<td style="border:1px solid #bbb;"></td>
<td style="border:1px solid #bbb; padding:10px;">Thành tiền (VNĐ)</td>
${mList.map(m => `<td style="text-align:right; color:#d32f2f; border:1px solid #bbb; padding:10px;" class="footCostMonth" data-m="${m}">0</td>`).join('')}
<td style="text-align:right; color:#d32f2f; border:1px solid #bbb; background:#dde6ef; padding:10px;" id="footCostGrand">0</td>
</tr>
`;
}
window.currentBillingMonths = mList;
window.currentMonthlyTotals = monthlyTotals;
window.currentGrandTotalDev = grandTotalDev;
calculateBillingCost();
}
function onBillingPriceChange(inp) {
let val = parseInt(inp.value.replace(/[^0-9]/g, '')) || 0;
inp.value = val > 0 ? val.toLocaleString('en-US') : '0';
calculateBillingCost();
}
function calculateBillingCost() {
const price = parseInt(document.getElementById('billingUnitPrice')?.value.replace(/[^0-9]/g, '')) || 0;
if (window.currentBillingMonths) {
window.currentBillingMonths.forEach(m => {
const cnt = window.currentMonthlyTotals[m] || 0;
const c = cnt * price;
const el = document.querySelector(`.footCostMonth[data-m="${m}"]`);
if (el) el.textContent = c > 0 ? c.toLocaleString('en-US') : '0';
});
}
const elG = document.getElementById('footCostGrand');
const gCost = (window.currentGrandTotalDev || 0) * price;
if (elG) elG.textContent = gCost > 0 ? gCost.toLocaleString('en-US') : '0';
}
async function exportBillingExcel() {
const btn = document.getElementById('btnBillingExport');
if (!btn) return;
btn.disabled = true;
btn.textContent = '⏳ Đang tạo Excel...';
try {
const price = parseInt(document.getElementById('billingUnitPrice').value.replace(/[^0-9]/g, '')) || 0;
const selYear = document.getElementById('billingYearFilter').value;
const res = await fetch(BILLING_GENERATE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
year: selYear,
unit_price: price,
months: window.currentBillingMonths || [],
units: billingItemsData
})
});
if (!res.ok) throw new Error('Lỗi xuất báo cáo');
const result = await res.json();
showRptToast('✅ Đã xuất báo cáo sản lượng: ' + result.filename, 'success');
setTimeout(() => { window.location.href = result.download_url; }, 600);
} catch (err) {
showRptToast('❌ Lỗi: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = '📥 Xuất Excel';
}
}
</script>
</body>
</html>