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