51 Commits

Author SHA1 Message Date
IT狗 acbc767247 feat(auth): switch to bcrypt primary scheme, keep sha256_crypt for migration
- database.py: CryptContext = ['bcrypt', 'sha256_crypt'] with sha256_crypt deprecated
- database.py: add verify_and_update_password() returning (ok, new_hash)
- main.py: login uses verify_and_update; on successful verify transparently
  commits new bcrypt hash when old hash is sha256_crypt
- requirements.txt: passlib[bcrypt]==1.7.4 + bcrypt==4.0.1 (compat with passlib 1.7.4)

Migration: zero-touch. Admin's first login after this deploy will
auto-upgrade the stored hash from $5$... to $2b$...

Avoids the passlib sha256_crypt cross-version verify bug that bit us
this morning. bcrypt is OpenSSL-native and stable across passlib
versions.
2026-07-22 12:07:51 +08:00
IT狗 f6712d9d0e test(auth): smoke test for /api/auth/users + reset-password 2026-07-22 11:02:11 +08:00
IT狗 262c44f6a1 feat(auth): user management UI in Settings + GET /api/auth/users
- New: GET /api/auth/users (admin only) returns [{id, email, name, role, is_active}]
- New: 'User Management' card in Settings page
  - Lists all users with role badge (admin/user) and inactive indicator
  - 'Reset Password' button per user
  - Modal: enter new password (>=6 chars), confirm/cancel
  - Toast success / inline error
  - Esc closes modal, click backdrop closes
  - ESC + Enter shortcuts
  - Non-admin (or 403) shows empty state
2026-07-22 11:00:48 +08:00
IT狗 3c855d521b fix(auth): import get_password_hash in main.py 2026-07-22 10:57:25 +08:00
IT狗 5596ab2cb5 feat(auth): add admin-only /api/auth/reset-password endpoint
POST /api/auth/reset-password
Body: { email, new_password }
Auth: requires admin role (get_current_admin)
Response: { ok, email, id, reset_by }

- Validates new_password >= 6 chars
- Returns 404 if target email not found
- Logs admin_id + target_user_id for audit trail
- Fixes passlib sha256_crypt cross-version verify bug by allowing
  admin to rehash any user's password on demand
2026-07-22 10:56:09 +08:00
IT狗 a7dfd208d2 fix(dashboard): use backend attendance_rate instead of broken client formula
Old formula: (total - late - abnormal) / total
- Did not subtract missing (so 100% with 缺勤 2)
- Subtracted abnormal twice (already hidden late/early/ot)
- Could never reach 0%

New: read backend's pre-computed attendance_rate
  = (total - missing) / total * 100

Wing Fung (10 rec, 2 missing) now 80% (was 100%)
Cindy (10 rec, 4 missing) now 60% (was 90%)
2026-07-22 10:41:11 +08:00
IT狗 a16e243da1 fix(dashboard): include abnormal-override records in component counts
status_code='abnormal' records (>30 min) hide the underlying late/early/ot
component from by_status. Use *_minutes>0 union to surface them so the
dashboard count matches the real data.

Late: 9 → 23, Early: 0 → 17, OT: 15 → 28
Note: component totals may now exceed total_records because each record
can carry multiple status components (e.g. late_ot).
2026-07-22 09:55:18 +08:00
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
IT Dog 8e0d73c37e Conditional upsert: upload skips edited records, updates not-edited
- Add last_edited_at + last_edited_by columns to holidays + leave_records
- Migration: ALTER TABLE adds columns (idempotent)
- PUT endpoints set last_edited_at on every manual edit
- POST /api/leave/upload: skip if last_edited_at NOT NULL, update otherwise
- New POST /api/holidays/upload: same logic, admin only
- LeaveOut + HolidayOut schemas include last_edited_at
- Response includes added/updated/skipped counters
- Verified: edited Jay Lam SL preserved, not-edited May Chan SL updated
2026-07-19 20:26:59 +08:00
IT Dog bbc0048c24 Initial commit: AARS backend + frontend + holiday/leave phase 1+2 2026-07-19 20:10:15 +08:00
40 changed files with 12903 additions and 3177 deletions
+20 -2
View File
@@ -1,5 +1,23 @@
# Runtime data
data/
logs/
*.db
*.sqlite
*.sqlite3
# Python
__pycache__/
*.py[cod]
*$py.class
*.pyc
node_modules/
.env
.venv/
venv/
# Node
frontend/node_modules/
frontend/dist/
# Backups
*.bak
*.bak*
main.py.bak*
+327
View File
@@ -127,3 +127,330 @@ docker compose up -d
**Q: Excel 导入失败?**
确保 Excel 文件第一行是表头,且日期格式为 `YYYY-MM-DD`
## 📊 Attendance System
### Data Source
- **Primary**: SQLite table `attendance_records` (SQLAlchemy ORM)
- **Legacy**: Excel file at `/app/data/attendance.xlsx` (still saved on upload for export compatibility)
### DB Schema (`attendance_records`)
| Column | Type | Notes |
|--------|------|-------|
| `id` | INTEGER PK | auto |
| `employee_name` | VARCHAR(255) | index |
| `company`, `department` | VARCHAR(100) | nullable |
| `date` | DATE | index |
| `weekday` | VARCHAR(20) | e.g. "Friday" |
| `check_in`, `check_out` | DATETIME | nullable |
| `shift_code` | VARCHAR(20) | e.g. "S7a" |
| `status_code` | VARCHAR(20) | `normal` / `late` / `early` / `late_early` / `late_ot` / `early_ot` / `ot` / `missing` / `abnormal` |
| `status_text` | VARCHAR(100) | Chinese e.g. 「遲到46分 / OT60分」 |
| `late_minutes`, `early_minutes`, `ot_minutes` | INTEGER | computed |
| `expected_in`, `expected_out`, `actual_in`, `actual_out` | VARCHAR(10) | "HH:MM" string |
| `is_manually_edited` | BOOLEAN | **preserves manual edits from import overwrite** |
| `raw_data` | JSON | source row |
| `shift_id` | INTEGER FK | → `shifts.id` |
UNIQUE constraint on `(employee_name, date)` — one record per staff per day.
### Shifts Table (`shifts`)
- 11 rows pre-loaded (S7a, S8a, ...)
- Columns: `shift_code` + per-day (`mon_start`/`mon_end` ... `sun_start`/`sun_end`), all `HH:MM` strings
- Method: `Shift.get_schedule(weekday)``(start_str, end_str)` or `(None, None)` on day off
### Status Calculation (`calculate_attendance_status` in `main.py`)
| Status | Rule |
|--------|------|
| `missing` | No check-in or no check-out |
| `abnormal` | check_in == check_out (exact same second) |
| `late` | check_in > expected start (late_minutes = round(diff)) |
| `early` | check_out < expected end (early_minutes = round(diff)) |
| `ot` | check_out > expected end (ot_minutes = round(diff)) |
| `late_early`, `late_ot`, `early_ot` | combined codes |
Notes:
- `int()` was buggy → now uses `round()` (off-by-one fix)
- `18:00:00` exact default check (NOT `18:00:03` which is real swipe)
### API Endpoints (current, attendance scope)
| Method | Path | Source | Notes |
|--------|------|--------|-------|
| GET | `/api/dashboard/summary` | **SQL** | Returns `total_records`, `*_count`, `staff_count`, `by_status`, `by_department` (frontend-compatible fields) |
| GET | `/api/attendance/stats` | **SQL (alias)** | Per-employee: count + minutes + avg + max + consecutive_missing + last_attendance_date + attendance_rate |
| GET | `/api/attendance/records` | **SQL (alias)** | Supports `staff`, `date_from`, `date_to`, `status`, `search`, `sort_by`, `sort_order`, `page`, `per_page` |
| GET | `/api/attendance/{id}` | SQL | Single record by ID |
| PUT | `/api/attendance/{id}` | SQL | Update + mark `is_manually_edited=true` (protected from import) |
| POST | `/api/upload/attendance` | Excel + SQL | Save file + import to DB (skips records where `is_manually_edited=true`) |
| GET | `/api/export/attendance/excel` | **SQL → xlsxwriter** | On-the-fly 16-col xlsx with status colors |
### Excel Export Columns (16)
| Col | Name | Notes |
|-----|------|-------|
| A | Staff Name | |
| B | Company | |
| C | Department | |
| D | Date | ISO `YYYY-MM-DD` |
| E | Weekday | |
| F | Shift | |
| G | Expected In | |
| H | Expected Out | |
| I | Actual In | |
| J | Actual Out | |
| **K** | **Status Code** | **color-coded per status** |
| L | Status Text | Chinese |
| M | Late Min | |
| N | Early Min | |
| O | OT Min | |
| P | Manual Edit | "Yes" if manually edited |
### Status Colors (xlsxwriter)
| Status | BG | Font |
|--------|------|------|
| Header | `#DBEAFE` (light blue) | `#1E3A8A` (dark blue, bold) — frozen top row |
| normal | `#D1FAE5` (green) | `#065F46` |
| late / late_early | `#FEF3C7` (amber) | `#92400E` |
| late_ot | `#FED7AA` (orange) | `#7C2D12` |
| early | `#DBEAFE` (blue) | `#1E40AF` |
| early_ot | `#E0E7FF` (indigo) | `#1E40AF` |
| ot | `#EDE9FE` (purple) | `#5B21B6` |
| abnormal | `#FEE2E2` (red) | `#7F1D1D` |
| missing | `#E5E7EB` (gray) | `#374151` |
### Frontend Components
- **Dashboard** (`pages/Dashboard.jsx`):
- 8 stats cards (total / normal / late / early / OT / missing / abnormal / staff)
- 4 leaderboards (Late / OT / Missing / Attendance Rate)
- 5 date presets (今日 / 本週 / 上週 / 本月 / 清除)
- Per-staff detail row: avg / max / consecutive_missing / last_attendance_date / attendance_rate
- **Attendance List** (`pages/attendance/List.jsx`):
- Table with filters (status, date range, staff, search)
- Pagination + sort
- "Dashboard" back-link button (`to="/"`, NOT `to="/dashboard"`)
- **Attendance Detail** (`pages/attendance/Detail.jsx`):
- Per-record view + edit
### Manual Edit Flow
1. User edits record via UI (Detail page) → `PUT /api/attendance/{id}` → marks `is_manually_edited=true`
2. Next upload of Excel → import loop checks `is_manually_edited` → skips this record (preserves manual change)
### Known Data Quirks
- `expected_in` / `expected_out` stored as strings ("11:00") for direct UI display
- `actual_in` / `actual_out` same — strings, NOT datetimes
- `Shift.get_schedule()` returns strings — caller must `datetime.strptime(start_str, "%H:%M").time()` before passing to `calculate_attendance_status`
### Files
- Backend: `/root/aars/backend/main.py` (~2100 lines), `models.py`, `database.py`, `schemas.py`, `auth.py`
- DB file: `/root/aars/data/aars.db` (SQLite, bind-mounted into container)
- Frontend: `/root/aars/frontend/src/pages/Dashboard.jsx`, `pages/attendance/List.jsx`, `pages/attendance/Detail.jsx`
- Excel data: `/root/aars/data/attendance.xlsx` (legacy), `roster.xlsx`, `accident.xlsx`
### Computed Backend Data Caveats
- **Status counts are dynamic** — computed from `attendance_records` table at query time
- **No materialized views** — query heavy if data grows beyond ~100k records
- **Roster changes affect historical data** — if you update `Shift.mon_start` for S7a, all past records with `shift_code="S7a"` are NOT auto-recalculated (run recompute manually if needed)
## 🐛 Debug Logging
Structured JSON logs written to `/root/aars/logs/` (bind-mounted into container at `/app/logs/`). Designed for production debugging — NOT compliance/audit.
### Files
| File | Contents | Retention |
|------|----------|-----------|
| `app-YYYY-MM-DD.jsonl` | General INFO+ events | 7 days |
| `error-YYYY-MM-DD.jsonl` | Server + client errors (with stack) | 30 days |
| `access-YYYY-MM-DD.jsonl` | One entry per HTTP request | 7 days |
Files auto-rotate daily via in-process `DailyJSONLHandler`. Old files deleted by `/root/aars/scripts/logs-cleanup.sh` (cron at 03:00 recommended).
### Log Schema
Every entry is single-line JSON:
```json
{
"ts": "2026-07-17T08:30:00.123Z",
"level": "info",
"service": "aars-backend",
"env": "production",
"request_id": "abc123-...",
"route": "/api/dashboard/summary",
"method": "GET",
"status": 200,
"duration_ms": 114,
"msg": "human readable summary",
"error": { "name": "...", "message": "...", "stack": "..." }
}
```
- `ts`: ISO 8601 UTC
- `level`: `debug` / `info` / `warn` / `error`
- `service`: `aars-backend` (frontend errors tagged with `"source": "client"` in `context`)
- `env`: hardcoded `production`
- `request_id`: from `X-Request-ID` header (auto-generated if missing, validated if present)
- Sensitive fields auto-redacted (see below)
### Sensitive Redaction
Backend redactor scrubs these substrings from any log payload (dicts/strings walked recursively):
- `password`, `passwd`, `pwd`, `pass`
- `token`, `access_token`, `refresh_token`, `id_token`, `jwt`, `bearer`
- `api_key`, `apikey`, `secret`, `client_secret`
- `authorization`, `cookie`, `set-cookie`, `session`
- `database_url`, `db_url`, `connection_string`
JWT regex: `eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+``[REDACTED:JWT]`
**Verification**:
```bash
grep -l password /root/aars/logs/*.jsonl # should return nothing
grep admin123 /root/aars/logs/*.jsonl # should return nothing
```
### Reproducing an Error (workflow for ITdog)
1. User reports an issue with `request_id` (visible in browser DevTools → Network → Response Headers → `X-Request-ID`)
2. SSH to VPS:
```bash
ssh root@187.127.116.15
```
3. Search by request_id across all 3 files:
```bash
grep '"request_id":"paste-id-here"' /root/aars/logs/{app,error,access}-2026-07-17.jsonl | jq .
```
4. Search by route / status / message:
```bash
grep '"route":"/api/dashboard/summary"' /root/aars/logs/error-*.jsonl | jq .
grep '"status":500' /root/aars/logs/access-*.jsonl | jq .
grep 'login failed' /root/aars/logs/app-*.jsonl | jq .
```
5. Live tail for new errors:
```bash
tail -f /root/aars/logs/error-*.jsonl | jq .
```
### Live Tailing
```bash
tail -f /root/aars/logs/app-2026-07-17.jsonl | jq . # app events
tail -f /root/aars/logs/access-2026-07-17.jsonl | jq . # HTTP requests
tail -f /root/aars/logs/error-2026-07-17.jsonl | jq . # errors
```
(Requires `jq` — `apt install -y jq` on VPS if not present.)
### npm Scripts (from frontend dir)
```bash
cd /root/aars/frontend
npm run logs:tail # tail app log
npm run logs:errors # tail error log
npm run logs:access # tail access log
npm run logs:search -- '"route":"/api/dashboard"' # search all 3 files
npm run logs:clean # manually trigger retention cleanup
```
### Triggering a Test Client Error
From browser console:
```js
import('/src/lib/logger.js').then(m => m.logger.error('manual test', { stack: 'fake stack' }))
```
Or via curl (no auth required):
```bash
curl -X POST https://excel.donton.cloud/api/client-logs \
-H "Content-Type: application/json" \
-H "X-Request-ID: my-debug-test" \
-d '{"level":"error","msg":"manual curl test","stack":"foo","page_url":"https://excel.donton.cloud/dashboard","app_version":"1.0.0"}'
```
Then verify:
```bash
grep "my-debug-test" /root/aars/logs/error-*.jsonl | jq .
```
### Manual Retention Cleanup
```bash
npm run logs:clean
# or directly
bash /root/aars/scripts/logs-cleanup.sh
```
To schedule daily, add to crontab on the host:
```cron
0 3 * * * /root/aars/scripts/logs-cleanup.sh >> /var/log/aars-cleanup.log 2>&1
```
### Architecture
**Backend** (FastAPI stdlib only, no new deps):
- `backend/logging_config.py`:
- `JSONFormatter` — formats `LogRecord` → single-line JSON
- `DailyJSONLHandler` — in-process daily file rotation (not stdlib `TimedRotatingFileHandler` which has awkward filename semantics)
- `_scrub(obj)` — recursive dict walker + JWT regex
- `ContextVar` `request_id_var` + `user_id_var` for cross-function context
- Console handler with `ContextFilter` for human-readable stdout output
- `backend/middleware.py`:
- `RequestIDMiddleware` — generate uuid4 if `X-Request-ID` missing, validate if present (`[A-Za-z0-9_-]{1,128}` regex)
- `AccessLogMiddleware` — log every request with route/method/status/duration_ms
- `unhandled_exception_handler` — catch-all 500 with stack trace
**Frontend** (vanilla JS, no extra deps):
- `frontend/src/lib/logger.js`:
- Buffer (50 entries max), flush every 10s + on `pagehide` + on visibility hidden
- `navigator.sendBeacon` (preferred) → `fetch` fallback
- Captures `console.error`, `unhandledrejection`, ErrorBoundary errors
- Redacts same keys as backend (client-side filter)
- `frontend/src/api.js`:
- Axios interceptor — generate `X-Request-ID` per request, capture from response
- `frontend/src/components/ErrorBoundary.jsx`:
- React error boundary that posts to `/api/client-logs`
- `frontend/src/main.jsx`:
- Wraps app in ErrorBoundary, installs global error handlers
**Server endpoint**:
- `POST /api/client-logs` — no auth, validates via `ClientLogEntry` Pydantic schema, writes to error log with `source: client` tag
### Excluded (by design)
- ❌ Rate limiting on `/api/client-logs` — debug use, no DDoS concern
- ❌ PII pattern scrubbing (emails, HKID) — DB has no such data
- ❌ Lint / type / unit tests — user said no testing around
- ❌ Compliance / audit logging — no requirements
- ❌ Centralized log aggregation (ELK, Datadog) — VPS-only, SSH access suffices
### Files
| Path | Purpose |
|------|---------|
| `/root/aars/backend/logging_config.py` | Formatter + handler + redactor |
| `/root/aars/backend/middleware.py` | RequestID + AccessLog + exception handler |
| `/root/aars/scripts/logs-cleanup.sh` | Retention cron (run daily) |
| `/root/aars/logs/app-*.jsonl` | App events (7d) |
| `/root/aars/logs/error-*.jsonl` | Errors + stack traces (30d) |
| `/root/aars/logs/access-*.jsonl` | HTTP access log (7d) |
| `/root/aars/frontend/src/lib/logger.js` | Client logger |
| `/root/aars/frontend/src/api.js` | Axios + X-Request-ID interceptor |
| `/root/aars/frontend/src/components/ErrorBoundary.jsx` | React error boundary |
### Known Issues
1. **`jq` not pre-installed on VPS** — `apt install -y jq` required for pretty-printed logs
2. **Crontab for `logs-cleanup.sh` not auto-installed** — manually add `0 3 * * *` cron entry on host
3. **Container healthcheck always reports unhealthy** — uses `/api/auth/me` (returns 403 without auth). Cosmetic only, doesn't affect functionality.
+1
View File
@@ -0,0 +1 @@
logs/
+14 -1
View File
@@ -15,7 +15,11 @@ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
pwd_context = CryptContext(schemes=["sha256_crypt"], deprecated="auto")
# bcrypt is the primary scheme (cross-version stable, OpenSSL-native).
# sha256_crypt is kept as deprecated fallback so legacy hashes still
# verify; on first successful login the password is re-hashed to bcrypt
# via verify_and_update().
pwd_context = CryptContext(schemes=["bcrypt", "sha256_crypt"], deprecated=["sha256_crypt"])
# JWT Settings
SECRET_KEY = secret.JWT_SECRET if hasattr(secret, 'JWT_SECRET') else "aars-dev-secret-change-in-production"
@@ -34,6 +38,15 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def verify_and_update_password(plain_password: str, hashed_password: str) -> tuple[bool, str | None]:
"""Verify password and return new hash if rehash is needed.
Returns (ok, new_hash_or_none). Caller is responsible for committing
the new hash to the DB when new_hash is not None.
"""
return pwd_context.verify_and_update(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
+251
View File
@@ -0,0 +1,251 @@
"""
Structured JSON logging for AARS backend.
Writes newline-delimited JSON to:
/app/logs/app-YYYY-MM-DD.jsonl - INFO+ (general)
/app/logs/error-YYYY-MM-DD.jsonl - ERROR only
/app/logs/access-YYYY-MM-DD.jsonl - HTTP access log
Features:
- ISO 8601 UTC timestamps
- X-Request-ID context propagation via ContextVar
- Automatic secret redaction (passwords, tokens, JWTs, etc.)
- Daily rotation via TimedRotatingFileHandler
"""
import json
import logging
import re
import sys
from contextvars import ContextVar
from datetime import datetime, timezone
from logging.handlers import TimedRotatingFileHandler
from typing import Any
# ============ Request context ============
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
user_id_var: ContextVar[int] = ContextVar("user_id", default=0)
def set_request_id(rid: str) -> None:
request_id_var.set(rid)
def get_request_id() -> str:
return request_id_var.get()
def set_user_id(uid: int) -> None:
user_id_var.set(uid)
# ============ Redactor ============
REDACT_KEYS = {
"password", "passwd", "pwd", "pass",
"token", "access_token", "refresh_token", "id_token",
"jwt", "bearer", "authorization",
"api_key", "apikey", "secret", "client_secret",
"cookie", "set-cookie", "session",
"database_url", "db_url", "connection_string",
"x-api-key", "x-auth-token",
}
REDACT_PATTERNS = [
(re.compile(r"eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+"), "[REDACTED:JWT]"),
(re.compile(r"(?i)(bearer\s+)[A-Za-z0-9\-_.]+"), r"\1[REDACTED]"),
]
REDACTED = "[REDACTED]"
def _scrub_str(s: str) -> str:
for pat, repl in REDACT_PATTERNS:
s = pat.sub(repl, s)
return s
def _scrub(obj: Any) -> Any:
"""Recursively redact secrets from dict/list/str."""
if isinstance(obj, dict):
out = {}
for k, v in obj.items():
if isinstance(k, str) and k.lower() in REDACT_KEYS:
out[k] = REDACTED
else:
out[k] = _scrub(v)
return out
if isinstance(obj, list):
return [_scrub(v) for v in obj]
if isinstance(obj, str):
return _scrub_str(obj)
return obj
# ============ JSON Formatter ============
class JSONFormatter(logging.Formatter):
SERVICE = "aars-backend"
ENV = "production"
def format(self, record: logging.LogRecord) -> str:
# Standard fields
payload = {
"ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
"level": record.levelname.lower(),
"service": self.SERVICE,
"env": self.ENV,
"request_id": request_id_var.get(),
"msg": record.getMessage(),
}
# Optional fields from extra=
for key in ("route", "method", "status", "duration_ms", "user_id",
"page_url", "app_version", "browser", "client_ip"):
v = getattr(record, key, None)
if v is not None:
payload[key] = v
# Exception info
if record.exc_info:
exc_type, exc_val, exc_tb = record.exc_info
payload["error"] = {
"name": exc_type.__name__ if exc_type else "Exception",
"message": str(exc_val) if exc_val else "",
"stack": self.formatException(record.exc_info),
}
# Custom extras (e.g. extra={"context": {...}})
if hasattr(record, "context") and record.context:
payload["context"] = _scrub(record.context)
# Scrub msg + all string fields
payload = _scrub(payload)
try:
return json.dumps(payload, ensure_ascii=False, default=str)
except (TypeError, ValueError):
payload["msg"] = str(payload.get("msg", ""))[:500]
return json.dumps(payload, ensure_ascii=False, default=str)
# ============ Setup ============
LOG_DIR = "/app/logs"
APP_RETENTION = 7 # days
ERROR_RETENTION = 30
class DailyJSONLHandler(logging.Handler):
"""Writes one line per record to ``<base>-YYYY-MM-DD.jsonl``.
Switches file automatically when the local date changes.
Honors a maximum retention by deleting old files on rollover.
"""
def __init__(self, base_name: str, retention_days: int, level: int = logging.INFO):
super().__init__(level=level)
self.base_name = base_name # e.g. "app"
self.retention_days = retention_days
self._date_str = None
self._stream = None
self.setFormatter(JSONFormatter())
def _current_path(self) -> str:
import os
return os.path.join(LOG_DIR, f"{self.base_name}-{self._date_str}.jsonl")
def _open(self) -> None:
import os
os.makedirs(LOG_DIR, exist_ok=True)
path = self._current_path()
self._stream = open(path, "a", encoding="utf-8")
self._cleanup_old()
def _cleanup_old(self) -> None:
import os
import time
cutoff = time.time() - (self.retention_days * 86400)
prefix = f"{self.base_name}-"
try:
for fname in os.listdir(LOG_DIR):
if not (fname.startswith(prefix) and fname.endswith(".jsonl")):
continue
fpath = os.path.join(LOG_DIR, fname)
if os.path.getmtime(fpath) < cutoff:
os.remove(fpath)
except OSError:
pass
def _maybe_rollover(self) -> None:
import os
today = datetime.now().strftime("%Y-%m-%d")
if self._date_str != today:
if self._stream:
self._stream.close()
self._stream = None
self._date_str = today
self._open()
def emit(self, record: logging.LogRecord) -> None:
try:
self._maybe_rollover()
if self._stream:
msg = self.format(record)
self._stream.write(msg + "\n")
self._stream.flush()
except Exception:
self.handleError(record)
def close(self) -> None:
if self._stream:
self._stream.close()
self._stream = None
super().close()
def setup_logging() -> None:
"""Configure root logger. Call once at app startup."""
root = logging.getLogger()
root.setLevel(logging.INFO)
# Clear handlers (uvicorn re-imports)
for h in list(root.handlers):
root.removeHandler(h)
# App logger — INFO+
app_handler = DailyJSONLHandler("app", APP_RETENTION, level=logging.INFO)
root.addHandler(app_handler)
# Error logger — ERROR only
error_handler = DailyJSONLHandler("error", ERROR_RETENTION, level=logging.ERROR)
root.addHandler(error_handler)
# Access logger (separate logger, not root)
access_logger = logging.getLogger("access")
access_logger.setLevel(logging.INFO)
access_logger.propagate = False
access_handler = DailyJSONLHandler("access", APP_RETENTION, level=logging.INFO)
access_logger.addHandler(access_handler)
# Console handler — pretty for dev/visible logs
# Use a custom filter to inject request_id from ContextVar
class _ContextFilter(logging.Filter):
def filter(self, record):
record.request_id = request_id_var.get()
record.user_id = user_id_var.get()
return True
console = logging.StreamHandler(sys.stdout)
console.setLevel(logging.INFO)
console.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s [%(request_id)s] %(name)s: %(message)s"
))
console.addFilter(_ContextFilter())
root.addHandler(console)
# Silence noisy libs
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger(__name__).info("logging initialized", extra={"context": {"log_dir": LOG_DIR}})
def get_access_logger() -> logging.Logger:
return logging.getLogger("access")
+3319 -629
View File
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
"""
Middleware for AARS backend:
- RequestIDMiddleware: generate/accept X-Request-ID
- AccessLogMiddleware: log every HTTP request
- error_handler: catch unhandled exceptions, log with stack trace
"""
import logging
import re
import time
import traceback
import uuid
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
from logging_config import (
get_access_logger,
set_request_id,
set_user_id,
)
logger = logging.getLogger(__name__)
REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
def _normalize_request_id(raw: str | None) -> str:
if raw and REQUEST_ID_PATTERN.match(raw):
return raw
return str(uuid.uuid4())
class RequestIDMiddleware(BaseHTTPMiddleware):
"""Generate or accept X-Request-ID and echo in response headers."""
async def dispatch(self, request: Request, call_next):
rid = _normalize_request_id(request.headers.get("X-Request-ID"))
set_request_id(rid)
request.state.request_id = rid
response = await call_next(request)
response.headers["X-Request-ID"] = rid
return response
class AccessLogMiddleware(BaseHTTPMiddleware):
"""Log every HTTP request with route, method, status, duration."""
async def dispatch(self, request: Request, call_next):
start = time.perf_counter()
rid = getattr(request.state, "request_id", "-")
status = 500 # default for exception path
try:
response = await call_next(request)
status = response.status_code
return response
finally:
duration_ms = int((time.perf_counter() - start) * 1000)
access = get_access_logger()
access.info(
"%s %s %d",
request.method,
request.url.path,
status,
extra={
"route": request.url.path,
"method": request.method,
"status": status,
"duration_ms": duration_ms,
},
)
async def unhandled_exception_handler(request: Request, exc: Exception):
"""Catch-all for unhandled exceptions. Log with stack + return 500 JSON."""
rid = getattr(request.state, "request_id", "-")
logger.error(
"unhandled exception: %s %s",
request.method,
request.url.path,
exc_info=exc,
extra={
"route": request.url.path,
"method": request.method,
"status": 500,
"context": {
"exception_type": type(exc).__name__,
"query_params": dict(request.query_params),
},
},
)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error", "request_id": rid},
headers={"X-Request-ID": rid},
)
+96
View File
@@ -0,0 +1,96 @@
"""Phase 2 Migration: Holidays + Leave Records
- Create holidays + leave_records tables
- Import HK public holidays 2025-2027 (51 records)
- Insert demo leave records
Run: docker exec aars-aars-1 python3 /app/migrate_phase2.py
"""
import sys
sys.path.insert(0, "/app")
from database import SessionLocal, engine, Base
from models import Holiday, LeaveRecord
from parsers import get_all_holidays
from datetime import date, datetime
print("=" * 60)
print("Phase 2 Migration: Holidays + Leave Records")
print("=" * 60)
# Step 1: Create tables (idempotent — Base.metadata.create_all skips existing)
print("\n[1/3] Creating tables...")
Base.metadata.create_all(engine)
print(" ✓ holidays + leave_records tables ready")
# Step 2: Import holidays
print("\n[2/3] Importing HK public holidays 2025-2027...")
db = SessionLocal()
added = 0
updated = 0
for d, name_zh, name_en, is_mandatory in get_all_holidays():
existing = db.query(Holiday).filter(Holiday.date == d).first()
if existing:
# Update if changed
if (existing.name != name_zh or existing.name_en != name_en
or existing.is_mandatory != is_mandatory):
existing.name = name_zh
existing.name_en = name_en
existing.is_mandatory = is_mandatory
existing.source = "gov_hk"
existing.updated_at = datetime.utcnow()
updated += 1
else:
db.add(Holiday(
date=d,
name=name_zh,
name_en=name_en,
region="HK",
is_mandatory=is_mandatory,
source="gov_hk",
))
added += 1
db.commit()
print(f" ✓ Added {added} new holidays, updated {updated}")
print(f" ✓ Total holidays in DB: {db.query(Holiday).count()}")
# Step 3: Demo leave records
print("\n[3/3] Inserting demo leave records...")
demo_leaves = [
# Jay Lam 2026-07-08: SL 2h (有返工但中間請病假)
("Jay Lam", "2026-07-08", "SL", 2.0, "睇醫生"),
# Jay Lam 2026-07-10: CL 4h (上午補鐘)
("Jay Lam", "2026-07-10", "CL", 4.0, "補鐘"),
# Cindy Cheung 2026-07-09: AL 8h (全日年假冇打卡)
("Cindy Cheung", "2026-07-09", "AL", 8.0, "Family trip"),
# May Chan 2026-07-07: SL 4h (下午走咗)
("May Chan", "2026-07-07", "SL", 4.0, "感冒"),
]
added_leave = 0
for name, d_str, lt, hrs, reason in demo_leaves:
d = datetime.strptime(d_str, "%Y-%m-%d").date()
existing = db.query(LeaveRecord).filter(
LeaveRecord.employee_name == name,
LeaveRecord.date == d,
LeaveRecord.leave_type == lt,
).first()
if not existing:
db.add(LeaveRecord(
employee_name=name,
date=d,
leave_type=lt,
hours=hrs,
reason=reason,
source="manual",
))
added_leave += 1
db.commit()
print(f" ✓ Added {added_leave} demo leave records")
print(f" ✓ Total leave records in DB: {db.query(LeaveRecord).count()}")
db.close()
print("\n" + "=" * 60)
print("Migration complete ✓")
print("=" * 60)
+205 -51
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Text, Float, Boolean, DateTime, Date, Time, JSON, ForeignKey
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, Date, JSON, ForeignKey, UniqueConstraint, Numeric
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
@@ -6,89 +6,243 @@ from datetime import datetime, timezone
def now_hkt():
"""Return current time in HKT timezone."""
return datetime.now(timezone.utc)
# ============ EXISTING MODELS (kept) ============
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, index=True, nullable=False)
password_hash = Column(String(255), nullable=False)
name = Column(String(255), nullable=False)
role = Column(String(50), default="user") # admin, user
role = Column(String(50), default="user")
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=now_hkt)
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
deleted_at = Column(DateTime, nullable=True)
class Attendance(Base):
__tablename__ = "attendance"
id = Column(Integer, primary_key=True, index=True)
employee_id = Column(String(100), index=True, nullable=False)
employee_name = Column(String(255), nullable=False)
department = Column(String(100), index=True)
date = Column(Date, nullable=False)
check_in = Column(Time, nullable=True)
check_out = Column(Time, nullable=True)
overtime_hours = Column(Float, default=0)
leave_type = Column(String(50), nullable=True) # annual, sick, unpaid, other
leave_hours = Column(Float, default=0)
status = Column(String(50), nullable=False) # present, absent, late, leave
remarks = Column(Text, nullable=True)
custom_fields = Column(JSON, default={})
created_by = Column(Integer, ForeignKey("users.id"))
created_at = Column(DateTime, default=now_hkt)
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
deleted_at = Column(DateTime, nullable=True) # Soft delete
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"
id = Column(Integer, primary_key=True, index=True)
date = Column(Date, nullable=False)
time = Column(Time, nullable=True)
time = Column(String(10), nullable=True)
location = Column(String(255), nullable=False)
employee_name = Column(String(255), nullable=False)
employee_id = Column(String(100))
submitter_email = Column(String(255), nullable=False)
injured_person_id = Column(String(100))
department = Column(String(100))
description = Column(Text, nullable=False)
severity = Column(String(50), nullable=False) # minor, moderate, serious, fatal
severity = Column(String(50), nullable=False)
medical_report = 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
patient_or_staff = Column(Text, nullable=True) # Excel col 8
long_description = Column(Text, nullable=True) # Excel col 9
incident_photos = Column(Text, nullable=True) # Excel col 10 (URL)
needs_escalation = Column(String(20), nullable=True) # Excel col 12
incident_score = Column(Integer, nullable=True) # Excel col 14
submitted_at = Column(DateTime, nullable=True) # Excel col 1 (timestamp)
excel_row = Column(Integer, nullable=True) # original Excel row (1-based)
custom_fields = Column(JSON, default={})
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)
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
deleted_at = Column(DateTime, nullable=True) # Soft delete
deleted_at = Column(DateTime, nullable=True)
class AttendanceCustomField(Base):
__tablename__ = "attendance_custom_fields"
# ============ NEW MODELS ============
class Shift(Base):
"""班次定義"""
__tablename__ = "shifts"
id = Column(Integer, primary_key=True, index=True)
field_name = Column(String(100), nullable=False)
field_type = Column(String(50), nullable=False) # text, number, date, select
field_key = Column(String(100), unique=True, nullable=False)
required = Column(Boolean, default=False)
options = Column(JSON, default=[]) # For select type
order = Column(Integer, default=0)
shift_code = Column(String(20), unique=True, nullable=False, index=True)
description = Column(String(100), nullable=True)
# 每天的上班時間 (HH:MM format as String)
mon_start = Column(String(5), nullable=True)
mon_end = Column(String(5), nullable=True)
tue_start = Column(String(5), nullable=True)
tue_end = Column(String(5), nullable=True)
wed_start = Column(String(5), nullable=True)
wed_end = Column(String(5), nullable=True)
thu_start = Column(String(5), nullable=True)
thu_end = Column(String(5), nullable=True)
fri_start = Column(String(5), nullable=True)
fri_end = Column(String(5), nullable=True)
sat_start = Column(String(5), nullable=True)
sat_end = Column(String(5), nullable=True)
sun_start = Column(String(5), nullable=True)
sun_end = Column(String(5), nullable=True)
created_at = Column(DateTime, default=now_hkt)
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
def get_schedule(self, weekday: str):
"""取得指定星期幾的上班時間 (start, end)"""
day_map = {
'monday': ('mon_start', 'mon_end'),
'tuesday': ('tue_start', 'tue_end'),
'wednesday': ('wed_start', 'wed_end'),
'thursday': ('thu_start', 'thu_end'),
'friday': ('fri_start', 'fri_end'),
'saturday': ('sat_start', 'sat_end'),
'sunday': ('sun_start', 'sun_end'),
# 中文
'星期一': ('mon_start', 'mon_end'),
'星期二': ('tue_start', 'tue_end'),
'星期三': ('wed_start', 'wed_end'),
'星期四': ('thu_start', 'thu_end'),
'星期五': ('fri_start', 'fri_end'),
'星期六': ('sat_start', 'sat_end'),
'星期日': ('sun_start', 'sun_end'),
'星期天': ('sun_start', 'sun_end'),
}
key = weekday.lower().strip()
if key not in day_map:
return None, None
start_attr, end_attr = day_map[key]
start = getattr(self, start_attr, None)
end = getattr(self, end_attr, None)
# 休息標記
if start in ('-', '休息', None, ''):
return None, None
if end in ('-', '休息', None, ''):
return None, None
return start, end
class AccidentCustomField(Base):
__tablename__ = "accident_custom_fields"
class AttendanceRecord(Base):
"""出勤記錄"""
__tablename__ = "attendance_records"
__table_args__ = (
UniqueConstraint('employee_name', 'date', name='uix_employee_date'),
)
id = Column(Integer, primary_key=True, index=True)
field_name = Column(String(100), nullable=False)
field_type = Column(String(50), nullable=False) # text, number, date, select
field_key = Column(String(100), unique=True, nullable=False)
required = Column(Boolean, default=False)
options = Column(JSON, default=[]) # For select type
order = Column(Integer, default=0)
# 員工信息
employee_name = Column(String(255), nullable=False, index=True)
company = Column(String(100), nullable=True)
department = Column(String(100), nullable=True)
# 日期
date = Column(Date, nullable=False, index=True)
weekday = Column(String(20), nullable=True)
# 打卡時間
check_in = Column(DateTime, nullable=True)
check_out = Column(DateTime, nullable=True)
# 班次
shift_code = Column(String(20), nullable=True)
# 計算結果
status_code = Column(String(20), nullable=False, default='normal')
status_text = Column(String(100), nullable=True)
late_minutes = Column(Integer, default=0)
early_minutes = Column(Integer, default=0)
ot_minutes = Column(Integer, default=0)
# Expected times
expected_in = Column(String(10), nullable=True)
expected_out = Column(String(10), nullable=True)
actual_in = 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)
# 原始 Excel 數據
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)
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
# Foreign key
shift_id = Column(Integer, ForeignKey("shifts.id"), nullable=True)
shift = relationship("Shift", foreign_keys=[shift_id])
class Holiday(Base):
"""公眾假期 / Public Holidays
Source: HK Gov (https://www.gov.hk/en/about/abouthk/holiday/{year}.htm)
Auto-imported by /api/holidays/refresh endpoint, manually editable.
"""
__tablename__ = "holidays"
id = Column(Integer, primary_key=True, index=True)
date = Column(Date, nullable=False, unique=True, index=True)
name = Column(String(100), nullable=False) # 中文 e.g. "元旦"
name_en = Column(String(200), nullable=True) # English e.g. "The first day of January"
region = Column(String(20), nullable=True, default="HK") # "HK" / "CN"
is_mandatory = Column(Boolean, default=True) # 勞工假 vs 公假 (Sunday replacement)
source = Column(String(50), nullable=True) # "gov_hk_1823" / "manual"
notes = Column(Text, nullable=True)
# 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)
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
class LeaveRecord(Base):
"""員工請假 / Employee Leave Records
Per-employee, per-day, per-type leave. Hours-based granularity (0.5 ~ 8.0).
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"
__table_args__ = (
UniqueConstraint("employee_name", "date", "leave_type", name="uix_emp_date_type"),
)
id = Column(Integer, primary_key=True, index=True)
employee_name = Column(String(255), nullable=False, index=True)
employee_id = Column(String(100), nullable=True) # future, currently NULL
date = Column(Date, nullable=False, index=True)
leave_type = Column(String(20), nullable=False) # "SL" / "CL" / "AL"
hours = Column(Numeric(4, 2), nullable=False) # 0.5 ~ 8.0
reason = Column(Text, nullable=True)
approved_by = Column(String(100), nullable=True)
source = Column(String(50), nullable=True) # "csv_upload" / "manual"
notes = Column(Text, nullable=True)
# 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_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=now_hkt)
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
+80
View File
@@ -0,0 +1,80 @@
"""HK Public Holidays data
Source: https://www.gov.hk/en/about/abouthk/holiday/{year}.htm
Last verified: 2026-07-19
Format: (date ISO, name 中文, name_en English, is_mandatory)
"""
from datetime import date
# HK General Holidays 2025-2027 (gov.hk official)
# is_mandatory = True means statutory labour holiday (法定假日)
# False means general holiday only (銀行/學校等)
HK_HOLIDAYS = [
# ============ 2025 ============
(date(2025, 1, 1), "元旦", "The first day of January", True),
(date(2025, 1, 29), "農曆年初一", "Lunar New Year's Day", True),
(date(2025, 1, 30), "農曆年初二", "The second day of Lunar New Year", True),
(date(2025, 1, 31), "農曆年初三", "The third day of Lunar New Year", True),
(date(2025, 4, 4), "清明節", "Ching Ming Festival", True),
(date(2025, 4, 18), "耶穌受難日", "Good Friday", False),
(date(2025, 4, 19), "耶穌受難日翌日", "The day following Good Friday", False),
(date(2025, 4, 21), "復活節星期一", "Easter Monday", False),
(date(2025, 5, 1), "勞動節", "Labour Day", True),
(date(2025, 5, 5), "佛誕", "The Birthday of the Buddha", True),
(date(2025, 5, 31), "端午節", "Tuen Ng Festival", True),
(date(2025, 7, 1), "香港特別行政區成立紀念日", "Hong Kong Special Administrative Region Establishment Day", True),
(date(2025, 10, 1), "國慶日", "National Day", True),
(date(2025, 10, 7), "中秋節翌日", "The day following the Chinese Mid-Autumn Festival", False),
(date(2025, 10, 29), "重陽節", "Chung Yeung Festival", True),
(date(2025, 12, 25), "聖誕節", "Christmas Day", True),
(date(2025, 12, 26), "聖誕節翌日", "The first weekday after Christmas Day", False),
# ============ 2026 ============
(date(2026, 1, 1), "元旦", "The first day of January", True),
(date(2026, 2, 17), "農曆年初一", "Lunar New Year's Day", True),
(date(2026, 2, 18), "農曆年初二", "The second day of Lunar New Year", True),
(date(2026, 2, 19), "農曆年初三", "The third day of Lunar New Year", True),
(date(2026, 4, 3), "耶穌受難日", "Good Friday", False),
(date(2026, 4, 4), "耶穌受難日翌日", "The day following Good Friday", False),
(date(2026, 4, 6), "清明節翌日", "The day following Ching Ming Festival", True),
(date(2026, 4, 7), "復活節星期一翌日", "The day following Easter Monday", False),
(date(2026, 5, 1), "勞動節", "Labour Day", True),
(date(2026, 5, 25), "佛誕翌日", "The day following the Birthday of the Buddha", True),
(date(2026, 6, 19), "端午節", "Tuen Ng Festival", True),
(date(2026, 7, 1), "香港特別行政區成立紀念日", "Hong Kong Special Administrative Region Establishment Day", True),
(date(2026, 9, 26), "中秋節翌日", "The day following the Chinese Mid-Autumn Festival", False),
(date(2026, 10, 1), "國慶日", "National Day", True),
(date(2026, 10, 19), "重陽節翌日", "The day following Chung Yeung Festival", True),
(date(2026, 12, 25), "聖誕節", "Christmas Day", True),
(date(2026, 12, 26), "聖誕節翌日", "The first weekday after Christmas Day", False),
# ============ 2027 ============
(date(2027, 1, 1), "元旦", "The first day of January", True),
(date(2027, 2, 6), "農曆年初一", "Lunar New Year's Day", True),
(date(2027, 2, 8), "農曆年初三", "The third day of Lunar New Year", True),
(date(2027, 2, 9), "農曆年初四", "The fourth day of Lunar New Year", True),
(date(2027, 3, 26), "耶穌受難日", "Good Friday", False),
(date(2027, 3, 27), "耶穌受難日翌日", "The day following Good Friday", False),
(date(2027, 3, 29), "復活節星期一", "Easter Monday", False),
(date(2027, 4, 5), "清明節", "Ching Ming Festival", True),
(date(2027, 5, 1), "勞動節", "Labour Day", True),
(date(2027, 5, 13), "佛誕", "The Birthday of the Buddha", True),
(date(2027, 6, 9), "端午節", "Tuen Ng Festival", True),
(date(2027, 7, 1), "香港特別行政區成立紀念日", "Hong Kong Special Administrative Region Establishment Day", True),
(date(2027, 9, 16), "中秋節翌日", "The day following the Chinese Mid-Autumn Festival", False),
(date(2027, 10, 1), "國慶日", "National Day", True),
(date(2027, 10, 8), "重陽節", "Chung Yeung Festival", True),
(date(2027, 12, 25), "聖誕節", "Christmas Day", True),
(date(2027, 12, 27), "聖誕節翌日", "The first weekday after Christmas Day", False),
]
GOV_HK_URL_TEMPLATE = "https://www.gov.hk/en/about/abouthk/holiday/{year}.htm"
def get_holidays_for_year(year: int):
"""Return list of (date, name_zh, name_en, is_mandatory) for given year."""
return [h for h in HK_HOLIDAYS if h[0].year == year]
def get_all_holidays():
"""Return full HK_HOLIDAYS list."""
return list(HK_HOLIDAYS)
+2 -1
View File
@@ -3,7 +3,8 @@ uvicorn[standard]==0.30.6
sqlalchemy==2.0.35
pydantic==2.9.2
python-jose[cryptography]==3.3.0
passlib==1.7.4
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
python-multipart==0.0.12
openpyxl==3.1.5
reportlab==4.2.5
Executable
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# AARS full deploy: build image + restart container + sync static to nginx root
# Usage: bash /root/aars/deploy.sh
set -e
cd /root/aars
echo "=========================================="
echo "AARS Deploy — $(date)"
echo "=========================================="
echo ""
echo "[1/3] Building Docker image..."
docker compose build aars
echo ""
echo "[2/3] Restarting container..."
docker compose up -d aars
sleep 5
echo ""
echo "[3/3] Syncing static files to nginx root..."
bash /root/aars/deploy_static.sh
echo ""
echo "=========================================="
echo "Deploy complete ✓"
echo "Reload https://excel.donton.cloud/"
echo "=========================================="
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
# Sync /app/static/ (backend build output) to /var/www/excel.donton.cloud/ (nginx root)
# Usage: bash /root/aars/deploy_static.sh
set -e
CONTAINER=$(docker ps -qf name=aars-aars-1)
if [ -z "$CONTAINER" ]; then
echo "ERROR: aars-aars-1 container not running"
exit 1
fi
WEB_ROOT=/var/www/excel.donton.cloud
echo "Syncing static files from container $CONTAINER -> $WEB_ROOT ..."
# Ensure dirs exist
mkdir -p "$WEB_ROOT/assets"
# Copy index.html
docker cp "$CONTAINER:/app/static/index.html" "$WEB_ROOT/index.html"
echo " ✓ index.html"
# Copy assets
docker cp "$CONTAINER:/app/static/assets/." "$WEB_ROOT/assets/"
asset_count=$(ls "$WEB_ROOT/assets/" | wc -l)
echo " ✓ assets/ ($asset_count files)"
# Show what is currently referenced
current_bundle=$(grep -oE "/assets/index-[A-Za-z0-9_-]+\.js" "$WEB_ROOT/index.html" | head -1)
echo " → Currently serving: $current_bundle"
# Optional: cleanup old bundles (keep only last 5 js + 3 css)
cd "$WEB_ROOT/assets"
ls -t index-*.js 2>/dev/null | tail -n +6 | xargs -r rm
ls -t index-*.css 2>/dev/null | tail -n +4 | xargs -r rm
remaining=$(ls "$WEB_ROOT/assets/" | wc -l)
echo " ✓ Cleanup done, $remaining files remaining"
echo "Done. Reload https://excel.donton.cloud/"
+1
View File
@@ -9,6 +9,7 @@ services:
- "18775:8000"
volumes:
- ./data:/app/data
- ./logs:/app/logs
environment:
- TZ=Asia/Hong_Kong
- PYTHONUNBUFFERED=1
+1
View File
@@ -0,0 +1 @@
logs/
+3
View File
@@ -7,7 +7,10 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js"></script>
<title>AARS - Attendance & Accident Record System</title>
</head>
<body>
+3380
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -1,12 +1,17 @@
{
"name": "aars-frontend",
"private": true,
"version": "1.0.0",
"version": "1.3.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"logs:tail": "tail -f /root/aars/logs/app-*.jsonl | sed -u 's/\\\\n/\\\\n/g' | jq -c . 2>/dev/null || tail -f /root/aars/logs/app-*.jsonl",
"logs:errors": "tail -f /root/aars/logs/error-*.jsonl | jq -c . 2>/dev/null || tail -f /root/aars/logs/error-*.jsonl",
"logs:access": "tail -f /root/aars/logs/access-*.jsonl | jq -c . 2>/dev/null || tail -f /root/aars/logs/access-*.jsonl",
"logs:search": "grep -hE -- \"$@\" /root/aars/logs/app-*.jsonl /root/aars/logs/error-*.jsonl /root/aars/logs/access-*.jsonl | jq -c . 2>/dev/null || true",
"logs:clean": "bash /root/aars/scripts/logs-cleanup.sh"
},
"dependencies": {
"react": "^18.3.1",
@@ -26,4 +31,4 @@
"tailwindcss": "^3.4.10",
"vite": "^5.4.6"
}
}
}
+7 -1
View File
@@ -5,10 +5,13 @@ import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import AttendanceList from './pages/attendance/List'
import AttendanceDetail from './pages/attendance/Detail'
import AccidentDashboard from './pages/accident/Dashboard'
import AccidentList from './pages/accident/List'
import AccidentDetail from './pages/accident/Detail'
import UploadPage from './pages/upload/Upload'
import RosterPage from './pages/roster/Roster'
import HolidaysPage from './pages/holidays/Holidays'
import Settings from './pages/Settings'
function ProtectedRoute({ children }) {
const { user, loading } = useAuth()
@@ -43,10 +46,13 @@ export default function App() {
<Route index element={<Dashboard />} />
<Route path="attendance" element={<AttendanceList />} />
<Route path="attendance/:id" element={<AttendanceDetail />} />
<Route path="accident" element={<AccidentList />} />
<Route path="accident" element={<AccidentDashboard />} />
<Route path="accident/list" element={<AccidentList />} />
<Route path="accident/:id" element={<AccidentDetail />} />
<Route path="upload" element={<UploadPage />} />
<Route path="roster" element={<RosterPage />} />
<Route path="holidays" element={<HolidaysPage />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
)
+25 -4
View File
@@ -1,29 +1,50 @@
import axios from 'axios'
import { setCurrentRequestId } from './lib/logger.js'
const api = axios.create({
baseURL: '',
timeout: 30000,
})
// Request interceptor to add auth token
// Generate a request id per outgoing call (for cross-correlation)
function newRequestId() {
// RFC4122-ish: use crypto if available
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID()
}
return 'req-' + Math.random().toString(36).slice(2) + Date.now().toString(36)
}
// Request interceptor: auth + X-Request-ID
api.interceptors.request.use(
config => {
const token = localStorage.getItem('aars_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
config.headers['X-Request-ID'] = newRequestId()
return config
},
error => Promise.reject(error)
)
// Response interceptor for error handling
// Response interceptor: capture server's X-Request-ID for log correlation + 401 handling
api.interceptors.response.use(
response => response,
response => {
const rid = response.headers['x-request-id']
if (rid) setCurrentRequestId(rid)
return response
},
error => {
const rid = error.response?.headers?.['x-request-id']
if (rid) setCurrentRequestId(rid)
if (error.response?.status === 401) {
localStorage.removeItem('aars_token')
window.location.href = '/login'
// Avoid loop if already on /login
if (!window.location.pathname.startsWith('/login')) {
window.location.href = '/login'
}
}
return Promise.reject(error)
}
+73
View File
@@ -0,0 +1,73 @@
import React from 'react'
import { logger } from '../lib/logger.js'
export default class ErrorBoundary extends React.Component {
constructor(props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error) {
return { hasError: true, error }
}
componentDidCatch(error, errorInfo) {
logger.error('React component error', {
stack: (error?.stack || '') + '\n\nComponent stack:\n' + (errorInfo?.componentStack || ''),
})
}
render() {
if (this.state.hasError) {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
fontFamily: 'sans-serif',
background: '#f8fafc',
color: '#0f172a',
}}>
<div style={{ maxWidth: 600, textAlign: 'center' }}>
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>
頁面發生錯誤 / Something went wrong
</h1>
<p style={{ color: '#64748b', marginBottom: '1.5rem' }}>
已經將錯誤自動回報俾 IT狗 reload 或者返回上一頁
</p>
<button
onClick={() => window.location.reload()}
style={{
padding: '0.5rem 1rem',
background: '#2563eb',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: 'pointer',
}}
>
Reload Page
</button>
{this.state.error?.message && (
<pre style={{
marginTop: '1.5rem',
padding: '1rem',
background: '#f1f5f9',
borderRadius: '0.375rem',
fontSize: '0.75rem',
textAlign: 'left',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}>
{this.state.error.message}
</pre>
)}
</div>
</div>
)
}
return this.props.children
}
}
+132 -64
View File
@@ -1,22 +1,44 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
import { useState } from 'react'
import { useAuth } from '../context/AuthContext'
import { LogOut, Users, AlertTriangle, LayoutDashboard, Upload, Calendar } from 'lucide-react'
import { LogOut, Users, AlertTriangle, LayoutDashboard, Upload, Calendar, Menu, X, Palmtree, Settings as SettingsIcon } from 'lucide-react'
const NAV_ITEMS = [
{ to: '/', end: true, icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/attendance', icon: Users, label: '出勤 Attendance' },
{ to: '/accident', icon: AlertTriangle, label: '意外 Accident' },
{ to: '/roster', icon: Calendar, label: '班次 Roster' },
{ to: '/holidays', icon: Palmtree, label: '假期 Holidays' },
{ to: '/settings', icon: SettingsIcon, label: '設定 Settings' },
]
export default function Layout() {
const { user, logout } = useAuth()
const navigate = useNavigate()
const [mobileOpen, setMobileOpen] = useState(false)
const handleLogout = () => {
logout()
navigate('/login')
}
const closeMobile = () => setMobileOpen(false)
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<header className="bg-white border-b border-slate-200 sticky top-0 z-50">
<header className="bg-white border-b border-slate-200 sticky top-0 z-40">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center justify-between h-14 sm:h-16">
{/* Mobile hamburger (left side) */}
<button
onClick={() => setMobileOpen(true)}
className="md:hidden p-2 -ml-2 text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-md"
aria-label="Open menu"
>
<Menu size={20} />
</button>
{/* Logo */}
<div className="flex items-center gap-8">
<div className="flex items-center gap-2">
@@ -25,71 +47,35 @@ export default function Layout() {
</div>
<span className="text-xl font-bold text-slate-900">AARS</span>
</div>
{/* Navigation */}
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1">
<NavLink
to="/"
end
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<LayoutDashboard size={18} />
Dashboard
</NavLink>
<NavLink
to="/attendance"
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<Users size={18} />
出勤 Attendance
</NavLink>
<NavLink
to="/accident"
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<AlertTriangle size={18} />
意外 Accident
</NavLink>
<NavLink
to="/roster"
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<Calendar size={18} />
班次 Roster
</NavLink>
{NAV_ITEMS.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<item.icon size={18} />
{item.label}
</NavLink>
))}
</nav>
</div>
{/* Right side */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 sm:gap-4">
<NavLink
to="/upload"
className={({ isActive }) =>
`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
`hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
@@ -99,9 +85,9 @@ export default function Layout() {
<Upload size={16} />
<span className="hidden sm:inline">上傳</span>
</NavLink>
<div className="flex items-center gap-3 pl-4 border-l border-slate-200">
<div className="text-right hidden sm:block">
<div className="hidden sm:flex items-center gap-3 pl-4 border-l border-slate-200">
<div className="text-right hidden md:block">
<div className="text-sm font-medium text-slate-900">{user?.name}</div>
<div className="text-xs text-slate-500">{user?.role}</div>
</div>
@@ -113,13 +99,95 @@ export default function Layout() {
<LogOut size={18} />
</button>
</div>
{/* Mobile logout button (always visible) */}
<button
onClick={handleLogout}
className="sm:hidden p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-md transition-colors"
title="Logout"
>
<LogOut size={18} />
</button>
</div>
</div>
</div>
</header>
{/* Mobile drawer overlay */}
{mobileOpen && (
<div
className="fixed inset-0 bg-black/50 z-50 md:hidden"
onClick={closeMobile}
/>
)}
{/* Mobile drawer panel */}
<div
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-xl transform transition-transform duration-200 ease-in-out md:hidden ${
mobileOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex items-center justify-between px-4 h-14 border-b border-slate-200">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">A</span>
</div>
<span className="text-lg font-bold text-slate-900">AARS</span>
</div>
<button
onClick={closeMobile}
className="p-2 text-slate-500 hover:text-slate-900 hover:bg-slate-100 rounded-md"
aria-label="Close menu"
>
<X size={20} />
</button>
</div>
<nav className="p-3 space-y-1">
{NAV_ITEMS.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
onClick={closeMobile}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<item.icon size={18} />
{item.label}
</NavLink>
))}
<NavLink
to="/upload"
onClick={closeMobile}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<Upload size={18} />
上傳
</NavLink>
</nav>
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-slate-200">
<div className="flex items-center gap-3">
<div className="text-sm">
<div className="font-medium text-slate-900">{user?.name}</div>
<div className="text-xs text-slate-500">{user?.role}</div>
</div>
</div>
</div>
</div>
{/* Main content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6 lg:py-8">
<Outlet />
</main>
</div>
+102
View File
@@ -0,0 +1,102 @@
import { CheckCircle, AlertCircle, CalendarX, Palmtree, Briefcase, Coffee } from 'lucide-react'
/**
* Reusable status badge for attendance records.
* 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 (fallback signal + suffix source)
* - size: 'sm' | 'md' (default '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'
const badge = (icon, label, colorCls) => (
<span className={`inline-flex items-center gap-1 ${sizeCls} rounded-full font-medium ${colorCls}`}>
{icon}
{label}
</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())
}
}
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'))
}
// 休息 (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'))
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Frontend logger for AARS.
*
* - Captures console.error, unhandledrejection, ErrorBoundary errors
* - Redacts secrets client-side before sending
* - Buffers, flushes via navigator.sendBeacon on pagehide
* - Posts to /api/client-logs (server-side correlation via X-Request-ID)
*/
const ENDPOINT = '/api/client-logs'
const APP_VERSION = (typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '1.0.0')
const FLUSH_INTERVAL_MS = 10_000
const MAX_BUFFER = 50
// ============ Redactor ============
const REDACT_KEYS = new Set([
'password', 'passwd', 'pwd', 'pass',
'token', 'access_token', 'refresh_token', 'id_token',
'jwt', 'bearer', 'authorization',
'api_key', 'apikey', 'secret', 'client_secret',
'cookie', 'set-cookie', 'session',
])
function redactString(s) {
if (typeof s !== 'string') return s
// JWT-like
s = s.replace(/eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+/g, '[REDACTED:JWT]')
// Bearer tokens
s = s.replace(/(bearer\s+)[A-Za-z0-9\-_.]+/gi, '$1[REDACTED]')
return s
}
function redact(obj) {
if (obj == null) return obj
if (typeof obj === 'string') return redactString(obj)
if (Array.isArray(obj)) return obj.map(redact)
if (typeof obj === 'object') {
const out = {}
for (const k of Object.keys(obj)) {
if (REDACT_KEYS.has(k.toLowerCase())) {
out[k] = '[REDACTED]'
} else {
out[k] = redact(obj[k])
}
}
return out
}
return obj
}
// ============ Buffer ============
const buffer = []
let flushTimer = null
function push(level, msg, extras = {}) {
const entry = {
level,
msg: redactString(String(msg).slice(0, 2000)),
stack: extras.stack ? redactString(String(extras.stack).slice(0, 8000)) : undefined,
page_url: typeof window !== 'undefined' ? window.location.href : undefined,
app_version: APP_VERSION,
request_id: getCurrentRequestId(),
browser: typeof navigator !== 'undefined' ? navigator.userAgent.slice(0, 500) : undefined,
ts: new Date().toISOString(),
}
// Drop undefined fields
Object.keys(entry).forEach(k => entry[k] === undefined && delete entry[k])
buffer.push(entry)
if (buffer.length >= MAX_BUFFER) flush()
}
function flush() {
if (!buffer.length) return
const entries = buffer.splice(0, buffer.length)
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
try {
const blob = new Blob([JSON.stringify(entries)], { type: 'application/json' })
navigator.sendBeacon(ENDPOINT, blob)
return
} catch (_) { /* fall through */ }
}
// Fallback: fetch (best-effort)
if (typeof fetch !== 'undefined') {
fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entries),
keepalive: true,
}).catch(() => { /* swallow */ })
}
}
// ============ Current request_id ============
let currentRequestId = null
export function setCurrentRequestId(rid) { currentRequestId = rid }
function getCurrentRequestId() { return currentRequestId }
// ============ Public API ============
export const logger = {
debug: (msg, ex) => push('debug', msg, ex),
info: (msg, ex) => push('info', msg, ex),
warn: (msg, ex) => push('warn', msg, ex),
error: (msg, ex) => push('error', msg, ex),
flush,
}
// ============ Global error hooks ============
export function installGlobalHandlers() {
if (typeof window === 'undefined') return
// Capture uncaught errors
window.addEventListener('error', (event) => {
logger.error('Uncaught error', {
stack: event.error?.stack || `${event.message} at ${event.filename}:${event.lineno}:${event.colno}`,
})
})
// Capture unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason
const msg = reason?.message || String(reason)
const stack = reason?.stack || `Unhandled rejection: ${msg}`
logger.error('Unhandled promise rejection', { stack })
})
// Flush on page hide
window.addEventListener('pagehide', () => flush())
window.addEventListener('beforeunload', () => flush())
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush()
})
// Periodic flush
if (flushTimer) clearInterval(flushTimer)
flushTimer = setInterval(flush, FLUSH_INTERVAL_MS)
}
export { redact }
+13 -6
View File
@@ -5,14 +5,21 @@ import './index.css'
import { BrowserRouter } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { AuthProvider } from './context/AuthContext.jsx'
import ErrorBoundary from './components/ErrorBoundary.jsx'
import { installGlobalHandlers } from './lib/logger.js'
// Install global error handlers before mounting
installGlobalHandlers()
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
<Toaster position="bottom-right" />
</AuthProvider>
</BrowserRouter>
<ErrorBoundary>
<BrowserRouter>
<AuthProvider>
<App />
<Toaster position="bottom-right" />
</AuthProvider>
</BrowserRouter>
</ErrorBoundary>
</React.StrictMode>,
)
+608 -315
View File
@@ -1,355 +1,648 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import api from '../api'
import { Users, AlertTriangle, FileSpreadsheet, RefreshCw, TrendingUp, MapPin, Clock, AlertCircle, FileCode } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line } from 'recharts'
const handleExportHTML = (section) => {
const token = localStorage.getItem('aars_token')
if (token) {
window.open(`/api/report/${section}/html?token=${token}`, '_blank')
}
}
const COLORS = ['#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']
import toast from 'react-hot-toast'
import { Users, AlertTriangle, Clock, CheckCircle, XCircle, Calendar, TrendingUp, ArrowRight, Award, AlertCircle, Clock3, Download, FileSpreadsheet, FileBarChart } from 'lucide-react'
export default function Dashboard() {
// Attendance state
const [attInfo, setAttInfo] = useState(null)
const [attDeptData, setAttDeptData] = useState([])
const [attStatusData, setAttStatusData] = useState([])
const [latenessData, setLatenessData] = useState(null)
// Accident state
const [accInfo, setAccInfo] = useState(null)
const [accSeverityData, setAccSeverityData] = useState([])
const [accLocationData, setAccLocationData] = useState([])
const [accMonthlyData, setAccMonthlyData] = useState([])
const [summary, setSummary] = useState(null)
const [stats, setStats] = useState(null)
const [loading, setLoading] = useState(true)
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [exporting, setExporting] = useState(false)
const pieRef = useRef(null)
const barRef = useRef(null)
const pieChart = useRef(null)
const barChart = useRef(null)
const dashboardRef = useRef(null)
useEffect(() => {
fetchData()
}, [dateFrom, dateTo])
useEffect(() => {
return () => {
if (pieChart.current) pieChart.current.destroy()
if (barChart.current) barChart.current.destroy()
}
}, [])
useEffect(() => {
if (summary && stats && !loading) {
renderCharts()
}
}, [summary, stats, loading])
const fetchData = async () => {
setLoading(true)
try {
const [attInfoRes, accInfoRes, attDataRes, accDataRes, lateRes] = await Promise.all([
api.get('/api/attendance/info'),
api.get('/api/accident/info'),
api.get('/api/attendance?page=1&page_size=1000'),
api.get('/api/accident?page=1&page_size=500'),
api.get('/api/attendance/lateness').catch(() => ({ data: null }))
const params = {}
if (dateFrom) params.date_from = dateFrom
if (dateTo) params.date_to = dateTo
const [sumRes, statRes] = await Promise.all([
api.get('/api/dashboard/summary', { params }),
api.get('/api/attendance/stats', { params }).catch(() => ({ data: null }))
])
setAttInfo(attInfoRes.data)
setAccInfo(accInfoRes.data)
setLatenessData(lateRes.data)
// Attendance - department
if (attDataRes.data.data && attDataRes.data.data.length > 0) {
const deptCount = {}
attDataRes.data.data.forEach(row => {
const deptKey = Object.keys(row).find(k =>
k.toLowerCase().includes('company') || k.includes('Company')
)
const dept = deptKey ? String(row[deptKey] || 'Unknown') : 'Unknown'
deptCount[dept] = (deptCount[dept] || 0) + 1
})
setAttDeptData(Object.entries(deptCount)
.map(([name, value]) => ({ name: name.slice(0, 15), value }))
.sort((a, b) => b.value - a.value)
.slice(0, 6))
// Status - from DURATION or other fields
const statusCount = {}
attDataRes.data.data.forEach(row => {
const durKey = Object.keys(row).find(k =>
k.toLowerCase().includes('duration') || k.includes('DURATION')
)
if (durKey && row[durKey]) {
const dur = String(row[durKey])
if (dur.includes('hours')) {
statusCount['正常工作'] = (statusCount['正常工作'] || 0) + 1
} else {
statusCount['其他'] = (statusCount['其他'] || 0) + 1
}
}
})
setAttStatusData(Object.entries(statusCount)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value))
}
// Accident - severity, location, monthly
if (accDataRes.data.data && accDataRes.data.data.length > 0) {
const sevCount = {}
const locCount = {}
const dateCount = {}
accDataRes.data.data.forEach(row => {
const sevKey = Object.keys(row).find(k =>
k.toLowerCase().includes('severity') || k.includes('緊急') || k.includes('程度')
)
if (sevKey) {
const sev = String(row[sevKey] || 'Unknown')
sevCount[sev] = (sevCount[sev] || 0) + 1
}
const locKey = Object.keys(row).find(k =>
k.toLowerCase().includes('location') || k.includes('部門') || k.includes('地點')
)
if (locKey) {
const loc = String(row[locKey] || 'Unknown')
locCount[loc] = (locCount[loc] || 0) + 1
}
const dateKey = Object.keys(row).find(k =>
k.toLowerCase().includes('date') || k.includes('日期') || k.includes('時間戳')
)
if (dateKey && row[dateKey]) {
const dateStr = String(row[dateKey]).slice(0, 7)
dateCount[dateStr] = (dateCount[dateStr] || 0) + 1
}
})
setAccSeverityData(Object.entries(sevCount)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value))
setAccLocationData(Object.entries(locCount)
.map(([name, value]) => ({ name: name.slice(0, 12), value }))
.sort((a, b) => b.value - a.value)
.slice(0, 6))
setAccMonthlyData(Object.entries(dateCount)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => a.name.localeCompare(b.name)))
}
setSummary(sumRes.data)
setStats(statRes.data)
} catch (err) {
console.error('Failed to fetch data:', err)
toast.error('載入失敗')
console.error(err)
} finally {
setLoading(false)
}
}
const SectionCard = ({ title, value, subtitle, icon: Icon, color, link }) => (
<Link to={link} className={`card p-4 hover:shadow-md transition-shadow border-l-4 ${color}`}>
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-medium text-slate-500">{title}</div>
<div className="text-xl font-bold text-slate-900 mt-1">{value ?? '-'}</div>
{subtitle && <div className="text-xs text-slate-400 mt-0.5">{subtitle}</div>}
const renderCharts = () => {
if (!summary) return
if (pieChart.current) pieChart.current.destroy()
if (barChart.current) barChart.current.destroy()
const Chart = window.Chart
if (!Chart) return
const pieCtx = pieRef.current?.getContext('2d')
if (pieCtx) {
const statusData = [
{ label: '正常', value: summary.normal_count || 0, color: '#10B981' },
{ label: '遲到', value: summary.late_count || 0, color: '#F59E0B' },
{ label: '早退', value: summary.early_count || 0, color: '#3B82F6' },
{ label: 'OT', value: summary.ot_count || 0, color: '#8B5CF6' },
{ label: '異常', value: summary.abnormal_count || 0, color: '#EF4444' },
{ label: '缺勤', value: summary.missing_count || 0, color: '#6B7280' },
].filter(s => s.value > 0)
if (statusData.length > 0) {
pieChart.current = new Chart(pieCtx, {
type: 'doughnut',
data: {
labels: statusData.map(s => s.label),
datasets: [{ data: statusData.map(s => s.value), backgroundColor: statusData.map(s => s.color), borderWidth: 2, borderColor: '#fff' }]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { padding: 16, font: { size: 12 } } }, title: { display: true, text: '出勤狀態分佈', font: { size: 14, weight: '600' }, padding: { bottom: 12 } } }
}
})
}
}
const barCtx = barRef.current?.getContext('2d')
if (barCtx && stats?.stats?.length > 0) {
const staffData = stats.stats.slice(0, 8)
barChart.current = new Chart(barCtx, {
type: 'bar',
data: {
labels: staffData.map(s => s.name),
datasets: [{ label: '出勤記錄', data: staffData.map(s => s.total_records || s.total || 0), backgroundColor: '#2563EB', borderRadius: 4 }]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false }, title: { display: true, text: '員工出勤記錄數', font: { size: 14, weight: '600' }, padding: { bottom: 12 } } },
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } }
}
})
}
}
// Export PDF - capture dashboard as image then make PDF
const exportPDF = async () => {
if (!dashboardRef.current) { toast.error('找不到 Dashboard'); return }
setExporting(true)
try {
if (typeof window.jspdf === 'undefined') throw new Error('jspdf 未載入')
if (typeof window.Chart === 'undefined') throw new Error('Chart.js 未載入')
const { jsPDF } = window.jspdf
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' })
const PW = pdf.internal.pageSize.getWidth() // 297mm
const PH = pdf.internal.pageSize.getHeight() // 210mm
const M = 12
// ─── Title ───
pdf.setFontSize(18); pdf.setTextColor(15,23,42)
pdf.text('Attendance Report', M, 14)
const range = (dateFrom || dateTo) ? `${dateFrom||'開始'} ~ ${dateTo||'今日'}` : '全部'
const now = new Date().toLocaleString('en-US',{hour12:false,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'})
pdf.setFontSize(9); pdf.setTextColor(100,116,139)
pdf.text(`Date Range: ${range} | Generated: ${now}`, M, 20)
// ─── Summary Cards ───
const cards = summary ? [
{ label: 'Total', value: summary.total_records, color: [37,99,235] },
{ label: 'Normal', value: summary.normal_count, color: [16,185,129] },
{ label: 'Late', value: summary.late_count, color: [245,158,11] },
{ label: 'OT', value: summary.ot_count, color: [139,92,246] },
{ label: 'Abnormal', value: summary.abnormal_count, color: [239,68,68] },
{ label: 'Missing', value: summary.missing_count, color: [107,114,128] },
] : []
const cardW = (PW - M*2) / cards.length - 3
cards.forEach((card, i) => {
const cx = M + i*(cardW+3)
pdf.setFillColor(...card.color)
pdf.roundedRect(cx, 22, cardW, 16, 2, 2, 'F')
pdf.setFontSize(14); pdf.setTextColor(255,255,255)
pdf.text(String(card.value ?? 0), cx+cardW/2, 30, {align:'center'})
pdf.setFontSize(7); pdf.setTextColor(255,255,255)
pdf.text(card.label, cx+cardW/2, 34, {align:'center'})
})
// ─── Charts ───
const chartTop = 44
const chartH = 60
const chartW = (PW - M*2 - 8) / 2 // two charts side by side
// Helper: draw doughnut / pie chart
const drawPieChart = (cx, cy, r, data, colors, pdf) => {
const total = data.reduce((a,b)=>a+b, 0)
if (total === 0) return
let angle = -Math.PI/2
data.forEach((val, i) => {
const slice = (val/total) * 2*Math.PI
pdf.setFillColor(...colors[i])
pdf.setDrawColor(255,255,255)
pdf.setLineWidth(0.3)
const x1 = cx + r * Math.cos(angle)
const y1 = cy + r * Math.sin(angle)
const x2 = cx + r * Math.cos(angle + slice)
const y2 = cy + r * Math.sin(angle + slice)
if (val === total) {
pdf.setFillColor(...colors[i])
pdf.circle(cx, cy, r, 'F')
} else {
const pts = [[cx, cy], [x1, y1], [x2, y2]]
pdf.triangle(pts[0][0], pts[0][1], pts[1][0], pts[1][1], pts[2][0], pts[2][1], 'F')
}
angle += slice
})
// inner circle (donut hole)
pdf.setFillColor(255,255,255)
pdf.circle(cx, cy, r*0.5, 'F')
}
// Helper: draw bar chart
const drawBarChart = (bx, by, bw, bh, labels, values, barColor, pdf) => {
const max = Math.max(...values, 1)
const barW = bw / labels.length * 0.6
const gap = bw / labels.length * 0.4
labels.forEach((lbl, i) => {
const barH = (values[i]/max) * bh
const x = bx + i*(barW+gap) + gap/2
const y = by + bh - barH
pdf.setFillColor(...barColor)
pdf.roundedRect(x, y, barW, barH, 1, 1, 'F')
// value label
pdf.setFontSize(6); pdf.setTextColor(100,116,139)
pdf.text(String(values[i]), x+barW/2, by+bh+3.5, {align:'center'})
// x label (truncated)
pdf.setFontSize(6); pdf.setTextColor(100,116,139)
pdf.text(lbl.length > 6 ? lbl.substring(0,5)+'.' : lbl, x+barW/2, by+bh+6.5, {align:'center'})
})
}
// ── Pie: 出勤狀態分佈 ──
if (summary) {
const pieCX = M + chartW/2
const pieCY = chartTop + chartH/2
const pieR = chartH/2 - 4
const pieData = [
summary.normal_count || 0,
summary.late_count || 0,
summary.early_count || 0,
summary.ot_count || 0,
summary.abnormal_count || 0,
summary.missing_count || 0,
]
const pieColors = [[16,185,129],[245,158,11],[59,130,246],[139,92,246],[239,68,68],[107,114,128]]
const pieLabels = ['Normal','Late','Early','OT','Abnormal','Missing']
const pieLegendX = M
const pieLegendY = chartTop + chartH + 5
pdf.setFontSize(10); pdf.setTextColor(15,23,42)
pdf.text('Attendance Status Distribution', M, chartTop - 2)
// Draw pie
drawPieChart(pieCX, pieCY, pieR, pieData, pieColors, pdf)
// Legend
let lx = M
pieLabels.forEach((lbl, i) => {
if (pieData[i] === 0) return
pdf.setFillColor(...pieColors[i])
pdf.rect(lx, pieLegendY, 4, 4, 'F')
pdf.setFontSize(7.5); pdf.setTextColor(60,70,90)
pdf.text(`${lbl} ${pieData[i]}`, lx+5, pieLegendY+3.5)
lx += 28
})
}
// ── Bar: 員工出勤記錄 ──
if (stats?.stats?.length > 0) {
const barX = M + chartW + 8
const barLabels = stats.stats.slice(0,8).map(s=>s.name)
const barValues = stats.stats.slice(0,8).map(s=>s.total_records || s.total || 0)
pdf.setFontSize(10); pdf.setTextColor(15,23,42)
pdf.text('Staff Attendance Records', barX, chartTop - 2)
// Axis
const axisX = barX + 2
const axisY = chartTop + chartH - 2
const axisW = chartW - 4
const axisH = chartH - 8
pdf.setDrawColor(200,210,220)
pdf.setLineWidth(0.3)
pdf.line(axisX, axisY, axisX, axisY - axisH) // y axis
pdf.line(axisX, axisY, axisX + axisW, axisY) // x axis
drawBarChart(axisX, axisY - axisH, axisW, axisH, barLabels, barValues, [37,99,235], pdf)
}
// ─── Leaderboards ───
if (stats?.stats?.length > 0) {
const staff = stats.stats
const lbTop = chartTop + chartH + 14
const lateRank = [...staff].sort((a,b)=>(b.late_count||0)-(a.late_count||0)).slice(0,5)
const otRank = [...staff].sort((a,b)=>(b.ot_count||0)-(a.ot_count||0)).slice(0,5)
const missRank = [...staff].sort((a,b)=>(b.missing_count||0)-(a.missing_count||0)).slice(0,5)
const rateRank = [...staff].map(s=>({...s,rate:(s.total_records||s.total||0)>0?Math.round((((s.total_records||s.total||0)-(s.late_count||0)-(s.abnormal_count||0))/(s.total_records||s.total||0))*100):0})).sort((a,b)=>b.rate-a.rate).slice(0,5)
const lbW = (PW - M*2) / 4 - 4
const lbData = [
{ title: 'Late Rank', data: lateRank, key: 'late_count', sub: 'late_minutes', color: [245,158,11] },
{ title: 'OT Rank', data: otRank, key: 'ot_count', sub: 'ot_minutes', color: [139,92,246] },
{ title: 'Missing Rank', data: missRank, key: 'missing_count', sub: null, color: [107,114,128] },
{ title: 'Attendance Rate', data: rateRank, key: 'rate', sub: null, color: [16,185,129] },
]
lbData.forEach((lb, li) => {
const lx = M + li*(lbW+4)
// Box
pdf.setFillColor(...lb.color)
pdf.roundedRect(lx, lbTop, lbW, 6, 2, 2, 'F')
pdf.setFontSize(8); pdf.setTextColor(255,255,255)
const lines = lb.title.split('\n')
lines.forEach((l, li2) => pdf.text(l, lx+lbW/2, lbTop+3+li2*3, {align:'center'}))
// Items
lb.data.forEach((item, di) => {
const iy = lbTop + 10 + di*5
const medal = ['🥇','🥈','🥉'][di] || ` ${di+1}.`
const sub = lb.sub && item[lb.sub] ? ` (${item[lb.sub]}min)` : ''
const val = `${item[lb.key]}${lb.key==='rate'?'%':''}${sub}`
pdf.setFontSize(7.5); pdf.setTextColor(30,41,59)
pdf.text(`${medal} ${item.name} ${val}`, lx+2, iy)
})
})
}
// ─── Staff Table ───
if (stats?.stats?.length > 0) {
const tblTop = PW > 250 ? (chartTop + chartH + 14 + 35) : (chartTop + chartH + 14 + 30)
pdf.setFontSize(11); pdf.setTextColor(15,23,42)
pdf.text('Staff Summary', M, tblTop - 1)
const staff = stats.stats
const colW = [46, 18, 16, 20, 16, 20, 16, 16]
const cols = ['Name','Total','Late','LateMin','OT','OTMin','Abn','Miss']
let xPos = [M]
for (let ci=0; ci<colW.length-1; ci++) xPos.push(xPos[ci]+colW[ci])
const rowH = 6
// Header
pdf.setFillColor(50,60,80)
pdf.rect(M, tblTop, PW-M*2, rowH, 'F')
pdf.setFontSize(8); pdf.setTextColor(255,255,255)
cols.forEach((h,ci) => pdf.text(h, xPos[ci]+colW[ci]/2, tblTop+4.5, {align:'center'}))
// Rows
staff.forEach((s, ri) => {
const ry = tblTop + rowH*(ri+1)
if (ri%2===0) { pdf.setFillColor(248,250,252); pdf.rect(M, ry, PW-M*2, rowH, 'F') }
pdf.setFontSize(8); pdf.setTextColor(30,41,59)
const vals = [s.name, String(s.total_records || s.total || 0), String(s.late_count||0), String(s.late_minutes||'-'), String(s.ot_count||0), String(s.ot_minutes||'-'), String(s.abnormal_count||0), String(s.missing_count||0)]
vals.forEach((v,ci) => pdf.text(v, xPos[ci]+colW[ci]/2, ry+4.5, {align:'center'}))
})
}
pdf.save(`attendance_report_${dateFrom||'all'}_${dateTo||''}.pdf`.replace(/-/g,''))
toast.success('✅ PDF 已下載')
} catch (err) {
console.error('PDF error:', err)
toast.error('PDF 導出失敗: ' + err.message)
} finally {
setExporting(false)
}
}
const exportExcel = async (full) => {
setExporting(true)
try {
const token = localStorage.getItem('aars_token')
const params = full ? {} : (dateFrom ? { date_from: dateFrom, date_to: dateTo || new Date().toISOString().slice(0, 10) } : {})
const url = `/api/export/attendance/excel${Object.keys(params).length ? '?' + new URLSearchParams(params).toString() : ''}`
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
if (!resp.ok) throw new Error('Export failed')
const blob = await resp.blob()
const filename = full
? `attendance_full.xlsx`
: `attendance_${dateFrom || 'start'}_${dateTo || 'today'}.xlsx`.replace(/-/g, '')
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = filename
a.click()
URL.revokeObjectURL(a.href)
toast.success('✅ Excel 已下載')
} catch (err) {
console.error('Excel export error:', err)
toast.error('Excel 導出失敗')
} finally {
setExporting(false)
}
}
const byStaff = stats?.stats || []
const lateRank = [...byStaff].sort((a, b) => (b.late_count || 0) - (a.late_count || 0))
const otRank = [...byStaff].sort((a, b) => (b.ot_count || 0) - (a.ot_count || 0))
const abnormalRank = [...byStaff].sort((a, b) => (b.abnormal_count || 0) - (a.abnormal_count || 0))
const missingRank = [...byStaff].sort((a, b) => (b.missing_count || 0) - (a.missing_count || 0))
// Use backend's pre-computed attendance_rate = (total - missing) / total * 100
// (handles missing records correctly; a record can still be late/early/ot
// and count as attended)
const attendanceRate = [...byStaff]
.map(s => ({ ...s, rate: s.attendance_rate ?? 0 }))
.sort((a, b) => b.rate - a.rate)
const statCards = summary ? [
{ label: '總記錄', value: summary.total_records, icon: Calendar, color: 'bg-blue-50 text-blue-600' },
{ label: '正常', value: summary.normal_count, icon: CheckCircle, color: 'bg-green-50 text-green-600' },
{ label: '遲到', value: summary.late_count, icon: Clock, color: 'bg-orange-50 text-orange-600' },
{ label: '早退', value: summary.early_count, icon: Clock3, color: 'bg-blue-50 text-blue-600' },
{ label: 'OT', value: summary.ot_count, icon: TrendingUp, color: 'bg-purple-50 text-purple-600' },
{ label: '異常', value: summary.abnormal_count, icon: AlertTriangle, color: 'bg-red-50 text-red-600' },
{ label: '缺勤', value: summary.missing_count, icon: XCircle, color: 'bg-gray-50 text-gray-600' },
{ label: '員工', value: summary.staff_count, icon: Users, color: 'bg-teal-50 text-teal-600' },
] : []
const today = new Date().toISOString().slice(0, 10)
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10)
// Date preset helpers
const fmt = (d) => d.toISOString().slice(0, 10)
const weekRange = (offsetWeeks) => {
// Monday = start of week
const now = new Date()
const dow = now.getDay() // Sun=0, Mon=1, ... Sat=6
const daysFromMonday = (dow + 6) % 7 // Mon=0, Tue=1, ..., Sun=6
const start = new Date(now)
start.setDate(now.getDate() - daysFromMonday + offsetWeeks * 7)
const end = new Date(start)
end.setDate(start.getDate() + 6)
return [fmt(start), fmt(end)]
}
// Map rankKey to Option 1 detail fields exposed by backend
const detailFieldsMap = {
late_count: [
{ key: 'late_minutes', label: '分鐘', unit: '分' },
{ key: 'late_avg_minutes', label: '平均', unit: '分' },
{ key: 'late_max_minutes', label: '最長', unit: '分' },
],
early_count: [
{ key: 'early_minutes', label: '分鐘', unit: '分' },
{ key: 'early_avg_minutes', label: '平均', unit: '分' },
{ key: 'early_max_minutes', label: '最長', unit: '分' },
],
ot_count: [
{ key: 'ot_minutes', label: '分鐘', unit: '分' },
{ key: 'ot_avg_minutes', label: '平均', unit: '分' },
{ key: 'ot_max_minutes', label: '最長', unit: '分' },
],
missing_count: [
{ key: 'consecutive_missing', label: '連續', unit: '次' },
{ key: 'attendance_rate', label: '出勤率', unit: '%', suffix: true },
{ key: 'last_attendance_date', label: '最後', isDate: true },
],
rate: [
{ key: 'total_records', label: '記錄', unit: '條' },
{ key: 'missing_count', label: '缺勤', unit: '次' },
],
}
const RankCard = ({ title, icon: Icon, iconColor, data, rankKey, subKey, unit, colorFn }) => {
const details = detailFieldsMap[rankKey] || []
const items = data.filter(d => d[rankKey] > 0 || rankKey === 'rate').slice(0, 6)
return (
<div className="card p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
<Icon size={16} style={{ color: iconColor }} /> {title}
</h3>
<div className="space-y-1">
{items.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-4">暫無數據</p>
) : items.map((s, i) => (
<div key={s.name} className="py-1.5 border-b border-slate-100 last:border-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold ${
i === 0 ? 'bg-yellow-100 text-yellow-700' : i === 1 ? 'bg-slate-100 text-slate-500' : i === 2 ? 'bg-orange-50 text-orange-400' : 'bg-slate-50 text-slate-400'
}`}>{i + 1}</span>
<span className="text-sm text-slate-700">{s.name}</span>
</div>
<div className="text-right">
<span className="text-sm font-bold" style={colorFn ? { color: colorFn(s) } : undefined}>
{rankKey === 'rate' ? `${s[rankKey]}%` : s[rankKey]}
</span>
{subKey && (s[subKey] || 0) > 0 && (
<span className="text-xs text-slate-400 ml-1">({s[subKey]}{unit || '次'})</span>
)}
</div>
</div>
{/* Option 1 detail row */}
{details.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1 ml-7 text-[11px] text-slate-500">
{details.map(d => {
const v = s[d.key]
let display
if (v === null || v === undefined || v === 0 && d.key !== 'attendance_rate') {
// Skip empty/zero for most fields, but show 0% rate
if (!(d.key === 'attendance_rate')) return null
display = `0${d.unit}`
} else if (d.isDate) {
display = `${d.label} ${v}`
} else if (d.suffix) {
// e.g. attendance_rate = 70 (already %)
display = `${d.label} ${v}${d.unit}`
} else {
display = `${d.label} ${v}${d.unit}`
}
// For attendance_rate, only show if rate exists
if (d.key === 'attendance_rate' && (v === null || v === undefined)) return null
// For last_attendance_date, only show if exists
if (d.isDate && !v) return null
// For consecutive_missing, only show if > 0 (otherwise skip)
if (d.key === 'consecutive_missing' && (v || 0) === 0) return null
return <span key={d.key}>{display}</span>
})}
</div>
)}
</div>
))}
</div>
<Icon size={20} className={color.replace('border-l-', 'text-')} />
</div>
</Link>
)
const SmallBarChart = ({ data, color }) => (
<div className="h-32">
{data.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} layout="vertical" margin={{ left: 5, right: 5 }}>
<XAxis type="number" tick={{ fontSize: 10 }} />
<YAxis dataKey="name" type="category" width={80} tick={{ fontSize: 10 }} />
<Tooltip contentStyle={{ borderRadius: '6px', border: '1px solid #e2e8f0', fontSize: 12 }} />
<Bar dataKey="value" radius={[0, 3, 3, 0]}>
{data.map((_, index) => (
<Cell key={index} fill={COLORS[index % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-slate-400 text-xs">暫無數據</div>
)}
</div>
)
const TrendChart = ({ data }) => (
<div className="h-40">
{data.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ left: 5, right: 15 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip contentStyle={{ borderRadius: '6px', border: '1px solid #e2e8f0', fontSize: 12 }} />
<Line type="monotone" dataKey="value" stroke="#ef4444" strokeWidth={2} dot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-slate-400 text-xs">暫無數據</div>
)}
</div>
)
// Lateness chart data
const latenessChartData = latenessData?.stats?.slice(0, 8).map(s => ({
name: String(s.name).slice(0, 10),
value: s.late_minutes,
count: s.late_count
})) || []
)
}
return (
<div className="space-y-8">
<div className="space-y-6" ref={dashboardRef}>
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-slate-900">Dashboard</h1>
<button onClick={fetchData} className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-100 rounded-md">
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
刷新
</button>
<div className="flex items-center gap-2">
{/* Export Buttons */}
<div className="flex items-center gap-1 border border-slate-300 rounded-md overflow-hidden">
<button
onClick={exportPDF}
disabled={exporting}
className="flex items-center gap-1 px-3 py-2 text-sm bg-white text-slate-700 hover:bg-slate-50 disabled:opacity-50"
title="導出 PDF"
>
<FileBarChart size={14} /> PDF
</button>
<div className="w-px h-5 bg-slate-300" />
<button
onClick={() => exportExcel(false)}
disabled={exporting}
className="flex items-center gap-1 px-3 py-2 text-sm bg-white text-slate-700 hover:bg-slate-50 disabled:opacity-50"
title="導出 Excel (跟時間範圍)"
>
<FileSpreadsheet size={14} /> Excel
</button>
<div className="w-px h-5 bg-slate-300" />
<button
onClick={() => exportExcel(true)}
disabled={exporting}
className="flex items-center gap-1 px-3 py-2 text-sm bg-blue-50 text-blue-700 hover:bg-blue-100 disabled:opacity-50 font-medium"
title="導出 Excel (全部)"
>
<Download size={14} /> 全部
</button>
</div>
<Link to="/attendance" className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 text-sm rounded-md hover:bg-slate-200">
<ArrowRight size={16} /> 出勤記錄
</Link>
</div>
</div>
{/* ========== ATTENDANCE SECTION ========== */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
<Users size={20} className="text-blue-600" />
出勤 Attendance
</h2>
<Link to="/attendance" className="text-sm text-blue-600 hover:text-blue-700">查看全部 </Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<SectionCard title="出勤記錄" value={attInfo?.rows || 0} icon={Users} color="border-l-blue-500" link="/attendance" />
<SectionCard title="出勤部門" value={attDeptData.length} subtitle="個部門" icon={MapPin} color="border-l-cyan-500" link="/attendance" />
<SectionCard
title="遲到人次"
value={latenessData?.total_late_count || 0}
icon={AlertCircle}
color="border-l-orange-500"
link="/attendance"
/>
<SectionCard
title="遲到總分鐘"
value={latenessData?.total_late_minutes || 0}
subtitle="分鐘"
icon={Clock}
color="border-l-red-500"
link="/attendance"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="card p-4 border-l-4 border-l-blue-500">
<h3 className="text-sm font-semibold text-slate-900 mb-3">部門分佈</h3>
<SmallBarChart data={attDeptData} color="blue" />
{/* Date Filter */}
<div className="card p-4">
<div className="flex flex-wrap gap-3 items-end">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500">日期範圍:</span>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" />
<span className="text-slate-400">-</span>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" />
</div>
<div className="card p-4 border-l-4 border-l-blue-500">
<h3 className="text-sm font-semibold text-slate-900 mb-3">出勤狀態</h3>
<SmallBarChart data={attStatusData} color="blue" />
</div>
{/* Lateness Chart */}
<div className="card p-4 border-l-4 border-l-orange-500">
<h3 className="text-sm font-semibold text-slate-900 mb-3">員工遲到分鐘</h3>
<div className="h-32">
{latenessChartData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={latenessChartData} layout="vertical" margin={{ left: 5, right: 5 }}>
<XAxis type="number" tick={{ fontSize: 10 }} />
<YAxis dataKey="name" type="category" width={60} tick={{ fontSize: 9 }} />
<Tooltip
contentStyle={{ borderRadius: '6px', border: '1px solid #e2e8f0', fontSize: 12 }}
formatter={(value, name, props) => [`${value}分鐘 (${props.payload.count}次)`, '遲到']}
/>
<Bar dataKey="value" radius={[0, 3, 3, 0]} fill="#f59e0b" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-slate-400 text-xs">暫無遲到數據</div>
)}
</div>
<div className="flex gap-2 flex-wrap">
<button onClick={() => { setDateFrom(today); setDateTo(today) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">今日</button>
<button onClick={() => {
const [w0, w1] = weekRange(0)
setDateFrom(w0); setDateTo(w1)
}} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">本週</button>
<button onClick={() => {
const [w0, w1] = weekRange(-1)
setDateFrom(w0); setDateTo(w1)
}} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">上週</button>
<button onClick={() => { setDateFrom(monthStart); setDateTo(today) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">本月</button>
<button onClick={() => { setDateFrom(''); setDateTo('') }} className="text-xs text-slate-500 hover:text-slate-700 px-2 py-2 border border-slate-300 rounded">清除</button>
</div>
</div>
{/* Top late employees table */}
{latenessData?.stats?.length > 0 && (
<div className="card p-4 mt-4">
<h3 className="text-sm font-semibold text-slate-900 mb-3 flex items-center gap-2">
<AlertCircle size={16} className="text-orange-500" />
遲到排行榜
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 font-medium text-slate-500">員工</th>
<th className="text-center py-2 px-3 font-medium text-slate-500">班次</th>
<th className="text-center py-2 px-3 font-medium text-slate-500">遲到次數</th>
<th className="text-center py-2 px-3 font-medium text-slate-500">總分鐘</th>
</tr>
</thead>
<tbody>
{latenessData.stats.slice(0, 5).map((stat, idx) => (
<tr key={idx} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-2 px-3 font-medium">{stat.name}</td>
<td className="text-center py-2 px-3">{stat.shift}</td>
<td className="text-center py-2 px-3 text-orange-600 font-medium">{stat.late_count} </td>
<td className="text-center py-2 px-3 text-red-600 font-medium">{stat.late_minutes} 分鐘</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{(dateFrom || dateTo) && (
<p className="text-xs text-slate-400 mt-2">顯示 {dateFrom || '開始'} ~ {dateTo || today} 的數據</p>
)}
</div>
{/* ========== ACCIDENT SECTION ========== */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
<AlertTriangle size={20} className="text-red-600" />
意外 Accident
</h2>
<Link to="/accident" className="text-sm text-red-600 hover:text-red-700">查看全部 </Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<SectionCard title="意外記錄" value={accInfo?.rows || 0} icon={AlertTriangle} color="border-l-red-500" link="/accident" />
<SectionCard title="出事地點" value={accLocationData.length} subtitle="個地點" icon={MapPin} color="border-l-orange-500" link="/accident" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="card p-4 border-l-4 border-l-red-500">
<h3 className="text-sm font-semibold text-slate-900 mb-3">緊急程度</h3>
<SmallBarChart data={accSeverityData} color="red" />
{loading ? (
<div className="text-center py-12 text-slate-400">載入中...</div>
) : summary ? (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{statCards.map(({ label, value, icon: Icon, color }) => (
<div key={label} className="card p-4">
<div className="flex items-center gap-3">
<div className={`p-2.5 rounded-lg ${color}`}><Icon size={20} /></div>
<div>
<div className="text-2xl font-bold text-slate-900">{value ?? 0}</div>
<div className="text-xs text-slate-500">{label}</div>
</div>
</div>
</div>
))}
</div>
<div className="card p-4 border-l-4 border-l-orange-500">
<h3 className="text-sm font-semibold text-slate-900 mb-3">出事地點</h3>
<SmallBarChart data={accLocationData} color="orange" />
</div>
<div className="card p-4 border-l-4 border-l-red-500">
<h3 className="text-sm font-semibold text-slate-900 mb-3">月度趨勢</h3>
<TrendChart data={accMonthlyData} />
</div>
</div>
</div>
{/* Quick Actions */}
<div className="flex flex-wrap gap-3">
<button onClick={() => handleExportHTML('attendance')} className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700">
<FileCode size={16} />
Attendance HTML Report
</button>
<button onClick={() => handleExportHTML('accident')} className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700">
<FileCode size={16} />
Accident HTML Report
</button>
<Link to="/upload" className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm rounded-md hover:bg-primary-700">
<FileSpreadsheet size={16} />
上傳 Excel
</Link>
<Link to="/roster" className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 text-sm rounded-md hover:bg-slate-200">
<Clock size={16} />
班次管理
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="card p-4">
<div style={{ height: '260px', position: 'relative' }}><canvas ref={pieRef}></canvas></div>
</div>
<div className="card p-4">
<div style={{ height: '260px', position: 'relative' }}><canvas ref={barRef}></canvas></div>
</div>
</div>
{byStaff.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<RankCard title="遲到排行榜" icon={Clock} iconColor="#F59E0B" data={lateRank} rankKey="late_count" subKey="late_minutes" unit="分" colorFn={s => s.late_count > 0 ? '#F59E0B' : '#10B981'} />
<RankCard title="OT排行榜" icon={TrendingUp} iconColor="#8B5CF6" data={otRank} rankKey="ot_count" subKey="ot_minutes" unit="分" colorFn={s => s.ot_count > 0 ? '#8B5CF6' : '#10B981'} />
<RankCard title="缺勤排行榜" icon={XCircle} iconColor="#6B7280" data={missingRank} rankKey="missing_count" colorFn={s => s.missing_count > 0 ? '#EF4444' : '#10B981'} />
<RankCard title="出勤率排行榜" icon={Award} iconColor="#10B981" data={attendanceRate} rankKey="rate" colorFn={s => s.rate >= 90 ? '#10B981' : s.rate >= 70 ? '#F59E0B' : '#EF4444'} />
</div>
)}
{stats?.stats?.length > 0 && (
<div className="card p-4">
<h2 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
<Users size={16} /> 員工出勤概覽
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-600">員工</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">出勤</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">正常</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">遲到</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">遲分鐘</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">OT</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">OT分鐘</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">異常</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">缺勤</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{byStaff.map((s) => (
<tr key={s.name} className="hover:bg-slate-50">
<td className="px-3 py-2 font-medium text-slate-700">{s.name}</td>
<td className="px-3 py-2 text-right text-slate-600">{s.total_records ?? s.total ?? 0}</td>
<td className="px-3 py-2 text-right text-green-600">{(s.total_records ?? s.total ?? 0) - (s.late_count || 0) - (s.abnormal_count || 0)}</td>
<td className="px-3 py-2 text-right text-orange-600">{s.late_count || 0}</td>
<td className="px-3 py-2 text-right text-orange-400">{s.late_minutes || '-'}</td>
<td className="px-3 py-2 text-right text-purple-600">{s.ot_count || 0}</td>
<td className="px-3 py-2 text-right text-purple-400">{s.ot_minutes || '-'}</td>
<td className="px-3 py-2 text-right text-red-600">{s.abnormal_count || 0}</td>
<td className="px-3 py-2 text-right text-gray-500">{s.missing_count || 0}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
) : (
<div className="text-center py-12 text-slate-400">暫無數據</div>
)}
</div>
)
}
+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>
)
}
+517
View File
@@ -0,0 +1,517 @@
import { useState, useEffect } from "react"
import { Trash2, AlertTriangle, Shield, Download, Upload, RefreshCw, Database, Info, KeyRound, User as UserIcon } 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("")
// User management (admin only)
const [users, setUsers] = useState([])
const [loadingUsers, setLoadingUsers] = useState(false)
const [resetTarget, setResetTarget] = useState(null)
const [newPassword, setNewPassword] = useState("")
const [resetting, setResetting] = useState(false)
const [resetError, setResetError] = useState(null)
useEffect(() => {
fetchVersion()
fetchBackups()
fetchUsers()
}, [])
const fetchUsers = async () => {
setLoadingUsers(true)
try {
const { data } = await api.get("/api/auth/users")
setUsers(data || [])
} catch (e) {
// 403 if not admin — leave list empty
setUsers([])
} finally {
setLoadingUsers(false)
}
}
const openResetDialog = (u) => {
setResetTarget(u)
setNewPassword("")
setResetError(null)
}
const closeResetDialog = () => {
if (resetting) return
setResetTarget(null)
setNewPassword("")
setResetError(null)
}
const handleResetPassword = async () => {
if (!resetTarget || !newPassword) return
if (newPassword.length < 6) {
setResetError("密碼至少需要 6 個字元")
return
}
setResetting(true)
setResetError(null)
try {
await api.post("/api/auth/reset-password", {
email: resetTarget.email,
new_password: newPassword,
})
toast.success(`已重設 ${resetTarget.email} 嘅密碼`)
closeResetDialog()
} catch (e) {
setResetError(e.response?.data?.detail || e.message)
} finally {
setResetting(false)
}
}
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>
{/* User Management — admin only */}
<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 gap-2">
<UserIcon className="w-5 h-5 text-slate-600" />
<h2 className="text-lg font-semibold text-slate-800">使用者管理 User Management</h2>
</div>
<p className="text-sm text-slate-500 mt-1">
重設使用者密碼Admin
</p>
</div>
<div className="divide-y divide-slate-100">
{loadingUsers && (
<div className="px-5 py-6 text-sm text-slate-400 text-center">載入中</div>
)}
{!loadingUsers && users.length === 0 && (
<div className="px-5 py-6 text-sm text-slate-400 text-center">
沒有使用者或你唔係 admin
</div>
)}
{!loadingUsers && users.map((u) => (
<div key={u.id} className="px-5 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center shrink-0">
<UserIcon className="w-4 h-4 text-slate-500" />
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-slate-800 truncate">
{u.name || u.email}
</div>
<div className="text-xs text-slate-500 truncate">
{u.email}
<span className={`ml-2 inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${
u.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-slate-100 text-slate-600'
}`}>
{u.role}
</span>
{!u.is_active && (
<span className="ml-1 inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-red-100 text-red-700">
inactive
</span>
)}
</div>
</div>
</div>
<button
onClick={() => openResetDialog(u)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 hover:bg-slate-50 hover:border-slate-400 rounded-md transition-colors"
>
<KeyRound className="w-3.5 h-3.5" />
重設密碼
</button>
</div>
))}
</div>
</div>
{/* Reset Password Modal */}
{resetTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4" onClick={closeResetDialog}>
<div
className="bg-white rounded-xl shadow-2xl w-full max-w-md overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="px-6 py-4 border-b border-slate-100">
<h3 className="text-lg font-semibold text-slate-900">重設密碼</h3>
<p className="text-sm text-slate-500 mt-1">
<span className="font-mono text-slate-700">{resetTarget.email}</span> 設定新密碼
</p>
</div>
<div className="px-6 py-5 space-y-3">
<label className="block text-sm font-medium text-slate-700">
新密碼
<input
type="text"
value={newPassword}
onChange={(e) => {
setNewPassword(e.target.value)
setResetError(null)
}}
placeholder="至少 6 個字元"
className="mt-1.5 block w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleResetPassword()
if (e.key === 'Escape') closeResetDialog()
}}
/>
</label>
{resetError && (
<div className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-md px-3 py-2">
{resetError}
</div>
)}
</div>
<div className="px-6 py-3 bg-slate-50 border-t border-slate-100 flex items-center justify-end gap-2">
<button
onClick={closeResetDialog}
disabled={resetting}
className="px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 rounded-md transition-colors disabled:opacity-50"
>
取消
</button>
<button
onClick={handleResetPassword}
disabled={resetting || !newPassword}
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-slate-300 disabled:cursor-not-allowed rounded-md transition-colors"
>
{resetting ? "重設中…" : "確認重設"}
</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>
)
}
+291
View File
@@ -0,0 +1,291 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import { Link } from 'react-router-dom'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend, LineChart, Line } from 'recharts'
import { AlertTriangle, MapPin, Users, FileText, Calendar, TrendingUp, XCircle, CheckCircle2, Download, Search, ChevronUp, ChevronDown, Filter, X, Activity } from 'lucide-react'
import api from '../../api'
import toast from 'react-hot-toast'
const SEVERITY_COLORS = {
'1': { bg: '#D1FAE5', fg: '#065F46', label: '低 Low' },
'2': { bg: '#DBEAFE', fg: '#1E40AF', label: '中 Medium' },
'3': { bg: '#FEF3C7', fg: '#92400E', label: '高 High' },
'4': { bg: '#FED7AA', fg: '#9A3412', label: '嚴重 Severe' },
'5': { bg: '#FEE2E2', fg: '#7F1D1D', label: '極嚴重 Critical' },
}
export default function AccidentDashboard() {
const [summary, setSummary] = useState(null)
const [stats, setStats] = useState(null)
const [records, setRecords] = useState([])
const [loading, setLoading] = useState(true)
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const dashboardRef = useRef(null)
const fmt = (d) => d.toISOString().slice(0, 10)
const weekRange = (offsetWeeks) => {
const now = new Date()
const dow = now.getDay()
const daysFromMonday = (dow + 6) % 7
const start = new Date(now)
start.setDate(now.getDate() - daysFromMonday + offsetWeeks * 7)
const end = new Date(start)
end.setDate(start.getDate() + 6)
return [fmt(start), fmt(end)]
}
const today = new Date().toISOString().slice(0, 10)
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10)
useEffect(() => {
fetchData()
}, [dateFrom, dateTo])
const fetchData = async () => {
setLoading(true)
try {
const params = {}
if (dateFrom) params.date_from = dateFrom
if (dateTo) params.date_to = dateTo
const [sumRes, statRes, recRes] = await Promise.all([
api.get('/api/accident/dashboard/summary', { params }),
api.get('/api/accident/dashboard/stats', { params }),
api.get('/api/accident/records', { params: { ...params, per_page: 1000 } }).catch(() => ({ data: { records: [] } })),
])
setSummary(sumRes.data)
setStats(statRes.data)
setRecords(recRes.data.records || [])
} catch (err) {
toast.error('載入失敗')
console.error(err)
} finally {
setLoading(false)
}
}
const handleExportExcel = async () => {
try {
const params = {}
if (dateFrom) params.date_from = dateFrom
if (dateTo) params.date_to = dateTo
const response = await api.get('/api/accident/export/excel', { params, responseType: 'blob' })
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
let suffix = ''
if (dateFrom && dateTo) suffix = `_${dateFrom}_${dateTo}`
else if (dateFrom) suffix = `_from_${dateFrom}`
else if (dateTo) suffix = `_to_${dateTo}`
link.setAttribute('download', `accident${suffix}.xlsx`)
document.body.appendChild(link)
link.click()
link.remove()
toast.success('Excel 已下載')
} catch (err) {
toast.error('匯出失敗')
}
}
// Build donut chart data (severity distribution)
const severityData = useMemo(() => {
if (!summary?.severity_count) return []
return Object.entries(summary.severity_count)
.filter(([_, count]) => count > 0)
.map(([sev, count]) => ({
name: SEVERITY_COLORS[sev]?.label || `Sev ${sev}`,
value: count,
severity: sev,
color: SEVERITY_COLORS[sev]?.fg || '#6B7280',
}))
}, [summary])
// Build line chart (monthly trend)
const monthlyTrend = useMemo(() => {
const byMonth = {}
for (const r of records) {
if (!r.date) continue
const ym = r.date.substring(0, 7)
byMonth[ym] = (byMonth[ym] || 0) + 1
}
return Object.entries(byMonth)
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, count]) => ({ month, count }))
}, [records])
if (loading && !summary) {
return <div className="p-12 text-center text-slate-500">載入中</div>
}
return (
<div className="space-y-6" ref={dashboardRef}>
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-slate-900">意外事故 Dashboard</h1>
<div className="flex items-center gap-2">
<Link
to="/accident/list"
className="flex items-center gap-2 px-3 py-1.5 border border-slate-300 text-slate-700 rounded-md hover:bg-slate-50 text-sm"
>
<FileText size={16} />
列表
</Link>
<button
onClick={handleExportExcel}
className="flex items-center gap-2 px-3 py-1.5 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
>
<Download size={16} />
匯出 Excel
</button>
</div>
</div>
{/* Date Range Filter */}
<div className="card p-4">
<div className="flex flex-wrap gap-3 items-end">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500">日期範圍:</span>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" />
<span className="text-slate-400">-</span>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" />
</div>
<div className="flex gap-2 flex-wrap">
<button onClick={() => { setDateFrom(today); setDateTo(today) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">今日</button>
<button onClick={() => { const [w0, w1] = weekRange(0); setDateFrom(w0); setDateTo(w1) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">本週</button>
<button onClick={() => { const [w0, w1] = weekRange(-1); setDateFrom(w0); setDateTo(w1) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">上週</button>
<button onClick={() => { setDateFrom(monthStart); setDateTo(today) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">本月</button>
<button onClick={() => { setDateFrom(''); setDateTo('') }} className="text-xs text-slate-500 hover:text-slate-700 px-2 py-2 border border-slate-300 rounded">清除</button>
</div>
</div>
{(dateFrom || dateTo) && (
<p className="text-xs text-slate-400 mt-2">顯示 {dateFrom || '開始'} ~ {dateTo || today} 的數據</p>
)}
</div>
{/* Stats Cards */}
{summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard icon={AlertTriangle} color="bg-red-50 text-red-600" label="總記錄 Total" value={summary.total_records || 0} />
<StatCard icon={Calendar} color="bg-blue-50 text-blue-600" label="本月" value={summary.this_month || 0} />
<StatCard icon={MapPin} color="bg-purple-50 text-purple-600" label="地點 Locations" value={summary.unique_locations || 0} />
<StatCard icon={TrendingUp} color="bg-green-50 text-green-600" label="日均" value={summary.avg_per_day || 0} />
{Object.entries(summary.severity_count || {}).map(([sev, count]) => (
<StatCard
key={sev}
icon={Activity}
color=""
label={`Severity ${sev}`}
value={count}
overrideColor={SEVERITY_COLORS[sev]?.fg}
overrideBg={SEVERITY_COLORS[sev]?.bg}
/>
))}
</div>
)}
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Severity Distribution */}
<div className="card p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3">嚴重程度分佈</h3>
{severityData.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-12">暫無數據</p>
) : (
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie
data={severityData}
dataKey="value"
nameKey="name"
cx="50%" cy="50%"
outerRadius={80}
label={(entry) => `${entry.name}: ${entry.value}`}
>
{severityData.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
)}
</div>
{/* Monthly Trend */}
<div className="card p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3">月度趨勢</h3>
{monthlyTrend.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-12">暫無數據</p>
) : (
<ResponsiveContainer width="100%" height={250}>
<LineChart data={monthlyTrend}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="count" stroke="#2563EB" strokeWidth={2} dot={{ r: 4 }} />
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Leaderboards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<RankCard title="地點 (Top 5)" icon={MapPin} data={stats.by_location?.slice(0, 5) || []} field="count" />
<RankCard title="員工 (Top 5)" icon={Users} data={stats.by_employee?.slice(0, 5) || []} field="count" />
<RankCard title="部門 (Top 5)" icon={FileText} data={stats.by_department?.slice(0, 5) || []} field="count" />
<RankCard title="負責人 (Top 5)" icon={CheckCircle2} data={stats.by_responsible?.slice(0, 5) || []} field="count" />
</div>
)}
</div>
)
}
function StatCard({ icon: Icon, color, label, value, overrideColor, overrideBg }) {
return (
<div
className="card p-4"
style={overrideBg ? { backgroundColor: overrideBg } : undefined}
>
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-slate-500 mb-1">{label}</p>
<p className="text-2xl font-bold" style={overrideColor ? { color: overrideColor } : undefined}>{value}</p>
</div>
{Icon && (
<div className={`p-2 rounded-lg ${color}`}>
<Icon size={20} />
</div>
)}
</div>
</div>
)
}
function RankCard({ title, icon: Icon, data, field }) {
return (
<div className="card p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
<Icon size={16} /> {title}
</h3>
<div className="space-y-1">
{data.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-4">暫無數據</p>
) : data.map((s, i) => (
<div key={s.name} className="py-1.5 border-b border-slate-100 last:border-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold ${i === 0 ? 'bg-yellow-100 text-yellow-700' : i === 1 ? 'bg-slate-100 text-slate-500' : i === 2 ? 'bg-orange-50 text-orange-400' : 'bg-slate-50 text-slate-400'}`}>{i + 1}</span>
<span className="text-sm text-slate-700 truncate max-w-[160px]" title={s.name}>{s.name}</span>
</div>
<div className="text-right">
<span className="text-sm font-bold text-slate-700">{s[field]}</span>
<span className="text-xs text-slate-400 ml-2">avg {s.avg_severity || 0}</span>
</div>
</div>
</div>
))}
</div>
</div>
)
}
+86 -45
View File
@@ -2,45 +2,67 @@ import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import api from '../../api'
import toast from 'react-hot-toast'
import { ArrowLeft, FileText } from 'lucide-react'
import { ArrowLeft, FileText, AlertTriangle } from 'lucide-react'
const SEVERITY_COLORS = {
'1': { bg: '#D1FAE5', fg: '#065F46', label: '低 Low' },
'2': { bg: '#DBEAFE', fg: '#1E40AF', label: '中 Medium' },
'3': { bg: '#FEF3C7', fg: '#92400E', label: '高 High' },
'4': { bg: '#FED7AA', fg: '#9A3412', label: '嚴重 Severe' },
'5': { bg: '#FEE2E2', fg: '#7F1D1D', label: '極嚴重 Critical' },
}
const FIELDS = [
{ key: 'date', label: '日期 Date' },
{ key: 'time', label: '時間 Time' },
{ key: 'severity', label: '嚴重程度 Severity', badge: true },
{ key: 'location', label: '地點 Location' },
{ key: 'employee_name', label: '員工/報告人 Employee' },
{ key: 'department', label: '部門 Department' },
{ key: 'description', label: '事件描述 Description' },
{ key: 'medical_report', label: '醫療報告 Medical Report' },
{ key: 'action_taken', label: '處理情況 Action Taken' },
{ key: 'responsible_person', label: '負責人 Responsible Person' },
{ key: 'created_at', label: '建立時間 Created At' },
]
export default function AccidentDetail() {
const { id } = useParams()
const navigate = useNavigate()
const [record, setRecord] = useState(null)
const [headers, setHeaders] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetchRecord()
const numericId = parseInt(id, 10)
if (isNaN(numericId) || numericId <= 0) {
navigate('/accident/list', { replace: true })
return
}
fetchRecord(numericId)
}, [id])
const fetchRecord = async () => {
const fetchRecord = async (recordId) => {
setLoading(true)
setError(null)
try {
// Get all records to find the one with this row id
const { data } = await api.get(`/api/accident?page=1&page_size=1000`)
if (data.data && data.data.length > 0) {
// Extract headers from first record
const keys = Object.keys(data.data[0]).filter(k => k !== '_row')
setHeaders(keys)
// Find the record with matching _row
const found = data.data.find(r => r._row === parseInt(id))
if (found) {
setRecord(found)
} else {
setError('Record not found')
}
} else {
setError('No data available')
}
const { data } = await api.get(`/api/accident/${recordId}`)
setRecord(data)
} catch (err) {
console.error('Error fetching record:', err)
setError('Failed to load record')
const detail = err.response?.data?.detail
// FastAPI 422 returns detail as array of validation errors — stringify safely
let msg = 'Failed to load record'
if (typeof detail === 'string') {
msg = detail
} else if (Array.isArray(detail)) {
msg = `Invalid request: ${detail.map(d => d.msg || JSON.stringify(d)).join('; ')}`
} else if (detail && typeof detail === 'object') {
msg = JSON.stringify(detail)
} else if (err.response?.status === 404) {
msg = 'Record not found'
}
setError(msg)
} finally {
setLoading(false)
}
@@ -59,7 +81,7 @@ export default function AccidentDetail() {
<div className="space-y-4">
<div className="flex items-center gap-4">
<Link
to="/accident"
to="/accident/list"
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
>
<ArrowLeft size={20} />
@@ -67,9 +89,10 @@ export default function AccidentDetail() {
</Link>
</div>
<div className="card p-8 text-center">
<div className="text-red-500">{error}</div>
<AlertTriangle size={48} className="mx-auto text-red-400 mb-2" />
<div className="text-red-500 mb-2">{error}</div>
<button
onClick={() => navigate('/accident')}
onClick={() => navigate('/accident/list')}
className="mt-4 px-4 py-2 text-sm text-primary-600 hover:text-primary-700"
>
返回列表
@@ -79,49 +102,67 @@ export default function AccidentDetail() {
)
}
const sev = String(record.severity || '')
const sevColor = SEVERITY_COLORS[sev] || { bg: '#F3F4F6', fg: '#374151', label: sev || '-' }
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-3 mb-4 sm:mb-6">
<div className="flex items-center gap-4">
<Link
to="/accident"
to="/accident/list"
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
>
<ArrowLeft size={20} />
返回
</Link>
<h1 className="text-2xl font-bold text-slate-900">
<h1 className="text-xl sm:text-2xl font-bold text-slate-900">
意外記錄 #{id}
</h1>
</div>
<span
className="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold"
style={{ backgroundColor: sevColor.bg, color: sevColor.fg }}
>
Severity {sev} · {sevColor.label}
</span>
</div>
{/* Record Details */}
<div className="card">
<div className="card overflow-hidden">
<div className="p-4 border-b border-slate-200 bg-slate-50">
<div className="flex items-center gap-3">
<FileText size={24} className="text-slate-400" />
<div>
<div className="font-medium text-slate-900">記錄詳情</div>
<div className="text-sm text-slate-500">Excel 原始數據</div>
<div className="text-sm text-slate-500">AARS 意外事故記錄</div>
</div>
</div>
</div>
<div className="divide-y divide-slate-100">
{headers.map(key => (
<div key={key} className="grid grid-cols-12">
<div className="col-span-3 px-4 py-3 text-sm font-medium text-slate-500 bg-slate-50">
{key}
{FIELDS.map(({ key, label, badge }) => {
const val = record[key]
const display = val !== null && val !== undefined && val !== '' ? String(val) : '-'
return (
<div key={key} className="grid grid-cols-1 sm:grid-cols-12">
<div className="sm:col-span-3 px-4 py-3 text-sm font-medium text-slate-500 bg-slate-50">
{label}
</div>
<div className="sm:col-span-9 px-4 py-3 text-sm text-slate-900 break-words">
{badge && sev !== '' ? (
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold"
style={{ backgroundColor: sevColor.bg, color: sevColor.fg }}
>
{sev} · {sevColor.label}
</span>
) : (
display
)}
</div>
</div>
<div className="col-span-9 px-4 py-3 text-sm text-slate-900">
{record[key] !== null && record[key] !== undefined
? String(record[key])
: '-'}
</div>
</div>
))}
)
})}
</div>
</div>
</div>
+127 -55
View File
@@ -1,79 +1,105 @@
import { useState, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
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'
const handleExportHTML = (section) => {
const token = localStorage.getItem('aars_token')
if (token) {
window.open(`/api/report/${section}/html?token=${token}`, '_blank')
}
}
import { Search, Eye, ChevronUp, ChevronDown, Filter, X, Settings2, RotateCcw } from 'lucide-react'
export default function AccidentList() {
const navigate = useNavigate()
const [records, setRecords] = useState([])
const [headers, setHeaders] = useState([])
const [excelHeaders, setExcelHeaders] = useState({}) // sql_col -> Excel chinese header
const [columnOrder, setColumnOrder] = useState([]) // ordered SQL cols (Excel order)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState(null)
const [sortDir, setSortDir] = useState('asc')
const [sortKey, setSortKey] = useState('date')
const [sortDir, setSortDir] = useState('desc')
const [filterKey, setFilterKey] = useState('')
const [filterValue, setFilterValue] = useState('')
// Column visibility — localStorage persisted
const STORAGE_KEY = 'aars.accident.visibleColumns.v1'
const [visibleCols, setVisibleCols] = useState(() => {
try {
const s = localStorage.getItem(STORAGE_KEY)
if (s) return JSON.parse(s)
} catch {}
return null // null = show all
})
const [showColumnSettings, setShowColumnSettings] = useState(false)
useEffect(() => {
fetchRecords()
}, [])
useEffect(() => {
if (visibleCols !== null) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(visibleCols))
}
}, [visibleCols])
const fetchRecords = async () => {
setLoading(true)
try {
const { data } = await api.get('/api/accident?page=1&page_size=1000')
setRecords(data.data || [])
if (data.data && data.data.length > 0) {
const keys = Object.keys(data.data[0]).filter(k => k !== '_row')
setHeaders(keys)
const { data } = await api.get('/api/accident/records?per_page=1000')
setRecords(data.records || [])
const eh = data.excel_headers || {}
const co = data.column_order || []
setExcelHeaders(eh)
setColumnOrder(co)
// Default sort
if (data.records && data.records.length > 0 && !sortKey) {
setSortKey('date')
setSortDir('desc')
}
} catch (err) {
toast.error('Failed to load records')
console.error(err)
} finally {
setLoading(false)
}
}
// The set of columns to display: filtered by visibleCols (if not null)
const displayedCols = useMemo(() => {
let cols = columnOrder.filter(c => c !== 'id')
if (visibleCols !== null) {
cols = cols.filter(c => visibleCols[c])
}
return cols
}, [columnOrder, visibleCols])
// Helper: get display name for a SQL column (uses Excel header, trims trailing spaces)
const displayName = (col) => {
const h = excelHeaders[col]
return (h ? String(h).trim() : col) || col
}
// Filtered and sorted data
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()
result = result.filter(r =>
Object.values(r).some(v =>
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)) {
if (!isNaN(aNum) && !isNaN(bNum) && aVal !== '' && bVal !== '') {
cmp = aNum - bNum
} else {
cmp = String(aVal).localeCompare(String(bVal))
@@ -81,7 +107,6 @@ export default function AccidentList() {
return sortDir === 'asc' ? cmp : -cmp
})
}
return result
}, [records, search, sortKey, sortDir, filterKey, filterValue])
@@ -109,18 +134,35 @@ export default function AccidentList() {
return sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
}
const toggleColumn = (col) => {
const next = visibleCols ? { ...visibleCols } : {}
// Initialize all columns to true first
columnOrder.forEach(c => { if (next[c] === undefined) next[c] = true })
next[col] = !next[col]
setVisibleCols(next)
}
const resetColumns = () => {
setVisibleCols(null)
localStorage.removeItem(STORAGE_KEY)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-slate-900">意外記錄 Accident</h1>
<h1 className="text-lg sm:text-xl font-bold text-slate-900">意外記錄 Accident</h1>
<div className="flex gap-2">
<button
onClick={() => handleExportHTML('accident')}
className="flex items-center gap-2 px-4 py-2 bg-slate-700 text-white text-sm rounded-md hover:bg-slate-800"
onClick={() => setShowColumnSettings(!showColumnSettings)}
className={`flex items-center gap-2 px-3 py-2 text-sm rounded-md border ${
showColumnSettings
? 'bg-slate-100 border-slate-400 text-slate-800'
: 'border-slate-300 text-slate-600 hover:bg-slate-50'
}`}
>
<FileCode size={16} />
匯出 HTML Report
<Settings2 size={16} />
欄位 ({displayedCols.length}/{columnOrder.filter(c => c !== 'id').length})
</button>
<Link
to="/upload"
@@ -131,10 +173,44 @@ export default function AccidentList() {
</div>
</div>
{/* Column Settings Panel */}
{showColumnSettings && (
<div className="card p-4 border-l-4 border-primary-500">
<div className="flex items-center justify-between mb-3">
<div className="text-sm font-semibold text-slate-700">
選擇顯示嘅欄位 (來源: Excel)
</div>
<button
onClick={resetColumns}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-800"
>
<RotateCcw size={12} />
重設全部顯示
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{columnOrder.filter(c => c !== 'id').map(col => {
const isVisible = visibleCols === null || (visibleCols[col] !== false)
return (
<label key={col} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-slate-50 px-2 py-1 rounded">
<input
type="checkbox"
checked={isVisible}
onChange={() => toggleColumn(col)}
className="rounded"
/>
<span className="text-slate-700">{displayName(col)}</span>
<span className="text-slate-400 text-[10px]">({col})</span>
</label>
)
})}
</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
@@ -145,8 +221,6 @@ export default function AccidentList() {
className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-md"
/>
</div>
{/* Column Filter */}
<div className="flex items-center gap-2">
<Filter size={16} className="text-slate-400" />
<select
@@ -155,11 +229,10 @@ export default function AccidentList() {
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>
{displayedCols.map(h => (
<option key={h} value={h}>{displayName(h)}</option>
))}
</select>
{filterKey && (
<>
<input
@@ -175,8 +248,6 @@ export default function AccidentList() {
</>
)}
</div>
{/* Result count */}
<div className="text-sm text-slate-500 py-2">
{processedData.length} 筆記錄
</div>
@@ -190,14 +261,15 @@ export default function AccidentList() {
<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"
{displayedCols.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 whitespace-nowrap"
onClick={() => handleSort(h)}
title={excelHeaders[h] || h}
>
<div className="flex items-center gap-1">
{h}
{displayName(h)}
{getSortIcon(h)}
</div>
</th>
@@ -208,32 +280,32 @@ export default function AccidentList() {
<tbody className="divide-y divide-slate-100">
{loading ? (
<tr>
<td colSpan={8} className="px-3 py-6 text-center text-slate-400">
<td colSpan={displayedCols.length + 2} className="px-3 py-6 text-center text-slate-400">
載入中...
</td>
</tr>
) : processedData.length === 0 ? (
<tr>
<td colSpan={8} className="px-3 py-6 text-center text-slate-400">
<td colSpan={displayedCols.length + 2} className="px-3 py-6 text-center text-slate-400">
沒有記錄
</td>
</tr>
) : (
processedData.map((record) => (
<tr
key={record._row}
<tr
key={record.id}
className="hover:bg-slate-50 cursor-pointer"
onClick={() => window.location.href = `/accident/${record._row}`}
onClick={() => navigate(`/accident/${record.id}`)}
>
<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 className="px-3 py-2 text-xs text-slate-400">{record.id}</td>
{displayedCols.map(h => (
<td key={h} className="px-3 py-2 text-slate-700 max-w-xs truncate" title={record[h]}>
{record[h] !== null && record[h] !== undefined && record[h] !== '' ? String(record[h]) : '-'}
</td>
))}
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
<Link
to={`/accident/${record._row}`}
to={`/accident/${record.id}`}
className="p-1 text-slate-400 hover:text-primary-600 inline-block"
>
<Eye size={16} />
+160 -61
View File
@@ -2,14 +2,16 @@ import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import api from '../../api'
import toast from 'react-hot-toast'
import { ArrowLeft, FileText } from 'lucide-react'
import { ArrowLeft, Save, X } from 'lucide-react'
export default function AttendanceDetail() {
const { id } = useParams()
const navigate = useNavigate()
const [record, setRecord] = useState(null)
const [headers, setHeaders] = useState([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [editMode, setEditMode] = useState(false)
const [formData, setFormData] = useState({})
const [error, setError] = useState(null)
useEffect(() => {
@@ -20,32 +22,111 @@ export default function AttendanceDetail() {
setLoading(true)
setError(null)
try {
// Get all records to find the one with this row id
const { data } = await api.get(`/api/attendance?page=1&page_size=1000`)
if (data.data && data.data.length > 0) {
// Extract headers from first record
const keys = Object.keys(data.data[0]).filter(k => k !== '_row')
setHeaders(keys)
// Find the record with matching _row
const found = data.data.find(r => r._row === parseInt(id))
if (found) {
setRecord(found)
} else {
setError('Record not found')
}
// Fetch all records and find by id
const { data } = await api.get('/api/attendance/records?per_page=1000')
const found = (data.records || []).find(r => r.id === parseInt(id))
if (found) {
setRecord(found)
setFormData({
employee_name: found.employee_name || '',
company: found.company || '',
date: found.date || '',
weekday: found.weekday || '',
shift_code: found.shift_code || '',
status_code: found.status_code || '',
status_text: found.status_text || '',
late_minutes: found.late_minutes || 0,
early_minutes: found.early_minutes || 0,
ot_minutes: found.ot_minutes || 0,
expected_in: found.expected_in || '',
expected_out: found.expected_out || '',
actual_in: found.actual_in || '',
actual_out: found.actual_out || '',
is_manually_edited: found.is_manually_edited || false,
})
} else {
setError('No data available')
setError('找不到記錄')
}
} catch (err) {
console.error('Error fetching record:', err)
setError('Failed to load record')
console.error('Error:', err)
setError('載入失敗')
} finally {
setLoading(false)
}
}
const handleSave = async () => {
setSaving(true)
try {
await api.put(`/api/attendance/${id}`, formData)
toast.success('保存成功')
setEditMode(false)
fetchRecord()
} catch (err) {
toast.error('保存失敗')
} finally {
setSaving(false)
}
}
const handleCancel = () => {
setEditMode(false)
if (record) {
setFormData({
employee_name: record.employee_name || '',
company: record.company || '',
date: record.date || '',
weekday: record.weekday || '',
shift_code: record.shift_code || '',
status_code: record.status_code || '',
status_text: record.status_text || '',
late_minutes: record.late_minutes || 0,
early_minutes: record.early_minutes || 0,
ot_minutes: record.ot_minutes || 0,
expected_in: record.expected_in || '',
expected_out: record.expected_out || '',
actual_in: record.actual_in || '',
actual_out: record.actual_out || '',
})
}
}
const getStatusBadge = (code, text) => {
const badges = {
normal: 'bg-green-100 text-green-700',
late: 'bg-orange-100 text-orange-700',
early: 'bg-blue-100 text-blue-700',
ot: 'bg-purple-100 text-purple-700',
abnormal: 'bg-red-100 text-red-700',
missing: 'bg-gray-200 text-gray-600',
late_early: 'bg-orange-100 text-orange-700',
late_ot: 'bg-orange-100 text-orange-700',
early_ot: 'bg-blue-100 text-blue-700',
}
return (
<span className={`px-3 py-1 rounded-full text-xs font-medium ${badges[code] || 'bg-gray-100 text-gray-600'}`}>
{text || code}
</span>
)
}
const displayFields = [
{ key: 'employee_name', label: '員工姓名' },
{ key: 'company', label: '公司' },
{ key: 'date', label: '日期' },
{ key: 'weekday', label: '星期' },
{ key: 'shift_code', label: '班次' },
{ key: 'expected_in', label: '應上班時間' },
{ key: 'expected_out', label: '應下班時間' },
{ key: 'actual_in', label: '實際上班' },
{ key: 'actual_out', label: '實際下班' },
{ key: 'late_minutes', label: '遲到分鐘' },
{ key: 'early_minutes', label: '早退分鐘' },
{ key: 'ot_minutes', label: 'OT分鐘' },
{ key: 'status_code', label: '狀態代碼' },
{ key: 'status_text', label: '狀態' },
]
if (loading) {
return (
<div className="flex items-center justify-center py-12">
@@ -57,24 +138,10 @@ export default function AttendanceDetail() {
if (error) {
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<Link
to="/attendance"
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
>
<ArrowLeft size={20} />
返回列表
</Link>
</div>
<div className="card p-8 text-center">
<div className="text-red-500">{error}</div>
<button
onClick={() => navigate('/attendance')}
className="mt-4 px-4 py-2 text-sm text-primary-600 hover:text-primary-700"
>
返回列表
</button>
</div>
<Link to="/attendance" className="flex items-center gap-2 text-slate-600 hover:text-slate-900">
<ArrowLeft size={20} /> 返回列表
</Link>
<div className="card p-8 text-center text-red-500">{error}</div>
</div>
)
}
@@ -84,41 +151,73 @@ export default function AttendanceDetail() {
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
to="/attendance"
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
>
<ArrowLeft size={20} />
返回
<Link to="/attendance" className="flex items-center gap-2 text-slate-600 hover:text-slate-900">
<ArrowLeft size={20} /> 返回
</Link>
<h1 className="text-2xl font-bold text-slate-900">
出勤記錄 #{id}
<h1 className="text-xl font-bold text-slate-900">
出勤記錄 - {record?.employee_name}
</h1>
</div>
<div className="flex items-center gap-3">
{record && getStatusBadge(record.status_code, record.status_text)}
{!editMode ? (
<button
onClick={() => setEditMode(true)}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
編輯
</button>
) : (
<>
<button
onClick={handleCancel}
className="flex items-center gap-2 px-4 py-2 border border-slate-300 text-slate-700 text-sm rounded-md hover:bg-slate-50"
>
<X size={16} /> 取消
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white text-sm rounded-md hover:bg-green-700 disabled:opacity-50"
>
<Save size={16} /> {saving ? '保存中...' : '保存'}
</button>
</>
)}
</div>
</div>
{/* Manual edit warning */}
{record?.is_manually_edited && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg px-4 py-3 text-sm text-yellow-800">
此記錄曾被手動修改之後重新導入 Excel 不會覆蓋此記錄
</div>
)}
{/* Record Details */}
<div className="card">
<div className="p-4 border-b border-slate-200 bg-slate-50">
<div className="flex items-center gap-3">
<FileText size={24} className="text-slate-400" />
<div>
<div className="font-medium text-slate-900">記錄詳情</div>
<div className="text-sm text-slate-500">Excel 原始數據</div>
</div>
</div>
<h2 className="font-semibold text-slate-700">記錄詳情</h2>
</div>
<div className="divide-y divide-slate-100">
{headers.map(key => (
<div key={key} className="grid grid-cols-12">
{displayFields.map(({ key, label }) => (
<div key={key} className="grid grid-cols-12 items-center">
<div className="col-span-3 px-4 py-3 text-sm font-medium text-slate-500 bg-slate-50">
{key}
{label}
</div>
<div className="col-span-9 px-4 py-3 text-sm text-slate-900">
{record[key] !== null && record[key] !== undefined
? String(record[key])
: '-'}
<div className="col-span-9 px-4 py-3">
{editMode && ['status_code', 'late_minutes', 'early_minutes', 'ot_minutes', 'actual_in', 'actual_out', 'shift_code'].includes(key) ? (
<input
type="text"
value={formData[key]}
onChange={(e) => setFormData({ ...formData, [key]: e.target.value })}
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<span className="text-sm text-slate-900">
{record?.[key] !== null && record?.[key] !== undefined ? String(record[key]) : '-'}
</span>
)}
</div>
</div>
))}
+228 -221
View File
@@ -2,231 +2,226 @@ 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'
const handleExportHTML = (section) => {
const token = localStorage.getItem('aars_token')
if (token) {
window.open(`/api/report/${section}/html?token=${token}`, '_blank')
}
}
import { Search, ChevronUp, ChevronDown, Filter, X, Users, Calendar } from 'lucide-react'
import StatusBadge from '../../components/StatusBadge'
export default function AttendanceList() {
const [records, setRecords] = useState([])
const [headers, setHeaders] = useState([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
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 [sortBy, setSortBy] = useState('date')
const [sortOrder, setSortOrder] = useState('desc')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
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()
fetchLateness()
}, [])
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 {
const { data } = await api.get('/api/attendance?page=1&page_size=1000')
setRecords(data.data || [])
if (data.data && data.data.length > 0) {
const keys = Object.keys(data.data[0]).filter(k => k !== '_row')
setHeaders(keys)
}
const params = new URLSearchParams({
page,
per_page: perPage,
sort_by: sortBy,
sort_order: sortOrder,
})
if (search) params.append('search', search)
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_text', statusFilter)
const { data } = await api.get(`/api/attendance/records?${params}`)
setRecords(data.records || [])
setTotal(data.total || 0)
// Extract unique staff names
const names = [...new Set((data.records || []).map(r => r.employee_name).filter(Boolean))]
setStaffList(names.sort())
} catch (err) {
toast.error('Failed to load records')
toast.error('載入失敗')
console.error(err)
} finally {
setLoading(false)
}
}
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)
}
const handleSearch = (e) => {
e.preventDefault()
setPage(1)
fetchRecords()
}
// 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
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()
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, filterKey, filterValue])
const handleSort = (key) => {
if (sortKey === key) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
if (sortBy === key) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
} else {
setSortKey(key)
setSortDir('asc')
setSortBy(key)
setSortOrder('asc')
}
}
const handleFilter = (key) => {
setFilterKey(key)
setFilterValue('')
}
const clearFilter = () => {
setFilterKey('')
setFilterValue('')
setPage(1)
}
const getSortIcon = (key) => {
if (sortKey !== key) return null
return sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
if (sortBy !== key) return null
return sortOrder === '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]
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) => {
// 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'
}
const totalPages = Math.ceil(total / perPage)
const columns = [
{ key: 'employee_name', label: '員工' },
{ key: 'date', label: '日期' },
{ key: 'weekday', label: '星期' },
{ key: 'shift_code', label: '班次' },
{ key: 'actual_in', label: '上班' },
{ key: 'actual_out', label: '下班' },
{ key: 'expected_in', label: '應上班' },
{ key: 'expected_out', label: '應下班' },
]
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>
<h1 className="text-lg sm: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"
>
<Link to="/" className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 text-sm rounded-md hover:bg-slate-200">
<Calendar size={16} /> Dashboard
</Link>
<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="card p-4 space-y-3">
{/* Search row */}
<form onSubmit={handleSearch} className="flex flex-wrap gap-3 items-end">
<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="搜尋所有欄位..."
placeholder="搜尋員工姓名..."
className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-md"
/>
</div>
{/* Column Filter */}
<button type="submit" className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700">
搜尋
</button>
</form>
{/* Filter row */}
<div className="flex flex-wrap gap-3 items-end">
<div className="flex items-center gap-2">
<Filter size={16} className="text-slate-400" />
<span className="text-xs text-slate-500">日期:</span>
<input
type="date"
value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
placeholder="由"
/>
<span className="text-slate-400">-</span>
<input
type="date"
value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
placeholder="至"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">員工:</span>
<select
value={filterKey}
onChange={(e) => handleFilter(e.target.value)}
className="text-sm border border-slate-300 rounded-md px-2 py-2"
value={staffFilter}
onChange={(e) => { setStaffFilter(e.target.value); setPage(1) }}
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
>
<option value="">篩選欄位</option>
{headers.slice(0, 6).map(h => (
<option key={h} value={h}>{h}</option>
<option value="">全部</option>
{staffList.map(s => (
<option key={s} value={s}>{s}</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">
<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>
)}
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">狀態:</span>
<select
value={statusFilter}
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>
{statusOptions.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
<button
onClick={() => {
setDateFrom(''); setDateTo(''); setStaffFilter(''); setStatusFilter(''); setSearch(''); setPage(1)
// Force refetch in case useEffect timing misses the state change.
setTimeout(() => fetchRecords(), 0)
}}
className="flex items-center gap-1 px-3 py-1.5 text-xs text-slate-500 hover:text-slate-700 border border-slate-300 rounded-md"
>
<X size={14} /> 清除篩選
</button>
</div>
{/* Stats bar */}
<div className="flex items-center gap-4 text-xs text-slate-500">
<span>{total} 筆記錄</span>
{staffFilter && <span> 員工: {staffFilter}</span>}
{statusFilter && <span> 狀態: {statusFilter}</span>}
{(dateFrom || dateTo) && <span> {dateFrom || '...'} {dateTo || '...'}</span>}
</div>
</div>
@@ -236,81 +231,93 @@ export default function AttendanceList() {
<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)}
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-600 w-12">#</th>
{columns.map(col => (
<th
key={col.key}
onClick={() => handleSort(col.key)}
className="px-3 py-2.5 text-left text-xs font-semibold text-slate-600 cursor-pointer hover:bg-slate-200"
>
<div className="flex items-center gap-1">
{h}
{getSortIcon(h)}
{col.label}
{getSortIcon(col.key)}
</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>
<th className="px-3 py-2.5 text-center text-xs font-semibold text-slate-600">狀態</th>
<th className="px-3 py-2.5 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 colSpan={10} className="px-3 py-8 text-center text-slate-400">
載入中...
</td>
</tr>
) : processedData.length === 0 ? (
) : records.length === 0 ? (
<tr>
<td colSpan={9} className="px-3 py-6 text-center text-slate-400">
<td colSpan={10} className="px-3 py-8 text-center text-slate-400">
沒有記錄
</td>
</tr>
) : (
processedData.map((record) => {
const lateInfo = getLatenessInfo(record)
const isLate = lateInfo && lateInfo.minutes_late > 0
return (
<tr
key={record._row}
className={`hover:bg-slate-50 cursor-pointer ${isLate ? 'bg-orange-50' : ''}`}
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">
{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>
)}
</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>
)
})
records.map((record, idx) => (
<tr
key={record.id || idx}
className={getRowClass(record.status_code)}
>
<td className="px-3 py-2.5 text-xs text-slate-400">{(page - 1) * perPage + idx + 1}</td>
<td className="px-3 py-2.5 text-slate-700 font-medium">{record.employee_name || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.date || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.weekday || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.shift_code || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.actual_in || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.actual_out || '-'}</td>
<td className="px-3 py-2.5 text-slate-400 text-xs">{record.expected_in || '-'}</td>
<td className="px-3 py-2.5 text-slate-400 text-xs">{record.expected_out || '-'}</td>
<td className="px-3 py-2.5 text-center">
{getStatusBadge(record)}
</td>
<td className="px-3 py-2.5 text-right">
<Link
to={`/attendance/${record.id}`}
className="p-1 text-slate-400 hover:text-blue-600 inline-block"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
<span className="text-xs text-slate-500">
{page} / {totalPages} {total} 筆記錄
</span>
<div className="flex gap-2">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-3 py-1 text-xs border border-slate-300 rounded-md disabled:opacity-50 hover:bg-slate-100"
>
上一頁
</button>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="px-3 py-1 text-xs border border-slate-300 rounded-md disabled:opacity-50 hover:bg-slate-100"
>
下一頁
</button>
</div>
</div>
)}
</div>
</div>
)
+986
View File
@@ -0,0 +1,986 @@
import { useState, useEffect, useRef } from 'react'
import api from '../../api'
import toast from 'react-hot-toast'
import {
Plus, Edit2, Trash2, RefreshCw, Upload, Download, Calendar as CalIcon,
Users, X, ChevronDown, ChevronUp, Filter,
} from 'lucide-react'
import StatusBadge from '../../components/StatusBadge'
const LEAVE_TYPES = [
{ value: 'SL', label: '病假 SL', color: 'pink' },
{ value: 'CL', label: '補鐘 CL', color: 'indigo' },
{ value: 'AL', label: '年假 AL', color: 'emerald' },
]
const CURRENT_YEAR = new Date().getFullYear()
const YEAR_OPTIONS = [CURRENT_YEAR - 1, CURRENT_YEAR, CURRENT_YEAR + 1, CURRENT_YEAR + 2]
export default function HolidaysPage() {
const [tab, setTab] = useState('holidays')
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">假期 Holidays</h1>
<p className="text-sm text-slate-500 mt-1">
管理公眾假期 + 員工請假 (SL 病假 / CL 補鐘 / AL 年假)
</p>
</div>
</div>
{/* Tabs */}
<div className="border-b border-slate-200">
<nav className="flex gap-4">
<button
onClick={() => setTab('holidays')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === 'holidays'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
<CalIcon size={16} className="inline mr-1.5" />
公眾假期
</button>
<button
onClick={() => setTab('leaves')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === 'leaves'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
<Users size={16} className="inline mr-1.5" />
員工請假
</button>
</nav>
</div>
{tab === 'holidays' ? <HolidaysTab /> : <LeavesTab />}
</div>
)
}
// ============ Tab 1: 公眾假期 ============
function HolidaysTab() {
const [year, setYear] = useState(CURRENT_YEAR)
const [holidays, setHolidays] = useState([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState(null) // Holiday object or null
const [showCreate, setShowCreate] = useState(false)
useEffect(() => {
load()
}, [year])
async function load() {
setLoading(true)
try {
const { data } = await api.get(`/api/holidays?year=${year}`)
setHolidays(data)
} catch (err) {
toast.error(err.response?.data?.detail || '載入失敗')
} finally {
setLoading(false)
}
}
async function handleRefresh() {
if (!confirm(`從 gov.hk 重新抓取 ${year} 年假期?\n(手動編輯嘅假期唔會被覆寫)`)) return
try {
const { data } = await api.post(`/api/holidays/refresh?years=${year}`)
toast.success(`${year} 已同步:新增 ${data.added} / 更新 ${data.updated} / 跳過 ${data.skipped}`)
load()
} catch (err) {
toast.error(err.response?.data?.detail || '同步失敗')
}
}
async function handleDelete(h) {
if (!confirm(`確定要刪除 ${h.date}${h.name}」?`)) return
try {
await api.delete(`/api/holidays/${h.id}`)
toast.success('已刪除')
load()
} catch (err) {
toast.error(err.response?.data?.detail || '刪除失敗')
}
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="bg-white border border-slate-200 rounded-lg p-4 flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-slate-700">年份:</label>
<select
value={year}
onChange={(e) => setYear(Number(e.target.value))}
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
>
{YEAR_OPTIONS.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div className="flex-1" />
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700"
>
<Plus size={14} /> 加公假
</button>
<button
onClick={handleRefresh}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-300 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-50"
>
<RefreshCw size={14} /> GovHK 同步
</button>
</div>
{/* Table */}
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
{loading ? (
<div className="p-8 text-center text-slate-400">載入中...</div>
) : holidays.length === 0 ? (
<div className="p-8 text-center text-slate-400">{year} 年冇假期資料</div>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">日期</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">名稱 ()</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">Name (EN)</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">類型</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">來源</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">備註</th>
<th className="px-4 py-2.5 text-right font-medium text-slate-700">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{holidays.map(h => (
<tr key={h.id} className="hover:bg-slate-50">
<td className="px-4 py-2 font-mono text-slate-700">{h.date}</td>
<td className="px-4 py-2 font-medium text-slate-900">{h.name}</td>
<td className="px-4 py-2 text-slate-600">{h.name_en || '-'}</td>
<td className="px-4 py-2">
{h.is_mandatory ? (
<span className="inline-block px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-700">法定</span>
) : (
<span className="inline-block px-2 py-0.5 rounded text-xs bg-slate-100 text-slate-600">銀行/學校</span>
)}
</td>
<td className="px-4 py-2 text-xs text-slate-500">{h.source || '-'}</td>
<td className="px-4 py-2 text-slate-500 text-xs">{h.notes || '-'}</td>
<td className="px-4 py-2 text-right">
<button
onClick={() => setEditing(h)}
className="p-1 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded"
title="編輯"
>
<Edit2 size={14} />
</button>
<button
onClick={() => handleDelete(h)}
className="p-1 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded ml-1"
title="刪除"
>
<Trash2 size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="text-xs text-slate-500 text-center">
{holidays.length} 個公眾假期 · 來源<a className="text-primary-600 underline" href="https://www.gov.hk/en/about/abouthk/holiday/" target="_blank" rel="noreferrer">gov.hk</a>
</div>
{(showCreate || editing) && (
<HolidayModal
holiday={editing}
onClose={() => { setShowCreate(false); setEditing(null) }}
onSaved={() => { setShowCreate(false); setEditing(null); load() }}
/>
)}
</div>
)
}
// ============ Tab 2: 員工請假 ============
function LeavesTab() {
const [leaves, setLeaves] = useState([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState(null)
const [showCreate, setShowCreate] = useState(false)
const [filterType, setFilterType] = useState('')
const [filterEmp, setFilterEmp] = useState('')
const [filterFrom, setFilterFrom] = useState('')
const [filterTo, setFilterTo] = useState('')
const fileInputRef = useRef(null)
useEffect(() => {
load()
}, [])
async function load() {
setLoading(true)
try {
const params = new URLSearchParams()
if (filterType) params.set('leave_type', filterType)
if (filterEmp) params.set('employee_name', filterEmp)
if (filterFrom) params.set('date_from', filterFrom)
if (filterTo) params.set('date_to', filterTo)
const { data } = await api.get(`/api/leave?${params}`)
setLeaves(data)
} catch (err) {
toast.error(err.response?.data?.detail || '載入失敗')
} finally {
setLoading(false)
}
}
async function handleDelete(lv) {
if (!confirm(`確定要刪除 ${lv.employee_name} ${lv.date} ${lv.leave_type}${lv.hours}h`)) return
try {
await api.delete(`/api/leave/${lv.id}`)
toast.success('已刪除')
load()
} catch (err) {
toast.error(err.response?.data?.detail || '刪除失敗')
}
}
async function handleUpload(e) {
const f = e.target.files?.[0]
if (!f) return
const fd = new FormData()
fd.append('file', f)
try {
const { data } = await api.post('/api/leave/upload', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const errMsg = data.errors?.length ? `\n錯誤 (前 ${data.errors.length} 條):\n${data.errors.join('\n')}` : ''
toast.success(`✓ 新增 ${data.added} 條 / 跳過 ${data.skipped}${errMsg}`, { duration: 6000 })
load()
} catch (err) {
toast.error(err.response?.data?.detail || '上傳失敗')
} finally {
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
function downloadTemplate() {
const csv = 'employee_name,date,leave_type,hours,reason,approved_by\nJay Lam,2026-07-15,SL,2.0,睇醫生,\nJay Lam,2026-07-20,AL,8.0,Family trip,Manager A\n'
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'leave_template.csv'
a.click()
URL.revokeObjectURL(url)
}
// 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 (
<div className="space-y-4">
{/* Filters */}
<div className="bg-white border border-slate-200 rounded-lg p-4 flex flex-wrap items-center gap-3">
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
>
<option value="">所有類型</option>
{LEAVE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<input
type="date"
value={filterFrom}
onChange={(e) => setFilterFrom(e.target.value)}
placeholder="由"
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
/>
<input
type="date"
value={filterTo}
onChange={(e) => setFilterTo(e.target.value)}
placeholder="至"
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
/>
<button
onClick={load}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-200"
>
<Filter size={14} /> 套用
</button>
<button
onClick={() => { setFilterType(''); setFilterEmp(''); setFilterFrom(''); setFilterTo(''); setTimeout(load, 0) }}
className="inline-flex items-center gap-1 px-2 py-1.5 text-slate-500 hover:text-slate-700 text-sm"
>
<X size={14} />
</button>
<div className="flex-1" />
<button
onClick={downloadTemplate}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-300 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-50"
>
<Download size={14} /> CSV 範本
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-300 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-50"
>
<Upload size={14} /> 上傳 CSV/Excel
</button>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.xls"
onChange={handleUpload}
className="hidden"
/>
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700"
>
<Plus size={14} /> 加請假
</button>
</div>
{/* Table */}
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
{loading ? (
<div className="p-8 text-center text-slate-400">載入中...</div>
) : leaves.length === 0 ? (
<div className="p-8 text-center text-slate-400">冇請假記錄</div>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">員工</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">日期</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">類型</th>
<th className="px-4 py-2.5 text-right font-medium text-slate-700">時數</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">原因</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">審批人</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">來源</th>
<th className="px-4 py-2.5 text-right font-medium text-slate-700">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{leaves.map(lv => (
<tr key={lv.id} className="hover:bg-slate-50">
<td className="px-4 py-2 font-medium text-slate-900">{lv.employee_name}</td>
<td className="px-4 py-2 font-mono text-slate-700">{lv.date}</td>
<td className="px-4 py-2">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${
lv.leave_type === 'SL' ? 'bg-pink-100 text-pink-700' :
lv.leave_type === 'CL' ? 'bg-indigo-100 text-indigo-700' :
'bg-emerald-100 text-emerald-700'
}`}>
{LEAVE_TYPES.find(t => t.value === lv.leave_type)?.label || lv.leave_type}
</span>
</td>
<td className="px-4 py-2 text-right tabular-nums">{lv.hours}h</td>
<td className="px-4 py-2 text-slate-600 text-xs">{lv.reason || '-'}</td>
<td className="px-4 py-2 text-slate-600 text-xs">{lv.approved_by || '-'}</td>
<td className="px-4 py-2 text-xs text-slate-500">{lv.source || '-'}</td>
<td className="px-4 py-2 text-right">
<button
onClick={() => setEditing(lv)}
className="p-1 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded"
title="編輯"
>
<Edit2 size={14} />
</button>
<button
onClick={() => handleDelete(lv)}
className="p-1 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded ml-1"
title="刪除"
>
<Trash2 size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="text-xs text-slate-500 text-center">
{leaves.length} 條請假記錄
</div>
{(showCreate || editing) && (
<LeaveModal
leave={editing}
employees={employees}
onClose={() => { setShowCreate(false); setEditing(null) }}
onSaved={() => { setShowCreate(false); setEditing(null); load() }}
/>
)}
</div>
)
}
// ============ Holiday Modal ============
function HolidayModal({ holiday, onClose, onSaved }) {
const isEdit = !!holiday
const [date, setDate] = useState(holiday?.date || '')
const [name, setName] = useState(holiday?.name || '')
const [nameEn, setNameEn] = useState(holiday?.name_en || '')
const [isMandatory, setIsMandatory] = useState(holiday?.is_mandatory ?? true)
const [notes, setNotes] = useState(holiday?.notes || '')
const [saving, setSaving] = useState(false)
async function handleSave() {
if (!date || !name) {
toast.error('日期同名稱係必填')
return
}
setSaving(true)
try {
if (isEdit) {
await api.put(`/api/holidays/${holiday.id}`, {
date, name, name_en: nameEn, is_mandatory: isMandatory, notes,
})
toast.success('已更新')
} else {
await api.post('/api/holidays', {
date, name, name_en: nameEn, is_mandatory: isMandatory, notes,
})
toast.success('已新增')
}
onSaved()
} catch (err) {
toast.error(err.response?.data?.detail || '儲存失敗')
} finally {
setSaving(false)
}
}
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-md" onClick={(e) => e.stopPropagation()}>
<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>
<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-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">日期 <span className="text-red-500">*</span></label>
<input
type="date"
value={date}
onChange={(e) => setDate(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">名稱 (中文) <span className="text-red-500">*</span></label>
<input
type="text"
value={name}
onChange={(e) => setName(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">Name (English)</label>
<input
type="text"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
placeholder="The first day of January"
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="mandatory"
checked={isMandatory}
onChange={(e) => setIsMandatory(e.target.checked)}
className="rounded"
/>
<label htmlFor="mandatory" className="text-sm text-slate-700">
法定假日 (勞工假)
</label>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">備註</label>
<input
type="text"
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
</div>
<div className="px-5 py-3 border-t border-slate-200 flex justify-end 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={handleSave}
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 ? '儲存中...' : (isEdit ? '更新' : '新增')}
</button>
</div>
</div>
</div>
)
}
// ============ Leave Modal (multi-row create, single edit) ============
function LeaveModal({ leave, employees, onClose, onSaved }) {
const isEdit = !!leave
// Edit-mode state
const [employeeName, setEmployeeName] = useState(leave?.employee_name || '')
const [date, setDate] = useState(leave?.date || '')
const [leaveType, setLeaveType] = useState(leave?.leave_type || 'SL')
const [hours, setHours] = useState(leave?.hours ?? 8)
const [reason, setReason] = useState(leave?.reason || '')
const [approvedBy, setApprovedBy] = useState(leave?.approved_by || '')
const [saving, setSaving] = useState(false)
// 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) {
toast.error('員工 / 日期 / 類型 係必填')
return
}
const h = Number(hours)
if (isNaN(h) || h < 0 || h > 24) {
toast.error('時數必須 0-24')
return
}
setSaving(true)
try {
await api.put(`/api/leave/${leave.id}`, {
employee_name: employeeName, date, leave_type: leaveType,
hours: h, reason, approved_by: approvedBy,
})
toast.success('已更新')
onSaved()
} catch (err) {
toast.error(err.response?.data?.detail || '儲存失敗')
} finally {
setSaving(false)
}
}
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 (
<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="px-5 py-3 border-b border-slate-200 flex items-center justify-between">
<h3 className="font-semibold text-slate-900">編輯請假</h3>
<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-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={employeeName}
onChange={(e) => setEmployeeName(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(employeeName) && employeeName && (
<option value={employeeName}>{employeeName} ()</option>
)}
</select>
) : (
<input
type="text"
value={employeeName}
onChange={(e) => setEmployeeName(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={date}
onChange={(e) => setDate(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">類型 <span className="text-red-500">*</span></label>
<div className="flex gap-2">
{LEAVE_TYPES.map(t => (
<button
key={t.value}
type="button"
onClick={() => setLeaveType(t.value)}
className={`flex-1 px-3 py-2 rounded-md text-sm font-medium border transition-colors ${
leaveType === 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-300 text-slate-600 hover:bg-slate-50'
}`}
>
{t.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">時數 (0-24) <span className="text-red-500">*</span></label>
<input
type="number"
step="0.5"
min="0"
max="24"
value={hours}
onChange={(e) => setHours(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
<div className="flex gap-1 mt-1">
{[1, 2, 4, 8].map(h => (
<button
key={h}
type="button"
onClick={() => setHours(h)}
className="px-2 py-0.5 text-xs bg-slate-100 hover:bg-slate-200 rounded"
>
{h}h
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">原因</label>
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="睇醫生 / Family trip / 補鐘"
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={approvedBy}
onChange={(e) => setApprovedBy(e.target.value)}
placeholder="Manager A"
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
</div>
<div className="px-5 py-3 border-t border-slate-200 flex justify-end 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={handleSaveEdit}
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 ? '儲存中...' : '更新'}
</button>
</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>
)
}
+1 -1
View File
@@ -84,7 +84,7 @@ export default function RosterPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6">班次管理 Roster</h1>
<h1 className="text-xl sm:text-2xl font-bold mb-4 sm:mb-6">班次管理 Roster</h1>
{/* Upload Section */}
<div className="card p-6 mb-6">
+1 -1
View File
@@ -125,7 +125,7 @@ export default function UploadPage() {
{SECTIONS.map(s => (
<button
key={s.key}
onClick={() => { setSection(s.key); setInfo(null); loadSectionInfo() }}
onClick={() => setSection(s.key)}
className={`px-4 py-2 rounded-md font-medium transition-colors ${
section === s.key
? s.color === 'red' ? 'bg-red-600 text-white' : 'bg-blue-600 text-white'
+1225
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# Daily log cleanup for AARS. Keep app/access for 7 days, error for 30 days.
# Add to crontab: 0 3 * * * /root/aars/scripts/logs-cleanup.sh
set -e
LOG_DIR="/root/aars/logs"
APP_DAYS=7
ERROR_DAYS=30
if [ ! -d "$LOG_DIR" ]; then
echo "$(date): $LOG_DIR not found, nothing to clean"
exit 0
fi
DELETED_APP=$(find "$LOG_DIR" -maxdepth 1 -name 'app-*.jsonl' -mtime +$APP_DAYS -delete -print | wc -l)
DELETED_ACCESS=$(find "$LOG_DIR" -maxdepth 1 -name 'access-*.jsonl' -mtime +$APP_DAYS -delete -print | wc -l)
DELETED_ERROR=$(find "$LOG_DIR" -maxdepth 1 -name 'error-*.jsonl' -mtime +$ERROR_DAYS -delete -print | wc -l)
echo "$(date): cleanup done — deleted app=$DELETED_APP access=$DELETED_ACCESS error=$DELETED_ERROR"
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Smoke test: /api/auth/users + /api/auth/reset-password."""
import json
import sys
import urllib.request
BASE = "http://localhost:8000"
def req(method, path, body=None, token=None):
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
data = json.dumps(body).encode() if body else None
r = urllib.request.Request(f"{BASE}{path}", data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(r, timeout=10) as resp:
return resp.status, json.loads(resp.read().decode() or "{}")
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read().decode() or "{}")
# 1. Login
code, body = req("POST", "/api/auth/login", {"email": "admin@aars.hk", "password": "admin123"})
if code != 200:
print(f"LOGIN FAILED: {code} {body}")
sys.exit(1)
token = body["access_token"]
print(f"✅ login ok, token={token[:30]}...")
# 2. List users
code, body = req("GET", "/api/auth/users", token=token)
print(f"\n--- GET /api/auth/users (code={code}) ---")
print(json.dumps(body, indent=2, ensure_ascii=False))
# 3. Reset password (no actual change, just smoke test)
code, body = req("POST", "/api/auth/reset-password",
{"email": "admin@aars.hk", "new_password": "admin123"},
token=token)
print(f"\n--- POST /api/auth/reset-password (code={code}) ---")
print(json.dumps(body, indent=2, ensure_ascii=False))
# 4. Verify login still works
code, body = req("POST", "/api/auth/login", {"email": "admin@aars.hk", "password": "admin123"})
print(f"\n--- verify login (code={code}) ---")
if code == 200:
print("✅ login still works after reset")
else:
print(f"❌ login broken: {body}")