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 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 (

{title}

{items.length === 0 ? (

暫無數據

) : items.map((s, i) => (
{i + 1} {s.name}
{rankKey === 'rate' ? `${s[rankKey]}%` : s[rankKey]} {subKey && (s[subKey] || 0) > 0 && ( ({s[subKey]}{unit || '次'}) )}
{/* Option 1 detail row */} {details.length > 0 && (
{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 {display} })}
)}
))}
) } return (
{/* Header */}

Dashboard

{/* Export Buttons */}
出勤記錄
{/* Date Filter */}
日期範圍: setDateFrom(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" /> - setDateTo(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" />
{(dateFrom || dateTo) && (

顯示 {dateFrom || '開始'} ~ {dateTo || today} 的數據

)}
{loading ? (
載入中...
) : summary ? ( <>
{statCards.map(({ label, value, icon: Icon, color }) => (
{value ?? 0}
{label}
))}
{byStaff.length > 0 && (
s.late_count > 0 ? '#F59E0B' : '#10B981'} /> s.ot_count > 0 ? '#8B5CF6' : '#10B981'} /> s.missing_count > 0 ? '#EF4444' : '#10B981'} /> s.rate >= 90 ? '#10B981' : s.rate >= 70 ? '#F59E0B' : '#EF4444'} />
)} {stats?.stats?.length > 0 && (

員工出勤概覽

{byStaff.map((s) => ( ))}
員工 出勤 正常 遲到 遲分鐘 OT OT分鐘 異常 缺勤
{s.name} {s.total_records ?? s.total ?? 0} {(s.total_records ?? s.total ?? 0) - (s.late_count || 0) - (s.abnormal_count || 0)} {s.late_count || 0} {s.late_minutes || '-'} {s.ot_count || 0} {s.ot_minutes || '-'} {s.abnormal_count || 0} {s.missing_count || 0}
)} ) : (
暫無數據
)}
) }