310 lines
12 KiB
React
310 lines
12 KiB
React
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>
|
||
)
|
||
}
|