Initial commit: AARS backend + frontend + holiday/leave phase 1+2
This commit is contained in:
@@ -0,0 +1 @@
|
||||
logs/
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js"></script>
|
||||
<title>AARS - Attendance & Accident Record System</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3380
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "aars-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"logs:tail": "tail -f /root/aars/logs/app-*.jsonl | sed -u 's/\\\\n/\\\\n/g' | jq -c . 2>/dev/null || tail -f /root/aars/logs/app-*.jsonl",
|
||||
"logs:errors": "tail -f /root/aars/logs/error-*.jsonl | jq -c . 2>/dev/null || tail -f /root/aars/logs/error-*.jsonl",
|
||||
"logs:access": "tail -f /root/aars/logs/access-*.jsonl | jq -c . 2>/dev/null || tail -f /root/aars/logs/access-*.jsonl",
|
||||
"logs:search": "grep -hE -- \"$@\" /root/aars/logs/app-*.jsonl /root/aars/logs/error-*.jsonl /root/aars/logs/access-*.jsonl | jq -c . 2>/dev/null || true",
|
||||
"logs:clean": "bash /root/aars/scripts/logs-cleanup.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"axios": "^1.7.7",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"recharts": "^2.12.7",
|
||||
"lucide-react": "^0.441.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.8",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.45",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"vite": "^5.4.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from './context/AuthContext'
|
||||
import Layout from './components/Layout'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import AttendanceList from './pages/attendance/List'
|
||||
import AttendanceDetail from './pages/attendance/Detail'
|
||||
import AccidentDashboard from './pages/accident/Dashboard'
|
||||
import AccidentList from './pages/accident/List'
|
||||
import AccidentDetail from './pages/accident/Detail'
|
||||
import UploadPage from './pages/upload/Upload'
|
||||
import RosterPage from './pages/roster/Roster'
|
||||
import HolidaysPage from './pages/holidays/Holidays'
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-pulse text-slate-500">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="attendance" element={<AttendanceList />} />
|
||||
<Route path="attendance/:id" element={<AttendanceDetail />} />
|
||||
<Route path="accident" element={<AccidentDashboard />} />
|
||||
<Route path="accident/list" element={<AccidentList />} />
|
||||
<Route path="accident/:id" element={<AccidentDetail />} />
|
||||
<Route path="upload" element={<UploadPage />} />
|
||||
<Route path="roster" element={<RosterPage />} />
|
||||
<Route path="holidays" element={<HolidaysPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import axios from 'axios'
|
||||
import { setCurrentRequestId } from './lib/logger.js'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// Generate a request id per outgoing call (for cross-correlation)
|
||||
function newRequestId() {
|
||||
// RFC4122-ish: use crypto if available
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return 'req-' + Math.random().toString(36).slice(2) + Date.now().toString(36)
|
||||
}
|
||||
|
||||
// Request interceptor: auth + X-Request-ID
|
||||
api.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('aars_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
config.headers['X-Request-ID'] = newRequestId()
|
||||
return config
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
)
|
||||
|
||||
// Response interceptor: capture server's X-Request-ID for log correlation + 401 handling
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
const rid = response.headers['x-request-id']
|
||||
if (rid) setCurrentRequestId(rid)
|
||||
return response
|
||||
},
|
||||
error => {
|
||||
const rid = error.response?.headers?.['x-request-id']
|
||||
if (rid) setCurrentRequestId(rid)
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('aars_token')
|
||||
// Avoid loop if already on /login
|
||||
if (!window.location.pathname.startsWith('/login')) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react'
|
||||
import { logger } from '../lib/logger.js'
|
||||
|
||||
export default class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
logger.error('React component error', {
|
||||
stack: (error?.stack || '') + '\n\nComponent stack:\n' + (errorInfo?.componentStack || ''),
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2rem',
|
||||
fontFamily: 'sans-serif',
|
||||
background: '#f8fafc',
|
||||
color: '#0f172a',
|
||||
}}>
|
||||
<div style={{ maxWidth: 600, textAlign: 'center' }}>
|
||||
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>
|
||||
⚠️ 頁面發生錯誤 / Something went wrong
|
||||
</h1>
|
||||
<p style={{ color: '#64748b', marginBottom: '1.5rem' }}>
|
||||
已經將錯誤自動回報俾 IT狗。請 reload 或者返回上一頁。
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#2563eb',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '0.375rem',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
{this.state.error?.message && (
|
||||
<pre style={{
|
||||
marginTop: '1.5rem',
|
||||
padding: '1rem',
|
||||
background: '#f1f5f9',
|
||||
borderRadius: '0.375rem',
|
||||
fontSize: '0.75rem',
|
||||
textAlign: 'left',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}>
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { LogOut, Users, AlertTriangle, LayoutDashboard, Upload, Calendar, Menu, X, Palmtree } from 'lucide-react'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', end: true, icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/attendance', icon: Users, label: '出勤 Attendance' },
|
||||
{ to: '/accident', icon: AlertTriangle, label: '意外 Accident' },
|
||||
{ to: '/roster', icon: Calendar, label: '班次 Roster' },
|
||||
{ to: '/holidays', icon: Palmtree, label: '假期 Holidays' },
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const closeMobile = () => setMobileOpen(false)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-slate-200 sticky top-0 z-40">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-14 sm:h-16">
|
||||
{/* Mobile hamburger (left side) */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="md:hidden p-2 -ml-2 text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-md"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">A</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-slate-900">AARS</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{NAV_ITEMS.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon size={18} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
<NavLink
|
||||
to="/upload"
|
||||
className={({ isActive }) =>
|
||||
`hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Upload size={16} />
|
||||
<span className="hidden sm:inline">上傳</span>
|
||||
</NavLink>
|
||||
|
||||
<div className="hidden sm:flex items-center gap-3 pl-4 border-l border-slate-200">
|
||||
<div className="text-right hidden md:block">
|
||||
<div className="text-sm font-medium text-slate-900">{user?.name}</div>
|
||||
<div className="text-xs text-slate-500">{user?.role}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-md transition-colors"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile logout button (always visible) */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="sm:hidden p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-md transition-colors"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile drawer overlay */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 md:hidden"
|
||||
onClick={closeMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile drawer panel */}
|
||||
<div
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-xl transform transition-transform duration-200 ease-in-out md:hidden ${
|
||||
mobileOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 h-14 border-b border-slate-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">A</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-slate-900">AARS</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={closeMobile}
|
||||
className="p-2 text-slate-500 hover:text-slate-900 hover:bg-slate-100 rounded-md"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="p-3 space-y-1">
|
||||
{NAV_ITEMS.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
onClick={closeMobile}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-3 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon size={18} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
<NavLink
|
||||
to="/upload"
|
||||
onClick={closeMobile}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-3 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Upload size={18} />
|
||||
上傳
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-slate-900">{user?.name}</div>
|
||||
<div className="text-xs text-slate-500">{user?.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6 lg:py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { CheckCircle, AlertCircle, Clock, X, AlertTriangle, Briefcase, Stethoscope, Timer, PartyPopper } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Reusable status badge for attendance records.
|
||||
* Handles all status codes including enriched leave types:
|
||||
* normal / late / late_early / late_ot / early / early_ot / ot / abnormal / missing
|
||||
* holiday / al / sl / cl / mixed_leave
|
||||
*
|
||||
* Props:
|
||||
* - code: status_code string
|
||||
* - text: status_text string (already enriched)
|
||||
* - size: 'sm' | 'md' (default 'sm')
|
||||
*/
|
||||
export default function StatusBadge({ code, text = '', size = 'sm' }) {
|
||||
const sizeCls = size === 'md'
|
||||
? 'px-3 py-1 text-sm'
|
||||
: 'px-2 py-0.5 text-xs'
|
||||
|
||||
// Split text into main + leave parts (after ' + ' separator)
|
||||
const parts = text.split(/\s*\+\s*/).filter(Boolean)
|
||||
const mainText = parts[0] || ''
|
||||
const leaveParts = parts.slice(1)
|
||||
|
||||
const badge = (icon, label, colorCls) => (
|
||||
<span className={`inline-flex items-center gap-1 ${sizeCls} rounded-full font-medium ${colorCls}`}>
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
|
||||
// ============ Attendance status badges ============
|
||||
if (code === 'normal') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{badge(<CheckCircle size={12} />, mainText || '正常', 'bg-green-100 text-green-700')}
|
||||
{leaveParts.map((lp, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
||||
+ {lp}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (code === 'late' || code === 'late_early' || code === 'late_ot') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{badge(<AlertCircle size={12} />, mainText || code, 'bg-orange-100 text-orange-700')}
|
||||
{leaveParts.map((lp, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
||||
+ {lp}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (code === 'early' || code === 'early_ot') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{badge(<Clock size={12} />, mainText || code, 'bg-blue-100 text-blue-700')}
|
||||
{leaveParts.map((lp, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
||||
+ {lp}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (code === 'ot') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{badge(<Clock size={12} />, mainText || '加班', 'bg-purple-100 text-purple-700')}
|
||||
{leaveParts.map((lp, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
||||
+ {lp}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (code === 'abnormal') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{badge(<AlertTriangle size={12} />, mainText || '⚠️異常', 'bg-red-100 text-red-700')}
|
||||
{leaveParts.map((lp, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
||||
+ {lp}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (code === 'missing') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{badge(<X size={12} />, '缺勤', 'bg-gray-200 text-gray-600')}
|
||||
{leaveParts.map((lp, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
||||
{lp}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Enriched leave status badges ============
|
||||
if (code === 'holiday') {
|
||||
return badge(<PartyPopper size={12} />, mainText, 'bg-amber-100 text-amber-700')
|
||||
}
|
||||
if (code === 'al') {
|
||||
return badge(<Briefcase size={12} />, mainText || '年假', 'bg-emerald-100 text-emerald-700')
|
||||
}
|
||||
if (code === 'sl') {
|
||||
return badge(<Stethoscope size={12} />, mainText || '病假', 'bg-pink-100 text-pink-700')
|
||||
}
|
||||
if (code === 'cl') {
|
||||
return badge(<Timer size={12} />, mainText || '補鐘', 'bg-indigo-100 text-indigo-700')
|
||||
}
|
||||
if (code === 'mixed_leave') {
|
||||
return badge(<Timer size={12} />, mainText || '混合假', 'bg-fuchsia-100 text-fuchsia-700')
|
||||
}
|
||||
|
||||
// fallback
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs text-slate-500 bg-slate-100`}>{text || code || '-'}</span>
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
import api from '../api'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('aars_token')
|
||||
if (token) {
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
||||
fetchUser()
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/auth/me')
|
||||
setUser(data)
|
||||
} catch (err) {
|
||||
localStorage.removeItem('aars_token')
|
||||
delete api.defaults.headers.common['Authorization']
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (email, password) => {
|
||||
const { data } = await api.post('/api/auth/login', { email, password })
|
||||
localStorage.setItem('aars_token', data.access_token)
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${data.access_token}`
|
||||
await fetchUser()
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('aars_token')
|
||||
delete api.defaults.headers.common['Authorization']
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext)
|
||||
@@ -0,0 +1,39 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: 'Inter', 'Noto Sans TC', system-ui, sans-serif;
|
||||
background-color: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white px-4 py-2 rounded-md font-medium
|
||||
hover:bg-primary-700 transition-colors duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-white text-slate-700 px-4 py-2 rounded-md font-medium
|
||||
border border-slate-300 hover:bg-slate-50 transition-colors duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white px-4 py-2 rounded-md font-medium
|
||||
hover:bg-red-700 transition-colors duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply w-full px-3 py-2 border border-slate-300 rounded-md
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500
|
||||
placeholder:text-slate-400;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg border border-slate-200 shadow-sm;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Frontend logger for AARS.
|
||||
*
|
||||
* - Captures console.error, unhandledrejection, ErrorBoundary errors
|
||||
* - Redacts secrets client-side before sending
|
||||
* - Buffers, flushes via navigator.sendBeacon on pagehide
|
||||
* - Posts to /api/client-logs (server-side correlation via X-Request-ID)
|
||||
*/
|
||||
|
||||
const ENDPOINT = '/api/client-logs'
|
||||
const APP_VERSION = (typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '1.0.0')
|
||||
const FLUSH_INTERVAL_MS = 10_000
|
||||
const MAX_BUFFER = 50
|
||||
|
||||
// ============ Redactor ============
|
||||
const REDACT_KEYS = new Set([
|
||||
'password', 'passwd', 'pwd', 'pass',
|
||||
'token', 'access_token', 'refresh_token', 'id_token',
|
||||
'jwt', 'bearer', 'authorization',
|
||||
'api_key', 'apikey', 'secret', 'client_secret',
|
||||
'cookie', 'set-cookie', 'session',
|
||||
])
|
||||
|
||||
function redactString(s) {
|
||||
if (typeof s !== 'string') return s
|
||||
// JWT-like
|
||||
s = s.replace(/eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+/g, '[REDACTED:JWT]')
|
||||
// Bearer tokens
|
||||
s = s.replace(/(bearer\s+)[A-Za-z0-9\-_.]+/gi, '$1[REDACTED]')
|
||||
return s
|
||||
}
|
||||
|
||||
function redact(obj) {
|
||||
if (obj == null) return obj
|
||||
if (typeof obj === 'string') return redactString(obj)
|
||||
if (Array.isArray(obj)) return obj.map(redact)
|
||||
if (typeof obj === 'object') {
|
||||
const out = {}
|
||||
for (const k of Object.keys(obj)) {
|
||||
if (REDACT_KEYS.has(k.toLowerCase())) {
|
||||
out[k] = '[REDACTED]'
|
||||
} else {
|
||||
out[k] = redact(obj[k])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
// ============ Buffer ============
|
||||
const buffer = []
|
||||
let flushTimer = null
|
||||
|
||||
function push(level, msg, extras = {}) {
|
||||
const entry = {
|
||||
level,
|
||||
msg: redactString(String(msg).slice(0, 2000)),
|
||||
stack: extras.stack ? redactString(String(extras.stack).slice(0, 8000)) : undefined,
|
||||
page_url: typeof window !== 'undefined' ? window.location.href : undefined,
|
||||
app_version: APP_VERSION,
|
||||
request_id: getCurrentRequestId(),
|
||||
browser: typeof navigator !== 'undefined' ? navigator.userAgent.slice(0, 500) : undefined,
|
||||
ts: new Date().toISOString(),
|
||||
}
|
||||
// Drop undefined fields
|
||||
Object.keys(entry).forEach(k => entry[k] === undefined && delete entry[k])
|
||||
buffer.push(entry)
|
||||
if (buffer.length >= MAX_BUFFER) flush()
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (!buffer.length) return
|
||||
const entries = buffer.splice(0, buffer.length)
|
||||
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
||||
try {
|
||||
const blob = new Blob([JSON.stringify(entries)], { type: 'application/json' })
|
||||
navigator.sendBeacon(ENDPOINT, blob)
|
||||
return
|
||||
} catch (_) { /* fall through */ }
|
||||
}
|
||||
// Fallback: fetch (best-effort)
|
||||
if (typeof fetch !== 'undefined') {
|
||||
fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(entries),
|
||||
keepalive: true,
|
||||
}).catch(() => { /* swallow */ })
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Current request_id ============
|
||||
let currentRequestId = null
|
||||
export function setCurrentRequestId(rid) { currentRequestId = rid }
|
||||
function getCurrentRequestId() { return currentRequestId }
|
||||
|
||||
// ============ Public API ============
|
||||
export const logger = {
|
||||
debug: (msg, ex) => push('debug', msg, ex),
|
||||
info: (msg, ex) => push('info', msg, ex),
|
||||
warn: (msg, ex) => push('warn', msg, ex),
|
||||
error: (msg, ex) => push('error', msg, ex),
|
||||
flush,
|
||||
}
|
||||
|
||||
// ============ Global error hooks ============
|
||||
export function installGlobalHandlers() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
// Capture uncaught errors
|
||||
window.addEventListener('error', (event) => {
|
||||
logger.error('Uncaught error', {
|
||||
stack: event.error?.stack || `${event.message} at ${event.filename}:${event.lineno}:${event.colno}`,
|
||||
})
|
||||
})
|
||||
|
||||
// Capture unhandled promise rejections
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const reason = event.reason
|
||||
const msg = reason?.message || String(reason)
|
||||
const stack = reason?.stack || `Unhandled rejection: ${msg}`
|
||||
logger.error('Unhandled promise rejection', { stack })
|
||||
})
|
||||
|
||||
// Flush on page hide
|
||||
window.addEventListener('pagehide', () => flush())
|
||||
window.addEventListener('beforeunload', () => flush())
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') flush()
|
||||
})
|
||||
|
||||
// Periodic flush
|
||||
if (flushTimer) clearInterval(flushTimer)
|
||||
flushTimer = setInterval(flush, FLUSH_INTERVAL_MS)
|
||||
}
|
||||
|
||||
export { redact }
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import { AuthProvider } from './context/AuthContext.jsx'
|
||||
import ErrorBoundary from './components/ErrorBoundary.jsx'
|
||||
import { installGlobalHandlers } from './lib/logger.js'
|
||||
|
||||
// Install global error handlers before mounting
|
||||
installGlobalHandlers()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
<Toaster position="bottom-right" />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await login(email, password)
|
||||
toast.success('登入成功!')
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '登入失敗')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-primary-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-white font-bold text-2xl">A</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">AARS</h1>
|
||||
<p className="text-slate-500 mt-1">Attendance & Accident Record System</p>
|
||||
</div>
|
||||
|
||||
{/* Login form */}
|
||||
<div className="card p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
電郵 Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input-field"
|
||||
placeholder="admin@aars.hk"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
密碼 Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input-field"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full py-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? '登入中...' : '登入'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-slate-500">
|
||||
<p>預設帳戶:admin@aars.hk / admin123</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ArrowLeft, FileText, AlertTriangle } from 'lucide-react'
|
||||
|
||||
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' },
|
||||
}
|
||||
|
||||
const FIELDS = [
|
||||
{ key: 'date', label: '日期 Date' },
|
||||
{ key: 'time', label: '時間 Time' },
|
||||
{ key: 'severity', label: '嚴重程度 Severity', badge: true },
|
||||
{ key: 'location', label: '地點 Location' },
|
||||
{ key: 'employee_name', label: '員工/報告人 Employee' },
|
||||
{ key: 'department', label: '部門 Department' },
|
||||
{ key: 'description', label: '事件描述 Description' },
|
||||
{ key: 'medical_report', label: '醫療報告 Medical Report' },
|
||||
{ key: 'action_taken', label: '處理情況 Action Taken' },
|
||||
{ key: 'responsible_person', label: '負責人 Responsible Person' },
|
||||
{ key: 'created_at', label: '建立時間 Created At' },
|
||||
]
|
||||
|
||||
export default function AccidentDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [record, setRecord] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
const numericId = parseInt(id, 10)
|
||||
if (isNaN(numericId) || numericId <= 0) {
|
||||
navigate('/accident/list', { replace: true })
|
||||
return
|
||||
}
|
||||
fetchRecord(numericId)
|
||||
}, [id])
|
||||
|
||||
const fetchRecord = async (recordId) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { data } = await api.get(`/api/accident/${recordId}`)
|
||||
setRecord(data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching record:', err)
|
||||
const detail = err.response?.data?.detail
|
||||
// FastAPI 422 returns detail as array of validation errors — stringify safely
|
||||
let msg = 'Failed to load record'
|
||||
if (typeof detail === 'string') {
|
||||
msg = detail
|
||||
} else if (Array.isArray(detail)) {
|
||||
msg = `Invalid request: ${detail.map(d => d.msg || JSON.stringify(d)).join('; ')}`
|
||||
} else if (detail && typeof detail === 'object') {
|
||||
msg = JSON.stringify(detail)
|
||||
} else if (err.response?.status === 404) {
|
||||
msg = 'Record not found'
|
||||
}
|
||||
setError(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-slate-500">載入中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/accident/list"
|
||||
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
返回列表
|
||||
</Link>
|
||||
</div>
|
||||
<div className="card p-8 text-center">
|
||||
<AlertTriangle size={48} className="mx-auto text-red-400 mb-2" />
|
||||
<div className="text-red-500 mb-2">{error}</div>
|
||||
<button
|
||||
onClick={() => navigate('/accident/list')}
|
||||
className="mt-4 px-4 py-2 text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
返回列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sev = String(record.severity || '')
|
||||
const sevColor = SEVERITY_COLORS[sev] || { bg: '#F3F4F6', fg: '#374151', label: sev || '-' }
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4 sm:mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/accident/list"
|
||||
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
返回
|
||||
</Link>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-slate-900">
|
||||
意外記錄 #{id}
|
||||
</h1>
|
||||
</div>
|
||||
<span
|
||||
className="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold"
|
||||
style={{ backgroundColor: sevColor.bg, color: sevColor.fg }}
|
||||
>
|
||||
Severity {sev} · {sevColor.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-200 bg-slate-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText size={24} className="text-slate-400" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">記錄詳情</div>
|
||||
<div className="text-sm text-slate-500">AARS 意外事故記錄</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{FIELDS.map(({ key, label, badge }) => {
|
||||
const val = record[key]
|
||||
const display = val !== null && val !== undefined && val !== '' ? String(val) : '-'
|
||||
return (
|
||||
<div key={key} className="grid grid-cols-1 sm:grid-cols-12">
|
||||
<div className="sm:col-span-3 px-4 py-3 text-sm font-medium text-slate-500 bg-slate-50">
|
||||
{label}
|
||||
</div>
|
||||
<div className="sm:col-span-9 px-4 py-3 text-sm text-slate-900 break-words">
|
||||
{badge && sev !== '' ? (
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold"
|
||||
style={{ backgroundColor: sevColor.bg, color: sevColor.fg }}
|
||||
>
|
||||
{sev} · {sevColor.label}
|
||||
</span>
|
||||
) : (
|
||||
display
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Search, Eye, ChevronUp, ChevronDown, Filter, X, Settings2, RotateCcw } from 'lucide-react'
|
||||
|
||||
export default function AccidentList() {
|
||||
const navigate = useNavigate()
|
||||
const [records, setRecords] = useState([])
|
||||
const [excelHeaders, setExcelHeaders] = useState({}) // sql_col -> Excel chinese header
|
||||
const [columnOrder, setColumnOrder] = useState([]) // ordered SQL cols (Excel order)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortKey, setSortKey] = useState('date')
|
||||
const [sortDir, setSortDir] = useState('desc')
|
||||
const [filterKey, setFilterKey] = useState('')
|
||||
const [filterValue, setFilterValue] = useState('')
|
||||
|
||||
// Column visibility — localStorage persisted
|
||||
const STORAGE_KEY = 'aars.accident.visibleColumns.v1'
|
||||
const [visibleCols, setVisibleCols] = useState(() => {
|
||||
try {
|
||||
const s = localStorage.getItem(STORAGE_KEY)
|
||||
if (s) return JSON.parse(s)
|
||||
} catch {}
|
||||
return null // null = show all
|
||||
})
|
||||
const [showColumnSettings, setShowColumnSettings] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecords()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleCols !== null) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(visibleCols))
|
||||
}
|
||||
}, [visibleCols])
|
||||
|
||||
const fetchRecords = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await api.get('/api/accident/records?per_page=1000')
|
||||
setRecords(data.records || [])
|
||||
const eh = data.excel_headers || {}
|
||||
const co = data.column_order || []
|
||||
setExcelHeaders(eh)
|
||||
setColumnOrder(co)
|
||||
// Default sort
|
||||
if (data.records && data.records.length > 0 && !sortKey) {
|
||||
setSortKey('date')
|
||||
setSortDir('desc')
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Failed to load records')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// The set of columns to display: filtered by visibleCols (if not null)
|
||||
const displayedCols = useMemo(() => {
|
||||
let cols = columnOrder.filter(c => c !== 'id')
|
||||
if (visibleCols !== null) {
|
||||
cols = cols.filter(c => visibleCols[c])
|
||||
}
|
||||
return cols
|
||||
}, [columnOrder, visibleCols])
|
||||
|
||||
// Helper: get display name for a SQL column (uses Excel header, trims trailing spaces)
|
||||
const displayName = (col) => {
|
||||
const h = excelHeaders[col]
|
||||
return (h ? String(h).trim() : col) || col
|
||||
}
|
||||
|
||||
// Filtered and sorted data
|
||||
const processedData = useMemo(() => {
|
||||
let result = [...records]
|
||||
if (filterKey && filterValue) {
|
||||
result = result.filter(r => {
|
||||
const val = String(r[filterKey] || '').toLowerCase()
|
||||
return val.includes(filterValue.toLowerCase())
|
||||
})
|
||||
}
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase()
|
||||
result = result.filter(r =>
|
||||
Object.values(r).some(v =>
|
||||
v && String(v).toLowerCase().includes(searchLower)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (sortKey) {
|
||||
result.sort((a, b) => {
|
||||
const aVal = a[sortKey] || ''
|
||||
const bVal = b[sortKey] || ''
|
||||
const aNum = Number(aVal)
|
||||
const bNum = Number(bVal)
|
||||
let cmp = 0
|
||||
if (!isNaN(aNum) && !isNaN(bNum) && aVal !== '' && bVal !== '') {
|
||||
cmp = aNum - bNum
|
||||
} else {
|
||||
cmp = String(aVal).localeCompare(String(bVal))
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}
|
||||
return result
|
||||
}, [records, search, sortKey, sortDir, filterKey, filterValue])
|
||||
|
||||
const handleSort = (key) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilter = (key) => {
|
||||
setFilterKey(key)
|
||||
setFilterValue('')
|
||||
}
|
||||
|
||||
const clearFilter = () => {
|
||||
setFilterKey('')
|
||||
setFilterValue('')
|
||||
}
|
||||
|
||||
const getSortIcon = (key) => {
|
||||
if (sortKey !== key) return null
|
||||
return sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||
}
|
||||
|
||||
const toggleColumn = (col) => {
|
||||
const next = visibleCols ? { ...visibleCols } : {}
|
||||
// Initialize all columns to true first
|
||||
columnOrder.forEach(c => { if (next[c] === undefined) next[c] = true })
|
||||
next[col] = !next[col]
|
||||
setVisibleCols(next)
|
||||
}
|
||||
|
||||
const resetColumns = () => {
|
||||
setVisibleCols(null)
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg sm:text-xl font-bold text-slate-900">意外記錄 Accident</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowColumnSettings(!showColumnSettings)}
|
||||
className={`flex items-center gap-2 px-3 py-2 text-sm rounded-md border ${
|
||||
showColumnSettings
|
||||
? 'bg-slate-100 border-slate-400 text-slate-800'
|
||||
: 'border-slate-300 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Settings2 size={16} />
|
||||
欄位 ({displayedCols.length}/{columnOrder.filter(c => c !== 'id').length})
|
||||
</button>
|
||||
<Link
|
||||
to="/upload"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700"
|
||||
>
|
||||
上傳 Excel
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column Settings Panel */}
|
||||
{showColumnSettings && (
|
||||
<div className="card p-4 border-l-4 border-primary-500">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-sm font-semibold text-slate-700">
|
||||
選擇顯示嘅欄位 (來源: Excel)
|
||||
</div>
|
||||
<button
|
||||
onClick={resetColumns}
|
||||
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-800"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
重設全部顯示
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{columnOrder.filter(c => c !== 'id').map(col => {
|
||||
const isVisible = visibleCols === null || (visibleCols[col] !== false)
|
||||
return (
|
||||
<label key={col} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-slate-50 px-2 py-1 rounded">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isVisible}
|
||||
onChange={() => toggleColumn(col)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-slate-700">{displayName(col)}</span>
|
||||
<span className="text-slate-400 text-[10px]">({col})</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card p-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜尋所有欄位..."
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={16} className="text-slate-400" />
|
||||
<select
|
||||
value={filterKey}
|
||||
onChange={(e) => handleFilter(e.target.value)}
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-2"
|
||||
>
|
||||
<option value="">篩選欄位</option>
|
||||
{displayedCols.map(h => (
|
||||
<option key={h} value={h}>{displayName(h)}</option>
|
||||
))}
|
||||
</select>
|
||||
{filterKey && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={filterValue}
|
||||
onChange={(e) => setFilterValue(e.target.value)}
|
||||
placeholder="輸入篩選值..."
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-2 w-32"
|
||||
/>
|
||||
<button onClick={clearFilter} className="p-1 text-slate-400 hover:text-slate-600">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 py-2">
|
||||
{processedData.length} 筆記錄
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-100 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-600 w-12">#</th>
|
||||
{displayedCols.map(h => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-3 py-2 text-left text-xs font-semibold text-slate-600 cursor-pointer hover:bg-slate-200 whitespace-nowrap"
|
||||
onClick={() => handleSort(h)}
|
||||
title={excelHeaders[h] || h}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{displayName(h)}
|
||||
{getSortIcon(h)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600 w-16">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={displayedCols.length + 2} className="px-3 py-6 text-center text-slate-400">
|
||||
載入中...
|
||||
</td>
|
||||
</tr>
|
||||
) : processedData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={displayedCols.length + 2} className="px-3 py-6 text-center text-slate-400">
|
||||
沒有記錄
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
processedData.map((record) => (
|
||||
<tr
|
||||
key={record.id}
|
||||
className="hover:bg-slate-50 cursor-pointer"
|
||||
onClick={() => navigate(`/accident/${record.id}`)}
|
||||
>
|
||||
<td className="px-3 py-2 text-xs text-slate-400">{record.id}</td>
|
||||
{displayedCols.map(h => (
|
||||
<td key={h} className="px-3 py-2 text-slate-700 max-w-xs truncate" title={record[h]}>
|
||||
{record[h] !== null && record[h] !== undefined && record[h] !== '' ? String(record[h]) : '-'}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<Link
|
||||
to={`/accident/${record.id}`}
|
||||
className="p-1 text-slate-400 hover:text-primary-600 inline-block"
|
||||
>
|
||||
<Eye size={16} />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ArrowLeft, Save, X } from 'lucide-react'
|
||||
|
||||
export default function AttendanceDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [record, setRecord] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [formData, setFormData] = useState({})
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecord()
|
||||
}, [id])
|
||||
|
||||
const fetchRecord = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Fetch all records and find by id
|
||||
const { data } = await api.get('/api/attendance/records?per_page=1000')
|
||||
const found = (data.records || []).find(r => r.id === parseInt(id))
|
||||
if (found) {
|
||||
setRecord(found)
|
||||
setFormData({
|
||||
employee_name: found.employee_name || '',
|
||||
company: found.company || '',
|
||||
date: found.date || '',
|
||||
weekday: found.weekday || '',
|
||||
shift_code: found.shift_code || '',
|
||||
status_code: found.status_code || '',
|
||||
status_text: found.status_text || '',
|
||||
late_minutes: found.late_minutes || 0,
|
||||
early_minutes: found.early_minutes || 0,
|
||||
ot_minutes: found.ot_minutes || 0,
|
||||
expected_in: found.expected_in || '',
|
||||
expected_out: found.expected_out || '',
|
||||
actual_in: found.actual_in || '',
|
||||
actual_out: found.actual_out || '',
|
||||
is_manually_edited: found.is_manually_edited || false,
|
||||
})
|
||||
} else {
|
||||
setError('找不到記錄')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error:', err)
|
||||
setError('載入失敗')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.put(`/api/attendance/${id}`, formData)
|
||||
toast.success('保存成功')
|
||||
setEditMode(false)
|
||||
fetchRecord()
|
||||
} catch (err) {
|
||||
toast.error('保存失敗')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditMode(false)
|
||||
if (record) {
|
||||
setFormData({
|
||||
employee_name: record.employee_name || '',
|
||||
company: record.company || '',
|
||||
date: record.date || '',
|
||||
weekday: record.weekday || '',
|
||||
shift_code: record.shift_code || '',
|
||||
status_code: record.status_code || '',
|
||||
status_text: record.status_text || '',
|
||||
late_minutes: record.late_minutes || 0,
|
||||
early_minutes: record.early_minutes || 0,
|
||||
ot_minutes: record.ot_minutes || 0,
|
||||
expected_in: record.expected_in || '',
|
||||
expected_out: record.expected_out || '',
|
||||
actual_in: record.actual_in || '',
|
||||
actual_out: record.actual_out || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (code, text) => {
|
||||
const badges = {
|
||||
normal: 'bg-green-100 text-green-700',
|
||||
late: 'bg-orange-100 text-orange-700',
|
||||
early: 'bg-blue-100 text-blue-700',
|
||||
ot: 'bg-purple-100 text-purple-700',
|
||||
abnormal: 'bg-red-100 text-red-700',
|
||||
missing: 'bg-gray-200 text-gray-600',
|
||||
late_early: 'bg-orange-100 text-orange-700',
|
||||
late_ot: 'bg-orange-100 text-orange-700',
|
||||
early_ot: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
return (
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${badges[code] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{text || code}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const displayFields = [
|
||||
{ key: 'employee_name', label: '員工姓名' },
|
||||
{ key: 'company', label: '公司' },
|
||||
{ key: 'date', label: '日期' },
|
||||
{ key: 'weekday', label: '星期' },
|
||||
{ key: 'shift_code', label: '班次' },
|
||||
{ key: 'expected_in', label: '應上班時間' },
|
||||
{ key: 'expected_out', label: '應下班時間' },
|
||||
{ key: 'actual_in', label: '實際上班' },
|
||||
{ key: 'actual_out', label: '實際下班' },
|
||||
{ key: 'late_minutes', label: '遲到分鐘' },
|
||||
{ key: 'early_minutes', label: '早退分鐘' },
|
||||
{ key: 'ot_minutes', label: 'OT分鐘' },
|
||||
{ key: 'status_code', label: '狀態代碼' },
|
||||
{ key: 'status_text', label: '狀態' },
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-slate-500">載入中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link to="/attendance" className="flex items-center gap-2 text-slate-600 hover:text-slate-900">
|
||||
<ArrowLeft size={20} /> 返回列表
|
||||
</Link>
|
||||
<div className="card p-8 text-center text-red-500">{error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/attendance" className="flex items-center gap-2 text-slate-600 hover:text-slate-900">
|
||||
<ArrowLeft size={20} /> 返回
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold text-slate-900">
|
||||
出勤記錄 - {record?.employee_name}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{record && getStatusBadge(record.status_code, record.status_text)}
|
||||
{!editMode ? (
|
||||
<button
|
||||
onClick={() => setEditMode(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
||||
>
|
||||
編輯
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-slate-300 text-slate-700 text-sm rounded-md hover:bg-slate-50"
|
||||
>
|
||||
<X size={16} /> 取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white text-sm rounded-md hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
<Save size={16} /> {saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manual edit warning */}
|
||||
{record?.is_manually_edited && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg px-4 py-3 text-sm text-yellow-800">
|
||||
⚠️ 此記錄曾被手動修改,之後重新導入 Excel 不會覆蓋此記錄
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Record Details */}
|
||||
<div className="card">
|
||||
<div className="p-4 border-b border-slate-200 bg-slate-50">
|
||||
<h2 className="font-semibold text-slate-700">記錄詳情</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{displayFields.map(({ key, label }) => (
|
||||
<div key={key} className="grid grid-cols-12 items-center">
|
||||
<div className="col-span-3 px-4 py-3 text-sm font-medium text-slate-500 bg-slate-50">
|
||||
{label}
|
||||
</div>
|
||||
<div className="col-span-9 px-4 py-3">
|
||||
{editMode && ['status_code', 'late_minutes', 'early_minutes', 'ot_minutes', 'actual_in', 'actual_out', 'shift_code'].includes(key) ? (
|
||||
<input
|
||||
type="text"
|
||||
value={formData[key]}
|
||||
onChange={(e) => setFormData({ ...formData, [key]: e.target.value })}
|
||||
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-slate-900">
|
||||
{record?.[key] !== null && record?.[key] !== undefined ? String(record[key]) : '-'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Search, ChevronUp, ChevronDown, Filter, X, Users, Calendar } from 'lucide-react'
|
||||
import StatusBadge from '../../components/StatusBadge'
|
||||
|
||||
export default function AttendanceList() {
|
||||
const [records, setRecords] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortBy, setSortBy] = useState('date')
|
||||
const [sortOrder, setSortOrder] = useState('desc')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [staffFilter, setStaffFilter] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const perPage = 50
|
||||
|
||||
const [staffList, setStaffList] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecords()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, sortBy, sortOrder, dateFrom, dateTo, staffFilter, statusFilter, search])
|
||||
|
||||
const fetchRecords = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page,
|
||||
per_page: perPage,
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder,
|
||||
})
|
||||
if (search) params.append('search', search)
|
||||
if (dateFrom) params.append('date_from', dateFrom)
|
||||
if (dateTo) params.append('date_to', dateTo)
|
||||
if (staffFilter) params.append('staff', staffFilter)
|
||||
if (statusFilter) params.append('status', statusFilter)
|
||||
|
||||
const { data } = await api.get(`/api/attendance/records?${params}`)
|
||||
setRecords(data.records || [])
|
||||
setTotal(data.total || 0)
|
||||
|
||||
// Extract unique staff names
|
||||
const names = [...new Set((data.records || []).map(r => r.employee_name).filter(Boolean))]
|
||||
setStaffList(names.sort())
|
||||
} catch (err) {
|
||||
toast.error('載入失敗')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault()
|
||||
setPage(1)
|
||||
fetchRecords()
|
||||
}
|
||||
|
||||
const handleSort = (key) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortBy(key)
|
||||
setSortOrder('asc')
|
||||
}
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const getSortIcon = (key) => {
|
||||
if (sortBy !== key) return null
|
||||
return sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||
}
|
||||
|
||||
const getStatusBadge = (record) => {
|
||||
return <StatusBadge code={record.status_code} text={record.status_text} />
|
||||
}
|
||||
|
||||
const getRowClass = (code) => {
|
||||
if (code === 'abnormal') return 'bg-red-50 hover:bg-red-100'
|
||||
if (code === 'missing') return 'bg-gray-100 hover:bg-gray-200'
|
||||
if (code === 'late' || code === 'late_early' || code === 'late_ot') return 'bg-orange-50 hover:bg-orange-100'
|
||||
if (code === 'early' || code === 'early_ot') return 'bg-blue-50 hover:bg-blue-100'
|
||||
if (code === 'ot') return 'bg-purple-50 hover:bg-purple-100'
|
||||
return 'hover:bg-slate-50'
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / perPage)
|
||||
|
||||
const columns = [
|
||||
{ key: 'employee_name', label: '員工' },
|
||||
{ key: 'date', label: '日期' },
|
||||
{ key: 'weekday', label: '星期' },
|
||||
{ key: 'shift_code', label: '班次' },
|
||||
{ key: 'actual_in', label: '上班' },
|
||||
{ key: 'actual_out', label: '下班' },
|
||||
{ key: 'expected_in', label: '應上班' },
|
||||
{ key: 'expected_out', label: '應下班' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg sm:text-xl font-bold text-slate-900">出勤記錄 Attendance</h1>
|
||||
<div className="flex gap-2">
|
||||
<Link to="/" className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 text-sm rounded-md hover:bg-slate-200">
|
||||
<Calendar size={16} /> Dashboard
|
||||
</Link>
|
||||
<Link to="/upload" className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700">
|
||||
上傳 Excel
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card p-4 space-y-3">
|
||||
{/* Search row */}
|
||||
<form onSubmit={handleSearch} className="flex flex-wrap gap-3 items-end">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜尋員工姓名..."
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700">
|
||||
搜尋
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Filter row */}
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">日期:</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
|
||||
placeholder="由"
|
||||
/>
|
||||
<span className="text-slate-400">-</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
|
||||
placeholder="至"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">員工:</span>
|
||||
<select
|
||||
value={staffFilter}
|
||||
onChange={(e) => { setStaffFilter(e.target.value); setPage(1) }}
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
|
||||
>
|
||||
<option value="">全部</option>
|
||||
{staffList.map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">狀態:</span>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
|
||||
>
|
||||
<option value="">全部</option>
|
||||
<option value="normal">正常</option>
|
||||
<option value="late">遲到</option>
|
||||
<option value="early">早退</option>
|
||||
<option value="ot">OT</option>
|
||||
<option value="abnormal">異常</option>
|
||||
<option value="missing">缺勤</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setDateFrom(''); setDateTo(''); setStaffFilter(''); setStatusFilter(''); setSearch(''); setPage(1)
|
||||
// Force refetch in case useEffect timing misses the state change.
|
||||
setTimeout(() => fetchRecords(), 0)
|
||||
}}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs text-slate-500 hover:text-slate-700 border border-slate-300 rounded-md"
|
||||
>
|
||||
<X size={14} /> 清除篩選
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="flex items-center gap-4 text-xs text-slate-500">
|
||||
<span>{total} 筆記錄</span>
|
||||
{staffFilter && <span>• 員工: {staffFilter}</span>}
|
||||
{statusFilter && <span>• 狀態: {statusFilter}</span>}
|
||||
{(dateFrom || dateTo) && <span>• {dateFrom || '...'} 至 {dateTo || '...'}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-100 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-600 w-12">#</th>
|
||||
{columns.map(col => (
|
||||
<th
|
||||
key={col.key}
|
||||
onClick={() => handleSort(col.key)}
|
||||
className="px-3 py-2.5 text-left text-xs font-semibold text-slate-600 cursor-pointer hover:bg-slate-200"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{col.label}
|
||||
{getSortIcon(col.key)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-2.5 text-center text-xs font-semibold text-slate-600">狀態</th>
|
||||
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-600 w-16">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-3 py-8 text-center text-slate-400">
|
||||
載入中...
|
||||
</td>
|
||||
</tr>
|
||||
) : records.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-3 py-8 text-center text-slate-400">
|
||||
沒有記錄
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
records.map((record, idx) => (
|
||||
<tr
|
||||
key={record.id || idx}
|
||||
className={getRowClass(record.status_code)}
|
||||
>
|
||||
<td className="px-3 py-2.5 text-xs text-slate-400">{(page - 1) * perPage + idx + 1}</td>
|
||||
<td className="px-3 py-2.5 text-slate-700 font-medium">{record.employee_name || '-'}</td>
|
||||
<td className="px-3 py-2.5 text-slate-600">{record.date || '-'}</td>
|
||||
<td className="px-3 py-2.5 text-slate-600">{record.weekday || '-'}</td>
|
||||
<td className="px-3 py-2.5 text-slate-600">{record.shift_code || '-'}</td>
|
||||
<td className="px-3 py-2.5 text-slate-600">{record.actual_in || '-'}</td>
|
||||
<td className="px-3 py-2.5 text-slate-600">{record.actual_out || '-'}</td>
|
||||
<td className="px-3 py-2.5 text-slate-400 text-xs">{record.expected_in || '-'}</td>
|
||||
<td className="px-3 py-2.5 text-slate-400 text-xs">{record.expected_out || '-'}</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
{getStatusBadge(record)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right">
|
||||
<Link
|
||||
to={`/attendance/${record.id}`}
|
||||
className="p-1 text-slate-400 hover:text-blue-600 inline-block"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
|
||||
<span className="text-xs text-slate-500">
|
||||
第 {page} / {totalPages} 頁,共 {total} 筆記錄
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 text-xs border border-slate-300 rounded-md disabled:opacity-50 hover:bg-slate-100"
|
||||
>
|
||||
上一頁
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1 text-xs border border-slate-300 rounded-md disabled:opacity-50 hover:bg-slate-100"
|
||||
>
|
||||
下一頁
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
Plus, Edit2, Trash2, RefreshCw, Upload, Download, Calendar as CalIcon,
|
||||
Users, X, ChevronDown, ChevronUp, Filter,
|
||||
} from 'lucide-react'
|
||||
import StatusBadge from '../../components/StatusBadge'
|
||||
|
||||
const LEAVE_TYPES = [
|
||||
{ value: 'SL', label: '病假 SL', color: 'pink' },
|
||||
{ value: 'CL', label: '補鐘 CL', color: 'indigo' },
|
||||
{ value: 'AL', label: '年假 AL', color: 'emerald' },
|
||||
]
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear()
|
||||
const YEAR_OPTIONS = [CURRENT_YEAR - 1, CURRENT_YEAR, CURRENT_YEAR + 1, CURRENT_YEAR + 2]
|
||||
|
||||
export default function HolidaysPage() {
|
||||
const [tab, setTab] = useState('holidays')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">假期 Holidays</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
管理公眾假期 + 員工請假 (SL 病假 / CL 補鐘 / AL 年假)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-slate-200">
|
||||
<nav className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setTab('holidays')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === 'holidays'
|
||||
? 'border-primary-600 text-primary-700'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<CalIcon size={16} className="inline mr-1.5" />
|
||||
公眾假期
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('leaves')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === 'leaves'
|
||||
? 'border-primary-600 text-primary-700'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Users size={16} className="inline mr-1.5" />
|
||||
員工請假
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{tab === 'holidays' ? <HolidaysTab /> : <LeavesTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Tab 1: 公眾假期 ============
|
||||
function HolidaysTab() {
|
||||
const [year, setYear] = useState(CURRENT_YEAR)
|
||||
const [holidays, setHolidays] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState(null) // Holiday object or null
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [year])
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await api.get(`/api/holidays?year=${year}`)
|
||||
setHolidays(data)
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '載入失敗')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
if (!confirm(`從 gov.hk 重新抓取 ${year} 年假期?\n(手動編輯嘅假期唔會被覆寫)`)) return
|
||||
try {
|
||||
const { data } = await api.post(`/api/holidays/refresh?years=${year}`)
|
||||
toast.success(`✓ ${year} 已同步:新增 ${data.added} / 更新 ${data.updated} / 跳過 ${data.skipped}`)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '同步失敗')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(h) {
|
||||
if (!confirm(`確定要刪除 ${h.date}「${h.name}」?`)) return
|
||||
try {
|
||||
await api.delete(`/api/holidays/${h.id}`)
|
||||
toast.success('已刪除')
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '刪除失敗')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-slate-700">年份:</label>
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(Number(e.target.value))}
|
||||
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
|
||||
>
|
||||
{YEAR_OPTIONS.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700"
|
||||
>
|
||||
<Plus size={14} /> 加公假
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-300 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-50"
|
||||
>
|
||||
<RefreshCw size={14} /> 從 GovHK 同步
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-slate-400">載入中...</div>
|
||||
) : holidays.length === 0 ? (
|
||||
<div className="p-8 text-center text-slate-400">{year} 年冇假期資料</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-slate-700">日期</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-slate-700">名稱 (中)</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-slate-700">Name (EN)</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-slate-700">類型</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-slate-700">來源</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-slate-700">備註</th>
|
||||
<th className="px-4 py-2.5 text-right font-medium text-slate-700">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{holidays.map(h => (
|
||||
<tr key={h.id} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-2 font-mono text-slate-700">{h.date}</td>
|
||||
<td className="px-4 py-2 font-medium text-slate-900">{h.name}</td>
|
||||
<td className="px-4 py-2 text-slate-600">{h.name_en || '-'}</td>
|
||||
<td className="px-4 py-2">
|
||||
{h.is_mandatory ? (
|
||||
<span className="inline-block px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-700">法定</span>
|
||||
) : (
|
||||
<span className="inline-block px-2 py-0.5 rounded text-xs bg-slate-100 text-slate-600">銀行/學校</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-slate-500">{h.source || '-'}</td>
|
||||
<td className="px-4 py-2 text-slate-500 text-xs">{h.notes || '-'}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<button
|
||||
onClick={() => setEditing(h)}
|
||||
className="p-1 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded"
|
||||
title="編輯"
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(h)}
|
||||
className="p-1 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded ml-1"
|
||||
title="刪除"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-500 text-center">
|
||||
共 {holidays.length} 個公眾假期 · 來源:<a className="text-primary-600 underline" href="https://www.gov.hk/en/about/abouthk/holiday/" target="_blank" rel="noreferrer">gov.hk</a>
|
||||
</div>
|
||||
|
||||
{(showCreate || editing) && (
|
||||
<HolidayModal
|
||||
holiday={editing}
|
||||
onClose={() => { setShowCreate(false); setEditing(null) }}
|
||||
onSaved={() => { setShowCreate(false); setEditing(null); load() }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Tab 2: 員工請假 ============
|
||||
function LeavesTab() {
|
||||
const [leaves, setLeaves] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [filterType, setFilterType] = useState('')
|
||||
const [filterEmp, setFilterEmp] = useState('')
|
||||
const [filterFrom, setFilterFrom] = useState('')
|
||||
const [filterTo, setFilterTo] = useState('')
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (filterType) params.set('leave_type', filterType)
|
||||
if (filterEmp) params.set('employee_name', filterEmp)
|
||||
if (filterFrom) params.set('date_from', filterFrom)
|
||||
if (filterTo) params.set('date_to', filterTo)
|
||||
const { data } = await api.get(`/api/leave?${params}`)
|
||||
setLeaves(data)
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '載入失敗')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(lv) {
|
||||
if (!confirm(`確定要刪除 ${lv.employee_name} ${lv.date} ${lv.leave_type}${lv.hours}h?`)) return
|
||||
try {
|
||||
await api.delete(`/api/leave/${lv.id}`)
|
||||
toast.success('已刪除')
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '刪除失敗')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(e) {
|
||||
const f = e.target.files?.[0]
|
||||
if (!f) return
|
||||
const fd = new FormData()
|
||||
fd.append('file', f)
|
||||
try {
|
||||
const { data } = await api.post('/api/leave/upload', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
const errMsg = data.errors?.length ? `\n錯誤 (前 ${data.errors.length} 條):\n${data.errors.join('\n')}` : ''
|
||||
toast.success(`✓ 新增 ${data.added} 條 / 跳過 ${data.skipped} 條${errMsg}`, { duration: 6000 })
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '上傳失敗')
|
||||
} finally {
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function downloadTemplate() {
|
||||
const csv = 'employee_name,date,leave_type,hours,reason,approved_by\nJay Lam,2026-07-15,SL,2.0,睇醫生,\nJay Lam,2026-07-20,AL,8.0,Family trip,Manager A\n'
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'leave_template.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const uniqueEmps = [...new Set(leaves.map(l => l.employee_name))].sort()
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value)}
|
||||
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="">所有類型</option>
|
||||
{LEAVE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={filterFrom}
|
||||
onChange={(e) => setFilterFrom(e.target.value)}
|
||||
placeholder="由"
|
||||
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={filterTo}
|
||||
onChange={(e) => setFilterTo(e.target.value)}
|
||||
placeholder="至"
|
||||
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={load}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-200"
|
||||
>
|
||||
<Filter size={14} /> 套用
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setFilterType(''); setFilterEmp(''); setFilterFrom(''); setFilterTo(''); setTimeout(load, 0) }}
|
||||
className="inline-flex items-center gap-1 px-2 py-1.5 text-slate-500 hover:text-slate-700 text-sm"
|
||||
>
|
||||
<X size={14} /> 清
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={downloadTemplate}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-300 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-50"
|
||||
>
|
||||
<Download size={14} /> CSV 範本
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-300 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-50"
|
||||
>
|
||||
<Upload size={14} /> 上傳 CSV/Excel
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700"
|
||||
>
|
||||
<Plus size={14} /> 加請假
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-slate-400">載入中...</div>
|
||||
) : leaves.length === 0 ? (
|
||||
<div className="p-8 text-center text-slate-400">冇請假記錄</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-slate-700">員工</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-slate-700">日期</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-slate-700">類型</th>
|
||||
<th className="px-4 py-2.5 text-right font-medium text-slate-700">時數</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-slate-700">原因</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-slate-700">審批人</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-slate-700">來源</th>
|
||||
<th className="px-4 py-2.5 text-right font-medium text-slate-700">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{leaves.map(lv => (
|
||||
<tr key={lv.id} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-2 font-medium text-slate-900">{lv.employee_name}</td>
|
||||
<td className="px-4 py-2 font-mono text-slate-700">{lv.date}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${
|
||||
lv.leave_type === 'SL' ? 'bg-pink-100 text-pink-700' :
|
||||
lv.leave_type === 'CL' ? 'bg-indigo-100 text-indigo-700' :
|
||||
'bg-emerald-100 text-emerald-700'
|
||||
}`}>
|
||||
{LEAVE_TYPES.find(t => t.value === lv.leave_type)?.label || lv.leave_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums">{lv.hours}h</td>
|
||||
<td className="px-4 py-2 text-slate-600 text-xs">{lv.reason || '-'}</td>
|
||||
<td className="px-4 py-2 text-slate-600 text-xs">{lv.approved_by || '-'}</td>
|
||||
<td className="px-4 py-2 text-xs text-slate-500">{lv.source || '-'}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<button
|
||||
onClick={() => setEditing(lv)}
|
||||
className="p-1 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded"
|
||||
title="編輯"
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(lv)}
|
||||
className="p-1 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded ml-1"
|
||||
title="刪除"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-500 text-center">
|
||||
共 {leaves.length} 條請假記錄
|
||||
</div>
|
||||
|
||||
{(showCreate || editing) && (
|
||||
<LeaveModal
|
||||
leave={editing}
|
||||
employees={uniqueEmps}
|
||||
onClose={() => { setShowCreate(false); setEditing(null) }}
|
||||
onSaved={() => { setShowCreate(false); setEditing(null); load() }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Holiday Modal ============
|
||||
function HolidayModal({ holiday, onClose, onSaved }) {
|
||||
const isEdit = !!holiday
|
||||
const [date, setDate] = useState(holiday?.date || '')
|
||||
const [name, setName] = useState(holiday?.name || '')
|
||||
const [nameEn, setNameEn] = useState(holiday?.name_en || '')
|
||||
const [isMandatory, setIsMandatory] = useState(holiday?.is_mandatory ?? true)
|
||||
const [notes, setNotes] = useState(holiday?.notes || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function handleSave() {
|
||||
if (!date || !name) {
|
||||
toast.error('日期同名稱係必填')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
if (isEdit) {
|
||||
await api.put(`/api/holidays/${holiday.id}`, {
|
||||
date, name, name_en: nameEn, is_mandatory: isMandatory, notes,
|
||||
})
|
||||
toast.success('已更新')
|
||||
} else {
|
||||
await api.post('/api/holidays', {
|
||||
date, name, name_en: nameEn, is_mandatory: isMandatory, notes,
|
||||
})
|
||||
toast.success('已新增')
|
||||
}
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '儲存失敗')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="px-5 py-3 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">{isEdit ? '編輯假期' : '加公假'}</h3>
|
||||
<button onClick={onClose} className="p-1 text-slate-400 hover:text-slate-700 rounded">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">日期 <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">名稱 (中文) <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="元旦 / 香港回歸紀念日"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Name (English)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nameEn}
|
||||
onChange={(e) => setNameEn(e.target.value)}
|
||||
placeholder="The first day of January"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="mandatory"
|
||||
checked={isMandatory}
|
||||
onChange={(e) => setIsMandatory(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="mandatory" className="text-sm text-slate-700">
|
||||
法定假日 (勞工假)
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">備註</label>
|
||||
<input
|
||||
type="text"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-3 border-t border-slate-200 flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-4 py-1.5 text-slate-600 hover:bg-slate-100 rounded-md text-sm">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? '儲存中...' : (isEdit ? '更新' : '新增')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Leave Modal ============
|
||||
function LeaveModal({ leave, employees, onClose, onSaved }) {
|
||||
const isEdit = !!leave
|
||||
const [employeeName, setEmployeeName] = useState(leave?.employee_name || '')
|
||||
const [date, setDate] = useState(leave?.date || '')
|
||||
const [leaveType, setLeaveType] = useState(leave?.leave_type || 'SL')
|
||||
const [hours, setHours] = useState(leave?.hours ?? 8)
|
||||
const [reason, setReason] = useState(leave?.reason || '')
|
||||
const [approvedBy, setApprovedBy] = useState(leave?.approved_by || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function handleSave() {
|
||||
if (!employeeName || !date || !leaveType) {
|
||||
toast.error('員工 / 日期 / 類型 係必填')
|
||||
return
|
||||
}
|
||||
if (hours < 0 || hours > 24) {
|
||||
toast.error('時數必須 0-24')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
if (isEdit) {
|
||||
await api.put(`/api/leave/${leave.id}`, {
|
||||
employee_name: employeeName, date, leave_type: leaveType,
|
||||
hours: Number(hours), reason, approved_by: approvedBy,
|
||||
})
|
||||
toast.success('已更新')
|
||||
} else {
|
||||
await api.post('/api/leave', {
|
||||
employee_name: employeeName, date, leave_type: leaveType,
|
||||
hours: Number(hours), reason, approved_by: approvedBy,
|
||||
})
|
||||
toast.success('已新增')
|
||||
}
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '儲存失敗')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="px-5 py-3 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">{isEdit ? '編輯請假' : '加請假'}</h3>
|
||||
<button onClick={onClose} className="p-1 text-slate-400 hover:text-slate-700 rounded">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">員工 <span className="text-red-500">*</span></label>
|
||||
{employees.length > 0 ? (
|
||||
<select
|
||||
value={employeeName}
|
||||
onChange={(e) => setEmployeeName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="">-- 揀員工 --</option>
|
||||
{employees.map(e => <option key={e} value={e}>{e}</option>)}
|
||||
{!employees.includes(employeeName) && employeeName && (
|
||||
<option value={employeeName}>{employeeName} (新)</option>
|
||||
)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={employeeName}
|
||||
onChange={(e) => setEmployeeName(e.target.value)}
|
||||
placeholder="員工姓名"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">日期 <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">類型 <span className="text-red-500">*</span></label>
|
||||
<div className="flex gap-2">
|
||||
{LEAVE_TYPES.map(t => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => setLeaveType(t.value)}
|
||||
className={`flex-1 px-3 py-2 rounded-md text-sm font-medium border transition-colors ${
|
||||
leaveType === t.value
|
||||
? t.color === 'pink' ? 'bg-pink-100 border-pink-400 text-pink-700'
|
||||
: t.color === 'indigo' ? 'bg-indigo-100 border-indigo-400 text-indigo-700'
|
||||
: 'bg-emerald-100 border-emerald-400 text-emerald-700'
|
||||
: 'bg-white border-slate-300 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">時數 (0-24) <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="24"
|
||||
value={hours}
|
||||
onChange={(e) => setHours(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{[1, 2, 4, 8].map(h => (
|
||||
<button
|
||||
key={h}
|
||||
type="button"
|
||||
onClick={() => setHours(h)}
|
||||
className="px-2 py-0.5 text-xs bg-slate-100 hover:bg-slate-200 rounded"
|
||||
>
|
||||
{h}h
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">原因</label>
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="睇醫生 / Family trip / 補鐘"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">審批人</label>
|
||||
<input
|
||||
type="text"
|
||||
value={approvedBy}
|
||||
onChange={(e) => setApprovedBy(e.target.value)}
|
||||
placeholder="Manager A"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-3 border-t border-slate-200 flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-4 py-1.5 text-slate-600 hover:bg-slate-100 rounded-md text-sm">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? '儲存中...' : (isEdit ? '更新' : '新增')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Upload, FileSpreadsheet, AlertCircle, CheckCircle, XCircle, ArrowRight, Download } from 'lucide-react'
|
||||
|
||||
const ATTENDANCE_COLUMNS = ['date', 'employee_name', 'employee_id', 'department', 'check_in', 'check_out', 'status']
|
||||
const ACCIDENT_COLUMNS = ['date', 'time', 'location', 'employee_name', 'employee_id', 'department', 'description', 'severity', 'medical_report', 'action_taken', 'responsible_person']
|
||||
|
||||
function downloadTemplate(section) {
|
||||
const XLSX = window.XLSX
|
||||
if (!XLSX) return toast.error('XLSX library not loaded')
|
||||
|
||||
const columns = section === 'attendance' ? ATTENDANCE_COLUMNS : ACCIDENT_COLUMNS
|
||||
const data = [columns, ['2026-01-01', '張三', 'A001', '工程部', '09:00', '18:00', 'present']]
|
||||
const ws = XLSX.utils.aoa_to_sheet(data)
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, section)
|
||||
XLSX.writeFile(wb, `${section}_template.xlsx`)
|
||||
}
|
||||
|
||||
export default function ImportPage() {
|
||||
const [step, setStep] = useState(1)
|
||||
const [section, setSection] = useState('attendance')
|
||||
const [file, setFile] = useState(null)
|
||||
const [headers, setHeaders] = useState([])
|
||||
const [preview, setPreview] = useState([])
|
||||
const [mapping, setMapping] = useState({})
|
||||
const [updateExisting, setUpdateExisting] = useState(true)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [result, setResult] = useState(null)
|
||||
const fileInputRef = useRef()
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.name.match(/\.(xlsx|xls)$/i)) {
|
||||
toast.error('請上傳 Excel 文件 (.xlsx, .xls)')
|
||||
return
|
||||
}
|
||||
|
||||
setFile(file)
|
||||
|
||||
// Read and parse headers
|
||||
const reader = new FileReader()
|
||||
reader.onload = async (event) => {
|
||||
try {
|
||||
const data = new Uint8Array(event.target.result)
|
||||
const XLSX = window.XLSX
|
||||
const wb = XLSX.read(data, { type: 'array' })
|
||||
const ws = wb.Sheets[wb.SheetNames[0]]
|
||||
const range = XLSX.utils.decode_range(ws['!ref'])
|
||||
|
||||
// Get headers
|
||||
const headersRow = []
|
||||
for (let C = range.s.c; C <= range.e.c; ++C) {
|
||||
const addr = XLSX.utils.encode_cell({ r: 0, c: C })
|
||||
headersRow.push(ws[addr]?.v || `Column ${C + 1}`)
|
||||
}
|
||||
setHeaders(headersRow)
|
||||
|
||||
// Get preview (first 5 rows)
|
||||
const previewRows = []
|
||||
for (let R = 1; R <= Math.min(5, range.e.r); ++R) {
|
||||
const row = []
|
||||
for (let C = range.s.c; C <= range.e.c; ++C) {
|
||||
const addr = XLSX.utils.encode_cell({ r: R, c: C })
|
||||
row.push(ws[addr]?.v || '')
|
||||
}
|
||||
previewRows.push(row)
|
||||
}
|
||||
setPreview(previewRows)
|
||||
|
||||
// Auto-map using Excel column names directly
|
||||
const autoMapping = {}
|
||||
headersRow.forEach((header, idx) => {
|
||||
if (header && header.trim()) {
|
||||
autoMapping[idx] = header.trim()
|
||||
}
|
||||
})
|
||||
|
||||
setMapping(autoMapping)
|
||||
setStep(2)
|
||||
} catch (err) {
|
||||
toast.error('讀取 Excel 文件失敗')
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
}
|
||||
|
||||
const handleMappingChange = (colIndex, field) => {
|
||||
setMapping(prev => ({ ...prev, [colIndex]: field }))
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
setImporting(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('update_existing', updateExisting.toString())
|
||||
|
||||
const { data } = await api.post(`/api/import/${section}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
|
||||
setResult(data)
|
||||
setStep(3)
|
||||
toast.success('Import completed!')
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || 'Import failed')
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Excel Import</h1>
|
||||
<p className="text-slate-500 mt-1">上傳 Excel 文件批量導入數據</p>
|
||||
</div>
|
||||
|
||||
{/* Section selector */}
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium text-slate-700">選擇類別:</span>
|
||||
<button
|
||||
onClick={() => setSection('attendance')}
|
||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||
section === 'attendance'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
出勤 Attendance
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSection('accident')}
|
||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||
section === 'accident'
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
意外 Accident
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadTemplate(section)}
|
||||
className="ml-auto flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md font-medium hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<Download size={16} />
|
||||
下載範本
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress steps */}
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div key={s} className="flex items-center gap-2">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-medium ${
|
||||
step >= s ? 'bg-primary-600 text-white' : 'bg-slate-200 text-slate-500'
|
||||
}`}>
|
||||
{s}
|
||||
</div>
|
||||
<span className={`text-sm ${step >= s ? 'text-primary-600' : 'text-slate-400'}`}>
|
||||
{s === 1 ? '上傳檔案' : s === 2 ? '欄位映射' : '完成'}
|
||||
</span>
|
||||
{s < 3 && <ArrowRight size={16} className="text-slate-300 mx-2" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Upload */}
|
||||
{step === 1 && (
|
||||
<div className="card p-12">
|
||||
<div
|
||||
className="border-2 border-dashed border-slate-300 rounded-lg p-12 text-center hover:border-primary-400 transition-colors cursor-pointer"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload size={48} className="mx-auto text-slate-400 mb-4" />
|
||||
<p className="text-lg font-medium text-slate-700">點擊上傳 Excel 文件</p>
|
||||
<p className="text-sm text-slate-500 mt-2">支援 .xlsx, .xls 格式</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Mapping & Preview */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
{/* File info */}
|
||||
<div className="card p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileSpreadsheet size={24} className="text-green-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">{file?.name}</div>
|
||||
<div className="text-sm text-slate-500">{preview.length + 1} rows</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setStep(1); setFile(null); setHeaders([]); setPreview([]); setMapping({}) }}
|
||||
className="text-sm text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
重新上傳
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mapping */}
|
||||
<div className="card">
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="font-medium text-slate-900">欄位映射 Field Mapping</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">將 Excel 欄位映射到系統欄位</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{headers.map((header, idx) => (
|
||||
<div key={idx} className="grid grid-cols-2 gap-4 items-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-700">{header}</div>
|
||||
<div className="text-xs text-slate-400">Column {idx + 1}</div>
|
||||
</div>
|
||||
<select
|
||||
value={mapping[idx] || ''}
|
||||
onChange={(e) => handleMappingChange(idx, e.target.value)}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="">-- 不映射 --</option>
|
||||
{section === 'attendance' ? (
|
||||
<>
|
||||
<option value="employee_id">員工編號 Employee ID</option>
|
||||
<option value="employee_name">員工姓名 Employee Name</option>
|
||||
<option value="department">部門 Department</option>
|
||||
<option value="date">日期 Date</option>
|
||||
<option value="check_in">上班時間 Check In</option>
|
||||
<option value="check_out">下班時間 Check Out</option>
|
||||
<option value="status">狀態 Status</option>
|
||||
<option value="overtime_hours">加班時數 OT Hours</option>
|
||||
<option value="leave_type">請假類型 Leave Type</option>
|
||||
<option value="remarks">備註 Remarks</option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<option value="date">日期 Date</option>
|
||||
<option value="time">時間 Time</option>
|
||||
<option value="location">地點 Location</option>
|
||||
<option value="employee_name">員工姓名 Employee Name</option>
|
||||
<option value="employee_id">員工編號 Employee ID</option>
|
||||
<option value="department">部門 Department</option>
|
||||
<option value="description">描述 Description</option>
|
||||
<option value="severity">嚴重程度 Severity</option>
|
||||
<option value="medical_report">醫療報告 Medical Report</option>
|
||||
<option value="action_taken">行動 Action Taken</option>
|
||||
<option value="responsible_person">負責人 Responsible Person</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="font-medium text-slate-900">預覽 Preview</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{headers.map((h, i) => (
|
||||
<th key={i} className="px-4 py-2 text-left text-xs font-medium text-slate-600">
|
||||
{h}
|
||||
{mapping[i] && (
|
||||
<span className="ml-2 text-primary-600">→ {mapping[i]}</span>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{preview.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className="px-4 py-2 text-slate-700">
|
||||
{String(cell)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="card p-6">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={updateExisting}
|
||||
onChange={(e) => setUpdateExisting(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-slate-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">更新已存在的記錄</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
如果數據已存在,則更新而非跳過(以 Employee ID + Date 判斷重複)
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Import button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={importing || Object.keys(mapping).length === 0}
|
||||
className="btn-primary px-8 py-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{importing ? '導入中...' : '開始導入'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Result */}
|
||||
{step === 3 && result && (
|
||||
<div className="card p-8 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<CheckCircle size={32} className="text-green-600" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-slate-900 mb-2">導入完成!</h2>
|
||||
<p className="text-slate-500 mb-8">已完成數據導入,以下是結果摘要</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-6 mb-8">
|
||||
<div className="p-4 bg-green-50 rounded-lg">
|
||||
<div className="text-3xl font-bold text-green-600">{result.created}</div>
|
||||
<div className="text-sm text-green-700">已建立</div>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-50 rounded-lg">
|
||||
<div className="text-3xl font-bold text-blue-600">{result.updated}</div>
|
||||
<div className="text-sm text-blue-700">已更新</div>
|
||||
</div>
|
||||
<div className="p-4 bg-amber-50 rounded-lg">
|
||||
<div className="text-3xl font-bold text-amber-600">{result.skipped}</div>
|
||||
<div className="text-sm text-amber-700">已跳過</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.errors?.length > 0 && (
|
||||
<div className="text-left card p-4 mb-8 border border-red-200">
|
||||
<div className="flex items-center gap-2 text-red-600 font-medium mb-2">
|
||||
<AlertCircle size={18} />
|
||||
錯誤 ({result.errors.length})
|
||||
</div>
|
||||
<ul className="text-sm text-red-600 space-y-1">
|
||||
{result.errors.slice(0, 10).map((err, i) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={() => { setStep(1); setFile(null); setHeaders([]); setPreview([]); setMapping({}); setResult(null) }}
|
||||
className="btn-secondary"
|
||||
>
|
||||
繼續導入
|
||||
</button>
|
||||
<Link
|
||||
to={section === 'attendance' ? '/attendance' : '/accident'}
|
||||
className="btn-primary"
|
||||
>
|
||||
查看列表
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Upload, CheckCircle, Trash2, Clock, Users } from 'lucide-react'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export default function RosterPage() {
|
||||
const [rosterData, setRosterData] = useState({ headers: [], rows: [] })
|
||||
const [rosterInfo, setRosterInfo] = useState({ uploaded: false })
|
||||
const [file, setFile] = useState(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadRosterData = async () => {
|
||||
try {
|
||||
const [infoRes, dataRes] = await Promise.all([
|
||||
api.get('/api/roster/info'),
|
||||
api.get('/api/roster/data').catch(() => ({ data: { headers: [], rows: [] } }))
|
||||
])
|
||||
setRosterInfo(infoRes.data)
|
||||
setRosterData(dataRes.data || { headers: [], rows: [] })
|
||||
} catch (err) {
|
||||
console.error('Load error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadRosterData()
|
||||
}, [])
|
||||
|
||||
const handleUpload = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!file) return
|
||||
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
await api.post('/api/roster/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
toast.success('上傳成功!')
|
||||
setFile(null)
|
||||
loadRosterData()
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '上傳失敗')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('確定要刪除班次表嗎?')) return
|
||||
try {
|
||||
await api.delete('/api/roster')
|
||||
toast.success('已刪除')
|
||||
setRosterData({ headers: [], rows: [] })
|
||||
setRosterInfo({ uploaded: false })
|
||||
} catch (err) {
|
||||
toast.error('刪除失敗')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-center">載入中...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-xl sm:text-2xl font-bold mb-4 sm:mb-6">班次管理 Roster</h1>
|
||||
|
||||
{/* Upload Section */}
|
||||
<div className="card p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Upload size={20} />
|
||||
上傳班次表
|
||||
</h2>
|
||||
|
||||
{!rosterInfo.uploaded ? (
|
||||
<form onSubmit={handleUpload} className="flex gap-4 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
選擇班次表 Excel 檔案
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={(e) => setFile(e.target.files[0])}
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={!file || uploading} className="btn-primary">
|
||||
{uploading ? '上傳中...' : '上傳'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="text-green-500" size={24} />
|
||||
<div>
|
||||
<p className="font-medium">已上傳班次表</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{rosterInfo.rows} 個班次,{rosterInfo.columns} 欄
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleDelete} className="btn-danger">
|
||||
<Trash2 size={16} className="inline mr-1" />
|
||||
刪除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Roster Table */}
|
||||
{rosterData.headers.length > 0 && (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-200">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Clock size={20} />
|
||||
班次表
|
||||
</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-100 border-b border-slate-200">
|
||||
<tr>
|
||||
{rosterData.headers.map((header, idx) => (
|
||||
<th key={idx} className="px-3 py-2 text-left text-xs font-semibold text-slate-600">
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{rosterData.rows.map((row, rowIdx) => (
|
||||
<tr key={rowIdx} className="hover:bg-slate-50">
|
||||
{rosterData.headers.map((header, colIdx) => (
|
||||
<td key={colIdx} className="px-3 py-2 text-slate-700">
|
||||
{row[colIdx] !== null && row[colIdx] !== undefined
|
||||
? String(row[colIdx])
|
||||
: '-'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
{!rosterInfo.uploaded && (
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">如何使用</h2>
|
||||
<ol className="list-decimal list-inside space-y-2 text-slate-600">
|
||||
<li>上傳包含班次資料的 Excel 檔案</li>
|
||||
<li>Excel 需要有「班次」同「描述」欄</li>
|
||||
<li>上傳後可以睇到所有班次</li>
|
||||
<li>喺 Attendance Excel 入面加「班次」欄,填入員工所屬班次代碼</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Upload, FileSpreadsheet, CheckCircle, AlertCircle, Download, Trash2 } from 'lucide-react'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const SECTIONS = [
|
||||
{ key: 'accident', label: '意外 Accident', color: 'red' },
|
||||
{ key: 'attendance', label: '出勤 Attendance', color: 'blue' },
|
||||
]
|
||||
|
||||
export default function UploadPage() {
|
||||
const [section, setSection] = useState('accident')
|
||||
const [file, setFile] = useState(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [result, setResult] = useState(null)
|
||||
const [info, setInfo] = useState(null)
|
||||
const fileInputRef = useRef()
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
|
||||
const currentSection = SECTIONS.find(s => s.key === section)
|
||||
|
||||
// Load current file info when section changes
|
||||
useEffect(() => {
|
||||
loadSectionInfo()
|
||||
}, [section])
|
||||
|
||||
async function loadSectionInfo() {
|
||||
try {
|
||||
const { data } = await api.get(`/api/${section}/info`)
|
||||
setInfo(data)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
if (!confirm(`確定要刪除 ${section} 的所有數據嗎?這個操作無法撤銷!`)) return
|
||||
try {
|
||||
await api.delete(`/api/upload/${section}`)
|
||||
toast.success(`${section} 數據已清除`)
|
||||
setInfo({ uploaded: false, rows: 0, columns: 0, headers: [] })
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '清除失敗')
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileChange(e) {
|
||||
const f = e.target.files[0]
|
||||
if (f) {
|
||||
setFile(f)
|
||||
setResult(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
setResult(null)
|
||||
setUploadProgress(0)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
// Simulate progress
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadProgress(p => Math.min(p + 10, 90))
|
||||
}, 200)
|
||||
|
||||
const { data } = await api.post(`/api/upload/${section}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (e) => {
|
||||
if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total))
|
||||
}
|
||||
})
|
||||
|
||||
clearInterval(progressInterval)
|
||||
setUploadProgress(100)
|
||||
|
||||
setResult(data)
|
||||
toast.success(`${section === 'accident' ? '意外' : '出勤'} 數據上傳成功!`)
|
||||
setFile(null)
|
||||
loadSectionInfo()
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '上傳失敗')
|
||||
setUploadProgress(0)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancelUpload() {
|
||||
// Cancel is handled by just resetting the state
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
setFile(null)
|
||||
toast.success('已取消上傳')
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
try {
|
||||
const { data: tokenData } = await api.post('/api/auth/login', {
|
||||
email: 'admin@aars.hk',
|
||||
password: 'Admin@1234'
|
||||
})
|
||||
|
||||
const token = tokenData.access_token
|
||||
window.location.href = `https://excel.donton.cloud/api/export/${section}/excel?token=${token}`
|
||||
} catch (err) {
|
||||
toast.error('Download failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">上傳 Excel</h1>
|
||||
<p className="text-slate-500 mt-1">直接上傳 Excel 檔案作為數據源</p>
|
||||
</div>
|
||||
|
||||
{/* Section selector */}
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium text-slate-700">選擇類別:</span>
|
||||
{SECTIONS.map(s => (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => setSection(s.key)}
|
||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||
section === s.key
|
||||
? s.color === 'red' ? 'bg-red-600 text-white' : 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current file info */}
|
||||
{info && (
|
||||
<div className="card p-4 bg-slate-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">
|
||||
{section === 'accident' ? '意外' : '出勤'} 數據:
|
||||
</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
{info.uploaded
|
||||
? `${info.rows} 行, ${info.columns} 列`
|
||||
: '尚未上傳'}
|
||||
</div>
|
||||
{info.headers && info.headers.length > 0 && (
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
欄位:{info.headers.slice(0, 5).join(', ')}
|
||||
{info.headers.length > 5 && '...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{info.uploaded && (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
|
||||
>
|
||||
<Download size={16} />
|
||||
下載
|
||||
</button>
|
||||
)}
|
||||
{info.uploaded && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload area */}
|
||||
<div className="card p-8">
|
||||
<div
|
||||
className="border-2 border-dashed border-slate-300 rounded-lg p-8 text-center hover:border-primary-400 transition-colors cursor-pointer"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload size={48} className="mx-auto text-slate-400 mb-4" />
|
||||
<p className="text-lg font-medium text-slate-700">
|
||||
{file ? file.name : '點擊選擇 Excel 檔案'}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 mt-2">支援 .xlsx, .xls 格式</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileSpreadsheet size={24} className="text-green-600" />
|
||||
<div>
|
||||
<div className="font-medium">{file.name}</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{(file.size / 1024).toFixed(1)} KB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCancelUpload}
|
||||
disabled={!uploading}
|
||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-md hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading}
|
||||
className="px-6 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? '上傳中...' : '上傳'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{uploading && uploadProgress > 0 && (
|
||||
<div className="w-full bg-slate-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className="card p-4 bg-green-50 border border-green-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle size={24} className="text-green-600" />
|
||||
<div>
|
||||
<div className="font-medium text-green-900">{result.message}</div>
|
||||
<div className="text-sm text-green-700">
|
||||
{result.rows} 行數據已就緒
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="card p-4 bg-amber-50 border border-amber-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle size={20} className="text-amber-600 mt-0.5" />
|
||||
<div>
|
||||
<div className="font-medium text-amber-900">注意事項</div>
|
||||
<ul className="text-sm text-amber-800 mt-2 space-y-1">
|
||||
<li>• 上傳的 Excel 將直接作為數據源</li>
|
||||
<li>• 第一行必須是欄位名稱</li>
|
||||
<li>• 之後每行是一條記錄</li>
|
||||
<li>• 現有數據會被新上傳的文件替換</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'Noto Sans TC', 'system-ui', 'sans-serif'],
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/',
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user