987 lines
40 KiB
React
987 lines
40 KiB
React
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>
|
||
)
|
||
}
|