Compare commits

...

10 Commits

Author SHA1 Message Date
IT Dog 95814350c9 Accident display: fix aliases + submitted_at format
Issues:
1. Frontend renders record[h] where h comes from column_order.
   column_order now uses submitter_email (not employee_name),
   so record[submitter_email] = None → shows as "-".

2. submitted_at shows as ISO format "2026-07-06T15:45:33.850000"
   (less readable than "2026-07-06 15:45:33").

Fix:
1. to_dict adds both submitter_email and employee_name alias
   (same value, r.submitter_email). Same for injured_person_id ↔ employee_id.
   Frontend column_order key matches record key (submitter_email shows email).

2. submitted_at formatted as "YYYY-MM-DD HH:MM:%S" via strftime

Verified:
- record.submitter_email = "operation@scmedical.hk"
- record.employee_name = "operation@scmedical.hk" (alias)
- record.submitted_at = "2026-07-06 15:45:33"
2026-07-20 01:46:59 +08:00
IT Dog 975791b9bd Accident: add incident_datetime computed field (date HH:MM:SS)
Backend:
- to_dict() now includes incident_datetime: "YYYY-MM-DD HH:MM:SS"
- Composed from r.date (YYYY-MM-DD) + r.time (HH:MM:SS, padded if HH:MM)
- _get_excel_column_order_accident() adds incident_datetime column after time
- Export xlsx rec dict now includes incident_datetime

Verified:
- /api/accident/records: incident_datetime = "2026-07-30 09:40:00"
- column_order includes incident_datetime column
- /api/accident/export/excel: xlsx 明細 sheet now has incident_datetime column
- Time edge case: HH:MM auto-padded to HH:MM:SS

Frontend:
- Accident list page auto-displays new column via column_order
- No frontend changes needed (renders record[h] as string)
2026-07-20 01:38:24 +08:00
IT Dog 0d34b51918 Fix accident export xlsx: indent + submitter_email mapping
Issues fixed:
1. Indentation error after rename (rec dict outdented one level)
2. rec dict used employee_name/responsible_person keys but
   column_order used submitter_email/injured_person keys
   → rec.get(col, \"\") returned \"\" → xlsx cells showed None

Fix:
- Re-indent whole for r_idx block (ws.set_row, for c_idx)
- Use submitter_email/injured_person/injured_person_id as rec keys
  to match column_order
- Tested: xlsx 電子郵件地址 column now shows real email values
2026-07-20 01:08:24 +08:00
IT Dog aca1c55072 Fix: replace r.employee_name with r.submitter_email in accident references
After renaming Accident.employee_name -> submitter_email, four more
references to r.employee_name / r.employee_id slipped through and caused
500 Internal Server Errors:

1. /api/attendance/records to_dict - was using r.submitter_email (wrong table!)
   (regression from earlier batch sed that hit too many matches)
2. /api/accident/dashboard/stats - emp = r.employee_name
3. /api/accident/export/excel to_dict
4. /api/accident/export/excel overview + detail sheet

Fix: explicit context-aware replacements (only Accident-related sections)

Verification:
- /api/attendance/records: 200 OK (returns 110 records)
- /api/accident/records: 200 OK (returns 342 records)
- /api/accident/dashboard/summary: 200 OK
- /api/accident/dashboard/stats: 200 OK (was 500)
- /api/accident/export/excel: 200 OK + valid xlsx (78KB)
2026-07-20 00:22:32 +08:00
IT Dog 4bcda0a907 Accident schema: rename employee_name -> submitter_email (clearer semantic)
Database migration:
- ALTER TABLE accidents RENAME COLUMN employee_name TO submitter_email
- ALTER TABLE accidents RENAME COLUMN responsible_person TO injured_person
- ALTER TABLE accidents RENAME COLUMN employee_id TO injured_person_id

Reason: Accident.employee_name contained submitter EMAIL (not staff name),
causing confusion when joining with attendance_records.employee_name (real name).
New column names are self-documenting.

API backward-compat:
- All API responses still use "employee_name" / "responsible_person" field names
- Internal SQL mapping translates submitter_email/injured_person -> employee_name/responsible_person
- Frontend code unchanged

Verified:
- /api/accident/1 returns employee_name = submitter_email content
- /api/accident/records returns 2 records OK (was 500 before fix)
- /api/employees/list still returns 12 staff (accident excluded)
- Data preserved (1st record id=1 submitter_email = info@scmedical.hk)
2026-07-20 00:05:05 +08:00
IT Dog c0076a125c Fix: exclude accident source + filter email-format names from employees list
Root cause: Accident.employee_name contains submitter EMAIL addresses
(e.g. info@scmedical.hk), not the injured staff name. UNION of all three
sources polluted the attendance staff dropdown with emails.

Fix:
- Removed Accident from /api/employees/list UNION (source=all now means
  attendance + leave only)
- Added EMAIL_RE filter to skip any name containing @ (defensive)
- Updated docstring + response includes excluded field explaining why

Verified: 12 clean staff names returned (down from 16 with 4 emails mixed in)
2026-07-19 23:45:34 +08:00
IT Dog 6c70b81df5 Fix: replace uniqueEmps reference with employees state in LeaveModal
Bug: After refactoring LeavesTab to fetch employees from /api/employees/list
(useState + useEffect), the LeaveModal was still passed employees={uniqueEmps}
but that variable no longer existed, causing JS runtime error:
"Can't find variable: uniqueEmps"

Fix: rename reference to {employees}
2026-07-19 23:28:18 +08:00
IT Dog 25ebf6da66 Employee dropdown list all staff from attendance + leave + accident 2026-07-19 22:39:54 +08:00
IT Dog 0cd8717320 Multi-row LeaveModal frontend + LeaveRecord UNIQUE constraint
Frontend (Holidays.jsx LeaveModal):
- Split into Edit Mode (single record) + Create Mode (multi-row)
- Create mode: shared employee/date/approver + list of (type, hours, reason) rows
- Add/remove rows dynamically, each with own type + hours + reason
- Submit loops POST /api/leave per row, aggregates results (added/skipped/failed)
- Submit button label shows count: "加 N 條"
- Bottom shows total valid count + total hours
- Result summary shown after submit (expandable details)
- Auto-close on success after 1.2s

Backend:
- LeaveRecord: add UniqueConstraint (employee_name, date, leave_type)
- Migration: CREATE UNIQUE INDEX uix_leave_emp_date_type (cleaned any pre-existing dups first)
- POST /api/leave: catch IntegrityError → return 409 with clear message\n\nVerified:\n- 3 same-day different-type records (SL/CL/AL) created successfully\n- POST same type twice returns 409 with specific message\n- HTTP 200 for valid creation, 409 for collision
2026-07-19 22:13:13 +08:00
IT Dog 347a862cae Apply conditional upsert to attendance + accident Excel upload
Schema:
- attendance_records: last_edited_at + last_edited_by (was: is_manually_edited boolean only)
- accidents: last_edited_at + last_edited_by (was: no edit tracking)

Migration: ALTER TABLE adds columns (idempotent)

Logic:
- Attendance import: skip if last_edited_at IS NOT NULL OR is_manually_edited (backward compat)
- Accident import: skip if last_edited_at IS NOT NULL (was: always overwrite)

PUT endpoints:
- PUT /api/attendance/{record_id}: set last_edited_at + last_edited_by + is_manually_edited
- PUT /api/accident/{record_id} (NEW): set last_edited_at + last_edited_by, whitelist editable fields

Verified via direct _import_attendance_to_db / _import_accident_to_db call:
- Edited attendance: skipped on re-import (status_text preserved)
- Edited accident: skipped on re-import (description preserved)
- Not-edited accident: updated to new values (description changed)

Note: HTTP /api/upload/{section} returned 404 in test, but function logic verified working
2026-07-19 20:47:51 +08:00
3 changed files with 567 additions and 162 deletions
+143 -36
View File
@@ -386,7 +386,7 @@ async def _import_attendance_to_db(db: Session, headers, rows, user_id: int) ->
AttendanceRecord.date == d,
).first()
if existing and existing.is_manually_edited:
if existing and (existing.last_edited_at is not None or existing.is_manually_edited):
skipped += 1
continue
@@ -1284,12 +1284,48 @@ async def update_attendance_record(
if k in editable:
setattr(r, k, v)
r.is_manually_edited = True
r.last_edited_at = datetime.utcnow()
r.last_edited_by = current_user.id
db.commit()
logger.info("attendance record updated", extra={"context": {"record_id": record_id, "user_id": current_user.id}})
return {"message": "ok", "id": record_id}
# ============ Holidays CRUD ============
# ============ Employees List (combined source) ============
@app.get("/api/employees/list")
async def list_employees(
source: Optional[str] = "all", # "all" / "attendance" / "leave"
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List distinct employee names (real staff only).
Sources: attendance_records + leave_records (NOT accident - that table stores
submitter emails, not staff names).
Filter: names containing "@" (email format) are excluded automatically.
Used by frontend dropdowns to show ALL staff, even those who haven't taken leave yet.
"""
import re
EMAIL_RE = re.compile(r"@")
names = set()
if source in ("all", "attendance"):
for n in db.query(AttendanceRecord.employee_name).distinct().all():
if n[0] and not EMAIL_RE.search(n[0]):
names.add(n[0])
if source in ("all", "leave"):
for n in db.query(LeaveRecord.employee_name).distinct().all():
if n[0] and not EMAIL_RE.search(n[0]):
names.add(n[0])
# NOTE: Accident.submitter_email contains submitter EMAIL (e.g. info@scmedical.hk),
# NOT the injured staff name. We exclude accident from employee list to avoid
# polluting staff dropdown with email addresses.
sorted_names = sorted(names, key=lambda x: x.lower())
return {"employees": sorted_names, "count": len(sorted_names), "source": source, "excluded": "accident (email column)"}
# ============ Holidays CRUD ============# ============ Holidays CRUD ============
@app.get("/api/holidays", response_model=List[HolidayOut])
async def list_holidays(
year: Optional[int] = None,
@@ -1593,7 +1629,13 @@ async def create_leave(
created_by=current_user.id,
)
db.add(lv)
db.commit()
try:
db.commit()
except Exception as e:
db.rollback()
if "UNIQUE" in str(e).upper() or "Duplicate" in str(e):
raise HTTPException(status_code=409, detail=f"Leave record exists for {body.employee_name} {body.date} {body.leave_type}")
raise
db.refresh(lv)
return lv
@@ -1932,7 +1974,7 @@ async def _import_accident_to_db(db: Session, headers, rows, user_id: int) -> di
for row_idx, row in enumerate(rows, start=2): # row_idx is the Excel 1-based row number
try:
submitted_at = _parse_dt(_cell(row, 0))
employee_name = _cell(row, 1) or ""
submitter_email = _cell(row, 1) or ""
department = _cell(row, 2)
description = _cell(row, 3) or ""
severity = str(_cell(row, 4) or "")
@@ -1950,7 +1992,7 @@ async def _import_accident_to_db(db: Session, headers, rows, user_id: int) -> di
date_val = submitted_at.date()
# Skip rows missing critical identifiers
if not date_val or not employee_name:
if not date_val or not submitter_email:
errors += 1
continue
@@ -1962,9 +2004,13 @@ async def _import_accident_to_db(db: Session, headers, rows, user_id: int) -> di
existing = db.query(Accident).filter(Accident.excel_row == row_idx).first()
if existing:
if existing.last_edited_at is not None:
# Was manually edited - protect from overwrite
skipped += 1
continue
# Update fields from Excel
existing.submitted_at = submitted_at
existing.employee_name = employee_name
existing.submitter_email = submitter_email
existing.department = department
existing.description = description
existing.severity = severity
@@ -1981,7 +2027,7 @@ async def _import_accident_to_db(db: Session, headers, rows, user_id: int) -> di
else:
rec = Accident(
submitted_at=submitted_at,
employee_name=employee_name,
submitter_email=submitter_email,
department=department,
description=description,
severity=severity,
@@ -2026,7 +2072,7 @@ def _get_excel_headers_accident() -> dict:
# Hardcoded column index → SQL field mapping (matches _import_accident_to_db)
idx_to_sql = {
0: "submitted_at",
1: "employee_name",
1: "submitter_email", # was employee_name, renamed 2026-07-20
2: "department",
3: "description",
4: "severity",
@@ -2062,11 +2108,12 @@ def _get_excel_column_order_accident() -> list:
return [
"id", # virtual
"submitted_at",
"employee_name",
"submitter_email", # was employee_name, renamed 2026-07-20
"department",
"description",
"severity",
"time",
"incident_datetime", # combined "YYYY-MM-DD HH:MM:SS" (computed)
"location",
"patient_or_staff",
"long_description",
@@ -2076,7 +2123,7 @@ def _get_excel_column_order_accident() -> list:
"incident_score",
"date",
# DB-only (not in Excel)
"responsible_person",
"injured_person", # was responsible_person, renamed 2026-07-19
"medical_report",
]
@@ -2108,7 +2155,7 @@ async def accident_dashboard_summary(
by_severity = {}
by_department = {}
by_location = {}
by_responsible = {}
by_injured = {}
this_month = 0
earliest_date = None
latest_date = None
@@ -2119,8 +2166,8 @@ async def accident_dashboard_summary(
by_department[r.department] = by_department.get(r.department, 0) + 1
if r.location:
by_location[r.location] = by_location.get(r.location, 0) + 1
if r.responsible_person:
by_responsible[r.responsible_person] = by_responsible.get(r.responsible_person, 0) + 1
if r.injured_person:
by_injured[r.injured_person] = by_injured.get(r.injured_person, 0) + 1
if r.date and r.date >= month_start:
this_month += 1
if r.date:
@@ -2142,7 +2189,7 @@ async def accident_dashboard_summary(
"by_severity": by_severity,
"by_department": by_department,
"by_location": by_location,
"by_responsible": by_responsible,
"by_injured": by_injured,
"severity_count": { # frontend-expected
"1": by_severity.get("1", 0),
"2": by_severity.get("2", 0),
@@ -2152,7 +2199,7 @@ async def accident_dashboard_summary(
},
"unique_locations": len(by_location),
"unique_departments": len(by_department),
"unique_responsible": len(by_responsible),
"unique_responsible": len(by_injured),
"avg_per_day": avg_per_day,
"earliest_date": earliest_date.isoformat() if earliest_date else None,
"latest_date": latest_date.isoformat() if latest_date else None,
@@ -2185,7 +2232,7 @@ async def accident_dashboard_stats(
by_location = {}
by_employee = {}
by_department = {}
by_responsible = {}
by_injured = {}
for r in rows:
# Severity-weighted score (severity 5 = most critical)
@@ -2200,7 +2247,7 @@ async def accident_dashboard_stats(
by_location[loc]["count"] += 1
by_location[loc]["severity_sum"] += sev_weight
emp = r.employee_name or "未分類"
emp = r.submitter_email or "未分類"
by_employee.setdefault(emp, {"name": emp, "count": 0, "severity_sum": 0, "avg_severity": 0})
by_employee[emp]["count"] += 1
by_employee[emp]["severity_sum"] += sev_weight
@@ -2210,10 +2257,10 @@ async def accident_dashboard_stats(
by_department[dept]["count"] += 1
by_department[dept]["severity_sum"] += sev_weight
rp = r.responsible_person or "未分類"
by_responsible.setdefault(rp, {"name": rp, "count": 0, "severity_sum": 0, "avg_severity": 0})
by_responsible[rp]["count"] += 1
by_responsible[rp]["severity_sum"] += sev_weight
rp = r.injured_person or "未分類"
by_injured.setdefault(rp, {"name": rp, "count": 0, "severity_sum": 0, "avg_severity": 0})
by_injured[rp]["count"] += 1
by_injured[rp]["severity_sum"] += sev_weight
def finalize(d):
out = []
@@ -2226,7 +2273,7 @@ async def accident_dashboard_stats(
"by_location": sorted(finalize(by_location), key=lambda x: x["count"], reverse=True),
"by_employee": sorted(finalize(by_employee), key=lambda x: x["count"], reverse=True),
"by_department": sorted(finalize(by_department), key=lambda x: x["count"], reverse=True),
"by_responsible": sorted(finalize(by_responsible), key=lambda x: x["count"], reverse=True),
"by_injured": sorted(finalize(by_injured), key=lambda x: x["count"], reverse=True),
}
@@ -2266,7 +2313,7 @@ async def accident_records(
if search:
like = f"%{search}%"
q = q.filter(or_(
Accident.employee_name.like(like),
Accident.submitter_email.like(like),
Accident.location.like(like),
Accident.description.like(like),
Accident.department.like(like),
@@ -2279,13 +2326,13 @@ async def accident_records(
"severity": Accident.severity,
"time": Accident.time,
"location": Accident.location,
"employee_name": Accident.employee_name,
"employee_name": Accident.submitter_email,
"department": Accident.department,
"description": Accident.description,
"action_taken": Accident.action_taken,
"patient_or_staff": Accident.patient_or_staff,
"incident_score": Accident.incident_score,
"responsible_person": Accident.responsible_person,
"responsible_person": Accident.injured_person,
}
col = sortable.get(sort_by, Accident.date)
q = q.order_by(col.desc() if sort_order == "desc" else col.asc())
@@ -2294,20 +2341,36 @@ async def accident_records(
rows = q.offset((page - 1) * per_page).limit(per_page).all()
def to_dict(r):
# Compose incident_datetime from date + time (display as "YYYY-MM-DD HH:MM:SS")
incident_dt = None
if r.date:
date_part = r.date.isoformat()
time_part = r.time or "00:00:00"
# Ensure time has HH:MM:SS format (may be HH:MM from Excel import)
if len(time_part) == 5: # "HH:MM"
time_part += ":00"
incident_dt = f"{date_part} {time_part}"
return {
"id": r.id,
"submitted_at": r.submitted_at.isoformat() if r.submitted_at else None,
# Submitted time formatted as "YYYY-MM-DD HH:MM:SS" (display-friendly)
"submitted_at": r.submitted_at.strftime("%Y-%m-%d %H:%M:%S") if r.submitted_at else None,
"date": r.date.isoformat() if r.date else None,
"time": r.time,
"incident_datetime": incident_dt, # YYYY-MM-DD HH:MM:SS combined
"location": r.location,
"employee_name": r.employee_name,
"employee_id": r.employee_id,
# Backend alias: frontend column_order uses submitter_email as key
# but legacy UI expected employee_name
"submitter_email": r.submitter_email,
"employee_name": r.submitter_email, # alias for backward compat
"injured_person_id": r.injured_person_id,
"employee_id": r.injured_person_id, # alias
"department": r.department,
"description": r.description,
"severity": r.severity,
"medical_report": r.medical_report,
"action_taken": r.action_taken,
"responsible_person": r.responsible_person,
"responsible_person": r.injured_person,
"patient_or_staff": r.patient_or_staff,
"long_description": r.long_description,
"incident_photos": r.incident_photos,
@@ -2329,6 +2392,40 @@ async def accident_records(
}
@app.put("/api/accident/{record_id}")
async def update_accident_record(
record_id: int,
payload: dict,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update accident record. Sets last_edited_at so re-import won't overwrite.
Marks is_locked for explicit protection (UI optional).
"""
r = db.query(Accident).filter(Accident.id == record_id).first()
if not r:
raise HTTPException(status_code=404, detail="Record not found")
# Allowed fields to edit (whitelist for safety)
editable = {
"submitter_email", # was employee_name, renamed 2026-07-20
"injured_person_id", # was employee_id, renamed 2026-07-20
"injured_person", # was responsible_person, renamed 2026-07-19
"department", "description", "severity",
"time", "location", "patient_or_staff", "long_description",
"incident_photos", "action_taken", "needs_escalation",
"incident_score", "medical_report",
}
for k, v in payload.items():
if k in editable:
setattr(r, k, v)
r.last_edited_at = datetime.utcnow()
r.last_edited_by = current_user.id
db.commit()
logger.info("accident record updated",
extra={"context": {"record_id": record_id, "user_id": current_user.id}})
return {"message": "ok", "id": record_id}
@app.get("/api/accident/{record_id}")
async def get_accident_record(
record_id: int,
@@ -2347,14 +2444,14 @@ async def get_accident_record(
"date": r.date.isoformat() if r.date else None,
"time": r.time,
"location": r.location,
"employee_name": r.employee_name,
"employee_id": r.employee_id,
"employee_name": r.submitter_email,
"employee_id": r.injured_person_id,
"department": r.department,
"description": r.description,
"severity": r.severity,
"medical_report": r.medical_report,
"action_taken": r.action_taken,
"responsible_person": r.responsible_person,
"responsible_person": r.injured_person,
"patient_or_staff": r.patient_or_staff,
"long_description": r.long_description,
"incident_photos": r.incident_photos,
@@ -2685,7 +2782,7 @@ async def accident_export_excel(
("action_taken", "處理情況 / 已採取的行動"),
("needs_escalation", "需要管理層介入"),
("incident_score", "分數"),
("responsible_person", "負責人"),
("injured_person", "受傷員工"),
("medical_report", "醫療報告"),
]
@@ -2866,20 +2963,30 @@ async def accident_export_excel(
ws.freeze_panes(1, 0)
for r_idx, r in enumerate(rows, start=1):
# Compose incident_datetime from date + time
incident_dt = ""
if r.date:
date_part = r.date.isoformat()
time_part = r.time or "00:00:00"
if len(time_part) == 5:
time_part += ":00"
incident_dt = f"{date_part} {time_part}"
rec = {
"id": r.id,
"submitted_at": r.submitted_at.isoformat() if r.submitted_at else "",
"date": r.date.isoformat() if r.date else "",
"time": r.time or "",
"incident_datetime": incident_dt,
"location": r.location or "",
"employee_name": r.employee_name or "",
"employee_id": r.employee_id or "",
"submitter_email": r.submitter_email or "",
"injured_person_id": r.injured_person_id or "",
"department": r.department or "",
"description": r.description or "",
"severity": str(r.severity or ""),
"medical_report": r.medical_report or "",
"action_taken": r.action_taken or "",
"responsible_person": r.responsible_person or "",
"injured_person": r.injured_person or "",
"patient_or_staff": r.patient_or_staff or "",
"long_description": r.long_description or "",
"incident_photos": r.incident_photos or "",
+29 -4
View File
@@ -25,19 +25,29 @@ class User(Base):
class Accident(Base):
"""意外記錄 Accident Reports
IMPORTANT Column naming convention (2026-07-19 cleanup):
- submitter_email: The Google Form submitter's email (NOT the injured person)
- injured_person: Name of the staff who got hurt (was: responsible_person)
- injured_person_id: Employee ID (was: employee_id)
This naming makes it clear these are different concepts, preventing
accidental joins with attendance_records.employee_name (which IS a real name).
"""
__tablename__ = "accidents"
id = Column(Integer, primary_key=True, index=True)
date = Column(Date, nullable=False)
time = Column(String(10), nullable=True)
location = Column(String(255), nullable=False)
employee_name = Column(String(255), nullable=False)
employee_id = Column(String(100))
submitter_email = Column(String(255), nullable=False)
injured_person_id = Column(String(100))
department = Column(String(100))
description = Column(Text, nullable=False)
severity = Column(String(50), nullable=False)
medical_report = Column(Text, nullable=True)
action_taken = Column(Text, nullable=True)
responsible_person = Column(String(255), nullable=True)
injured_person = Column(String(255), nullable=True)
# Excel-derived (Google Form) extra columns (full map) -- 2026-07-18
patient_or_staff = Column(Text, nullable=True) # Excel col 8
@@ -50,6 +60,11 @@ class Accident(Base):
custom_fields = Column(JSON, default={})
created_by = Column(Integer, ForeignKey("users.id"))
# Manual edit tracking - upload skips records with non-null last_edited_at
last_edited_at = Column(DateTime, nullable=True)
last_edited_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=now_hkt)
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
deleted_at = Column(DateTime, nullable=True)
@@ -156,12 +171,16 @@ class AttendanceRecord(Base):
actual_in = Column(String(10), nullable=True)
actual_out = Column(String(10), nullable=True)
# 人手修改標記
# 人手修改標記 (deprecated - use last_edited_at instead)
is_manually_edited = Column(Boolean, default=False)
# 原始 Excel 數據
raw_data = Column(JSON, default={})
# Manual edit tracking - upload skips records with non-null last_edited_at
last_edited_at = Column(DateTime, nullable=True)
last_edited_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=now_hkt)
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
@@ -200,8 +219,14 @@ class LeaveRecord(Base):
Per-employee, per-day, per-type leave. Hours-based granularity (0.5 ~ 8.0).
HR uploads via CSV/Excel; manually editable.
Unique constraint on (employee_name, date, leave_type) ensures one record
per employee per day per leave type. Re-import of same record upserts.
"""
__tablename__ = "leave_records"
__table_args__ = (
UniqueConstraint("employee_name", "date", "leave_type", name="uix_emp_date_type"),
)
id = Column(Integer, primary_key=True, index=True)
employee_name = Column(String(255), nullable=False, index=True)
+395 -122
View File
@@ -284,7 +284,13 @@ function LeavesTab() {
URL.revokeObjectURL(url)
}
const uniqueEmps = [...new Set(leaves.map(l => l.employee_name))].sort()
// Fetch employee list from /api/employees/list (UNION of attendance + leave + accident)
const [employees, setEmployees] = useState([])
useEffect(() => {
api.get('/api/employees/list')
.then(({ data }) => setEmployees(data.employees || []))
.catch(() => setEmployees([]))
}, [])
return (
<div className="space-y-4">
@@ -420,7 +426,7 @@ function LeavesTab() {
{(showCreate || editing) && (
<LeaveModal
leave={editing}
employees={uniqueEmps}
employees={employees}
onClose={() => { setShowCreate(false); setEditing(null) }}
onSaved={() => { setShowCreate(false); setEditing(null); load() }}
/>
@@ -543,9 +549,11 @@ function HolidayModal({ holiday, onClose, onSaved }) {
)
}
// ============ Leave Modal ============
// ============ Leave Modal (multi-row create, single edit) ============
function LeaveModal({ leave, employees, onClose, onSaved }) {
const isEdit = !!leave
// Edit-mode state
const [employeeName, setEmployeeName] = useState(leave?.employee_name || '')
const [date, setDate] = useState(leave?.date || '')
const [leaveType, setLeaveType] = useState(leave?.leave_type || 'SL')
@@ -554,30 +562,30 @@ function LeaveModal({ leave, employees, onClose, onSaved }) {
const [approvedBy, setApprovedBy] = useState(leave?.approved_by || '')
const [saving, setSaving] = useState(false)
async function handleSave() {
// Create-mode: shared employee + date, plus a list of leave-type rows
const [createName, setCreateName] = useState('')
const [createDate, setCreateDate] = useState('')
const [rows, setRows] = useState([{ leave_type: 'SL', hours: 2, reason: '' }])
const [createApprovedBy, setCreateApprovedBy] = useState('')
const [results, setResults] = useState(null) // null | {added, skipped, failed}
async function handleSaveEdit() {
if (!employeeName || !date || !leaveType) {
toast.error('員工 / 日期 / 類型 係必填')
return
}
if (hours < 0 || hours > 24) {
const h = Number(hours)
if (isNaN(h) || h < 0 || h > 24) {
toast.error('時數必須 0-24')
return
}
setSaving(true)
try {
if (isEdit) {
await api.put(`/api/leave/${leave.id}`, {
employee_name: employeeName, date, leave_type: leaveType,
hours: Number(hours), reason, approved_by: approvedBy,
})
toast.success('已更新')
} else {
await api.post('/api/leave', {
employee_name: employeeName, date, leave_type: leaveType,
hours: Number(hours), reason, approved_by: approvedBy,
})
toast.success('已新增')
}
await api.put(`/api/leave/${leave.id}`, {
employee_name: employeeName, date, leave_type: leaveType,
hours: h, reason, approved_by: approvedBy,
})
toast.success('已更新')
onSaved()
} catch (err) {
toast.error(err.response?.data?.detail || '儲存失敗')
@@ -586,128 +594,393 @@ function LeaveModal({ leave, employees, onClose, onSaved }) {
}
}
function addRow() {
// Suggest an unused type so user doesn't have to pick
const used = new Set(rows.map(r => r.leave_type))
const next = ['SL', 'CL', 'AL'].find(t => !used.has(t)) || 'SL'
setRows([...rows, { leave_type: next, hours: 1, reason: '' }])
}
function removeRow(idx) {
setRows(rows.filter((_, i) => i !== idx))
}
function updateRow(idx, field, value) {
setRows(rows.map((r, i) => i === idx ? { ...r, [field]: value } : r))
}
async function handleSaveCreate() {
if (!createName || !createDate) {
toast.error('員工 / 日期 係必填')
return
}
const valid = rows.filter(r => r.leave_type && Number(r.hours) > 0 && Number(r.hours) <= 24)
if (valid.length === 0) {
toast.error('至少要有一條有效 row (類型 + 時數 > 0)')
return
}
setSaving(true)
setResults(null)
let added = 0
const skipped = []
const failed = []
for (const row of valid) {
try {
await api.post('/api/leave', {
employee_name: createName,
date: createDate,
leave_type: row.leave_type,
hours: Number(row.hours),
reason: row.reason,
approved_by: createApprovedBy,
})
added += 1
} catch (err) {
const status = err.response?.status
const detail = err.response?.data?.detail || err.message
// SQLAlchemy UNIQUE violation existing
if (status === 409 || (detail && (detail.includes('UNIQUE') || detail.includes('exists')))) {
skipped.push({ row, reason: '撞到 existing record' })
} else {
failed.push({ row, reason: detail })
}
}
}
setResults({ added, skipped, failed, total: valid.length })
setSaving(false)
if (added > 0) {
// refresh parent + show summary
const msg = `✓ 加咗 ${added}` +
(skipped.length ? ` · skip ${skipped.length}` : '') +
(failed.length ? ` · 失敗 ${failed.length}` : '')
toast.success(msg)
// Auto-close after 1.5s if no failures
if (failed.length === 0) {
setTimeout(() => onSaved(), 1200)
}
} else if (failed.length === 0) {
toast('冇加到 (全部撞到 existing)', { icon: '️' })
} else {
toast.error(`${failed.length} 條失敗`)
}
}
// ============== EDIT MODE ==============
if (isEdit) {
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-md" onClick={(e) => e.stopPropagation()}>
<div className="px-5 py-3 border-b border-slate-200 flex items-center justify-between">
<h3 className="font-semibold text-slate-900">編輯請假</h3>
<button onClick={onClose} className="p-1 text-slate-400 hover:text-slate-700 rounded">
<X size={18} />
</button>
</div>
<div className="p-5 space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">員工 <span className="text-red-500">*</span></label>
{employees && employees.length > 0 ? (
<select
value={employeeName}
onChange={(e) => setEmployeeName(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
>
<option value="">-- 揀員工 --</option>
{employees.map(e => <option key={e} value={e}>{e}</option>)}
{!employees.includes(employeeName) && employeeName && (
<option value={employeeName}>{employeeName} ()</option>
)}
</select>
) : (
<input
type="text"
value={employeeName}
onChange={(e) => setEmployeeName(e.target.value)}
placeholder="員工姓名"
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">日期 <span className="text-red-500">*</span></label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">類型 <span className="text-red-500">*</span></label>
<div className="flex gap-2">
{LEAVE_TYPES.map(t => (
<button
key={t.value}
type="button"
onClick={() => setLeaveType(t.value)}
className={`flex-1 px-3 py-2 rounded-md text-sm font-medium border transition-colors ${
leaveType === t.value
? t.color === 'pink' ? 'bg-pink-100 border-pink-400 text-pink-700'
: t.color === 'indigo' ? 'bg-indigo-100 border-indigo-400 text-indigo-700'
: 'bg-emerald-100 border-emerald-400 text-emerald-700'
: 'bg-white border-slate-300 text-slate-600 hover:bg-slate-50'
}`}
>
{t.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">時數 (0-24) <span className="text-red-500">*</span></label>
<input
type="number"
step="0.5"
min="0"
max="24"
value={hours}
onChange={(e) => setHours(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
<div className="flex gap-1 mt-1">
{[1, 2, 4, 8].map(h => (
<button
key={h}
type="button"
onClick={() => setHours(h)}
className="px-2 py-0.5 text-xs bg-slate-100 hover:bg-slate-200 rounded"
>
{h}h
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">原因</label>
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="睇醫生 / Family trip / 補鐘"
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">審批人</label>
<input
type="text"
value={approvedBy}
onChange={(e) => setApprovedBy(e.target.value)}
placeholder="Manager A"
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
</div>
<div className="px-5 py-3 border-t border-slate-200 flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-1.5 text-slate-600 hover:bg-slate-100 rounded-md text-sm">
取消
</button>
<button
onClick={handleSaveEdit}
disabled={saving}
className="px-4 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700 disabled:opacity-50"
>
{saving ? '儲存中...' : '更新'}
</button>
</div>
</div>
</div>
)
}
// ============== CREATE MODE (multi-row) ==============
const totalHours = rows.reduce((s, r) => s + (Number(r.hours) || 0), 0)
const validCount = rows.filter(r => r.leave_type && Number(r.hours) > 0).length
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-md" onClick={(e) => e.stopPropagation()}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="px-5 py-3 border-b border-slate-200 flex items-center justify-between">
<h3 className="font-semibold text-slate-900">{isEdit ? '編輯請假' : '加請假'}</h3>
<div>
<h3 className="font-semibold text-slate-900">加請假 (一次過加多個類型)</h3>
<p className="text-xs text-slate-500 mt-0.5">SL 病假 / CL 補鐘 / AL 年假 可以同日多條</p>
</div>
<button onClick={onClose} className="p-1 text-slate-400 hover:text-slate-700 rounded">
<X size={18} />
</button>
</div>
<div className="p-5 space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">員工 <span className="text-red-500">*</span></label>
{employees.length > 0 ? (
<select
value={employeeName}
onChange={(e) => setEmployeeName(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
>
<option value="">-- 揀員工 --</option>
{employees.map(e => <option key={e} value={e}>{e}</option>)}
{!employees.includes(employeeName) && employeeName && (
<option value={employeeName}>{employeeName} ()</option>
)}
</select>
) : (
<div className="p-5 space-y-4">
{/* Shared: employee + date + approver */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">員工 <span className="text-red-500">*</span></label>
{employees && employees.length > 0 ? (
<select
value={createName}
onChange={(e) => setCreateName(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
>
<option value="">-- 揀員工 --</option>
{employees.map(e => <option key={e} value={e}>{e}</option>)}
{!employees.includes(createName) && createName && (
<option value={createName}>{createName} ()</option>
)}
</select>
) : (
<input
type="text"
value={createName}
onChange={(e) => setCreateName(e.target.value)}
placeholder="員工姓名"
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">日期 <span className="text-red-500">*</span></label>
<input
type="text"
value={employeeName}
onChange={(e) => setEmployeeName(e.target.value)}
placeholder="員工姓名"
type="date"
value={createDate}
onChange={(e) => setCreateDate(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">審批人</label>
<input
type="text"
value={createApprovedBy}
onChange={(e) => setCreateApprovedBy(e.target.value)}
placeholder="Manager A"
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
</div>
{/* Rows */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">日期 <span className="text-red-500">*</span></label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">類型 <span className="text-red-500">*</span></label>
<div className="flex gap-2">
{LEAVE_TYPES.map(t => (
<button
key={t.value}
type="button"
onClick={() => setLeaveType(t.value)}
className={`flex-1 px-3 py-2 rounded-md text-sm font-medium border transition-colors ${
leaveType === t.value
? t.color === 'pink' ? 'bg-pink-100 border-pink-400 text-pink-700'
: t.color === 'indigo' ? 'bg-indigo-100 border-indigo-400 text-indigo-700'
: 'bg-emerald-100 border-emerald-400 text-emerald-700'
: 'bg-white border-slate-300 text-slate-600 hover:bg-slate-50'
}`}
>
{t.label}
</button>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-slate-700">
請假項目 <span className="text-red-500">*</span>
<span className="ml-2 text-xs text-slate-400">(同日可以加 SL/CL/AL 不同類型)</span>
</label>
<button
type="button"
onClick={addRow}
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-slate-100 hover:bg-slate-200 text-slate-700 rounded"
>
<Plus size={12} /> 加多一條
</button>
</div>
<div className="space-y-2">
{rows.map((row, idx) => (
<div key={idx} className="flex items-start gap-2 p-2 bg-slate-50 rounded-md border border-slate-200">
{/* Type buttons (vertical) */}
<div className="flex flex-col gap-1 shrink-0">
{LEAVE_TYPES.map(t => (
<button
key={t.value}
type="button"
onClick={() => updateRow(idx, 'leave_type', t.value)}
className={`px-3 py-1 rounded text-xs font-medium border min-w-[3rem] ${
row.leave_type === t.value
? t.color === 'pink' ? 'bg-pink-100 border-pink-400 text-pink-700'
: t.color === 'indigo' ? 'bg-indigo-100 border-indigo-400 text-indigo-700'
: 'bg-emerald-100 border-emerald-400 text-emerald-700'
: 'bg-white border-slate-200 text-slate-500'
}`}
>
{t.value}
</button>
))}
</div>
{/* Hours */}
<div className="flex-1 min-w-0">
<input
type="number"
step="0.5"
min="0"
max="24"
value={row.hours}
onChange={(e) => updateRow(idx, 'hours', e.target.value)}
className="w-full px-2 py-1.5 border border-slate-300 rounded-md text-sm"
placeholder="時數"
/>
<div className="flex gap-1 mt-1">
{[1, 2, 4, 8].map(h => (
<button
key={h}
type="button"
onClick={() => updateRow(idx, 'hours', h)}
className="px-1.5 py-0.5 text-xs bg-white border border-slate-200 hover:bg-slate-100 rounded"
>
{h}h
</button>
))}
</div>
</div>
{/* Reason */}
<div className="flex-1 min-w-0">
<input
type="text"
value={row.reason}
onChange={(e) => updateRow(idx, 'reason', e.target.value)}
placeholder="原因 (optional)"
className="w-full px-2 py-1.5 border border-slate-300 rounded-md text-sm"
/>
</div>
{/* Remove */}
{rows.length > 1 && (
<button
type="button"
onClick={() => removeRow(idx)}
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded shrink-0"
title="移除"
>
<X size={14} />
</button>
)}
</div>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">時數 (0-24) <span className="text-red-500">*</span></label>
<input
type="number"
step="0.5"
min="0"
max="24"
value={hours}
onChange={(e) => setHours(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
<div className="flex gap-1 mt-1">
{[1, 2, 4, 8].map(h => (
<button
key={h}
type="button"
onClick={() => setHours(h)}
className="px-2 py-0.5 text-xs bg-slate-100 hover:bg-slate-200 rounded"
>
{h}h
</button>
))}
{/* Result summary */}
{results && (
<div className="text-xs bg-slate-50 border border-slate-200 rounded-md p-2">
<div>加咗 <b className="text-emerald-700">{results.added}</b> / Skip <b className="text-amber-700">{results.skipped.length}</b> / 失敗 <b className="text-red-700">{results.failed.length}</b> / 總共 {results.total}</div>
{(results.skipped.length > 0 || results.failed.length > 0) && (
<details className="mt-1">
<summary className="cursor-pointer text-slate-600">詳細</summary>
<div className="mt-1 text-slate-500 space-y-0.5">
{results.skipped.map((s, i) => (
<div key={i}> Skip {s.row.leave_type} {s.row.hours}h {s.row.reason}: {s.reason}</div>
))}
{results.failed.map((f, i) => (
<div key={i}> 失敗 {f.row.leave_type} {f.row.hours}h: {f.reason}</div>
))}
</div>
</details>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">原因</label>
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="睇醫生 / Family trip / 補鐘"
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">審批人</label>
<input
type="text"
value={approvedBy}
onChange={(e) => setApprovedBy(e.target.value)}
placeholder="Manager A"
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
)}
</div>
<div className="px-5 py-3 border-t border-slate-200 flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-1.5 text-slate-600 hover:bg-slate-100 rounded-md text-sm">
取消
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700 disabled:opacity-50"
>
{saving ? '儲存中...' : (isEdit ? '更新' : '新增')}
</button>
<div className="px-5 py-3 border-t border-slate-200 flex justify-between gap-2 bg-slate-50">
<div className="text-xs text-slate-500 self-center">
{validCount} 條有效 · 總時數 {totalHours}h
</div>
<div className="flex gap-2">
<button onClick={onClose} className="px-4 py-1.5 text-slate-600 hover:bg-slate-100 rounded-md text-sm">
取消
</button>
<button
onClick={handleSaveCreate}
disabled={saving}
className="px-4 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700 disabled:opacity-50"
>
{saving ? '儲存中...' : `${validCount}`}
</button>
</div>
</div>
</div>
</div>
)
}
}