Initial commit: AARS backend + frontend + holiday/leave phase 1+2
This commit is contained in:
@@ -0,0 +1,713 @@
|
||||
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)
|
||||
}
|
||||
|
||||
const uniqueEmps = [...new Set(leaves.map(l => l.employee_name))].sort()
|
||||
|
||||
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 ============
|
||||
function LeaveModal({ leave, employees, onClose, onSaved }) {
|
||||
const isEdit = !!leave
|
||||
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)
|
||||
|
||||
async function handleSave() {
|
||||
if (!employeeName || !date || !leaveType) {
|
||||
toast.error('員工 / 日期 / 類型 係必填')
|
||||
return
|
||||
}
|
||||
if (hours < 0 || hours > 24) {
|
||||
toast.error('時數必須 0-24')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
if (isEdit) {
|
||||
await api.put(`/api/leave/${leave.id}`, {
|
||||
employee_name: employeeName, date, leave_type: leaveType,
|
||||
hours: Number(hours), reason, approved_by: approvedBy,
|
||||
})
|
||||
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()
|
||||
} 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>
|
||||
{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={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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user