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')
elif status_text in ('holiday', 'al', 'sl', 'cl', 'mixed_leave'):
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':
q = q.filter(
(AttendanceRecord.late_minutes == 0) &
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aars-frontend",
"version": "1.0.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aars-frontend",
"version": "1.0.0",
"version": "1.2.0",
"dependencies": {
"axios": "^1.7.7",
"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.
* 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:
* - code: status_code string (used for row-coloring and fallback)
* - late: late_minutes number
* - early: early_minutes number
* - ot: ot_minutes number
* - text: status_text string (used for leave / holiday / mixed)
* - code: status_code string
* - text: status_text string (fallback signal)
* - 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'
? 'px-3 py-1 text-sm'
: 'px-2 py-0.5 text-xs'
@@ -24,53 +28,31 @@ export default function StatusBadge({ code, late = 0, early = 0, ot = 0, text =
</span>
)
// Missing: show grey badge
if (code === 'missing' || text === '缺勤') {
const c = String(code || '').toLowerCase()
const t = String(text || '')
// (missing)
if (c === 'missing' || t === '缺勤') {
return badge(<CalendarX size={12} />, '缺勤', 'bg-gray-200 text-gray-700')
}
// Holiday: show purple badge
if (code === 'holiday' || text === '🏖️公假') {
// (holiday)
if (c === 'holiday' || t.includes('公假') || t.includes('🏖️')) {
return badge(<Palmtree size={12} />, '公假', 'bg-indigo-100 text-indigo-700')
}
// Annual leave
if (code === 'al' || text === '年假 AL') {
return badge(<Palmtree size={12} />, '年假 AL', 'bg-pink-100 text-pink-700')
// (any leave type)
if (['al', 'sl', 'cl', 'mixed_leave'].includes(c)
|| 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') {
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) {
//
if (c === 'normal' && !t) {
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 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">
{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,14 +204,10 @@ export default function AttendanceList() {
</td>
</tr>
) : (
processedData.map((record) => {
const lateInfo = getLatenessInfo(record)
const isLate = lateInfo && lateInfo.minutes_late > 0
return (
processedData.map((record) => (
<tr
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}`}
>
<td className="px-3 py-2 text-xs text-slate-400">{record._row}</td>
@@ -284,17 +217,10 @@ export default function AttendanceList() {
</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>
)}
<StatusBadge
code={record.status_code}
text={record.status_text}
/>
</td>
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
<Link
@@ -305,8 +231,7 @@ export default function AttendanceList() {
</Link>
</td>
</tr>
)
})
))
)}
</tbody>
</table>