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:
@@ -0,0 +1,17 @@
|
||||
<!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.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.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>
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "aars-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"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,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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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-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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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