Initial commit: AARS backend + frontend + holiday/leave phase 1+2

This commit is contained in:
IT Dog
2026-07-19 20:10:15 +08:00
commit bbc0048c24
49 changed files with 13375 additions and 0 deletions
+309
View File
@@ -0,0 +1,309 @@
import { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom'
import api from '../../api'
import toast from 'react-hot-toast'
import { Search, ChevronUp, ChevronDown, Filter, X, Users, Calendar } from 'lucide-react'
import StatusBadge from '../../components/StatusBadge'
export default function AttendanceList() {
const [records, setRecords] = useState([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [sortBy, setSortBy] = useState('date')
const [sortOrder, setSortOrder] = useState('desc')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [staffFilter, setStaffFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [page, setPage] = useState(1)
const perPage = 50
const [staffList, setStaffList] = useState([])
useEffect(() => {
fetchRecords()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, sortBy, sortOrder, dateFrom, dateTo, staffFilter, statusFilter, search])
const fetchRecords = async () => {
setLoading(true)
try {
const params = new URLSearchParams({
page,
per_page: perPage,
sort_by: sortBy,
sort_order: sortOrder,
})
if (search) params.append('search', search)
if (dateFrom) params.append('date_from', dateFrom)
if (dateTo) params.append('date_to', dateTo)
if (staffFilter) params.append('staff', staffFilter)
if (statusFilter) params.append('status', statusFilter)
const { data } = await api.get(`/api/attendance/records?${params}`)
setRecords(data.records || [])
setTotal(data.total || 0)
// Extract unique staff names
const names = [...new Set((data.records || []).map(r => r.employee_name).filter(Boolean))]
setStaffList(names.sort())
} catch (err) {
toast.error('載入失敗')
console.error(err)
} finally {
setLoading(false)
}
}
const handleSearch = (e) => {
e.preventDefault()
setPage(1)
fetchRecords()
}
const handleSort = (key) => {
if (sortBy === key) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
} else {
setSortBy(key)
setSortOrder('asc')
}
setPage(1)
}
const getSortIcon = (key) => {
if (sortBy !== key) return null
return sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
}
const getStatusBadge = (record) => {
return <StatusBadge code={record.status_code} text={record.status_text} />
}
const getRowClass = (code) => {
if (code === 'abnormal') return 'bg-red-50 hover:bg-red-100'
if (code === 'missing') return 'bg-gray-100 hover:bg-gray-200'
if (code === 'late' || code === 'late_early' || code === 'late_ot') return 'bg-orange-50 hover:bg-orange-100'
if (code === 'early' || code === 'early_ot') return 'bg-blue-50 hover:bg-blue-100'
if (code === 'ot') return 'bg-purple-50 hover:bg-purple-100'
return 'hover:bg-slate-50'
}
const totalPages = Math.ceil(total / perPage)
const columns = [
{ key: 'employee_name', label: '員工' },
{ key: 'date', label: '日期' },
{ key: 'weekday', label: '星期' },
{ key: 'shift_code', label: '班次' },
{ key: 'actual_in', label: '上班' },
{ key: 'actual_out', label: '下班' },
{ key: 'expected_in', label: '應上班' },
{ key: 'expected_out', label: '應下班' },
]
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-lg sm:text-xl font-bold text-slate-900">出勤記錄 Attendance</h1>
<div className="flex gap-2">
<Link to="/" className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 text-sm rounded-md hover:bg-slate-200">
<Calendar size={16} /> Dashboard
</Link>
<Link to="/upload" className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700">
上傳 Excel
</Link>
</div>
</div>
{/* Filters */}
<div className="card p-4 space-y-3">
{/* Search row */}
<form onSubmit={handleSearch} className="flex flex-wrap gap-3 items-end">
<div className="relative flex-1 min-w-[200px]">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜尋員工姓名..."
className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-md"
/>
</div>
<button type="submit" className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700">
搜尋
</button>
</form>
{/* Filter row */}
<div className="flex flex-wrap gap-3 items-end">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">日期:</span>
<input
type="date"
value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
placeholder="由"
/>
<span className="text-slate-400">-</span>
<input
type="date"
value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
placeholder="至"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">員工:</span>
<select
value={staffFilter}
onChange={(e) => { setStaffFilter(e.target.value); setPage(1) }}
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
>
<option value="">全部</option>
{staffList.map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">狀態:</span>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
>
<option value="">全部</option>
<option value="normal">正常</option>
<option value="late">遲到</option>
<option value="early">早退</option>
<option value="ot">OT</option>
<option value="abnormal">異常</option>
<option value="missing">缺勤</option>
</select>
</div>
<button
onClick={() => {
setDateFrom(''); setDateTo(''); setStaffFilter(''); setStatusFilter(''); setSearch(''); setPage(1)
// Force refetch in case useEffect timing misses the state change.
setTimeout(() => fetchRecords(), 0)
}}
className="flex items-center gap-1 px-3 py-1.5 text-xs text-slate-500 hover:text-slate-700 border border-slate-300 rounded-md"
>
<X size={14} /> 清除篩選
</button>
</div>
{/* Stats bar */}
<div className="flex items-center gap-4 text-xs text-slate-500">
<span>{total} 筆記錄</span>
{staffFilter && <span> 員工: {staffFilter}</span>}
{statusFilter && <span> 狀態: {statusFilter}</span>}
{(dateFrom || dateTo) && <span> {dateFrom || '...'} {dateTo || '...'}</span>}
</div>
</div>
{/* Table */}
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-100 border-b border-slate-200">
<tr>
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-600 w-12">#</th>
{columns.map(col => (
<th
key={col.key}
onClick={() => handleSort(col.key)}
className="px-3 py-2.5 text-left text-xs font-semibold text-slate-600 cursor-pointer hover:bg-slate-200"
>
<div className="flex items-center gap-1">
{col.label}
{getSortIcon(col.key)}
</div>
</th>
))}
<th className="px-3 py-2.5 text-center text-xs font-semibold text-slate-600">狀態</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-600 w-16">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading ? (
<tr>
<td colSpan={10} className="px-3 py-8 text-center text-slate-400">
載入中...
</td>
</tr>
) : records.length === 0 ? (
<tr>
<td colSpan={10} className="px-3 py-8 text-center text-slate-400">
沒有記錄
</td>
</tr>
) : (
records.map((record, idx) => (
<tr
key={record.id || idx}
className={getRowClass(record.status_code)}
>
<td className="px-3 py-2.5 text-xs text-slate-400">{(page - 1) * perPage + idx + 1}</td>
<td className="px-3 py-2.5 text-slate-700 font-medium">{record.employee_name || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.date || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.weekday || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.shift_code || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.actual_in || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.actual_out || '-'}</td>
<td className="px-3 py-2.5 text-slate-400 text-xs">{record.expected_in || '-'}</td>
<td className="px-3 py-2.5 text-slate-400 text-xs">{record.expected_out || '-'}</td>
<td className="px-3 py-2.5 text-center">
{getStatusBadge(record)}
</td>
<td className="px-3 py-2.5 text-right">
<Link
to={`/attendance/${record.id}`}
className="p-1 text-slate-400 hover:text-blue-600 inline-block"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
<span className="text-xs text-slate-500">
{page} / {totalPages} {total} 筆記錄
</span>
<div className="flex gap-2">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-3 py-1 text-xs border border-slate-300 rounded-md disabled:opacity-50 hover:bg-slate-100"
>
上一頁
</button>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="px-3 py-1 text-xs border border-slate-300 rounded-md disabled:opacity-50 hover:bg-slate-100"
>
下一頁
</button>
</div>
</div>
)}
</div>
</div>
)
}