Compare commits
32 Commits
95814350c9
...
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a01b53541 | |||
| 404329d3fa | |||
| 811b9991c9 | |||
| f12a239e26 | |||
| fcc3246d8e | |||
| 3ba6617009 | |||
| 4bd6d0e7c8 | |||
| 7702fe4ae5 | |||
| d302fa9beb | |||
| 4b967f7920 | |||
| 8b378ec4c6 | |||
| 7dd53e0a5f | |||
| 33a5e3b2ad | |||
| 4c9db01213 | |||
| ef57d7cd59 | |||
| f41dad6127 | |||
| 6f2df815f5 | |||
| c1b1d6f35b | |||
| e531cea8a4 | |||
| 2d64d3f0b6 | |||
| 08ba4cbca9 | |||
| c07f40b28f | |||
| 2d9b73df9d | |||
| 9980f8d80a | |||
| 1e7d58486b | |||
| cca088261b | |||
| adc9e6451b | |||
| a34905661b | |||
| 89c14c7a72 | |||
| 1a60c71032 | |||
| d5d9d644a0 | |||
| 6f90d60b13 |
+372
-55
@@ -1,10 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
import secret
|
import secret
|
||||||
from fastapi import FastAPI, Depends, HTTPException, Query, UploadFile, File, Form, Request
|
from fastapi import FastAPI, Depends, HTTPException, Query, UploadFile, File, Form, Request, Body
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -125,8 +126,40 @@ def enrich_attendance_with_leave(records, db):
|
|||||||
if not r.date:
|
if not r.date:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Rest day detection: roster says the shift rests on this weekday
|
||||||
|
# (e.g. Sunday for S2/S3) but raw status is 'missing'. Don't flag as 缺勤
|
||||||
|
# when the employee wasn't scheduled to work.
|
||||||
|
if (r.status_code or '').lower() == 'missing' and r.shift_code and r.weekday:
|
||||||
|
shift_info = get_shift_schedule_full(r.shift_code, r.weekday)
|
||||||
|
if shift_info is None:
|
||||||
|
r.status_code = 'rest'
|
||||||
|
r.status_text = '休息'
|
||||||
|
continue
|
||||||
|
|
||||||
if r.date in holidays_by_date:
|
if r.date in holidays_by_date:
|
||||||
h = holidays_by_date[r.date]
|
h = holidays_by_date[r.date]
|
||||||
|
# Check roster: if shift's public_holiday == 'Yes', employee works on holidays
|
||||||
|
# Don't override with holiday status in that case
|
||||||
|
shift_holiday = '休息' # default: assume rest on holiday
|
||||||
|
if r.shift_code:
|
||||||
|
shift_info = get_shift_schedule_full(r.shift_code, r.weekday or '')
|
||||||
|
if shift_info and len(shift_info) >= 3:
|
||||||
|
shift_holiday = shift_info[2]
|
||||||
|
if shift_holiday == 'Yes':
|
||||||
|
# Employee works on public holidays - keep attendance status, append holiday note
|
||||||
|
base = r.status_text or "正常"
|
||||||
|
r.status_text = f"{base} (🎉{h.name})"
|
||||||
|
continue
|
||||||
|
# Employee rests on public holidays - mark as holiday unless already has clock-in
|
||||||
|
has_clock_in = (
|
||||||
|
(r.check_in is not None) or
|
||||||
|
(r.actual_in and r.actual_in not in ("", "-"))
|
||||||
|
)
|
||||||
|
if not has_clock_in:
|
||||||
|
r.status_code = "holiday"
|
||||||
|
r.status_text = f"\U0001F3D6\uFE0F{h.name}"
|
||||||
|
continue
|
||||||
|
# Has clock-in but roster says rest on holiday - still show holiday
|
||||||
r.status_code = "holiday"
|
r.status_code = "holiday"
|
||||||
r.status_text = f"\U0001F3D6\uFE0F{h.name}"
|
r.status_text = f"\U0001F3D6\uFE0F{h.name}"
|
||||||
continue
|
continue
|
||||||
@@ -135,13 +168,19 @@ def enrich_attendance_with_leave(records, db):
|
|||||||
if not emp_leaves:
|
if not emp_leaves:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Aggregate all leaves by type (sum hours when multiple records of same type)
|
||||||
leave_by_type = {}
|
leave_by_type = {}
|
||||||
for lv in emp_leaves:
|
for lv in emp_leaves:
|
||||||
t = (lv.leave_type or "").upper()
|
t = (lv.leave_type or "").upper()
|
||||||
h_val = float(lv.hours or 0)
|
h_val = float(lv.hours or 0)
|
||||||
leave_by_type[t] = leave_by_type.get(t, 0.0) + h_val
|
leave_by_type[t] = leave_by_type.get(t, 0.0) + h_val
|
||||||
|
|
||||||
leave_text = " + ".join(f"{t}{h}h" for t, h in sorted(leave_by_type.items()))
|
# Build display text from aggregated values (no duplicates)
|
||||||
|
leave_parts = []
|
||||||
|
for t, h in sorted(leave_by_type.items()):
|
||||||
|
if h > 0:
|
||||||
|
leave_parts.append(f"{t}{h:.1f}h" if h != int(h) else f"{t}{int(h)}h")
|
||||||
|
leave_text = " + ".join(leave_parts)
|
||||||
|
|
||||||
original_code = (r.status_code or "").lower()
|
original_code = (r.status_code or "").lower()
|
||||||
if original_code == "missing":
|
if original_code == "missing":
|
||||||
@@ -151,7 +190,23 @@ def enrich_attendance_with_leave(records, db):
|
|||||||
else:
|
else:
|
||||||
r.status_code = "mixed_leave"
|
r.status_code = "mixed_leave"
|
||||||
r.status_text = leave_text
|
r.status_text = leave_text
|
||||||
|
elif original_code in ("al", "sl", "cl"):
|
||||||
|
# Pure leave status: replace with aggregated leave text
|
||||||
|
if len(leave_by_type) > 1:
|
||||||
|
r.status_code = "mixed_leave"
|
||||||
|
r.status_text = leave_text
|
||||||
|
else:
|
||||||
|
r.status_text = leave_text
|
||||||
|
elif original_code not in ("missing", "", "holiday", "al", "sl", "cl"):
|
||||||
|
# Non-leave attendance status: strip any previously-enriched leave suffix and rebuild
|
||||||
|
base_text = (r.status_text or "正常").split(" + ")[0] # keep only attendance part
|
||||||
|
base_text = base_text.split(" (")[0] # strip holiday annotation
|
||||||
|
if leave_text:
|
||||||
|
r.status_text = f"{base_text} + {leave_text}"
|
||||||
|
else:
|
||||||
|
r.status_text = base_text
|
||||||
else:
|
else:
|
||||||
|
# original_code is empty or holiday or already handled above
|
||||||
base = r.status_text or "正常"
|
base = r.status_text or "正常"
|
||||||
r.status_text = f"{base} + {leave_text}"
|
r.status_text = f"{base} + {leave_text}"
|
||||||
|
|
||||||
@@ -254,7 +309,7 @@ async def upload_excel(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Upload Excel file — saves file AND imports rows into SQL table."""
|
"""Upload Excel file - saves file AND imports rows into SQL table."""
|
||||||
if section not in ["attendance", "accident"]:
|
if section not in ["attendance", "accident"]:
|
||||||
raise HTTPException(status_code=400, detail="Invalid section")
|
raise HTTPException(status_code=400, detail="Invalid section")
|
||||||
|
|
||||||
@@ -487,7 +542,8 @@ async def _import_attendance_to_db(db: Session, headers, rows, user_id: int) ->
|
|||||||
# ============ Attendance Calculation ============
|
# ============ Attendance Calculation ============
|
||||||
def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]:
|
def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]:
|
||||||
"""Get the expected start and end time for a shift on a given weekday.
|
"""Get the expected start and end time for a shift on a given weekday.
|
||||||
Returns (start_time, end_time) tuple.
|
Returns (start_time, end_time, public_holiday) tuple.
|
||||||
|
public_holiday is 'Yes' if the shift requires working on public holidays, '休息' otherwise.
|
||||||
"""
|
"""
|
||||||
roster_path = get_excel_path("roster")
|
roster_path = get_excel_path("roster")
|
||||||
if not os.path.exists(roster_path):
|
if not os.path.exists(roster_path):
|
||||||
@@ -501,10 +557,12 @@ def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]:
|
|||||||
|
|
||||||
# Find column indices
|
# Find column indices
|
||||||
shift_col = None
|
shift_col = None
|
||||||
|
ph_col = None
|
||||||
for i, h in enumerate(headers):
|
for i, h in enumerate(headers):
|
||||||
if h == '班次':
|
if h == '班次':
|
||||||
shift_col = i
|
shift_col = i
|
||||||
break
|
elif h == 'public holiday':
|
||||||
|
ph_col = i
|
||||||
|
|
||||||
if shift_col is None:
|
if shift_col is None:
|
||||||
return None
|
return None
|
||||||
@@ -532,6 +590,7 @@ def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]:
|
|||||||
# Find the shift row
|
# Find the shift row
|
||||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||||
if row[shift_col] == shift_code:
|
if row[shift_col] == shift_code:
|
||||||
|
public_holiday = str(row[ph_col]).strip() if ph_col is not None else '休息'
|
||||||
day_col = day_col_map.get(target_day)
|
day_col = day_col_map.get(target_day)
|
||||||
if day_col is not None:
|
if day_col is not None:
|
||||||
schedule = row[day_col]
|
schedule = row[day_col]
|
||||||
@@ -544,7 +603,7 @@ def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]:
|
|||||||
end_str = parts[1].strip()
|
end_str = parts[1].strip()
|
||||||
start_time = datetime.strptime(start_str, '%H:%M').time()
|
start_time = datetime.strptime(start_str, '%H:%M').time()
|
||||||
end_time = datetime.strptime(end_str, '%H:%M').time()
|
end_time = datetime.strptime(end_str, '%H:%M').time()
|
||||||
return (start_time, end_time)
|
return (start_time, end_time, public_holiday)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -656,13 +715,17 @@ def calculate_attendance_status(check_in, check_out, shift_start, shift_end):
|
|||||||
expected_end_dt = datetime.combine(check_out_dt.date(), shift_end)
|
expected_end_dt = datetime.combine(check_out_dt.date(), shift_end)
|
||||||
|
|
||||||
if check_out_dt < expected_end_dt:
|
if check_out_dt < expected_end_dt:
|
||||||
diff = (expected_end_dt - check_out_dt).total_seconds() / 60
|
diff_seconds = (expected_end_dt - check_out_dt).total_seconds()
|
||||||
result["early_minutes"] = round(diff)
|
result["early_minutes"] = round(diff_seconds / 60) if diff_seconds >= 30 else 0
|
||||||
|
|
||||||
# Calculate OT minutes (leaving after scheduled end)
|
# Calculate OT minutes (leaving after scheduled end)
|
||||||
if check_out_dt > expected_end_dt:
|
if check_out_dt > expected_end_dt:
|
||||||
diff = (check_out_dt - expected_end_dt).total_seconds() / 60
|
diff_seconds = (check_out_dt - expected_end_dt).total_seconds()
|
||||||
result["ot_minutes"] = round(diff)
|
# Ignore tiny differences (< 30 seconds) to avoid floating-point noise
|
||||||
|
if diff_seconds >= 30:
|
||||||
|
result["ot_minutes"] = round(diff_seconds / 60)
|
||||||
|
else:
|
||||||
|
result["ot_minutes"] = 0
|
||||||
|
|
||||||
# Determine status code and text
|
# Determine status code and text
|
||||||
status_parts = []
|
status_parts = []
|
||||||
@@ -689,6 +752,16 @@ def calculate_attendance_status(check_in, check_out, shift_start, shift_end):
|
|||||||
result["status_code"] = "normal"
|
result["status_code"] = "normal"
|
||||||
|
|
||||||
result["status_text"] = " / ".join(status_parts) if status_parts else "正常"
|
result["status_text"] = " / ".join(status_parts) if status_parts else "正常"
|
||||||
|
|
||||||
|
# If late / early / OT > 30 min → mark as abnormal (override after status_text is set)
|
||||||
|
if result["late_minutes"] > 30 or result["early_minutes"] > 30 or result["ot_minutes"] > 30:
|
||||||
|
result["status_code"] = "abnormal"
|
||||||
|
if result["late_minutes"] > 30:
|
||||||
|
result["status_text"] = f"異常-遲到{result['late_minutes']}分"
|
||||||
|
elif result["early_minutes"] > 30:
|
||||||
|
result["status_text"] = f"異常-早退{result['early_minutes']}分"
|
||||||
|
else:
|
||||||
|
result["status_text"] = f"異常-OT{result['ot_minutes']}分"
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -758,7 +831,16 @@ async def get_lateness_stats(current_user: User = Depends(get_current_user)):
|
|||||||
totals["ot"] += status["ot_minutes"]
|
totals["ot"] += status["ot_minutes"]
|
||||||
|
|
||||||
stats = sorted(employee_stats.values(), key=lambda x: x["late_minutes"], reverse=True)
|
stats = sorted(employee_stats.values(), key=lambda x: x["late_minutes"], reverse=True)
|
||||||
return {"stats": stats, **totals}
|
return {
|
||||||
|
"stats": stats,
|
||||||
|
"total_late_count": sum(s["late_count"] for s in employee_stats.values()),
|
||||||
|
"total_late_minutes": totals["late"],
|
||||||
|
"total_early_count": sum(s["early_count"] for s in employee_stats.values()),
|
||||||
|
"total_early_minutes": totals["early"],
|
||||||
|
"total_ot_count": sum(s["ot_count"] for s in employee_stats.values()),
|
||||||
|
"total_ot_minutes": totals["ot"],
|
||||||
|
"total_missing_count": totals["missing"],
|
||||||
|
}
|
||||||
|
|
||||||
@app.get("/api/attendance/lateness/records")
|
@app.get("/api/attendance/lateness/records")
|
||||||
async def get_lateness_records(
|
async def get_lateness_records(
|
||||||
@@ -1022,6 +1104,20 @@ async def dashboard_summary(
|
|||||||
"staff_count": len(staff_set),
|
"staff_count": len(staff_set),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@app.get("/api/attendance/status_texts")
|
||||||
|
async def attendance_status_texts(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return BASIC status categories for filter dropdown.
|
||||||
|
|
||||||
|
The frontend renders only 5 basic statuses (正常/異常/缺勤/公假/請假),
|
||||||
|
so the dropdown should offer those exact values rather than every
|
||||||
|
raw status_text variant (e.g. '異常-遲到39分'). Keeping it server-driven
|
||||||
|
so client and server agree on the filter vocabulary.
|
||||||
|
"""
|
||||||
|
return ['正常', '異常', '缺勤', '公假', '請假', '休息']
|
||||||
|
|
||||||
@app.get("/api/attendance/stats")
|
@app.get("/api/attendance/stats")
|
||||||
async def attendance_stats(
|
async def attendance_stats(
|
||||||
date_from: Optional[str] = None,
|
date_from: Optional[str] = None,
|
||||||
@@ -1158,10 +1254,18 @@ async def attendance_records(
|
|||||||
date_to: Optional[str] = None,
|
date_to: Optional[str] = None,
|
||||||
staff: Optional[str] = None,
|
staff: Optional[str] = None,
|
||||||
status: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
|
status_text: Optional[str] = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Paginated records from attendance_records (SQL)."""
|
"""Paginated records from attendance_records (SQL).
|
||||||
|
|
||||||
|
status_text filter runs AFTER enrichment so it matches the badge rendered
|
||||||
|
from post-enrich status_code. Otherwise missing-day-with-leave records
|
||||||
|
would surface under '缺勤' but render as '請假'/'公假' badges (mismatch).
|
||||||
|
Trade-off: status_text/sort_by filter runs in Python after enrich instead
|
||||||
|
of SQL — fine while dataset stays small (~80 records), log if it grows.
|
||||||
|
"""
|
||||||
from datetime import datetime as _dt
|
from datetime import datetime as _dt
|
||||||
from sqlalchemy import or_, and_
|
from sqlalchemy import or_, and_
|
||||||
|
|
||||||
@@ -1188,20 +1292,74 @@ async def attendance_records(
|
|||||||
AttendanceRecord.shift_code.like(like),
|
AttendanceRecord.shift_code.like(like),
|
||||||
))
|
))
|
||||||
|
|
||||||
# sort
|
# Pre-sort by raw date so enrich-then-filter ordering is deterministic.
|
||||||
sortable = {
|
sortable_raw = {
|
||||||
"date": AttendanceRecord.date,
|
"date": AttendanceRecord.date,
|
||||||
"employee_name": AttendanceRecord.employee_name,
|
"employee_name": AttendanceRecord.employee_name,
|
||||||
"weekday": AttendanceRecord.weekday,
|
"weekday": AttendanceRecord.weekday,
|
||||||
"shift_code": AttendanceRecord.shift_code,
|
"shift_code": AttendanceRecord.shift_code,
|
||||||
"status_code": AttendanceRecord.status_code,
|
|
||||||
}
|
}
|
||||||
col = sortable.get(sort_by, AttendanceRecord.date)
|
col = sortable_raw.get(sort_by, AttendanceRecord.date)
|
||||||
q = q.order_by(col.desc() if sort_order == "desc" else col.asc())
|
q = q.order_by(col.desc() if sort_order == "desc" else col.asc())
|
||||||
|
|
||||||
total = q.count()
|
all_rows = q.all()
|
||||||
rows = q.offset((page - 1) * per_page).limit(per_page).all()
|
if len(all_rows) > 5000:
|
||||||
enrich_attendance_with_leave(rows, db)
|
logger.warning(
|
||||||
|
"attendance_records: in-memory filter over %d rows — consider prefiltering via date range",
|
||||||
|
len(all_rows),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enrich ALL rows so we can filter by post-enrich status_code.
|
||||||
|
enrich_attendance_with_leave(all_rows, db)
|
||||||
|
|
||||||
|
# Apply status_text filter on enriched status_code (post-leave/holiday override)
|
||||||
|
if status_text:
|
||||||
|
abnormal_codes = ('abnormal', 'late', 'early', 'ot',
|
||||||
|
'late_early', 'late_ot', 'early_ot')
|
||||||
|
leave_codes = ('al', 'sl', 'cl', 'mixed_leave')
|
||||||
|
if status_text in ('abnormal', '異常'):
|
||||||
|
rows = [r for r in all_rows if (r.status_code or '').lower() in abnormal_codes]
|
||||||
|
elif status_text == 'late':
|
||||||
|
rows = [r for r in all_rows if (r.status_code or '').lower() in ('late', 'late_early', 'late_ot')]
|
||||||
|
elif status_text == 'early':
|
||||||
|
rows = [r for r in all_rows if (r.status_code or '').lower() in ('early', 'late_early', 'early_ot')]
|
||||||
|
elif status_text == 'ot':
|
||||||
|
rows = [r for r in all_rows if (r.status_code or '').lower() in ('ot', 'late_ot', 'early_ot')]
|
||||||
|
elif status_text in ('missing', '缺勤'):
|
||||||
|
rows = [r for r in all_rows if (r.status_code or '').lower() == 'missing']
|
||||||
|
elif status_text in ('holiday', '公假'):
|
||||||
|
# Holiday shift OR raw records carrying a holiday suffix in status_text
|
||||||
|
# (e.g. '異常 (🎉香港特區成立紀念日)' where the employee worked on the holiday).
|
||||||
|
rows = [r for r in all_rows
|
||||||
|
if (r.status_code or '').lower() == 'holiday'
|
||||||
|
or '🎉' in (r.status_text or '')]
|
||||||
|
elif status_text in leave_codes:
|
||||||
|
rows = [r for r in all_rows if (r.status_code or '').lower() == status_text]
|
||||||
|
elif status_text in ('leave', '請假'):
|
||||||
|
# Any leave presence: pure leave status_code OR a leave suffix appended
|
||||||
|
# to a normal/abnormal/ot code (employee worked but took leave mid-day).
|
||||||
|
# Suffix format produced by enrich: ' + SL2h', ' + AL4h', ' + CL4h', etc.
|
||||||
|
rows = [r for r in all_rows
|
||||||
|
if (r.status_code or '').lower() in leave_codes
|
||||||
|
or any(f'+ {tag}' in (r.status_text or '')
|
||||||
|
for tag in ('SL', 'AL', 'CL'))]
|
||||||
|
elif status_text in ('normal', '正常'):
|
||||||
|
rows = [r for r in all_rows if (r.status_code or '').lower() == 'normal']
|
||||||
|
elif status_text in ('rest', '休息'):
|
||||||
|
rows = [r for r in all_rows if (r.status_code or '').lower() == 'rest']
|
||||||
|
else:
|
||||||
|
# Fallback to raw status_text substring search
|
||||||
|
rows = [r for r in all_rows
|
||||||
|
if r.status_text and status_text in r.status_text]
|
||||||
|
else:
|
||||||
|
rows = all_rows
|
||||||
|
|
||||||
|
# Python sort by status_code when requested (raw DB only handled the basic columns)
|
||||||
|
if sort_by == 'status_code':
|
||||||
|
rows.sort(key=lambda r: (r.status_code or ''), reverse=(sort_order == 'desc'))
|
||||||
|
|
||||||
|
total = len(rows)
|
||||||
|
page_rows = rows[(page - 1) * per_page: (page - 1) * per_page + per_page]
|
||||||
|
|
||||||
def to_dict(r):
|
def to_dict(r):
|
||||||
return {
|
return {
|
||||||
@@ -1227,7 +1385,7 @@ async def attendance_records(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"records": [to_dict(r) for r in rows],
|
"records": [to_dict(r) for r in page_rows],
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": per_page,
|
"per_page": per_page,
|
||||||
@@ -1764,8 +1922,8 @@ async def upload_leaves(
|
|||||||
errors.append(f"Row {i+2}: {r['employee_name']} {d} {r['leave_type']} was edited at {existing.last_edited_at.isoformat()} - skipped")
|
errors.append(f"Row {i+2}: {r['employee_name']} {d} {r['leave_type']} was edited at {existing.last_edited_at.isoformat()} - skipped")
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
# Not edited - safe to update
|
# Not edited - safe to update (or multiple identical records exist - merge by adding hours)
|
||||||
existing.hours = r["hours"]
|
existing.hours = r["hours"] # overwrite with latest uploaded value
|
||||||
existing.reason = r["reason"]
|
existing.reason = r["reason"]
|
||||||
existing.approved_by = r["approved_by"]
|
existing.approved_by = r["approved_by"]
|
||||||
existing.source = "csv_upload"
|
existing.source = "csv_upload"
|
||||||
@@ -1796,6 +1954,151 @@ async def upload_leaves(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Backup / Restore + Version
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
import shutil, hashlib
|
||||||
|
from datetime import datetime as _dt2
|
||||||
|
|
||||||
|
BACKUP_DIR = Path("/app/data/backups")
|
||||||
|
DB_PATH = "/app/data/aars.db"
|
||||||
|
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _backup_path(name):
|
||||||
|
ts = _dt2.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
return BACKUP_DIR / f"aars_backup_{ts}_{name}.db"
|
||||||
|
|
||||||
|
@app.get("/api/admin/backup/list")
|
||||||
|
async def list_backups(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List all backup files."""
|
||||||
|
files = []
|
||||||
|
for p in sorted(BACKUP_DIR.glob("aars_backup_*.db"), reverse=True):
|
||||||
|
files.append({
|
||||||
|
"filename": p.name,
|
||||||
|
"size": p.stat().st_size,
|
||||||
|
"created": _dt2.fromtimestamp(p.stat().st_mtime).isoformat(),
|
||||||
|
})
|
||||||
|
return files
|
||||||
|
|
||||||
|
@app.post("/api/admin/backup/create")
|
||||||
|
async def create_backup(
|
||||||
|
note: str = "",
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Create a manual backup."""
|
||||||
|
src = Path(DB_PATH)
|
||||||
|
if not src.exists():
|
||||||
|
raise HTTPException(status_code=500, detail="Database file not found")
|
||||||
|
dst = _backup_path("manual")
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
return {"saved": dst.name, "size": dst.stat().st_size, "note": note}
|
||||||
|
|
||||||
|
@app.get("/api/admin/backup/download/{filename}")
|
||||||
|
async def download_backup(
|
||||||
|
filename: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Download a backup file."""
|
||||||
|
fp = BACKUP_DIR / filename
|
||||||
|
if not fp.exists() or ".." in filename:
|
||||||
|
raise HTTPException(status_code=404, detail="Backup not found")
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
return FileResponse(fp, media_type="application/x-sqlite3", filename=filename)
|
||||||
|
|
||||||
|
@app.post("/api/admin/backup/restore")
|
||||||
|
async def restore_backup(
|
||||||
|
filename: str = Body(...),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Restore from backup. Creates an auto-backup of current state first."""
|
||||||
|
fp = BACKUP_DIR / filename
|
||||||
|
if not fp.exists() or ".." in filename:
|
||||||
|
raise HTTPException(status_code=404, detail="Backup not found")
|
||||||
|
# Auto-backup current state
|
||||||
|
src = Path(DB_PATH)
|
||||||
|
if src.exists():
|
||||||
|
auto_dst = _backup_path("auto_pre_restore")
|
||||||
|
shutil.copy2(src, auto_dst)
|
||||||
|
# Restore
|
||||||
|
shutil.copy2(fp, src)
|
||||||
|
return {"restored": filename, "auto_backup": auto_dst.name if src.exists() else None}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/admin/backup/{filename}")
|
||||||
|
async def delete_backup(
|
||||||
|
filename: str,
|
||||||
|
current_user: User = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""Delete a backup file."""
|
||||||
|
fp = BACKUP_DIR / filename
|
||||||
|
if not fp.exists() or ".." in filename:
|
||||||
|
raise HTTPException(status_code=404, detail="Backup not found")
|
||||||
|
fp.unlink()
|
||||||
|
return {"deleted": filename}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/reset/{section}")
|
||||||
|
async def reset_section(
|
||||||
|
section: str,
|
||||||
|
current_user: User = Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Reset (truncate) a specific data section. Creates auto-backup first."""
|
||||||
|
single_models = {"attendance": AttendanceRecord, "accident": Accident}
|
||||||
|
if section not in single_models and section not in ("holidays", "leaves"):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown section: {section}")
|
||||||
|
# Auto-backup before reset
|
||||||
|
src = Path(DB_PATH)
|
||||||
|
auto_dst = None
|
||||||
|
if src.exists():
|
||||||
|
auto_dst = _backup_path(f"auto_pre_{section}_reset")
|
||||||
|
shutil.copy2(src, auto_dst)
|
||||||
|
# Truncate table(s)
|
||||||
|
if section == "holidays":
|
||||||
|
db.execute(Holiday.__table__.delete())
|
||||||
|
db.execute(LeaveRecord.__table__.delete())
|
||||||
|
elif section == "leaves":
|
||||||
|
db.execute(LeaveRecord.__table__.delete())
|
||||||
|
else:
|
||||||
|
db.execute(single_models[section].__table__.delete())
|
||||||
|
db.commit()
|
||||||
|
return {"reset": section, "auto_backup": auto_dst.name if auto_dst else None}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/version")
|
||||||
|
async def get_version(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get app version info."""
|
||||||
|
import subprocess
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "log", "-1", "--format=%H|%cd", "--date=iso"],
|
||||||
|
cwd="/app",
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
parts = result.stdout.strip().split("|")
|
||||||
|
commit = parts[0]
|
||||||
|
date = parts[1] if len(parts) > 1 else ""
|
||||||
|
else:
|
||||||
|
commit = "unknown"
|
||||||
|
date = ""
|
||||||
|
except Exception:
|
||||||
|
commit = "unknown"
|
||||||
|
date = ""
|
||||||
|
return {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"commit": commit,
|
||||||
|
"date": date,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/{section}")
|
@app.get("/api/{section}")
|
||||||
async def list_records(
|
async def list_records(
|
||||||
section: str,
|
section: str,
|
||||||
@@ -1914,7 +2217,7 @@ async def _import_accident_to_db(db: Session, headers, rows, user_id: int) -> di
|
|||||||
Col 8 涉及的患者或相關人員 -> patient_or_staff
|
Col 8 涉及的患者或相關人員 -> patient_or_staff
|
||||||
Col 9 事件描述 -> long_description
|
Col 9 事件描述 -> long_description
|
||||||
Col 10 如有事件相關照片可提供-> incident_photos
|
Col 10 如有事件相關照片可提供-> incident_photos
|
||||||
Col 11 處理情況/已採取的行動-> action_taken
|
Col 11 處理情況/已採取的行動-> action_taken
|
||||||
Col 12 需要管理層介入的事項 -> needs_escalation
|
Col 12 需要管理層介入的事項 -> needs_escalation
|
||||||
Col 13 第 12 欄 -> (skipped)
|
Col 13 第 12 欄 -> (skipped)
|
||||||
Col 14 分數 -> incident_score
|
Col 14 分數 -> incident_score
|
||||||
@@ -2632,7 +2935,7 @@ async def accident_export_excel(
|
|||||||
# User types case_no in C5 of 個案查詢 sheet.
|
# User types case_no in C5 of 個案查詢 sheet.
|
||||||
# xlsxwriter requires single quotes around sheet names containing non-ASCII.
|
# xlsxwriter requires single quotes around sheet names containing non-ASCII.
|
||||||
# Use proper xlsxwriter hyperlink element (write_url with internal:Sheet!Cell).
|
# Use proper xlsxwriter hyperlink element (write_url with internal:Sheet!Cell).
|
||||||
# HYPERLINK formula would cache value 0 — write_url sets display text directly.
|
# HYPERLINK formula would cache value 0 - write_url sets display text directly.
|
||||||
overview.write_url(row_n, 6,
|
overview.write_url(row_n, 6,
|
||||||
f"internal:個案查詢!A1",
|
f"internal:個案查詢!A1",
|
||||||
link_fmt,
|
link_fmt,
|
||||||
@@ -2640,7 +2943,7 @@ async def accident_export_excel(
|
|||||||
tip=f"跳到「個案查詢」輸入個案 ID {r.id}")
|
tip=f"跳到「個案查詢」輸入個案 ID {r.id}")
|
||||||
overview.set_row(row_n, 18)
|
overview.set_row(row_n, 18)
|
||||||
|
|
||||||
# ============ Charts block — 統計圖表 (Sheet 1 總覽 右側 columns I-N) ============
|
# ============ Charts block - 統計圖表 (Sheet 1 總覽 右側 columns I-N) ============
|
||||||
# 4 charts: Severity Pie, Severity Column, Department Bar TOP 10, Monthly Trend Line
|
# 4 charts: Severity Pie, Severity Column, Department Bar TOP 10, Monthly Trend Line
|
||||||
# Position: I column (right of KPI + 個案索引 blocks), so no overlap with case index.
|
# Position: I column (right of KPI + 個案索引 blocks), so no overlap with case index.
|
||||||
|
|
||||||
@@ -2750,7 +3053,7 @@ async def accident_export_excel(
|
|||||||
# ============ Sheet 3 (built FIRST so detail-VLOOKUP can reference it): 明細 ============
|
# ============ Sheet 3 (built FIRST so detail-VLOOKUP can reference it): 明細 ============
|
||||||
# Build it now so that 個案查詢 formulas can resolve its range properly.
|
# Build it now so that 個案查詢 formulas can resolve its range properly.
|
||||||
# However, xlsxwriter's defined_name allows forward reference; formula text is
|
# However, xlsxwriter's defined_name allows forward reference; formula text is
|
||||||
# what matters at runtime — even if the sheet is added later, the formula string
|
# what matters at runtime - even if the sheet is added later, the formula string
|
||||||
# '明細!A:O' will be valid when Excel opens the file.
|
# '明細!A:O' will be valid when Excel opens the file.
|
||||||
# We define 明細 here so that the formula range 明細!A2:O{max_row} resolves.
|
# We define 明細 here so that the formula range 明細!A2:O{max_row} resolves.
|
||||||
# But xlsxwriter requires sheets to be added in the order they're defined.
|
# But xlsxwriter requires sheets to be added in the order they're defined.
|
||||||
@@ -2810,12 +3113,12 @@ async def accident_export_excel(
|
|||||||
"align": "center", "num_format": "@"})
|
"align": "center", "num_format": "@"})
|
||||||
# B5 = label only (NOT merged with C5). Otherwise user-typed text goes to
|
# B5 = label only (NOT merged with C5). Otherwise user-typed text goes to
|
||||||
# B5 (top-left of merge) instead of C5 where the formula references.
|
# B5 (top-left of merge) instead of C5 where the formula references.
|
||||||
detail.write("B5", "輸入個案 ID:", input_label_fmt)
|
detail.write("B5", "輸入個案 ID:", input_label_fmt)
|
||||||
# C5 = case_no input cell (NOT merged)
|
# C5 = case_no input cell (NOT merged)
|
||||||
detail.write_string("C5", "", text_input_fmt)
|
detail.write_string("C5", "", text_input_fmt)
|
||||||
# Hint
|
# Hint
|
||||||
detail.merge_range("B6:C6",
|
detail.merge_range("B6:C6",
|
||||||
"提示:個案 ID 喺「總覽」個案索引或「明細」表嘅 id 欄。輸入後下面自動顯示。",
|
"提示:個案 ID 喺「總覽」個案索引或「明細」表嘅 id 欄。輸入後下面自動顯示。",
|
||||||
subtitle_fmt)
|
subtitle_fmt)
|
||||||
|
|
||||||
# Detail rows: each pulls from 明細!<col><row> via VLOOKUP keyed on id (col A)
|
# Detail rows: each pulls from 明細!<col><row> via VLOOKUP keyed on id (col A)
|
||||||
@@ -2854,7 +3157,7 @@ async def accident_export_excel(
|
|||||||
col_sev = col_index_lookup.get("severity", 6)
|
col_sev = col_index_lookup.get("severity", 6)
|
||||||
col_emp = col_index_lookup.get("employee_name", 3)
|
col_emp = col_index_lookup.get("employee_name", 3)
|
||||||
col_desc = col_index_lookup.get("description", 5)
|
col_desc = col_index_lookup.get("description", 5)
|
||||||
# Use INDIRECT or simply use cell reference for ID — but ID varies per row.
|
# Use INDIRECT or simply use cell reference for ID - but ID varies per row.
|
||||||
# Easier: each row's ID is in B<row_n>; build formula with that.
|
# Easier: each row's ID is in B<row_n>; build formula with that.
|
||||||
# VLOOKUP needs lookup_value, table, col_index, FALSE.
|
# VLOOKUP needs lookup_value, table, col_index, FALSE.
|
||||||
# table 明細!$A:$O.
|
# table 明細!$A:$O.
|
||||||
@@ -2901,13 +3204,13 @@ async def accident_export_excel(
|
|||||||
compare_start_row = list_header_row + n_list_rows + 3
|
compare_start_row = list_header_row + n_list_rows + 3
|
||||||
detail.write(f"B{compare_start_row}", "兩個個案 並列比較 Side-by-Side", section_fmt)
|
detail.write(f"B{compare_start_row}", "兩個個案 並列比較 Side-by-Side", section_fmt)
|
||||||
detail.write(f"B{compare_start_row + 1}",
|
detail.write(f"B{compare_start_row + 1}",
|
||||||
"輸入兩個個案 ID (左個案 A, 右個案 B),對比每個欄位。空白表示無資料。",
|
"輸入兩個個案 ID (左個案 A, 右個案 B),對比每個欄位。空白表示無資料。",
|
||||||
subtitle_fmt)
|
subtitle_fmt)
|
||||||
|
|
||||||
cmp_label_row = compare_start_row + 2
|
cmp_label_row = compare_start_row + 2
|
||||||
detail.write(cmp_label_row, 1, "個案 A ID:", input_label_fmt)
|
detail.write(cmp_label_row, 1, "個案 A ID:", input_label_fmt)
|
||||||
detail.write_string(cmp_label_row, 2, "", text_input_fmt)
|
detail.write_string(cmp_label_row, 2, "", text_input_fmt)
|
||||||
detail.write(cmp_label_row, 4, "個案 B ID:", input_label_fmt)
|
detail.write(cmp_label_row, 4, "個案 B ID:", input_label_fmt)
|
||||||
detail.write_string(cmp_label_row, 5, "", text_input_fmt)
|
detail.write_string(cmp_label_row, 5, "", text_input_fmt)
|
||||||
detail.set_column("F:F", 50)
|
detail.set_column("F:F", 50)
|
||||||
|
|
||||||
@@ -2939,7 +3242,7 @@ async def accident_export_excel(
|
|||||||
|
|
||||||
# Hint at top
|
# Hint at top
|
||||||
detail.merge_range(f"B{cmp_data_start + len(detail_fields) + 2}:C{cmp_data_start + len(detail_fields) + 2}",
|
detail.merge_range(f"B{cmp_data_start + len(detail_fields) + 2}:C{cmp_data_start + len(detail_fields) + 2}",
|
||||||
"💡 全部公式 (VLOOKUP),無需啟用巨集。Excel/Numbers 都正常運作。",
|
"💡 全部公式 (VLOOKUP),無需啟用巨集。Excel/Numbers 都正常運作。",
|
||||||
subtitle_fmt)
|
subtitle_fmt)
|
||||||
|
|
||||||
# ============ Sheet 3: 明細 ============
|
# ============ Sheet 3: 明細 ============
|
||||||
@@ -3204,31 +3507,45 @@ async def export_excel(
|
|||||||
earliest = min((r.date for r in rows if r.date), default=None)
|
earliest = min((r.date for r in rows if r.date), default=None)
|
||||||
overview.merge_range("F7:G7", earliest.isoformat() if earliest else "-", kpi_value_fmt)
|
overview.merge_range("F7:G7", earliest.isoformat() if earliest else "-", kpi_value_fmt)
|
||||||
|
|
||||||
# Status distribution table (B10:D)
|
# Status distribution table - simplified 6-group (B10:D)
|
||||||
overview.write("B10", "出勤狀態分佈", section_fmt)
|
overview.write("B10", "出勤狀態分佈", section_fmt)
|
||||||
overview.write("B11", "Status", header_fmt_ov)
|
overview.write("B11", "Status", header_fmt_ov)
|
||||||
overview.write("C11", "數量", header_fmt_ov)
|
overview.write("C11", "數量", header_fmt_ov)
|
||||||
overview.write("D11", "百分比", header_fmt_ov)
|
overview.write("D11", "百分比", header_fmt_ov)
|
||||||
status_codes_order = ["normal", "late", "late_early", "late_ot", "early", "early_ot", "ot", "abnormal", "missing",
|
# Simplified 6-group status
|
||||||
"holiday", "al", "sl", "cl", "mixed_leave"]
|
_grp_status = {
|
||||||
status_labels = {
|
"正常": 0, "遲到": 0, "早退": 0, "加班": 0, "異常/缺勤": 0, "假期/請假": 0, "(空)": 0,
|
||||||
"normal": "正常", "late": "遲到", "late_early": "遲到+早走",
|
|
||||||
"late_ot": "遲到+加班", "early": "早走", "early_ot": "早走+加班",
|
|
||||||
"ot": "加班", "abnormal": "異常", "missing": "缺勤",
|
|
||||||
"holiday": "🏖️公假", "al": "年假 AL", "sl": "病假 SL", "cl": "補鐘 CL",
|
|
||||||
"mixed_leave": "混合假",
|
|
||||||
}
|
}
|
||||||
for i, code in enumerate(status_codes_order):
|
for r in rows:
|
||||||
count = sum(1 for r in rows if (r.status_code or "").lower() == code)
|
c = (r.status_code or "").lower()
|
||||||
overview.write(11 + i, 1, status_labels.get(code, code), cell_fmt_ov)
|
if not c.strip():
|
||||||
|
_grp_status["(空)"] += 1
|
||||||
|
elif c == "normal":
|
||||||
|
_grp_status["正常"] += 1
|
||||||
|
elif "late" in c and "early" in c:
|
||||||
|
_grp_status["遲到"] += 1
|
||||||
|
elif "late" in c:
|
||||||
|
_grp_status["遲到"] += 1
|
||||||
|
elif "early" in c:
|
||||||
|
_grp_status["早退"] += 1
|
||||||
|
elif "ot" in c:
|
||||||
|
_grp_status["加班"] += 1
|
||||||
|
elif c in ("abnormal", "missing", "", " "):
|
||||||
|
_grp_status["異常/缺勤"] += 1
|
||||||
|
else:
|
||||||
|
_grp_status["假期/請假"] += 1
|
||||||
|
_total = len(rows)
|
||||||
|
for i, (label, count) in enumerate(_grp_status.items()):
|
||||||
|
overview.write(11 + i, 1, label, cell_fmt_ov)
|
||||||
overview.write(11 + i, 2, count, cell_fmt_ov)
|
overview.write(11 + i, 2, count, cell_fmt_ov)
|
||||||
pct = (count / len(rows) * 100) if rows else 0
|
pct = (count / _total * 100) if _total else 0
|
||||||
overview.write(11 + i, 3, f"{pct:.1f}%", cell_fmt_ov)
|
overview.write(11 + i, 3, f"{pct:.1f}%", cell_fmt_ov)
|
||||||
unk = sum(1 for r in rows if not (r.status_code or "").strip())
|
_n_status_groups = len(_grp_status)
|
||||||
overview.write(11 + len(status_codes_order), 1, "(空)", cell_fmt_ov)
|
# Also update KPI counters to match simplified grouping
|
||||||
overview.write(11 + len(status_codes_order), 2, unk, cell_fmt_ov)
|
n_late_grp = _grp_status["遲到"]
|
||||||
pct_unk = (unk / len(rows) * 100) if rows else 0
|
n_early_grp = _grp_status["早退"]
|
||||||
overview.write(11 + len(status_codes_order), 3, f"{pct_unk:.1f}%", cell_fmt_ov)
|
n_ot_grp = _grp_status["加班"]
|
||||||
|
n_abn_grp = _grp_status["異常/缺勤"]
|
||||||
|
|
||||||
# Department TOP 10 table (F10:G)
|
# Department TOP 10 table (F10:G)
|
||||||
overview.write("F10", "部門記錄數 (TOP 10)", section_fmt)
|
overview.write("F10", "部門記錄數 (TOP 10)", section_fmt)
|
||||||
@@ -3246,8 +3563,8 @@ async def export_excel(
|
|||||||
status_pie = wb.add_chart({"type": "pie"})
|
status_pie = wb.add_chart({"type": "pie"})
|
||||||
status_pie.add_series({
|
status_pie.add_series({
|
||||||
"name": "出勤狀態分佈",
|
"name": "出勤狀態分佈",
|
||||||
"categories": ["總覽", 11, 1, 11 + len(status_codes_order), 1],
|
"categories": ["總覽", 11, 1, 11 + _n_status_groups, 1],
|
||||||
"values": ["總覽", 11, 2, 11 + len(status_codes_order), 2],
|
"values": ["總覽", 11, 2, 11 + _n_status_groups, 2],
|
||||||
"data_labels": {"percentage": True, "category": False, "position": "outside_end"},
|
"data_labels": {"percentage": True, "category": False, "position": "outside_end"},
|
||||||
})
|
})
|
||||||
status_pie.set_title({"name": "出勤狀態分佈 (Pie)"})
|
status_pie.set_title({"name": "出勤狀態分佈 (Pie)"})
|
||||||
@@ -3277,8 +3594,8 @@ async def export_excel(
|
|||||||
status_col_chart = wb.add_chart({"type": "column"})
|
status_col_chart = wb.add_chart({"type": "column"})
|
||||||
status_col_chart.add_series({
|
status_col_chart.add_series({
|
||||||
"name": "出勤狀態 數量",
|
"name": "出勤狀態 數量",
|
||||||
"categories": ["總覽", 11, 1, 11 + len(status_codes_order) - 1, 1],
|
"categories": ["總覽", 11, 1, 11 + _n_status_groups - 1, 1],
|
||||||
"values": ["總覽", 11, 2, 11 + len(status_codes_order) - 1, 2],
|
"values": ["總覽", 11, 2, 11 + _n_status_groups - 1, 2],
|
||||||
"fill": {"color": "#10B981"},
|
"fill": {"color": "#10B981"},
|
||||||
"border": {"color": "#065F46"},
|
"border": {"color": "#065F46"},
|
||||||
"data_labels": {"value": True},
|
"data_labels": {"value": True},
|
||||||
|
|||||||
@@ -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>
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aars-frontend",
|
"name": "aars-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aars-frontend",
|
"name": "aars-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"lucide-react": "^0.441.0",
|
"lucide-react": "^0.441.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "aars-frontend",
|
"name": "aars-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import AccidentDetail from './pages/accident/Detail'
|
|||||||
import UploadPage from './pages/upload/Upload'
|
import UploadPage from './pages/upload/Upload'
|
||||||
import RosterPage from './pages/roster/Roster'
|
import RosterPage from './pages/roster/Roster'
|
||||||
import HolidaysPage from './pages/holidays/Holidays'
|
import HolidaysPage from './pages/holidays/Holidays'
|
||||||
|
import Settings from './pages/Settings'
|
||||||
|
|
||||||
function ProtectedRoute({ children }) {
|
function ProtectedRoute({ children }) {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
@@ -51,6 +52,7 @@ export default function App() {
|
|||||||
<Route path="upload" element={<UploadPage />} />
|
<Route path="upload" element={<UploadPage />} />
|
||||||
<Route path="roster" element={<RosterPage />} />
|
<Route path="roster" element={<RosterPage />} />
|
||||||
<Route path="holidays" element={<HolidaysPage />} />
|
<Route path="holidays" element={<HolidaysPage />} />
|
||||||
|
<Route path="settings" element={<Settings />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { LogOut, Users, AlertTriangle, LayoutDashboard, Upload, Calendar, Menu, X, Palmtree } from 'lucide-react'
|
import { LogOut, Users, AlertTriangle, LayoutDashboard, Upload, Calendar, Menu, X, Palmtree, Settings as SettingsIcon } from 'lucide-react'
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: '/', end: true, icon: LayoutDashboard, label: 'Dashboard' },
|
{ to: '/', end: true, icon: LayoutDashboard, label: 'Dashboard' },
|
||||||
@@ -9,6 +9,7 @@ const NAV_ITEMS = [
|
|||||||
{ to: '/accident', icon: AlertTriangle, label: '意外 Accident' },
|
{ to: '/accident', icon: AlertTriangle, label: '意外 Accident' },
|
||||||
{ to: '/roster', icon: Calendar, label: '班次 Roster' },
|
{ to: '/roster', icon: Calendar, label: '班次 Roster' },
|
||||||
{ to: '/holidays', icon: Palmtree, label: '假期 Holidays' },
|
{ to: '/holidays', icon: Palmtree, label: '假期 Holidays' },
|
||||||
|
{ to: '/settings', icon: SettingsIcon, label: '設定 Settings' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
import { CheckCircle, AlertCircle, Clock, X, AlertTriangle, Briefcase, Stethoscope, Timer, PartyPopper } from 'lucide-react'
|
import { CheckCircle, AlertCircle, CalendarX, Palmtree, Briefcase, Coffee } from 'lucide-react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reusable status badge for attendance records.
|
* Reusable status badge for attendance records.
|
||||||
* Handles all status codes including enriched leave types:
|
* Simplified to BASIC status categories — no detailed breakdowns.
|
||||||
* normal / late / late_early / late_ot / early / early_ot / ot / abnormal / missing
|
*
|
||||||
* holiday / al / sl / cl / mixed_leave
|
* Status mapping (driven by backend status_code / status_text):
|
||||||
|
* - missing → 缺勤 (grey)
|
||||||
|
* - holiday → 公假 (indigo)
|
||||||
|
* - al / sl / cl / mixed_leave → 請假 (violet)
|
||||||
|
* - normal → 正常 (green)
|
||||||
|
* - everything else (late/early/ot/abnormal/late_early/...) → 異常 (orange)
|
||||||
|
*
|
||||||
|
* If status_text carries a leave/holiday suffix (e.g. '+ SL2h', '🎉香港…'),
|
||||||
|
* a small secondary chip is rendered next to the main badge so the list page
|
||||||
|
* shows the underlying leave/holiday detail without expanding the row.
|
||||||
*
|
*
|
||||||
* Props:
|
* Props:
|
||||||
* - code: status_code string
|
* - code: status_code string
|
||||||
* - text: status_text string (already enriched)
|
* - text: status_text string (fallback signal + suffix source)
|
||||||
* - size: 'sm' | 'md' (default 'sm')
|
* - size: 'sm' | 'md' (default 'sm')
|
||||||
*/
|
*/
|
||||||
export default function StatusBadge({ code, text = '', size = 'sm' }) {
|
export default function StatusBadge({ code = '', text = '', size = 'sm' }) {
|
||||||
const sizeCls = size === 'md'
|
const sizeCls = size === 'md'
|
||||||
? 'px-3 py-1 text-sm'
|
? 'px-3 py-1 text-sm'
|
||||||
: 'px-2 py-0.5 text-xs'
|
: 'px-2 py-0.5 text-xs'
|
||||||
|
|
||||||
// Split text into main + leave parts (after ' + ' separator)
|
|
||||||
const parts = text.split(/\s*\+\s*/).filter(Boolean)
|
|
||||||
const mainText = parts[0] || ''
|
|
||||||
const leaveParts = parts.slice(1)
|
|
||||||
|
|
||||||
const badge = (icon, label, colorCls) => (
|
const badge = (icon, label, colorCls) => (
|
||||||
<span className={`inline-flex items-center gap-1 ${sizeCls} rounded-full font-medium ${colorCls}`}>
|
<span className={`inline-flex items-center gap-1 ${sizeCls} rounded-full font-medium ${colorCls}`}>
|
||||||
{icon}
|
{icon}
|
||||||
@@ -28,97 +32,71 @@ export default function StatusBadge({ code, text = '', size = 'sm' }) {
|
|||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|
||||||
// ============ Attendance status badges ============
|
// Extract leave suffix from status_text (holiday suffix dropped — the main
|
||||||
if (code === 'normal') {
|
// badge already says 公假, so repeating the long name like
|
||||||
return (
|
// '香港特別行政區成立紀念日' clutters the row).
|
||||||
<span className="inline-flex items-center gap-1.5">
|
// '異常-遲到48分 + SL2h' → ['SL2h']
|
||||||
{badge(<CheckCircle size={12} />, mainText || '正常', 'bg-green-100 text-green-700')}
|
// 'OT3分 + AL4h + SL3h' → ['AL4h', 'SL3h']
|
||||||
{leaveParts.map((lp, i) => (
|
// '正常 + CL4h' → ['CL4h']
|
||||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
const t = String(text || '')
|
||||||
+ {lp}
|
const noteParts = []
|
||||||
</span>
|
const split = t.split(' + ').map(s => s.trim())
|
||||||
))}
|
if (split.length > 1) {
|
||||||
</span>
|
for (let i = 1; i < split.length; i++) {
|
||||||
)
|
noteParts.push(split[i].replace(/^\(|\)$/g, '').trim())
|
||||||
}
|
}
|
||||||
if (code === 'late' || code === 'late_early' || code === 'late_ot') {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
{badge(<AlertCircle size={12} />, mainText || code, 'bg-orange-100 text-orange-700')}
|
|
||||||
{leaveParts.map((lp, i) => (
|
|
||||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
|
||||||
+ {lp}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (code === 'early' || code === 'early_ot') {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
{badge(<Clock size={12} />, mainText || code, 'bg-blue-100 text-blue-700')}
|
|
||||||
{leaveParts.map((lp, i) => (
|
|
||||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
|
||||||
+ {lp}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (code === 'ot') {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
{badge(<Clock size={12} />, mainText || '加班', 'bg-purple-100 text-purple-700')}
|
|
||||||
{leaveParts.map((lp, i) => (
|
|
||||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
|
||||||
+ {lp}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (code === 'abnormal') {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
{badge(<AlertTriangle size={12} />, mainText || '⚠️異常', 'bg-red-100 text-red-700')}
|
|
||||||
{leaveParts.map((lp, i) => (
|
|
||||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
|
||||||
+ {lp}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (code === 'missing') {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
{badge(<X size={12} />, '缺勤', 'bg-gray-200 text-gray-600')}
|
|
||||||
{leaveParts.map((lp, i) => (
|
|
||||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
|
||||||
{lp}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Enriched leave status badges ============
|
const noteChips = noteParts.filter(Boolean).map((part, idx) => {
|
||||||
if (code === 'holiday') {
|
const cls = 'bg-violet-50 text-violet-600 border border-violet-200'
|
||||||
return badge(<PartyPopper size={12} />, mainText, 'bg-amber-100 text-amber-700')
|
return (
|
||||||
}
|
<span
|
||||||
if (code === 'al') {
|
key={idx}
|
||||||
return badge(<Briefcase size={12} />, mainText || '年假', 'bg-emerald-100 text-emerald-700')
|
className={`inline-flex items-center ${sizeCls} rounded-full font-normal ${cls}`}
|
||||||
}
|
title={part}
|
||||||
if (code === 'sl') {
|
>
|
||||||
return badge(<Stethoscope size={12} />, mainText || '病假', 'bg-pink-100 text-pink-700')
|
+{part}
|
||||||
}
|
</span>
|
||||||
if (code === 'cl') {
|
)
|
||||||
return badge(<Timer size={12} />, mainText || '補鐘', 'bg-indigo-100 text-indigo-700')
|
})
|
||||||
}
|
|
||||||
if (code === 'mixed_leave') {
|
const wrap = (content) => (
|
||||||
return badge(<Timer size={12} />, mainText || '混合假', 'bg-fuchsia-100 text-fuchsia-700')
|
<span className="inline-flex items-center gap-1 flex-wrap">
|
||||||
|
{content}
|
||||||
|
{noteChips}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
const c = String(code || '').toLowerCase()
|
||||||
|
|
||||||
|
// 缺勤 (missing)
|
||||||
|
if (c === 'missing' || t === '缺勤') {
|
||||||
|
return wrap(badge(<CalendarX size={12} />, '缺勤', 'bg-gray-200 text-gray-700'))
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback
|
// 休息 (rest day — roster says not scheduled, employee didn't clock in)
|
||||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs text-slate-500 bg-slate-100`}>{text || code || '-'}</span>
|
if (c === 'rest' || t === '休息') {
|
||||||
|
return wrap(badge(<Coffee size={12} />, '休息', 'bg-slate-100 text-slate-600'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公假 (holiday)
|
||||||
|
if (c === 'holiday' || t.includes('公假') || t.includes('🏖️')) {
|
||||||
|
return wrap(badge(<Palmtree size={12} />, '公假', 'bg-indigo-100 text-indigo-700'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 請假 (any leave type)
|
||||||
|
if (['al', 'sl', 'cl', 'mixed_leave'].includes(c)
|
||||||
|
|| t.includes('年假') || t.includes('病假') || t.includes('補鐘')
|
||||||
|
|| t.includes('混合假')) {
|
||||||
|
return wrap(badge(<Briefcase size={12} />, '請假', 'bg-violet-100 text-violet-700'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常 (only when status_code is 'normal' — text suffix like '+ CL4h' is allowed,
|
||||||
|
// we still want to flag it as 正常 first since the employee did clock in/out normally)
|
||||||
|
if (c === 'normal') {
|
||||||
|
return wrap(badge(<CheckCircle size={12} />, '正常', 'bg-green-100 text-green-700'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 異常 (late/early/ot/abnormal/late_early/late_ot/early_ot/...)
|
||||||
|
return wrap(badge(<AlertCircle size={12} />, '異常', 'bg-orange-100 text-orange-700'))
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
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, FileCode } from 'lucide-react'
|
||||||
|
import StatusBadge from '../components/StatusBadge'
|
||||||
|
|
||||||
|
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([])
|
||||||
|
const [headers, setHeaders] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [sortKey, setSortKey] = useState(null)
|
||||||
|
const [sortDir, setSortDir] = useState('asc')
|
||||||
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRecords()
|
||||||
|
}, [statusFilter])
|
||||||
|
|
||||||
|
const fetchRecords = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params = { page: 1, per_page: 1000 }
|
||||||
|
if (statusFilter) params.status_text = statusFilter
|
||||||
|
const { data } = await api.get('/api/attendance/records', { params })
|
||||||
|
setRecords(data.records || [])
|
||||||
|
|
||||||
|
if (data.records && data.records.length > 0) {
|
||||||
|
const keys = Object.keys(data.records[0]).filter(k => k !== '_row')
|
||||||
|
setHeaders(keys)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Failed to load records')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtered and sorted data (client-side: search + sort only; status handled by backend)
|
||||||
|
const processedData = useMemo(() => {
|
||||||
|
let result = [...records]
|
||||||
|
|
||||||
|
// Search
|
||||||
|
if (search) {
|
||||||
|
const searchLower = search.toLowerCase()
|
||||||
|
result = result.filter(r =>
|
||||||
|
Object.values(r).some(v =>
|
||||||
|
v && String(v).toLowerCase().includes(searchLower)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
if (sortKey) {
|
||||||
|
result.sort((a, b) => {
|
||||||
|
const aVal = a[sortKey] || ''
|
||||||
|
const bVal = b[sortKey] || ''
|
||||||
|
const aNum = Number(aVal)
|
||||||
|
const bNum = Number(bVal)
|
||||||
|
|
||||||
|
let cmp = 0
|
||||||
|
if (!isNaN(aNum) && !isNaN(bNum)) {
|
||||||
|
cmp = aNum - bNum
|
||||||
|
} else {
|
||||||
|
cmp = String(aVal).localeCompare(String(bVal))
|
||||||
|
}
|
||||||
|
return sortDir === 'asc' ? cmp : -cmp
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}, [records, search, sortKey, sortDir])
|
||||||
|
|
||||||
|
const handleSort = (key) => {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
|
||||||
|
} else {
|
||||||
|
setSortKey(key)
|
||||||
|
setSortDir('asc')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSortIcon = (key) => {
|
||||||
|
if (sortKey !== key) return null
|
||||||
|
return sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold text-slate-900">出勤記錄 Attendance</h1>
|
||||||
|
<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 */}
|
||||||
|
<div className="card p-3">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="搜尋所有欄位..."
|
||||||
|
className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter (basic categories — server-side) */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter size={16} className="text-slate-400" />
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="text-sm border border-slate-300 rounded-md px-2 py-2"
|
||||||
|
>
|
||||||
|
<option value="">全部狀態</option>
|
||||||
|
<option value="normal">正常</option>
|
||||||
|
<option value="abnormal">異常</option>
|
||||||
|
<option value="missing">缺勤</option>
|
||||||
|
<option value="holiday">公假</option>
|
||||||
|
<option value="leave">請假</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{statusFilter && (
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('')}
|
||||||
|
className="p-1 text-slate-400 hover:text-slate-600"
|
||||||
|
title="清除篩選"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Result count */}
|
||||||
|
<div className="text-sm text-slate-500 py-2">
|
||||||
|
{processedData.length} 筆記錄
|
||||||
|
{statusFilter && (
|
||||||
|
<span className="ml-2 text-slate-400">• 已篩選:{statusFilter}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-100 border-b border-slate-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-600 w-12">#</th>
|
||||||
|
{headers.slice(0, 6).map(h => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
className="px-3 py-2 text-left text-xs font-semibold text-slate-600 cursor-pointer hover:bg-slate-200"
|
||||||
|
onClick={() => handleSort(h)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{h}
|
||||||
|
{getSortIcon(h)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="px-3 py-2 text-center text-xs font-semibold text-slate-600 w-20">狀態</th>
|
||||||
|
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600 w-16">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={9} className="px-3 py-6 text-center text-slate-400">
|
||||||
|
載入中...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : processedData.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={9} className="px-3 py-6 text-center text-slate-400">
|
||||||
|
沒有記錄
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
processedData.map((record) => (
|
||||||
|
<tr
|
||||||
|
key={record._row}
|
||||||
|
className="hover:bg-slate-50 cursor-pointer"
|
||||||
|
onClick={() => window.location.href = `/attendance/${record._row}`}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 text-xs text-slate-400">{record._row}</td>
|
||||||
|
{headers.slice(0, 6).map(h => (
|
||||||
|
<td key={h} className="px-3 py-2 text-slate-700 max-w-xs truncate">
|
||||||
|
{record[h] !== null && record[h] !== undefined ? String(record[h]) : '-'}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<StatusBadge
|
||||||
|
code={record.status_code}
|
||||||
|
text={record.status_text}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Link
|
||||||
|
to={`/attendance/${record._row}`}
|
||||||
|
className="p-1 text-slate-400 hover:text-primary-600 inline-block"
|
||||||
|
>
|
||||||
|
<Eye size={16} />
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Trash2, AlertTriangle, Shield, Download, Upload, RefreshCw, Database, Info } from "lucide-react"
|
||||||
|
import api from "../api"
|
||||||
|
import toast from "react-hot-toast"
|
||||||
|
|
||||||
|
const RESET_SECTIONS = [
|
||||||
|
{
|
||||||
|
key: "attendance",
|
||||||
|
label: "出勤 Attendance",
|
||||||
|
description: "刪除所有出勤記錄(attendance_records 全表清空)",
|
||||||
|
color: "red",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "accident",
|
||||||
|
label: "意外 Accident",
|
||||||
|
description: "刪除所有意外記錄(accidents 全表清空)",
|
||||||
|
color: "orange",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "holidays",
|
||||||
|
label: "假期 Holidays",
|
||||||
|
description: "刪除所有公眾假期記錄",
|
||||||
|
color: "amber",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "leaves",
|
||||||
|
label: "請假 Leaves",
|
||||||
|
description: "刪除所有員工請假記錄",
|
||||||
|
color: "lime",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const [confirmText, setConfirmText] = useState({})
|
||||||
|
const [loading, setLoading] = useState({})
|
||||||
|
const [result, setResult] = useState({})
|
||||||
|
const [version, setVersion] = useState(null)
|
||||||
|
const [backups, setBackups] = useState([])
|
||||||
|
const [loadingBackups, setLoadingBackups] = useState(false)
|
||||||
|
const [creatingBackup, setCreatingBackup] = useState(false)
|
||||||
|
const [restoring, setRestoring] = useState(null)
|
||||||
|
const [restoreConfirm, setRestoreConfirm] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVersion()
|
||||||
|
fetchBackups()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchVersion = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get("/api/version")
|
||||||
|
setVersion(data)
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchBackups = async () => {
|
||||||
|
setLoadingBackups(true)
|
||||||
|
try {
|
||||||
|
const { data } = await api.get("/api/admin/backup/list")
|
||||||
|
setBackups(data || [])
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch backups:", e)
|
||||||
|
} finally {
|
||||||
|
setLoadingBackups(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateBackup = async () => {
|
||||||
|
setCreatingBackup(true)
|
||||||
|
try {
|
||||||
|
const { data } = await api.post("/api/admin/backup/create")
|
||||||
|
toast.success(`Backup created: ${data.saved}`)
|
||||||
|
fetchBackups()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Backup failed: " + (e.response?.data?.detail || e.message))
|
||||||
|
} finally {
|
||||||
|
setCreatingBackup(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = async (filename) => {
|
||||||
|
if (restoreConfirm !== filename) {
|
||||||
|
toast.error("Please type the filename to confirm restore")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRestoring(filename)
|
||||||
|
try {
|
||||||
|
const { data } = await api.post("/api/admin/backup/restore", { filename })
|
||||||
|
toast.success(`Restored from ${filename}. Auto-backup: ${data.auto_backup}`)
|
||||||
|
setRestoreConfirm("")
|
||||||
|
fetchBackups()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Restore failed: " + (e.response?.data?.detail || e.message))
|
||||||
|
} finally {
|
||||||
|
setRestoring(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownload = (filename) => {
|
||||||
|
const token = localStorage.getItem("aars_token")
|
||||||
|
if (!token) return
|
||||||
|
window.open(`/api/admin/backup/download/${filename}?token=${token}`, "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = async (section) => {
|
||||||
|
const confirmVal = confirmText[section] || ""
|
||||||
|
if (confirmVal !== section) {
|
||||||
|
setResult((prev) => ({ ...prev, [section]: { error: `請輸入 "${section}" 以確認刪除` } }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading((prev) => ({ ...prev, [section]: true }))
|
||||||
|
setResult((prev) => ({ ...prev, [section]: null }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/api/admin/reset/${section}`, { confirm: section })
|
||||||
|
setResult((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[section]: {
|
||||||
|
success: `已刪除 ${res.data.deleted} 條記錄`,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
setConfirmText((prev) => ({ ...prev, [section]: "" }))
|
||||||
|
} catch (err) {
|
||||||
|
setResult((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[section]: {
|
||||||
|
error: err.response?.data?.detail || err.message || "刪除失敗",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
} finally {
|
||||||
|
setLoading((prev) => ({ ...prev, [section]: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSize = (bytes) => {
|
||||||
|
if (!bytes) return "—"
|
||||||
|
if (bytes < 1024) return bytes + " B"
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
|
||||||
|
return (bytes / 1024 / 1024).toFixed(1) + " MB"
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (iso) => {
|
||||||
|
if (!iso) return "—"
|
||||||
|
try {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleString("zh-HK", { timeZone: "Asia/Hong_Kong" })
|
||||||
|
} catch { return iso }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
|
||||||
|
{/* Version Info */}
|
||||||
|
{version && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-xl px-5 py-4 flex items-start gap-3">
|
||||||
|
<Info className="w-5 h-5 text-blue-600 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-blue-900">
|
||||||
|
AARS v{version.version}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-blue-700 mt-0.5">
|
||||||
|
Commit: <code className="bg-blue-100 px-1 rounded">{version.commit?.slice(0, 8)}</code>
|
||||||
|
{version.date && <> · {new Date(version.date).toLocaleString("zh-HK", {timeZone:"Asia/Hong_Kong"})}</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Backup / Restore */}
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-slate-100 bg-slate-50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="w-5 h-5 text-slate-600" />
|
||||||
|
<h2 className="text-lg font-semibold text-slate-800">💾 資料庫 Backup / Restore</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateBackup}
|
||||||
|
disabled={creatingBackup}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-slate-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${creatingBackup ? "animate-spin" : ""}`} />
|
||||||
|
{creatingBackup ? "建立中..." : "建立 Backup"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">手動建立快照,或從過往備份還原</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{loadingBackups ? (
|
||||||
|
<div className="px-5 py-6 text-sm text-slate-400 text-center">載入中...</div>
|
||||||
|
) : backups.length === 0 ? (
|
||||||
|
<div className="px-5 py-6 text-sm text-slate-400 text-center">尚無備份記錄</div>
|
||||||
|
) : (
|
||||||
|
backups.map((b) => (
|
||||||
|
<div key={b.filename} className="px-5 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-slate-800 truncate">{b.filename}</div>
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
{formatDate(b.created)} · {formatSize(b.size)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload(b.filename)}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-md transition-colors"
|
||||||
|
title="Download backup"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" /> 下載
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setRestoreConfirm(b.filename === restoreConfirm ? "" : b.filename)}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-amber-600 hover:text-amber-700 hover:bg-amber-50 rounded-md transition-colors"
|
||||||
|
title="Restore from this backup"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5" /> 還原
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm(`確定要刪除 ${b.filename}?`)) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/admin/backup/${b.filename}`)
|
||||||
|
toast.success(`已刪除 ${b.filename}`)
|
||||||
|
fetchBackups()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("刪除失敗: " + (e.response?.data?.detail || e.message))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-red-500 hover:text-red-700 hover:bg-red-50 rounded-md transition-colors"
|
||||||
|
title="Delete backup"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" /> 刪除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Restore confirmation */}
|
||||||
|
{restoreConfirm && (
|
||||||
|
<div className="px-5 py-4 bg-amber-50 border-t border-amber-100">
|
||||||
|
<p className="text-sm text-amber-800 mb-2">
|
||||||
|
⚠️ 還原將覆蓋當前資料庫。輸入 <code className="font-mono bg-amber-100 px-1 rounded">{restoreConfirm}</code> 確認:
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="flex-1 px-3 py-2 text-sm border border-amber-300 rounded-md focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||||
|
placeholder={"輸入檔案名稱確認"}
|
||||||
|
value={restoreConfirm}
|
||||||
|
onChange={(e) => setRestoreConfirm(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" && restoreConfirm === restoreConfirm) handleRestore(restoreConfirm) }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRestore(restoreConfirm)}
|
||||||
|
disabled={restoreConfirm !== restoreConfirm || restoring}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 disabled:bg-slate-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
{restoring ? "還原中..." : "確認還原"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setRestoreConfirm("")}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<div className="bg-white border border-red-200 rounded-xl overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-red-100 bg-red-50/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||||
|
<h2 className="text-lg font-semibold text-red-800">Danger Zone — 資料庫重置</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-red-600 mt-1">
|
||||||
|
以下操作將永久刪除資料,無法復原。請確認後再執行。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{RESET_SECTIONS.map((section) => {
|
||||||
|
const res = result[section.key]
|
||||||
|
const isLoading = loading[section.key]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={section.key} className="px-5 py-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-slate-900">{section.label}</h3>
|
||||||
|
<p className="text-sm text-slate-500 mt-0.5">{section.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-36 px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 placeholder:text-slate-400"
|
||||||
|
placeholder={`輸入 "${section.key}"`}
|
||||||
|
value={confirmText[section.key] || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
setConfirmText((prev) => ({ ...prev, [section.key]: e.target.value }))
|
||||||
|
setResult((prev) => ({ ...prev, [section.key]: null }))
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleReset(section.key)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleReset(section.key)}
|
||||||
|
disabled={isLoading || (confirmText[section.key] || "") !== section.key}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-slate-300 disabled:cursor-not-allowed rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
{isLoading ? "刪除中..." : "重置"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{res && (
|
||||||
|
<div
|
||||||
|
className={`mt-3 text-sm px-3 py-2 rounded-md ${
|
||||||
|
res.error
|
||||||
|
? "bg-red-50 text-red-700 border border-red-200"
|
||||||
|
: "bg-green-50 text-green-700 border border-green-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{res.error ? `❌ ${res.error}` : `✅ ${res.success}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,15 +17,24 @@ export default function AttendanceList() {
|
|||||||
const [staffFilter, setStaffFilter] = useState('')
|
const [staffFilter, setStaffFilter] = useState('')
|
||||||
const [statusFilter, setStatusFilter] = useState('')
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const [statusOptions, setStatusOptions] = useState([])
|
||||||
const perPage = 50
|
const perPage = 50
|
||||||
|
|
||||||
const [staffList, setStaffList] = useState([])
|
const [staffList, setStaffList] = useState([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRecords()
|
fetchRecords()
|
||||||
|
fetchStatusTexts()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [page, sortBy, sortOrder, dateFrom, dateTo, staffFilter, statusFilter, search])
|
}, [page, sortBy, sortOrder, dateFrom, dateTo, staffFilter, statusFilter, search])
|
||||||
|
|
||||||
|
const fetchStatusTexts = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/api/attendance/status_texts')
|
||||||
|
setStatusOptions(data || [])
|
||||||
|
} catch (e) { console.error('Failed to fetch status texts', e) }
|
||||||
|
}
|
||||||
|
|
||||||
const fetchRecords = async () => {
|
const fetchRecords = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -39,7 +48,7 @@ export default function AttendanceList() {
|
|||||||
if (dateFrom) params.append('date_from', dateFrom)
|
if (dateFrom) params.append('date_from', dateFrom)
|
||||||
if (dateTo) params.append('date_to', dateTo)
|
if (dateTo) params.append('date_to', dateTo)
|
||||||
if (staffFilter) params.append('staff', staffFilter)
|
if (staffFilter) params.append('staff', staffFilter)
|
||||||
if (statusFilter) params.append('status', statusFilter)
|
if (statusFilter) params.append('status_text', statusFilter)
|
||||||
|
|
||||||
const { data } = await api.get(`/api/attendance/records?${params}`)
|
const { data } = await api.get(`/api/attendance/records?${params}`)
|
||||||
setRecords(data.records || [])
|
setRecords(data.records || [])
|
||||||
@@ -78,15 +87,21 @@ export default function AttendanceList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getStatusBadge = (record) => {
|
const getStatusBadge = (record) => {
|
||||||
|
// StatusBadge now drives from status_code + status_text only (basic 5 categories).
|
||||||
return <StatusBadge code={record.status_code} text={record.status_text} />
|
return <StatusBadge code={record.status_code} text={record.status_text} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRowClass = (code) => {
|
const getRowClass = (code) => {
|
||||||
if (code === 'abnormal') return 'bg-red-50 hover:bg-red-100'
|
// Match the basic-status grouping so highlights line up with the badge.
|
||||||
if (code === 'missing') return 'bg-gray-100 hover:bg-gray-200'
|
const c = String(code || '').toLowerCase()
|
||||||
if (code === 'late' || code === 'late_early' || code === 'late_ot') return 'bg-orange-50 hover:bg-orange-100'
|
if (c === 'abnormal' || c === 'late' || c === 'early' || c === 'ot'
|
||||||
if (code === 'early' || code === 'early_ot') return 'bg-blue-50 hover:bg-blue-100'
|
|| c === 'late_early' || c === 'late_ot' || c === 'early_ot') {
|
||||||
if (code === 'ot') return 'bg-purple-50 hover:bg-purple-100'
|
return 'bg-orange-50 hover:bg-orange-100'
|
||||||
|
}
|
||||||
|
if (c === 'missing') return 'bg-gray-100 hover:bg-gray-200'
|
||||||
|
if (c === 'rest') return 'bg-slate-50 hover:bg-slate-100'
|
||||||
|
if (c === 'holiday') return 'bg-indigo-50 hover:bg-indigo-100'
|
||||||
|
if (['al', 'sl', 'cl', 'mixed_leave'].includes(c)) return 'bg-violet-50 hover:bg-violet-100'
|
||||||
return 'hover:bg-slate-50'
|
return 'hover:bg-slate-50'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,16 +191,16 @@ export default function AttendanceList() {
|
|||||||
<span className="text-xs text-slate-500">狀態:</span>
|
<span className="text-xs text-slate-500">狀態:</span>
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}
|
onChange={(e) => {
|
||||||
|
setStatusFilter(e.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
|
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
|
||||||
>
|
>
|
||||||
<option value="">全部</option>
|
<option value="">全部</option>
|
||||||
<option value="normal">正常</option>
|
{statusOptions.map(opt => (
|
||||||
<option value="late">遲到</option>
|
<option key={opt} value={opt}>{opt}</option>
|
||||||
<option value="early">早退</option>
|
))}
|
||||||
<option value="ot">OT</option>
|
|
||||||
<option value="abnormal">異常</option>
|
|
||||||
<option value="missing">缺勤</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Upload, CheckCircle, Trash2, Clock, Users } from 'lucide-react'
|
import { Upload, CheckCircle, Trash2, Clock, Users, RefreshCw } from 'lucide-react'
|
||||||
import api from '../../api'
|
import api from '../../api'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
@@ -63,6 +63,21 @@ export default function RosterPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRecalc = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
api.get('/api/roster/info'),
|
||||||
|
api.get('/api/roster/data').catch(() => ({ data: { headers: [], rows: [] } }))
|
||||||
|
])
|
||||||
|
toast.success('已重新計算')
|
||||||
|
} catch {
|
||||||
|
toast.error('重新計算失敗')
|
||||||
|
} finally {
|
||||||
|
loadRosterData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="p-8 text-center">載入中...</div>
|
return <div className="p-8 text-center">載入中...</div>
|
||||||
}
|
}
|
||||||
@@ -106,10 +121,16 @@ export default function RosterPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleDelete} className="btn-danger">
|
<div className="flex gap-2">
|
||||||
<Trash2 size={16} className="inline mr-1" />
|
<button onClick={handleRecalc} className="btn-primary">
|
||||||
刪除
|
<RefreshCw size={16} className="inline mr-1" />
|
||||||
</button>
|
RECALC
|
||||||
|
</button>
|
||||||
|
<button onClick={handleDelete} className="btn-danger">
|
||||||
|
<Trash2 size={16} className="inline mr-1" />
|
||||||
|
刪除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user