diff --git a/backend/main.py b/backend/main.py index 822d1e7..21b0363 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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="
" + "請先上傳 Excel 檔案再生成報告。
" + "返回首頁", + 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'' 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="Generated: {{GENERATED_DATE}} | {{TOTAL_RECORDS}} 筆記錄
+