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:
+7
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user