Add HTML5 report export for Attendance and Accident

- Backend: /api/report/{section}/html endpoint generates self-contained HTML5 report
- Frontend: Export buttons added to Dashboard, Attendance List, Accident List
- Report features: embedded data, 3 interactive Chart.js charts, stats cards,
  sortable/filterable table with color-coded rows, popup detail on click,
  click chart segment to auto-filter table, print support
- Template: report_template.html with responsive design, Chinese labels
This commit is contained in:
IT狗
2026-07-20 23:21:12 +08:00
parent eafc1c33fc
commit 535d10a885
5 changed files with 735 additions and 15 deletions
+307
View File
@@ -770,6 +770,313 @@ async def export_excel(
headers={"Content-Disposition": f"attachment; filename={section}_export.xlsx"}
)
# ============ HTML5 Report Export ============
import re
def _make_chart_config(data, rows, section):
"""Build chart config + stats from raw Excel rows."""
headers = data["headers"]
records = data["rows"]
# Build dicts
recs = []
for row in records:
rec = {}
for i, h in enumerate(headers):
if h:
rec[str(h)] = row[i] if i < len(row) else None
recs.append(rec)
# ---- Stats ----
stats = []
# Total records
stats.append({"label": "總記錄", "value": len(recs), "sub": ""})
if section == "attendance":
# Find relevant columns
staff_col = next((h for h in headers if h and any(k in str(h) for k in ["員工", "員工名稱", "employee", "name", "Name"])), None)
shift_col = next((h for h in headers if h and "班次" in str(h)), None)
# Count unique staff
unique_staff = set()
late_count = 0
early_count = 0
ot_count = 0
missing_count = 0
for r in recs:
if staff_col and r.get(staff_col):
unique_staff.add(r[staff_col])
# Status-like fields
for k, v in r.items():
if not v: continue
sv = str(v).lower()
if any(t in sv for t in ["missing", "缺勤"]): missing_count += 1
elif "" in sv or "late" in sv: late_count += 1
elif "早退" in sv or "early" in sv: early_count += 1
elif "ot" in sv or "超時" in sv: ot_count += 1
stats.append({"label": "員工人數", "value": len(unique_staff), "sub": ""})
stats.append({"label": "遲到人次", "value": late_count, "sub": ""})
stats.append({"label": "早退人次", "value": early_count, "sub": ""})
stats.append({"label": "OT 人次", "value": ot_count, "sub": ""})
stats.append({"label": "缺勤", "value": missing_count, "sub": ""})
# Department count
dept_col = next((h for h in headers if h and any(k in str(h) for k in ["dept", "部門", "公司"])), None)
if dept_col:
dept_count = len(set(r.get(dept_col) for r in recs if r.get(dept_col)))
stats.append({"label": "部門數目", "value": dept_count, "sub": ""})
elif section == "accident":
# Severity breakdown
sev_col = next((h for h in headers if h and any(k in str(h) for k in ["severity", "緊急", "程度", "Severity"])), None)
loc_col = next((h for h in headers if h and any(k in str(h) for k in ["location", "地點", "部門", "地點"])), None)
date_col = next((h for h in headers if h and any(k in str(h) for k in ["date", "日期", "時間", "Date"])), None)
sev_count = {}
loc_count = {}
for r in recs:
if sev_col and r.get(sev_col):
sev_count[r[sev_col]] = sev_count.get(r[sev_col], 0) + 1
if loc_col and r.get(loc_col):
loc_count[r[loc_col]] = loc_count.get(r[loc_col], 0) + 1
if sev_count:
total_sev = sum(sev_count.values())
stats.append({"label": "緊急/嚴重個案", "value": total_sev, "sub": ""})
if loc_count:
stats.append({"label": "出事地點", "value": len(loc_count), "sub": "個地點"})
# ---- Charts ----
charts = []
if section == "attendance":
# Dept bar chart
dept_col = next((h for h in headers if h and any(k in str(h) for k in ["dept", "部門", "公司", "company"])), None)
if dept_col:
dept_map = {}
for r in recs:
d = str(r.get(dept_col) or "Unknown")
dept_map[d] = dept_map.get(d, 0) + 1
sorted_depts = sorted(dept_map.items(), key=lambda x: x[1], reverse=True)[:8]
charts.append({
"type": "bar",
"data": {
"labels": [d[0][:12] for d in sorted_depts],
"datasets": [{"label": "人次", "data": [d[1] for d in sorted_depts], "backgroundColor": ["#3b82f6","#06b6d4","#10b981","#f59e0b","#ef4444","#8b5cf6","#ec4899","#64748b"]}]
},
"filterColumn": dept_col,
"tooltipCallbacks": {}
})
# Status pie chart
status_map = {"正常": 0, "遲到": 0, "早退": 0, "缺勤": 0, "OT": 0}
for r in recs:
for v in r.values():
if not v: continue
sv = str(v).lower()
if "missing" in sv or "缺勤" in sv: status_map["缺勤"] = status_map.get("缺勤", 0) + 1
elif "" in sv or "late" in sv: status_map["遲到"] = status_map.get("遲到", 0) + 1
elif "早退" in sv or "early" in sv: status_map["早退"] = status_map.get("早退", 0) + 1
elif "ot" in sv or "超時" in sv: status_map["OT"] = status_map.get("OT", 0) + 1
else: status_map["正常"] = status_map.get("正常", 0) + 1
pie_data = [v for v in status_map.values() if v > 0]
pie_labels = [k for k, v in status_map.items() if v > 0]
if pie_data:
charts.append({
"type": "doughnut",
"data": {
"labels": pie_labels,
"datasets": [{"data": pie_data, "backgroundColor": ["#10b981","#f59e0b","#f97316","#ef4444","#3b82f6"]}]
},
"filterColumn": None,
"tooltipCallbacks": {}
})
# Monthly/date trend
date_col = next((h for h in headers if h and any(k in str(h) for k in ["date", "日期", "Date"])), None)
if date_col:
date_map = {}
for r in recs:
d = r.get(date_col)
if d:
key = str(d)[:7] # YYYY-MM
date_map[key] = date_map.get(key, 0) + 1
sorted_dates = sorted(date_map.items())
charts.append({
"type": "line",
"data": {
"labels": [d[0] for d in sorted_dates],
"datasets": [{"label": "出勤人次", "data": [d[1] for d in sorted_dates], "borderColor": "#3b82f6", "backgroundColor": "rgba(59,130,246,0.1)", "fill": True, "tension": 0.3}]
},
"filterColumn": None,
"tooltipCallbacks": {}
})
elif section == "accident":
# Severity bar chart
sev_col = next((h for h in headers if h and any(k in str(h) for k in ["severity", "緊急", "程度"])), None)
if sev_col:
sev_map = {}
for r in recs:
s = str(r.get(sev_col) or "Unknown")
sev_map[s] = sev_map.get(s, 0) + 1
sorted_sev = sorted(sev_map.items(), key=lambda x: x[1], reverse=True)[:6]
charts.append({
"type": "bar",
"data": {
"labels": [s[0] for s in sorted_sev],
"datasets": [{"label": "個案", "data": [s[1] for s in sorted_sev], "backgroundColor": ["#ef4444","#f59e0b","#3b82f6","#10b981","#8b5cf6","#64748b"]}]
},
"filterColumn": sev_col,
"tooltipCallbacks": {}
})
# Location bar chart
loc_col = next((h for h in headers if h and any(k in str(h) for k in ["location", "地點", "部門", "地點"])), None)
if loc_col:
loc_map = {}
for r in recs:
l = str(r.get(loc_col) or "Unknown")
loc_map[l] = loc_map.get(l, 0) + 1
sorted_locs = sorted(loc_map.items(), key=lambda x: x[1], reverse=True)[:8]
charts.append({
"type": "bar",
"data": {
"labels": [l[0][:12] for l in sorted_locs],
"datasets": [{"label": "個案", "data": [l[1] for l in sorted_locs], "backgroundColor": ["#ef4444","#f59e0b","#3b82f6","#10b981","#8b5cf6","#ec4899","#06b6d4","#64748b"]}]
},
"filterColumn": loc_col,
"tooltipCallbacks": {}
})
# Monthly trend
date_col = next((h for h in headers if h and any(k in str(h) for k in ["date", "日期", "時間", "Date"])), None)
if date_col:
date_map = {}
for r in recs:
d = r.get(date_col)
if d:
key = str(d)[:7]
date_map[key] = date_map.get(key, 0) + 1
sorted_dates = sorted(date_map.items())
charts.append({
"type": "line",
"data": {
"labels": [d[0] for d in sorted_dates],
"datasets": [{"label": "意外個案", "data": [d[1] for d in sorted_dates], "borderColor": "#ef4444", "backgroundColor": "rgba(239,68,68,0.1)", "fill": True, "tension": 0.3}]
},
"filterColumn": None,
"tooltipCallbacks": {}
})
# Fill missing chart slots
while len(charts) < 3:
charts.append({"type": "bar", "data": {"labels": [], "datasets": [{"label": "無數據", "data": []}]}, "filterColumn": None, "tooltipCallbacks": {}})
return {"stats": stats, "charts": charts[:3]}
@app.get("/api/report/{section}/html")
async def export_html_report(
section: str,
current_user: User = Depends(get_current_user)
):
"""Generate self-contained HTML5 report with embedded data, charts, sorting, filtering, popup."""
from fastapi.responses import HTMLResponse
import json
from datetime import datetime
if section not in ["attendance", "accident"]:
raise HTTPException(status_code=400, detail="Invalid section")
# Read data
headers, rows = read_excel_file(section)
if headers is None:
return HTMLResponse(
content="<html><body style='font-family:sans-serif;padding:40px;text-align:center;color:#666;'>"
"<h2>暫無數據</h2><p>請先上傳 Excel 檔案再生成報告。</p>"
"<a href='/'>返回首頁</a></body></html>",
media_type="text/html"
)
# Build records
records = []
for row_idx, row in enumerate(rows, 1):
rec = {"_row": row_idx}
for i, h in enumerate(headers):
if h:
rec[str(h)] = row[i] if i < len(row) else None
records.append(rec)
# Chart + stats config
chart_cfg = _make_chart_config({"headers": headers, "rows": rows}, rows, section)
# Column defs
col_defs = []
hide_keys = {"_row"}
for h in headers:
if not h or h in hide_keys:
continue
label = str(h)
col_defs.append({"key": str(h), "label": label, "hide": False})
# Template config
if section == "attendance":
title = "出勤 Attendance"
header_color = "#2563EB"
header_dark = "#1D4ED8"
accent = "#2563EB"
else:
title = "意外 Accident"
header_color = "#DC2626"
header_dark = "#B91C1C"
accent = "#DC2626"
generated_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
total_records = len(records)
# Column options for filter
col_options = "".join(f'<option value="{c["key"]}">{c["label"]}</option>' for c in col_defs[:8])
# Serialize for embedding
def safe_json(obj):
return json.dumps(obj, default=lambda x: None, ensure_ascii=False)
embedded_data = safe_json(records)
column_defs = safe_json(col_defs)
chart_config = safe_json(chart_cfg)
# Read template
import os
template_path = os.path.join(os.path.dirname(__file__), "report_template.html")
if os.path.exists(template_path):
with open(template_path, "r", encoding="utf-8") as f:
template = f.read()
else:
return HTMLResponse(content="<html><body><h2>Template not found</h2></body></html>", status_code=500)
# Replace placeholders
html = template
html = html.replace("{{TITLE}}", title)
html = html.replace("{{REPORT_TITLE}}", title)
html = html.replace("{{GENERATED_DATE}}", generated_date)
html = html.replace("{{TOTAL_RECORDS}}", str(total_records))
html = html.replace("{{HEADER_COLOR}}", header_color)
html = html.replace("{{HEADER_DARK}}", header_dark)
html = html.replace("{{ACCENT_COLOR}}", accent)
html = html.replace("{{COLUMN_OPTIONS}}", col_options)
html = html.replace("{{EMBEDDED_DATA}}", embedded_data)
html = html.replace("{{COLUMN_DEFS}}", column_defs)
html = html.replace("{{CHART_CONFIG}}", chart_config)
filename = f"{section}_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html"
headers_dict = {"Content-Disposition": f"attachment; filename={filename}"}
return HTMLResponse(content=html, media_type="text/html", headers=headers_dict)
# ============ Roster / Shift Management ============
@app.get("/")
+366
View File
@@ -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>
+16 -1
View File
@@ -1,7 +1,14 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import api from '../api'
import { Users, AlertTriangle, FileSpreadsheet, RefreshCw, TrendingUp, MapPin, Clock, AlertCircle } from 'lucide-react'
import { Users, AlertTriangle, FileSpreadsheet, RefreshCw, TrendingUp, MapPin, Clock, AlertCircle, FileCode } from 'lucide-react'
const handleExportHTML = (section) => {
const token = localStorage.getItem('aars_token')
if (token) {
window.open(`/api/report/${section}/html?token=${token}`, '_blank')
}
}
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line } from 'recharts'
const COLORS = ['#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']
@@ -326,6 +333,14 @@ export default function Dashboard() {
{/* Quick Actions */}
<div className="flex flex-wrap gap-3">
<button onClick={() => handleExportHTML('attendance')} className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700">
<FileCode size={16} />
Attendance HTML Report
</button>
<button onClick={() => handleExportHTML('accident')} className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700">
<FileCode size={16} />
Accident HTML Report
</button>
<Link to="/upload" className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm rounded-md hover:bg-primary-700">
<FileSpreadsheet size={16} />
上傳 Excel
+23 -7
View File
@@ -2,7 +2,14 @@ import { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom'
import api from '../../api'
import toast from 'react-hot-toast'
import { Search, Eye, ChevronUp, ChevronDown, Filter, X } from 'lucide-react'
import { Search, Eye, ChevronUp, ChevronDown, Filter, X, FileCode } from 'lucide-react'
const handleExportHTML = (section) => {
const token = localStorage.getItem('aars_token')
if (token) {
window.open(`/api/report/${section}/html?token=${token}`, '_blank')
}
}
export default function AccidentList() {
const [records, setRecords] = useState([])
@@ -107,12 +114,21 @@ export default function AccidentList() {
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-slate-900">意外記錄 Accident</h1>
<Link
to="/upload"
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700"
>
上傳 Excel
</Link>
<div className="flex gap-2">
<button
onClick={() => handleExportHTML('accident')}
className="flex items-center gap-2 px-4 py-2 bg-slate-700 text-white text-sm rounded-md hover:bg-slate-800"
>
<FileCode size={16} />
匯出 HTML Report
</button>
<Link
to="/upload"
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700"
>
上傳 Excel
</Link>
</div>
</div>
{/* Filters */}
+23 -7
View File
@@ -2,7 +2,14 @@ import { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom'
import api from '../../api'
import toast from 'react-hot-toast'
import { Search, Eye, ChevronUp, ChevronDown, Filter, X, AlertCircle, Clock } from 'lucide-react'
import { Search, Eye, ChevronUp, ChevronDown, Filter, X, AlertCircle, Clock, FileCode } from 'lucide-react'
const handleExportHTML = (section) => {
const token = localStorage.getItem('aars_token')
if (token) {
window.open(`/api/report/${section}/html?token=${token}`, '_blank')
}
}
export default function AttendanceList() {
const [records, setRecords] = useState([])
@@ -149,12 +156,21 @@ export default function AttendanceList() {
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-slate-900">出勤記錄 Attendance</h1>
<Link
to="/upload"
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
上傳 Excel
</Link>
<div className="flex gap-2">
<button
onClick={() => handleExportHTML('attendance')}
className="flex items-center gap-2 px-4 py-2 bg-slate-700 text-white text-sm rounded-md hover:bg-slate-800"
>
<FileCode size={16} />
匯出 HTML Report
</button>
<Link
to="/upload"
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
上傳 Excel
</Link>
</div>
</div>
{/* Filters */}