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:
+307
@@ -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("/")
|
||||
|
||||
Reference in New Issue
Block a user