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 (

假期 Holidays

管理公眾假期 + 員工請假 (SL 病假 / CL 補鐘 / AL 年假)

{/* Tabs */}
{tab === 'holidays' ? : }
) } // ============ 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 (
{/* Toolbar */}
{/* Table */}
{loading ? (
載入中...
) : holidays.length === 0 ? (
{year} 年冇假期資料
) : ( {holidays.map(h => ( ))}
日期 名稱 (中) Name (EN) 類型 來源 備註 操作
{h.date} {h.name} {h.name_en || '-'} {h.is_mandatory ? ( 法定 ) : ( 銀行/學校 )} {h.source || '-'} {h.notes || '-'}
)}
共 {holidays.length} 個公眾假期 · 來源:gov.hk
{(showCreate || editing) && ( { setShowCreate(false); setEditing(null) }} onSaved={() => { setShowCreate(false); setEditing(null); load() }} /> )}
) } // ============ 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 (
{/* Filters */}
setFilterFrom(e.target.value)} placeholder="由" className="px-3 py-1.5 border border-slate-300 rounded-md text-sm" /> setFilterTo(e.target.value)} placeholder="至" className="px-3 py-1.5 border border-slate-300 rounded-md text-sm" />
{/* Table */}
{loading ? (
載入中...
) : leaves.length === 0 ? (
冇請假記錄
) : ( {leaves.map(lv => ( ))}
員工 日期 類型 時數 原因 審批人 來源 操作
{lv.employee_name} {lv.date} {LEAVE_TYPES.find(t => t.value === lv.leave_type)?.label || lv.leave_type} {lv.hours}h {lv.reason || '-'} {lv.approved_by || '-'} {lv.source || '-'}
)}
共 {leaves.length} 條請假記錄
{(showCreate || editing) && ( { setShowCreate(false); setEditing(null) }} onSaved={() => { setShowCreate(false); setEditing(null); load() }} /> )}
) } // ============ 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 (
e.stopPropagation()}>

{isEdit ? '編輯假期' : '加公假'}

setDate(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" />
setName(e.target.value)} placeholder="元旦 / 香港回歸紀念日" className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" />
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" />
setIsMandatory(e.target.checked)} className="rounded" />
setNotes(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" />
) } // ============ 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 (
e.stopPropagation()}>

編輯請假

{employees && employees.length > 0 ? ( ) : ( setEmployeeName(e.target.value)} placeholder="員工姓名" className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" /> )}
setDate(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" />
{LEAVE_TYPES.map(t => ( ))}
setHours(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" />
{[1, 2, 4, 8].map(h => ( ))}
setReason(e.target.value)} placeholder="睇醫生 / Family trip / 補鐘" className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" />
setApprovedBy(e.target.value)} placeholder="Manager A" className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" />
) } // ============== 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 (
e.stopPropagation()}>

加請假 (一次過加多個類型)

SL 病假 / CL 補鐘 / AL 年假 — 可以同日多條

{/* Shared: employee + date + approver */}
{employees && employees.length > 0 ? ( ) : ( setCreateName(e.target.value)} placeholder="員工姓名" className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" /> )}
setCreateDate(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" />
setCreateApprovedBy(e.target.value)} placeholder="Manager A" className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" />
{/* Rows */}
{rows.map((row, idx) => (
{/* Type buttons (vertical) */}
{LEAVE_TYPES.map(t => ( ))}
{/* Hours */}
updateRow(idx, 'hours', e.target.value)} className="w-full px-2 py-1.5 border border-slate-300 rounded-md text-sm" placeholder="時數" />
{[1, 2, 4, 8].map(h => ( ))}
{/* Reason */}
updateRow(idx, 'reason', e.target.value)} placeholder="原因 (optional)" className="w-full px-2 py-1.5 border border-slate-300 rounded-md text-sm" />
{/* Remove */} {rows.length > 1 && ( )}
))}
{/* Result summary */} {results && (
加咗 {results.added} / Skip {results.skipped.length} / 失敗 {results.failed.length} / 總共 {results.total}
{(results.skipped.length > 0 || results.failed.length > 0) && (
詳細
{results.skipped.map((s, i) => (
• Skip {s.row.leave_type} {s.row.hours}h {s.row.reason}: {s.reason}
))} {results.failed.map((f, i) => (
• 失敗 {f.row.leave_type} {f.row.hours}h: {f.reason}
))}
)}
)}
{validCount} 條有效 · 總時數 {totalHours}h
) }