Initial commit: AARS backend + frontend + holiday/leave phase 1+2
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend, LineChart, Line } from 'recharts'
|
||||
import { AlertTriangle, MapPin, Users, FileText, Calendar, TrendingUp, XCircle, CheckCircle2, Download, Search, ChevronUp, ChevronDown, Filter, X, Activity } from 'lucide-react'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const SEVERITY_COLORS = {
|
||||
'1': { bg: '#D1FAE5', fg: '#065F46', label: '低 Low' },
|
||||
'2': { bg: '#DBEAFE', fg: '#1E40AF', label: '中 Medium' },
|
||||
'3': { bg: '#FEF3C7', fg: '#92400E', label: '高 High' },
|
||||
'4': { bg: '#FED7AA', fg: '#9A3412', label: '嚴重 Severe' },
|
||||
'5': { bg: '#FEE2E2', fg: '#7F1D1D', label: '極嚴重 Critical' },
|
||||
}
|
||||
|
||||
export default function AccidentDashboard() {
|
||||
const [summary, setSummary] = useState(null)
|
||||
const [stats, setStats] = useState(null)
|
||||
const [records, setRecords] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const dashboardRef = useRef(null)
|
||||
|
||||
const fmt = (d) => d.toISOString().slice(0, 10)
|
||||
const weekRange = (offsetWeeks) => {
|
||||
const now = new Date()
|
||||
const dow = now.getDay()
|
||||
const daysFromMonday = (dow + 6) % 7
|
||||
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)]
|
||||
}
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [dateFrom, dateTo])
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = {}
|
||||
if (dateFrom) params.date_from = dateFrom
|
||||
if (dateTo) params.date_to = dateTo
|
||||
const [sumRes, statRes, recRes] = await Promise.all([
|
||||
api.get('/api/accident/dashboard/summary', { params }),
|
||||
api.get('/api/accident/dashboard/stats', { params }),
|
||||
api.get('/api/accident/records', { params: { ...params, per_page: 1000 } }).catch(() => ({ data: { records: [] } })),
|
||||
])
|
||||
setSummary(sumRes.data)
|
||||
setStats(statRes.data)
|
||||
setRecords(recRes.data.records || [])
|
||||
} catch (err) {
|
||||
toast.error('載入失敗')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportExcel = async () => {
|
||||
try {
|
||||
const params = {}
|
||||
if (dateFrom) params.date_from = dateFrom
|
||||
if (dateTo) params.date_to = dateTo
|
||||
const response = await api.get('/api/accident/export/excel', { params, responseType: 'blob' })
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
let suffix = ''
|
||||
if (dateFrom && dateTo) suffix = `_${dateFrom}_${dateTo}`
|
||||
else if (dateFrom) suffix = `_from_${dateFrom}`
|
||||
else if (dateTo) suffix = `_to_${dateTo}`
|
||||
link.setAttribute('download', `accident${suffix}.xlsx`)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
toast.success('Excel 已下載')
|
||||
} catch (err) {
|
||||
toast.error('匯出失敗')
|
||||
}
|
||||
}
|
||||
|
||||
// Build donut chart data (severity distribution)
|
||||
const severityData = useMemo(() => {
|
||||
if (!summary?.severity_count) return []
|
||||
return Object.entries(summary.severity_count)
|
||||
.filter(([_, count]) => count > 0)
|
||||
.map(([sev, count]) => ({
|
||||
name: SEVERITY_COLORS[sev]?.label || `Sev ${sev}`,
|
||||
value: count,
|
||||
severity: sev,
|
||||
color: SEVERITY_COLORS[sev]?.fg || '#6B7280',
|
||||
}))
|
||||
}, [summary])
|
||||
|
||||
// Build line chart (monthly trend)
|
||||
const monthlyTrend = useMemo(() => {
|
||||
const byMonth = {}
|
||||
for (const r of records) {
|
||||
if (!r.date) continue
|
||||
const ym = r.date.substring(0, 7)
|
||||
byMonth[ym] = (byMonth[ym] || 0) + 1
|
||||
}
|
||||
return Object.entries(byMonth)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([month, count]) => ({ month, count }))
|
||||
}, [records])
|
||||
|
||||
if (loading && !summary) {
|
||||
return <div className="p-12 text-center text-slate-500">載入中…</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">
|
||||
<Link
|
||||
to="/accident/list"
|
||||
className="flex items-center gap-2 px-3 py-1.5 border border-slate-300 text-slate-700 rounded-md hover:bg-slate-50 text-sm"
|
||||
>
|
||||
<FileText size={16} />
|
||||
列表
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleExportExcel}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
|
||||
>
|
||||
<Download size={16} />
|
||||
匯出 Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Range 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>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard icon={AlertTriangle} color="bg-red-50 text-red-600" label="總記錄 Total" value={summary.total_records || 0} />
|
||||
<StatCard icon={Calendar} color="bg-blue-50 text-blue-600" label="本月" value={summary.this_month || 0} />
|
||||
<StatCard icon={MapPin} color="bg-purple-50 text-purple-600" label="地點 Locations" value={summary.unique_locations || 0} />
|
||||
<StatCard icon={TrendingUp} color="bg-green-50 text-green-600" label="日均" value={summary.avg_per_day || 0} />
|
||||
{Object.entries(summary.severity_count || {}).map(([sev, count]) => (
|
||||
<StatCard
|
||||
key={sev}
|
||||
icon={Activity}
|
||||
color=""
|
||||
label={`Severity ${sev}`}
|
||||
value={count}
|
||||
overrideColor={SEVERITY_COLORS[sev]?.fg}
|
||||
overrideBg={SEVERITY_COLORS[sev]?.bg}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Severity Distribution */}
|
||||
<div className="card p-4">
|
||||
<h3 className="text-sm font-semibold text-slate-700 mb-3">嚴重程度分佈</h3>
|
||||
{severityData.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 text-center py-12">暫無數據</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={severityData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%" cy="50%"
|
||||
outerRadius={80}
|
||||
label={(entry) => `${entry.name}: ${entry.value}`}
|
||||
>
|
||||
{severityData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Monthly Trend */}
|
||||
<div className="card p-4">
|
||||
<h3 className="text-sm font-semibold text-slate-700 mb-3">月度趨勢</h3>
|
||||
{monthlyTrend.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 text-center py-12">暫無數據</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={monthlyTrend}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="count" stroke="#2563EB" strokeWidth={2} dot={{ r: 4 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leaderboards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<RankCard title="地點 (Top 5)" icon={MapPin} data={stats.by_location?.slice(0, 5) || []} field="count" />
|
||||
<RankCard title="員工 (Top 5)" icon={Users} data={stats.by_employee?.slice(0, 5) || []} field="count" />
|
||||
<RankCard title="部門 (Top 5)" icon={FileText} data={stats.by_department?.slice(0, 5) || []} field="count" />
|
||||
<RankCard title="負責人 (Top 5)" icon={CheckCircle2} data={stats.by_responsible?.slice(0, 5) || []} field="count" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, color, label, value, overrideColor, overrideBg }) {
|
||||
return (
|
||||
<div
|
||||
className="card p-4"
|
||||
style={overrideBg ? { backgroundColor: overrideBg } : undefined}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">{label}</p>
|
||||
<p className="text-2xl font-bold" style={overrideColor ? { color: overrideColor } : undefined}>{value}</p>
|
||||
</div>
|
||||
{Icon && (
|
||||
<div className={`p-2 rounded-lg ${color}`}>
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RankCard({ title, icon: Icon, data, field }) {
|
||||
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} /> {title}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{data.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 text-center py-4">暫無數據</p>
|
||||
) : data.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 truncate max-w-[160px]" title={s.name}>{s.name}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-bold text-slate-700">{s[field]}</span>
|
||||
<span className="text-xs text-slate-400 ml-2">avg {s.avg_severity || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user