d302fa9beb
- Response key is 'records' not 'data' - Backend /api/attendance/records uses 'per_page' (not 'page_size') - Without this fix, list never loaded data — would always show 'no records'
243 lines
8.4 KiB
React
243 lines
8.4 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, Eye, ChevronUp, ChevronDown, Filter, X, FileCode } from 'lucide-react'
|
||
import StatusBadge from '../components/StatusBadge'
|
||
|
||
const handleExportHTML = (section) => {
|
||
const token = localStorage.getItem('aars_token')
|
||
if (token) {
|
||
window.open(`/api/report/${section}/html?token=${token}`, '_blank')
|
||
}
|
||
}
|
||
|
||
export default function AttendanceList() {
|
||
const [records, setRecords] = useState([])
|
||
const [headers, setHeaders] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [search, setSearch] = useState('')
|
||
const [sortKey, setSortKey] = useState(null)
|
||
const [sortDir, setSortDir] = useState('asc')
|
||
const [statusFilter, setStatusFilter] = useState('')
|
||
|
||
useEffect(() => {
|
||
fetchRecords()
|
||
}, [statusFilter])
|
||
|
||
const fetchRecords = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const params = { page: 1, per_page: 1000 }
|
||
if (statusFilter) params.status_text = statusFilter
|
||
const { data } = await api.get('/api/attendance/records', { params })
|
||
setRecords(data.records || [])
|
||
|
||
if (data.records && data.records.length > 0) {
|
||
const keys = Object.keys(data.records[0]).filter(k => k !== '_row')
|
||
setHeaders(keys)
|
||
}
|
||
} catch (err) {
|
||
toast.error('Failed to load records')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
// Filtered and sorted data (client-side: search + sort only; status handled by backend)
|
||
const processedData = useMemo(() => {
|
||
let result = [...records]
|
||
|
||
// Search
|
||
if (search) {
|
||
const searchLower = search.toLowerCase()
|
||
result = result.filter(r =>
|
||
Object.values(r).some(v =>
|
||
v && String(v).toLowerCase().includes(searchLower)
|
||
)
|
||
)
|
||
}
|
||
|
||
// Sort
|
||
if (sortKey) {
|
||
result.sort((a, b) => {
|
||
const aVal = a[sortKey] || ''
|
||
const bVal = b[sortKey] || ''
|
||
const aNum = Number(aVal)
|
||
const bNum = Number(bVal)
|
||
|
||
let cmp = 0
|
||
if (!isNaN(aNum) && !isNaN(bNum)) {
|
||
cmp = aNum - bNum
|
||
} else {
|
||
cmp = String(aVal).localeCompare(String(bVal))
|
||
}
|
||
return sortDir === 'asc' ? cmp : -cmp
|
||
})
|
||
}
|
||
|
||
return result
|
||
}, [records, search, sortKey, sortDir])
|
||
|
||
const handleSort = (key) => {
|
||
if (sortKey === key) {
|
||
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
|
||
} else {
|
||
setSortKey(key)
|
||
setSortDir('asc')
|
||
}
|
||
}
|
||
|
||
const getSortIcon = (key) => {
|
||
if (sortKey !== key) return null
|
||
return sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-xl font-bold text-slate-900">出勤記錄 Attendance</h1>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => handleExportHTML('attendance')}
|
||
className="flex items-center gap-2 px-4 py-2 bg-slate-700 text-white text-sm rounded-md hover:bg-slate-800"
|
||
>
|
||
<FileCode size={16} />
|
||
匯出 HTML Report
|
||
</button>
|
||
<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-3">
|
||
<div className="flex flex-wrap gap-3">
|
||
{/* Search */}
|
||
<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>
|
||
|
||
{/* Status Filter (basic categories — server-side) */}
|
||
<div className="flex items-center gap-2">
|
||
<Filter size={16} className="text-slate-400" />
|
||
<select
|
||
value={statusFilter}
|
||
onChange={(e) => setStatusFilter(e.target.value)}
|
||
className="text-sm border border-slate-300 rounded-md px-2 py-2"
|
||
>
|
||
<option value="">全部狀態</option>
|
||
<option value="normal">正常</option>
|
||
<option value="abnormal">異常</option>
|
||
<option value="missing">缺勤</option>
|
||
<option value="holiday">公假</option>
|
||
<option value="leave">請假</option>
|
||
</select>
|
||
|
||
{statusFilter && (
|
||
<button
|
||
onClick={() => setStatusFilter('')}
|
||
className="p-1 text-slate-400 hover:text-slate-600"
|
||
title="清除篩選"
|
||
>
|
||
<X size={16} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Result count */}
|
||
<div className="text-sm text-slate-500 py-2">
|
||
{processedData.length} 筆記錄
|
||
{statusFilter && (
|
||
<span className="ml-2 text-slate-400">• 已篩選:{statusFilter}</span>
|
||
)}
|
||
</div>
|
||
</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 text-left text-xs font-semibold text-slate-600 w-12">#</th>
|
||
{headers.slice(0, 6).map(h => (
|
||
<th
|
||
key={h}
|
||
className="px-3 py-2 text-left text-xs font-semibold text-slate-600 cursor-pointer hover:bg-slate-200"
|
||
onClick={() => handleSort(h)}
|
||
>
|
||
<div className="flex items-center gap-1">
|
||
{h}
|
||
{getSortIcon(h)}
|
||
</div>
|
||
</th>
|
||
))}
|
||
<th className="px-3 py-2 text-center text-xs font-semibold text-slate-600 w-20">狀態</th>
|
||
<th className="px-3 py-2 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={9} className="px-3 py-6 text-center text-slate-400">
|
||
載入中...
|
||
</td>
|
||
</tr>
|
||
) : processedData.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={9} className="px-3 py-6 text-center text-slate-400">
|
||
沒有記錄
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
processedData.map((record) => (
|
||
<tr
|
||
key={record._row}
|
||
className="hover:bg-slate-50 cursor-pointer"
|
||
onClick={() => window.location.href = `/attendance/${record._row}`}
|
||
>
|
||
<td className="px-3 py-2 text-xs text-slate-400">{record._row}</td>
|
||
{headers.slice(0, 6).map(h => (
|
||
<td key={h} className="px-3 py-2 text-slate-700 max-w-xs truncate">
|
||
{record[h] !== null && record[h] !== undefined ? String(record[h]) : '-'}
|
||
</td>
|
||
))}
|
||
<td className="px-3 py-2 text-center">
|
||
<StatusBadge
|
||
code={record.status_code}
|
||
text={record.status_text}
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
|
||
<Link
|
||
to={`/attendance/${record._row}`}
|
||
className="p-1 text-slate-400 hover:text-primary-600 inline-block"
|
||
>
|
||
<Eye size={16} />
|
||
</Link>
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|