Add Settings page, List page, report template
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user