Compare commits
35 Commits
89c14c7a72
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| acbc767247 | |||
| f6712d9d0e | |||
| 262c44f6a1 | |||
| 3c855d521b | |||
| 5596ab2cb5 | |||
| a7dfd208d2 | |||
| a16e243da1 | |||
| 4a01b53541 | |||
| 404329d3fa | |||
| 811b9991c9 | |||
| f12a239e26 | |||
| fcc3246d8e | |||
| 3ba6617009 | |||
| 4bd6d0e7c8 | |||
| 7702fe4ae5 | |||
| d302fa9beb | |||
| 4b967f7920 | |||
| 8b378ec4c6 | |||
| 7dd53e0a5f | |||
| 33a5e3b2ad | |||
| 4c9db01213 | |||
| ef57d7cd59 | |||
| f41dad6127 | |||
| 6f2df815f5 | |||
| c1b1d6f35b | |||
| e531cea8a4 | |||
| 2d64d3f0b6 | |||
| 08ba4cbca9 | |||
| c07f40b28f | |||
| 2d9b73df9d | |||
| 9980f8d80a | |||
| 1e7d58486b | |||
| cca088261b | |||
| adc9e6451b | |||
| a34905661b |
+14
-1
@@ -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)
|
||||
|
||||
|
||||
+346
-56
@@ -9,7 +9,7 @@ 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 *
|
||||
@@ -126,8 +126,40 @@ def enrich_attendance_with_leave(records, db):
|
||||
if not r.date:
|
||||
continue
|
||||
|
||||
# Rest day detection: roster says the shift rests on this weekday
|
||||
# (e.g. Sunday for S2/S3) but raw status is 'missing'. Don't flag as 缺勤
|
||||
# when the employee wasn't scheduled to work.
|
||||
if (r.status_code or '').lower() == 'missing' and r.shift_code and r.weekday:
|
||||
shift_info = get_shift_schedule_full(r.shift_code, r.weekday)
|
||||
if shift_info is None:
|
||||
r.status_code = 'rest'
|
||||
r.status_text = '休息'
|
||||
continue
|
||||
|
||||
if r.date in holidays_by_date:
|
||||
h = holidays_by_date[r.date]
|
||||
# Check roster: if shift's public_holiday == 'Yes', employee works on holidays
|
||||
# Don't override with holiday status in that case
|
||||
shift_holiday = '休息' # default: assume rest on holiday
|
||||
if r.shift_code:
|
||||
shift_info = get_shift_schedule_full(r.shift_code, r.weekday or '')
|
||||
if shift_info and len(shift_info) >= 3:
|
||||
shift_holiday = shift_info[2]
|
||||
if shift_holiday == 'Yes':
|
||||
# Employee works on public holidays - keep attendance status, append holiday note
|
||||
base = r.status_text or "正常"
|
||||
r.status_text = f"{base} (🎉{h.name})"
|
||||
continue
|
||||
# Employee rests on public holidays - mark as holiday unless already has clock-in
|
||||
has_clock_in = (
|
||||
(r.check_in is not None) or
|
||||
(r.actual_in and r.actual_in not in ("", "-"))
|
||||
)
|
||||
if not has_clock_in:
|
||||
r.status_code = "holiday"
|
||||
r.status_text = f"\U0001F3D6\uFE0F{h.name}"
|
||||
continue
|
||||
# Has clock-in but roster says rest on holiday - still show holiday
|
||||
r.status_code = "holiday"
|
||||
r.status_text = f"\U0001F3D6\uFE0F{h.name}"
|
||||
continue
|
||||
@@ -158,7 +190,23 @@ def enrich_attendance_with_leave(records, db):
|
||||
else:
|
||||
r.status_code = "mixed_leave"
|
||||
r.status_text = leave_text
|
||||
elif original_code in ("al", "sl", "cl"):
|
||||
# Pure leave status: replace with aggregated leave text
|
||||
if len(leave_by_type) > 1:
|
||||
r.status_code = "mixed_leave"
|
||||
r.status_text = leave_text
|
||||
else:
|
||||
r.status_text = leave_text
|
||||
elif original_code not in ("missing", "", "holiday", "al", "sl", "cl"):
|
||||
# Non-leave attendance status: strip any previously-enriched leave suffix and rebuild
|
||||
base_text = (r.status_text or "正常").split(" + ")[0] # keep only attendance part
|
||||
base_text = base_text.split(" (")[0] # strip holiday annotation
|
||||
if leave_text:
|
||||
r.status_text = f"{base_text} + {leave_text}"
|
||||
else:
|
||||
r.status_text = base_text
|
||||
else:
|
||||
# original_code is empty or holiday or already handled above
|
||||
base = r.status_text or "正常"
|
||||
r.status_text = f"{base} + {leave_text}"
|
||||
|
||||
@@ -206,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}})
|
||||
@@ -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)):
|
||||
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"""
|
||||
@@ -261,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")
|
||||
|
||||
@@ -494,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):
|
||||
@@ -508,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
|
||||
@@ -539,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]
|
||||
@@ -551,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
if check_out_dt < expected_end_dt:
|
||||
diff = (expected_end_dt - check_out_dt).total_seconds() / 60
|
||||
result["early_minutes"] = round(diff)
|
||||
diff_seconds = (expected_end_dt - check_out_dt).total_seconds()
|
||||
result["early_minutes"] = round(diff_seconds / 60) if diff_seconds >= 30 else 0
|
||||
|
||||
# Calculate OT minutes (leaving after scheduled end)
|
||||
if check_out_dt > expected_end_dt:
|
||||
diff = (check_out_dt - expected_end_dt).total_seconds() / 60
|
||||
result["ot_minutes"] = round(diff)
|
||||
diff_seconds = (check_out_dt - expected_end_dt).total_seconds()
|
||||
# Ignore tiny differences (< 30 seconds) to avoid floating-point noise
|
||||
if diff_seconds >= 30:
|
||||
result["ot_minutes"] = round(diff_seconds / 60)
|
||||
else:
|
||||
result["ot_minutes"] = 0
|
||||
|
||||
# Determine status code and text
|
||||
status_parts = []
|
||||
@@ -696,6 +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
|
||||
|
||||
|
||||
@@ -1019,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),
|
||||
@@ -1028,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,
|
||||
@@ -1174,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_
|
||||
|
||||
@@ -1204,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 {
|
||||
@@ -1243,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,
|
||||
@@ -1822,6 +2055,7 @@ import shutil, hashlib
|
||||
from datetime import datetime as _dt2
|
||||
|
||||
BACKUP_DIR = Path("/app/data/backups")
|
||||
DB_PATH = "/app/data/aars.db"
|
||||
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _backup_path(name):
|
||||
@@ -1885,6 +2119,48 @@ async def restore_backup(
|
||||
shutil.copy2(fp, src)
|
||||
return {"restored": filename, "auto_backup": auto_dst.name if src.exists() else None}
|
||||
|
||||
|
||||
@app.delete("/api/admin/backup/{filename}")
|
||||
async def delete_backup(
|
||||
filename: str,
|
||||
current_user: User = Depends(get_current_admin),
|
||||
):
|
||||
"""Delete a backup file."""
|
||||
fp = BACKUP_DIR / filename
|
||||
if not fp.exists() or ".." in filename:
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
fp.unlink()
|
||||
return {"deleted": filename}
|
||||
|
||||
|
||||
@app.post("/api/admin/reset/{section}")
|
||||
async def reset_section(
|
||||
section: str,
|
||||
current_user: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Reset (truncate) a specific data section. Creates auto-backup first."""
|
||||
single_models = {"attendance": AttendanceRecord, "accident": Accident}
|
||||
if section not in single_models and section not in ("holidays", "leaves"):
|
||||
raise HTTPException(status_code=400, detail=f"Unknown section: {section}")
|
||||
# Auto-backup before reset
|
||||
src = Path(DB_PATH)
|
||||
auto_dst = None
|
||||
if src.exists():
|
||||
auto_dst = _backup_path(f"auto_pre_{section}_reset")
|
||||
shutil.copy2(src, auto_dst)
|
||||
# Truncate table(s)
|
||||
if section == "holidays":
|
||||
db.execute(Holiday.__table__.delete())
|
||||
db.execute(LeaveRecord.__table__.delete())
|
||||
elif section == "leaves":
|
||||
db.execute(LeaveRecord.__table__.delete())
|
||||
else:
|
||||
db.execute(single_models[section].__table__.delete())
|
||||
db.commit()
|
||||
return {"reset": section, "auto_backup": auto_dst.name if auto_dst else None}
|
||||
|
||||
|
||||
@app.get("/api/version")
|
||||
async def get_version(
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -1908,7 +2184,7 @@ async def get_version(
|
||||
commit = "unknown"
|
||||
date = ""
|
||||
return {
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"commit": commit,
|
||||
"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 9 事件描述 -> long_description
|
||||
Col 10 如有事件相關照片可提供-> incident_photos
|
||||
Col 11 處理情況/已採取的行動-> action_taken
|
||||
Col 11 處理情況/已採取的行動-> action_taken
|
||||
Col 12 需要管理層介入的事項 -> needs_escalation
|
||||
Col 13 第 12 欄 -> (skipped)
|
||||
Col 14 分數 -> incident_score
|
||||
@@ -2750,7 +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,
|
||||
@@ -2758,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.
|
||||
|
||||
@@ -2868,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.
|
||||
@@ -2928,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)
|
||||
@@ -2972,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.
|
||||
@@ -3019,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)
|
||||
|
||||
@@ -3057,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: 明細 ============
|
||||
@@ -3322,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)
|
||||
@@ -3364,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)"})
|
||||
@@ -3395,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},
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "aars-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
+58
-133
@@ -2,7 +2,8 @@ import { useState, useEffect, useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Search, Eye, ChevronUp, ChevronDown, Filter, X, AlertCircle, Clock, FileCode } from 'lucide-react'
|
||||
import { Search, Eye, ChevronUp, ChevronDown, Filter, X, FileCode } from 'lucide-react'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
|
||||
const handleExportHTML = (section) => {
|
||||
const token = localStorage.getItem('aars_token')
|
||||
@@ -18,23 +19,22 @@ export default function AttendanceList() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortKey, setSortKey] = useState(null)
|
||||
const [sortDir, setSortDir] = useState('asc')
|
||||
const [filterKey, setFilterKey] = useState('')
|
||||
const [filterValue, setFilterValue] = useState('')
|
||||
const [latenessRecords, setLatenessRecords] = useState([])
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecords()
|
||||
fetchLateness()
|
||||
}, [])
|
||||
}, [statusFilter])
|
||||
|
||||
const fetchRecords = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await api.get('/api/attendance?page=1&page_size=1000')
|
||||
setRecords(data.data || [])
|
||||
const params = { page: 1, per_page: 1000 }
|
||||
if (statusFilter) params.status_text = statusFilter
|
||||
const { data } = await api.get('/api/attendance/records', { params })
|
||||
setRecords(data.records || [])
|
||||
|
||||
if (data.data && data.data.length > 0) {
|
||||
const keys = Object.keys(data.data[0]).filter(k => k !== '_row')
|
||||
if (data.records && data.records.length > 0) {
|
||||
const keys = Object.keys(data.records[0]).filter(k => k !== '_row')
|
||||
setHeaders(keys)
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -44,37 +44,10 @@ export default function AttendanceList() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchLateness = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/attendance/lateness/records')
|
||||
setLatenessRecords(data || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load lateness data:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a map of late records for quick lookup
|
||||
const latenessMap = useMemo(() => {
|
||||
const map = {}
|
||||
latenessRecords.forEach(rec => {
|
||||
const key = `${rec.staff}-${rec.date}`
|
||||
map[key] = rec
|
||||
})
|
||||
return map
|
||||
}, [latenessRecords])
|
||||
|
||||
// Filtered and sorted data
|
||||
// Filtered and sorted data (client-side: search + sort only; status handled by backend)
|
||||
const processedData = useMemo(() => {
|
||||
let result = [...records]
|
||||
|
||||
// Filter
|
||||
if (filterKey && filterValue) {
|
||||
result = result.filter(r => {
|
||||
const val = String(r[filterKey] || '').toLowerCase()
|
||||
return val.includes(filterValue.toLowerCase())
|
||||
})
|
||||
}
|
||||
|
||||
// Search
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase()
|
||||
@@ -104,7 +77,7 @@ export default function AttendanceList() {
|
||||
}
|
||||
|
||||
return result
|
||||
}, [records, search, sortKey, sortDir, filterKey, filterValue])
|
||||
}, [records, search, sortKey, sortDir])
|
||||
|
||||
const handleSort = (key) => {
|
||||
if (sortKey === key) {
|
||||
@@ -115,42 +88,11 @@ export default function AttendanceList() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilter = (key) => {
|
||||
setFilterKey(key)
|
||||
setFilterValue('')
|
||||
}
|
||||
|
||||
const clearFilter = () => {
|
||||
setFilterKey('')
|
||||
setFilterValue('')
|
||||
}
|
||||
|
||||
const getSortIcon = (key) => {
|
||||
if (sortKey !== key) return null
|
||||
return sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||
}
|
||||
|
||||
// Get lateness info for a record
|
||||
const getLatenessInfo = (record) => {
|
||||
// Find staff name and date columns
|
||||
const staffKey = Object.keys(record).find(k =>
|
||||
k.toLowerCase().includes('staff') || k.toLowerCase().includes('name') || k.toLowerCase().includes('員工')
|
||||
)
|
||||
const dateKey = Object.keys(record).find(k =>
|
||||
k.toLowerCase().includes('date') || k.toLowerCase().includes('日期')
|
||||
)
|
||||
|
||||
if (!staffKey || !dateKey) return null
|
||||
|
||||
const staff = record[staffKey]
|
||||
const date = record[dateKey]
|
||||
|
||||
if (!staff || !date) return null
|
||||
|
||||
const key = `${staff}-${String(date).slice(0, 10)}`
|
||||
return latenessMap[key]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
@@ -188,43 +130,38 @@ export default function AttendanceList() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Column Filter */}
|
||||
{/* Status Filter (basic categories — server-side) */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={16} className="text-slate-400" />
|
||||
<select
|
||||
value={filterKey}
|
||||
onChange={(e) => handleFilter(e.target.value)}
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-2"
|
||||
>
|
||||
<option value="">篩選欄位</option>
|
||||
{headers.slice(0, 6).map(h => (
|
||||
<option key={h} value={h}>{h}</option>
|
||||
))}
|
||||
<option value="">全部狀態</option>
|
||||
<option value="normal">正常</option>
|
||||
<option value="abnormal">異常</option>
|
||||
<option value="missing">缺勤</option>
|
||||
<option value="holiday">公假</option>
|
||||
<option value="leave">請假</option>
|
||||
</select>
|
||||
|
||||
{filterKey && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={filterValue}
|
||||
onChange={(e) => setFilterValue(e.target.value)}
|
||||
placeholder="輸入篩選值..."
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-2 w-32"
|
||||
/>
|
||||
<button onClick={clearFilter} className="p-1 text-slate-400 hover:text-slate-600">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</>
|
||||
{statusFilter && (
|
||||
<button
|
||||
onClick={() => setStatusFilter('')}
|
||||
className="p-1 text-slate-400 hover:text-slate-600"
|
||||
title="清除篩選"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Result count */}
|
||||
<div className="text-sm text-slate-500 py-2">
|
||||
{processedData.length} 筆記錄
|
||||
{latenessRecords.length > 0 && (
|
||||
<span className="ml-2 text-orange-600">
|
||||
• {latenessRecords.length} 筆遲到
|
||||
</span>
|
||||
{statusFilter && (
|
||||
<span className="ml-2 text-slate-400">• 已篩選:{statusFilter}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,46 +204,34 @@ export default function AttendanceList() {
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
processedData.map((record) => {
|
||||
const lateInfo = getLatenessInfo(record)
|
||||
const isLate = lateInfo && lateInfo.minutes_late > 0
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={record._row}
|
||||
className={`hover:bg-slate-50 cursor-pointer ${isLate ? 'bg-orange-50' : ''}`}
|
||||
onClick={() => window.location.href = `/attendance/${record._row}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-xs text-slate-400">{record._row}</td>
|
||||
{headers.slice(0, 6).map(h => (
|
||||
<td key={h} className="px-3 py-2 text-slate-700 max-w-xs truncate">
|
||||
{record[h] !== null && record[h] !== undefined ? String(record[h]) : '-'}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-3 py-2 text-center">
|
||||
{isLate ? (
|
||||
<div className="flex items-center justify-center gap-1 text-orange-600" title={`遲到 ${lateInfo.minutes_late} 分鐘`}>
|
||||
<AlertCircle size={14} />
|
||||
<span className="text-xs font-medium">{lateInfo.minutes_late}分</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-1 text-green-600">
|
||||
<Clock size={14} />
|
||||
<span className="text-xs">正常</span>
|
||||
</div>
|
||||
)}
|
||||
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-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>
|
||||
)
|
||||
})
|
||||
))}
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 toast from "react-hot-toast"
|
||||
|
||||
@@ -18,10 +18,16 @@ const RESET_SECTIONS = [
|
||||
},
|
||||
{
|
||||
key: "holidays",
|
||||
label: "假期 Holidays + Leave",
|
||||
description: "刪除所有公眾假期 + 員工請假記錄",
|
||||
label: "假期 Holidays",
|
||||
description: "刪除所有公眾假期記錄",
|
||||
color: "amber",
|
||||
},
|
||||
{
|
||||
key: "leaves",
|
||||
label: "請假 Leaves",
|
||||
description: "刪除所有員工請假記錄",
|
||||
color: "lime",
|
||||
},
|
||||
]
|
||||
|
||||
export default function Settings() {
|
||||
@@ -35,11 +41,68 @@ export default function Settings() {
|
||||
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")
|
||||
@@ -212,6 +275,22 @@ export default function Settings() {
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" /> 還原
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`確定要刪除 ${b.filename}?`)) return
|
||||
try {
|
||||
await api.delete(`/api/admin/backup/${b.filename}`)
|
||||
toast.success(`已刪除 ${b.filename}`)
|
||||
fetchBackups()
|
||||
} catch (e) {
|
||||
toast.error("刪除失敗: " + (e.response?.data?.detail || e.message))
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-red-500 hover:text-red-700 hover:bg-red-50 rounded-md transition-colors"
|
||||
title="Delete backup"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> 刪除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -251,6 +330,122 @@ export default function Settings() {
|
||||
)}
|
||||
</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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user