Initial commit: AARS backend + frontend + holiday/leave phase 1+2
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user