292 lines
12 KiB
React
292 lines
12 KiB
React
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>
|
|
)
|
|
}
|