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
This commit is contained in:
IT Dog
2026-07-19 22:13:13 +08:00
parent 347a862cae
commit 0cd8717320
3 changed files with 400 additions and 121 deletions
+7 -1
View File
@@ -1595,7 +1595,13 @@ async def create_leave(
created_by=current_user.id, created_by=current_user.id,
) )
db.add(lv) 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) db.refresh(lv)
return lv return lv
+6
View File
@@ -209,8 +209,14 @@ class LeaveRecord(Base):
Per-employee, per-day, per-type leave. Hours-based granularity (0.5 ~ 8.0). Per-employee, per-day, per-type leave. Hours-based granularity (0.5 ~ 8.0).
HR uploads via CSV/Excel; manually editable. 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" __tablename__ = "leave_records"
__table_args__ = (
UniqueConstraint("employee_name", "date", "leave_type", name="uix_emp_date_type"),
)
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
employee_name = Column(String(255), nullable=False, index=True) employee_name = Column(String(255), nullable=False, index=True)
+387 -120
View File
@@ -543,9 +543,11 @@ function HolidayModal({ holiday, onClose, onSaved }) {
) )
} }
// ============ Leave Modal ============ // ============ Leave Modal (multi-row create, single edit) ============
function LeaveModal({ leave, employees, onClose, onSaved }) { function LeaveModal({ leave, employees, onClose, onSaved }) {
const isEdit = !!leave const isEdit = !!leave
// Edit-mode state
const [employeeName, setEmployeeName] = useState(leave?.employee_name || '') const [employeeName, setEmployeeName] = useState(leave?.employee_name || '')
const [date, setDate] = useState(leave?.date || '') const [date, setDate] = useState(leave?.date || '')
const [leaveType, setLeaveType] = useState(leave?.leave_type || 'SL') 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 [approvedBy, setApprovedBy] = useState(leave?.approved_by || '')
const [saving, setSaving] = useState(false) 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) { if (!employeeName || !date || !leaveType) {
toast.error('員工 / 日期 / 類型 係必填') toast.error('員工 / 日期 / 類型 係必填')
return return
} }
if (hours < 0 || hours > 24) { const h = Number(hours)
if (isNaN(h) || h < 0 || h > 24) {
toast.error('時數必須 0-24') toast.error('時數必須 0-24')
return return
} }
setSaving(true) setSaving(true)
try { try {
if (isEdit) { await api.put(`/api/leave/${leave.id}`, {
await api.put(`/api/leave/${leave.id}`, { employee_name: employeeName, date, leave_type: leaveType,
employee_name: employeeName, date, leave_type: leaveType, hours: h, reason, approved_by: approvedBy,
hours: Number(hours), reason, approved_by: approvedBy, })
}) toast.success('已更新')
toast.success('已更新')
} else {
await api.post('/api/leave', {
employee_name: employeeName, date, leave_type: leaveType,
hours: Number(hours), reason, approved_by: approvedBy,
})
toast.success('已新增')
}
onSaved() onSaved()
} catch (err) { } catch (err) {
toast.error(err.response?.data?.detail || '儲存失敗') 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 (
<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 ( return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}> <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"> <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"> <button onClick={onClose} className="p-1 text-slate-400 hover:text-slate-700 rounded">
<X size={18} /> <X size={18} />
</button> </button>
</div> </div>
<div className="p-5 space-y-3"> <div className="p-5 space-y-4">
<div> {/* Shared: employee + date + approver */}
<label className="block text-sm font-medium text-slate-700 mb-1">員工 <span className="text-red-500">*</span></label> <div className="grid grid-cols-3 gap-3">
{employees.length > 0 ? ( <div>
<select <label className="block text-sm font-medium text-slate-700 mb-1">員工 <span className="text-red-500">*</span></label>
value={employeeName} {employees && employees.length > 0 ? (
onChange={(e) => setEmployeeName(e.target.value)} <select
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" value={createName}
> onChange={(e) => setCreateName(e.target.value)}
<option value="">-- 揀員工 --</option> className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
{employees.map(e => <option key={e} value={e}>{e}</option>)} >
{!employees.includes(employeeName) && employeeName && ( <option value="">-- 揀員工 --</option>
<option value={employeeName}>{employeeName} ()</option> {employees.map(e => <option key={e} value={e}>{e}</option>)}
)} {!employees.includes(createName) && createName && (
</select> <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 <input
type="text" type="date"
value={employeeName} value={createDate}
onChange={(e) => setEmployeeName(e.target.value)} onChange={(e) => setCreateDate(e.target.value)}
placeholder="員工姓名"
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" 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> </div>
{/* Rows */}
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1">日期 <span className="text-red-500">*</span></label> <div className="flex items-center justify-between mb-2">
<input <label className="text-sm font-medium text-slate-700">
type="date" 請假項目 <span className="text-red-500">*</span>
value={date} <span className="ml-2 text-xs text-slate-400">(同日可以加 SL/CL/AL 不同類型)</span>
onChange={(e) => setDate(e.target.value)} </label>
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" <button
/> type="button"
</div> onClick={addRow}
<div> className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-slate-100 hover:bg-slate-200 text-slate-700 rounded"
<label className="block text-sm font-medium text-slate-700 mb-1">類型 <span className="text-red-500">*</span></label> >
<div className="flex gap-2"> <Plus size={12} /> 加多一條
{LEAVE_TYPES.map(t => ( </button>
<button </div>
key={t.value} <div className="space-y-2">
type="button" {rows.map((row, idx) => (
onClick={() => setLeaveType(t.value)} <div key={idx} className="flex items-start gap-2 p-2 bg-slate-50 rounded-md border border-slate-200">
className={`flex-1 px-3 py-2 rounded-md text-sm font-medium border transition-colors ${ {/* Type buttons (vertical) */}
leaveType === t.value <div className="flex flex-col gap-1 shrink-0">
? t.color === 'pink' ? 'bg-pink-100 border-pink-400 text-pink-700' {LEAVE_TYPES.map(t => (
: t.color === 'indigo' ? 'bg-indigo-100 border-indigo-400 text-indigo-700' <button
: 'bg-emerald-100 border-emerald-400 text-emerald-700' key={t.value}
: 'bg-white border-slate-300 text-slate-600 hover:bg-slate-50' type="button"
}`} onClick={() => updateRow(idx, 'leave_type', t.value)}
> className={`px-3 py-1 rounded text-xs font-medium border min-w-[3rem] ${
{t.label} row.leave_type === t.value
</button> ? 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> </div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">時數 (0-24) <span className="text-red-500">*</span></label> {/* Result summary */}
<input {results && (
type="number" <div className="text-xs bg-slate-50 border border-slate-200 rounded-md p-2">
step="0.5" <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>
min="0" {(results.skipped.length > 0 || results.failed.length > 0) && (
max="24" <details className="mt-1">
value={hours} <summary className="cursor-pointer text-slate-600">詳細</summary>
onChange={(e) => setHours(e.target.value)} <div className="mt-1 text-slate-500 space-y-0.5">
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" {results.skipped.map((s, i) => (
/> <div key={i}> Skip {s.row.leave_type} {s.row.hours}h {s.row.reason}: {s.reason}</div>
<div className="flex gap-1 mt-1"> ))}
{[1, 2, 4, 8].map(h => ( {results.failed.map((f, i) => (
<button <div key={i}> 失敗 {f.row.leave_type} {f.row.hours}h: {f.reason}</div>
key={h} ))}
type="button" </div>
onClick={() => setHours(h)} </details>
className="px-2 py-0.5 text-xs bg-slate-100 hover:bg-slate-200 rounded" )}
>
{h}h
</button>
))}
</div> </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>
<div className="px-5 py-3 border-t border-slate-200 flex justify-end gap-2"> <div className="px-5 py-3 border-t border-slate-200 flex justify-between gap-2 bg-slate-50">
<button onClick={onClose} className="px-4 py-1.5 text-slate-600 hover:bg-slate-100 rounded-md text-sm"> <div className="text-xs text-slate-500 self-center">
取消 {validCount} 條有效 · 總時數 {totalHours}h
</button> </div>
<button <div className="flex gap-2">
onClick={handleSave} <button onClick={onClose} className="px-4 py-1.5 text-slate-600 hover:bg-slate-100 rounded-md text-sm">
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" </button>
> <button
{saving ? '儲存中...' : (isEdit ? '更新' : '新增')} onClick={handleSaveCreate}
</button> 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> </div>
</div> </div>
) )
} }