Compare commits

...

28 Commits

Author SHA1 Message Date
IT狗 4a01b53541 Bump version 1.2.0 → 1.3.0
Status & filter improvements consolidated:
- StatusBadge: 5 basic categories + leave suffix chip
- Backend filter: enrich-then-filter so badges match result set
- 請假/公假 filter: also match leave/holiday suffix in status_text
- Rest day detection: roster rest weekday → 休息 (not 缺勤)
2026-07-22 00:06:44 +08:00
IT狗 404329d3fa Attendance: detect rest day in enrich → render 休息 instead of 缺勤
When roster says the shift rests on a given weekday (e.g. Sunday for S2/S3),
the employee wasn't scheduled to work. Import still marks the record
status_code='missing' because no shift times were available, but the
right user-facing label is '休息' not '缺勤'.

Enrich now: if status_code='missing' and get_shift_schedule_full returns
None (shift defined no schedule for that weekday), override to
status_code='rest' / status_text='休息'.

Added '休息' to the basic-status vocabulary: dropdown option, filter
condition, StatusBadge (slate grey + coffee icon), and row highlight.
2026-07-21 23:54:57 +08:00
IT狗 811b9991c9 Attendance filter: 請假/公假 — also match leave/holiday suffix in status_text
Pure-leave status_code (al/sl/cl/mixed_leave) only catches employees who
took the whole day off. Records where the employee worked AND took leave
mid-day keep their raw status_code (normal/abnormal/ot) but carry a
' + SL2h' / ' + AL4h' suffix that the badge now surfaces as a chip —
yet those records disappeared under the '請假' filter.

Same gap for holidays: '公假' only matched status_code='holiday' but an
employee who worked on the public holiday still gets the '🎉' suffix and
should be reachable from the holiday filter.

Expand both filters to also match records whose status_text contains the
relevant suffix (' + SL' / ' + AL' / ' + CL' / '🎉'), keeping the result
set aligned with what the badge chip advertises.
2026-07-21 23:39:15 +08:00
IT狗 f12a239e26 StatusBadge: drop holiday suffix chip — main badge already says 公假
The previous '🎉香港特別行政區成立紀念日' chip wrapped to 7 lines on
mobile and duplicated info already conveyed by the '公假' main badge.

Keep leave chips (+SL2h, +AL4h, etc.) since the basic-status badge
doesn't reveal the leave type, but skip holiday decoration entirely.
2026-07-21 22:41:45 +08:00
IT狗 fcc3246d8e StatusBadge: surface leave/holiday suffix as secondary chip
status_text fields like '異常-遲到48分 + SL2h' or '缺勤 (🎉香港特區…)' carried
important leave/holiday context that the simplified badge was dropping on the
list page — the user had no way to tell an abnormal record was partly covered
by sick leave.

Extract the suffix from status_text and render a small secondary chip next
to the basic-status badge (violet for leave types, indigo for holidays).
Detail page picks this up automatically since the component is shared.

Example: '異常 +SL2h' / '缺勤 🎉香港特區成立紀念日'
2026-07-21 22:31:26 +08:00
IT狗 3ba6617009 Attendance filter: enrich-then-filter so badge matches result set
The previous filter applied status_code conditions on raw DB rows, then
enriched the page of results. That meant a missing record with a leave
request would surface under '缺勤' (raw code='missing') but render a
'請假'/'公假' badge (enriched status_code).

Move status_text filter to AFTER enrichment so it matches the badge.
status_text sort_by='status_code' also moves to Python sort since
post-enrich codes aren't in the DB. Log a warning if the in-memory
filter scans >5000 rows so we know when to revisit.
2026-07-21 22:18:55 +08:00
IT狗 4bd6d0e7c8 StatusBadge: fix normal badge — only check status_code, not text
The previous condition 'c === "normal" && !t' required status_text to be
empty, but enriched records carry text suffixes like '正常 + CL4h' which
are truthy and dropped normal records into the '異常' fallback.

3 Kaka records (id=22/28/30) were affected — act_in 10:38/10:43 (early),
act_out 20:00 (on time), so they should render the green '正常' badge but
were showing '異常' instead.
2026-07-21 22:13:12 +08:00
IT狗 7702fe4ae5 Fix attendance filter: align with badge via status_code (not late/early/OT thresholds)
The previous filter used 'late_minutes == 0 AND early_minutes == 0 AND
ot_minutes == 0' for '正常', which incorrectly included records whose
import-time late_minutes was 0 but post-enrich status_code was 'abnormal'
(e.g. act_in=18:00 vs exp_in=11:00) or 'missing' (act_in='-').

Switch filter to status_code conditions so the result set matches the
badge rendered from status_code. Also accept the Chinese basic-status
labels (正常/異常/缺勤/公假/請假) as filter values, and return only those
5 from /api/attendance/status_texts so the dropdown vocabulary stays
in sync.

Frontend attendance list: simplify getStatusBadge to drop now-unused
late/early/ot props; align row highlight with the basic-status grouping.
2026-07-21 21:55:55 +08:00
IT狗 d302fa9beb List.jsx: fix API path /api/attendance -> /api/attendance/records
- Response key is 'records' not 'data'
- Backend /api/attendance/records uses 'per_page' (not 'page_size')
- Without this fix, list never loaded data — would always show 'no records'
2026-07-21 21:38:22 +08:00
IT狗 4b967f7920 StatusBadge: simplify to 5 basic categories (正常/異常/缺勤/公假/請假)
- StatusBadge no longer shows detailed late/early/OT minute breakdowns
  nor AL/SL/CL sub-types; collapse to single basic-status badge
- List page: replace text-input column filter with status dropdown
  (server-side ?status_text= filter; backend now supports 'leave' IN-clause
  for al/sl/cl/mixed_leave group)
- List page: badge now driven by backend status_code/status_text
  (remove frontend latenessMap which contradicted backend status)
2026-07-21 21:33:42 +08:00
IT狗 8b378ec4c6 StatusBadge: handle missing/holiday/AL/SL/CL/mixed_leave codes; pass status_text to StatusBadge 2026-07-21 21:14:12 +08:00
IT狗 7dd53e0a5f StatusBadge: preserve late/early/ot minutes for abnormal records (don't zero them out) 2026-07-21 18:22:03 +08:00
IT狗 33a5e3b2ad StatusBadge: split badges per dimension (abnormal/late/early/OT <=30min); Attendance filter by status_code conditions for abnormal/late/early/OT/normal/leave types 2026-07-21 18:10:43 +08:00
IT狗 4c9db01213 Add /api/attendance/status_texts endpoint; dynamic status filter in Attendance List (filter by status_text LIKE) 2026-07-21 18:02:32 +08:00
IT狗 ef57d7cd59 If late/early/OT > 30min → mark as abnormal status (override status_code+text, zero out minutes) 2026-07-21 17:52:36 +08:00
IT狗 f41dad6127 Fix: add 30-second tolerance for OT/early/late calculation to avoid floating-point noise (e.g. 20:00==20:00 showing OT 1min) 2026-07-21 17:41:14 +08:00
IT狗 6f2df815f5 Simplify Attendance export status groups to 6 categories: 正常|遲到|早退|加班|異常/缺勤|假期/請假 2026-07-21 13:38:58 +08:00
IT狗 c1b1d6f35b Add DELETE /api/admin/backup/{filename} and delete button in Settings 2026-07-21 13:14:56 +08:00
IT狗 e531cea8a4 Add leaves reset, split holidays/leave resets, support multi-table reset 2026-07-21 13:10:46 +08:00
IT狗 2d64d3f0b6 Add accident to reset sections 2026-07-21 13:08:00 +08:00
IT狗 08ba4cbca9 Fix: add missing db Session dependency to reset_section 2026-07-21 12:49:44 +08:00
IT狗 c07f40b28f Add /api/admin/reset/{section} endpoint for Danger Zone reset 2026-07-21 12:49:07 +08:00
IT狗 2d9b73df9d Bump version to 1.2.0 2026-07-21 12:45:46 +08:00
IT狗 9980f8d80a Fix: strip existing leave suffix before appending new leave to prevent duplication 2026-07-21 12:38:48 +08:00
IT狗 1e7d58486b Fix: prevent leave type duplication in status_text for attendance records 2026-07-21 12:35:16 +08:00
IT狗 cca088261b Fix: use roster public_holiday column to determine if employee works on holidays, instead of checking clock-in 2026-07-21 12:26:27 +08:00
IT狗 adc9e6451b Fix: show attendance status for employees who worked on holiday (not just holiday name) 2026-07-21 12:08:31 +08:00
IT狗 a34905661b Fix: add missing DB_PATH constant for backup/restore endpoints 2026-07-21 11:56:22 +08:00
7 changed files with 533 additions and 394 deletions
+250 -51
View File
@@ -126,8 +126,40 @@ def enrich_attendance_with_leave(records, db):
if not r.date:
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:
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_text = f"\U0001F3D6\uFE0F{h.name}"
continue
@@ -158,7 +190,23 @@ def enrich_attendance_with_leave(records, db):
else:
r.status_code = "mixed_leave"
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:
# original_code is empty or holiday or already handled above
base = r.status_text or "正常"
r.status_text = f"{base} + {leave_text}"
@@ -261,7 +309,7 @@ async def upload_excel(
db: Session = Depends(get_db),
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"]:
raise HTTPException(status_code=400, detail="Invalid section")
@@ -494,7 +542,8 @@ async def _import_attendance_to_db(db: Session, headers, rows, user_id: int) ->
# ============ Attendance Calculation ============
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.
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")
if not os.path.exists(roster_path):
@@ -508,10 +557,12 @@ def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]:
# Find column indices
shift_col = None
ph_col = None
for i, h in enumerate(headers):
if h == '班次':
shift_col = i
break
elif h == 'public holiday':
ph_col = i
if shift_col is None:
return None
@@ -539,6 +590,7 @@ def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]:
# Find the shift row
for row in ws.iter_rows(min_row=2, values_only=True):
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)
if day_col is not None:
schedule = row[day_col]
@@ -551,7 +603,7 @@ def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]:
end_str = parts[1].strip()
start_time = datetime.strptime(start_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:
pass
@@ -663,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)
if check_out_dt < expected_end_dt:
diff = (expected_end_dt - check_out_dt).total_seconds() / 60
result["early_minutes"] = round(diff)
diff_seconds = (expected_end_dt - check_out_dt).total_seconds()
result["early_minutes"] = round(diff_seconds / 60) if diff_seconds >= 30 else 0
# Calculate OT minutes (leaving after scheduled end)
if check_out_dt > expected_end_dt:
diff = (check_out_dt - expected_end_dt).total_seconds() / 60
result["ot_minutes"] = round(diff)
diff_seconds = (check_out_dt - expected_end_dt).total_seconds()
# 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
status_parts = []
@@ -696,6 +752,16 @@ def calculate_attendance_status(check_in, check_out, shift_start, shift_end):
result["status_code"] = "normal"
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
@@ -1038,6 +1104,20 @@ async def dashboard_summary(
"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")
async def attendance_stats(
date_from: Optional[str] = None,
@@ -1174,10 +1254,18 @@ async def attendance_records(
date_to: Optional[str] = None,
staff: Optional[str] = None,
status: Optional[str] = None,
status_text: Optional[str] = None,
db: Session = Depends(get_db),
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 sqlalchemy import or_, and_
@@ -1204,20 +1292,74 @@ async def attendance_records(
AttendanceRecord.shift_code.like(like),
))
# sort
sortable = {
# Pre-sort by raw date so enrich-then-filter ordering is deterministic.
sortable_raw = {
"date": AttendanceRecord.date,
"employee_name": AttendanceRecord.employee_name,
"weekday": AttendanceRecord.weekday,
"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())
total = q.count()
rows = q.offset((page - 1) * per_page).limit(per_page).all()
enrich_attendance_with_leave(rows, db)
all_rows = q.all()
if len(all_rows) > 5000:
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):
return {
@@ -1243,7 +1385,7 @@ async def attendance_records(
}
return {
"records": [to_dict(r) for r in rows],
"records": [to_dict(r) for r in page_rows],
"total": total,
"page": page,
"per_page": per_page,
@@ -1822,6 +1964,7 @@ 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):
@@ -1885,6 +2028,48 @@ async def restore_backup(
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),
@@ -1908,7 +2093,7 @@ async def get_version(
commit = "unknown"
date = ""
return {
"version": "1.1.0",
"version": "1.3.0",
"commit": commit,
"date": date,
}
@@ -2032,7 +2217,7 @@ async def _import_accident_to_db(db: Session, headers, rows, user_id: int) -> di
Col 8 涉及的患者或相關人員 -> patient_or_staff
Col 9 事件描述 -> long_description
Col 10 如有事件相關照片可提供-> incident_photos
Col 11 處理情況已採取的行動-> action_taken
Col 11 處理情況/已採取的行動-> action_taken
Col 12 需要管理層介入的事項 -> needs_escalation
Col 13 第 12 欄 -> (skipped)
Col 14 分數 -> incident_score
@@ -2750,7 +2935,7 @@ async def accident_export_excel(
# User types case_no in C5 of 個案查詢 sheet.
# xlsxwriter requires single quotes around sheet names containing non-ASCII.
# 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,
f"internal:個案查詢!A1",
link_fmt,
@@ -2758,7 +2943,7 @@ async def accident_export_excel(
tip=f"跳到「個案查詢」輸入個案 ID {r.id}")
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
# Position: I column (right of KPI + 個案索引 blocks), so no overlap with case index.
@@ -2868,7 +3053,7 @@ async def accident_export_excel(
# ============ Sheet 3 (built FIRST so detail-VLOOKUP can reference it): 明細 ============
# Build it now so that 個案查詢 formulas can resolve its range properly.
# 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.
# 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.
@@ -2928,12 +3113,12 @@ async def accident_export_excel(
"align": "center", "num_format": "@"})
# 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.
detail.write("B5", "輸入個案 ID", input_label_fmt)
detail.write("B5", "輸入個案 ID:", input_label_fmt)
# C5 = case_no input cell (NOT merged)
detail.write_string("C5", "", text_input_fmt)
# Hint
detail.merge_range("B6:C6",
"提示個案 ID 喺「總覽」個案索引或「明細」表嘅 id 欄。輸入後下面自動顯示。",
"提示:個案 ID 喺「總覽」個案索引或「明細」表嘅 id 欄。輸入後下面自動顯示。",
subtitle_fmt)
# Detail rows: each pulls from 明細!<col><row> via VLOOKUP keyed on id (col A)
@@ -2972,7 +3157,7 @@ async def accident_export_excel(
col_sev = col_index_lookup.get("severity", 6)
col_emp = col_index_lookup.get("employee_name", 3)
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.
# VLOOKUP needs lookup_value, table, col_index, FALSE.
# table 明細!$A:$O.
@@ -3019,13 +3204,13 @@ async def accident_export_excel(
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 + 1}",
"輸入兩個個案 ID (左個案 A, 右個案 B)對比每個欄位。空白表示無資料。",
"輸入兩個個案 ID (左個案 A, 右個案 B),對比每個欄位。空白表示無資料。",
subtitle_fmt)
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(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.set_column("F:F", 50)
@@ -3057,7 +3242,7 @@ async def accident_export_excel(
# Hint at top
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)
# ============ Sheet 3: 明細 ============
@@ -3322,31 +3507,45 @@ async def export_excel(
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)
# Status distribution table (B10:D)
# Status distribution table - simplified 6-group (B10:D)
overview.write("B10", "出勤狀態分佈", section_fmt)
overview.write("B11", "Status", header_fmt_ov)
overview.write("C11", "數量", header_fmt_ov)
overview.write("D11", "百分比", header_fmt_ov)
status_codes_order = ["normal", "late", "late_early", "late_ot", "early", "early_ot", "ot", "abnormal", "missing",
"holiday", "al", "sl", "cl", "mixed_leave"]
status_labels = {
"normal": "正常", "late": "遲到", "late_early": "遲到+早走",
"late_ot": "遲到+加班", "early": "早走", "early_ot": "早走+加班",
"ot": "加班", "abnormal": "異常", "missing": "缺勤",
"holiday": "🏖️公假", "al": "年假 AL", "sl": "病假 SL", "cl": "補鐘 CL",
"mixed_leave": "混合假",
# Simplified 6-group status
_grp_status = {
"正常": 0, "遲到": 0, "早退": 0, "加班": 0, "異常/缺勤": 0, "假期/請假": 0, "(空)": 0,
}
for i, code in enumerate(status_codes_order):
count = sum(1 for r in rows if (r.status_code or "").lower() == code)
overview.write(11 + i, 1, status_labels.get(code, code), cell_fmt_ov)
for r in rows:
c = (r.status_code or "").lower()
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)
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)
unk = sum(1 for r in rows if not (r.status_code or "").strip())
overview.write(11 + len(status_codes_order), 1, "(空)", cell_fmt_ov)
overview.write(11 + len(status_codes_order), 2, unk, cell_fmt_ov)
pct_unk = (unk / len(rows) * 100) if rows else 0
overview.write(11 + len(status_codes_order), 3, f"{pct_unk:.1f}%", cell_fmt_ov)
_n_status_groups = len(_grp_status)
# Also update KPI counters to match simplified grouping
n_late_grp = _grp_status["遲到"]
n_early_grp = _grp_status["早退"]
n_ot_grp = _grp_status["加班"]
n_abn_grp = _grp_status["異常/缺勤"]
# Department TOP 10 table (F10:G)
overview.write("F10", "部門記錄數 (TOP 10)", section_fmt)
@@ -3364,8 +3563,8 @@ async def export_excel(
status_pie = wb.add_chart({"type": "pie"})
status_pie.add_series({
"name": "出勤狀態分佈",
"categories": ["總覽", 11, 1, 11 + len(status_codes_order), 1],
"values": ["總覽", 11, 2, 11 + len(status_codes_order), 2],
"categories": ["總覽", 11, 1, 11 + _n_status_groups, 1],
"values": ["總覽", 11, 2, 11 + _n_status_groups, 2],
"data_labels": {"percentage": True, "category": False, "position": "outside_end"},
})
status_pie.set_title({"name": "出勤狀態分佈 (Pie)"})
@@ -3395,8 +3594,8 @@ async def export_excel(
status_col_chart = wb.add_chart({"type": "column"})
status_col_chart.add_series({
"name": "出勤狀態 數量",
"categories": ["總覽", 11, 1, 11 + len(status_codes_order) - 1, 1],
"values": ["總覽", 11, 2, 11 + len(status_codes_order) - 1, 2],
"categories": ["總覽", 11, 1, 11 + _n_status_groups - 1, 1],
"values": ["總覽", 11, 2, 11 + _n_status_groups - 1, 2],
"fill": {"color": "#10B981"},
"border": {"color": "#065F46"},
"data_labels": {"value": True},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aars-frontend",
"version": "1.0.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aars-frontend",
"version": "1.0.0",
"version": "1.2.0",
"dependencies": {
"axios": "^1.7.7",
"lucide-react": "^0.441.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "aars-frontend",
"private": true,
"version": "1.0.0",
"version": "1.3.0",
"type": "module",
"scripts": {
"dev": "vite",
+77 -99
View File
@@ -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.
* Handles all status codes including enriched leave types:
* normal / late / late_early / late_ot / early / early_ot / ot / abnormal / missing
* holiday / al / sl / cl / mixed_leave
* Simplified to BASIC status categories no detailed breakdowns.
*
* 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:
* - code: status_code string
* - text: status_text string (already enriched)
* - text: status_text string (fallback signal + suffix source)
* - 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'
? 'px-3 py-1 text-sm'
: '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) => (
<span className={`inline-flex items-center gap-1 ${sizeCls} rounded-full font-medium ${colorCls}`}>
{icon}
@@ -28,97 +32,71 @@ export default function StatusBadge({ code, text = '', size = 'sm' }) {
</span>
)
// ============ Attendance status badges ============
if (code === 'normal') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<CheckCircle size={12} />, mainText || '正常', 'bg-green-100 text-green-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>
)
// Extract leave suffix from status_text (holiday suffix dropped the main
// badge already says , so repeating the long name like
// '' clutters the row).
// '-48 + SL2h' ['SL2h']
// 'OT3 + AL4h + SL3h' ['AL4h', 'SL3h']
// ' + CL4h' ['CL4h']
const t = String(text || '')
const noteParts = []
const split = t.split(' + ').map(s => s.trim())
if (split.length > 1) {
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 ============
if (code === 'holiday') {
return badge(<PartyPopper size={12} />, mainText, 'bg-amber-100 text-amber-700')
}
if (code === 'al') {
return badge(<Briefcase size={12} />, mainText || '年假', 'bg-emerald-100 text-emerald-700')
}
if (code === 'sl') {
return badge(<Stethoscope size={12} />, mainText || '病假', 'bg-pink-100 text-pink-700')
}
if (code === 'cl') {
return badge(<Timer size={12} />, mainText || '補鐘', 'bg-indigo-100 text-indigo-700')
}
if (code === 'mixed_leave') {
return badge(<Timer size={12} />, mainText || '混合假', 'bg-fuchsia-100 text-fuchsia-700')
const noteChips = noteParts.filter(Boolean).map((part, idx) => {
const cls = 'bg-violet-50 text-violet-600 border border-violet-200'
return (
<span
key={idx}
className={`inline-flex items-center ${sizeCls} rounded-full font-normal ${cls}`}
title={part}
>
+{part}
</span>
)
})
const wrap = (content) => (
<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
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs text-slate-500 bg-slate-100`}>{text || code || '-'}</span>
// (rest day roster says not scheduled, employee didn't clock in)
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'))
}
+36 -111
View File
@@ -2,7 +2,8 @@ import { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom'
import api from '../../api'
import toast from 'react-hot-toast'
import { Search, Eye, ChevronUp, ChevronDown, Filter, X, AlertCircle, Clock, FileCode } from 'lucide-react'
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')
@@ -18,23 +19,22 @@ export default function AttendanceList() {
const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState(null)
const [sortDir, setSortDir] = useState('asc')
const [filterKey, setFilterKey] = useState('')
const [filterValue, setFilterValue] = useState('')
const [latenessRecords, setLatenessRecords] = useState([])
const [statusFilter, setStatusFilter] = useState('')
useEffect(() => {
fetchRecords()
fetchLateness()
}, [])
}, [statusFilter])
const fetchRecords = async () => {
setLoading(true)
try {
const { data } = await api.get('/api/attendance?page=1&page_size=1000')
setRecords(data.data || [])
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.data && data.data.length > 0) {
const keys = Object.keys(data.data[0]).filter(k => k !== '_row')
if (data.records && data.records.length > 0) {
const keys = Object.keys(data.records[0]).filter(k => k !== '_row')
setHeaders(keys)
}
} catch (err) {
@@ -44,37 +44,10 @@ export default function AttendanceList() {
}
}
const fetchLateness = async () => {
try {
const { data } = await api.get('/api/attendance/lateness/records')
setLatenessRecords(data || [])
} catch (err) {
console.error('Failed to load lateness data:', err)
}
}
// Create a map of late records for quick lookup
const latenessMap = useMemo(() => {
const map = {}
latenessRecords.forEach(rec => {
const key = `${rec.staff}-${rec.date}`
map[key] = rec
})
return map
}, [latenessRecords])
// Filtered and sorted data
// Filtered and sorted data (client-side: search + sort only; status handled by backend)
const processedData = useMemo(() => {
let result = [...records]
// Filter
if (filterKey && filterValue) {
result = result.filter(r => {
const val = String(r[filterKey] || '').toLowerCase()
return val.includes(filterValue.toLowerCase())
})
}
// Search
if (search) {
const searchLower = search.toLowerCase()
@@ -104,7 +77,7 @@ export default function AttendanceList() {
}
return result
}, [records, search, sortKey, sortDir, filterKey, filterValue])
}, [records, search, sortKey, sortDir])
const handleSort = (key) => {
if (sortKey === key) {
@@ -115,42 +88,11 @@ export default function AttendanceList() {
}
}
const handleFilter = (key) => {
setFilterKey(key)
setFilterValue('')
}
const clearFilter = () => {
setFilterKey('')
setFilterValue('')
}
const getSortIcon = (key) => {
if (sortKey !== key) return null
return sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
}
// Get lateness info for a record
const getLatenessInfo = (record) => {
// Find staff name and date columns
const staffKey = Object.keys(record).find(k =>
k.toLowerCase().includes('staff') || k.toLowerCase().includes('name') || k.toLowerCase().includes('員工')
)
const dateKey = Object.keys(record).find(k =>
k.toLowerCase().includes('date') || k.toLowerCase().includes('日期')
)
if (!staffKey || !dateKey) return null
const staff = record[staffKey]
const date = record[dateKey]
if (!staff || !date) return null
const key = `${staff}-${String(date).slice(0, 10)}`
return latenessMap[key]
}
return (
<div className="space-y-4">
{/* Header */}
@@ -188,43 +130,38 @@ export default function AttendanceList() {
/>
</div>
{/* Column Filter */}
{/* Status Filter (basic categories — server-side) */}
<div className="flex items-center gap-2">
<Filter size={16} className="text-slate-400" />
<select
value={filterKey}
onChange={(e) => handleFilter(e.target.value)}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="text-sm border border-slate-300 rounded-md px-2 py-2"
>
<option value="">篩選欄位</option>
{headers.slice(0, 6).map(h => (
<option key={h} value={h}>{h}</option>
))}
<option value="">全部狀態</option>
<option value="normal">正常</option>
<option value="abnormal">異常</option>
<option value="missing">缺勤</option>
<option value="holiday">公假</option>
<option value="leave">請假</option>
</select>
{filterKey && (
<>
<input
type="text"
value={filterValue}
onChange={(e) => setFilterValue(e.target.value)}
placeholder="輸入篩選值..."
className="text-sm border border-slate-300 rounded-md px-2 py-2 w-32"
/>
<button onClick={clearFilter} className="p-1 text-slate-400 hover:text-slate-600">
{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} 筆記錄
{latenessRecords.length > 0 && (
<span className="ml-2 text-orange-600">
{latenessRecords.length} 筆遲到
</span>
{statusFilter && (
<span className="ml-2 text-slate-400"> 已篩選{statusFilter}</span>
)}
</div>
</div>
@@ -267,14 +204,10 @@ export default function AttendanceList() {
</td>
</tr>
) : (
processedData.map((record) => {
const lateInfo = getLatenessInfo(record)
const isLate = lateInfo && lateInfo.minutes_late > 0
return (
processedData.map((record) => (
<tr
key={record._row}
className={`hover:bg-slate-50 cursor-pointer ${isLate ? 'bg-orange-50' : ''}`}
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>
@@ -284,17 +217,10 @@ export default function AttendanceList() {
</td>
))}
<td className="px-3 py-2 text-center">
{isLate ? (
<div className="flex items-center justify-center gap-1 text-orange-600" title={`遲到 ${lateInfo.minutes_late} 分鐘`}>
<AlertCircle size={14} />
<span className="text-xs font-medium">{lateInfo.minutes_late}</span>
</div>
) : (
<div className="flex items-center justify-center gap-1 text-green-600">
<Clock size={14} />
<span className="text-xs">正常</span>
</div>
)}
<StatusBadge
code={record.status_code}
text={record.status_text}
/>
</td>
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
<Link
@@ -305,8 +231,7 @@ export default function AttendanceList() {
</Link>
</td>
</tr>
)
})
))
)}
</tbody>
</table>
+24 -2
View File
@@ -18,10 +18,16 @@ const RESET_SECTIONS = [
},
{
key: "holidays",
label: "假期 Holidays + Leave",
description: "刪除所有公眾假期 + 員工請假記錄",
label: "假期 Holidays",
description: "刪除所有公眾假期記錄",
color: "amber",
},
{
key: "leaves",
label: "請假 Leaves",
description: "刪除所有員工請假記錄",
color: "lime",
},
]
export default function Settings() {
@@ -212,6 +218,22 @@ export default function Settings() {
>
<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>
))
+28 -13
View File
@@ -17,15 +17,24 @@ export default function AttendanceList() {
const [staffFilter, setStaffFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [page, setPage] = useState(1)
const [statusOptions, setStatusOptions] = useState([])
const perPage = 50
const [staffList, setStaffList] = useState([])
useEffect(() => {
fetchRecords()
fetchStatusTexts()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 () => {
setLoading(true)
try {
@@ -39,7 +48,7 @@ export default function AttendanceList() {
if (dateFrom) params.append('date_from', dateFrom)
if (dateTo) params.append('date_to', dateTo)
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}`)
setRecords(data.records || [])
@@ -78,15 +87,21 @@ export default function AttendanceList() {
}
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} />
}
const getRowClass = (code) => {
if (code === 'abnormal') return 'bg-red-50 hover:bg-red-100'
if (code === 'missing') return 'bg-gray-100 hover:bg-gray-200'
if (code === 'late' || code === 'late_early' || code === 'late_ot') return 'bg-orange-50 hover:bg-orange-100'
if (code === 'early' || code === 'early_ot') return 'bg-blue-50 hover:bg-blue-100'
if (code === 'ot') return 'bg-purple-50 hover:bg-purple-100'
// Match the basic-status grouping so highlights line up with the badge.
const c = String(code || '').toLowerCase()
if (c === 'abnormal' || c === 'late' || c === 'early' || c === 'ot'
|| c === 'late_early' || c === 'late_ot' || c === 'early_ot') {
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'
}
@@ -176,16 +191,16 @@ export default function AttendanceList() {
<span className="text-xs text-slate-500">狀態:</span>
<select
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"
>
<option value="">全部</option>
<option value="normal">正常</option>
<option value="late">遲到</option>
<option value="early">早退</option>
<option value="ot">OT</option>
<option value="abnormal">異常</option>
<option value="missing">缺勤</option>
{statusOptions.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>