Initial commit: AARS backend + frontend + holiday/leave phase 1+2

This commit is contained in:
IT Dog
2026-07-19 20:10:15 +08:00
commit bbc0048c24
49 changed files with 13375 additions and 0 deletions
+646
View File
@@ -0,0 +1,646 @@
import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import api from '../api'
import toast from 'react-hot-toast'
import { Users, AlertTriangle, Clock, CheckCircle, XCircle, Calendar, TrendingUp, ArrowRight, Award, AlertCircle, Clock3, Download, FileSpreadsheet, FileBarChart } from 'lucide-react'
export default function Dashboard() {
const [summary, setSummary] = useState(null)
const [stats, setStats] = useState(null)
const [loading, setLoading] = useState(true)
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [exporting, setExporting] = useState(false)
const pieRef = useRef(null)
const barRef = useRef(null)
const pieChart = useRef(null)
const barChart = useRef(null)
const dashboardRef = useRef(null)
useEffect(() => {
fetchData()
}, [dateFrom, dateTo])
useEffect(() => {
return () => {
if (pieChart.current) pieChart.current.destroy()
if (barChart.current) barChart.current.destroy()
}
}, [])
useEffect(() => {
if (summary && stats && !loading) {
renderCharts()
}
}, [summary, stats, loading])
const fetchData = async () => {
setLoading(true)
try {
const params = {}
if (dateFrom) params.date_from = dateFrom
if (dateTo) params.date_to = dateTo
const [sumRes, statRes] = await Promise.all([
api.get('/api/dashboard/summary', { params }),
api.get('/api/attendance/stats', { params }).catch(() => ({ data: null }))
])
setSummary(sumRes.data)
setStats(statRes.data)
} catch (err) {
toast.error('載入失敗')
console.error(err)
} finally {
setLoading(false)
}
}
const renderCharts = () => {
if (!summary) return
if (pieChart.current) pieChart.current.destroy()
if (barChart.current) barChart.current.destroy()
const Chart = window.Chart
if (!Chart) return
const pieCtx = pieRef.current?.getContext('2d')
if (pieCtx) {
const statusData = [
{ label: '正常', value: summary.normal_count || 0, color: '#10B981' },
{ label: '遲到', value: summary.late_count || 0, color: '#F59E0B' },
{ label: '早退', value: summary.early_count || 0, color: '#3B82F6' },
{ label: 'OT', value: summary.ot_count || 0, color: '#8B5CF6' },
{ label: '異常', value: summary.abnormal_count || 0, color: '#EF4444' },
{ label: '缺勤', value: summary.missing_count || 0, color: '#6B7280' },
].filter(s => s.value > 0)
if (statusData.length > 0) {
pieChart.current = new Chart(pieCtx, {
type: 'doughnut',
data: {
labels: statusData.map(s => s.label),
datasets: [{ data: statusData.map(s => s.value), backgroundColor: statusData.map(s => s.color), borderWidth: 2, borderColor: '#fff' }]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { padding: 16, font: { size: 12 } } }, title: { display: true, text: '出勤狀態分佈', font: { size: 14, weight: '600' }, padding: { bottom: 12 } } }
}
})
}
}
const barCtx = barRef.current?.getContext('2d')
if (barCtx && stats?.stats?.length > 0) {
const staffData = stats.stats.slice(0, 8)
barChart.current = new Chart(barCtx, {
type: 'bar',
data: {
labels: staffData.map(s => s.name),
datasets: [{ label: '出勤記錄', data: staffData.map(s => s.total_records || s.total || 0), backgroundColor: '#2563EB', borderRadius: 4 }]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false }, title: { display: true, text: '員工出勤記錄數', font: { size: 14, weight: '600' }, padding: { bottom: 12 } } },
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } }
}
})
}
}
// Export PDF - capture dashboard as image then make PDF
const exportPDF = async () => {
if (!dashboardRef.current) { toast.error('找不到 Dashboard'); return }
setExporting(true)
try {
if (typeof window.jspdf === 'undefined') throw new Error('jspdf 未載入')
if (typeof window.Chart === 'undefined') throw new Error('Chart.js 未載入')
const { jsPDF } = window.jspdf
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' })
const PW = pdf.internal.pageSize.getWidth() // 297mm
const PH = pdf.internal.pageSize.getHeight() // 210mm
const M = 12
// ─── Title ───
pdf.setFontSize(18); pdf.setTextColor(15,23,42)
pdf.text('Attendance Report', M, 14)
const range = (dateFrom || dateTo) ? `${dateFrom||'開始'} ~ ${dateTo||'今日'}` : '全部'
const now = new Date().toLocaleString('en-US',{hour12:false,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'})
pdf.setFontSize(9); pdf.setTextColor(100,116,139)
pdf.text(`Date Range: ${range} | Generated: ${now}`, M, 20)
// ─── Summary Cards ───
const cards = summary ? [
{ label: 'Total', value: summary.total_records, color: [37,99,235] },
{ label: 'Normal', value: summary.normal_count, color: [16,185,129] },
{ label: 'Late', value: summary.late_count, color: [245,158,11] },
{ label: 'OT', value: summary.ot_count, color: [139,92,246] },
{ label: 'Abnormal', value: summary.abnormal_count, color: [239,68,68] },
{ label: 'Missing', value: summary.missing_count, color: [107,114,128] },
] : []
const cardW = (PW - M*2) / cards.length - 3
cards.forEach((card, i) => {
const cx = M + i*(cardW+3)
pdf.setFillColor(...card.color)
pdf.roundedRect(cx, 22, cardW, 16, 2, 2, 'F')
pdf.setFontSize(14); pdf.setTextColor(255,255,255)
pdf.text(String(card.value ?? 0), cx+cardW/2, 30, {align:'center'})
pdf.setFontSize(7); pdf.setTextColor(255,255,255)
pdf.text(card.label, cx+cardW/2, 34, {align:'center'})
})
// ─── Charts ───
const chartTop = 44
const chartH = 60
const chartW = (PW - M*2 - 8) / 2 // two charts side by side
// Helper: draw doughnut / pie chart
const drawPieChart = (cx, cy, r, data, colors, pdf) => {
const total = data.reduce((a,b)=>a+b, 0)
if (total === 0) return
let angle = -Math.PI/2
data.forEach((val, i) => {
const slice = (val/total) * 2*Math.PI
pdf.setFillColor(...colors[i])
pdf.setDrawColor(255,255,255)
pdf.setLineWidth(0.3)
const x1 = cx + r * Math.cos(angle)
const y1 = cy + r * Math.sin(angle)
const x2 = cx + r * Math.cos(angle + slice)
const y2 = cy + r * Math.sin(angle + slice)
if (val === total) {
pdf.setFillColor(...colors[i])
pdf.circle(cx, cy, r, 'F')
} else {
const pts = [[cx, cy], [x1, y1], [x2, y2]]
pdf.triangle(pts[0][0], pts[0][1], pts[1][0], pts[1][1], pts[2][0], pts[2][1], 'F')
}
angle += slice
})
// inner circle (donut hole)
pdf.setFillColor(255,255,255)
pdf.circle(cx, cy, r*0.5, 'F')
}
// Helper: draw bar chart
const drawBarChart = (bx, by, bw, bh, labels, values, barColor, pdf) => {
const max = Math.max(...values, 1)
const barW = bw / labels.length * 0.6
const gap = bw / labels.length * 0.4
labels.forEach((lbl, i) => {
const barH = (values[i]/max) * bh
const x = bx + i*(barW+gap) + gap/2
const y = by + bh - barH
pdf.setFillColor(...barColor)
pdf.roundedRect(x, y, barW, barH, 1, 1, 'F')
// value label
pdf.setFontSize(6); pdf.setTextColor(100,116,139)
pdf.text(String(values[i]), x+barW/2, by+bh+3.5, {align:'center'})
// x label (truncated)
pdf.setFontSize(6); pdf.setTextColor(100,116,139)
pdf.text(lbl.length > 6 ? lbl.substring(0,5)+'.' : lbl, x+barW/2, by+bh+6.5, {align:'center'})
})
}
// ── Pie: 出勤狀態分佈 ──
if (summary) {
const pieCX = M + chartW/2
const pieCY = chartTop + chartH/2
const pieR = chartH/2 - 4
const pieData = [
summary.normal_count || 0,
summary.late_count || 0,
summary.early_count || 0,
summary.ot_count || 0,
summary.abnormal_count || 0,
summary.missing_count || 0,
]
const pieColors = [[16,185,129],[245,158,11],[59,130,246],[139,92,246],[239,68,68],[107,114,128]]
const pieLabels = ['Normal','Late','Early','OT','Abnormal','Missing']
const pieLegendX = M
const pieLegendY = chartTop + chartH + 5
pdf.setFontSize(10); pdf.setTextColor(15,23,42)
pdf.text('Attendance Status Distribution', M, chartTop - 2)
// Draw pie
drawPieChart(pieCX, pieCY, pieR, pieData, pieColors, pdf)
// Legend
let lx = M
pieLabels.forEach((lbl, i) => {
if (pieData[i] === 0) return
pdf.setFillColor(...pieColors[i])
pdf.rect(lx, pieLegendY, 4, 4, 'F')
pdf.setFontSize(7.5); pdf.setTextColor(60,70,90)
pdf.text(`${lbl} ${pieData[i]}`, lx+5, pieLegendY+3.5)
lx += 28
})
}
// ── Bar: 員工出勤記錄 ──
if (stats?.stats?.length > 0) {
const barX = M + chartW + 8
const barLabels = stats.stats.slice(0,8).map(s=>s.name)
const barValues = stats.stats.slice(0,8).map(s=>s.total_records || s.total || 0)
pdf.setFontSize(10); pdf.setTextColor(15,23,42)
pdf.text('Staff Attendance Records', barX, chartTop - 2)
// Axis
const axisX = barX + 2
const axisY = chartTop + chartH - 2
const axisW = chartW - 4
const axisH = chartH - 8
pdf.setDrawColor(200,210,220)
pdf.setLineWidth(0.3)
pdf.line(axisX, axisY, axisX, axisY - axisH) // y axis
pdf.line(axisX, axisY, axisX + axisW, axisY) // x axis
drawBarChart(axisX, axisY - axisH, axisW, axisH, barLabels, barValues, [37,99,235], pdf)
}
// ─── Leaderboards ───
if (stats?.stats?.length > 0) {
const staff = stats.stats
const lbTop = chartTop + chartH + 14
const lateRank = [...staff].sort((a,b)=>(b.late_count||0)-(a.late_count||0)).slice(0,5)
const otRank = [...staff].sort((a,b)=>(b.ot_count||0)-(a.ot_count||0)).slice(0,5)
const missRank = [...staff].sort((a,b)=>(b.missing_count||0)-(a.missing_count||0)).slice(0,5)
const rateRank = [...staff].map(s=>({...s,rate:(s.total_records||s.total||0)>0?Math.round((((s.total_records||s.total||0)-(s.late_count||0)-(s.abnormal_count||0))/(s.total_records||s.total||0))*100):0})).sort((a,b)=>b.rate-a.rate).slice(0,5)
const lbW = (PW - M*2) / 4 - 4
const lbData = [
{ title: 'Late Rank', data: lateRank, key: 'late_count', sub: 'late_minutes', color: [245,158,11] },
{ title: 'OT Rank', data: otRank, key: 'ot_count', sub: 'ot_minutes', color: [139,92,246] },
{ title: 'Missing Rank', data: missRank, key: 'missing_count', sub: null, color: [107,114,128] },
{ title: 'Attendance Rate', data: rateRank, key: 'rate', sub: null, color: [16,185,129] },
]
lbData.forEach((lb, li) => {
const lx = M + li*(lbW+4)
// Box
pdf.setFillColor(...lb.color)
pdf.roundedRect(lx, lbTop, lbW, 6, 2, 2, 'F')
pdf.setFontSize(8); pdf.setTextColor(255,255,255)
const lines = lb.title.split('\n')
lines.forEach((l, li2) => pdf.text(l, lx+lbW/2, lbTop+3+li2*3, {align:'center'}))
// Items
lb.data.forEach((item, di) => {
const iy = lbTop + 10 + di*5
const medal = ['🥇','🥈','🥉'][di] || ` ${di+1}.`
const sub = lb.sub && item[lb.sub] ? ` (${item[lb.sub]}min)` : ''
const val = `${item[lb.key]}${lb.key==='rate'?'%':''}${sub}`
pdf.setFontSize(7.5); pdf.setTextColor(30,41,59)
pdf.text(`${medal} ${item.name} ${val}`, lx+2, iy)
})
})
}
// ─── Staff Table ───
if (stats?.stats?.length > 0) {
const tblTop = PW > 250 ? (chartTop + chartH + 14 + 35) : (chartTop + chartH + 14 + 30)
pdf.setFontSize(11); pdf.setTextColor(15,23,42)
pdf.text('Staff Summary', M, tblTop - 1)
const staff = stats.stats
const colW = [46, 18, 16, 20, 16, 20, 16, 16]
const cols = ['Name','Total','Late','LateMin','OT','OTMin','Abn','Miss']
let xPos = [M]
for (let ci=0; ci<colW.length-1; ci++) xPos.push(xPos[ci]+colW[ci])
const rowH = 6
// Header
pdf.setFillColor(50,60,80)
pdf.rect(M, tblTop, PW-M*2, rowH, 'F')
pdf.setFontSize(8); pdf.setTextColor(255,255,255)
cols.forEach((h,ci) => pdf.text(h, xPos[ci]+colW[ci]/2, tblTop+4.5, {align:'center'}))
// Rows
staff.forEach((s, ri) => {
const ry = tblTop + rowH*(ri+1)
if (ri%2===0) { pdf.setFillColor(248,250,252); pdf.rect(M, ry, PW-M*2, rowH, 'F') }
pdf.setFontSize(8); pdf.setTextColor(30,41,59)
const vals = [s.name, String(s.total_records || s.total || 0), String(s.late_count||0), String(s.late_minutes||'-'), String(s.ot_count||0), String(s.ot_minutes||'-'), String(s.abnormal_count||0), String(s.missing_count||0)]
vals.forEach((v,ci) => pdf.text(v, xPos[ci]+colW[ci]/2, ry+4.5, {align:'center'}))
})
}
pdf.save(`attendance_report_${dateFrom||'all'}_${dateTo||''}.pdf`.replace(/-/g,''))
toast.success('✅ PDF 已下載')
} catch (err) {
console.error('PDF error:', err)
toast.error('PDF 導出失敗: ' + err.message)
} finally {
setExporting(false)
}
}
const exportExcel = async (full) => {
setExporting(true)
try {
const token = localStorage.getItem('aars_token')
const params = full ? {} : (dateFrom ? { date_from: dateFrom, date_to: dateTo || new Date().toISOString().slice(0, 10) } : {})
const url = `/api/export/attendance/excel${Object.keys(params).length ? '?' + new URLSearchParams(params).toString() : ''}`
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
if (!resp.ok) throw new Error('Export failed')
const blob = await resp.blob()
const filename = full
? `attendance_full.xlsx`
: `attendance_${dateFrom || 'start'}_${dateTo || 'today'}.xlsx`.replace(/-/g, '')
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = filename
a.click()
URL.revokeObjectURL(a.href)
toast.success('✅ Excel 已下載')
} catch (err) {
console.error('Excel export error:', err)
toast.error('Excel 導出失敗')
} finally {
setExporting(false)
}
}
const byStaff = stats?.stats || []
const lateRank = [...byStaff].sort((a, b) => (b.late_count || 0) - (a.late_count || 0))
const otRank = [...byStaff].sort((a, b) => (b.ot_count || 0) - (a.ot_count || 0))
const abnormalRank = [...byStaff].sort((a, b) => (b.abnormal_count || 0) - (a.abnormal_count || 0))
const missingRank = [...byStaff].sort((a, b) => (b.missing_count || 0) - (a.missing_count || 0))
const attendanceRate = [...byStaff].map(s => ({
...s,
rate: (s.total_records || s.total || 0) > 0 ? Math.round(((((s.total_records || s.total || 0) - (s.late_count || 0) - (s.abnormal_count || 0))) / (s.total_records || s.total || 0)) * 100) : 0
})).sort((a, b) => b.rate - a.rate)
const statCards = summary ? [
{ label: '總記錄', value: summary.total_records, icon: Calendar, color: 'bg-blue-50 text-blue-600' },
{ label: '正常', value: summary.normal_count, icon: CheckCircle, color: 'bg-green-50 text-green-600' },
{ label: '遲到', value: summary.late_count, icon: Clock, color: 'bg-orange-50 text-orange-600' },
{ label: '早退', value: summary.early_count, icon: Clock3, color: 'bg-blue-50 text-blue-600' },
{ label: 'OT', value: summary.ot_count, icon: TrendingUp, color: 'bg-purple-50 text-purple-600' },
{ label: '異常', value: summary.abnormal_count, icon: AlertTriangle, color: 'bg-red-50 text-red-600' },
{ label: '缺勤', value: summary.missing_count, icon: XCircle, color: 'bg-gray-50 text-gray-600' },
{ label: '員工', value: summary.staff_count, icon: Users, color: 'bg-teal-50 text-teal-600' },
] : []
const today = new Date().toISOString().slice(0, 10)
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10)
// Date preset helpers
const fmt = (d) => d.toISOString().slice(0, 10)
const weekRange = (offsetWeeks) => {
// Monday = start of week
const now = new Date()
const dow = now.getDay() // Sun=0, Mon=1, ... Sat=6
const daysFromMonday = (dow + 6) % 7 // Mon=0, Tue=1, ..., Sun=6
const start = new Date(now)
start.setDate(now.getDate() - daysFromMonday + offsetWeeks * 7)
const end = new Date(start)
end.setDate(start.getDate() + 6)
return [fmt(start), fmt(end)]
}
// Map rankKey to Option 1 detail fields exposed by backend
const detailFieldsMap = {
late_count: [
{ key: 'late_minutes', label: '分鐘', unit: '分' },
{ key: 'late_avg_minutes', label: '平均', unit: '分' },
{ key: 'late_max_minutes', label: '最長', unit: '分' },
],
early_count: [
{ key: 'early_minutes', label: '分鐘', unit: '分' },
{ key: 'early_avg_minutes', label: '平均', unit: '分' },
{ key: 'early_max_minutes', label: '最長', unit: '分' },
],
ot_count: [
{ key: 'ot_minutes', label: '分鐘', unit: '分' },
{ key: 'ot_avg_minutes', label: '平均', unit: '分' },
{ key: 'ot_max_minutes', label: '最長', unit: '分' },
],
missing_count: [
{ key: 'consecutive_missing', label: '連續', unit: '次' },
{ key: 'attendance_rate', label: '出勤率', unit: '%', suffix: true },
{ key: 'last_attendance_date', label: '最後', isDate: true },
],
rate: [
{ key: 'total_records', label: '記錄', unit: '條' },
{ key: 'missing_count', label: '缺勤', unit: '次' },
],
}
const RankCard = ({ title, icon: Icon, iconColor, data, rankKey, subKey, unit, colorFn }) => {
const details = detailFieldsMap[rankKey] || []
const items = data.filter(d => d[rankKey] > 0 || rankKey === 'rate').slice(0, 6)
return (
<div className="card p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
<Icon size={16} style={{ color: iconColor }} /> {title}
</h3>
<div className="space-y-1">
{items.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-4">暫無數據</p>
) : items.map((s, i) => (
<div key={s.name} className="py-1.5 border-b border-slate-100 last:border-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold ${
i === 0 ? 'bg-yellow-100 text-yellow-700' : i === 1 ? 'bg-slate-100 text-slate-500' : i === 2 ? 'bg-orange-50 text-orange-400' : 'bg-slate-50 text-slate-400'
}`}>{i + 1}</span>
<span className="text-sm text-slate-700">{s.name}</span>
</div>
<div className="text-right">
<span className="text-sm font-bold" style={colorFn ? { color: colorFn(s) } : undefined}>
{rankKey === 'rate' ? `${s[rankKey]}%` : s[rankKey]}
</span>
{subKey && (s[subKey] || 0) > 0 && (
<span className="text-xs text-slate-400 ml-1">({s[subKey]}{unit || '次'})</span>
)}
</div>
</div>
{/* Option 1 detail row */}
{details.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1 ml-7 text-[11px] text-slate-500">
{details.map(d => {
const v = s[d.key]
let display
if (v === null || v === undefined || v === 0 && d.key !== 'attendance_rate') {
// Skip empty/zero for most fields, but show 0% rate
if (!(d.key === 'attendance_rate')) return null
display = `0${d.unit}`
} else if (d.isDate) {
display = `${d.label} ${v}`
} else if (d.suffix) {
// e.g. attendance_rate = 70 (already %)
display = `${d.label} ${v}${d.unit}`
} else {
display = `${d.label} ${v}${d.unit}`
}
// For attendance_rate, only show if rate exists
if (d.key === 'attendance_rate' && (v === null || v === undefined)) return null
// For last_attendance_date, only show if exists
if (d.isDate && !v) return null
// For consecutive_missing, only show if > 0 (otherwise skip)
if (d.key === 'consecutive_missing' && (v || 0) === 0) return null
return <span key={d.key}>{display}</span>
})}
</div>
)}
</div>
))}
</div>
</div>
)
}
return (
<div className="space-y-6" ref={dashboardRef}>
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-slate-900">Dashboard</h1>
<div className="flex items-center gap-2">
{/* Export Buttons */}
<div className="flex items-center gap-1 border border-slate-300 rounded-md overflow-hidden">
<button
onClick={exportPDF}
disabled={exporting}
className="flex items-center gap-1 px-3 py-2 text-sm bg-white text-slate-700 hover:bg-slate-50 disabled:opacity-50"
title="導出 PDF"
>
<FileBarChart size={14} /> PDF
</button>
<div className="w-px h-5 bg-slate-300" />
<button
onClick={() => exportExcel(false)}
disabled={exporting}
className="flex items-center gap-1 px-3 py-2 text-sm bg-white text-slate-700 hover:bg-slate-50 disabled:opacity-50"
title="導出 Excel (跟時間範圍)"
>
<FileSpreadsheet size={14} /> Excel
</button>
<div className="w-px h-5 bg-slate-300" />
<button
onClick={() => exportExcel(true)}
disabled={exporting}
className="flex items-center gap-1 px-3 py-2 text-sm bg-blue-50 text-blue-700 hover:bg-blue-100 disabled:opacity-50 font-medium"
title="導出 Excel (全部)"
>
<Download size={14} /> 全部
</button>
</div>
<Link to="/attendance" className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 text-sm rounded-md hover:bg-slate-200">
<ArrowRight size={16} /> 出勤記錄
</Link>
</div>
</div>
{/* Date Filter */}
<div className="card p-4">
<div className="flex flex-wrap gap-3 items-end">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500">日期範圍:</span>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" />
<span className="text-slate-400">-</span>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" />
</div>
<div className="flex gap-2 flex-wrap">
<button onClick={() => { setDateFrom(today); setDateTo(today) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">今日</button>
<button onClick={() => {
const [w0, w1] = weekRange(0)
setDateFrom(w0); setDateTo(w1)
}} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">本週</button>
<button onClick={() => {
const [w0, w1] = weekRange(-1)
setDateFrom(w0); setDateTo(w1)
}} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">上週</button>
<button onClick={() => { setDateFrom(monthStart); setDateTo(today) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">本月</button>
<button onClick={() => { setDateFrom(''); setDateTo('') }} className="text-xs text-slate-500 hover:text-slate-700 px-2 py-2 border border-slate-300 rounded">清除</button>
</div>
</div>
{(dateFrom || dateTo) && (
<p className="text-xs text-slate-400 mt-2">顯示 {dateFrom || '開始'} ~ {dateTo || today} 的數據</p>
)}
</div>
{loading ? (
<div className="text-center py-12 text-slate-400">載入中...</div>
) : summary ? (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{statCards.map(({ label, value, icon: Icon, color }) => (
<div key={label} className="card p-4">
<div className="flex items-center gap-3">
<div className={`p-2.5 rounded-lg ${color}`}><Icon size={20} /></div>
<div>
<div className="text-2xl font-bold text-slate-900">{value ?? 0}</div>
<div className="text-xs text-slate-500">{label}</div>
</div>
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="card p-4">
<div style={{ height: '260px', position: 'relative' }}><canvas ref={pieRef}></canvas></div>
</div>
<div className="card p-4">
<div style={{ height: '260px', position: 'relative' }}><canvas ref={barRef}></canvas></div>
</div>
</div>
{byStaff.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<RankCard title="遲到排行榜" icon={Clock} iconColor="#F59E0B" data={lateRank} rankKey="late_count" subKey="late_minutes" unit="分" colorFn={s => s.late_count > 0 ? '#F59E0B' : '#10B981'} />
<RankCard title="OT排行榜" icon={TrendingUp} iconColor="#8B5CF6" data={otRank} rankKey="ot_count" subKey="ot_minutes" unit="分" colorFn={s => s.ot_count > 0 ? '#8B5CF6' : '#10B981'} />
<RankCard title="缺勤排行榜" icon={XCircle} iconColor="#6B7280" data={missingRank} rankKey="missing_count" colorFn={s => s.missing_count > 0 ? '#EF4444' : '#10B981'} />
<RankCard title="出勤率排行榜" icon={Award} iconColor="#10B981" data={attendanceRate} rankKey="rate" colorFn={s => s.rate >= 90 ? '#10B981' : s.rate >= 70 ? '#F59E0B' : '#EF4444'} />
</div>
)}
{stats?.stats?.length > 0 && (
<div className="card p-4">
<h2 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
<Users size={16} /> 員工出勤概覽
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-600">員工</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">出勤</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">正常</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">遲到</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">遲分鐘</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">OT</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">OT分鐘</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">異常</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">缺勤</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{byStaff.map((s) => (
<tr key={s.name} className="hover:bg-slate-50">
<td className="px-3 py-2 font-medium text-slate-700">{s.name}</td>
<td className="px-3 py-2 text-right text-slate-600">{s.total_records ?? s.total ?? 0}</td>
<td className="px-3 py-2 text-right text-green-600">{(s.total_records ?? s.total ?? 0) - (s.late_count || 0) - (s.abnormal_count || 0)}</td>
<td className="px-3 py-2 text-right text-orange-600">{s.late_count || 0}</td>
<td className="px-3 py-2 text-right text-orange-400">{s.late_minutes || '-'}</td>
<td className="px-3 py-2 text-right text-purple-600">{s.ot_count || 0}</td>
<td className="px-3 py-2 text-right text-purple-400">{s.ot_minutes || '-'}</td>
<td className="px-3 py-2 text-right text-red-600">{s.abnormal_count || 0}</td>
<td className="px-3 py-2 text-right text-gray-500">{s.missing_count || 0}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
) : (
<div className="text-center py-12 text-slate-400">暫無數據</div>
)}
</div>
)
}