Add full AARS system - Attendance & Accident Record System

- FastAPI backend with SQLite database
- React + TailwindCSS frontend
- JWT authentication
- Excel upload/download for attendance and accident records
- Shift roster management with S7a/S7b split
- Lateness/OT calculation based on shift times
- Roster info API endpoints
- Export API with token query parameter support
- Docker compose deployment
This commit is contained in:
IT狗
2026-07-16 03:16:33 +08:00
parent 7f34b1fa09
commit eafc1c33fc
34 changed files with 4388 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
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 AccidentList from './pages/accident/List'
import AccidentDetail from './pages/accident/Detail'
import UploadPage from './pages/upload/Upload'
import RosterPage from './pages/roster/Roster'
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={<AccidentList />} />
<Route path="accident/:id" element={<AccidentDetail />} />
<Route path="upload" element={<UploadPage />} />
<Route path="roster" element={<RosterPage />} />
</Route>
</Routes>
)
}
+32
View File
@@ -0,0 +1,32 @@
import axios from 'axios'
const api = axios.create({
baseURL: '',
timeout: 30000,
})
// Request interceptor to add auth token
api.interceptors.request.use(
config => {
const token = localStorage.getItem('aars_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => Promise.reject(error)
)
// Response interceptor for error handling
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
localStorage.removeItem('aars_token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default api
+127
View File
@@ -0,0 +1,127 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { LogOut, Users, AlertTriangle, LayoutDashboard, Upload, Calendar } from 'lucide-react'
export default function Layout() {
const { user, logout } = useAuth()
const navigate = useNavigate()
const handleLogout = () => {
logout()
navigate('/login')
}
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<header className="bg-white border-b border-slate-200 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* 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>
{/* Navigation */}
<nav className="hidden md:flex items-center gap-1">
<NavLink
to="/"
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'
}`
}
>
<LayoutDashboard size={18} />
Dashboard
</NavLink>
<NavLink
to="/attendance"
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'
}`
}
>
<Users size={18} />
出勤 Attendance
</NavLink>
<NavLink
to="/accident"
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'
}`
}
>
<AlertTriangle size={18} />
意外 Accident
</NavLink>
<NavLink
to="/roster"
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'
}`
}
>
<Calendar size={18} />
班次 Roster
</NavLink>
</nav>
</div>
{/* Right side */}
<div className="flex items-center gap-4">
<NavLink
to="/upload"
className={({ isActive }) =>
`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="flex items-center gap-3 pl-4 border-l border-slate-200">
<div className="text-right hidden sm: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>
</div>
</div>
</div>
</header>
{/* Main content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Outlet />
</main>
</div>
)
}
+52
View File
@@ -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)
+39
View File
@@ -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;
}
}
+18
View File
@@ -0,0 +1,18 @@
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'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
<Toaster position="bottom-right" />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
)
+340
View File
@@ -0,0 +1,340 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import api from '../api'
import { Users, AlertTriangle, FileSpreadsheet, RefreshCw, TrendingUp, MapPin, Clock, AlertCircle } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line } from 'recharts'
const COLORS = ['#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']
export default function Dashboard() {
// Attendance state
const [attInfo, setAttInfo] = useState(null)
const [attDeptData, setAttDeptData] = useState([])
const [attStatusData, setAttStatusData] = useState([])
const [latenessData, setLatenessData] = useState(null)
// Accident state
const [accInfo, setAccInfo] = useState(null)
const [accSeverityData, setAccSeverityData] = useState([])
const [accLocationData, setAccLocationData] = useState([])
const [accMonthlyData, setAccMonthlyData] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchData()
}, [])
const fetchData = async () => {
setLoading(true)
try {
const [attInfoRes, accInfoRes, attDataRes, accDataRes, lateRes] = await Promise.all([
api.get('/api/attendance/info'),
api.get('/api/accident/info'),
api.get('/api/attendance?page=1&page_size=1000'),
api.get('/api/accident?page=1&page_size=500'),
api.get('/api/attendance/lateness').catch(() => ({ data: null }))
])
setAttInfo(attInfoRes.data)
setAccInfo(accInfoRes.data)
setLatenessData(lateRes.data)
// Attendance - department
if (attDataRes.data.data && attDataRes.data.data.length > 0) {
const deptCount = {}
attDataRes.data.data.forEach(row => {
const deptKey = Object.keys(row).find(k =>
k.toLowerCase().includes('company') || k.includes('Company')
)
const dept = deptKey ? String(row[deptKey] || 'Unknown') : 'Unknown'
deptCount[dept] = (deptCount[dept] || 0) + 1
})
setAttDeptData(Object.entries(deptCount)
.map(([name, value]) => ({ name: name.slice(0, 15), value }))
.sort((a, b) => b.value - a.value)
.slice(0, 6))
// Status - from DURATION or other fields
const statusCount = {}
attDataRes.data.data.forEach(row => {
const durKey = Object.keys(row).find(k =>
k.toLowerCase().includes('duration') || k.includes('DURATION')
)
if (durKey && row[durKey]) {
const dur = String(row[durKey])
if (dur.includes('hours')) {
statusCount['正常工作'] = (statusCount['正常工作'] || 0) + 1
} else {
statusCount['其他'] = (statusCount['其他'] || 0) + 1
}
}
})
setAttStatusData(Object.entries(statusCount)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value))
}
// Accident - severity, location, monthly
if (accDataRes.data.data && accDataRes.data.data.length > 0) {
const sevCount = {}
const locCount = {}
const dateCount = {}
accDataRes.data.data.forEach(row => {
const sevKey = Object.keys(row).find(k =>
k.toLowerCase().includes('severity') || k.includes('緊急') || k.includes('程度')
)
if (sevKey) {
const sev = String(row[sevKey] || 'Unknown')
sevCount[sev] = (sevCount[sev] || 0) + 1
}
const locKey = Object.keys(row).find(k =>
k.toLowerCase().includes('location') || k.includes('部門') || k.includes('地點')
)
if (locKey) {
const loc = String(row[locKey] || 'Unknown')
locCount[loc] = (locCount[loc] || 0) + 1
}
const dateKey = Object.keys(row).find(k =>
k.toLowerCase().includes('date') || k.includes('日期') || k.includes('時間戳')
)
if (dateKey && row[dateKey]) {
const dateStr = String(row[dateKey]).slice(0, 7)
dateCount[dateStr] = (dateCount[dateStr] || 0) + 1
}
})
setAccSeverityData(Object.entries(sevCount)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value))
setAccLocationData(Object.entries(locCount)
.map(([name, value]) => ({ name: name.slice(0, 12), value }))
.sort((a, b) => b.value - a.value)
.slice(0, 6))
setAccMonthlyData(Object.entries(dateCount)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => a.name.localeCompare(b.name)))
}
} catch (err) {
console.error('Failed to fetch data:', err)
} finally {
setLoading(false)
}
}
const SectionCard = ({ title, value, subtitle, icon: Icon, color, link }) => (
<Link to={link} className={`card p-4 hover:shadow-md transition-shadow border-l-4 ${color}`}>
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-medium text-slate-500">{title}</div>
<div className="text-xl font-bold text-slate-900 mt-1">{value ?? '-'}</div>
{subtitle && <div className="text-xs text-slate-400 mt-0.5">{subtitle}</div>}
</div>
<Icon size={20} className={color.replace('border-l-', 'text-')} />
</div>
</Link>
)
const SmallBarChart = ({ data, color }) => (
<div className="h-32">
{data.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} layout="vertical" margin={{ left: 5, right: 5 }}>
<XAxis type="number" tick={{ fontSize: 10 }} />
<YAxis dataKey="name" type="category" width={80} tick={{ fontSize: 10 }} />
<Tooltip contentStyle={{ borderRadius: '6px', border: '1px solid #e2e8f0', fontSize: 12 }} />
<Bar dataKey="value" radius={[0, 3, 3, 0]}>
{data.map((_, index) => (
<Cell key={index} fill={COLORS[index % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-slate-400 text-xs">暫無數據</div>
)}
</div>
)
const TrendChart = ({ data }) => (
<div className="h-40">
{data.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ left: 5, right: 15 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip contentStyle={{ borderRadius: '6px', border: '1px solid #e2e8f0', fontSize: 12 }} />
<Line type="monotone" dataKey="value" stroke="#ef4444" strokeWidth={2} dot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-slate-400 text-xs">暫無數據</div>
)}
</div>
)
// Lateness chart data
const latenessChartData = latenessData?.stats?.slice(0, 8).map(s => ({
name: String(s.name).slice(0, 10),
value: s.late_minutes,
count: s.late_count
})) || []
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-slate-900">Dashboard</h1>
<button onClick={fetchData} className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-100 rounded-md">
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
刷新
</button>
</div>
{/* ========== ATTENDANCE SECTION ========== */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
<Users size={20} className="text-blue-600" />
出勤 Attendance
</h2>
<Link to="/attendance" className="text-sm text-blue-600 hover:text-blue-700">查看全部 </Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<SectionCard title="出勤記錄" value={attInfo?.rows || 0} icon={Users} color="border-l-blue-500" link="/attendance" />
<SectionCard title="出勤部門" value={attDeptData.length} subtitle="個部門" icon={MapPin} color="border-l-cyan-500" link="/attendance" />
<SectionCard
title="遲到人次"
value={latenessData?.total_late_count || 0}
icon={AlertCircle}
color="border-l-orange-500"
link="/attendance"
/>
<SectionCard
title="遲到總分鐘"
value={latenessData?.total_late_minutes || 0}
subtitle="分鐘"
icon={Clock}
color="border-l-red-500"
link="/attendance"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="card p-4 border-l-4 border-l-blue-500">
<h3 className="text-sm font-semibold text-slate-900 mb-3">部門分佈</h3>
<SmallBarChart data={attDeptData} color="blue" />
</div>
<div className="card p-4 border-l-4 border-l-blue-500">
<h3 className="text-sm font-semibold text-slate-900 mb-3">出勤狀態</h3>
<SmallBarChart data={attStatusData} color="blue" />
</div>
{/* Lateness Chart */}
<div className="card p-4 border-l-4 border-l-orange-500">
<h3 className="text-sm font-semibold text-slate-900 mb-3">員工遲到分鐘</h3>
<div className="h-32">
{latenessChartData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={latenessChartData} layout="vertical" margin={{ left: 5, right: 5 }}>
<XAxis type="number" tick={{ fontSize: 10 }} />
<YAxis dataKey="name" type="category" width={60} tick={{ fontSize: 9 }} />
<Tooltip
contentStyle={{ borderRadius: '6px', border: '1px solid #e2e8f0', fontSize: 12 }}
formatter={(value, name, props) => [`${value}分鐘 (${props.payload.count}次)`, '遲到']}
/>
<Bar dataKey="value" radius={[0, 3, 3, 0]} fill="#f59e0b" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-slate-400 text-xs">暫無遲到數據</div>
)}
</div>
</div>
</div>
{/* Top late employees table */}
{latenessData?.stats?.length > 0 && (
<div className="card p-4 mt-4">
<h3 className="text-sm font-semibold text-slate-900 mb-3 flex items-center gap-2">
<AlertCircle size={16} className="text-orange-500" />
遲到排行榜
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 font-medium text-slate-500">員工</th>
<th className="text-center py-2 px-3 font-medium text-slate-500">班次</th>
<th className="text-center py-2 px-3 font-medium text-slate-500">遲到次數</th>
<th className="text-center py-2 px-3 font-medium text-slate-500">總分鐘</th>
</tr>
</thead>
<tbody>
{latenessData.stats.slice(0, 5).map((stat, idx) => (
<tr key={idx} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-2 px-3 font-medium">{stat.name}</td>
<td className="text-center py-2 px-3">{stat.shift}</td>
<td className="text-center py-2 px-3 text-orange-600 font-medium">{stat.late_count} </td>
<td className="text-center py-2 px-3 text-red-600 font-medium">{stat.late_minutes} 分鐘</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* ========== ACCIDENT SECTION ========== */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
<AlertTriangle size={20} className="text-red-600" />
意外 Accident
</h2>
<Link to="/accident" className="text-sm text-red-600 hover:text-red-700">查看全部 </Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<SectionCard title="意外記錄" value={accInfo?.rows || 0} icon={AlertTriangle} color="border-l-red-500" link="/accident" />
<SectionCard title="出事地點" value={accLocationData.length} subtitle="個地點" icon={MapPin} color="border-l-orange-500" link="/accident" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="card p-4 border-l-4 border-l-red-500">
<h3 className="text-sm font-semibold text-slate-900 mb-3">緊急程度</h3>
<SmallBarChart data={accSeverityData} color="red" />
</div>
<div className="card p-4 border-l-4 border-l-orange-500">
<h3 className="text-sm font-semibold text-slate-900 mb-3">出事地點</h3>
<SmallBarChart data={accLocationData} color="orange" />
</div>
<div className="card p-4 border-l-4 border-l-red-500">
<h3 className="text-sm font-semibold text-slate-900 mb-3">月度趨勢</h3>
<TrendChart data={accMonthlyData} />
</div>
</div>
</div>
{/* Quick Actions */}
<div className="flex flex-wrap gap-3">
<Link to="/upload" className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm rounded-md hover:bg-primary-700">
<FileSpreadsheet size={16} />
上傳 Excel
</Link>
<Link to="/roster" className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 text-sm rounded-md hover:bg-slate-200">
<Clock size={16} />
班次管理
</Link>
</div>
</div>
)
}
+87
View File
@@ -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>
)
}
+129
View File
@@ -0,0 +1,129 @@
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 } from 'lucide-react'
export default function AccidentDetail() {
const { id } = useParams()
const navigate = useNavigate()
const [record, setRecord] = useState(null)
const [headers, setHeaders] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetchRecord()
}, [id])
const fetchRecord = async () => {
setLoading(true)
setError(null)
try {
// Get all records to find the one with this row id
const { data } = await api.get(`/api/accident?page=1&page_size=1000`)
if (data.data && data.data.length > 0) {
// Extract headers from first record
const keys = Object.keys(data.data[0]).filter(k => k !== '_row')
setHeaders(keys)
// Find the record with matching _row
const found = data.data.find(r => r._row === parseInt(id))
if (found) {
setRecord(found)
} else {
setError('Record not found')
}
} else {
setError('No data available')
}
} catch (err) {
console.error('Error fetching record:', err)
setError('Failed to load record')
} 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"
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">
<div className="text-red-500">{error}</div>
<button
onClick={() => navigate('/accident')}
className="mt-4 px-4 py-2 text-sm text-primary-600 hover:text-primary-700"
>
返回列表
</button>
</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="/accident"
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
>
<ArrowLeft size={20} />
返回
</Link>
<h1 className="text-2xl font-bold text-slate-900">
意外記錄 #{id}
</h1>
</div>
</div>
{/* Record Details */}
<div className="card">
<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">Excel 原始數據</div>
</div>
</div>
</div>
<div className="divide-y divide-slate-100">
{headers.map(key => (
<div key={key} className="grid grid-cols-12">
<div className="col-span-3 px-4 py-3 text-sm font-medium text-slate-500 bg-slate-50">
{key}
</div>
<div className="col-span-9 px-4 py-3 text-sm text-slate-900">
{record[key] !== null && record[key] !== undefined
? String(record[key])
: '-'}
</div>
</div>
))}
</div>
</div>
</div>
)
}
+235
View File
@@ -0,0 +1,235 @@
import { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom'
import api from '../../api'
import toast from 'react-hot-toast'
import { Search, Eye, ChevronUp, ChevronDown, Filter, X } from 'lucide-react'
export default function AccidentList() {
const [records, setRecords] = useState([])
const [headers, setHeaders] = useState([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState(null)
const [sortDir, setSortDir] = useState('asc')
const [filterKey, setFilterKey] = useState('')
const [filterValue, setFilterValue] = useState('')
useEffect(() => {
fetchRecords()
}, [])
const fetchRecords = async () => {
setLoading(true)
try {
const { data } = await api.get('/api/accident?page=1&page_size=1000')
setRecords(data.data || [])
if (data.data && data.data.length > 0) {
const keys = Object.keys(data.data[0]).filter(k => k !== '_row')
setHeaders(keys)
}
} catch (err) {
toast.error('Failed to load records')
} finally {
setLoading(false)
}
}
// Filtered and sorted data
const processedData = useMemo(() => {
let result = [...records]
// Filter
if (filterKey && filterValue) {
result = result.filter(r => {
const val = String(r[filterKey] || '').toLowerCase()
return val.includes(filterValue.toLowerCase())
})
}
// Search
if (search) {
const searchLower = search.toLowerCase()
result = result.filter(r =>
Object.values(r).some(v =>
v && String(v).toLowerCase().includes(searchLower)
)
)
}
// Sort
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)) {
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} />
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-slate-900">意外記錄 Accident</h1>
<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>
{/* Filters */}
<div className="card p-3">
<div className="flex flex-wrap gap-3">
{/* Search */}
<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>
{/* Column Filter */}
<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>
{headers.slice(0, 6).map(h => (
<option key={h} value={h}>{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>
{/* Result count */}
<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>
{headers.slice(0, 6).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"
onClick={() => handleSort(h)}
>
<div className="flex items-center gap-1">
{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={8} className="px-3 py-6 text-center text-slate-400">
載入中...
</td>
</tr>
) : processedData.length === 0 ? (
<tr>
<td colSpan={8} className="px-3 py-6 text-center text-slate-400">
沒有記錄
</td>
</tr>
) : (
processedData.map((record) => (
<tr
key={record._row}
className="hover:bg-slate-50 cursor-pointer"
onClick={() => window.location.href = `/accident/${record._row}`}
>
<td className="px-3 py-2 text-xs text-slate-400">{record._row}</td>
{headers.slice(0, 6).map(h => (
<td key={h} className="px-3 py-2 text-slate-700 max-w-xs truncate">
{record[h] !== null && record[h] !== undefined ? String(record[h]) : '-'}
</td>
))}
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
<Link
to={`/accident/${record._row}`}
className="p-1 text-slate-400 hover:text-primary-600 inline-block"
>
<Eye size={16} />
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
+129
View File
@@ -0,0 +1,129 @@
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 } from 'lucide-react'
export default function AttendanceDetail() {
const { id } = useParams()
const navigate = useNavigate()
const [record, setRecord] = useState(null)
const [headers, setHeaders] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetchRecord()
}, [id])
const fetchRecord = async () => {
setLoading(true)
setError(null)
try {
// Get all records to find the one with this row id
const { data } = await api.get(`/api/attendance?page=1&page_size=1000`)
if (data.data && data.data.length > 0) {
// Extract headers from first record
const keys = Object.keys(data.data[0]).filter(k => k !== '_row')
setHeaders(keys)
// Find the record with matching _row
const found = data.data.find(r => r._row === parseInt(id))
if (found) {
setRecord(found)
} else {
setError('Record not found')
}
} else {
setError('No data available')
}
} catch (err) {
console.error('Error fetching record:', err)
setError('Failed to load record')
} 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="/attendance"
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">
<div className="text-red-500">{error}</div>
<button
onClick={() => navigate('/attendance')}
className="mt-4 px-4 py-2 text-sm text-primary-600 hover:text-primary-700"
>
返回列表
</button>
</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-2xl font-bold text-slate-900">
出勤記錄 #{id}
</h1>
</div>
</div>
{/* Record Details */}
<div className="card">
<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">Excel 原始數據</div>
</div>
</div>
</div>
<div className="divide-y divide-slate-100">
{headers.map(key => (
<div key={key} className="grid grid-cols-12">
<div className="col-span-3 px-4 py-3 text-sm font-medium text-slate-500 bg-slate-50">
{key}
</div>
<div className="col-span-9 px-4 py-3 text-sm text-slate-900">
{record[key] !== null && record[key] !== undefined
? String(record[key])
: '-'}
</div>
</div>
))}
</div>
</div>
</div>
)
}
+301
View File
@@ -0,0 +1,301 @@
import { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom'
import api from '../../api'
import toast from 'react-hot-toast'
import { Search, Eye, ChevronUp, ChevronDown, Filter, X, AlertCircle, Clock } from 'lucide-react'
export default function AttendanceList() {
const [records, setRecords] = useState([])
const [headers, setHeaders] = useState([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState(null)
const [sortDir, setSortDir] = useState('asc')
const [filterKey, setFilterKey] = useState('')
const [filterValue, setFilterValue] = useState('')
const [latenessRecords, setLatenessRecords] = useState([])
useEffect(() => {
fetchRecords()
fetchLateness()
}, [])
const fetchRecords = async () => {
setLoading(true)
try {
const { data } = await api.get('/api/attendance?page=1&page_size=1000')
setRecords(data.data || [])
if (data.data && data.data.length > 0) {
const keys = Object.keys(data.data[0]).filter(k => k !== '_row')
setHeaders(keys)
}
} catch (err) {
toast.error('Failed to load records')
} finally {
setLoading(false)
}
}
const fetchLateness = async () => {
try {
const { data } = await api.get('/api/attendance/lateness/records')
setLatenessRecords(data || [])
} catch (err) {
console.error('Failed to load lateness data:', err)
}
}
// Create a map of late records for quick lookup
const latenessMap = useMemo(() => {
const map = {}
latenessRecords.forEach(rec => {
const key = `${rec.staff}-${rec.date}`
map[key] = rec
})
return map
}, [latenessRecords])
// Filtered and sorted data
const processedData = useMemo(() => {
let result = [...records]
// Filter
if (filterKey && filterValue) {
result = result.filter(r => {
const val = String(r[filterKey] || '').toLowerCase()
return val.includes(filterValue.toLowerCase())
})
}
// Search
if (search) {
const searchLower = search.toLowerCase()
result = result.filter(r =>
Object.values(r).some(v =>
v && String(v).toLowerCase().includes(searchLower)
)
)
}
// Sort
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)) {
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} />
}
// Get lateness info for a record
const getLatenessInfo = (record) => {
// Find staff name and date columns
const staffKey = Object.keys(record).find(k =>
k.toLowerCase().includes('staff') || k.toLowerCase().includes('name') || k.toLowerCase().includes('員工')
)
const dateKey = Object.keys(record).find(k =>
k.toLowerCase().includes('date') || k.toLowerCase().includes('日期')
)
if (!staffKey || !dateKey) return null
const staff = record[staffKey]
const date = record[dateKey]
if (!staff || !date) return null
const key = `${staff}-${String(date).slice(0, 10)}`
return latenessMap[key]
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-slate-900">出勤記錄 Attendance</h1>
<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>
{/* Filters */}
<div className="card p-3">
<div className="flex flex-wrap gap-3">
{/* Search */}
<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>
{/* Column Filter */}
<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>
{headers.slice(0, 6).map(h => (
<option key={h} value={h}>{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>
{/* Result count */}
<div className="text-sm text-slate-500 py-2">
{processedData.length} 筆記錄
{latenessRecords.length > 0 && (
<span className="ml-2 text-orange-600">
{latenessRecords.length} 筆遲到
</span>
)}
</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>
{headers.slice(0, 6).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"
onClick={() => handleSort(h)}
>
<div className="flex items-center gap-1">
{h}
{getSortIcon(h)}
</div>
</th>
))}
<th className="px-3 py-2 text-center text-xs font-semibold text-slate-600 w-20">狀態</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={9} className="px-3 py-6 text-center text-slate-400">
載入中...
</td>
</tr>
) : processedData.length === 0 ? (
<tr>
<td colSpan={9} className="px-3 py-6 text-center text-slate-400">
沒有記錄
</td>
</tr>
) : (
processedData.map((record) => {
const lateInfo = getLatenessInfo(record)
const isLate = lateInfo && lateInfo.minutes_late > 0
return (
<tr
key={record._row}
className={`hover:bg-slate-50 cursor-pointer ${isLate ? 'bg-orange-50' : ''}`}
onClick={() => window.location.href = `/attendance/${record._row}`}
>
<td className="px-3 py-2 text-xs text-slate-400">{record._row}</td>
{headers.slice(0, 6).map(h => (
<td key={h} className="px-3 py-2 text-slate-700 max-w-xs truncate">
{record[h] !== null && record[h] !== undefined ? String(record[h]) : '-'}
</td>
))}
<td className="px-3 py-2 text-center">
{isLate ? (
<div className="flex items-center justify-center gap-1 text-orange-600" title={`遲到 ${lateInfo.minutes_late} 分鐘`}>
<AlertCircle size={14} />
<span className="text-xs font-medium">{lateInfo.minutes_late}</span>
</div>
) : (
<div className="flex items-center justify-center gap-1 text-green-600">
<Clock size={14} />
<span className="text-xs">正常</span>
</div>
)}
</td>
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
<Link
to={`/attendance/${record._row}`}
className="p-1 text-slate-400 hover:text-primary-600 inline-block"
>
<Eye size={16} />
</Link>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
+394
View File
@@ -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>
)
}
+169
View File
@@ -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-2xl font-bold 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>
)
}
+278
View File
@@ -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); setInfo(null); loadSectionInfo() }}
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>
)
}