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} 年冇假期資料
) : (
| 日期 |
名稱 (中) |
Name (EN) |
類型 |
來源 |
備註 |
操作 |
{holidays.map(h => (
| {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 */}
{/* 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 ? '編輯假期' : '加公假'}
)
}
// ============ 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()}>
編輯請假
)
}
// ============== 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 */}
{/* Rows */}
{rows.map((row, idx) => (
{/* Type buttons (vertical) */}
{LEAVE_TYPES.map(t => (
))}
{/* Hours */}
{/* 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
)
}