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:
IT狗
2026-07-21 21:33:42 +08:00
parent 8b378ec4c6
commit 4b967f7920
4 changed files with 88 additions and 178 deletions
+3
View File
@@ -1289,6 +1289,9 @@ async def attendance_records(
q = q.filter(AttendanceRecord.status_code == 'missing') q = q.filter(AttendanceRecord.status_code == 'missing')
elif status_text in ('holiday', 'al', 'sl', 'cl', 'mixed_leave'): elif status_text in ('holiday', 'al', 'sl', 'cl', 'mixed_leave'):
q = q.filter(AttendanceRecord.status_code == status_text) q = q.filter(AttendanceRecord.status_code == status_text)
elif status_text == 'leave':
# Any leave type — group filter
q = q.filter(AttendanceRecord.status_code.in_(['al', 'sl', 'cl', 'mixed_leave']))
elif status_text == 'normal': elif status_text == 'normal':
q = q.filter( q = q.filter(
(AttendanceRecord.late_minutes == 0) & (AttendanceRecord.late_minutes == 0) &
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "aars-frontend", "name": "aars-frontend",
"version": "1.0.0", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aars-frontend", "name": "aars-frontend",
"version": "1.0.0", "version": "1.2.0",
"dependencies": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",
"lucide-react": "^0.441.0", "lucide-react": "^0.441.0",
+28 -46
View File
@@ -1,18 +1,22 @@
import { CheckCircle, AlertCircle, Clock, X, AlertTriangle, Briefcase, Stethoscope, Timer, PartyPopper, CalendarX, Palmtree } from 'lucide-react' import { CheckCircle, AlertCircle, CalendarX, Palmtree, Briefcase } from 'lucide-react'
/** /**
* Reusable status badge for attendance records. * Reusable status badge for attendance records.
* Renders one badge per status dimension (abnormal / late / early / OT / leave). * Simplified to BASIC status categories — no detailed breakdowns.
*
* Status mapping (driven by backend status_code / status_text):
* - missing → 缺勤 (grey)
* - holiday → 公假 (indigo)
* - al / sl / cl / mixed_leave → 請假 (violet)
* - normal → 正常 (green)
* - everything else (late/early/ot/abnormal/late_early/...) → 異常 (orange)
* *
* Props: * Props:
* - code: status_code string (used for row-coloring and fallback) * - code: status_code string
* - late: late_minutes number * - text: status_text string (fallback signal)
* - early: early_minutes number
* - ot: ot_minutes number
* - text: status_text string (used for leave / holiday / mixed)
* - size: 'sm' | 'md' (default 'sm') * - size: 'sm' | 'md' (default 'sm')
*/ */
export default function StatusBadge({ code, late = 0, early = 0, ot = 0, text = '', size = 'sm' }) { export default function StatusBadge({ code = '', text = '', size = 'sm' }) {
const sizeCls = size === 'md' const sizeCls = size === 'md'
? 'px-3 py-1 text-sm' ? 'px-3 py-1 text-sm'
: 'px-2 py-0.5 text-xs' : 'px-2 py-0.5 text-xs'
@@ -24,53 +28,31 @@ export default function StatusBadge({ code, late = 0, early = 0, ot = 0, text =
</span> </span>
) )
// Missing: show grey 缺勤 badge const c = String(code || '').toLowerCase()
if (code === 'missing' || text === '缺勤') { const t = String(text || '')
// 缺勤 (missing)
if (c === 'missing' || t === '缺勤') {
return badge(<CalendarX size={12} />, '缺勤', 'bg-gray-200 text-gray-700') return badge(<CalendarX size={12} />, '缺勤', 'bg-gray-200 text-gray-700')
} }
// Holiday: show purple 假期 badge // 公假 (holiday)
if (code === 'holiday' || text === '🏖️公假') { if (c === 'holiday' || t.includes('公假') || t.includes('🏖️')) {
return badge(<Palmtree size={12} />, '公假', 'bg-indigo-100 text-indigo-700') return badge(<Palmtree size={12} />, '公假', 'bg-indigo-100 text-indigo-700')
} }
// Annual leave // 請假 (any leave type)
if (code === 'al' || text === '年假 AL') { if (['al', 'sl', 'cl', 'mixed_leave'].includes(c)
return badge(<Palmtree size={12} />, '年假 AL', 'bg-pink-100 text-pink-700') || t.includes('年假') || t.includes('病假') || t.includes('補鐘')
|| t.includes('混合假')) {
return badge(<Briefcase size={12} />, '請假', 'bg-violet-100 text-violet-700')
} }
// Sick leave // 正常
if (code === 'sl' || text === '病假 SL') { if (c === 'normal' && !t) {
return badge(<Stethoscope size={12} />, '病假 SL', 'bg-cyan-100 text-cyan-700')
}
// Compensation leave
if (code === 'cl' || text === '補鐘 CL') {
return badge(<Timer size={12} />, '補鐘 CL', 'bg-amber-100 text-amber-700')
}
// Mixed leave
if (code === 'mixed_leave' || text === '混合假') {
return badge(<Briefcase size={12} />, '混合假', 'bg-violet-100 text-violet-700')
}
const parts = []
// Abnormal (any dimension > 30 min)
if (late > 30 || early > 30 || ot > 30) {
if (late > 30) parts.push(badge(<AlertTriangle size={12} />, `異常-遲到${late}`, 'bg-red-100 text-red-700'))
else if (early > 30) parts.push(badge(<AlertTriangle size={12} />, `異常-早退${early}`, 'bg-red-100 text-red-700'))
else parts.push(badge(<AlertTriangle size={12} />, `異常-OT${ot}`, 'bg-red-100 text-red-700'))
} else {
// Individual badges (≤ 30 min each)
if (late > 0) parts.push(badge(<AlertCircle size={12} />, `遲到${late}`, 'bg-orange-100 text-orange-700'))
if (early > 0) parts.push(badge(<Clock size={12} />, `早退${early}`, 'bg-blue-100 text-blue-700'))
if (ot > 0) parts.push(badge(<Clock size={12} />, `OT${ot}`, 'bg-purple-100 text-purple-700'))
}
if (parts.length === 0) {
return badge(<CheckCircle size={12} />, '正常', 'bg-green-100 text-green-700') return badge(<CheckCircle size={12} />, '正常', 'bg-green-100 text-green-700')
} }
return <span className="inline-flex items-center gap-1 flex-wrap">{parts}</span> // 異常 (late/early/ot/abnormal/late_early/late_ot/early_ot/...)
return badge(<AlertCircle size={12} />, '異常', 'bg-orange-100 text-orange-700')
} }
+33 -108
View File
@@ -2,7 +2,8 @@ import { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import api from '../../api' import api from '../../api'
import toast from 'react-hot-toast' 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 handleExportHTML = (section) => {
const token = localStorage.getItem('aars_token') const token = localStorage.getItem('aars_token')
@@ -18,19 +19,18 @@ export default function AttendanceList() {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState(null) const [sortKey, setSortKey] = useState(null)
const [sortDir, setSortDir] = useState('asc') const [sortDir, setSortDir] = useState('asc')
const [filterKey, setFilterKey] = useState('') const [statusFilter, setStatusFilter] = useState('')
const [filterValue, setFilterValue] = useState('')
const [latenessRecords, setLatenessRecords] = useState([])
useEffect(() => { useEffect(() => {
fetchRecords() fetchRecords()
fetchLateness() }, [statusFilter])
}, [])
const fetchRecords = async () => { const fetchRecords = async () => {
setLoading(true) setLoading(true)
try { 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 || []) setRecords(data.data || [])
if (data.data && data.data.length > 0) { if (data.data && data.data.length > 0) {
@@ -44,37 +44,10 @@ export default function AttendanceList() {
} }
} }
const fetchLateness = async () => { // Filtered and sorted data (client-side: search + sort only; status handled by backend)
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
const processedData = useMemo(() => { const processedData = useMemo(() => {
let result = [...records] let result = [...records]
// Filter
if (filterKey && filterValue) {
result = result.filter(r => {
const val = String(r[filterKey] || '').toLowerCase()
return val.includes(filterValue.toLowerCase())
})
}
// Search // Search
if (search) { if (search) {
const searchLower = search.toLowerCase() const searchLower = search.toLowerCase()
@@ -104,7 +77,7 @@ export default function AttendanceList() {
} }
return result return result
}, [records, search, sortKey, sortDir, filterKey, filterValue]) }, [records, search, sortKey, sortDir])
const handleSort = (key) => { const handleSort = (key) => {
if (sortKey === 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) => { const getSortIcon = (key) => {
if (sortKey !== key) return null if (sortKey !== key) return null
return sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} /> 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Header */} {/* Header */}
@@ -188,43 +130,38 @@ export default function AttendanceList() {
/> />
</div> </div>
{/* Column Filter */} {/* Status Filter (basic categories — server-side) */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Filter size={16} className="text-slate-400" /> <Filter size={16} className="text-slate-400" />
<select <select
value={filterKey} value={statusFilter}
onChange={(e) => handleFilter(e.target.value)} onChange={(e) => setStatusFilter(e.target.value)}
className="text-sm border border-slate-300 rounded-md px-2 py-2" className="text-sm border border-slate-300 rounded-md px-2 py-2"
> >
<option value="">篩選欄位</option> <option value="">全部狀態</option>
{headers.slice(0, 6).map(h => ( <option value="normal">正常</option>
<option key={h} value={h}>{h}</option> <option value="abnormal">異常</option>
))} <option value="missing">缺勤</option>
<option value="holiday">公假</option>
<option value="leave">請假</option>
</select> </select>
{filterKey && ( {statusFilter && (
<> <button
<input onClick={() => setStatusFilter('')}
type="text" className="p-1 text-slate-400 hover:text-slate-600"
value={filterValue} title="清除篩選"
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} /> <X size={16} />
</button> </button>
</>
)} )}
</div> </div>
{/* Result count */} {/* Result count */}
<div className="text-sm text-slate-500 py-2"> <div className="text-sm text-slate-500 py-2">
{processedData.length} 筆記錄 {processedData.length} 筆記錄
{latenessRecords.length > 0 && ( {statusFilter && (
<span className="ml-2 text-orange-600"> <span className="ml-2 text-slate-400"> 已篩選{statusFilter}</span>
{latenessRecords.length} 筆遲到
</span>
)} )}
</div> </div>
</div> </div>
@@ -267,14 +204,10 @@ export default function AttendanceList() {
</td> </td>
</tr> </tr>
) : ( ) : (
processedData.map((record) => { processedData.map((record) => (
const lateInfo = getLatenessInfo(record)
const isLate = lateInfo && lateInfo.minutes_late > 0
return (
<tr <tr
key={record._row} key={record._row}
className={`hover:bg-slate-50 cursor-pointer ${isLate ? 'bg-orange-50' : ''}`} className="hover:bg-slate-50 cursor-pointer"
onClick={() => window.location.href = `/attendance/${record._row}`} onClick={() => window.location.href = `/attendance/${record._row}`}
> >
<td className="px-3 py-2 text-xs text-slate-400">{record._row}</td> <td className="px-3 py-2 text-xs text-slate-400">{record._row}</td>
@@ -284,17 +217,10 @@ export default function AttendanceList() {
</td> </td>
))} ))}
<td className="px-3 py-2 text-center"> <td className="px-3 py-2 text-center">
{isLate ? ( <StatusBadge
<div className="flex items-center justify-center gap-1 text-orange-600" title={`遲到 ${lateInfo.minutes_late} 分鐘`}> code={record.status_code}
<AlertCircle size={14} /> text={record.status_text}
<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>
)}
</td> </td>
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}> <td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
<Link <Link
@@ -305,8 +231,7 @@ export default function AttendanceList() {
</Link> </Link>
</td> </td>
</tr> </tr>
) ))
})
)} )}
</tbody> </tbody>
</table> </table>