Compare commits
10 Commits
8e0d73c37e
...
95814350c9
| Author | SHA1 | Date | |
|---|---|---|---|
| 95814350c9 | |||
| 975791b9bd | |||
| 0d34b51918 | |||
| aca1c55072 | |||
| 4bcda0a907 | |||
| c0076a125c | |||
| 6c70b81df5 | |||
| 25ebf6da66 | |||
| 0cd8717320 | |||
| 347a862cae |
+143
-36
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user