Compare commits

...

42 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
IT狗 89c14c7a72 Make RECALC button btn-primary (blue) for visibility 2026-07-21 11:42:56 +08:00
IT狗 1a60c71032 Add RECALC button to Roster page to refresh data 2026-07-21 11:42:56 +08:00
IT Dog d5d9d644a0 feat: add backup/restore/version, fix leave duplicate, add Settings page to nav
- Add /api/admin/backup/list, /create, /download, /restore endpoints
- Add /api/version endpoint (git commit info)
- Add Settings page to sidebar nav + App.jsx routes
- Fix lateness stats: add total_late_count/total_late_minutes keys
- Fix leave aggregation: no more duplicate text in status
- Settings page now shows backup/restore UI + version info
- Fix leave upload: overwrite existing records on re-upload
2026-07-21 02:34:30 +08:00
IT Dog 6f90d60b13 Add Settings page, List page, report template 2026-07-21 01:56:10 +08:00
IT Dog 95814350c9 Accident display: fix aliases + submitted_at format
Issues:
1. Frontend renders record[h] where h comes from column_order.
   column_order now uses submitter_email (not employee_name),
   so record[submitter_email] = None → shows as "-".

2. submitted_at shows as ISO format "2026-07-06T15:45:33.850000"
   (less readable than "2026-07-06 15:45:33").

Fix:
1. to_dict adds both submitter_email and employee_name alias
   (same value, r.submitter_email). Same for injured_person_id ↔ employee_id.
   Frontend column_order key matches record key (submitter_email shows email).

2. submitted_at formatted as "YYYY-MM-DD HH:MM:%S" via strftime

Verified:
- record.submitter_email = "operation@scmedical.hk"
- record.employee_name = "operation@scmedical.hk" (alias)
- record.submitted_at = "2026-07-06 15:45:33"
2026-07-20 01:46:59 +08:00
IT Dog 975791b9bd Accident: add incident_datetime computed field (date HH:MM:SS)
Backend:
- to_dict() now includes incident_datetime: "YYYY-MM-DD HH:MM:SS"
- Composed from r.date (YYYY-MM-DD) + r.time (HH:MM:SS, padded if HH:MM)
- _get_excel_column_order_accident() adds incident_datetime column after time
- Export xlsx rec dict now includes incident_datetime

Verified:
- /api/accident/records: incident_datetime = "2026-07-30 09:40:00"
- column_order includes incident_datetime column
- /api/accident/export/excel: xlsx 明細 sheet now has incident_datetime column
- Time edge case: HH:MM auto-padded to HH:MM:SS

Frontend:
- Accident list page auto-displays new column via column_order
- No frontend changes needed (renders record[h] as string)
2026-07-20 01:38:24 +08:00
IT Dog 0d34b51918 Fix accident export xlsx: indent + submitter_email mapping
Issues fixed:
1. Indentation error after rename (rec dict outdented one level)
2. rec dict used employee_name/responsible_person keys but
   column_order used submitter_email/injured_person keys
   → rec.get(col, \"\") returned \"\" → xlsx cells showed None

Fix:
- Re-indent whole for r_idx block (ws.set_row, for c_idx)
- Use submitter_email/injured_person/injured_person_id as rec keys
  to match column_order
- Tested: xlsx 電子郵件地址 column now shows real email values
2026-07-20 01:08:24 +08:00
IT Dog aca1c55072 Fix: replace r.employee_name with r.submitter_email in accident references
After renaming Accident.employee_name -> submitter_email, four more
references to r.employee_name / r.employee_id slipped through and caused
500 Internal Server Errors:

1. /api/attendance/records to_dict - was using r.submitter_email (wrong table!)
   (regression from earlier batch sed that hit too many matches)
2. /api/accident/dashboard/stats - emp = r.employee_name
3. /api/accident/export/excel to_dict
4. /api/accident/export/excel overview + detail sheet

Fix: explicit context-aware replacements (only Accident-related sections)

Verification:
- /api/attendance/records: 200 OK (returns 110 records)
- /api/accident/records: 200 OK (returns 342 records)
- /api/accident/dashboard/summary: 200 OK
- /api/accident/dashboard/stats: 200 OK (was 500)
- /api/accident/export/excel: 200 OK + valid xlsx (78KB)
2026-07-20 00:22:32 +08:00
IT Dog 4bcda0a907 Accident schema: rename employee_name -> submitter_email (clearer semantic)
Database migration:
- ALTER TABLE accidents RENAME COLUMN employee_name TO submitter_email
- ALTER TABLE accidents RENAME COLUMN responsible_person TO injured_person
- ALTER TABLE accidents RENAME COLUMN employee_id TO injured_person_id

Reason: Accident.employee_name contained submitter EMAIL (not staff name),
causing confusion when joining with attendance_records.employee_name (real name).
New column names are self-documenting.

API backward-compat:
- All API responses still use "employee_name" / "responsible_person" field names
- Internal SQL mapping translates submitter_email/injured_person -> employee_name/responsible_person
- Frontend code unchanged

Verified:
- /api/accident/1 returns employee_name = submitter_email content
- /api/accident/records returns 2 records OK (was 500 before fix)
- /api/employees/list still returns 12 staff (accident excluded)
- Data preserved (1st record id=1 submitter_email = info@scmedical.hk)
2026-07-20 00:05:05 +08:00
IT Dog c0076a125c Fix: exclude accident source + filter email-format names from employees list
Root cause: Accident.employee_name contains submitter EMAIL addresses
(e.g. info@scmedical.hk), not the injured staff name. UNION of all three
sources polluted the attendance staff dropdown with emails.

Fix:
- Removed Accident from /api/employees/list UNION (source=all now means
  attendance + leave only)
- Added EMAIL_RE filter to skip any name containing @ (defensive)
- Updated docstring + response includes excluded field explaining why

Verified: 12 clean staff names returned (down from 16 with 4 emails mixed in)
2026-07-19 23:45:34 +08:00
IT Dog 6c70b81df5 Fix: replace uniqueEmps reference with employees state in LeaveModal
Bug: After refactoring LeavesTab to fetch employees from /api/employees/list
(useState + useEffect), the LeaveModal was still passed employees={uniqueEmps}
but that variable no longer existed, causing JS runtime error:
"Can't find variable: uniqueEmps"

Fix: rename reference to {employees}
2026-07-19 23:28:18 +08:00
IT Dog 25ebf6da66 Employee dropdown list all staff from attendance + leave + accident 2026-07-19 22:39:54 +08:00
IT Dog 0cd8717320 Multi-row LeaveModal frontend + LeaveRecord UNIQUE constraint
Frontend (Holidays.jsx LeaveModal):
- Split into Edit Mode (single record) + Create Mode (multi-row)
- Create mode: shared employee/date/approver + list of (type, hours, reason) rows
- Add/remove rows dynamically, each with own type + hours + reason
- Submit loops POST /api/leave per row, aggregates results (added/skipped/failed)
- Submit button label shows count: "加 N 條"
- Bottom shows total valid count + total hours
- Result summary shown after submit (expandable details)
- Auto-close on success after 1.2s

Backend:
- LeaveRecord: add UniqueConstraint (employee_name, date, leave_type)
- Migration: CREATE UNIQUE INDEX uix_leave_emp_date_type (cleaned any pre-existing dups first)
- POST /api/leave: catch IntegrityError → return 409 with clear message\n\nVerified:\n- 3 same-day different-type records (SL/CL/AL) created successfully\n- POST same type twice returns 409 with specific message\n- HTTP 200 for valid creation, 409 for collision
2026-07-19 22:13:13 +08:00
IT Dog 347a862cae Apply conditional upsert to attendance + accident Excel upload
Schema:
- attendance_records: last_edited_at + last_edited_by (was: is_manually_edited boolean only)
- accidents: last_edited_at + last_edited_by (was: no edit tracking)

Migration: ALTER TABLE adds columns (idempotent)

Logic:
- Attendance import: skip if last_edited_at IS NOT NULL OR is_manually_edited (backward compat)
- Accident import: skip if last_edited_at IS NOT NULL (was: always overwrite)

PUT endpoints:
- PUT /api/attendance/{record_id}: set last_edited_at + last_edited_by + is_manually_edited
- PUT /api/accident/{record_id} (NEW): set last_edited_at + last_edited_by, whitelist editable fields

Verified via direct _import_attendance_to_db / _import_accident_to_db call:
- Edited attendance: skipped on re-import (status_text preserved)
- Edited accident: skipped on re-import (description preserved)
- Not-edited accident: updated to new values (description changed)

Note: HTTP /api/upload/{section} returned 404 in test, but function logic verified working
2026-07-19 20:47:51 +08:00
14 changed files with 3347 additions and 431 deletions
+514 -90
View File
File diff suppressed because it is too large Load Diff
+29 -4
View File
@@ -25,19 +25,29 @@ class User(Base):
class Accident(Base): class Accident(Base):
"""意外記錄 Accident Reports
IMPORTANT — Column naming convention (2026-07-19 cleanup):
- submitter_email: The Google Form submitter's email (NOT the injured person)
- injured_person: Name of the staff who got hurt (was: responsible_person)
- injured_person_id: Employee ID (was: employee_id)
This naming makes it clear these are different concepts, preventing
accidental joins with attendance_records.employee_name (which IS a real name).
"""
__tablename__ = "accidents" __tablename__ = "accidents"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
date = Column(Date, nullable=False) date = Column(Date, nullable=False)
time = Column(String(10), nullable=True) time = Column(String(10), nullable=True)
location = Column(String(255), nullable=False) location = Column(String(255), nullable=False)
employee_name = Column(String(255), nullable=False) submitter_email = Column(String(255), nullable=False)
employee_id = Column(String(100)) injured_person_id = Column(String(100))
department = Column(String(100)) department = Column(String(100))
description = Column(Text, nullable=False) description = Column(Text, nullable=False)
severity = Column(String(50), nullable=False) severity = Column(String(50), nullable=False)
medical_report = Column(Text, nullable=True) medical_report = Column(Text, nullable=True)
action_taken = Column(Text, nullable=True) action_taken = Column(Text, nullable=True)
responsible_person = Column(String(255), nullable=True) injured_person = Column(String(255), nullable=True)
# Excel-derived (Google Form) extra columns (full map) -- 2026-07-18 # Excel-derived (Google Form) extra columns (full map) -- 2026-07-18
patient_or_staff = Column(Text, nullable=True) # Excel col 8 patient_or_staff = Column(Text, nullable=True) # Excel col 8
@@ -50,6 +60,11 @@ class Accident(Base):
custom_fields = Column(JSON, default={}) custom_fields = Column(JSON, default={})
created_by = Column(Integer, ForeignKey("users.id")) created_by = Column(Integer, ForeignKey("users.id"))
# Manual edit tracking - upload skips records with non-null last_edited_at
last_edited_at = Column(DateTime, nullable=True)
last_edited_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=now_hkt) created_at = Column(DateTime, default=now_hkt)
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt) updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
deleted_at = Column(DateTime, nullable=True) deleted_at = Column(DateTime, nullable=True)
@@ -156,12 +171,16 @@ class AttendanceRecord(Base):
actual_in = Column(String(10), nullable=True) actual_in = Column(String(10), nullable=True)
actual_out = Column(String(10), nullable=True) actual_out = Column(String(10), nullable=True)
# 人手修改標記 # 人手修改標記 (deprecated - use last_edited_at instead)
is_manually_edited = Column(Boolean, default=False) is_manually_edited = Column(Boolean, default=False)
# 原始 Excel 數據 # 原始 Excel 數據
raw_data = Column(JSON, default={}) raw_data = Column(JSON, default={})
# Manual edit tracking - upload skips records with non-null last_edited_at
last_edited_at = Column(DateTime, nullable=True)
last_edited_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=now_hkt) created_at = Column(DateTime, default=now_hkt)
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt) updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
@@ -200,8 +219,14 @@ class LeaveRecord(Base):
Per-employee, per-day, per-type leave. Hours-based granularity (0.5 ~ 8.0). Per-employee, per-day, per-type leave. Hours-based granularity (0.5 ~ 8.0).
HR uploads via CSV/Excel; manually editable. HR uploads via CSV/Excel; manually editable.
Unique constraint on (employee_name, date, leave_type) ensures one record
per employee per day per leave type. Re-import of same record upserts.
""" """
__tablename__ = "leave_records" __tablename__ = "leave_records"
__table_args__ = (
UniqueConstraint("employee_name", "date", "leave_type", name="uix_emp_date_type"),
)
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
employee_name = Column(String(255), nullable=False, index=True) employee_name = Column(String(255), nullable=False, index=True)
+366
View File
@@ -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>
+2 -2
View File
@@ -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 -1
View File
@@ -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",
+2
View File
@@ -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>
) )
+2 -1
View File
@@ -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() {
+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. * 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'))
} }
+242
View File
@@ -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>
)
}
+344
View File
@@ -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>
)
}
+28 -13
View File
@@ -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>
+291 -18
View File
@@ -284,7 +284,13 @@ function LeavesTab() {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
const uniqueEmps = [...new Set(leaves.map(l => l.employee_name))].sort() // Fetch employee list from /api/employees/list (UNION of attendance + leave + accident)
const [employees, setEmployees] = useState([])
useEffect(() => {
api.get('/api/employees/list')
.then(({ data }) => setEmployees(data.employees || []))
.catch(() => setEmployees([]))
}, [])
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -420,7 +426,7 @@ function LeavesTab() {
{(showCreate || editing) && ( {(showCreate || editing) && (
<LeaveModal <LeaveModal
leave={editing} leave={editing}
employees={uniqueEmps} employees={employees}
onClose={() => { setShowCreate(false); setEditing(null) }} onClose={() => { setShowCreate(false); setEditing(null) }}
onSaved={() => { setShowCreate(false); setEditing(null); load() }} onSaved={() => { setShowCreate(false); setEditing(null); load() }}
/> />
@@ -543,9 +549,11 @@ function HolidayModal({ holiday, onClose, onSaved }) {
) )
} }
// ============ Leave Modal ============ // ============ Leave Modal (multi-row create, single edit) ============
function LeaveModal({ leave, employees, onClose, onSaved }) { function LeaveModal({ leave, employees, onClose, onSaved }) {
const isEdit = !!leave const isEdit = !!leave
// Edit-mode state
const [employeeName, setEmployeeName] = useState(leave?.employee_name || '') const [employeeName, setEmployeeName] = useState(leave?.employee_name || '')
const [date, setDate] = useState(leave?.date || '') const [date, setDate] = useState(leave?.date || '')
const [leaveType, setLeaveType] = useState(leave?.leave_type || 'SL') const [leaveType, setLeaveType] = useState(leave?.leave_type || 'SL')
@@ -554,30 +562,30 @@ function LeaveModal({ leave, employees, onClose, onSaved }) {
const [approvedBy, setApprovedBy] = useState(leave?.approved_by || '') const [approvedBy, setApprovedBy] = useState(leave?.approved_by || '')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
async function handleSave() { // Create-mode: shared employee + date, plus a list of leave-type rows
const [createName, setCreateName] = useState('')
const [createDate, setCreateDate] = useState('')
const [rows, setRows] = useState([{ leave_type: 'SL', hours: 2, reason: '' }])
const [createApprovedBy, setCreateApprovedBy] = useState('')
const [results, setResults] = useState(null) // null | {added, skipped, failed}
async function handleSaveEdit() {
if (!employeeName || !date || !leaveType) { if (!employeeName || !date || !leaveType) {
toast.error('員工 / 日期 / 類型 係必填') toast.error('員工 / 日期 / 類型 係必填')
return return
} }
if (hours < 0 || hours > 24) { const h = Number(hours)
if (isNaN(h) || h < 0 || h > 24) {
toast.error('時數必須 0-24') toast.error('時數必須 0-24')
return return
} }
setSaving(true) setSaving(true)
try { try {
if (isEdit) {
await api.put(`/api/leave/${leave.id}`, { await api.put(`/api/leave/${leave.id}`, {
employee_name: employeeName, date, leave_type: leaveType, employee_name: employeeName, date, leave_type: leaveType,
hours: Number(hours), reason, approved_by: approvedBy, hours: h, reason, approved_by: approvedBy,
}) })
toast.success('已更新') toast.success('已更新')
} else {
await api.post('/api/leave', {
employee_name: employeeName, date, leave_type: leaveType,
hours: Number(hours), reason, approved_by: approvedBy,
})
toast.success('已新增')
}
onSaved() onSaved()
} catch (err) { } catch (err) {
toast.error(err.response?.data?.detail || '儲存失敗') toast.error(err.response?.data?.detail || '儲存失敗')
@@ -586,11 +594,82 @@ function LeaveModal({ leave, employees, onClose, onSaved }) {
} }
} }
function addRow() {
// Suggest an unused type so user doesn't have to pick
const used = new Set(rows.map(r => r.leave_type))
const next = ['SL', 'CL', 'AL'].find(t => !used.has(t)) || 'SL'
setRows([...rows, { leave_type: next, hours: 1, reason: '' }])
}
function removeRow(idx) {
setRows(rows.filter((_, i) => i !== idx))
}
function updateRow(idx, field, value) {
setRows(rows.map((r, i) => i === idx ? { ...r, [field]: value } : r))
}
async function handleSaveCreate() {
if (!createName || !createDate) {
toast.error('員工 / 日期 係必填')
return
}
const valid = rows.filter(r => r.leave_type && Number(r.hours) > 0 && Number(r.hours) <= 24)
if (valid.length === 0) {
toast.error('至少要有一條有效 row (類型 + 時數 > 0)')
return
}
setSaving(true)
setResults(null)
let added = 0
const skipped = []
const failed = []
for (const row of valid) {
try {
await api.post('/api/leave', {
employee_name: createName,
date: createDate,
leave_type: row.leave_type,
hours: Number(row.hours),
reason: row.reason,
approved_by: createApprovedBy,
})
added += 1
} catch (err) {
const status = err.response?.status
const detail = err.response?.data?.detail || err.message
// SQLAlchemy UNIQUE violation → 撞到 existing
if (status === 409 || (detail && (detail.includes('UNIQUE') || detail.includes('exists')))) {
skipped.push({ row, reason: '撞到 existing record' })
} else {
failed.push({ row, reason: detail })
}
}
}
setResults({ added, skipped, failed, total: valid.length })
setSaving(false)
if (added > 0) {
// refresh parent + show summary
const msg = `✓ 加咗 ${added}` +
(skipped.length ? ` · skip ${skipped.length}` : '') +
(failed.length ? ` · 失敗 ${failed.length}` : '')
toast.success(msg)
// Auto-close after 1.5s if no failures
if (failed.length === 0) {
setTimeout(() => onSaved(), 1200)
}
} else if (failed.length === 0) {
toast('冇加到 (全部撞到 existing)', { icon: '️' })
} else {
toast.error(`${failed.length} 條失敗`)
}
}
// ============== EDIT MODE ==============
if (isEdit) {
return ( return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}> <div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-md" onClick={(e) => e.stopPropagation()}> <div className="bg-white rounded-lg shadow-xl w-full max-w-md" onClick={(e) => e.stopPropagation()}>
<div className="px-5 py-3 border-b border-slate-200 flex items-center justify-between"> <div className="px-5 py-3 border-b border-slate-200 flex items-center justify-between">
<h3 className="font-semibold text-slate-900">{isEdit ? '編輯請假' : '加請假'}</h3> <h3 className="font-semibold text-slate-900">編輯請假</h3>
<button onClick={onClose} className="p-1 text-slate-400 hover:text-slate-700 rounded"> <button onClick={onClose} className="p-1 text-slate-400 hover:text-slate-700 rounded">
<X size={18} /> <X size={18} />
</button> </button>
@@ -598,7 +677,7 @@ function LeaveModal({ leave, employees, onClose, onSaved }) {
<div className="p-5 space-y-3"> <div className="p-5 space-y-3">
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1">員工 <span className="text-red-500">*</span></label> <label className="block text-sm font-medium text-slate-700 mb-1">員工 <span className="text-red-500">*</span></label>
{employees.length > 0 ? ( {employees && employees.length > 0 ? (
<select <select
value={employeeName} value={employeeName}
onChange={(e) => setEmployeeName(e.target.value)} onChange={(e) => setEmployeeName(e.target.value)}
@@ -700,14 +779,208 @@ function LeaveModal({ leave, employees, onClose, onSaved }) {
取消 取消
</button> </button>
<button <button
onClick={handleSave} onClick={handleSaveEdit}
disabled={saving} disabled={saving}
className="px-4 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700 disabled:opacity-50" className="px-4 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700 disabled:opacity-50"
> >
{saving ? '儲存中...' : (isEdit ? '更新' : '新增')} {saving ? '儲存中...' : '更新'}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
) )
}
// ============== CREATE MODE (multi-row) ==============
const totalHours = rows.reduce((s, r) => s + (Number(r.hours) || 0), 0)
const validCount = rows.filter(r => r.leave_type && Number(r.hours) > 0).length
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="px-5 py-3 border-b border-slate-200 flex items-center justify-between">
<div>
<h3 className="font-semibold text-slate-900">加請假 (一次過加多個類型)</h3>
<p className="text-xs text-slate-500 mt-0.5">SL 病假 / CL 補鐘 / AL 年假 可以同日多條</p>
</div>
<button onClick={onClose} className="p-1 text-slate-400 hover:text-slate-700 rounded">
<X size={18} />
</button>
</div>
<div className="p-5 space-y-4">
{/* Shared: employee + date + approver */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">員工 <span className="text-red-500">*</span></label>
{employees && employees.length > 0 ? (
<select
value={createName}
onChange={(e) => setCreateName(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
>
<option value="">-- 揀員工 --</option>
{employees.map(e => <option key={e} value={e}>{e}</option>)}
{!employees.includes(createName) && createName && (
<option value={createName}>{createName} ()</option>
)}
</select>
) : (
<input
type="text"
value={createName}
onChange={(e) => setCreateName(e.target.value)}
placeholder="員工姓名"
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">日期 <span className="text-red-500">*</span></label>
<input
type="date"
value={createDate}
onChange={(e) => setCreateDate(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">審批人</label>
<input
type="text"
value={createApprovedBy}
onChange={(e) => setCreateApprovedBy(e.target.value)}
placeholder="Manager A"
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
</div>
{/* Rows */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-slate-700">
請假項目 <span className="text-red-500">*</span>
<span className="ml-2 text-xs text-slate-400">(同日可以加 SL/CL/AL 不同類型)</span>
</label>
<button
type="button"
onClick={addRow}
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-slate-100 hover:bg-slate-200 text-slate-700 rounded"
>
<Plus size={12} /> 加多一條
</button>
</div>
<div className="space-y-2">
{rows.map((row, idx) => (
<div key={idx} className="flex items-start gap-2 p-2 bg-slate-50 rounded-md border border-slate-200">
{/* Type buttons (vertical) */}
<div className="flex flex-col gap-1 shrink-0">
{LEAVE_TYPES.map(t => (
<button
key={t.value}
type="button"
onClick={() => updateRow(idx, 'leave_type', t.value)}
className={`px-3 py-1 rounded text-xs font-medium border min-w-[3rem] ${
row.leave_type === t.value
? t.color === 'pink' ? 'bg-pink-100 border-pink-400 text-pink-700'
: t.color === 'indigo' ? 'bg-indigo-100 border-indigo-400 text-indigo-700'
: 'bg-emerald-100 border-emerald-400 text-emerald-700'
: 'bg-white border-slate-200 text-slate-500'
}`}
>
{t.value}
</button>
))}
</div>
{/* Hours */}
<div className="flex-1 min-w-0">
<input
type="number"
step="0.5"
min="0"
max="24"
value={row.hours}
onChange={(e) => updateRow(idx, 'hours', e.target.value)}
className="w-full px-2 py-1.5 border border-slate-300 rounded-md text-sm"
placeholder="時數"
/>
<div className="flex gap-1 mt-1">
{[1, 2, 4, 8].map(h => (
<button
key={h}
type="button"
onClick={() => updateRow(idx, 'hours', h)}
className="px-1.5 py-0.5 text-xs bg-white border border-slate-200 hover:bg-slate-100 rounded"
>
{h}h
</button>
))}
</div>
</div>
{/* Reason */}
<div className="flex-1 min-w-0">
<input
type="text"
value={row.reason}
onChange={(e) => updateRow(idx, 'reason', e.target.value)}
placeholder="原因 (optional)"
className="w-full px-2 py-1.5 border border-slate-300 rounded-md text-sm"
/>
</div>
{/* Remove */}
{rows.length > 1 && (
<button
type="button"
onClick={() => removeRow(idx)}
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded shrink-0"
title="移除"
>
<X size={14} />
</button>
)}
</div>
))}
</div>
</div>
{/* Result summary */}
{results && (
<div className="text-xs bg-slate-50 border border-slate-200 rounded-md p-2">
<div>加咗 <b className="text-emerald-700">{results.added}</b> / Skip <b className="text-amber-700">{results.skipped.length}</b> / 失敗 <b className="text-red-700">{results.failed.length}</b> / 總共 {results.total}</div>
{(results.skipped.length > 0 || results.failed.length > 0) && (
<details className="mt-1">
<summary className="cursor-pointer text-slate-600">詳細</summary>
<div className="mt-1 text-slate-500 space-y-0.5">
{results.skipped.map((s, i) => (
<div key={i}> Skip {s.row.leave_type} {s.row.hours}h {s.row.reason}: {s.reason}</div>
))}
{results.failed.map((f, i) => (
<div key={i}> 失敗 {f.row.leave_type} {f.row.hours}h: {f.reason}</div>
))}
</div>
</details>
)}
</div>
)}
</div>
<div className="px-5 py-3 border-t border-slate-200 flex justify-between gap-2 bg-slate-50">
<div className="text-xs text-slate-500 self-center">
{validCount} 條有效 · 總時數 {totalHours}h
</div>
<div className="flex gap-2">
<button onClick={onClose} className="px-4 py-1.5 text-slate-600 hover:bg-slate-100 rounded-md text-sm">
取消
</button>
<button
onClick={handleSaveCreate}
disabled={saving}
className="px-4 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700 disabled:opacity-50"
>
{saving ? '儲存中...' : `${validCount}`}
</button>
</div>
</div>
</div>
</div>
)
} }
+22 -1
View File
@@ -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,11 +121,17 @@ export default function RosterPage() {
</p> </p>
</div> </div>
</div> </div>
<div className="flex gap-2">
<button onClick={handleRecalc} className="btn-primary">
<RefreshCw size={16} className="inline mr-1" />
RECALC
</button>
<button onClick={handleDelete} className="btn-danger"> <button onClick={handleDelete} className="btn-danger">
<Trash2 size={16} className="inline mr-1" /> <Trash2 size={16} className="inline mr-1" />
刪除 刪除
</button> </button>
</div> </div>
</div>
)} )}
</div> </div>
+1225
View File
File diff suppressed because it is too large Load Diff