StatusBadge: simplify to 5 basic categories (正常/異常/缺勤/公假/請假)
- StatusBadge no longer shows detailed late/early/OT minute breakdowns nor AL/SL/CL sub-types; collapse to single basic-status badge - List page: replace text-input column filter with status dropdown (server-side ?status_text= filter; backend now supports 'leave' IN-clause for al/sl/cl/mixed_leave group) - List page: badge now driven by backend status_code/status_text (remove frontend latenessMap which contradicted backend status)
This commit is contained in:
+55
-130
@@ -2,7 +2,8 @@ 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, AlertCircle, Clock, FileCode } from 'lucide-react'
|
||||
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')
|
||||
@@ -18,19 +19,18 @@ export default function AttendanceList() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortKey, setSortKey] = useState(null)
|
||||
const [sortDir, setSortDir] = useState('asc')
|
||||
const [filterKey, setFilterKey] = useState('')
|
||||
const [filterValue, setFilterValue] = useState('')
|
||||
const [latenessRecords, setLatenessRecords] = useState([])
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecords()
|
||||
fetchLateness()
|
||||
}, [])
|
||||
}, [statusFilter])
|
||||
|
||||
const fetchRecords = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await api.get('/api/attendance?page=1&page_size=1000')
|
||||
const params = { page: 1, page_size: 1000 }
|
||||
if (statusFilter) params.status_text = statusFilter
|
||||
const { data } = await api.get('/api/attendance', { params })
|
||||
setRecords(data.data || [])
|
||||
|
||||
if (data.data && data.data.length > 0) {
|
||||
@@ -44,37 +44,10 @@ export default function AttendanceList() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchLateness = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/attendance/lateness/records')
|
||||
setLatenessRecords(data || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load lateness data:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a map of late records for quick lookup
|
||||
const latenessMap = useMemo(() => {
|
||||
const map = {}
|
||||
latenessRecords.forEach(rec => {
|
||||
const key = `${rec.staff}-${rec.date}`
|
||||
map[key] = rec
|
||||
})
|
||||
return map
|
||||
}, [latenessRecords])
|
||||
|
||||
// Filtered and sorted data
|
||||
// Filtered and sorted data (client-side: search + sort only; status handled by backend)
|
||||
const processedData = useMemo(() => {
|
||||
let result = [...records]
|
||||
|
||||
// Filter
|
||||
if (filterKey && filterValue) {
|
||||
result = result.filter(r => {
|
||||
const val = String(r[filterKey] || '').toLowerCase()
|
||||
return val.includes(filterValue.toLowerCase())
|
||||
})
|
||||
}
|
||||
|
||||
// Search
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase()
|
||||
@@ -104,7 +77,7 @@ export default function AttendanceList() {
|
||||
}
|
||||
|
||||
return result
|
||||
}, [records, search, sortKey, sortDir, filterKey, filterValue])
|
||||
}, [records, search, sortKey, sortDir])
|
||||
|
||||
const handleSort = (key) => {
|
||||
if (sortKey === key) {
|
||||
@@ -115,42 +88,11 @@ export default function AttendanceList() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilter = (key) => {
|
||||
setFilterKey(key)
|
||||
setFilterValue('')
|
||||
}
|
||||
|
||||
const clearFilter = () => {
|
||||
setFilterKey('')
|
||||
setFilterValue('')
|
||||
}
|
||||
|
||||
const getSortIcon = (key) => {
|
||||
if (sortKey !== key) return null
|
||||
return sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||
}
|
||||
|
||||
// Get lateness info for a record
|
||||
const getLatenessInfo = (record) => {
|
||||
// Find staff name and date columns
|
||||
const staffKey = Object.keys(record).find(k =>
|
||||
k.toLowerCase().includes('staff') || k.toLowerCase().includes('name') || k.toLowerCase().includes('員工')
|
||||
)
|
||||
const dateKey = Object.keys(record).find(k =>
|
||||
k.toLowerCase().includes('date') || k.toLowerCase().includes('日期')
|
||||
)
|
||||
|
||||
if (!staffKey || !dateKey) return null
|
||||
|
||||
const staff = record[staffKey]
|
||||
const date = record[dateKey]
|
||||
|
||||
if (!staff || !date) return null
|
||||
|
||||
const key = `${staff}-${String(date).slice(0, 10)}`
|
||||
return latenessMap[key]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
@@ -188,43 +130,38 @@ export default function AttendanceList() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Column Filter */}
|
||||
{/* Status Filter (basic categories — server-side) */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={16} className="text-slate-400" />
|
||||
<select
|
||||
value={filterKey}
|
||||
onChange={(e) => handleFilter(e.target.value)}
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-2"
|
||||
>
|
||||
<option value="">篩選欄位</option>
|
||||
{headers.slice(0, 6).map(h => (
|
||||
<option key={h} value={h}>{h}</option>
|
||||
))}
|
||||
<option value="">全部狀態</option>
|
||||
<option value="normal">正常</option>
|
||||
<option value="abnormal">異常</option>
|
||||
<option value="missing">缺勤</option>
|
||||
<option value="holiday">公假</option>
|
||||
<option value="leave">請假</option>
|
||||
</select>
|
||||
|
||||
{filterKey && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={filterValue}
|
||||
onChange={(e) => setFilterValue(e.target.value)}
|
||||
placeholder="輸入篩選值..."
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-2 w-32"
|
||||
/>
|
||||
<button onClick={clearFilter} className="p-1 text-slate-400 hover:text-slate-600">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</>
|
||||
{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} 筆記錄
|
||||
{latenessRecords.length > 0 && (
|
||||
<span className="ml-2 text-orange-600">
|
||||
• {latenessRecords.length} 筆遲到
|
||||
</span>
|
||||
{statusFilter && (
|
||||
<span className="ml-2 text-slate-400">• 已篩選:{statusFilter}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,46 +204,34 @@ export default function AttendanceList() {
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
processedData.map((record) => {
|
||||
const lateInfo = getLatenessInfo(record)
|
||||
const isLate = lateInfo && lateInfo.minutes_late > 0
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={record._row}
|
||||
className={`hover:bg-slate-50 cursor-pointer ${isLate ? 'bg-orange-50' : ''}`}
|
||||
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">
|
||||
{isLate ? (
|
||||
<div className="flex items-center justify-center gap-1 text-orange-600" title={`遲到 ${lateInfo.minutes_late} 分鐘`}>
|
||||
<AlertCircle size={14} />
|
||||
<span className="text-xs font-medium">{lateInfo.minutes_late}分</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-1 text-green-600">
|
||||
<Clock size={14} />
|
||||
<span className="text-xs">正常</span>
|
||||
</div>
|
||||
)}
|
||||
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-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>
|
||||
)
|
||||
})
|
||||
))}
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user