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:
@@ -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) &
|
||||||
|
|||||||
Generated
+2
-2
@@ -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",
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
+55
-130
@@ -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="輸入篩選值..."
|
<X size={16} />
|
||||||
className="text-sm border border-slate-300 rounded-md px-2 py-2 w-32"
|
</button>
|
||||||
/>
|
|
||||||
<button onClick={clearFilter} className="p-1 text-slate-400 hover:text-slate-600">
|
|
||||||
<X size={16} />
|
|
||||||
</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,46 +204,34 @@ export default function AttendanceList() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
processedData.map((record) => {
|
processedData.map((record) => (
|
||||||
const lateInfo = getLatenessInfo(record)
|
<tr
|
||||||
const isLate = lateInfo && lateInfo.minutes_late > 0
|
key={record._row}
|
||||||
|
className="hover:bg-slate-50 cursor-pointer"
|
||||||
return (
|
onClick={() => window.location.href = `/attendance/${record._row}`}
|
||||||
<tr
|
>
|
||||||
key={record._row}
|
<td className="px-3 py-2 text-xs text-slate-400">{record._row}</td>
|
||||||
className={`hover:bg-slate-50 cursor-pointer ${isLate ? 'bg-orange-50' : ''}`}
|
{headers.slice(0, 6).map(h => (
|
||||||
onClick={() => window.location.href = `/attendance/${record._row}`}
|
<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 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>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
|
))}
|
||||||
<Link
|
<td className="px-3 py-2 text-center">
|
||||||
to={`/attendance/${record._row}`}
|
<StatusBadge
|
||||||
className="p-1 text-slate-400 hover:text-primary-600 inline-block"
|
code={record.status_code}
|
||||||
>
|
text={record.status_text}
|
||||||
<Eye size={16} />
|
/>
|
||||||
</Link>
|
</td>
|
||||||
</td>
|
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
</tr>
|
<Link
|
||||||
)
|
to={`/attendance/${record._row}`}
|
||||||
})
|
className="p-1 text-slate-400 hover:text-primary-600 inline-block"
|
||||||
|
>
|
||||||
|
<Eye size={16} />
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user