Files
aars/frontend/src/pages/holidays/Holidays.jsx
T

987 lines
40 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from 'react'
import api from '../../api'
import toast from 'react-hot-toast'
import {
Plus, Edit2, Trash2, RefreshCw, Upload, Download, Calendar as CalIcon,
Users, X, ChevronDown, ChevronUp, Filter,
} from 'lucide-react'
import StatusBadge from '../../components/StatusBadge'
const LEAVE_TYPES = [
{ value: 'SL', label: '病假 SL', color: 'pink' },
{ value: 'CL', label: '補鐘 CL', color: 'indigo' },
{ value: 'AL', label: '年假 AL', color: 'emerald' },
]
const CURRENT_YEAR = new Date().getFullYear()
const YEAR_OPTIONS = [CURRENT_YEAR - 1, CURRENT_YEAR, CURRENT_YEAR + 1, CURRENT_YEAR + 2]
export default function HolidaysPage() {
const [tab, setTab] = useState('holidays')
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">假期 Holidays</h1>
<p className="text-sm text-slate-500 mt-1">
管理公眾假期 + 員工請假 (SL 病假 / CL 補鐘 / AL 年假)
</p>
</div>
</div>
{/* Tabs */}
<div className="border-b border-slate-200">
<nav className="flex gap-4">
<button
onClick={() => setTab('holidays')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === 'holidays'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
<CalIcon size={16} className="inline mr-1.5" />
公眾假期
</button>
<button
onClick={() => setTab('leaves')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === 'leaves'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
<Users size={16} className="inline mr-1.5" />
員工請假
</button>
</nav>
</div>
{tab === 'holidays' ? <HolidaysTab /> : <LeavesTab />}
</div>
)
}
// ============ Tab 1: 公眾假期 ============
function HolidaysTab() {
const [year, setYear] = useState(CURRENT_YEAR)
const [holidays, setHolidays] = useState([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState(null) // Holiday object or null
const [showCreate, setShowCreate] = useState(false)
useEffect(() => {
load()
}, [year])
async function load() {
setLoading(true)
try {
const { data } = await api.get(`/api/holidays?year=${year}`)
setHolidays(data)
} catch (err) {
toast.error(err.response?.data?.detail || '載入失敗')
} finally {
setLoading(false)
}
}
async function handleRefresh() {
if (!confirm(`從 gov.hk 重新抓取 ${year} 年假期?\n(手動編輯嘅假期唔會被覆寫)`)) return
try {
const { data } = await api.post(`/api/holidays/refresh?years=${year}`)
toast.success(`${year} 已同步:新增 ${data.added} / 更新 ${data.updated} / 跳過 ${data.skipped}`)
load()
} catch (err) {
toast.error(err.response?.data?.detail || '同步失敗')
}
}
async function handleDelete(h) {
if (!confirm(`確定要刪除 ${h.date}${h.name}」?`)) return
try {
await api.delete(`/api/holidays/${h.id}`)
toast.success('已刪除')
load()
} catch (err) {
toast.error(err.response?.data?.detail || '刪除失敗')
}
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="bg-white border border-slate-200 rounded-lg p-4 flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-slate-700">年份:</label>
<select
value={year}
onChange={(e) => setYear(Number(e.target.value))}
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
>
{YEAR_OPTIONS.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div className="flex-1" />
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700"
>
<Plus size={14} /> 加公假
</button>
<button
onClick={handleRefresh}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-300 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-50"
>
<RefreshCw size={14} /> GovHK 同步
</button>
</div>
{/* Table */}
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
{loading ? (
<div className="p-8 text-center text-slate-400">載入中...</div>
) : holidays.length === 0 ? (
<div className="p-8 text-center text-slate-400">{year} 年冇假期資料</div>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">日期</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">名稱 ()</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">Name (EN)</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">類型</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">來源</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">備註</th>
<th className="px-4 py-2.5 text-right font-medium text-slate-700">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{holidays.map(h => (
<tr key={h.id} className="hover:bg-slate-50">
<td className="px-4 py-2 font-mono text-slate-700">{h.date}</td>
<td className="px-4 py-2 font-medium text-slate-900">{h.name}</td>
<td className="px-4 py-2 text-slate-600">{h.name_en || '-'}</td>
<td className="px-4 py-2">
{h.is_mandatory ? (
<span className="inline-block px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-700">法定</span>
) : (
<span className="inline-block px-2 py-0.5 rounded text-xs bg-slate-100 text-slate-600">銀行/學校</span>
)}
</td>
<td className="px-4 py-2 text-xs text-slate-500">{h.source || '-'}</td>
<td className="px-4 py-2 text-slate-500 text-xs">{h.notes || '-'}</td>
<td className="px-4 py-2 text-right">
<button
onClick={() => setEditing(h)}
className="p-1 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded"
title="編輯"
>
<Edit2 size={14} />
</button>
<button
onClick={() => handleDelete(h)}
className="p-1 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded ml-1"
title="刪除"
>
<Trash2 size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="text-xs text-slate-500 text-center">
{holidays.length} 個公眾假期 · 來源<a className="text-primary-600 underline" href="https://www.gov.hk/en/about/abouthk/holiday/" target="_blank" rel="noreferrer">gov.hk</a>
</div>
{(showCreate || editing) && (
<HolidayModal
holiday={editing}
onClose={() => { setShowCreate(false); setEditing(null) }}
onSaved={() => { setShowCreate(false); setEditing(null); load() }}
/>
)}
</div>
)
}
// ============ Tab 2: 員工請假 ============
function LeavesTab() {
const [leaves, setLeaves] = useState([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState(null)
const [showCreate, setShowCreate] = useState(false)
const [filterType, setFilterType] = useState('')
const [filterEmp, setFilterEmp] = useState('')
const [filterFrom, setFilterFrom] = useState('')
const [filterTo, setFilterTo] = useState('')
const fileInputRef = useRef(null)
useEffect(() => {
load()
}, [])
async function load() {
setLoading(true)
try {
const params = new URLSearchParams()
if (filterType) params.set('leave_type', filterType)
if (filterEmp) params.set('employee_name', filterEmp)
if (filterFrom) params.set('date_from', filterFrom)
if (filterTo) params.set('date_to', filterTo)
const { data } = await api.get(`/api/leave?${params}`)
setLeaves(data)
} catch (err) {
toast.error(err.response?.data?.detail || '載入失敗')
} finally {
setLoading(false)
}
}
async function handleDelete(lv) {
if (!confirm(`確定要刪除 ${lv.employee_name} ${lv.date} ${lv.leave_type}${lv.hours}h`)) return
try {
await api.delete(`/api/leave/${lv.id}`)
toast.success('已刪除')
load()
} catch (err) {
toast.error(err.response?.data?.detail || '刪除失敗')
}
}
async function handleUpload(e) {
const f = e.target.files?.[0]
if (!f) return
const fd = new FormData()
fd.append('file', f)
try {
const { data } = await api.post('/api/leave/upload', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const errMsg = data.errors?.length ? `\n錯誤 (前 ${data.errors.length} 條):\n${data.errors.join('\n')}` : ''
toast.success(`✓ 新增 ${data.added} 條 / 跳過 ${data.skipped}${errMsg}`, { duration: 6000 })
load()
} catch (err) {
toast.error(err.response?.data?.detail || '上傳失敗')
} finally {
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
function downloadTemplate() {
const csv = 'employee_name,date,leave_type,hours,reason,approved_by\nJay Lam,2026-07-15,SL,2.0,睇醫生,\nJay Lam,2026-07-20,AL,8.0,Family trip,Manager A\n'
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'leave_template.csv'
a.click()
URL.revokeObjectURL(url)
}
// 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">
{/* Filters */}
<div className="bg-white border border-slate-200 rounded-lg p-4 flex flex-wrap items-center gap-3">
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
>
<option value="">所有類型</option>
{LEAVE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<input
type="date"
value={filterFrom}
onChange={(e) => setFilterFrom(e.target.value)}
placeholder="由"
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
/>
<input
type="date"
value={filterTo}
onChange={(e) => setFilterTo(e.target.value)}
placeholder="至"
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
/>
<button
onClick={load}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-200"
>
<Filter size={14} /> 套用
</button>
<button
onClick={() => { setFilterType(''); setFilterEmp(''); setFilterFrom(''); setFilterTo(''); setTimeout(load, 0) }}
className="inline-flex items-center gap-1 px-2 py-1.5 text-slate-500 hover:text-slate-700 text-sm"
>
<X size={14} />
</button>
<div className="flex-1" />
<button
onClick={downloadTemplate}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-300 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-50"
>
<Download size={14} /> CSV 範本
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-300 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-50"
>
<Upload size={14} /> 上傳 CSV/Excel
</button>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.xls"
onChange={handleUpload}
className="hidden"
/>
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700"
>
<Plus size={14} /> 加請假
</button>
</div>
{/* Table */}
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
{loading ? (
<div className="p-8 text-center text-slate-400">載入中...</div>
) : leaves.length === 0 ? (
<div className="p-8 text-center text-slate-400">冇請假記錄</div>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">員工</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">日期</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">類型</th>
<th className="px-4 py-2.5 text-right font-medium text-slate-700">時數</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">原因</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">審批人</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">來源</th>
<th className="px-4 py-2.5 text-right font-medium text-slate-700">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{leaves.map(lv => (
<tr key={lv.id} className="hover:bg-slate-50">
<td className="px-4 py-2 font-medium text-slate-900">{lv.employee_name}</td>
<td className="px-4 py-2 font-mono text-slate-700">{lv.date}</td>
<td className="px-4 py-2">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${
lv.leave_type === 'SL' ? 'bg-pink-100 text-pink-700' :
lv.leave_type === 'CL' ? 'bg-indigo-100 text-indigo-700' :
'bg-emerald-100 text-emerald-700'
}`}>
{LEAVE_TYPES.find(t => t.value === lv.leave_type)?.label || lv.leave_type}
</span>
</td>
<td className="px-4 py-2 text-right tabular-nums">{lv.hours}h</td>
<td className="px-4 py-2 text-slate-600 text-xs">{lv.reason || '-'}</td>
<td className="px-4 py-2 text-slate-600 text-xs">{lv.approved_by || '-'}</td>
<td className="px-4 py-2 text-xs text-slate-500">{lv.source || '-'}</td>
<td className="px-4 py-2 text-right">
<button
onClick={() => setEditing(lv)}
className="p-1 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded"
title="編輯"
>
<Edit2 size={14} />
</button>
<button
onClick={() => handleDelete(lv)}
className="p-1 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded ml-1"
title="刪除"
>
<Trash2 size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="text-xs text-slate-500 text-center">
{leaves.length} 條請假記錄
</div>
{(showCreate || editing) && (
<LeaveModal
leave={editing}
employees={uniqueEmps}
onClose={() => { setShowCreate(false); setEditing(null) }}
onSaved={() => { setShowCreate(false); setEditing(null); load() }}
/>
)}
</div>
)
}
// ============ Holiday Modal ============
function HolidayModal({ holiday, onClose, onSaved }) {
const isEdit = !!holiday
const [date, setDate] = useState(holiday?.date || '')
const [name, setName] = useState(holiday?.name || '')
const [nameEn, setNameEn] = useState(holiday?.name_en || '')
const [isMandatory, setIsMandatory] = useState(holiday?.is_mandatory ?? true)
const [notes, setNotes] = useState(holiday?.notes || '')
const [saving, setSaving] = useState(false)
async function handleSave() {
if (!date || !name) {
toast.error('日期同名稱係必填')
return
}
setSaving(true)
try {
if (isEdit) {
await api.put(`/api/holidays/${holiday.id}`, {
date, name, name_en: nameEn, is_mandatory: isMandatory, notes,
})
toast.success('已更新')
} else {
await api.post('/api/holidays', {
date, name, name_en: nameEn, is_mandatory: isMandatory, notes,
})
toast.success('已新增')
}
onSaved()
} catch (err) {
toast.error(err.response?.data?.detail || '儲存失敗')
} finally {
setSaving(false)
}
}
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">{isEdit ? '編輯假期' : '加公假'}</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>
<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>
<input
type="text"
value={name}
onChange={(e) => setName(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">Name (English)</label>
<input
type="text"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
placeholder="The first day of January"
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="mandatory"
checked={isMandatory}
onChange={(e) => setIsMandatory(e.target.checked)}
className="rounded"
/>
<label htmlFor="mandatory" className="text-sm text-slate-700">
法定假日 (勞工假)
</label>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">備註</label>
<input
type="text"
value={notes}
onChange={(e) => setNotes(e.target.value)}
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>
</div>
</div>
)
}
// ============ 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')
const [hours, setHours] = useState(leave?.hours ?? 8)
const [reason, setReason] = useState(leave?.reason || '')
const [approvedBy, setApprovedBy] = useState(leave?.approved_by || '')
const [saving, setSaving] = useState(false)
// 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
}
const h = Number(hours)
if (isNaN(h) || h < 0 || h > 24) {
toast.error('時數必須 0-24')
return
}
setSaving(true)
try {
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 || '儲存失敗')
} finally {
setSaving(false)
}
}
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-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>
<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-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="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>
<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>
{/* 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 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>
)
}