Compare commits

...

37 Commits

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

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

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

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

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

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

Late: 9 → 23, Early: 0 → 17, OT: 15 → 28
Note: component totals may now exceed total_records because each record
can carry multiple status components (e.g. late_ot).
2026-07-22 09:55:18 +08:00
IT狗 4a01b53541 Bump version 1.2.0 → 1.3.0
Status & filter improvements consolidated:
- StatusBadge: 5 basic categories + leave suffix chip
- Backend filter: enrich-then-filter so badges match result set
- 請假/公假 filter: also match leave/holiday suffix in status_text
- Rest day detection: roster rest weekday → 休息 (not 缺勤)
2026-07-22 00:06:44 +08:00
IT狗 404329d3fa Attendance: detect rest day in enrich → render 休息 instead of 缺勤
When roster says the shift rests on a given weekday (e.g. Sunday for S2/S3),
the employee wasn't scheduled to work. Import still marks the record
status_code='missing' because no shift times were available, but the
right user-facing label is '休息' not '缺勤'.

Enrich now: if status_code='missing' and get_shift_schedule_full returns
None (shift defined no schedule for that weekday), override to
status_code='rest' / status_text='休息'.

Added '休息' to the basic-status vocabulary: dropdown option, filter
condition, StatusBadge (slate grey + coffee icon), and row highlight.
2026-07-21 23:54:57 +08:00
IT狗 811b9991c9 Attendance filter: 請假/公假 — also match leave/holiday suffix in status_text
Pure-leave status_code (al/sl/cl/mixed_leave) only catches employees who
took the whole day off. Records where the employee worked AND took leave
mid-day keep their raw status_code (normal/abnormal/ot) but carry a
' + SL2h' / ' + AL4h' suffix that the badge now surfaces as a chip —
yet those records disappeared under the '請假' filter.

Same gap for holidays: '公假' only matched status_code='holiday' but an
employee who worked on the public holiday still gets the '🎉' suffix and
should be reachable from the holiday filter.

Expand both filters to also match records whose status_text contains the
relevant suffix (' + SL' / ' + AL' / ' + CL' / '🎉'), keeping the result
set aligned with what the badge chip advertises.
2026-07-21 23:39:15 +08:00
IT狗 f12a239e26 StatusBadge: drop holiday suffix chip — main badge already says 公假
The previous '🎉香港特別行政區成立紀念日' chip wrapped to 7 lines on
mobile and duplicated info already conveyed by the '公假' main badge.

Keep leave chips (+SL2h, +AL4h, etc.) since the basic-status badge
doesn't reveal the leave type, but skip holiday decoration entirely.
2026-07-21 22:41:45 +08:00
IT狗 fcc3246d8e StatusBadge: surface leave/holiday suffix as secondary chip
status_text fields like '異常-遲到48分 + SL2h' or '缺勤 (🎉香港特區…)' carried
important leave/holiday context that the simplified badge was dropping on the
list page — the user had no way to tell an abnormal record was partly covered
by sick leave.

Extract the suffix from status_text and render a small secondary chip next
to the basic-status badge (violet for leave types, indigo for holidays).
Detail page picks this up automatically since the component is shared.

Example: '異常 +SL2h' / '缺勤 🎉香港特區成立紀念日'
2026-07-21 22:31:26 +08:00
IT狗 3ba6617009 Attendance filter: enrich-then-filter so badge matches result set
The previous filter applied status_code conditions on raw DB rows, then
enriched the page of results. That meant a missing record with a leave
request would surface under '缺勤' (raw code='missing') but render a
'請假'/'公假' badge (enriched status_code).

Move status_text filter to AFTER enrichment so it matches the badge.
status_text sort_by='status_code' also moves to Python sort since
post-enrich codes aren't in the DB. Log a warning if the in-memory
filter scans >5000 rows so we know when to revisit.
2026-07-21 22:18:55 +08:00
IT狗 4bd6d0e7c8 StatusBadge: fix normal badge — only check status_code, not text
The previous condition 'c === "normal" && !t' required status_text to be
empty, but enriched records carry text suffixes like '正常 + CL4h' which
are truthy and dropped normal records into the '異常' fallback.

3 Kaka records (id=22/28/30) were affected — act_in 10:38/10:43 (early),
act_out 20:00 (on time), so they should render the green '正常' badge but
were showing '異常' instead.
2026-07-21 22:13:12 +08:00
IT狗 7702fe4ae5 Fix attendance filter: align with badge via status_code (not late/early/OT thresholds)
The previous filter used 'late_minutes == 0 AND early_minutes == 0 AND
ot_minutes == 0' for '正常', which incorrectly included records whose
import-time late_minutes was 0 but post-enrich status_code was 'abnormal'
(e.g. act_in=18:00 vs exp_in=11:00) or 'missing' (act_in='-').

Switch filter to status_code conditions so the result set matches the
badge rendered from status_code. Also accept the Chinese basic-status
labels (正常/異常/缺勤/公假/請假) as filter values, and return only those
5 from /api/attendance/status_texts so the dropdown vocabulary stays
in sync.

Frontend attendance list: simplify getStatusBadge to drop now-unused
late/early/ot props; align row highlight with the basic-status grouping.
2026-07-21 21:55:55 +08:00
IT狗 d302fa9beb List.jsx: fix API path /api/attendance -> /api/attendance/records
- Response key is 'records' not 'data'
- Backend /api/attendance/records uses 'per_page' (not 'page_size')
- Without this fix, list never loaded data — would always show 'no records'
2026-07-21 21:38:22 +08:00
IT狗 4b967f7920 StatusBadge: simplify to 5 basic categories (正常/異常/缺勤/公假/請假)
- StatusBadge no longer shows detailed late/early/OT minute breakdowns
  nor AL/SL/CL sub-types; collapse to single basic-status badge
- List page: replace text-input column filter with status dropdown
  (server-side ?status_text= filter; backend now supports 'leave' IN-clause
  for al/sl/cl/mixed_leave group)
- List page: badge now driven by backend status_code/status_text
  (remove frontend latenessMap which contradicted backend status)
2026-07-21 21:33:42 +08:00
IT狗 8b378ec4c6 StatusBadge: handle missing/holiday/AL/SL/CL/mixed_leave codes; pass status_text to StatusBadge 2026-07-21 21:14:12 +08:00
IT狗 7dd53e0a5f StatusBadge: preserve late/early/ot minutes for abnormal records (don't zero them out) 2026-07-21 18:22:03 +08:00
IT狗 33a5e3b2ad StatusBadge: split badges per dimension (abnormal/late/early/OT <=30min); Attendance filter by status_code conditions for abnormal/late/early/OT/normal/leave types 2026-07-21 18:10:43 +08:00
IT狗 4c9db01213 Add /api/attendance/status_texts endpoint; dynamic status filter in Attendance List (filter by status_text LIKE) 2026-07-21 18:02:32 +08:00
IT狗 ef57d7cd59 If late/early/OT > 30min → mark as abnormal status (override status_code+text, zero out minutes) 2026-07-21 17:52:36 +08:00
IT狗 f41dad6127 Fix: add 30-second tolerance for OT/early/late calculation to avoid floating-point noise (e.g. 20:00==20:00 showing OT 1min) 2026-07-21 17:41:14 +08:00
IT狗 6f2df815f5 Simplify Attendance export status groups to 6 categories: 正常|遲到|早退|加班|異常/缺勤|假期/請假 2026-07-21 13:38:58 +08:00
IT狗 c1b1d6f35b Add DELETE /api/admin/backup/{filename} and delete button in Settings 2026-07-21 13:14:56 +08:00
IT狗 e531cea8a4 Add leaves reset, split holidays/leave resets, support multi-table reset 2026-07-21 13:10:46 +08:00
IT狗 2d64d3f0b6 Add accident to reset sections 2026-07-21 13:08:00 +08:00
IT狗 08ba4cbca9 Fix: add missing db Session dependency to reset_section 2026-07-21 12:49:44 +08:00
IT狗 c07f40b28f Add /api/admin/reset/{section} endpoint for Danger Zone reset 2026-07-21 12:49:07 +08:00
IT狗 2d9b73df9d Bump version to 1.2.0 2026-07-21 12:45:46 +08:00
IT狗 9980f8d80a Fix: strip existing leave suffix before appending new leave to prevent duplication 2026-07-21 12:38:48 +08:00
IT狗 1e7d58486b Fix: prevent leave type duplication in status_text for attendance records 2026-07-21 12:35:16 +08:00
IT狗 cca088261b Fix: use roster public_holiday column to determine if employee works on holidays, instead of checking clock-in 2026-07-21 12:26:27 +08:00
IT狗 adc9e6451b Fix: show attendance status for employees who worked on holiday (not just holiday name) 2026-07-21 12:08:31 +08:00
IT狗 a34905661b Fix: add missing DB_PATH constant for backup/restore endpoints 2026-07-21 11:56:22 +08:00
IT狗 89c14c7a72 Make RECALC button btn-primary (blue) for visibility 2026-07-21 11:42:56 +08:00
IT狗 1a60c71032 Add RECALC button to Roster page to refresh data 2026-07-21 11:42:56 +08:00
12 changed files with 898 additions and 411 deletions
+14 -1
View File
@@ -15,7 +15,11 @@ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
pwd_context = CryptContext(schemes=["sha256_crypt"], deprecated="auto")
# bcrypt is the primary scheme (cross-version stable, OpenSSL-native).
# sha256_crypt is kept as deprecated fallback so legacy hashes still
# verify; on first successful login the password is re-hashed to bcrypt
# via verify_and_update().
pwd_context = CryptContext(schemes=["bcrypt", "sha256_crypt"], deprecated=["sha256_crypt"])
# JWT Settings
SECRET_KEY = secret.JWT_SECRET if hasattr(secret, 'JWT_SECRET') else "aars-dev-secret-change-in-production"
@@ -34,6 +38,15 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def verify_and_update_password(plain_password: str, hashed_password: str) -> tuple[bool, str | None]:
"""Verify password and return new hash if rehash is needed.
Returns (ok, new_hash_or_none). Caller is responsible for committing
the new hash to the DB when new_hash is not None.
"""
return pwd_context.verify_and_update(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
+438 -148
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -3,7 +3,8 @@ uvicorn[standard]==0.30.6
sqlalchemy==2.0.35
pydantic==2.9.2
python-jose[cryptography]==3.3.0
passlib==1.7.4
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
python-multipart==0.0.12
openpyxl==3.1.5
reportlab==4.2.5
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aars-frontend",
"version": "1.0.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aars-frontend",
"version": "1.0.0",
"version": "1.2.0",
"dependencies": {
"axios": "^1.7.7",
"lucide-react": "^0.441.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "aars-frontend",
"private": true,
"version": "1.0.0",
"version": "1.3.0",
"type": "module",
"scripts": {
"dev": "vite",
+78 -100
View File
@@ -1,26 +1,30 @@
import { CheckCircle, AlertCircle, Clock, X, AlertTriangle, Briefcase, Stethoscope, Timer, PartyPopper } from 'lucide-react'
import { CheckCircle, AlertCircle, CalendarX, Palmtree, Briefcase, Coffee } from 'lucide-react'
/**
* Reusable status badge for attendance records.
* Handles all status codes including enriched leave types:
* normal / late / late_early / late_ot / early / early_ot / ot / abnormal / missing
* holiday / al / sl / cl / mixed_leave
* Simplified to BASIC status categories — no detailed breakdowns.
*
* Status mapping (driven by backend status_code / status_text):
* - missing → 缺勤 (grey)
* - holiday → 公假 (indigo)
* - al / sl / cl / mixed_leave → 請假 (violet)
* - normal → 正常 (green)
* - everything else (late/early/ot/abnormal/late_early/...) → 異常 (orange)
*
* If status_text carries a leave/holiday suffix (e.g. '+ SL2h', '🎉香港…'),
* a small secondary chip is rendered next to the main badge so the list page
* shows the underlying leave/holiday detail without expanding the row.
*
* Props:
* - code: status_code string
* - text: status_text string (already enriched)
* - text: status_text string (fallback signal + suffix source)
* - size: 'sm' | 'md' (default 'sm')
*/
export default function StatusBadge({ code, text = '', size = 'sm' }) {
export default function StatusBadge({ code = '', text = '', size = 'sm' }) {
const sizeCls = size === 'md'
? 'px-3 py-1 text-sm'
: 'px-2 py-0.5 text-xs'
// Split text into main + leave parts (after ' + ' separator)
const parts = text.split(/\s*\+\s*/).filter(Boolean)
const mainText = parts[0] || ''
const leaveParts = parts.slice(1)
const badge = (icon, label, colorCls) => (
<span className={`inline-flex items-center gap-1 ${sizeCls} rounded-full font-medium ${colorCls}`}>
{icon}
@@ -28,97 +32,71 @@ export default function StatusBadge({ code, text = '', size = 'sm' }) {
</span>
)
// ============ Attendance status badges ============
if (code === 'normal') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<CheckCircle size={12} />, mainText || '正常', 'bg-green-100 text-green-700')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
+ {lp}
</span>
))}
</span>
)
}
if (code === 'late' || code === 'late_early' || code === 'late_ot') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<AlertCircle size={12} />, mainText || code, 'bg-orange-100 text-orange-700')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
+ {lp}
</span>
))}
</span>
)
}
if (code === 'early' || code === 'early_ot') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<Clock size={12} />, mainText || code, 'bg-blue-100 text-blue-700')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
+ {lp}
</span>
))}
</span>
)
}
if (code === 'ot') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<Clock size={12} />, mainText || '加班', 'bg-purple-100 text-purple-700')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
+ {lp}
</span>
))}
</span>
)
}
if (code === 'abnormal') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<AlertTriangle size={12} />, mainText || '⚠️異常', 'bg-red-100 text-red-700')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
+ {lp}
</span>
))}
</span>
)
}
if (code === 'missing') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<X size={12} />, '缺勤', 'bg-gray-200 text-gray-600')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
{lp}
</span>
))}
</span>
)
// Extract leave suffix from status_text (holiday suffix dropped — the main
// badge already says 公假, so repeating the long name like
// '香港特別行政區成立紀念日' clutters the row).
// '異常-遲到48分 + SL2h' → ['SL2h']
// 'OT3分 + AL4h + SL3h' → ['AL4h', 'SL3h']
// '正常 + CL4h' → ['CL4h']
const t = String(text || '')
const noteParts = []
const split = t.split(' + ').map(s => s.trim())
if (split.length > 1) {
for (let i = 1; i < split.length; i++) {
noteParts.push(split[i].replace(/^\(|\)$/g, '').trim())
}
}
// ============ Enriched leave status badges ============
if (code === 'holiday') {
return badge(<PartyPopper size={12} />, mainText, 'bg-amber-100 text-amber-700')
}
if (code === 'al') {
return badge(<Briefcase size={12} />, mainText || '年假', 'bg-emerald-100 text-emerald-700')
}
if (code === 'sl') {
return badge(<Stethoscope size={12} />, mainText || '病假', 'bg-pink-100 text-pink-700')
}
if (code === 'cl') {
return badge(<Timer size={12} />, mainText || '補鐘', 'bg-indigo-100 text-indigo-700')
}
if (code === 'mixed_leave') {
return badge(<Timer size={12} />, mainText || '混合假', 'bg-fuchsia-100 text-fuchsia-700')
const noteChips = noteParts.filter(Boolean).map((part, idx) => {
const cls = 'bg-violet-50 text-violet-600 border border-violet-200'
return (
<span
key={idx}
className={`inline-flex items-center ${sizeCls} rounded-full font-normal ${cls}`}
title={part}
>
+{part}
</span>
)
})
const wrap = (content) => (
<span className="inline-flex items-center gap-1 flex-wrap">
{content}
{noteChips}
</span>
)
const c = String(code || '').toLowerCase()
// 缺勤 (missing)
if (c === 'missing' || t === '缺勤') {
return wrap(badge(<CalendarX size={12} />, '缺勤', 'bg-gray-200 text-gray-700'))
}
// fallback
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs text-slate-500 bg-slate-100`}>{text || code || '-'}</span>
// 休息 (rest day — roster says not scheduled, employee didn't clock in)
if (c === 'rest' || t === '休息') {
return wrap(badge(<Coffee size={12} />, '休息', 'bg-slate-100 text-slate-600'))
}
// 公假 (holiday)
if (c === 'holiday' || t.includes('公假') || t.includes('🏖️')) {
return wrap(badge(<Palmtree size={12} />, '公假', 'bg-indigo-100 text-indigo-700'))
}
// 請假 (any leave type)
if (['al', 'sl', 'cl', 'mixed_leave'].includes(c)
|| t.includes('年假') || t.includes('病假') || t.includes('補鐘')
|| t.includes('混合假')) {
return wrap(badge(<Briefcase size={12} />, '請假', 'bg-violet-100 text-violet-700'))
}
// 正常 (only when status_code is 'normal' — text suffix like '+ CL4h' is allowed,
// we still want to flag it as 正常 first since the employee did clock in/out normally)
if (c === 'normal') {
return wrap(badge(<CheckCircle size={12} />, '正常', 'bg-green-100 text-green-700'))
}
// 異常 (late/early/ot/abnormal/late_early/late_ot/early_ot/...)
return wrap(badge(<AlertCircle size={12} />, '異常', 'bg-orange-100 text-orange-700'))
}
+6 -4
View File
@@ -368,10 +368,12 @@ export default function Dashboard() {
const otRank = [...byStaff].sort((a, b) => (b.ot_count || 0) - (a.ot_count || 0))
const abnormalRank = [...byStaff].sort((a, b) => (b.abnormal_count || 0) - (a.abnormal_count || 0))
const missingRank = [...byStaff].sort((a, b) => (b.missing_count || 0) - (a.missing_count || 0))
const attendanceRate = [...byStaff].map(s => ({
...s,
rate: (s.total_records || s.total || 0) > 0 ? Math.round(((((s.total_records || s.total || 0) - (s.late_count || 0) - (s.abnormal_count || 0))) / (s.total_records || s.total || 0)) * 100) : 0
})).sort((a, b) => b.rate - a.rate)
// Use backend's pre-computed attendance_rate = (total - missing) / total * 100
// (handles missing records correctly; a record can still be late/early/ot
// and count as attended)
const attendanceRate = [...byStaff]
.map(s => ({ ...s, rate: s.attendance_rate ?? 0 }))
.sort((a, b) => b.rate - a.rate)
const statCards = summary ? [
{ label: '總記錄', value: summary.total_records, icon: Calendar, color: 'bg-blue-50 text-blue-600' },
+58 -133
View File
@@ -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>
+198 -3
View File
@@ -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">
+28 -13
View File
@@ -17,15 +17,24 @@ export default function AttendanceList() {
const [staffFilter, setStaffFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [page, setPage] = useState(1)
const [statusOptions, setStatusOptions] = useState([])
const perPage = 50
const [staffList, setStaffList] = useState([])
useEffect(() => {
fetchRecords()
fetchStatusTexts()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, sortBy, sortOrder, dateFrom, dateTo, staffFilter, statusFilter, search])
const fetchStatusTexts = async () => {
try {
const { data } = await api.get('/api/attendance/status_texts')
setStatusOptions(data || [])
} catch (e) { console.error('Failed to fetch status texts', e) }
}
const fetchRecords = async () => {
setLoading(true)
try {
@@ -39,7 +48,7 @@ export default function AttendanceList() {
if (dateFrom) params.append('date_from', dateFrom)
if (dateTo) params.append('date_to', dateTo)
if (staffFilter) params.append('staff', staffFilter)
if (statusFilter) params.append('status', statusFilter)
if (statusFilter) params.append('status_text', statusFilter)
const { data } = await api.get(`/api/attendance/records?${params}`)
setRecords(data.records || [])
@@ -78,15 +87,21 @@ export default function AttendanceList() {
}
const getStatusBadge = (record) => {
// StatusBadge now drives from status_code + status_text only (basic 5 categories).
return <StatusBadge code={record.status_code} text={record.status_text} />
}
const getRowClass = (code) => {
if (code === 'abnormal') return 'bg-red-50 hover:bg-red-100'
if (code === 'missing') return 'bg-gray-100 hover:bg-gray-200'
if (code === 'late' || code === 'late_early' || code === 'late_ot') return 'bg-orange-50 hover:bg-orange-100'
if (code === 'early' || code === 'early_ot') return 'bg-blue-50 hover:bg-blue-100'
if (code === 'ot') return 'bg-purple-50 hover:bg-purple-100'
// Match the basic-status grouping so highlights line up with the badge.
const c = String(code || '').toLowerCase()
if (c === 'abnormal' || c === 'late' || c === 'early' || c === 'ot'
|| c === 'late_early' || c === 'late_ot' || c === 'early_ot') {
return 'bg-orange-50 hover:bg-orange-100'
}
if (c === 'missing') return 'bg-gray-100 hover:bg-gray-200'
if (c === 'rest') return 'bg-slate-50 hover:bg-slate-100'
if (c === 'holiday') return 'bg-indigo-50 hover:bg-indigo-100'
if (['al', 'sl', 'cl', 'mixed_leave'].includes(c)) return 'bg-violet-50 hover:bg-violet-100'
return 'hover:bg-slate-50'
}
@@ -176,16 +191,16 @@ export default function AttendanceList() {
<span className="text-xs text-slate-500">狀態:</span>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}
onChange={(e) => {
setStatusFilter(e.target.value)
setPage(1)
}}
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
>
<option value="">全部</option>
<option value="normal">正常</option>
<option value="late">遲到</option>
<option value="early">早退</option>
<option value="ot">OT</option>
<option value="abnormal">異常</option>
<option value="missing">缺勤</option>
{statusOptions.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
+26 -5
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Upload, CheckCircle, Trash2, Clock, Users } from 'lucide-react'
import { Upload, CheckCircle, Trash2, Clock, Users, RefreshCw } from 'lucide-react'
import api from '../../api'
import toast from 'react-hot-toast'
@@ -63,6 +63,21 @@ export default function RosterPage() {
}
}
const handleRecalc = async () => {
setLoading(true)
try {
await Promise.all([
api.get('/api/roster/info'),
api.get('/api/roster/data').catch(() => ({ data: { headers: [], rows: [] } }))
])
toast.success('已重新計算')
} catch {
toast.error('重新計算失敗')
} finally {
loadRosterData()
}
}
if (loading) {
return <div className="p-8 text-center">載入中...</div>
}
@@ -106,10 +121,16 @@ export default function RosterPage() {
</p>
</div>
</div>
<button onClick={handleDelete} className="btn-danger">
<Trash2 size={16} className="inline mr-1" />
刪除
</button>
<div className="flex gap-2">
<button onClick={handleRecalc} className="btn-primary">
<RefreshCw size={16} className="inline mr-1" />
RECALC
</button>
<button onClick={handleDelete} className="btn-danger">
<Trash2 size={16} className="inline mr-1" />
刪除
</button>
</div>
</div>
)}
</div>
+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}")