Compare commits

...

35 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
11 changed files with 872 additions and 406 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) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base() 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 # JWT Settings
SECRET_KEY = secret.JWT_SECRET if hasattr(secret, 'JWT_SECRET') else "aars-dev-secret-change-in-production" 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) 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: def get_password_hash(password: str) -> str:
return pwd_context.hash(password) return pwd_context.hash(password)
+346 -56
View File
@@ -9,7 +9,7 @@ from pathlib import Path
from typing import Optional, List from typing import Optional, List
import json 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 models import User, AttendanceRecord, Shift, Accident, Holiday, LeaveRecord
from sqlalchemy import func as sqlfunc from sqlalchemy import func as sqlfunc
from schemas import * from schemas import *
@@ -126,8 +126,40 @@ def enrich_attendance_with_leave(records, db):
if not r.date: if not r.date:
continue continue
# Rest day detection: roster says the shift rests on this weekday
# (e.g. Sunday for S2/S3) but raw status is 'missing'. Don't flag as 缺勤
# when the employee wasn't scheduled to work.
if (r.status_code or '').lower() == 'missing' and r.shift_code and r.weekday:
shift_info = get_shift_schedule_full(r.shift_code, r.weekday)
if shift_info is None:
r.status_code = 'rest'
r.status_text = '休息'
continue
if r.date in holidays_by_date: if r.date in holidays_by_date:
h = holidays_by_date[r.date] h = holidays_by_date[r.date]
# Check roster: if shift's public_holiday == 'Yes', employee works on holidays
# Don't override with holiday status in that case
shift_holiday = '休息' # default: assume rest on holiday
if r.shift_code:
shift_info = get_shift_schedule_full(r.shift_code, r.weekday or '')
if shift_info and len(shift_info) >= 3:
shift_holiday = shift_info[2]
if shift_holiday == 'Yes':
# Employee works on public holidays - keep attendance status, append holiday note
base = r.status_text or "正常"
r.status_text = f"{base} (🎉{h.name})"
continue
# Employee rests on public holidays - mark as holiday unless already has clock-in
has_clock_in = (
(r.check_in is not None) or
(r.actual_in and r.actual_in not in ("", "-"))
)
if not has_clock_in:
r.status_code = "holiday"
r.status_text = f"\U0001F3D6\uFE0F{h.name}"
continue
# Has clock-in but roster says rest on holiday - still show holiday
r.status_code = "holiday" r.status_code = "holiday"
r.status_text = f"\U0001F3D6\uFE0F{h.name}" r.status_text = f"\U0001F3D6\uFE0F{h.name}"
continue continue
@@ -158,7 +190,23 @@ def enrich_attendance_with_leave(records, db):
else: else:
r.status_code = "mixed_leave" r.status_code = "mixed_leave"
r.status_text = leave_text r.status_text = leave_text
elif original_code in ("al", "sl", "cl"):
# Pure leave status: replace with aggregated leave text
if len(leave_by_type) > 1:
r.status_code = "mixed_leave"
r.status_text = leave_text
else: 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 "正常" base = r.status_text or "正常"
r.status_text = f"{base} + {leave_text}" r.status_text = f"{base} + {leave_text}"
@@ -206,9 +254,20 @@ async def startup():
@app.post("/api/auth/login", response_model=TokenResponse) @app.post("/api/auth/login", response_model=TokenResponse)
async def login(form_data: LoginRequest, db: Session = Depends(get_db)): async def login(form_data: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == form_data.email).first() 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"}}) logger.warning("login failed", extra={"context": {"email": form_data.email, "reason": "invalid_credentials"}})
raise HTTPException(status_code=401, detail="Invalid email or password") 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) set_user_id(user.id)
access_token = create_access_token({"sub": str(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}}) logger.info("login success", extra={"context": {"user_id": user.id, "email": form_data.email}})
@@ -218,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)): async def get_me(current_user: User = Depends(get_current_user)):
return 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 ============ # ============ Excel File Storage ============
def get_excel_path(section: str) -> str: def get_excel_path(section: str) -> str:
"""Get path to the stored Excel file for a section""" """Get path to the stored Excel file for a section"""
@@ -261,7 +378,7 @@ async def upload_excel(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Upload Excel file saves file AND imports rows into SQL table.""" """Upload Excel file - saves file AND imports rows into SQL table."""
if section not in ["attendance", "accident"]: if section not in ["attendance", "accident"]:
raise HTTPException(status_code=400, detail="Invalid section") raise HTTPException(status_code=400, detail="Invalid section")
@@ -494,7 +611,8 @@ async def _import_attendance_to_db(db: Session, headers, rows, user_id: int) ->
# ============ Attendance Calculation ============ # ============ Attendance Calculation ============
def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]: def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]:
"""Get the expected start and end time for a shift on a given weekday. """Get the expected start and end time for a shift on a given weekday.
Returns (start_time, end_time) tuple. Returns (start_time, end_time, public_holiday) tuple.
public_holiday is 'Yes' if the shift requires working on public holidays, '休息' otherwise.
""" """
roster_path = get_excel_path("roster") roster_path = get_excel_path("roster")
if not os.path.exists(roster_path): if not os.path.exists(roster_path):
@@ -508,10 +626,12 @@ def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]:
# Find column indices # Find column indices
shift_col = None shift_col = None
ph_col = None
for i, h in enumerate(headers): for i, h in enumerate(headers):
if h == '班次': if h == '班次':
shift_col = i shift_col = i
break elif h == 'public holiday':
ph_col = i
if shift_col is None: if shift_col is None:
return None return None
@@ -539,6 +659,7 @@ def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]:
# Find the shift row # Find the shift row
for row in ws.iter_rows(min_row=2, values_only=True): for row in ws.iter_rows(min_row=2, values_only=True):
if row[shift_col] == shift_code: if row[shift_col] == shift_code:
public_holiday = str(row[ph_col]).strip() if ph_col is not None else '休息'
day_col = day_col_map.get(target_day) day_col = day_col_map.get(target_day)
if day_col is not None: if day_col is not None:
schedule = row[day_col] schedule = row[day_col]
@@ -551,7 +672,7 @@ def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]:
end_str = parts[1].strip() end_str = parts[1].strip()
start_time = datetime.strptime(start_str, '%H:%M').time() start_time = datetime.strptime(start_str, '%H:%M').time()
end_time = datetime.strptime(end_str, '%H:%M').time() end_time = datetime.strptime(end_str, '%H:%M').time()
return (start_time, end_time) return (start_time, end_time, public_holiday)
except: except:
pass pass
@@ -663,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) expected_end_dt = datetime.combine(check_out_dt.date(), shift_end)
if check_out_dt < expected_end_dt: if check_out_dt < expected_end_dt:
diff = (expected_end_dt - check_out_dt).total_seconds() / 60 diff_seconds = (expected_end_dt - check_out_dt).total_seconds()
result["early_minutes"] = round(diff) result["early_minutes"] = round(diff_seconds / 60) if diff_seconds >= 30 else 0
# Calculate OT minutes (leaving after scheduled end) # Calculate OT minutes (leaving after scheduled end)
if check_out_dt > expected_end_dt: if check_out_dt > expected_end_dt:
diff = (check_out_dt - expected_end_dt).total_seconds() / 60 diff_seconds = (check_out_dt - expected_end_dt).total_seconds()
result["ot_minutes"] = round(diff) # Ignore tiny differences (< 30 seconds) to avoid floating-point noise
if diff_seconds >= 30:
result["ot_minutes"] = round(diff_seconds / 60)
else:
result["ot_minutes"] = 0
# Determine status code and text # Determine status code and text
status_parts = [] status_parts = []
@@ -696,6 +821,16 @@ def calculate_attendance_status(check_in, check_out, shift_start, shift_end):
result["status_code"] = "normal" result["status_code"] = "normal"
result["status_text"] = " / ".join(status_parts) if status_parts else "正常" result["status_text"] = " / ".join(status_parts) if status_parts else "正常"
# If late / early / OT > 30 min → mark as abnormal (override after status_text is set)
if result["late_minutes"] > 30 or result["early_minutes"] > 30 or result["ot_minutes"] > 30:
result["status_code"] = "abnormal"
if result["late_minutes"] > 30:
result["status_text"] = f"異常-遲到{result['late_minutes']}"
elif result["early_minutes"] > 30:
result["status_text"] = f"異常-早退{result['early_minutes']}"
else:
result["status_text"] = f"異常-OT{result['ot_minutes']}"
return result return result
@@ -1019,6 +1154,25 @@ async def dashboard_summary(
if r.employee_name: if r.employee_name:
staff_set.add(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 { return {
# Backend / dashboard canonical fields # Backend / dashboard canonical fields
"total": len(rows), "total": len(rows),
@@ -1028,16 +1182,33 @@ async def dashboard_summary(
"by_department": by_department, "by_department": by_department,
"trend": [], "trend": [],
# Frontend-expected fields (alias for legacy UI) # 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), "total_records": len(rows),
"normal_count": by_status.get("normal", 0), "normal_count": by_status.get("normal", 0),
"late_count": sum(v for k, v in by_status.items() if "late" in k), "late_count": late_count,
"early_count": sum(v for k, v in by_status.items() if "early" in k), "early_count": early_count,
"ot_count": sum(v for k, v in by_status.items() if "ot" in k), "ot_count": ot_count,
"missing_count": by_status.get("missing", 0), "missing_count": by_status.get("missing", 0),
"abnormal_count": by_status.get("abnormal", 0), "abnormal_count": by_status.get("abnormal", 0),
"staff_count": len(staff_set), "staff_count": len(staff_set),
} }
@app.get("/api/attendance/status_texts")
async def attendance_status_texts(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return BASIC status categories for filter dropdown.
The frontend renders only 5 basic statuses (正常/異常/缺勤/公假/請假),
so the dropdown should offer those exact values rather than every
raw status_text variant (e.g. '異常-遲到39分'). Keeping it server-driven
so client and server agree on the filter vocabulary.
"""
return ['正常', '異常', '缺勤', '公假', '請假', '休息']
@app.get("/api/attendance/stats") @app.get("/api/attendance/stats")
async def attendance_stats( async def attendance_stats(
date_from: Optional[str] = None, date_from: Optional[str] = None,
@@ -1174,10 +1345,18 @@ async def attendance_records(
date_to: Optional[str] = None, date_to: Optional[str] = None,
staff: Optional[str] = None, staff: Optional[str] = None,
status: Optional[str] = None, status: Optional[str] = None,
status_text: Optional[str] = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Paginated records from attendance_records (SQL).""" """Paginated records from attendance_records (SQL).
status_text filter runs AFTER enrichment so it matches the badge rendered
from post-enrich status_code. Otherwise missing-day-with-leave records
would surface under '缺勤' but render as '請假'/'公假' badges (mismatch).
Trade-off: status_text/sort_by filter runs in Python after enrich instead
of SQL — fine while dataset stays small (~80 records), log if it grows.
"""
from datetime import datetime as _dt from datetime import datetime as _dt
from sqlalchemy import or_, and_ from sqlalchemy import or_, and_
@@ -1204,20 +1383,74 @@ async def attendance_records(
AttendanceRecord.shift_code.like(like), AttendanceRecord.shift_code.like(like),
)) ))
# sort # Pre-sort by raw date so enrich-then-filter ordering is deterministic.
sortable = { sortable_raw = {
"date": AttendanceRecord.date, "date": AttendanceRecord.date,
"employee_name": AttendanceRecord.employee_name, "employee_name": AttendanceRecord.employee_name,
"weekday": AttendanceRecord.weekday, "weekday": AttendanceRecord.weekday,
"shift_code": AttendanceRecord.shift_code, "shift_code": AttendanceRecord.shift_code,
"status_code": AttendanceRecord.status_code,
} }
col = sortable.get(sort_by, AttendanceRecord.date) col = sortable_raw.get(sort_by, AttendanceRecord.date)
q = q.order_by(col.desc() if sort_order == "desc" else col.asc()) q = q.order_by(col.desc() if sort_order == "desc" else col.asc())
total = q.count() all_rows = q.all()
rows = q.offset((page - 1) * per_page).limit(per_page).all() if len(all_rows) > 5000:
enrich_attendance_with_leave(rows, db) logger.warning(
"attendance_records: in-memory filter over %d rows — consider prefiltering via date range",
len(all_rows),
)
# Enrich ALL rows so we can filter by post-enrich status_code.
enrich_attendance_with_leave(all_rows, db)
# Apply status_text filter on enriched status_code (post-leave/holiday override)
if status_text:
abnormal_codes = ('abnormal', 'late', 'early', 'ot',
'late_early', 'late_ot', 'early_ot')
leave_codes = ('al', 'sl', 'cl', 'mixed_leave')
if status_text in ('abnormal', '異常'):
rows = [r for r in all_rows if (r.status_code or '').lower() in abnormal_codes]
elif status_text == 'late':
rows = [r for r in all_rows if (r.status_code or '').lower() in ('late', 'late_early', 'late_ot')]
elif status_text == 'early':
rows = [r for r in all_rows if (r.status_code or '').lower() in ('early', 'late_early', 'early_ot')]
elif status_text == 'ot':
rows = [r for r in all_rows if (r.status_code or '').lower() in ('ot', 'late_ot', 'early_ot')]
elif status_text in ('missing', '缺勤'):
rows = [r for r in all_rows if (r.status_code or '').lower() == 'missing']
elif status_text in ('holiday', '公假'):
# Holiday shift OR raw records carrying a holiday suffix in status_text
# (e.g. '異常 (🎉香港特區成立紀念日)' where the employee worked on the holiday).
rows = [r for r in all_rows
if (r.status_code or '').lower() == 'holiday'
or '🎉' in (r.status_text or '')]
elif status_text in leave_codes:
rows = [r for r in all_rows if (r.status_code or '').lower() == status_text]
elif status_text in ('leave', '請假'):
# Any leave presence: pure leave status_code OR a leave suffix appended
# to a normal/abnormal/ot code (employee worked but took leave mid-day).
# Suffix format produced by enrich: ' + SL2h', ' + AL4h', ' + CL4h', etc.
rows = [r for r in all_rows
if (r.status_code or '').lower() in leave_codes
or any(f'+ {tag}' in (r.status_text or '')
for tag in ('SL', 'AL', 'CL'))]
elif status_text in ('normal', '正常'):
rows = [r for r in all_rows if (r.status_code or '').lower() == 'normal']
elif status_text in ('rest', '休息'):
rows = [r for r in all_rows if (r.status_code or '').lower() == 'rest']
else:
# Fallback to raw status_text substring search
rows = [r for r in all_rows
if r.status_text and status_text in r.status_text]
else:
rows = all_rows
# Python sort by status_code when requested (raw DB only handled the basic columns)
if sort_by == 'status_code':
rows.sort(key=lambda r: (r.status_code or ''), reverse=(sort_order == 'desc'))
total = len(rows)
page_rows = rows[(page - 1) * per_page: (page - 1) * per_page + per_page]
def to_dict(r): def to_dict(r):
return { return {
@@ -1243,7 +1476,7 @@ async def attendance_records(
} }
return { return {
"records": [to_dict(r) for r in rows], "records": [to_dict(r) for r in page_rows],
"total": total, "total": total,
"page": page, "page": page,
"per_page": per_page, "per_page": per_page,
@@ -1822,6 +2055,7 @@ import shutil, hashlib
from datetime import datetime as _dt2 from datetime import datetime as _dt2
BACKUP_DIR = Path("/app/data/backups") BACKUP_DIR = Path("/app/data/backups")
DB_PATH = "/app/data/aars.db"
BACKUP_DIR.mkdir(parents=True, exist_ok=True) BACKUP_DIR.mkdir(parents=True, exist_ok=True)
def _backup_path(name): def _backup_path(name):
@@ -1885,6 +2119,48 @@ async def restore_backup(
shutil.copy2(fp, src) shutil.copy2(fp, src)
return {"restored": filename, "auto_backup": auto_dst.name if src.exists() else None} 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") @app.get("/api/version")
async def get_version( async def get_version(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
@@ -1908,7 +2184,7 @@ async def get_version(
commit = "unknown" commit = "unknown"
date = "" date = ""
return { return {
"version": "1.1.0", "version": "1.3.0",
"commit": commit, "commit": commit,
"date": date, "date": date,
} }
@@ -2032,7 +2308,7 @@ async def _import_accident_to_db(db: Session, headers, rows, user_id: int) -> di
Col 8 涉及的患者或相關人員 -> patient_or_staff Col 8 涉及的患者或相關人員 -> patient_or_staff
Col 9 事件描述 -> long_description Col 9 事件描述 -> long_description
Col 10 如有事件相關照片可提供-> incident_photos Col 10 如有事件相關照片可提供-> incident_photos
Col 11 處理情況已採取的行動-> action_taken Col 11 處理情況/已採取的行動-> action_taken
Col 12 需要管理層介入的事項 -> needs_escalation Col 12 需要管理層介入的事項 -> needs_escalation
Col 13 第 12 欄 -> (skipped) Col 13 第 12 欄 -> (skipped)
Col 14 分數 -> incident_score Col 14 分數 -> incident_score
@@ -2750,7 +3026,7 @@ async def accident_export_excel(
# User types case_no in C5 of 個案查詢 sheet. # User types case_no in C5 of 個案查詢 sheet.
# xlsxwriter requires single quotes around sheet names containing non-ASCII. # xlsxwriter requires single quotes around sheet names containing non-ASCII.
# Use proper xlsxwriter hyperlink element (write_url with internal:Sheet!Cell). # Use proper xlsxwriter hyperlink element (write_url with internal:Sheet!Cell).
# HYPERLINK formula would cache value 0 write_url sets display text directly. # HYPERLINK formula would cache value 0 - write_url sets display text directly.
overview.write_url(row_n, 6, overview.write_url(row_n, 6,
f"internal:個案查詢!A1", f"internal:個案查詢!A1",
link_fmt, link_fmt,
@@ -2758,7 +3034,7 @@ async def accident_export_excel(
tip=f"跳到「個案查詢」輸入個案 ID {r.id}") tip=f"跳到「個案查詢」輸入個案 ID {r.id}")
overview.set_row(row_n, 18) overview.set_row(row_n, 18)
# ============ Charts block 統計圖表 (Sheet 1 總覽 右側 columns I-N) ============ # ============ Charts block - 統計圖表 (Sheet 1 總覽 右側 columns I-N) ============
# 4 charts: Severity Pie, Severity Column, Department Bar TOP 10, Monthly Trend Line # 4 charts: Severity Pie, Severity Column, Department Bar TOP 10, Monthly Trend Line
# Position: I column (right of KPI + 個案索引 blocks), so no overlap with case index. # Position: I column (right of KPI + 個案索引 blocks), so no overlap with case index.
@@ -2868,7 +3144,7 @@ async def accident_export_excel(
# ============ Sheet 3 (built FIRST so detail-VLOOKUP can reference it): 明細 ============ # ============ Sheet 3 (built FIRST so detail-VLOOKUP can reference it): 明細 ============
# Build it now so that 個案查詢 formulas can resolve its range properly. # Build it now so that 個案查詢 formulas can resolve its range properly.
# However, xlsxwriter's defined_name allows forward reference; formula text is # However, xlsxwriter's defined_name allows forward reference; formula text is
# what matters at runtime even if the sheet is added later, the formula string # what matters at runtime - even if the sheet is added later, the formula string
# '明細!A:O' will be valid when Excel opens the file. # '明細!A:O' will be valid when Excel opens the file.
# We define 明細 here so that the formula range 明細!A2:O{max_row} resolves. # We define 明細 here so that the formula range 明細!A2:O{max_row} resolves.
# But xlsxwriter requires sheets to be added in the order they're defined. # But xlsxwriter requires sheets to be added in the order they're defined.
@@ -2928,12 +3204,12 @@ async def accident_export_excel(
"align": "center", "num_format": "@"}) "align": "center", "num_format": "@"})
# B5 = label only (NOT merged with C5). Otherwise user-typed text goes to # B5 = label only (NOT merged with C5). Otherwise user-typed text goes to
# B5 (top-left of merge) instead of C5 where the formula references. # B5 (top-left of merge) instead of C5 where the formula references.
detail.write("B5", "輸入個案 ID", input_label_fmt) detail.write("B5", "輸入個案 ID:", input_label_fmt)
# C5 = case_no input cell (NOT merged) # C5 = case_no input cell (NOT merged)
detail.write_string("C5", "", text_input_fmt) detail.write_string("C5", "", text_input_fmt)
# Hint # Hint
detail.merge_range("B6:C6", detail.merge_range("B6:C6",
"提示個案 ID 喺「總覽」個案索引或「明細」表嘅 id 欄。輸入後下面自動顯示。", "提示:個案 ID 喺「總覽」個案索引或「明細」表嘅 id 欄。輸入後下面自動顯示。",
subtitle_fmt) subtitle_fmt)
# Detail rows: each pulls from 明細!<col><row> via VLOOKUP keyed on id (col A) # Detail rows: each pulls from 明細!<col><row> via VLOOKUP keyed on id (col A)
@@ -2972,7 +3248,7 @@ async def accident_export_excel(
col_sev = col_index_lookup.get("severity", 6) col_sev = col_index_lookup.get("severity", 6)
col_emp = col_index_lookup.get("employee_name", 3) col_emp = col_index_lookup.get("employee_name", 3)
col_desc = col_index_lookup.get("description", 5) col_desc = col_index_lookup.get("description", 5)
# Use INDIRECT or simply use cell reference for ID but ID varies per row. # Use INDIRECT or simply use cell reference for ID - but ID varies per row.
# Easier: each row's ID is in B<row_n>; build formula with that. # Easier: each row's ID is in B<row_n>; build formula with that.
# VLOOKUP needs lookup_value, table, col_index, FALSE. # VLOOKUP needs lookup_value, table, col_index, FALSE.
# table 明細!$A:$O. # table 明細!$A:$O.
@@ -3019,13 +3295,13 @@ async def accident_export_excel(
compare_start_row = list_header_row + n_list_rows + 3 compare_start_row = list_header_row + n_list_rows + 3
detail.write(f"B{compare_start_row}", "兩個個案 並列比較 Side-by-Side", section_fmt) detail.write(f"B{compare_start_row}", "兩個個案 並列比較 Side-by-Side", section_fmt)
detail.write(f"B{compare_start_row + 1}", detail.write(f"B{compare_start_row + 1}",
"輸入兩個個案 ID (左個案 A, 右個案 B)對比每個欄位。空白表示無資料。", "輸入兩個個案 ID (左個案 A, 右個案 B),對比每個欄位。空白表示無資料。",
subtitle_fmt) subtitle_fmt)
cmp_label_row = compare_start_row + 2 cmp_label_row = compare_start_row + 2
detail.write(cmp_label_row, 1, "個案 A ID", input_label_fmt) detail.write(cmp_label_row, 1, "個案 A ID:", input_label_fmt)
detail.write_string(cmp_label_row, 2, "", text_input_fmt) detail.write_string(cmp_label_row, 2, "", text_input_fmt)
detail.write(cmp_label_row, 4, "個案 B ID", input_label_fmt) detail.write(cmp_label_row, 4, "個案 B ID:", input_label_fmt)
detail.write_string(cmp_label_row, 5, "", text_input_fmt) detail.write_string(cmp_label_row, 5, "", text_input_fmt)
detail.set_column("F:F", 50) detail.set_column("F:F", 50)
@@ -3057,7 +3333,7 @@ async def accident_export_excel(
# Hint at top # Hint at top
detail.merge_range(f"B{cmp_data_start + len(detail_fields) + 2}:C{cmp_data_start + len(detail_fields) + 2}", detail.merge_range(f"B{cmp_data_start + len(detail_fields) + 2}:C{cmp_data_start + len(detail_fields) + 2}",
"💡 全部公式 (VLOOKUP)無需啟用巨集。Excel/Numbers 都正常運作。", "💡 全部公式 (VLOOKUP),無需啟用巨集。Excel/Numbers 都正常運作。",
subtitle_fmt) subtitle_fmt)
# ============ Sheet 3: 明細 ============ # ============ Sheet 3: 明細 ============
@@ -3322,31 +3598,45 @@ async def export_excel(
earliest = min((r.date for r in rows if r.date), default=None) earliest = min((r.date for r in rows if r.date), default=None)
overview.merge_range("F7:G7", earliest.isoformat() if earliest else "-", kpi_value_fmt) overview.merge_range("F7:G7", earliest.isoformat() if earliest else "-", kpi_value_fmt)
# Status distribution table (B10:D) # Status distribution table - simplified 6-group (B10:D)
overview.write("B10", "出勤狀態分佈", section_fmt) overview.write("B10", "出勤狀態分佈", section_fmt)
overview.write("B11", "Status", header_fmt_ov) overview.write("B11", "Status", header_fmt_ov)
overview.write("C11", "數量", header_fmt_ov) overview.write("C11", "數量", header_fmt_ov)
overview.write("D11", "百分比", header_fmt_ov) overview.write("D11", "百分比", header_fmt_ov)
status_codes_order = ["normal", "late", "late_early", "late_ot", "early", "early_ot", "ot", "abnormal", "missing", # Simplified 6-group status
"holiday", "al", "sl", "cl", "mixed_leave"] _grp_status = {
status_labels = { "正常": 0, "遲到": 0, "早退": 0, "加班": 0, "異常/缺勤": 0, "假期/請假": 0, "(空)": 0,
"normal": "正常", "late": "遲到", "late_early": "遲到+早走",
"late_ot": "遲到+加班", "early": "早走", "early_ot": "早走+加班",
"ot": "加班", "abnormal": "異常", "missing": "缺勤",
"holiday": "🏖️公假", "al": "年假 AL", "sl": "病假 SL", "cl": "補鐘 CL",
"mixed_leave": "混合假",
} }
for i, code in enumerate(status_codes_order): for r in rows:
count = sum(1 for r in rows if (r.status_code or "").lower() == code) c = (r.status_code or "").lower()
overview.write(11 + i, 1, status_labels.get(code, code), cell_fmt_ov) if not c.strip():
_grp_status["(空)"] += 1
elif c == "normal":
_grp_status["正常"] += 1
elif "late" in c and "early" in c:
_grp_status["遲到"] += 1
elif "late" in c:
_grp_status["遲到"] += 1
elif "early" in c:
_grp_status["早退"] += 1
elif "ot" in c:
_grp_status["加班"] += 1
elif c in ("abnormal", "missing", "", " "):
_grp_status["異常/缺勤"] += 1
else:
_grp_status["假期/請假"] += 1
_total = len(rows)
for i, (label, count) in enumerate(_grp_status.items()):
overview.write(11 + i, 1, label, cell_fmt_ov)
overview.write(11 + i, 2, count, cell_fmt_ov) overview.write(11 + i, 2, count, cell_fmt_ov)
pct = (count / len(rows) * 100) if rows else 0 pct = (count / _total * 100) if _total else 0
overview.write(11 + i, 3, f"{pct:.1f}%", cell_fmt_ov) overview.write(11 + i, 3, f"{pct:.1f}%", cell_fmt_ov)
unk = sum(1 for r in rows if not (r.status_code or "").strip()) _n_status_groups = len(_grp_status)
overview.write(11 + len(status_codes_order), 1, "(空)", cell_fmt_ov) # Also update KPI counters to match simplified grouping
overview.write(11 + len(status_codes_order), 2, unk, cell_fmt_ov) n_late_grp = _grp_status["遲到"]
pct_unk = (unk / len(rows) * 100) if rows else 0 n_early_grp = _grp_status["早退"]
overview.write(11 + len(status_codes_order), 3, f"{pct_unk:.1f}%", cell_fmt_ov) n_ot_grp = _grp_status["加班"]
n_abn_grp = _grp_status["異常/缺勤"]
# Department TOP 10 table (F10:G) # Department TOP 10 table (F10:G)
overview.write("F10", "部門記錄數 (TOP 10)", section_fmt) overview.write("F10", "部門記錄數 (TOP 10)", section_fmt)
@@ -3364,8 +3654,8 @@ async def export_excel(
status_pie = wb.add_chart({"type": "pie"}) status_pie = wb.add_chart({"type": "pie"})
status_pie.add_series({ status_pie.add_series({
"name": "出勤狀態分佈", "name": "出勤狀態分佈",
"categories": ["總覽", 11, 1, 11 + len(status_codes_order), 1], "categories": ["總覽", 11, 1, 11 + _n_status_groups, 1],
"values": ["總覽", 11, 2, 11 + len(status_codes_order), 2], "values": ["總覽", 11, 2, 11 + _n_status_groups, 2],
"data_labels": {"percentage": True, "category": False, "position": "outside_end"}, "data_labels": {"percentage": True, "category": False, "position": "outside_end"},
}) })
status_pie.set_title({"name": "出勤狀態分佈 (Pie)"}) status_pie.set_title({"name": "出勤狀態分佈 (Pie)"})
@@ -3395,8 +3685,8 @@ async def export_excel(
status_col_chart = wb.add_chart({"type": "column"}) status_col_chart = wb.add_chart({"type": "column"})
status_col_chart.add_series({ status_col_chart.add_series({
"name": "出勤狀態 數量", "name": "出勤狀態 數量",
"categories": ["總覽", 11, 1, 11 + len(status_codes_order) - 1, 1], "categories": ["總覽", 11, 1, 11 + _n_status_groups - 1, 1],
"values": ["總覽", 11, 2, 11 + len(status_codes_order) - 1, 2], "values": ["總覽", 11, 2, 11 + _n_status_groups - 1, 2],
"fill": {"color": "#10B981"}, "fill": {"color": "#10B981"},
"border": {"color": "#065F46"}, "border": {"color": "#065F46"},
"data_labels": {"value": True}, "data_labels": {"value": True},
+2 -1
View File
@@ -3,7 +3,8 @@ uvicorn[standard]==0.30.6
sqlalchemy==2.0.35 sqlalchemy==2.0.35
pydantic==2.9.2 pydantic==2.9.2
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
passlib==1.7.4 passlib[bcrypt]==1.7.4
bcrypt==4.0.1
python-multipart==0.0.12 python-multipart==0.0.12
openpyxl==3.1.5 openpyxl==3.1.5
reportlab==4.2.5 reportlab==4.2.5
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "aars-frontend", "name": "aars-frontend",
"version": "1.0.0", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aars-frontend", "name": "aars-frontend",
"version": "1.0.0", "version": "1.2.0",
"dependencies": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",
"lucide-react": "^0.441.0", "lucide-react": "^0.441.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "aars-frontend", "name": "aars-frontend",
"private": true, "private": true,
"version": "1.0.0", "version": "1.3.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+77 -99
View File
@@ -1,26 +1,30 @@
import { CheckCircle, AlertCircle, Clock, X, AlertTriangle, Briefcase, Stethoscope, Timer, PartyPopper } from 'lucide-react' import { CheckCircle, AlertCircle, CalendarX, Palmtree, Briefcase, Coffee } from 'lucide-react'
/** /**
* Reusable status badge for attendance records. * Reusable status badge for attendance records.
* Handles all status codes including enriched leave types: * Simplified to BASIC status categories no detailed breakdowns.
* normal / late / late_early / late_ot / early / early_ot / ot / abnormal / missing *
* holiday / al / sl / cl / mixed_leave * Status mapping (driven by backend status_code / status_text):
* - missing 缺勤 (grey)
* - holiday 公假 (indigo)
* - al / sl / cl / mixed_leave 請假 (violet)
* - normal 正常 (green)
* - everything else (late/early/ot/abnormal/late_early/...) 異常 (orange)
*
* If status_text carries a leave/holiday suffix (e.g. '+ SL2h', '🎉香港…'),
* a small secondary chip is rendered next to the main badge so the list page
* shows the underlying leave/holiday detail without expanding the row.
* *
* Props: * Props:
* - code: status_code string * - code: status_code string
* - text: status_text string (already enriched) * - text: status_text string (fallback signal + suffix source)
* - size: 'sm' | 'md' (default 'sm') * - size: 'sm' | 'md' (default 'sm')
*/ */
export default function StatusBadge({ code, text = '', size = 'sm' }) { export default function StatusBadge({ code = '', text = '', size = 'sm' }) {
const sizeCls = size === 'md' const sizeCls = size === 'md'
? 'px-3 py-1 text-sm' ? 'px-3 py-1 text-sm'
: 'px-2 py-0.5 text-xs' : 'px-2 py-0.5 text-xs'
// Split text into main + leave parts (after ' + ' separator)
const parts = text.split(/\s*\+\s*/).filter(Boolean)
const mainText = parts[0] || ''
const leaveParts = parts.slice(1)
const badge = (icon, label, colorCls) => ( const badge = (icon, label, colorCls) => (
<span className={`inline-flex items-center gap-1 ${sizeCls} rounded-full font-medium ${colorCls}`}> <span className={`inline-flex items-center gap-1 ${sizeCls} rounded-full font-medium ${colorCls}`}>
{icon} {icon}
@@ -28,97 +32,71 @@ export default function StatusBadge({ code, text = '', size = 'sm' }) {
</span> </span>
) )
// ============ Attendance status badges ============ // Extract leave suffix from status_text (holiday suffix dropped the main
if (code === 'normal') { // badge already says , so repeating the long name like
return ( // '' clutters the row).
<span className="inline-flex items-center gap-1.5"> // '-48 + SL2h' ['SL2h']
{badge(<CheckCircle size={12} />, mainText || '正常', 'bg-green-100 text-green-700')} // 'OT3 + AL4h + SL3h' ['AL4h', 'SL3h']
{leaveParts.map((lp, i) => ( // ' + CL4h' ['CL4h']
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600"> const t = String(text || '')
+ {lp} const noteParts = []
</span> const split = t.split(' + ').map(s => s.trim())
))} if (split.length > 1) {
</span> for (let i = 1; i < split.length; i++) {
) noteParts.push(split[i].replace(/^\(|\)$/g, '').trim())
} }
if (code === 'late' || code === 'late_early' || code === 'late_ot') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<AlertCircle size={12} />, mainText || code, 'bg-orange-100 text-orange-700')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
+ {lp}
</span>
))}
</span>
)
}
if (code === 'early' || code === 'early_ot') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<Clock size={12} />, mainText || code, 'bg-blue-100 text-blue-700')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
+ {lp}
</span>
))}
</span>
)
}
if (code === 'ot') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<Clock size={12} />, mainText || '加班', 'bg-purple-100 text-purple-700')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
+ {lp}
</span>
))}
</span>
)
}
if (code === 'abnormal') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<AlertTriangle size={12} />, mainText || '⚠️異常', 'bg-red-100 text-red-700')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
+ {lp}
</span>
))}
</span>
)
}
if (code === 'missing') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<X size={12} />, '缺勤', 'bg-gray-200 text-gray-600')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
{lp}
</span>
))}
</span>
)
} }
// ============ Enriched leave status badges ============ const noteChips = noteParts.filter(Boolean).map((part, idx) => {
if (code === 'holiday') { const cls = 'bg-violet-50 text-violet-600 border border-violet-200'
return badge(<PartyPopper size={12} />, mainText, 'bg-amber-100 text-amber-700') return (
} <span
if (code === 'al') { key={idx}
return badge(<Briefcase size={12} />, mainText || '年假', 'bg-emerald-100 text-emerald-700') className={`inline-flex items-center ${sizeCls} rounded-full font-normal ${cls}`}
} title={part}
if (code === 'sl') { >
return badge(<Stethoscope size={12} />, mainText || '病假', 'bg-pink-100 text-pink-700') +{part}
} </span>
if (code === 'cl') { )
return badge(<Timer size={12} />, mainText || '補鐘', 'bg-indigo-100 text-indigo-700') })
}
if (code === 'mixed_leave') { const wrap = (content) => (
return badge(<Timer size={12} />, mainText || '混合假', 'bg-fuchsia-100 text-fuchsia-700') <span className="inline-flex items-center gap-1 flex-wrap">
{content}
{noteChips}
</span>
)
const c = String(code || '').toLowerCase()
// (missing)
if (c === 'missing' || t === '缺勤') {
return wrap(badge(<CalendarX size={12} />, '缺勤', 'bg-gray-200 text-gray-700'))
} }
// fallback // (rest day roster says not scheduled, employee didn't clock in)
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs text-slate-500 bg-slate-100`}>{text || code || '-'}</span> if (c === 'rest' || t === '休息') {
return wrap(badge(<Coffee size={12} />, '休息', 'bg-slate-100 text-slate-600'))
}
// (holiday)
if (c === 'holiday' || t.includes('公假') || t.includes('🏖️')) {
return wrap(badge(<Palmtree size={12} />, '公假', 'bg-indigo-100 text-indigo-700'))
}
// (any leave type)
if (['al', 'sl', 'cl', 'mixed_leave'].includes(c)
|| t.includes('年假') || t.includes('病假') || t.includes('補鐘')
|| t.includes('混合假')) {
return wrap(badge(<Briefcase size={12} />, '請假', 'bg-violet-100 text-violet-700'))
}
// (only when status_code is 'normal' text suffix like '+ CL4h' is allowed,
// we still want to flag it as first since the employee did clock in/out normally)
if (c === 'normal') {
return wrap(badge(<CheckCircle size={12} />, '正常', 'bg-green-100 text-green-700'))
}
// (late/early/ot/abnormal/late_early/late_ot/early_ot/...)
return wrap(badge(<AlertCircle size={12} />, '異常', 'bg-orange-100 text-orange-700'))
} }
+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 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 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 missingRank = [...byStaff].sort((a, b) => (b.missing_count || 0) - (a.missing_count || 0))
const attendanceRate = [...byStaff].map(s => ({ // Use backend's pre-computed attendance_rate = (total - missing) / total * 100
...s, // (handles missing records correctly; a record can still be late/early/ot
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 // and count as attended)
})).sort((a, b) => b.rate - a.rate) const attendanceRate = [...byStaff]
.map(s => ({ ...s, rate: s.attendance_rate ?? 0 }))
.sort((a, b) => b.rate - a.rate)
const statCards = summary ? [ const statCards = summary ? [
{ label: '總記錄', value: summary.total_records, icon: Calendar, color: 'bg-blue-50 text-blue-600' }, { label: '總記錄', value: summary.total_records, icon: Calendar, color: 'bg-blue-50 text-blue-600' },
+36 -111
View File
@@ -2,7 +2,8 @@ import { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import api from '../../api' import api from '../../api'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { Search, Eye, ChevronUp, ChevronDown, Filter, X, AlertCircle, Clock, FileCode } from 'lucide-react' import { Search, Eye, ChevronUp, ChevronDown, Filter, X, FileCode } from 'lucide-react'
import StatusBadge from '../components/StatusBadge'
const handleExportHTML = (section) => { const handleExportHTML = (section) => {
const token = localStorage.getItem('aars_token') const token = localStorage.getItem('aars_token')
@@ -18,23 +19,22 @@ export default function AttendanceList() {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState(null) const [sortKey, setSortKey] = useState(null)
const [sortDir, setSortDir] = useState('asc') const [sortDir, setSortDir] = useState('asc')
const [filterKey, setFilterKey] = useState('') const [statusFilter, setStatusFilter] = useState('')
const [filterValue, setFilterValue] = useState('')
const [latenessRecords, setLatenessRecords] = useState([])
useEffect(() => { useEffect(() => {
fetchRecords() fetchRecords()
fetchLateness() }, [statusFilter])
}, [])
const fetchRecords = async () => { const fetchRecords = async () => {
setLoading(true) setLoading(true)
try { try {
const { data } = await api.get('/api/attendance?page=1&page_size=1000') const params = { page: 1, per_page: 1000 }
setRecords(data.data || []) if (statusFilter) params.status_text = statusFilter
const { data } = await api.get('/api/attendance/records', { params })
setRecords(data.records || [])
if (data.data && data.data.length > 0) { if (data.records && data.records.length > 0) {
const keys = Object.keys(data.data[0]).filter(k => k !== '_row') const keys = Object.keys(data.records[0]).filter(k => k !== '_row')
setHeaders(keys) setHeaders(keys)
} }
} catch (err) { } catch (err) {
@@ -44,37 +44,10 @@ export default function AttendanceList() {
} }
} }
const fetchLateness = async () => { // Filtered and sorted data (client-side: search + sort only; status handled by backend)
try {
const { data } = await api.get('/api/attendance/lateness/records')
setLatenessRecords(data || [])
} catch (err) {
console.error('Failed to load lateness data:', err)
}
}
// Create a map of late records for quick lookup
const latenessMap = useMemo(() => {
const map = {}
latenessRecords.forEach(rec => {
const key = `${rec.staff}-${rec.date}`
map[key] = rec
})
return map
}, [latenessRecords])
// Filtered and sorted data
const processedData = useMemo(() => { const processedData = useMemo(() => {
let result = [...records] let result = [...records]
// Filter
if (filterKey && filterValue) {
result = result.filter(r => {
const val = String(r[filterKey] || '').toLowerCase()
return val.includes(filterValue.toLowerCase())
})
}
// Search // Search
if (search) { if (search) {
const searchLower = search.toLowerCase() const searchLower = search.toLowerCase()
@@ -104,7 +77,7 @@ export default function AttendanceList() {
} }
return result return result
}, [records, search, sortKey, sortDir, filterKey, filterValue]) }, [records, search, sortKey, sortDir])
const handleSort = (key) => { const handleSort = (key) => {
if (sortKey === key) { if (sortKey === key) {
@@ -115,42 +88,11 @@ export default function AttendanceList() {
} }
} }
const handleFilter = (key) => {
setFilterKey(key)
setFilterValue('')
}
const clearFilter = () => {
setFilterKey('')
setFilterValue('')
}
const getSortIcon = (key) => { const getSortIcon = (key) => {
if (sortKey !== key) return null if (sortKey !== key) return null
return sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} /> return sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
} }
// Get lateness info for a record
const getLatenessInfo = (record) => {
// Find staff name and date columns
const staffKey = Object.keys(record).find(k =>
k.toLowerCase().includes('staff') || k.toLowerCase().includes('name') || k.toLowerCase().includes('員工')
)
const dateKey = Object.keys(record).find(k =>
k.toLowerCase().includes('date') || k.toLowerCase().includes('日期')
)
if (!staffKey || !dateKey) return null
const staff = record[staffKey]
const date = record[dateKey]
if (!staff || !date) return null
const key = `${staff}-${String(date).slice(0, 10)}`
return latenessMap[key]
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Header */} {/* Header */}
@@ -188,43 +130,38 @@ export default function AttendanceList() {
/> />
</div> </div>
{/* Column Filter */} {/* Status Filter (basic categories — server-side) */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Filter size={16} className="text-slate-400" /> <Filter size={16} className="text-slate-400" />
<select <select
value={filterKey} value={statusFilter}
onChange={(e) => handleFilter(e.target.value)} onChange={(e) => setStatusFilter(e.target.value)}
className="text-sm border border-slate-300 rounded-md px-2 py-2" className="text-sm border border-slate-300 rounded-md px-2 py-2"
> >
<option value="">篩選欄位</option> <option value="">全部狀態</option>
{headers.slice(0, 6).map(h => ( <option value="normal">正常</option>
<option key={h} value={h}>{h}</option> <option value="abnormal">異常</option>
))} <option value="missing">缺勤</option>
<option value="holiday">公假</option>
<option value="leave">請假</option>
</select> </select>
{filterKey && ( {statusFilter && (
<> <button
<input onClick={() => setStatusFilter('')}
type="text" className="p-1 text-slate-400 hover:text-slate-600"
value={filterValue} title="清除篩選"
onChange={(e) => setFilterValue(e.target.value)} >
placeholder="輸入篩選值..."
className="text-sm border border-slate-300 rounded-md px-2 py-2 w-32"
/>
<button onClick={clearFilter} className="p-1 text-slate-400 hover:text-slate-600">
<X size={16} /> <X size={16} />
</button> </button>
</>
)} )}
</div> </div>
{/* Result count */} {/* Result count */}
<div className="text-sm text-slate-500 py-2"> <div className="text-sm text-slate-500 py-2">
{processedData.length} 筆記錄 {processedData.length} 筆記錄
{latenessRecords.length > 0 && ( {statusFilter && (
<span className="ml-2 text-orange-600"> <span className="ml-2 text-slate-400"> 已篩選{statusFilter}</span>
{latenessRecords.length} 筆遲到
</span>
)} )}
</div> </div>
</div> </div>
@@ -267,14 +204,10 @@ export default function AttendanceList() {
</td> </td>
</tr> </tr>
) : ( ) : (
processedData.map((record) => { processedData.map((record) => (
const lateInfo = getLatenessInfo(record)
const isLate = lateInfo && lateInfo.minutes_late > 0
return (
<tr <tr
key={record._row} key={record._row}
className={`hover:bg-slate-50 cursor-pointer ${isLate ? 'bg-orange-50' : ''}`} className="hover:bg-slate-50 cursor-pointer"
onClick={() => window.location.href = `/attendance/${record._row}`} onClick={() => window.location.href = `/attendance/${record._row}`}
> >
<td className="px-3 py-2 text-xs text-slate-400">{record._row}</td> <td className="px-3 py-2 text-xs text-slate-400">{record._row}</td>
@@ -284,17 +217,10 @@ export default function AttendanceList() {
</td> </td>
))} ))}
<td className="px-3 py-2 text-center"> <td className="px-3 py-2 text-center">
{isLate ? ( <StatusBadge
<div className="flex items-center justify-center gap-1 text-orange-600" title={`遲到 ${lateInfo.minutes_late} 分鐘`}> code={record.status_code}
<AlertCircle size={14} /> text={record.status_text}
<span className="text-xs font-medium">{lateInfo.minutes_late}</span> />
</div>
) : (
<div className="flex items-center justify-center gap-1 text-green-600">
<Clock size={14} />
<span className="text-xs">正常</span>
</div>
)}
</td> </td>
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}> <td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
<Link <Link
@@ -305,8 +231,7 @@ export default function AttendanceList() {
</Link> </Link>
</td> </td>
</tr> </tr>
) ))
})
)} )}
</tbody> </tbody>
</table> </table>
+198 -3
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { Trash2, AlertTriangle, Shield, Download, Upload, RefreshCw, Database, Info } from "lucide-react" import { Trash2, AlertTriangle, Shield, Download, Upload, RefreshCw, Database, Info, KeyRound, User as UserIcon } from "lucide-react"
import api from "../api" import api from "../api"
import toast from "react-hot-toast" import toast from "react-hot-toast"
@@ -18,10 +18,16 @@ const RESET_SECTIONS = [
}, },
{ {
key: "holidays", key: "holidays",
label: "假期 Holidays + Leave", label: "假期 Holidays",
description: "刪除所有公眾假期 + 員工請假記錄", description: "刪除所有公眾假期記錄",
color: "amber", color: "amber",
}, },
{
key: "leaves",
label: "請假 Leaves",
description: "刪除所有員工請假記錄",
color: "lime",
},
] ]
export default function Settings() { export default function Settings() {
@@ -35,11 +41,68 @@ export default function Settings() {
const [restoring, setRestoring] = useState(null) const [restoring, setRestoring] = useState(null)
const [restoreConfirm, setRestoreConfirm] = useState("") 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(() => { useEffect(() => {
fetchVersion() fetchVersion()
fetchBackups() 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 () => { const fetchVersion = async () => {
try { try {
const { data } = await api.get("/api/version") const { data } = await api.get("/api/version")
@@ -212,6 +275,22 @@ export default function Settings() {
> >
<Upload className="w-3.5 h-3.5" /> 還原 <Upload className="w-3.5 h-3.5" /> 還原
</button> </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> </div>
)) ))
@@ -251,6 +330,122 @@ export default function Settings() {
)} )}
</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 */} {/* Danger Zone */}
<div className="bg-white border border-red-200 rounded-xl overflow-hidden"> <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="px-5 py-4 border-b border-red-100 bg-red-50/50">
+28 -13
View File
@@ -17,15 +17,24 @@ export default function AttendanceList() {
const [staffFilter, setStaffFilter] = useState('') const [staffFilter, setStaffFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('') const [statusFilter, setStatusFilter] = useState('')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [statusOptions, setStatusOptions] = useState([])
const perPage = 50 const perPage = 50
const [staffList, setStaffList] = useState([]) const [staffList, setStaffList] = useState([])
useEffect(() => { useEffect(() => {
fetchRecords() fetchRecords()
fetchStatusTexts()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, sortBy, sortOrder, dateFrom, dateTo, staffFilter, statusFilter, search]) }, [page, sortBy, sortOrder, dateFrom, dateTo, staffFilter, statusFilter, search])
const fetchStatusTexts = async () => {
try {
const { data } = await api.get('/api/attendance/status_texts')
setStatusOptions(data || [])
} catch (e) { console.error('Failed to fetch status texts', e) }
}
const fetchRecords = async () => { const fetchRecords = async () => {
setLoading(true) setLoading(true)
try { try {
@@ -39,7 +48,7 @@ export default function AttendanceList() {
if (dateFrom) params.append('date_from', dateFrom) if (dateFrom) params.append('date_from', dateFrom)
if (dateTo) params.append('date_to', dateTo) if (dateTo) params.append('date_to', dateTo)
if (staffFilter) params.append('staff', staffFilter) if (staffFilter) params.append('staff', staffFilter)
if (statusFilter) params.append('status', statusFilter) if (statusFilter) params.append('status_text', statusFilter)
const { data } = await api.get(`/api/attendance/records?${params}`) const { data } = await api.get(`/api/attendance/records?${params}`)
setRecords(data.records || []) setRecords(data.records || [])
@@ -78,15 +87,21 @@ export default function AttendanceList() {
} }
const getStatusBadge = (record) => { const getStatusBadge = (record) => {
// StatusBadge now drives from status_code + status_text only (basic 5 categories).
return <StatusBadge code={record.status_code} text={record.status_text} /> return <StatusBadge code={record.status_code} text={record.status_text} />
} }
const getRowClass = (code) => { const getRowClass = (code) => {
if (code === 'abnormal') return 'bg-red-50 hover:bg-red-100' // Match the basic-status grouping so highlights line up with the badge.
if (code === 'missing') return 'bg-gray-100 hover:bg-gray-200' const c = String(code || '').toLowerCase()
if (code === 'late' || code === 'late_early' || code === 'late_ot') return 'bg-orange-50 hover:bg-orange-100' if (c === 'abnormal' || c === 'late' || c === 'early' || c === 'ot'
if (code === 'early' || code === 'early_ot') return 'bg-blue-50 hover:bg-blue-100' || c === 'late_early' || c === 'late_ot' || c === 'early_ot') {
if (code === 'ot') return 'bg-purple-50 hover:bg-purple-100' return 'bg-orange-50 hover:bg-orange-100'
}
if (c === 'missing') return 'bg-gray-100 hover:bg-gray-200'
if (c === 'rest') return 'bg-slate-50 hover:bg-slate-100'
if (c === 'holiday') return 'bg-indigo-50 hover:bg-indigo-100'
if (['al', 'sl', 'cl', 'mixed_leave'].includes(c)) return 'bg-violet-50 hover:bg-violet-100'
return 'hover:bg-slate-50' return 'hover:bg-slate-50'
} }
@@ -176,16 +191,16 @@ export default function AttendanceList() {
<span className="text-xs text-slate-500">狀態:</span> <span className="text-xs text-slate-500">狀態:</span>
<select <select
value={statusFilter} value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }} onChange={(e) => {
setStatusFilter(e.target.value)
setPage(1)
}}
className="text-sm border border-slate-300 rounded-md px-2 py-1.5" className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
> >
<option value="">全部</option> <option value="">全部</option>
<option value="normal">正常</option> {statusOptions.map(opt => (
<option value="late">遲到</option> <option key={opt} value={opt}>{opt}</option>
<option value="early">早退</option> ))}
<option value="ot">OT</option>
<option value="abnormal">異常</option>
<option value="missing">缺勤</option>
</select> </select>
</div> </div>
+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}")