diff --git a/backend/main.py b/backend/main.py index 921d863..960ef63 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1595,7 +1595,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 diff --git a/backend/models.py b/backend/models.py index e374afc..168d5f0 100644 --- a/backend/models.py +++ b/backend/models.py @@ -209,8 +209,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) diff --git a/frontend/src/pages/holidays/Holidays.jsx b/frontend/src/pages/holidays/Holidays.jsx index c2ebd13..8b47f60 100644 --- a/frontend/src/pages/holidays/Holidays.jsx +++ b/frontend/src/pages/holidays/Holidays.jsx @@ -543,9 +543,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 +556,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 +588,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 ( +
SL 病假 / CL 補鐘 / AL 年假 — 可以同日多條
+