647 lines
30 KiB
React
647 lines
30 KiB
React
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>
|
|
)
|
|
}
|