Compare commits

..

39 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
16 changed files with 3118 additions and 280 deletions
+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)
+468 -60
View File
@@ -1,14 +1,15 @@
import os
import secret
from fastapi import FastAPI, Depends, HTTPException, Query, UploadFile, File, Form, Request
from fastapi import FastAPI, Depends, HTTPException, Query, UploadFile, File, Form, Request, Body
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy.orm import Session
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Optional, List
import json
from database import get_db, create_access_token, verify_password, init_db, Base, engine
from database import get_db, create_access_token, verify_password, verify_and_update_password, get_password_hash, init_db, Base, engine
from models import User, AttendanceRecord, Shift, Accident, Holiday, LeaveRecord
from sqlalchemy import func as sqlfunc
from schemas import *
@@ -125,8 +126,40 @@ def enrich_attendance_with_leave(records, db):
if not r.date:
continue
# Rest day detection: roster says the shift rests on this weekday
# (e.g. Sunday for S2/S3) but raw status is 'missing'. Don't flag as 缺勤
# when the employee wasn't scheduled to work.
if (r.status_code or '').lower() == 'missing' and r.shift_code and r.weekday:
shift_info = get_shift_schedule_full(r.shift_code, r.weekday)
if shift_info is None:
r.status_code = 'rest'
r.status_text = '休息'
continue
if r.date in holidays_by_date:
h = holidays_by_date[r.date]
# Check roster: if shift's public_holiday == 'Yes', employee works on holidays
# Don't override with holiday status in that case
shift_holiday = '休息' # default: assume rest on holiday
if r.shift_code:
shift_info = get_shift_schedule_full(r.shift_code, r.weekday or '')
if shift_info and len(shift_info) >= 3:
shift_holiday = shift_info[2]
if shift_holiday == 'Yes':
# Employee works on public holidays - keep attendance status, append holiday note
base = r.status_text or "正常"
r.status_text = f"{base} (🎉{h.name})"
continue
# Employee rests on public holidays - mark as holiday unless already has clock-in
has_clock_in = (
(r.check_in is not None) or
(r.actual_in and r.actual_in not in ("", "-"))
)
if not has_clock_in:
r.status_code = "holiday"
r.status_text = f"\U0001F3D6\uFE0F{h.name}"
continue
# Has clock-in but roster says rest on holiday - still show holiday
r.status_code = "holiday"
r.status_text = f"\U0001F3D6\uFE0F{h.name}"
continue
@@ -135,13 +168,19 @@ def enrich_attendance_with_leave(records, db):
if not emp_leaves:
continue
# Aggregate all leaves by type (sum hours when multiple records of same type)
leave_by_type = {}
for lv in emp_leaves:
t = (lv.leave_type or "").upper()
h_val = float(lv.hours or 0)
leave_by_type[t] = leave_by_type.get(t, 0.0) + h_val
leave_text = " + ".join(f"{t}{h}h" for t, h in sorted(leave_by_type.items()))
# Build display text from aggregated values (no duplicates)
leave_parts = []
for t, h in sorted(leave_by_type.items()):
if h > 0:
leave_parts.append(f"{t}{h:.1f}h" if h != int(h) else f"{t}{int(h)}h")
leave_text = " + ".join(leave_parts)
original_code = (r.status_code or "").lower()
if original_code == "missing":
@@ -151,7 +190,23 @@ def enrich_attendance_with_leave(records, db):
else:
r.status_code = "mixed_leave"
r.status_text = leave_text
elif original_code in ("al", "sl", "cl"):
# Pure leave status: replace with aggregated leave text
if len(leave_by_type) > 1:
r.status_code = "mixed_leave"
r.status_text = leave_text
else:
r.status_text = leave_text
elif original_code not in ("missing", "", "holiday", "al", "sl", "cl"):
# Non-leave attendance status: strip any previously-enriched leave suffix and rebuild
base_text = (r.status_text or "正常").split(" + ")[0] # keep only attendance part
base_text = base_text.split(" (")[0] # strip holiday annotation
if leave_text:
r.status_text = f"{base_text} + {leave_text}"
else:
r.status_text = base_text
else:
# original_code is empty or holiday or already handled above
base = r.status_text or "正常"
r.status_text = f"{base} + {leave_text}"
@@ -199,9 +254,20 @@ async def startup():
@app.post("/api/auth/login", response_model=TokenResponse)
async def login(form_data: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == form_data.email).first()
if not user or not verify_password(form_data.password, user.password_hash):
if not user:
logger.warning("login failed", extra={"context": {"email": form_data.email, "reason": "user_not_found"}})
raise HTTPException(status_code=401, detail="Invalid email or password")
# verify_and_update returns (ok, new_hash_if_needs_rehash)
ok, new_hash = verify_and_update_password(form_data.password, user.password_hash)
if not ok:
logger.warning("login failed", extra={"context": {"email": form_data.email, "reason": "invalid_credentials"}})
raise HTTPException(status_code=401, detail="Invalid email or password")
# Migrate hash scheme in place (e.g. sha256_crypt -> bcrypt) without forcing logout
if new_hash:
user.password_hash = new_hash
db.commit()
db.refresh(user)
logger.info("password hash upgraded", extra={"context": {"user_id": user.id, "email": form_data.email}})
set_user_id(user.id)
access_token = create_access_token({"sub": str(user.id)})
logger.info("login success", extra={"context": {"user_id": user.id, "email": form_data.email}})
@@ -211,6 +277,64 @@ async def login(form_data: LoginRequest, db: Session = Depends(get_db)):
async def get_me(current_user: User = Depends(get_current_user)):
return current_user
class ResetPasswordRequest(BaseModel):
email: str
new_password: str
@app.post("/api/auth/reset-password")
async def reset_password(
payload: ResetPasswordRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
"""Admin-only: reset another user's password.
Body: {"email": "<target email>", "new_password": "<new plaintext>"}
"""
if not payload.email or not payload.new_password:
raise HTTPException(status_code=400, detail="email and new_password are required")
if len(payload.new_password) < 6:
raise HTTPException(status_code=400, detail="new_password must be at least 6 characters")
target = db.query(User).filter(User.email == payload.email).first()
if not target:
raise HTTPException(status_code=404, detail=f"User not found: {payload.email}")
target.password_hash = get_password_hash(payload.new_password)
db.commit()
db.refresh(target)
logger.info(
"password reset by admin",
extra={"context": {"admin_id": current_user.id, "target_user_id": target.id, "email": payload.email}},
)
return {
"ok": True,
"email": target.email,
"id": target.id,
"reset_by": current_user.email,
}
@app.get("/api/auth/users")
async def list_users(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
"""Admin-only: list all users (id, email, name, role, is_active)."""
users = db.query(User).order_by(User.id.asc()).all()
return [
{
"id": u.id,
"email": u.email,
"name": u.name,
"role": u.role,
"is_active": u.is_active,
}
for u in users
]
# ============ Excel File Storage ============
def get_excel_path(section: str) -> str:
"""Get path to the stored Excel file for a section"""
@@ -254,7 +378,7 @@ async def upload_excel(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Upload Excel file saves file AND imports rows into SQL table."""
"""Upload Excel file - saves file AND imports rows into SQL table."""
if section not in ["attendance", "accident"]:
raise HTTPException(status_code=400, detail="Invalid section")
@@ -487,7 +611,8 @@ async def _import_attendance_to_db(db: Session, headers, rows, user_id: int) ->
# ============ Attendance Calculation ============
def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]:
"""Get the expected start and end time for a shift on a given weekday.
Returns (start_time, end_time) tuple.
Returns (start_time, end_time, public_holiday) tuple.
public_holiday is 'Yes' if the shift requires working on public holidays, '休息' otherwise.
"""
roster_path = get_excel_path("roster")
if not os.path.exists(roster_path):
@@ -501,10 +626,12 @@ def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]:
# Find column indices
shift_col = None
ph_col = None
for i, h in enumerate(headers):
if h == '班次':
shift_col = i
break
elif h == 'public holiday':
ph_col = i
if shift_col is None:
return None
@@ -532,6 +659,7 @@ def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]:
# Find the shift row
for row in ws.iter_rows(min_row=2, values_only=True):
if row[shift_col] == shift_code:
public_holiday = str(row[ph_col]).strip() if ph_col is not None else '休息'
day_col = day_col_map.get(target_day)
if day_col is not None:
schedule = row[day_col]
@@ -544,7 +672,7 @@ def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]:
end_str = parts[1].strip()
start_time = datetime.strptime(start_str, '%H:%M').time()
end_time = datetime.strptime(end_str, '%H:%M').time()
return (start_time, end_time)
return (start_time, end_time, public_holiday)
except:
pass
@@ -656,13 +784,17 @@ def calculate_attendance_status(check_in, check_out, shift_start, shift_end):
expected_end_dt = datetime.combine(check_out_dt.date(), shift_end)
if check_out_dt < expected_end_dt:
diff = (expected_end_dt - check_out_dt).total_seconds() / 60
result["early_minutes"] = round(diff)
diff_seconds = (expected_end_dt - check_out_dt).total_seconds()
result["early_minutes"] = round(diff_seconds / 60) if diff_seconds >= 30 else 0
# Calculate OT minutes (leaving after scheduled end)
if check_out_dt > expected_end_dt:
diff = (check_out_dt - expected_end_dt).total_seconds() / 60
result["ot_minutes"] = round(diff)
diff_seconds = (check_out_dt - expected_end_dt).total_seconds()
# Ignore tiny differences (< 30 seconds) to avoid floating-point noise
if diff_seconds >= 30:
result["ot_minutes"] = round(diff_seconds / 60)
else:
result["ot_minutes"] = 0
# Determine status code and text
status_parts = []
@@ -689,6 +821,16 @@ def calculate_attendance_status(check_in, check_out, shift_start, shift_end):
result["status_code"] = "normal"
result["status_text"] = " / ".join(status_parts) if status_parts else "正常"
# If late / early / OT > 30 min → mark as abnormal (override after status_text is set)
if result["late_minutes"] > 30 or result["early_minutes"] > 30 or result["ot_minutes"] > 30:
result["status_code"] = "abnormal"
if result["late_minutes"] > 30:
result["status_text"] = f"異常-遲到{result['late_minutes']}"
elif result["early_minutes"] > 30:
result["status_text"] = f"異常-早退{result['early_minutes']}"
else:
result["status_text"] = f"異常-OT{result['ot_minutes']}"
return result
@@ -758,7 +900,16 @@ async def get_lateness_stats(current_user: User = Depends(get_current_user)):
totals["ot"] += status["ot_minutes"]
stats = sorted(employee_stats.values(), key=lambda x: x["late_minutes"], reverse=True)
return {"stats": stats, **totals}
return {
"stats": stats,
"total_late_count": sum(s["late_count"] for s in employee_stats.values()),
"total_late_minutes": totals["late"],
"total_early_count": sum(s["early_count"] for s in employee_stats.values()),
"total_early_minutes": totals["early"],
"total_ot_count": sum(s["ot_count"] for s in employee_stats.values()),
"total_ot_minutes": totals["ot"],
"total_missing_count": totals["missing"],
}
@app.get("/api/attendance/lateness/records")
async def get_lateness_records(
@@ -1003,6 +1154,25 @@ async def dashboard_summary(
if r.employee_name:
staff_set.add(r.employee_name)
# Component counts: union of status_code match AND minutes field
# (handles abnormal override where status_code='abnormal' hides
# the underlying late/early/ot component from by_status)
late_count = (
sum(v for k, v in by_status.items() if "late" in k)
+ sum(1 for r in rows if r.late_minutes and r.late_minutes > 0
and (r.status_code or "").lower() == "abnormal")
)
early_count = (
sum(v for k, v in by_status.items() if "early" in k)
+ sum(1 for r in rows if r.early_minutes and r.early_minutes > 0
and (r.status_code or "").lower() == "abnormal")
)
ot_count = (
sum(v for k, v in by_status.items() if "ot" in k)
+ sum(1 for r in rows if r.ot_minutes and r.ot_minutes > 0
and (r.status_code or "").lower() == "abnormal")
)
return {
# Backend / dashboard canonical fields
"total": len(rows),
@@ -1012,16 +1182,33 @@ async def dashboard_summary(
"by_department": by_department,
"trend": [],
# Frontend-expected fields (alias for legacy UI)
# Component counts: late + early + ot may exceed total because
# a single record can carry multiple status components
# (e.g. status_code='late_ot' contributes to both late and ot).
"total_records": len(rows),
"normal_count": by_status.get("normal", 0),
"late_count": sum(v for k, v in by_status.items() if "late" in k),
"early_count": sum(v for k, v in by_status.items() if "early" in k),
"ot_count": sum(v for k, v in by_status.items() if "ot" in k),
"late_count": late_count,
"early_count": early_count,
"ot_count": ot_count,
"missing_count": by_status.get("missing", 0),
"abnormal_count": by_status.get("abnormal", 0),
"staff_count": len(staff_set),
}
@app.get("/api/attendance/status_texts")
async def attendance_status_texts(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return BASIC status categories for filter dropdown.
The frontend renders only 5 basic statuses (正常/異常/缺勤/公假/請假),
so the dropdown should offer those exact values rather than every
raw status_text variant (e.g. '異常-遲到39分'). Keeping it server-driven
so client and server agree on the filter vocabulary.
"""
return ['正常', '異常', '缺勤', '公假', '請假', '休息']
@app.get("/api/attendance/stats")
async def attendance_stats(
date_from: Optional[str] = None,
@@ -1158,10 +1345,18 @@ async def attendance_records(
date_to: Optional[str] = None,
staff: Optional[str] = None,
status: Optional[str] = None,
status_text: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Paginated records from attendance_records (SQL)."""
"""Paginated records from attendance_records (SQL).
status_text filter runs AFTER enrichment so it matches the badge rendered
from post-enrich status_code. Otherwise missing-day-with-leave records
would surface under '缺勤' but render as '請假'/'公假' badges (mismatch).
Trade-off: status_text/sort_by filter runs in Python after enrich instead
of SQL — fine while dataset stays small (~80 records), log if it grows.
"""
from datetime import datetime as _dt
from sqlalchemy import or_, and_
@@ -1188,20 +1383,74 @@ async def attendance_records(
AttendanceRecord.shift_code.like(like),
))
# sort
sortable = {
# Pre-sort by raw date so enrich-then-filter ordering is deterministic.
sortable_raw = {
"date": AttendanceRecord.date,
"employee_name": AttendanceRecord.employee_name,
"weekday": AttendanceRecord.weekday,
"shift_code": AttendanceRecord.shift_code,
"status_code": AttendanceRecord.status_code,
}
col = sortable.get(sort_by, AttendanceRecord.date)
col = sortable_raw.get(sort_by, AttendanceRecord.date)
q = q.order_by(col.desc() if sort_order == "desc" else col.asc())
total = q.count()
rows = q.offset((page - 1) * per_page).limit(per_page).all()
enrich_attendance_with_leave(rows, db)
all_rows = q.all()
if len(all_rows) > 5000:
logger.warning(
"attendance_records: in-memory filter over %d rows — consider prefiltering via date range",
len(all_rows),
)
# Enrich ALL rows so we can filter by post-enrich status_code.
enrich_attendance_with_leave(all_rows, db)
# Apply status_text filter on enriched status_code (post-leave/holiday override)
if status_text:
abnormal_codes = ('abnormal', 'late', 'early', 'ot',
'late_early', 'late_ot', 'early_ot')
leave_codes = ('al', 'sl', 'cl', 'mixed_leave')
if status_text in ('abnormal', '異常'):
rows = [r for r in all_rows if (r.status_code or '').lower() in abnormal_codes]
elif status_text == 'late':
rows = [r for r in all_rows if (r.status_code or '').lower() in ('late', 'late_early', 'late_ot')]
elif status_text == 'early':
rows = [r for r in all_rows if (r.status_code or '').lower() in ('early', 'late_early', 'early_ot')]
elif status_text == 'ot':
rows = [r for r in all_rows if (r.status_code or '').lower() in ('ot', 'late_ot', 'early_ot')]
elif status_text in ('missing', '缺勤'):
rows = [r for r in all_rows if (r.status_code or '').lower() == 'missing']
elif status_text in ('holiday', '公假'):
# Holiday shift OR raw records carrying a holiday suffix in status_text
# (e.g. '異常 (🎉香港特區成立紀念日)' where the employee worked on the holiday).
rows = [r for r in all_rows
if (r.status_code or '').lower() == 'holiday'
or '🎉' in (r.status_text or '')]
elif status_text in leave_codes:
rows = [r for r in all_rows if (r.status_code or '').lower() == status_text]
elif status_text in ('leave', '請假'):
# Any leave presence: pure leave status_code OR a leave suffix appended
# to a normal/abnormal/ot code (employee worked but took leave mid-day).
# Suffix format produced by enrich: ' + SL2h', ' + AL4h', ' + CL4h', etc.
rows = [r for r in all_rows
if (r.status_code or '').lower() in leave_codes
or any(f'+ {tag}' in (r.status_text or '')
for tag in ('SL', 'AL', 'CL'))]
elif status_text in ('normal', '正常'):
rows = [r for r in all_rows if (r.status_code or '').lower() == 'normal']
elif status_text in ('rest', '休息'):
rows = [r for r in all_rows if (r.status_code or '').lower() == 'rest']
else:
# Fallback to raw status_text substring search
rows = [r for r in all_rows
if r.status_text and status_text in r.status_text]
else:
rows = all_rows
# Python sort by status_code when requested (raw DB only handled the basic columns)
if sort_by == 'status_code':
rows.sort(key=lambda r: (r.status_code or ''), reverse=(sort_order == 'desc'))
total = len(rows)
page_rows = rows[(page - 1) * per_page: (page - 1) * per_page + per_page]
def to_dict(r):
return {
@@ -1227,7 +1476,7 @@ async def attendance_records(
}
return {
"records": [to_dict(r) for r in rows],
"records": [to_dict(r) for r in page_rows],
"total": total,
"page": page,
"per_page": per_page,
@@ -1764,8 +2013,8 @@ async def upload_leaves(
errors.append(f"Row {i+2}: {r['employee_name']} {d} {r['leave_type']} was edited at {existing.last_edited_at.isoformat()} - skipped")
skipped += 1
continue
# Not edited - safe to update
existing.hours = r["hours"]
# Not edited - safe to update (or multiple identical records exist - merge by adding hours)
existing.hours = r["hours"] # overwrite with latest uploaded value
existing.reason = r["reason"]
existing.approved_by = r["approved_by"]
existing.source = "csv_upload"
@@ -1796,6 +2045,151 @@ async def upload_leaves(
# =============================================================================
# Backup / Restore + Version
# =============================================================================
import shutil, hashlib
from datetime import datetime as _dt2
BACKUP_DIR = Path("/app/data/backups")
DB_PATH = "/app/data/aars.db"
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
def _backup_path(name):
ts = _dt2.now().strftime("%Y%m%d_%H%M%S")
return BACKUP_DIR / f"aars_backup_{ts}_{name}.db"
@app.get("/api/admin/backup/list")
async def list_backups(
current_user: User = Depends(get_current_user),
):
"""List all backup files."""
files = []
for p in sorted(BACKUP_DIR.glob("aars_backup_*.db"), reverse=True):
files.append({
"filename": p.name,
"size": p.stat().st_size,
"created": _dt2.fromtimestamp(p.stat().st_mtime).isoformat(),
})
return files
@app.post("/api/admin/backup/create")
async def create_backup(
note: str = "",
current_user: User = Depends(get_current_user),
):
"""Create a manual backup."""
src = Path(DB_PATH)
if not src.exists():
raise HTTPException(status_code=500, detail="Database file not found")
dst = _backup_path("manual")
shutil.copy2(src, dst)
return {"saved": dst.name, "size": dst.stat().st_size, "note": note}
@app.get("/api/admin/backup/download/{filename}")
async def download_backup(
filename: str,
current_user: User = Depends(get_current_user),
):
"""Download a backup file."""
fp = BACKUP_DIR / filename
if not fp.exists() or ".." in filename:
raise HTTPException(status_code=404, detail="Backup not found")
from fastapi.responses import FileResponse
return FileResponse(fp, media_type="application/x-sqlite3", filename=filename)
@app.post("/api/admin/backup/restore")
async def restore_backup(
filename: str = Body(...),
current_user: User = Depends(get_current_user),
):
"""Restore from backup. Creates an auto-backup of current state first."""
fp = BACKUP_DIR / filename
if not fp.exists() or ".." in filename:
raise HTTPException(status_code=404, detail="Backup not found")
# Auto-backup current state
src = Path(DB_PATH)
if src.exists():
auto_dst = _backup_path("auto_pre_restore")
shutil.copy2(src, auto_dst)
# Restore
shutil.copy2(fp, src)
return {"restored": filename, "auto_backup": auto_dst.name if src.exists() else None}
@app.delete("/api/admin/backup/{filename}")
async def delete_backup(
filename: str,
current_user: User = Depends(get_current_admin),
):
"""Delete a backup file."""
fp = BACKUP_DIR / filename
if not fp.exists() or ".." in filename:
raise HTTPException(status_code=404, detail="Backup not found")
fp.unlink()
return {"deleted": filename}
@app.post("/api/admin/reset/{section}")
async def reset_section(
section: str,
current_user: User = Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""Reset (truncate) a specific data section. Creates auto-backup first."""
single_models = {"attendance": AttendanceRecord, "accident": Accident}
if section not in single_models and section not in ("holidays", "leaves"):
raise HTTPException(status_code=400, detail=f"Unknown section: {section}")
# Auto-backup before reset
src = Path(DB_PATH)
auto_dst = None
if src.exists():
auto_dst = _backup_path(f"auto_pre_{section}_reset")
shutil.copy2(src, auto_dst)
# Truncate table(s)
if section == "holidays":
db.execute(Holiday.__table__.delete())
db.execute(LeaveRecord.__table__.delete())
elif section == "leaves":
db.execute(LeaveRecord.__table__.delete())
else:
db.execute(single_models[section].__table__.delete())
db.commit()
return {"reset": section, "auto_backup": auto_dst.name if auto_dst else None}
@app.get("/api/version")
async def get_version(
current_user: User = Depends(get_current_user),
):
"""Get app version info."""
import subprocess
try:
result = subprocess.run(
["git", "log", "-1", "--format=%H|%cd", "--date=iso"],
cwd="/app",
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
parts = result.stdout.strip().split("|")
commit = parts[0]
date = parts[1] if len(parts) > 1 else ""
else:
commit = "unknown"
date = ""
except Exception:
commit = "unknown"
date = ""
return {
"version": "1.3.0",
"commit": commit,
"date": date,
}
@app.get("/api/{section}")
async def list_records(
section: str,
@@ -1914,7 +2308,7 @@ async def _import_accident_to_db(db: Session, headers, rows, user_id: int) -> di
Col 8 涉及的患者或相關人員 -> patient_or_staff
Col 9 事件描述 -> long_description
Col 10 如有事件相關照片可提供-> incident_photos
Col 11 處理情況已採取的行動-> action_taken
Col 11 處理情況/已採取的行動-> action_taken
Col 12 需要管理層介入的事項 -> needs_escalation
Col 13 第 12 欄 -> (skipped)
Col 14 分數 -> incident_score
@@ -2632,7 +3026,7 @@ async def accident_export_excel(
# User types case_no in C5 of 個案查詢 sheet.
# xlsxwriter requires single quotes around sheet names containing non-ASCII.
# Use proper xlsxwriter hyperlink element (write_url with internal:Sheet!Cell).
# HYPERLINK formula would cache value 0 write_url sets display text directly.
# HYPERLINK formula would cache value 0 - write_url sets display text directly.
overview.write_url(row_n, 6,
f"internal:個案查詢!A1",
link_fmt,
@@ -2640,7 +3034,7 @@ async def accident_export_excel(
tip=f"跳到「個案查詢」輸入個案 ID {r.id}")
overview.set_row(row_n, 18)
# ============ Charts block 統計圖表 (Sheet 1 總覽 右側 columns I-N) ============
# ============ Charts block - 統計圖表 (Sheet 1 總覽 右側 columns I-N) ============
# 4 charts: Severity Pie, Severity Column, Department Bar TOP 10, Monthly Trend Line
# Position: I column (right of KPI + 個案索引 blocks), so no overlap with case index.
@@ -2750,7 +3144,7 @@ async def accident_export_excel(
# ============ Sheet 3 (built FIRST so detail-VLOOKUP can reference it): 明細 ============
# Build it now so that 個案查詢 formulas can resolve its range properly.
# However, xlsxwriter's defined_name allows forward reference; formula text is
# what matters at runtime even if the sheet is added later, the formula string
# what matters at runtime - even if the sheet is added later, the formula string
# '明細!A:O' will be valid when Excel opens the file.
# We define 明細 here so that the formula range 明細!A2:O{max_row} resolves.
# But xlsxwriter requires sheets to be added in the order they're defined.
@@ -2810,12 +3204,12 @@ async def accident_export_excel(
"align": "center", "num_format": "@"})
# B5 = label only (NOT merged with C5). Otherwise user-typed text goes to
# B5 (top-left of merge) instead of C5 where the formula references.
detail.write("B5", "輸入個案 ID", input_label_fmt)
detail.write("B5", "輸入個案 ID:", input_label_fmt)
# C5 = case_no input cell (NOT merged)
detail.write_string("C5", "", text_input_fmt)
# Hint
detail.merge_range("B6:C6",
"提示個案 ID 喺「總覽」個案索引或「明細」表嘅 id 欄。輸入後下面自動顯示。",
"提示:個案 ID 喺「總覽」個案索引或「明細」表嘅 id 欄。輸入後下面自動顯示。",
subtitle_fmt)
# Detail rows: each pulls from 明細!<col><row> via VLOOKUP keyed on id (col A)
@@ -2854,7 +3248,7 @@ async def accident_export_excel(
col_sev = col_index_lookup.get("severity", 6)
col_emp = col_index_lookup.get("employee_name", 3)
col_desc = col_index_lookup.get("description", 5)
# Use INDIRECT or simply use cell reference for ID but ID varies per row.
# Use INDIRECT or simply use cell reference for ID - but ID varies per row.
# Easier: each row's ID is in B<row_n>; build formula with that.
# VLOOKUP needs lookup_value, table, col_index, FALSE.
# table 明細!$A:$O.
@@ -2901,13 +3295,13 @@ async def accident_export_excel(
compare_start_row = list_header_row + n_list_rows + 3
detail.write(f"B{compare_start_row}", "兩個個案 並列比較 Side-by-Side", section_fmt)
detail.write(f"B{compare_start_row + 1}",
"輸入兩個個案 ID (左個案 A, 右個案 B)對比每個欄位。空白表示無資料。",
"輸入兩個個案 ID (左個案 A, 右個案 B),對比每個欄位。空白表示無資料。",
subtitle_fmt)
cmp_label_row = compare_start_row + 2
detail.write(cmp_label_row, 1, "個案 A ID", input_label_fmt)
detail.write(cmp_label_row, 1, "個案 A ID:", input_label_fmt)
detail.write_string(cmp_label_row, 2, "", text_input_fmt)
detail.write(cmp_label_row, 4, "個案 B ID", input_label_fmt)
detail.write(cmp_label_row, 4, "個案 B ID:", input_label_fmt)
detail.write_string(cmp_label_row, 5, "", text_input_fmt)
detail.set_column("F:F", 50)
@@ -2939,7 +3333,7 @@ async def accident_export_excel(
# Hint at top
detail.merge_range(f"B{cmp_data_start + len(detail_fields) + 2}:C{cmp_data_start + len(detail_fields) + 2}",
"💡 全部公式 (VLOOKUP)無需啟用巨集。Excel/Numbers 都正常運作。",
"💡 全部公式 (VLOOKUP),無需啟用巨集。Excel/Numbers 都正常運作。",
subtitle_fmt)
# ============ Sheet 3: 明細 ============
@@ -3204,31 +3598,45 @@ async def export_excel(
earliest = min((r.date for r in rows if r.date), default=None)
overview.merge_range("F7:G7", earliest.isoformat() if earliest else "-", kpi_value_fmt)
# Status distribution table (B10:D)
# Status distribution table - simplified 6-group (B10:D)
overview.write("B10", "出勤狀態分佈", section_fmt)
overview.write("B11", "Status", header_fmt_ov)
overview.write("C11", "數量", header_fmt_ov)
overview.write("D11", "百分比", header_fmt_ov)
status_codes_order = ["normal", "late", "late_early", "late_ot", "early", "early_ot", "ot", "abnormal", "missing",
"holiday", "al", "sl", "cl", "mixed_leave"]
status_labels = {
"normal": "正常", "late": "遲到", "late_early": "遲到+早走",
"late_ot": "遲到+加班", "early": "早走", "early_ot": "早走+加班",
"ot": "加班", "abnormal": "異常", "missing": "缺勤",
"holiday": "🏖️公假", "al": "年假 AL", "sl": "病假 SL", "cl": "補鐘 CL",
"mixed_leave": "混合假",
# Simplified 6-group status
_grp_status = {
"正常": 0, "遲到": 0, "早退": 0, "加班": 0, "異常/缺勤": 0, "假期/請假": 0, "(空)": 0,
}
for i, code in enumerate(status_codes_order):
count = sum(1 for r in rows if (r.status_code or "").lower() == code)
overview.write(11 + i, 1, status_labels.get(code, code), cell_fmt_ov)
for r in rows:
c = (r.status_code or "").lower()
if not c.strip():
_grp_status["(空)"] += 1
elif c == "normal":
_grp_status["正常"] += 1
elif "late" in c and "early" in c:
_grp_status["遲到"] += 1
elif "late" in c:
_grp_status["遲到"] += 1
elif "early" in c:
_grp_status["早退"] += 1
elif "ot" in c:
_grp_status["加班"] += 1
elif c in ("abnormal", "missing", "", " "):
_grp_status["異常/缺勤"] += 1
else:
_grp_status["假期/請假"] += 1
_total = len(rows)
for i, (label, count) in enumerate(_grp_status.items()):
overview.write(11 + i, 1, label, cell_fmt_ov)
overview.write(11 + i, 2, count, cell_fmt_ov)
pct = (count / len(rows) * 100) if rows else 0
pct = (count / _total * 100) if _total else 0
overview.write(11 + i, 3, f"{pct:.1f}%", cell_fmt_ov)
unk = sum(1 for r in rows if not (r.status_code or "").strip())
overview.write(11 + len(status_codes_order), 1, "(空)", cell_fmt_ov)
overview.write(11 + len(status_codes_order), 2, unk, cell_fmt_ov)
pct_unk = (unk / len(rows) * 100) if rows else 0
overview.write(11 + len(status_codes_order), 3, f"{pct_unk:.1f}%", cell_fmt_ov)
_n_status_groups = len(_grp_status)
# Also update KPI counters to match simplified grouping
n_late_grp = _grp_status["遲到"]
n_early_grp = _grp_status["早退"]
n_ot_grp = _grp_status["加班"]
n_abn_grp = _grp_status["異常/缺勤"]
# Department TOP 10 table (F10:G)
overview.write("F10", "部門記錄數 (TOP 10)", section_fmt)
@@ -3246,8 +3654,8 @@ async def export_excel(
status_pie = wb.add_chart({"type": "pie"})
status_pie.add_series({
"name": "出勤狀態分佈",
"categories": ["總覽", 11, 1, 11 + len(status_codes_order), 1],
"values": ["總覽", 11, 2, 11 + len(status_codes_order), 2],
"categories": ["總覽", 11, 1, 11 + _n_status_groups, 1],
"values": ["總覽", 11, 2, 11 + _n_status_groups, 2],
"data_labels": {"percentage": True, "category": False, "position": "outside_end"},
})
status_pie.set_title({"name": "出勤狀態分佈 (Pie)"})
@@ -3277,8 +3685,8 @@ async def export_excel(
status_col_chart = wb.add_chart({"type": "column"})
status_col_chart.add_series({
"name": "出勤狀態 數量",
"categories": ["總覽", 11, 1, 11 + len(status_codes_order) - 1, 1],
"values": ["總覽", 11, 2, 11 + len(status_codes_order) - 1, 2],
"categories": ["總覽", 11, 1, 11 + _n_status_groups - 1, 1],
"values": ["總覽", 11, 2, 11 + _n_status_groups - 1, 2],
"fill": {"color": "#10B981"},
"border": {"color": "#065F46"},
"data_labels": {"value": True},
+366
View File
@@ -0,0 +1,366 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{TITLE}} Report</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', 'Noto Sans TC', sans-serif; background: #f8fafc; color: #0f172a; font-size: 13px; }
.report-header { background: linear-gradient(135deg, {{HEADER_COLOR}} 0%, {{HEADER_DARK}} 100%); color: white; padding: 24px 32px; }
.report-header h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
.report-header p { opacity: 0.85; font-size: 13px; }
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; padding: 20px 32px; }
.stat-card { background: white; border-radius: 10px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); border-left: 4px solid {{ACCENT_COLOR}}; }
.stat-card .label { font-size: 11px; color: #64748b; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-card .value { font-size: 26px; font-weight: 700; color: #0f172a; margin-top: 4px; }
.stat-card .sub { font-size: 11px; color: #94a3b8; margin-top: 2px; }
.charts-section { padding: 16px 32px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.chart-card { background: white; border-radius: 10px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.chart-card h3 { font-size: 13px; font-weight: 600; color: #334155; margin-bottom: 12px; border-bottom: 2px solid {{ACCENT_COLOR}}; padding-bottom: 6px; }
.chart-wrap { position: relative; height: 200px; }
.filter-bar { padding: 12px 32px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; background: white; border-bottom: 1px solid #e2e8f0; }
.filter-bar input { padding: 7px 12px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 13px; width: 200px; }
.filter-bar select { padding: 7px 12px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 13px; }
.filter-bar .badge { background: #f1f5f9; padding: 6px 12px; border-radius: 20px; font-size: 12px; color: #475569; }
.table-section { padding: 0 32px 32px; }
.table-wrap { background: white; border-radius: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); overflow: hidden; }
table { width: 100%; border-collapse: collapse; }
thead { background: #f8fafc; }
thead th { padding: 10px 12px; text-align: left; font-size: 11px; font-weight: 600; color: #475569; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 2px solid #e2e8f0; cursor: pointer; user-select: none; white-space: nowrap; }
thead th:hover { background: #e2e8f0; }
thead th .sort-icon { margin-left: 4px; opacity: 0.4; font-size: 10px; }
thead th.sorted .sort-icon { opacity: 1; color: {{ACCENT_COLOR}}; }
tbody tr { border-bottom: 1px solid #f1f5f9; transition: background 0.15s; }
tbody tr:hover { background: #f8fafc; }
tbody td { padding: 9px 12px; font-size: 13px; color: #334155; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
tbody tr.row-normal { border-left: 3px solid transparent; }
tbody tr.row-warning { border-left: 3px solid #f59e0b; }
tbody tr.row-danger { border-left: 3px solid #ef4444; }
tbody tr.row-success { border-left: 3px solid #10b981; }
.cell-normal { color: #334155; }
.cell-warning { color: #d97706; background: #fef3c7; padding: 2px 6px; border-radius: 4px; font-weight: 500; }
.cell-danger { color: #dc2626; background: #fee2e2; padding: 2px 6px; border-radius: 4px; font-weight: 500; }
.cell-success { color: #059669; background: #d1fae5; padding: 2px 6px; border-radius: 4px; font-weight: 500; }
.cell-info { color: #2563eb; background: #dbeafe; padding: 2px 6px; border-radius: 4px; font-weight: 500; }
/* Popup */
.popup-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 9999; align-items: center; justify-content: center; }
.popup-overlay.active { display: flex; }
.popup { background: white; border-radius: 12px; width: 480px; max-height: 80vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
.popup-header { padding: 16px 20px; border-bottom: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; background: {{HEADER_COLOR}}; color: white; border-radius: 12px 12px 0 0; }
.popup-header h2 { font-size: 15px; font-weight: 600; }
.popup-close { background: rgba(255,255,255,0.2); border: none; color: white; width: 28px; height: 28px; border-radius: 50%; cursor: pointer; font-size: 16px; }
.popup-body { padding: 16px 20px; }
.popup-row { display: flex; padding: 8px 0; border-bottom: 1px solid #f1f5f9; }
.popup-row:last-child { border-bottom: none; }
.popup-label { font-weight: 600; color: #64748b; font-size: 12px; width: 130px; flex-shrink: 0; }
.popup-value { color: #0f172a; font-size: 13px; word-break: break-all; }
.pagination { padding: 12px 32px; display: flex; gap: 6px; align-items: center; justify-content: space-between; background: white; border-top: 1px solid #e2e8f0; }
.pagination-info { font-size: 12px; color: #64748b; }
.pagination buttons { display: flex; gap: 4px; }
.pagination button { padding: 6px 12px; border: 1px solid #cbd5e1; background: white; border-radius: 6px; cursor: pointer; font-size: 12px; }
.pagination button:hover { background: #f1f5f9; }
.pagination button.active { background: {{ACCENT_COLOR}}; color: white; border-color: {{ACCENT_COLOR}}; }
.pagination button:disabled { opacity: 0.4; cursor: default; }
.footer { text-align: center; padding: 16px; color: #94a3b8; font-size: 11px; }
</style>
</head>
<body>
<!-- Popup -->
<div class="popup-overlay" id="popup">
<div class="popup">
<div class="popup-header">
<h2 id="popup-title">詳細資料</h2>
<button class="popup-close" onclick="closePopup()">×</button>
</div>
<div class="popup-body" id="popup-body"></div>
</div>
</div>
<!-- Header -->
<div class="report-header">
<h1>{{REPORT_TITLE}}</h1>
<p>Generated: {{GENERATED_DATE}} | {{TOTAL_RECORDS}} 筆記錄</p>
</div>
<!-- Stats -->
<div class="stats-row" id="stats-row"></div>
<!-- Charts -->
<div class="charts-section">
<div class="chart-card">
<h3>📊 圖表 1</h3>
<div class="chart-wrap"><canvas id="chart1"></canvas></div>
</div>
<div class="chart-card">
<h3>📊 圖表 2</h3>
<div class="chart-wrap"><canvas id="chart2"></canvas></div>
</div>
<div class="chart-card">
<h3>📊 圖表 3</h3>
<div class="chart-wrap"><canvas id="chart3"></canvas></div>
</div>
</div>
<!-- Filter -->
<div class="filter-bar">
<input type="text" id="searchInput" placeholder="🔍 搜尋所有欄位..." oninput="applyFilters()">
<select id="filterCol" onchange="applyFilters()"><option value="">全部欄位</option>{{COLUMN_OPTIONS}}</select>
<select id="filterOp" onchange="applyFilters()"><option value="contains">包含</option><option value="equals">等於</option><option value="gt">大於</option><option value="lt">小於</option></select>
<input type="text" id="filterVal" placeholder="篩選值..." oninput="applyFilters()">
<span class="badge" id="resultCount"></span>
<button onclick="clearFilters()" style="margin-left:auto;padding:6px 12px;border:1px solid #cbd5e1;background:white;border-radius:6px;cursor:pointer;font-size:12px;">清除篩選</button>
<button onclick="window.print()" style="padding:6px 14px;background:{{ACCENT_COLOR}};color:white;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:500;">🖨️ 列印</button>
</div>
<!-- Table -->
<div class="table-section">
<div class="table-wrap">
<table>
<thead id="thead"></thead>
<tbody id="tbody"></tbody>
</table>
</div>
</div>
<!-- Pagination -->
<div class="pagination">
<div class="pagination-info" id="pageInfo"></div>
<div id="pageButtons"></div>
</div>
<div class="footer">
AARS Report System | {{GENERATED_DATE}} | Page 1 of 1
</div>
<script>
const DATA = {{EMBEDDED_DATA}};
const COLS = {{COLUMN_DEFS}};
const CHARTS = {{CHART_CONFIG}};
const ACCENT = "{{ACCENT_COLOR}}";
const PAGE_SIZE = 50;
let currentPage = 1;
let filteredData = [];
let sortKey = null;
let sortDir = "asc";
function init() {
filteredData = [...DATA];
renderStats();
renderCharts();
applyFilters();
}
function renderStats() {
const row = document.getElementById("stats-row");
row.innerHTML = CHARTS.stats.map(s => `
<div class="stat-card">
<div class="label">${s.label}</div>
<div class="value">${s.value}</div>
${s.sub ? `<div class="sub">${s.sub}</div>` : ""}
</div>
`).join("");
}
function renderCharts() {
CHARTS.charts.forEach((cfg, i) => {
const ctx = document.getElementById(`chart${i+1}`);
if (!ctx) return;
new Chart(ctx, {
type: cfg.type,
data: cfg.data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { font: { size: 11 }, boxWidth: 12, padding: 8 } },
tooltip: { callbacks: cfg.tooltipCallbacks || {} }
},
onClick: (e, elements) => {
if (elements.length > 0) {
const idx = elements[0].index;
const label = cfg.data.labels[idx];
// Auto-filter table when clicking chart segment
const col = cfg.filterColumn;
if (col) {
document.getElementById("filterCol").value = col;
document.getElementById("filterVal").value = label;
applyFilters();
}
}
}
}
});
});
}
function getCellClass(val, col) {
if (val === null || val === undefined || val === "") return "cell-normal";
const v = String(val).toLowerCase();
if (["missing", "缺勤", "no show"].some(t => v.includes(t))) return "cell-danger";
if (["late", "遲到", "遲"].some(t => v.includes(t))) return "cell-warning";
if (["early", "早退"].some(t => v.includes(t))) return "cell-warning";
if (["normal", "正常", "present", "準時"].some(t => v.includes(t))) return "cell-success";
if (["ot", "overtime", "超時"].some(t => v.includes(t))) return "cell-info";
if (["high", "red", "danger", "緊急", "嚴重"].some(t => v.includes(t))) return "cell-danger";
if (["medium", "medium", "中度", "中等"].some(t => v.includes(t))) return "cell-warning";
if (["low", "green", "輕微", "輕度"].some(t => v.includes(t))) return "cell-success";
return "cell-normal";
}
function getRowClass(row) {
const statusKeys = Object.keys(row).find(k => /status|狀態|status_text/i.test(k));
if (statusKeys) {
const v = String(row[statusKeys] || "").toLowerCase();
if (["missing", "缺勤", "no show", "late", "遲到", "early", "早退"].some(t => v.includes(t))) return "row-warning";
if (["normal", "正常", "present"].some(t => v.includes(t))) return "row-normal";
}
const sevKeys = Object.keys(row).find(k => /severity|緊急|程度/i.test(k));
if (sevKeys) {
const v = String(row[sevKeys] || "").toLowerCase();
if (["high", "red", "danger", "緊急", "嚴重", "死亡"].some(t => v.includes(t))) return "row-danger";
if (["medium", "中度", "中等"].some(t => v.includes(t))) return "row-warning";
if (["low", "green", "輕微", "輕度", "輕傷"].some(t => v.includes(t))) return "row-success";
}
return "row-normal";
}
function applyFilters() {
const search = document.getElementById("searchInput").value.toLowerCase();
const col = document.getElementById("filterCol").value;
const op = document.getElementById("filterOp").value;
const val = document.getElementById("filterVal").value.toLowerCase();
filteredData = DATA.filter(row => {
if (search) {
const hit = Object.values(row).some(v => v !== null && String(v).toLowerCase().includes(search));
if (!hit) return false;
}
if (col && val) {
const cellVal = String(row[col] || "").toLowerCase();
if (op === "contains") { if (!cellVal.includes(val)) return false; }
else if (op === "equals") { if (cellVal !== val) return false; }
else if (op === "gt") { if (!(parseFloat(row[col]) > parseFloat(val))) return false; }
else if (op === "lt") { if (!(parseFloat(row[col]) < parseFloat(val))) return false; }
}
return true;
});
if (sortKey) sortFiltered();
currentPage = 1;
renderTable();
document.getElementById("resultCount").textContent = `${filteredData.length} 筆記錄`;
}
function clearFilters() {
document.getElementById("searchInput").value = "";
document.getElementById("filterCol").value = "";
document.getElementById("filterOp").value = "contains";
document.getElementById("filterVal").value = "";
applyFilters();
}
function sortFiltered() {
filteredData.sort((a, b) => {
let aVal = a[sortKey] || "";
let bVal = b[sortKey] || "";
const aNum = parseFloat(aVal);
const bNum = parseFloat(bVal);
let cmp;
if (!isNaN(aNum) && !isNaN(bNum)) cmp = aNum - bNum;
else cmp = String(aVal).localeCompare(String(bVal), "zh-Hant");
return sortDir === "asc" ? cmp : -cmp;
});
}
function handleSort(key) {
if (sortKey === key) sortDir = sortDir === "asc" ? "desc" : "asc";
else { sortKey = key; sortDir = "asc"; }
sortFiltered();
renderTable();
}
function renderTable() {
const thead = document.getElementById("thead");
const visibleCols = COLS.filter(c => !c.hide);
const displayCols = visibleCols.length > 6 ? visibleCols.slice(0, 6) : visibleCols;
thead.innerHTML = `<tr>${displayCols.map((c, i) => {
const icon = sortKey === c.key ? (sortDir === "asc" ? "▲" : "▼") : "▽";
const cls = sortKey === c.key ? "sorted" : "";
return `<th class="${cls}" onclick="handleSort('${c.key}')">${c.label || c.key}<span class="sort-icon">${icon}</span></th>`;
}).join("")}<th>操作</th></tr>`;
const totalPages = Math.max(1, Math.ceil(filteredData.length / PAGE_SIZE));
const start = (currentPage - 1) * PAGE_SIZE;
const pageRows = filteredData.slice(start, start + PAGE_SIZE);
document.getElementById("tbody").innerHTML = pageRows.map(row => {
const rowClass = getRowClass(row);
const cells = displayCols.map(c => {
const val = row[c.key];
const cls = getCellClass(val, c.key);
return `<td class="${cls}" title="${val ?? ""}">${val !== null && val !== undefined ? String(val) : "-"}</td>`;
}).join("");
return `<tr class="${rowClass}" ondblclick="showPopup(${start + filteredData.indexOf(row)})">${cells}<td><button onclick="showPopup(${start + filteredData.indexOf(row)})" style="background:{{ACCENT_COLOR}};color:white;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px;">詳情</button></td></tr>`;
}).join("");
// pagination buttons
const pageInfo = document.getElementById("pageInfo");
pageInfo.textContent = `${currentPage} / ${totalPages} 頁,共 ${filteredData.length} 筆記錄`;
const btns = document.getElementById("pageButtons");
btns.innerHTML = `<button ${currentPage===1?"disabled":""} onclick="goPage(${currentPage-1})">上一頁</button>`;
for (let p = Math.max(1, currentPage-2); p <= Math.min(totalPages, currentPage+3); p++) {
btns.innerHTML += `<button class="${p===currentPage?"active":""}" onclick="goPage(${p})">${p}</button>`;
}
btns.innerHTML += `<button ${currentPage===totalPages?"disabled":""} onclick="goPage(${currentPage+1})">下一頁</button>`;
}
function goPage(p) {
const totalPages = Math.max(1, Math.ceil(filteredData.length / PAGE_SIZE));
if (p < 1 || p > totalPages) return;
currentPage = p;
renderTable();
}
function showPopup(idx) {
const row = filteredData[idx];
if (!row) return;
const visibleCols = COLS.filter(c => !c.hide);
let html = visibleCols.map(c => `<div class="popup-row"><div class="popup-label">${c.label || c.key}</div><div class="popup-value">${row[c.key] !== null && row[c.key] !== undefined ? String(row[c.key]) : "-"}</div></div>`).join("");
document.getElementById("popup-body").innerHTML = html;
document.getElementById("popup-title").textContent = "詳細資料 #" + idx;
document.getElementById("popup").classList.add("active");
}
function closePopup() {
document.getElementById("popup").classList.remove("active");
}
document.getElementById("popup").addEventListener("click", function(e) {
if (e.target === this) closePopup();
});
document.addEventListener("keydown", function(e) {
if (e.key === "Escape") closePopup();
});
init();
</script>
</body>
</html>
+2 -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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aars-frontend",
"version": "1.0.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aars-frontend",
"version": "1.0.0",
"version": "1.2.0",
"dependencies": {
"axios": "^1.7.7",
"lucide-react": "^0.441.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "aars-frontend",
"private": true,
"version": "1.0.0",
"version": "1.3.0",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -11,6 +11,7 @@ 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()
@@ -51,6 +52,7 @@ export default function App() {
<Route path="upload" element={<UploadPage />} />
<Route path="roster" element={<RosterPage />} />
<Route path="holidays" element={<HolidaysPage />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
)
+2 -1
View File
@@ -1,7 +1,7 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
import { useState } from 'react'
import { useAuth } from '../context/AuthContext'
import { LogOut, Users, AlertTriangle, LayoutDashboard, Upload, Calendar, Menu, X, Palmtree } from 'lucide-react'
import { LogOut, Users, AlertTriangle, LayoutDashboard, Upload, Calendar, Menu, X, Palmtree, Settings as SettingsIcon } from 'lucide-react'
const NAV_ITEMS = [
{ to: '/', end: true, icon: LayoutDashboard, label: 'Dashboard' },
@@ -9,6 +9,7 @@ const NAV_ITEMS = [
{ 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() {
+78 -100
View File
@@ -1,26 +1,30 @@
import { CheckCircle, AlertCircle, Clock, X, AlertTriangle, Briefcase, Stethoscope, Timer, PartyPopper } from 'lucide-react'
import { CheckCircle, AlertCircle, CalendarX, Palmtree, Briefcase, Coffee } from 'lucide-react'
/**
* Reusable status badge for attendance records.
* Handles all status codes including enriched leave types:
* normal / late / late_early / late_ot / early / early_ot / ot / abnormal / missing
* holiday / al / sl / cl / mixed_leave
* Simplified to BASIC status categories — no detailed breakdowns.
*
* Status mapping (driven by backend status_code / status_text):
* - missing → 缺勤 (grey)
* - holiday → 公假 (indigo)
* - al / sl / cl / mixed_leave → 請假 (violet)
* - normal → 正常 (green)
* - everything else (late/early/ot/abnormal/late_early/...) → 異常 (orange)
*
* If status_text carries a leave/holiday suffix (e.g. '+ SL2h', '🎉香港…'),
* a small secondary chip is rendered next to the main badge so the list page
* shows the underlying leave/holiday detail without expanding the row.
*
* Props:
* - code: status_code string
* - text: status_text string (already enriched)
* - text: status_text string (fallback signal + suffix source)
* - size: 'sm' | 'md' (default 'sm')
*/
export default function StatusBadge({ code, text = '', size = 'sm' }) {
export default function StatusBadge({ code = '', text = '', size = 'sm' }) {
const sizeCls = size === 'md'
? 'px-3 py-1 text-sm'
: 'px-2 py-0.5 text-xs'
// Split text into main + leave parts (after ' + ' separator)
const parts = text.split(/\s*\+\s*/).filter(Boolean)
const mainText = parts[0] || ''
const leaveParts = parts.slice(1)
const badge = (icon, label, colorCls) => (
<span className={`inline-flex items-center gap-1 ${sizeCls} rounded-full font-medium ${colorCls}`}>
{icon}
@@ -28,97 +32,71 @@ export default function StatusBadge({ code, text = '', size = 'sm' }) {
</span>
)
// ============ Attendance status badges ============
if (code === 'normal') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<CheckCircle size={12} />, mainText || '正常', 'bg-green-100 text-green-700')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
+ {lp}
</span>
))}
</span>
)
}
if (code === 'late' || code === 'late_early' || code === 'late_ot') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<AlertCircle size={12} />, mainText || code, 'bg-orange-100 text-orange-700')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
+ {lp}
</span>
))}
</span>
)
}
if (code === 'early' || code === 'early_ot') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<Clock size={12} />, mainText || code, 'bg-blue-100 text-blue-700')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
+ {lp}
</span>
))}
</span>
)
}
if (code === 'ot') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<Clock size={12} />, mainText || '加班', 'bg-purple-100 text-purple-700')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
+ {lp}
</span>
))}
</span>
)
}
if (code === 'abnormal') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<AlertTriangle size={12} />, mainText || '⚠️異常', 'bg-red-100 text-red-700')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
+ {lp}
</span>
))}
</span>
)
}
if (code === 'missing') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<X size={12} />, '缺勤', 'bg-gray-200 text-gray-600')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
{lp}
</span>
))}
</span>
)
// 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())
}
}
// ============ Enriched leave status badges ============
if (code === 'holiday') {
return badge(<PartyPopper size={12} />, mainText, 'bg-amber-100 text-amber-700')
}
if (code === 'al') {
return badge(<Briefcase size={12} />, mainText || '年假', 'bg-emerald-100 text-emerald-700')
}
if (code === 'sl') {
return badge(<Stethoscope size={12} />, mainText || '病假', 'bg-pink-100 text-pink-700')
}
if (code === 'cl') {
return badge(<Timer size={12} />, mainText || '補鐘', 'bg-indigo-100 text-indigo-700')
}
if (code === 'mixed_leave') {
return badge(<Timer size={12} />, mainText || '混合假', 'bg-fuchsia-100 text-fuchsia-700')
const noteChips = noteParts.filter(Boolean).map((part, idx) => {
const cls = 'bg-violet-50 text-violet-600 border border-violet-200'
return (
<span
key={idx}
className={`inline-flex items-center ${sizeCls} rounded-full font-normal ${cls}`}
title={part}
>
+{part}
</span>
)
})
const wrap = (content) => (
<span className="inline-flex items-center gap-1 flex-wrap">
{content}
{noteChips}
</span>
)
const c = String(code || '').toLowerCase()
// 缺勤 (missing)
if (c === 'missing' || t === '缺勤') {
return wrap(badge(<CalendarX size={12} />, '缺勤', 'bg-gray-200 text-gray-700'))
}
// fallback
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs text-slate-500 bg-slate-100`}>{text || code || '-'}</span>
// 休息 (rest day — roster says not scheduled, employee didn't clock in)
if (c === 'rest' || t === '休息') {
return wrap(badge(<Coffee size={12} />, '休息', 'bg-slate-100 text-slate-600'))
}
// 公假 (holiday)
if (c === 'holiday' || t.includes('公假') || t.includes('🏖️')) {
return wrap(badge(<Palmtree size={12} />, '公假', 'bg-indigo-100 text-indigo-700'))
}
// 請假 (any leave type)
if (['al', 'sl', 'cl', 'mixed_leave'].includes(c)
|| t.includes('年假') || t.includes('病假') || t.includes('補鐘')
|| t.includes('混合假')) {
return wrap(badge(<Briefcase size={12} />, '請假', 'bg-violet-100 text-violet-700'))
}
// 正常 (only when status_code is 'normal' — text suffix like '+ CL4h' is allowed,
// we still want to flag it as 正常 first since the employee did clock in/out normally)
if (c === 'normal') {
return wrap(badge(<CheckCircle size={12} />, '正常', 'bg-green-100 text-green-700'))
}
// 異常 (late/early/ot/abnormal/late_early/late_ot/early_ot/...)
return wrap(badge(<AlertCircle size={12} />, '異常', 'bg-orange-100 text-orange-700'))
}
+6 -4
View File
@@ -368,10 +368,12 @@ export default function Dashboard() {
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))
const attendanceRate = [...byStaff].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)
// 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' },
+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>
)
}
+28 -13
View File
@@ -17,15 +17,24 @@ export default function AttendanceList() {
const [staffFilter, setStaffFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [page, setPage] = useState(1)
const [statusOptions, setStatusOptions] = useState([])
const perPage = 50
const [staffList, setStaffList] = useState([])
useEffect(() => {
fetchRecords()
fetchStatusTexts()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, sortBy, sortOrder, dateFrom, dateTo, staffFilter, statusFilter, search])
const fetchStatusTexts = async () => {
try {
const { data } = await api.get('/api/attendance/status_texts')
setStatusOptions(data || [])
} catch (e) { console.error('Failed to fetch status texts', e) }
}
const fetchRecords = async () => {
setLoading(true)
try {
@@ -39,7 +48,7 @@ export default function AttendanceList() {
if (dateFrom) params.append('date_from', dateFrom)
if (dateTo) params.append('date_to', dateTo)
if (staffFilter) params.append('staff', staffFilter)
if (statusFilter) params.append('status', statusFilter)
if (statusFilter) params.append('status_text', statusFilter)
const { data } = await api.get(`/api/attendance/records?${params}`)
setRecords(data.records || [])
@@ -78,15 +87,21 @@ export default function AttendanceList() {
}
const getStatusBadge = (record) => {
// StatusBadge now drives from status_code + status_text only (basic 5 categories).
return <StatusBadge code={record.status_code} text={record.status_text} />
}
const getRowClass = (code) => {
if (code === 'abnormal') return 'bg-red-50 hover:bg-red-100'
if (code === 'missing') return 'bg-gray-100 hover:bg-gray-200'
if (code === 'late' || code === 'late_early' || code === 'late_ot') return 'bg-orange-50 hover:bg-orange-100'
if (code === 'early' || code === 'early_ot') return 'bg-blue-50 hover:bg-blue-100'
if (code === 'ot') return 'bg-purple-50 hover:bg-purple-100'
// Match the basic-status grouping so highlights line up with the badge.
const c = String(code || '').toLowerCase()
if (c === 'abnormal' || c === 'late' || c === 'early' || c === 'ot'
|| c === 'late_early' || c === 'late_ot' || c === 'early_ot') {
return 'bg-orange-50 hover:bg-orange-100'
}
if (c === 'missing') return 'bg-gray-100 hover:bg-gray-200'
if (c === 'rest') return 'bg-slate-50 hover:bg-slate-100'
if (c === 'holiday') return 'bg-indigo-50 hover:bg-indigo-100'
if (['al', 'sl', 'cl', 'mixed_leave'].includes(c)) return 'bg-violet-50 hover:bg-violet-100'
return 'hover:bg-slate-50'
}
@@ -176,16 +191,16 @@ export default function AttendanceList() {
<span className="text-xs text-slate-500">狀態:</span>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}
onChange={(e) => {
setStatusFilter(e.target.value)
setPage(1)
}}
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
>
<option value="">全部</option>
<option value="normal">正常</option>
<option value="late">遲到</option>
<option value="early">早退</option>
<option value="ot">OT</option>
<option value="abnormal">異常</option>
<option value="missing">缺勤</option>
{statusOptions.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
+26 -5
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Upload, CheckCircle, Trash2, Clock, Users } from 'lucide-react'
import { Upload, CheckCircle, Trash2, Clock, Users, RefreshCw } from 'lucide-react'
import api from '../../api'
import toast from 'react-hot-toast'
@@ -63,6 +63,21 @@ export default function RosterPage() {
}
}
const handleRecalc = async () => {
setLoading(true)
try {
await Promise.all([
api.get('/api/roster/info'),
api.get('/api/roster/data').catch(() => ({ data: { headers: [], rows: [] } }))
])
toast.success('已重新計算')
} catch {
toast.error('重新計算失敗')
} finally {
loadRosterData()
}
}
if (loading) {
return <div className="p-8 text-center">載入中...</div>
}
@@ -106,10 +121,16 @@ export default function RosterPage() {
</p>
</div>
</div>
<button onClick={handleDelete} className="btn-danger">
<Trash2 size={16} className="inline mr-1" />
刪除
</button>
<div className="flex gap-2">
<button onClick={handleRecalc} className="btn-primary">
<RefreshCw size={16} className="inline mr-1" />
RECALC
</button>
<button onClick={handleDelete} className="btn-danger">
<Trash2 size={16} className="inline mr-1" />
刪除
</button>
</div>
</div>
)}
</div>
+1225
View File
File diff suppressed because it is too large Load Diff
+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}")