Files
aars/backend/report_template.html
T
2026-07-21 01:56:10 +08:00

367 lines
16 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="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{TITLE}} Report</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', 'Noto Sans TC', sans-serif; background: #f8fafc; color: #0f172a; font-size: 13px; }
.report-header { background: linear-gradient(135deg, {{HEADER_COLOR}} 0%, {{HEADER_DARK}} 100%); color: white; padding: 24px 32px; }
.report-header h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
.report-header p { opacity: 0.85; font-size: 13px; }
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; padding: 20px 32px; }
.stat-card { background: white; border-radius: 10px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); border-left: 4px solid {{ACCENT_COLOR}}; }
.stat-card .label { font-size: 11px; color: #64748b; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-card .value { font-size: 26px; font-weight: 700; color: #0f172a; margin-top: 4px; }
.stat-card .sub { font-size: 11px; color: #94a3b8; margin-top: 2px; }
.charts-section { padding: 16px 32px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.chart-card { background: white; border-radius: 10px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.chart-card h3 { font-size: 13px; font-weight: 600; color: #334155; margin-bottom: 12px; border-bottom: 2px solid {{ACCENT_COLOR}}; padding-bottom: 6px; }
.chart-wrap { position: relative; height: 200px; }
.filter-bar { padding: 12px 32px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; background: white; border-bottom: 1px solid #e2e8f0; }
.filter-bar input { padding: 7px 12px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 13px; width: 200px; }
.filter-bar select { padding: 7px 12px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 13px; }
.filter-bar .badge { background: #f1f5f9; padding: 6px 12px; border-radius: 20px; font-size: 12px; color: #475569; }
.table-section { padding: 0 32px 32px; }
.table-wrap { background: white; border-radius: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); overflow: hidden; }
table { width: 100%; border-collapse: collapse; }
thead { background: #f8fafc; }
thead th { padding: 10px 12px; text-align: left; font-size: 11px; font-weight: 600; color: #475569; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 2px solid #e2e8f0; cursor: pointer; user-select: none; white-space: nowrap; }
thead th:hover { background: #e2e8f0; }
thead th .sort-icon { margin-left: 4px; opacity: 0.4; font-size: 10px; }
thead th.sorted .sort-icon { opacity: 1; color: {{ACCENT_COLOR}}; }
tbody tr { border-bottom: 1px solid #f1f5f9; transition: background 0.15s; }
tbody tr:hover { background: #f8fafc; }
tbody td { padding: 9px 12px; font-size: 13px; color: #334155; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
tbody tr.row-normal { border-left: 3px solid transparent; }
tbody tr.row-warning { border-left: 3px solid #f59e0b; }
tbody tr.row-danger { border-left: 3px solid #ef4444; }
tbody tr.row-success { border-left: 3px solid #10b981; }
.cell-normal { color: #334155; }
.cell-warning { color: #d97706; background: #fef3c7; padding: 2px 6px; border-radius: 4px; font-weight: 500; }
.cell-danger { color: #dc2626; background: #fee2e2; padding: 2px 6px; border-radius: 4px; font-weight: 500; }
.cell-success { color: #059669; background: #d1fae5; padding: 2px 6px; border-radius: 4px; font-weight: 500; }
.cell-info { color: #2563eb; background: #dbeafe; padding: 2px 6px; border-radius: 4px; font-weight: 500; }
/* Popup */
.popup-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 9999; align-items: center; justify-content: center; }
.popup-overlay.active { display: flex; }
.popup { background: white; border-radius: 12px; width: 480px; max-height: 80vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
.popup-header { padding: 16px 20px; border-bottom: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; background: {{HEADER_COLOR}}; color: white; border-radius: 12px 12px 0 0; }
.popup-header h2 { font-size: 15px; font-weight: 600; }
.popup-close { background: rgba(255,255,255,0.2); border: none; color: white; width: 28px; height: 28px; border-radius: 50%; cursor: pointer; font-size: 16px; }
.popup-body { padding: 16px 20px; }
.popup-row { display: flex; padding: 8px 0; border-bottom: 1px solid #f1f5f9; }
.popup-row:last-child { border-bottom: none; }
.popup-label { font-weight: 600; color: #64748b; font-size: 12px; width: 130px; flex-shrink: 0; }
.popup-value { color: #0f172a; font-size: 13px; word-break: break-all; }
.pagination { padding: 12px 32px; display: flex; gap: 6px; align-items: center; justify-content: space-between; background: white; border-top: 1px solid #e2e8f0; }
.pagination-info { font-size: 12px; color: #64748b; }
.pagination buttons { display: flex; gap: 4px; }
.pagination button { padding: 6px 12px; border: 1px solid #cbd5e1; background: white; border-radius: 6px; cursor: pointer; font-size: 12px; }
.pagination button:hover { background: #f1f5f9; }
.pagination button.active { background: {{ACCENT_COLOR}}; color: white; border-color: {{ACCENT_COLOR}}; }
.pagination button:disabled { opacity: 0.4; cursor: default; }
.footer { text-align: center; padding: 16px; color: #94a3b8; font-size: 11px; }
</style>
</head>
<body>
<!-- Popup -->
<div class="popup-overlay" id="popup">
<div class="popup">
<div class="popup-header">
<h2 id="popup-title">詳細資料</h2>
<button class="popup-close" onclick="closePopup()">×</button>
</div>
<div class="popup-body" id="popup-body"></div>
</div>
</div>
<!-- Header -->
<div class="report-header">
<h1>{{REPORT_TITLE}}</h1>
<p>Generated: {{GENERATED_DATE}} | {{TOTAL_RECORDS}} 筆記錄</p>
</div>
<!-- Stats -->
<div class="stats-row" id="stats-row"></div>
<!-- Charts -->
<div class="charts-section">
<div class="chart-card">
<h3>📊 圖表 1</h3>
<div class="chart-wrap"><canvas id="chart1"></canvas></div>
</div>
<div class="chart-card">
<h3>📊 圖表 2</h3>
<div class="chart-wrap"><canvas id="chart2"></canvas></div>
</div>
<div class="chart-card">
<h3>📊 圖表 3</h3>
<div class="chart-wrap"><canvas id="chart3"></canvas></div>
</div>
</div>
<!-- Filter -->
<div class="filter-bar">
<input type="text" id="searchInput" placeholder="🔍 搜尋所有欄位..." oninput="applyFilters()">
<select id="filterCol" onchange="applyFilters()"><option value="">全部欄位</option>{{COLUMN_OPTIONS}}</select>
<select id="filterOp" onchange="applyFilters()"><option value="contains">包含</option><option value="equals">等於</option><option value="gt">大於</option><option value="lt">小於</option></select>
<input type="text" id="filterVal" placeholder="篩選值..." oninput="applyFilters()">
<span class="badge" id="resultCount"></span>
<button onclick="clearFilters()" style="margin-left:auto;padding:6px 12px;border:1px solid #cbd5e1;background:white;border-radius:6px;cursor:pointer;font-size:12px;">清除篩選</button>
<button onclick="window.print()" style="padding:6px 14px;background:{{ACCENT_COLOR}};color:white;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:500;">🖨️ 列印</button>
</div>
<!-- Table -->
<div class="table-section">
<div class="table-wrap">
<table>
<thead id="thead"></thead>
<tbody id="tbody"></tbody>
</table>
</div>
</div>
<!-- Pagination -->
<div class="pagination">
<div class="pagination-info" id="pageInfo"></div>
<div id="pageButtons"></div>
</div>
<div class="footer">
AARS Report System | {{GENERATED_DATE}} | Page 1 of 1
</div>
<script>
const DATA = {{EMBEDDED_DATA}};
const COLS = {{COLUMN_DEFS}};
const CHARTS = {{CHART_CONFIG}};
const ACCENT = "{{ACCENT_COLOR}}";
const PAGE_SIZE = 50;
let currentPage = 1;
let filteredData = [];
let sortKey = null;
let sortDir = "asc";
function init() {
filteredData = [...DATA];
renderStats();
renderCharts();
applyFilters();
}
function renderStats() {
const row = document.getElementById("stats-row");
row.innerHTML = CHARTS.stats.map(s => `
<div class="stat-card">
<div class="label">${s.label}</div>
<div class="value">${s.value}</div>
${s.sub ? `<div class="sub">${s.sub}</div>` : ""}
</div>
`).join("");
}
function renderCharts() {
CHARTS.charts.forEach((cfg, i) => {
const ctx = document.getElementById(`chart${i+1}`);
if (!ctx) return;
new Chart(ctx, {
type: cfg.type,
data: cfg.data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { font: { size: 11 }, boxWidth: 12, padding: 8 } },
tooltip: { callbacks: cfg.tooltipCallbacks || {} }
},
onClick: (e, elements) => {
if (elements.length > 0) {
const idx = elements[0].index;
const label = cfg.data.labels[idx];
// Auto-filter table when clicking chart segment
const col = cfg.filterColumn;
if (col) {
document.getElementById("filterCol").value = col;
document.getElementById("filterVal").value = label;
applyFilters();
}
}
}
}
});
});
}
function getCellClass(val, col) {
if (val === null || val === undefined || val === "") return "cell-normal";
const v = String(val).toLowerCase();
if (["missing", "缺勤", "no show"].some(t => v.includes(t))) return "cell-danger";
if (["late", "遲到", "遲"].some(t => v.includes(t))) return "cell-warning";
if (["early", "早退"].some(t => v.includes(t))) return "cell-warning";
if (["normal", "正常", "present", "準時"].some(t => v.includes(t))) return "cell-success";
if (["ot", "overtime", "超時"].some(t => v.includes(t))) return "cell-info";
if (["high", "red", "danger", "緊急", "嚴重"].some(t => v.includes(t))) return "cell-danger";
if (["medium", "medium", "中度", "中等"].some(t => v.includes(t))) return "cell-warning";
if (["low", "green", "輕微", "輕度"].some(t => v.includes(t))) return "cell-success";
return "cell-normal";
}
function getRowClass(row) {
const statusKeys = Object.keys(row).find(k => /status|狀態|status_text/i.test(k));
if (statusKeys) {
const v = String(row[statusKeys] || "").toLowerCase();
if (["missing", "缺勤", "no show", "late", "遲到", "early", "早退"].some(t => v.includes(t))) return "row-warning";
if (["normal", "正常", "present"].some(t => v.includes(t))) return "row-normal";
}
const sevKeys = Object.keys(row).find(k => /severity|緊急|程度/i.test(k));
if (sevKeys) {
const v = String(row[sevKeys] || "").toLowerCase();
if (["high", "red", "danger", "緊急", "嚴重", "死亡"].some(t => v.includes(t))) return "row-danger";
if (["medium", "中度", "中等"].some(t => v.includes(t))) return "row-warning";
if (["low", "green", "輕微", "輕度", "輕傷"].some(t => v.includes(t))) return "row-success";
}
return "row-normal";
}
function applyFilters() {
const search = document.getElementById("searchInput").value.toLowerCase();
const col = document.getElementById("filterCol").value;
const op = document.getElementById("filterOp").value;
const val = document.getElementById("filterVal").value.toLowerCase();
filteredData = DATA.filter(row => {
if (search) {
const hit = Object.values(row).some(v => v !== null && String(v).toLowerCase().includes(search));
if (!hit) return false;
}
if (col && val) {
const cellVal = String(row[col] || "").toLowerCase();
if (op === "contains") { if (!cellVal.includes(val)) return false; }
else if (op === "equals") { if (cellVal !== val) return false; }
else if (op === "gt") { if (!(parseFloat(row[col]) > parseFloat(val))) return false; }
else if (op === "lt") { if (!(parseFloat(row[col]) < parseFloat(val))) return false; }
}
return true;
});
if (sortKey) sortFiltered();
currentPage = 1;
renderTable();
document.getElementById("resultCount").textContent = `${filteredData.length} 筆記錄`;
}
function clearFilters() {
document.getElementById("searchInput").value = "";
document.getElementById("filterCol").value = "";
document.getElementById("filterOp").value = "contains";
document.getElementById("filterVal").value = "";
applyFilters();
}
function sortFiltered() {
filteredData.sort((a, b) => {
let aVal = a[sortKey] || "";
let bVal = b[sortKey] || "";
const aNum = parseFloat(aVal);
const bNum = parseFloat(bVal);
let cmp;
if (!isNaN(aNum) && !isNaN(bNum)) cmp = aNum - bNum;
else cmp = String(aVal).localeCompare(String(bVal), "zh-Hant");
return sortDir === "asc" ? cmp : -cmp;
});
}
function handleSort(key) {
if (sortKey === key) sortDir = sortDir === "asc" ? "desc" : "asc";
else { sortKey = key; sortDir = "asc"; }
sortFiltered();
renderTable();
}
function renderTable() {
const thead = document.getElementById("thead");
const visibleCols = COLS.filter(c => !c.hide);
const displayCols = visibleCols.length > 6 ? visibleCols.slice(0, 6) : visibleCols;
thead.innerHTML = `<tr>${displayCols.map((c, i) => {
const icon = sortKey === c.key ? (sortDir === "asc" ? "▲" : "▼") : "▽";
const cls = sortKey === c.key ? "sorted" : "";
return `<th class="${cls}" onclick="handleSort('${c.key}')">${c.label || c.key}<span class="sort-icon">${icon}</span></th>`;
}).join("")}<th>操作</th></tr>`;
const totalPages = Math.max(1, Math.ceil(filteredData.length / PAGE_SIZE));
const start = (currentPage - 1) * PAGE_SIZE;
const pageRows = filteredData.slice(start, start + PAGE_SIZE);
document.getElementById("tbody").innerHTML = pageRows.map(row => {
const rowClass = getRowClass(row);
const cells = displayCols.map(c => {
const val = row[c.key];
const cls = getCellClass(val, c.key);
return `<td class="${cls}" title="${val ?? ""}">${val !== null && val !== undefined ? String(val) : "-"}</td>`;
}).join("");
return `<tr class="${rowClass}" ondblclick="showPopup(${start + filteredData.indexOf(row)})">${cells}<td><button onclick="showPopup(${start + filteredData.indexOf(row)})" style="background:{{ACCENT_COLOR}};color:white;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px;">詳情</button></td></tr>`;
}).join("");
// pagination buttons
const pageInfo = document.getElementById("pageInfo");
pageInfo.textContent = `${currentPage} / ${totalPages} 頁,共 ${filteredData.length} 筆記錄`;
const btns = document.getElementById("pageButtons");
btns.innerHTML = `<button ${currentPage===1?"disabled":""} onclick="goPage(${currentPage-1})">上一頁</button>`;
for (let p = Math.max(1, currentPage-2); p <= Math.min(totalPages, currentPage+3); p++) {
btns.innerHTML += `<button class="${p===currentPage?"active":""}" onclick="goPage(${p})">${p}</button>`;
}
btns.innerHTML += `<button ${currentPage===totalPages?"disabled":""} onclick="goPage(${currentPage+1})">下一頁</button>`;
}
function goPage(p) {
const totalPages = Math.max(1, Math.ceil(filteredData.length / PAGE_SIZE));
if (p < 1 || p > totalPages) return;
currentPage = p;
renderTable();
}
function showPopup(idx) {
const row = filteredData[idx];
if (!row) return;
const visibleCols = COLS.filter(c => !c.hide);
let html = visibleCols.map(c => `<div class="popup-row"><div class="popup-label">${c.label || c.key}</div><div class="popup-value">${row[c.key] !== null && row[c.key] !== undefined ? String(row[c.key]) : "-"}</div></div>`).join("");
document.getElementById("popup-body").innerHTML = html;
document.getElementById("popup-title").textContent = "詳細資料 #" + idx;
document.getElementById("popup").classList.add("active");
}
function closePopup() {
document.getElementById("popup").classList.remove("active");
}
document.getElementById("popup").addEventListener("click", function(e) {
if (e.target === this) closePopup();
});
document.addEventListener("keydown", function(e) {
if (e.key === "Escape") closePopup();
});
init();
</script>
</body>
</html>