Initial commit: AARS backend + frontend + holiday/leave phase 1+2

This commit is contained in:
IT Dog
2026-07-19 20:10:15 +08:00
commit bbc0048c24
49 changed files with 13375 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
logs/
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js"></script>
<title>AARS - Attendance & Accident Record System</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+3380
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "aars-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"logs:tail": "tail -f /root/aars/logs/app-*.jsonl | sed -u 's/\\\\n/\\\\n/g' | jq -c . 2>/dev/null || tail -f /root/aars/logs/app-*.jsonl",
"logs:errors": "tail -f /root/aars/logs/error-*.jsonl | jq -c . 2>/dev/null || tail -f /root/aars/logs/error-*.jsonl",
"logs:access": "tail -f /root/aars/logs/access-*.jsonl | jq -c . 2>/dev/null || tail -f /root/aars/logs/access-*.jsonl",
"logs:search": "grep -hE -- \"$@\" /root/aars/logs/app-*.jsonl /root/aars/logs/error-*.jsonl /root/aars/logs/access-*.jsonl | jq -c . 2>/dev/null || true",
"logs:clean": "bash /root/aars/scripts/logs-cleanup.sh"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"axios": "^1.7.7",
"react-hot-toast": "^2.4.1",
"recharts": "^2.12.7",
"lucide-react": "^0.441.0"
},
"devDependencies": {
"@types/react": "^18.3.8",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.45",
"tailwindcss": "^3.4.10",
"vite": "^5.4.6"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+57
View File
@@ -0,0 +1,57 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuth } from './context/AuthContext'
import Layout from './components/Layout'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import AttendanceList from './pages/attendance/List'
import AttendanceDetail from './pages/attendance/Detail'
import AccidentDashboard from './pages/accident/Dashboard'
import AccidentList from './pages/accident/List'
import AccidentDetail from './pages/accident/Detail'
import UploadPage from './pages/upload/Upload'
import RosterPage from './pages/roster/Roster'
import HolidaysPage from './pages/holidays/Holidays'
function ProtectedRoute({ children }) {
const { user, loading } = useAuth()
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-pulse text-slate-500">Loading...</div>
</div>
)
}
if (!user) {
return <Navigate to="/login" replace />
}
return children
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<Dashboard />} />
<Route path="attendance" element={<AttendanceList />} />
<Route path="attendance/:id" element={<AttendanceDetail />} />
<Route path="accident" element={<AccidentDashboard />} />
<Route path="accident/list" element={<AccidentList />} />
<Route path="accident/:id" element={<AccidentDetail />} />
<Route path="upload" element={<UploadPage />} />
<Route path="roster" element={<RosterPage />} />
<Route path="holidays" element={<HolidaysPage />} />
</Route>
</Routes>
)
}
+53
View File
@@ -0,0 +1,53 @@
import axios from 'axios'
import { setCurrentRequestId } from './lib/logger.js'
const api = axios.create({
baseURL: '',
timeout: 30000,
})
// Generate a request id per outgoing call (for cross-correlation)
function newRequestId() {
// RFC4122-ish: use crypto if available
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID()
}
return 'req-' + Math.random().toString(36).slice(2) + Date.now().toString(36)
}
// Request interceptor: auth + X-Request-ID
api.interceptors.request.use(
config => {
const token = localStorage.getItem('aars_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
config.headers['X-Request-ID'] = newRequestId()
return config
},
error => Promise.reject(error)
)
// Response interceptor: capture server's X-Request-ID for log correlation + 401 handling
api.interceptors.response.use(
response => {
const rid = response.headers['x-request-id']
if (rid) setCurrentRequestId(rid)
return response
},
error => {
const rid = error.response?.headers?.['x-request-id']
if (rid) setCurrentRequestId(rid)
if (error.response?.status === 401) {
localStorage.removeItem('aars_token')
// Avoid loop if already on /login
if (!window.location.pathname.startsWith('/login')) {
window.location.href = '/login'
}
}
return Promise.reject(error)
}
)
export default api
+73
View File
@@ -0,0 +1,73 @@
import React from 'react'
import { logger } from '../lib/logger.js'
export default class ErrorBoundary extends React.Component {
constructor(props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error) {
return { hasError: true, error }
}
componentDidCatch(error, errorInfo) {
logger.error('React component error', {
stack: (error?.stack || '') + '\n\nComponent stack:\n' + (errorInfo?.componentStack || ''),
})
}
render() {
if (this.state.hasError) {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
fontFamily: 'sans-serif',
background: '#f8fafc',
color: '#0f172a',
}}>
<div style={{ maxWidth: 600, textAlign: 'center' }}>
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>
頁面發生錯誤 / Something went wrong
</h1>
<p style={{ color: '#64748b', marginBottom: '1.5rem' }}>
已經將錯誤自動回報俾 IT狗 reload 或者返回上一頁
</p>
<button
onClick={() => window.location.reload()}
style={{
padding: '0.5rem 1rem',
background: '#2563eb',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: 'pointer',
}}
>
Reload Page
</button>
{this.state.error?.message && (
<pre style={{
marginTop: '1.5rem',
padding: '1rem',
background: '#f1f5f9',
borderRadius: '0.375rem',
fontSize: '0.75rem',
textAlign: 'left',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}>
{this.state.error.message}
</pre>
)}
</div>
</div>
)
}
return this.props.children
}
}
+194
View File
@@ -0,0 +1,194 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
import { useState } from 'react'
import { useAuth } from '../context/AuthContext'
import { LogOut, Users, AlertTriangle, LayoutDashboard, Upload, Calendar, Menu, X, Palmtree } from 'lucide-react'
const NAV_ITEMS = [
{ to: '/', end: true, icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/attendance', icon: Users, label: '出勤 Attendance' },
{ to: '/accident', icon: AlertTriangle, label: '意外 Accident' },
{ to: '/roster', icon: Calendar, label: '班次 Roster' },
{ to: '/holidays', icon: Palmtree, label: '假期 Holidays' },
]
export default function Layout() {
const { user, logout } = useAuth()
const navigate = useNavigate()
const [mobileOpen, setMobileOpen] = useState(false)
const handleLogout = () => {
logout()
navigate('/login')
}
const closeMobile = () => setMobileOpen(false)
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<header className="bg-white border-b border-slate-200 sticky top-0 z-40">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14 sm:h-16">
{/* Mobile hamburger (left side) */}
<button
onClick={() => setMobileOpen(true)}
className="md:hidden p-2 -ml-2 text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-md"
aria-label="Open menu"
>
<Menu size={20} />
</button>
{/* Logo */}
<div className="flex items-center gap-8">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">A</span>
</div>
<span className="text-xl font-bold text-slate-900">AARS</span>
</div>
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1">
{NAV_ITEMS.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<item.icon size={18} />
{item.label}
</NavLink>
))}
</nav>
</div>
{/* Right side */}
<div className="flex items-center gap-2 sm:gap-4">
<NavLink
to="/upload"
className={({ isActive }) =>
`hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<Upload size={16} />
<span className="hidden sm:inline">上傳</span>
</NavLink>
<div className="hidden sm:flex items-center gap-3 pl-4 border-l border-slate-200">
<div className="text-right hidden md:block">
<div className="text-sm font-medium text-slate-900">{user?.name}</div>
<div className="text-xs text-slate-500">{user?.role}</div>
</div>
<button
onClick={handleLogout}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-md transition-colors"
title="Logout"
>
<LogOut size={18} />
</button>
</div>
{/* Mobile logout button (always visible) */}
<button
onClick={handleLogout}
className="sm:hidden p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-md transition-colors"
title="Logout"
>
<LogOut size={18} />
</button>
</div>
</div>
</div>
</header>
{/* Mobile drawer overlay */}
{mobileOpen && (
<div
className="fixed inset-0 bg-black/50 z-50 md:hidden"
onClick={closeMobile}
/>
)}
{/* Mobile drawer panel */}
<div
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-xl transform transition-transform duration-200 ease-in-out md:hidden ${
mobileOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex items-center justify-between px-4 h-14 border-b border-slate-200">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">A</span>
</div>
<span className="text-lg font-bold text-slate-900">AARS</span>
</div>
<button
onClick={closeMobile}
className="p-2 text-slate-500 hover:text-slate-900 hover:bg-slate-100 rounded-md"
aria-label="Close menu"
>
<X size={20} />
</button>
</div>
<nav className="p-3 space-y-1">
{NAV_ITEMS.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
onClick={closeMobile}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<item.icon size={18} />
{item.label}
</NavLink>
))}
<NavLink
to="/upload"
onClick={closeMobile}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<Upload size={18} />
上傳
</NavLink>
</nav>
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-slate-200">
<div className="flex items-center gap-3">
<div className="text-sm">
<div className="font-medium text-slate-900">{user?.name}</div>
<div className="text-xs text-slate-500">{user?.role}</div>
</div>
</div>
</div>
</div>
{/* Main content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6 lg:py-8">
<Outlet />
</main>
</div>
)
}
+124
View File
@@ -0,0 +1,124 @@
import { CheckCircle, AlertCircle, Clock, X, AlertTriangle, Briefcase, Stethoscope, Timer, PartyPopper } from 'lucide-react'
/**
* Reusable status badge for attendance records.
* Handles all status codes including enriched leave types:
* normal / late / late_early / late_ot / early / early_ot / ot / abnormal / missing
* holiday / al / sl / cl / mixed_leave
*
* Props:
* - code: status_code string
* - text: status_text string (already enriched)
* - size: 'sm' | 'md' (default 'sm')
*/
export default function StatusBadge({ code, text = '', size = 'sm' }) {
const sizeCls = size === 'md'
? 'px-3 py-1 text-sm'
: 'px-2 py-0.5 text-xs'
// Split text into main + leave parts (after ' + ' separator)
const parts = text.split(/\s*\+\s*/).filter(Boolean)
const mainText = parts[0] || ''
const leaveParts = parts.slice(1)
const badge = (icon, label, colorCls) => (
<span className={`inline-flex items-center gap-1 ${sizeCls} rounded-full font-medium ${colorCls}`}>
{icon}
{label}
</span>
)
// ============ Attendance status badges ============
if (code === 'normal') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<CheckCircle size={12} />, mainText || '正常', 'bg-green-100 text-green-700')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
+ {lp}
</span>
))}
</span>
)
}
if (code === 'late' || code === 'late_early' || code === 'late_ot') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<AlertCircle size={12} />, mainText || code, 'bg-orange-100 text-orange-700')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
+ {lp}
</span>
))}
</span>
)
}
if (code === 'early' || code === 'early_ot') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<Clock size={12} />, mainText || code, 'bg-blue-100 text-blue-700')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
+ {lp}
</span>
))}
</span>
)
}
if (code === 'ot') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<Clock size={12} />, mainText || '加班', 'bg-purple-100 text-purple-700')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
+ {lp}
</span>
))}
</span>
)
}
if (code === 'abnormal') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<AlertTriangle size={12} />, mainText || '⚠️異常', 'bg-red-100 text-red-700')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
+ {lp}
</span>
))}
</span>
)
}
if (code === 'missing') {
return (
<span className="inline-flex items-center gap-1.5">
{badge(<X size={12} />, '缺勤', 'bg-gray-200 text-gray-600')}
{leaveParts.map((lp, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
{lp}
</span>
))}
</span>
)
}
// ============ Enriched leave status badges ============
if (code === 'holiday') {
return badge(<PartyPopper size={12} />, mainText, 'bg-amber-100 text-amber-700')
}
if (code === 'al') {
return badge(<Briefcase size={12} />, mainText || '年假', 'bg-emerald-100 text-emerald-700')
}
if (code === 'sl') {
return badge(<Stethoscope size={12} />, mainText || '病假', 'bg-pink-100 text-pink-700')
}
if (code === 'cl') {
return badge(<Timer size={12} />, mainText || '補鐘', 'bg-indigo-100 text-indigo-700')
}
if (code === 'mixed_leave') {
return badge(<Timer size={12} />, mainText || '混合假', 'bg-fuchsia-100 text-fuchsia-700')
}
// fallback
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs text-slate-500 bg-slate-100`}>{text || code || '-'}</span>
}
+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;
}
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Frontend logger for AARS.
*
* - Captures console.error, unhandledrejection, ErrorBoundary errors
* - Redacts secrets client-side before sending
* - Buffers, flushes via navigator.sendBeacon on pagehide
* - Posts to /api/client-logs (server-side correlation via X-Request-ID)
*/
const ENDPOINT = '/api/client-logs'
const APP_VERSION = (typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '1.0.0')
const FLUSH_INTERVAL_MS = 10_000
const MAX_BUFFER = 50
// ============ Redactor ============
const REDACT_KEYS = new Set([
'password', 'passwd', 'pwd', 'pass',
'token', 'access_token', 'refresh_token', 'id_token',
'jwt', 'bearer', 'authorization',
'api_key', 'apikey', 'secret', 'client_secret',
'cookie', 'set-cookie', 'session',
])
function redactString(s) {
if (typeof s !== 'string') return s
// JWT-like
s = s.replace(/eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+/g, '[REDACTED:JWT]')
// Bearer tokens
s = s.replace(/(bearer\s+)[A-Za-z0-9\-_.]+/gi, '$1[REDACTED]')
return s
}
function redact(obj) {
if (obj == null) return obj
if (typeof obj === 'string') return redactString(obj)
if (Array.isArray(obj)) return obj.map(redact)
if (typeof obj === 'object') {
const out = {}
for (const k of Object.keys(obj)) {
if (REDACT_KEYS.has(k.toLowerCase())) {
out[k] = '[REDACTED]'
} else {
out[k] = redact(obj[k])
}
}
return out
}
return obj
}
// ============ Buffer ============
const buffer = []
let flushTimer = null
function push(level, msg, extras = {}) {
const entry = {
level,
msg: redactString(String(msg).slice(0, 2000)),
stack: extras.stack ? redactString(String(extras.stack).slice(0, 8000)) : undefined,
page_url: typeof window !== 'undefined' ? window.location.href : undefined,
app_version: APP_VERSION,
request_id: getCurrentRequestId(),
browser: typeof navigator !== 'undefined' ? navigator.userAgent.slice(0, 500) : undefined,
ts: new Date().toISOString(),
}
// Drop undefined fields
Object.keys(entry).forEach(k => entry[k] === undefined && delete entry[k])
buffer.push(entry)
if (buffer.length >= MAX_BUFFER) flush()
}
function flush() {
if (!buffer.length) return
const entries = buffer.splice(0, buffer.length)
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
try {
const blob = new Blob([JSON.stringify(entries)], { type: 'application/json' })
navigator.sendBeacon(ENDPOINT, blob)
return
} catch (_) { /* fall through */ }
}
// Fallback: fetch (best-effort)
if (typeof fetch !== 'undefined') {
fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entries),
keepalive: true,
}).catch(() => { /* swallow */ })
}
}
// ============ Current request_id ============
let currentRequestId = null
export function setCurrentRequestId(rid) { currentRequestId = rid }
function getCurrentRequestId() { return currentRequestId }
// ============ Public API ============
export const logger = {
debug: (msg, ex) => push('debug', msg, ex),
info: (msg, ex) => push('info', msg, ex),
warn: (msg, ex) => push('warn', msg, ex),
error: (msg, ex) => push('error', msg, ex),
flush,
}
// ============ Global error hooks ============
export function installGlobalHandlers() {
if (typeof window === 'undefined') return
// Capture uncaught errors
window.addEventListener('error', (event) => {
logger.error('Uncaught error', {
stack: event.error?.stack || `${event.message} at ${event.filename}:${event.lineno}:${event.colno}`,
})
})
// Capture unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason
const msg = reason?.message || String(reason)
const stack = reason?.stack || `Unhandled rejection: ${msg}`
logger.error('Unhandled promise rejection', { stack })
})
// Flush on page hide
window.addEventListener('pagehide', () => flush())
window.addEventListener('beforeunload', () => flush())
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush()
})
// Periodic flush
if (flushTimer) clearInterval(flushTimer)
flushTimer = setInterval(flush, FLUSH_INTERVAL_MS)
}
export { redact }
+25
View File
@@ -0,0 +1,25 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
import { BrowserRouter } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { AuthProvider } from './context/AuthContext.jsx'
import ErrorBoundary from './components/ErrorBoundary.jsx'
import { installGlobalHandlers } from './lib/logger.js'
// Install global error handlers before mounting
installGlobalHandlers()
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ErrorBoundary>
<BrowserRouter>
<AuthProvider>
<App />
<Toaster position="bottom-right" />
</AuthProvider>
</BrowserRouter>
</ErrorBoundary>
</React.StrictMode>,
)
+646
View File
@@ -0,0 +1,646 @@
import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import api from '../api'
import toast from 'react-hot-toast'
import { Users, AlertTriangle, Clock, CheckCircle, XCircle, Calendar, TrendingUp, ArrowRight, Award, AlertCircle, Clock3, Download, FileSpreadsheet, FileBarChart } from 'lucide-react'
export default function Dashboard() {
const [summary, setSummary] = useState(null)
const [stats, setStats] = useState(null)
const [loading, setLoading] = useState(true)
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [exporting, setExporting] = useState(false)
const pieRef = useRef(null)
const barRef = useRef(null)
const pieChart = useRef(null)
const barChart = useRef(null)
const dashboardRef = useRef(null)
useEffect(() => {
fetchData()
}, [dateFrom, dateTo])
useEffect(() => {
return () => {
if (pieChart.current) pieChart.current.destroy()
if (barChart.current) barChart.current.destroy()
}
}, [])
useEffect(() => {
if (summary && stats && !loading) {
renderCharts()
}
}, [summary, stats, loading])
const fetchData = async () => {
setLoading(true)
try {
const params = {}
if (dateFrom) params.date_from = dateFrom
if (dateTo) params.date_to = dateTo
const [sumRes, statRes] = await Promise.all([
api.get('/api/dashboard/summary', { params }),
api.get('/api/attendance/stats', { params }).catch(() => ({ data: null }))
])
setSummary(sumRes.data)
setStats(statRes.data)
} catch (err) {
toast.error('載入失敗')
console.error(err)
} finally {
setLoading(false)
}
}
const renderCharts = () => {
if (!summary) return
if (pieChart.current) pieChart.current.destroy()
if (barChart.current) barChart.current.destroy()
const Chart = window.Chart
if (!Chart) return
const pieCtx = pieRef.current?.getContext('2d')
if (pieCtx) {
const statusData = [
{ label: '正常', value: summary.normal_count || 0, color: '#10B981' },
{ label: '遲到', value: summary.late_count || 0, color: '#F59E0B' },
{ label: '早退', value: summary.early_count || 0, color: '#3B82F6' },
{ label: 'OT', value: summary.ot_count || 0, color: '#8B5CF6' },
{ label: '異常', value: summary.abnormal_count || 0, color: '#EF4444' },
{ label: '缺勤', value: summary.missing_count || 0, color: '#6B7280' },
].filter(s => s.value > 0)
if (statusData.length > 0) {
pieChart.current = new Chart(pieCtx, {
type: 'doughnut',
data: {
labels: statusData.map(s => s.label),
datasets: [{ data: statusData.map(s => s.value), backgroundColor: statusData.map(s => s.color), borderWidth: 2, borderColor: '#fff' }]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { padding: 16, font: { size: 12 } } }, title: { display: true, text: '出勤狀態分佈', font: { size: 14, weight: '600' }, padding: { bottom: 12 } } }
}
})
}
}
const barCtx = barRef.current?.getContext('2d')
if (barCtx && stats?.stats?.length > 0) {
const staffData = stats.stats.slice(0, 8)
barChart.current = new Chart(barCtx, {
type: 'bar',
data: {
labels: staffData.map(s => s.name),
datasets: [{ label: '出勤記錄', data: staffData.map(s => s.total_records || s.total || 0), backgroundColor: '#2563EB', borderRadius: 4 }]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false }, title: { display: true, text: '員工出勤記錄數', font: { size: 14, weight: '600' }, padding: { bottom: 12 } } },
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } }
}
})
}
}
// Export PDF - capture dashboard as image then make PDF
const exportPDF = async () => {
if (!dashboardRef.current) { toast.error('找不到 Dashboard'); return }
setExporting(true)
try {
if (typeof window.jspdf === 'undefined') throw new Error('jspdf 未載入')
if (typeof window.Chart === 'undefined') throw new Error('Chart.js 未載入')
const { jsPDF } = window.jspdf
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' })
const PW = pdf.internal.pageSize.getWidth() // 297mm
const PH = pdf.internal.pageSize.getHeight() // 210mm
const M = 12
// ─── Title ───
pdf.setFontSize(18); pdf.setTextColor(15,23,42)
pdf.text('Attendance Report', M, 14)
const range = (dateFrom || dateTo) ? `${dateFrom||'開始'} ~ ${dateTo||'今日'}` : '全部'
const now = new Date().toLocaleString('en-US',{hour12:false,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'})
pdf.setFontSize(9); pdf.setTextColor(100,116,139)
pdf.text(`Date Range: ${range} | Generated: ${now}`, M, 20)
// ─── Summary Cards ───
const cards = summary ? [
{ label: 'Total', value: summary.total_records, color: [37,99,235] },
{ label: 'Normal', value: summary.normal_count, color: [16,185,129] },
{ label: 'Late', value: summary.late_count, color: [245,158,11] },
{ label: 'OT', value: summary.ot_count, color: [139,92,246] },
{ label: 'Abnormal', value: summary.abnormal_count, color: [239,68,68] },
{ label: 'Missing', value: summary.missing_count, color: [107,114,128] },
] : []
const cardW = (PW - M*2) / cards.length - 3
cards.forEach((card, i) => {
const cx = M + i*(cardW+3)
pdf.setFillColor(...card.color)
pdf.roundedRect(cx, 22, cardW, 16, 2, 2, 'F')
pdf.setFontSize(14); pdf.setTextColor(255,255,255)
pdf.text(String(card.value ?? 0), cx+cardW/2, 30, {align:'center'})
pdf.setFontSize(7); pdf.setTextColor(255,255,255)
pdf.text(card.label, cx+cardW/2, 34, {align:'center'})
})
// ─── Charts ───
const chartTop = 44
const chartH = 60
const chartW = (PW - M*2 - 8) / 2 // two charts side by side
// Helper: draw doughnut / pie chart
const drawPieChart = (cx, cy, r, data, colors, pdf) => {
const total = data.reduce((a,b)=>a+b, 0)
if (total === 0) return
let angle = -Math.PI/2
data.forEach((val, i) => {
const slice = (val/total) * 2*Math.PI
pdf.setFillColor(...colors[i])
pdf.setDrawColor(255,255,255)
pdf.setLineWidth(0.3)
const x1 = cx + r * Math.cos(angle)
const y1 = cy + r * Math.sin(angle)
const x2 = cx + r * Math.cos(angle + slice)
const y2 = cy + r * Math.sin(angle + slice)
if (val === total) {
pdf.setFillColor(...colors[i])
pdf.circle(cx, cy, r, 'F')
} else {
const pts = [[cx, cy], [x1, y1], [x2, y2]]
pdf.triangle(pts[0][0], pts[0][1], pts[1][0], pts[1][1], pts[2][0], pts[2][1], 'F')
}
angle += slice
})
// inner circle (donut hole)
pdf.setFillColor(255,255,255)
pdf.circle(cx, cy, r*0.5, 'F')
}
// Helper: draw bar chart
const drawBarChart = (bx, by, bw, bh, labels, values, barColor, pdf) => {
const max = Math.max(...values, 1)
const barW = bw / labels.length * 0.6
const gap = bw / labels.length * 0.4
labels.forEach((lbl, i) => {
const barH = (values[i]/max) * bh
const x = bx + i*(barW+gap) + gap/2
const y = by + bh - barH
pdf.setFillColor(...barColor)
pdf.roundedRect(x, y, barW, barH, 1, 1, 'F')
// value label
pdf.setFontSize(6); pdf.setTextColor(100,116,139)
pdf.text(String(values[i]), x+barW/2, by+bh+3.5, {align:'center'})
// x label (truncated)
pdf.setFontSize(6); pdf.setTextColor(100,116,139)
pdf.text(lbl.length > 6 ? lbl.substring(0,5)+'.' : lbl, x+barW/2, by+bh+6.5, {align:'center'})
})
}
// ── Pie: 出勤狀態分佈 ──
if (summary) {
const pieCX = M + chartW/2
const pieCY = chartTop + chartH/2
const pieR = chartH/2 - 4
const pieData = [
summary.normal_count || 0,
summary.late_count || 0,
summary.early_count || 0,
summary.ot_count || 0,
summary.abnormal_count || 0,
summary.missing_count || 0,
]
const pieColors = [[16,185,129],[245,158,11],[59,130,246],[139,92,246],[239,68,68],[107,114,128]]
const pieLabels = ['Normal','Late','Early','OT','Abnormal','Missing']
const pieLegendX = M
const pieLegendY = chartTop + chartH + 5
pdf.setFontSize(10); pdf.setTextColor(15,23,42)
pdf.text('Attendance Status Distribution', M, chartTop - 2)
// Draw pie
drawPieChart(pieCX, pieCY, pieR, pieData, pieColors, pdf)
// Legend
let lx = M
pieLabels.forEach((lbl, i) => {
if (pieData[i] === 0) return
pdf.setFillColor(...pieColors[i])
pdf.rect(lx, pieLegendY, 4, 4, 'F')
pdf.setFontSize(7.5); pdf.setTextColor(60,70,90)
pdf.text(`${lbl} ${pieData[i]}`, lx+5, pieLegendY+3.5)
lx += 28
})
}
// ── Bar: 員工出勤記錄 ──
if (stats?.stats?.length > 0) {
const barX = M + chartW + 8
const barLabels = stats.stats.slice(0,8).map(s=>s.name)
const barValues = stats.stats.slice(0,8).map(s=>s.total_records || s.total || 0)
pdf.setFontSize(10); pdf.setTextColor(15,23,42)
pdf.text('Staff Attendance Records', barX, chartTop - 2)
// Axis
const axisX = barX + 2
const axisY = chartTop + chartH - 2
const axisW = chartW - 4
const axisH = chartH - 8
pdf.setDrawColor(200,210,220)
pdf.setLineWidth(0.3)
pdf.line(axisX, axisY, axisX, axisY - axisH) // y axis
pdf.line(axisX, axisY, axisX + axisW, axisY) // x axis
drawBarChart(axisX, axisY - axisH, axisW, axisH, barLabels, barValues, [37,99,235], pdf)
}
// ─── Leaderboards ───
if (stats?.stats?.length > 0) {
const staff = stats.stats
const lbTop = chartTop + chartH + 14
const lateRank = [...staff].sort((a,b)=>(b.late_count||0)-(a.late_count||0)).slice(0,5)
const otRank = [...staff].sort((a,b)=>(b.ot_count||0)-(a.ot_count||0)).slice(0,5)
const missRank = [...staff].sort((a,b)=>(b.missing_count||0)-(a.missing_count||0)).slice(0,5)
const rateRank = [...staff].map(s=>({...s,rate:(s.total_records||s.total||0)>0?Math.round((((s.total_records||s.total||0)-(s.late_count||0)-(s.abnormal_count||0))/(s.total_records||s.total||0))*100):0})).sort((a,b)=>b.rate-a.rate).slice(0,5)
const lbW = (PW - M*2) / 4 - 4
const lbData = [
{ title: 'Late Rank', data: lateRank, key: 'late_count', sub: 'late_minutes', color: [245,158,11] },
{ title: 'OT Rank', data: otRank, key: 'ot_count', sub: 'ot_minutes', color: [139,92,246] },
{ title: 'Missing Rank', data: missRank, key: 'missing_count', sub: null, color: [107,114,128] },
{ title: 'Attendance Rate', data: rateRank, key: 'rate', sub: null, color: [16,185,129] },
]
lbData.forEach((lb, li) => {
const lx = M + li*(lbW+4)
// Box
pdf.setFillColor(...lb.color)
pdf.roundedRect(lx, lbTop, lbW, 6, 2, 2, 'F')
pdf.setFontSize(8); pdf.setTextColor(255,255,255)
const lines = lb.title.split('\n')
lines.forEach((l, li2) => pdf.text(l, lx+lbW/2, lbTop+3+li2*3, {align:'center'}))
// Items
lb.data.forEach((item, di) => {
const iy = lbTop + 10 + di*5
const medal = ['🥇','🥈','🥉'][di] || ` ${di+1}.`
const sub = lb.sub && item[lb.sub] ? ` (${item[lb.sub]}min)` : ''
const val = `${item[lb.key]}${lb.key==='rate'?'%':''}${sub}`
pdf.setFontSize(7.5); pdf.setTextColor(30,41,59)
pdf.text(`${medal} ${item.name} ${val}`, lx+2, iy)
})
})
}
// ─── Staff Table ───
if (stats?.stats?.length > 0) {
const tblTop = PW > 250 ? (chartTop + chartH + 14 + 35) : (chartTop + chartH + 14 + 30)
pdf.setFontSize(11); pdf.setTextColor(15,23,42)
pdf.text('Staff Summary', M, tblTop - 1)
const staff = stats.stats
const colW = [46, 18, 16, 20, 16, 20, 16, 16]
const cols = ['Name','Total','Late','LateMin','OT','OTMin','Abn','Miss']
let xPos = [M]
for (let ci=0; ci<colW.length-1; ci++) xPos.push(xPos[ci]+colW[ci])
const rowH = 6
// Header
pdf.setFillColor(50,60,80)
pdf.rect(M, tblTop, PW-M*2, rowH, 'F')
pdf.setFontSize(8); pdf.setTextColor(255,255,255)
cols.forEach((h,ci) => pdf.text(h, xPos[ci]+colW[ci]/2, tblTop+4.5, {align:'center'}))
// Rows
staff.forEach((s, ri) => {
const ry = tblTop + rowH*(ri+1)
if (ri%2===0) { pdf.setFillColor(248,250,252); pdf.rect(M, ry, PW-M*2, rowH, 'F') }
pdf.setFontSize(8); pdf.setTextColor(30,41,59)
const vals = [s.name, String(s.total_records || s.total || 0), String(s.late_count||0), String(s.late_minutes||'-'), String(s.ot_count||0), String(s.ot_minutes||'-'), String(s.abnormal_count||0), String(s.missing_count||0)]
vals.forEach((v,ci) => pdf.text(v, xPos[ci]+colW[ci]/2, ry+4.5, {align:'center'}))
})
}
pdf.save(`attendance_report_${dateFrom||'all'}_${dateTo||''}.pdf`.replace(/-/g,''))
toast.success('✅ PDF 已下載')
} catch (err) {
console.error('PDF error:', err)
toast.error('PDF 導出失敗: ' + err.message)
} finally {
setExporting(false)
}
}
const exportExcel = async (full) => {
setExporting(true)
try {
const token = localStorage.getItem('aars_token')
const params = full ? {} : (dateFrom ? { date_from: dateFrom, date_to: dateTo || new Date().toISOString().slice(0, 10) } : {})
const url = `/api/export/attendance/excel${Object.keys(params).length ? '?' + new URLSearchParams(params).toString() : ''}`
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
if (!resp.ok) throw new Error('Export failed')
const blob = await resp.blob()
const filename = full
? `attendance_full.xlsx`
: `attendance_${dateFrom || 'start'}_${dateTo || 'today'}.xlsx`.replace(/-/g, '')
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = filename
a.click()
URL.revokeObjectURL(a.href)
toast.success('✅ Excel 已下載')
} catch (err) {
console.error('Excel export error:', err)
toast.error('Excel 導出失敗')
} finally {
setExporting(false)
}
}
const byStaff = stats?.stats || []
const lateRank = [...byStaff].sort((a, b) => (b.late_count || 0) - (a.late_count || 0))
const otRank = [...byStaff].sort((a, b) => (b.ot_count || 0) - (a.ot_count || 0))
const abnormalRank = [...byStaff].sort((a, b) => (b.abnormal_count || 0) - (a.abnormal_count || 0))
const missingRank = [...byStaff].sort((a, b) => (b.missing_count || 0) - (a.missing_count || 0))
const attendanceRate = [...byStaff].map(s => ({
...s,
rate: (s.total_records || s.total || 0) > 0 ? Math.round(((((s.total_records || s.total || 0) - (s.late_count || 0) - (s.abnormal_count || 0))) / (s.total_records || s.total || 0)) * 100) : 0
})).sort((a, b) => b.rate - a.rate)
const statCards = summary ? [
{ label: '總記錄', value: summary.total_records, icon: Calendar, color: 'bg-blue-50 text-blue-600' },
{ label: '正常', value: summary.normal_count, icon: CheckCircle, color: 'bg-green-50 text-green-600' },
{ label: '遲到', value: summary.late_count, icon: Clock, color: 'bg-orange-50 text-orange-600' },
{ label: '早退', value: summary.early_count, icon: Clock3, color: 'bg-blue-50 text-blue-600' },
{ label: 'OT', value: summary.ot_count, icon: TrendingUp, color: 'bg-purple-50 text-purple-600' },
{ label: '異常', value: summary.abnormal_count, icon: AlertTriangle, color: 'bg-red-50 text-red-600' },
{ label: '缺勤', value: summary.missing_count, icon: XCircle, color: 'bg-gray-50 text-gray-600' },
{ label: '員工', value: summary.staff_count, icon: Users, color: 'bg-teal-50 text-teal-600' },
] : []
const today = new Date().toISOString().slice(0, 10)
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10)
// Date preset helpers
const fmt = (d) => d.toISOString().slice(0, 10)
const weekRange = (offsetWeeks) => {
// Monday = start of week
const now = new Date()
const dow = now.getDay() // Sun=0, Mon=1, ... Sat=6
const daysFromMonday = (dow + 6) % 7 // Mon=0, Tue=1, ..., Sun=6
const start = new Date(now)
start.setDate(now.getDate() - daysFromMonday + offsetWeeks * 7)
const end = new Date(start)
end.setDate(start.getDate() + 6)
return [fmt(start), fmt(end)]
}
// Map rankKey to Option 1 detail fields exposed by backend
const detailFieldsMap = {
late_count: [
{ key: 'late_minutes', label: '分鐘', unit: '分' },
{ key: 'late_avg_minutes', label: '平均', unit: '分' },
{ key: 'late_max_minutes', label: '最長', unit: '分' },
],
early_count: [
{ key: 'early_minutes', label: '分鐘', unit: '分' },
{ key: 'early_avg_minutes', label: '平均', unit: '分' },
{ key: 'early_max_minutes', label: '最長', unit: '分' },
],
ot_count: [
{ key: 'ot_minutes', label: '分鐘', unit: '分' },
{ key: 'ot_avg_minutes', label: '平均', unit: '分' },
{ key: 'ot_max_minutes', label: '最長', unit: '分' },
],
missing_count: [
{ key: 'consecutive_missing', label: '連續', unit: '次' },
{ key: 'attendance_rate', label: '出勤率', unit: '%', suffix: true },
{ key: 'last_attendance_date', label: '最後', isDate: true },
],
rate: [
{ key: 'total_records', label: '記錄', unit: '條' },
{ key: 'missing_count', label: '缺勤', unit: '次' },
],
}
const RankCard = ({ title, icon: Icon, iconColor, data, rankKey, subKey, unit, colorFn }) => {
const details = detailFieldsMap[rankKey] || []
const items = data.filter(d => d[rankKey] > 0 || rankKey === 'rate').slice(0, 6)
return (
<div className="card p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
<Icon size={16} style={{ color: iconColor }} /> {title}
</h3>
<div className="space-y-1">
{items.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-4">暫無數據</p>
) : items.map((s, i) => (
<div key={s.name} className="py-1.5 border-b border-slate-100 last:border-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold ${
i === 0 ? 'bg-yellow-100 text-yellow-700' : i === 1 ? 'bg-slate-100 text-slate-500' : i === 2 ? 'bg-orange-50 text-orange-400' : 'bg-slate-50 text-slate-400'
}`}>{i + 1}</span>
<span className="text-sm text-slate-700">{s.name}</span>
</div>
<div className="text-right">
<span className="text-sm font-bold" style={colorFn ? { color: colorFn(s) } : undefined}>
{rankKey === 'rate' ? `${s[rankKey]}%` : s[rankKey]}
</span>
{subKey && (s[subKey] || 0) > 0 && (
<span className="text-xs text-slate-400 ml-1">({s[subKey]}{unit || '次'})</span>
)}
</div>
</div>
{/* Option 1 detail row */}
{details.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1 ml-7 text-[11px] text-slate-500">
{details.map(d => {
const v = s[d.key]
let display
if (v === null || v === undefined || v === 0 && d.key !== 'attendance_rate') {
// Skip empty/zero for most fields, but show 0% rate
if (!(d.key === 'attendance_rate')) return null
display = `0${d.unit}`
} else if (d.isDate) {
display = `${d.label} ${v}`
} else if (d.suffix) {
// e.g. attendance_rate = 70 (already %)
display = `${d.label} ${v}${d.unit}`
} else {
display = `${d.label} ${v}${d.unit}`
}
// For attendance_rate, only show if rate exists
if (d.key === 'attendance_rate' && (v === null || v === undefined)) return null
// For last_attendance_date, only show if exists
if (d.isDate && !v) return null
// For consecutive_missing, only show if > 0 (otherwise skip)
if (d.key === 'consecutive_missing' && (v || 0) === 0) return null
return <span key={d.key}>{display}</span>
})}
</div>
)}
</div>
))}
</div>
</div>
)
}
return (
<div className="space-y-6" ref={dashboardRef}>
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-slate-900">Dashboard</h1>
<div className="flex items-center gap-2">
{/* Export Buttons */}
<div className="flex items-center gap-1 border border-slate-300 rounded-md overflow-hidden">
<button
onClick={exportPDF}
disabled={exporting}
className="flex items-center gap-1 px-3 py-2 text-sm bg-white text-slate-700 hover:bg-slate-50 disabled:opacity-50"
title="導出 PDF"
>
<FileBarChart size={14} /> PDF
</button>
<div className="w-px h-5 bg-slate-300" />
<button
onClick={() => exportExcel(false)}
disabled={exporting}
className="flex items-center gap-1 px-3 py-2 text-sm bg-white text-slate-700 hover:bg-slate-50 disabled:opacity-50"
title="導出 Excel (跟時間範圍)"
>
<FileSpreadsheet size={14} /> Excel
</button>
<div className="w-px h-5 bg-slate-300" />
<button
onClick={() => exportExcel(true)}
disabled={exporting}
className="flex items-center gap-1 px-3 py-2 text-sm bg-blue-50 text-blue-700 hover:bg-blue-100 disabled:opacity-50 font-medium"
title="導出 Excel (全部)"
>
<Download size={14} /> 全部
</button>
</div>
<Link to="/attendance" className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 text-sm rounded-md hover:bg-slate-200">
<ArrowRight size={16} /> 出勤記錄
</Link>
</div>
</div>
{/* Date Filter */}
<div className="card p-4">
<div className="flex flex-wrap gap-3 items-end">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500">日期範圍:</span>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" />
<span className="text-slate-400">-</span>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" />
</div>
<div className="flex gap-2 flex-wrap">
<button onClick={() => { setDateFrom(today); setDateTo(today) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">今日</button>
<button onClick={() => {
const [w0, w1] = weekRange(0)
setDateFrom(w0); setDateTo(w1)
}} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">本週</button>
<button onClick={() => {
const [w0, w1] = weekRange(-1)
setDateFrom(w0); setDateTo(w1)
}} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">上週</button>
<button onClick={() => { setDateFrom(monthStart); setDateTo(today) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">本月</button>
<button onClick={() => { setDateFrom(''); setDateTo('') }} className="text-xs text-slate-500 hover:text-slate-700 px-2 py-2 border border-slate-300 rounded">清除</button>
</div>
</div>
{(dateFrom || dateTo) && (
<p className="text-xs text-slate-400 mt-2">顯示 {dateFrom || '開始'} ~ {dateTo || today} 的數據</p>
)}
</div>
{loading ? (
<div className="text-center py-12 text-slate-400">載入中...</div>
) : summary ? (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{statCards.map(({ label, value, icon: Icon, color }) => (
<div key={label} className="card p-4">
<div className="flex items-center gap-3">
<div className={`p-2.5 rounded-lg ${color}`}><Icon size={20} /></div>
<div>
<div className="text-2xl font-bold text-slate-900">{value ?? 0}</div>
<div className="text-xs text-slate-500">{label}</div>
</div>
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="card p-4">
<div style={{ height: '260px', position: 'relative' }}><canvas ref={pieRef}></canvas></div>
</div>
<div className="card p-4">
<div style={{ height: '260px', position: 'relative' }}><canvas ref={barRef}></canvas></div>
</div>
</div>
{byStaff.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<RankCard title="遲到排行榜" icon={Clock} iconColor="#F59E0B" data={lateRank} rankKey="late_count" subKey="late_minutes" unit="分" colorFn={s => s.late_count > 0 ? '#F59E0B' : '#10B981'} />
<RankCard title="OT排行榜" icon={TrendingUp} iconColor="#8B5CF6" data={otRank} rankKey="ot_count" subKey="ot_minutes" unit="分" colorFn={s => s.ot_count > 0 ? '#8B5CF6' : '#10B981'} />
<RankCard title="缺勤排行榜" icon={XCircle} iconColor="#6B7280" data={missingRank} rankKey="missing_count" colorFn={s => s.missing_count > 0 ? '#EF4444' : '#10B981'} />
<RankCard title="出勤率排行榜" icon={Award} iconColor="#10B981" data={attendanceRate} rankKey="rate" colorFn={s => s.rate >= 90 ? '#10B981' : s.rate >= 70 ? '#F59E0B' : '#EF4444'} />
</div>
)}
{stats?.stats?.length > 0 && (
<div className="card p-4">
<h2 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
<Users size={16} /> 員工出勤概覽
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-600">員工</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">出勤</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">正常</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">遲到</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">遲分鐘</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">OT</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">OT分鐘</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">異常</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">缺勤</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{byStaff.map((s) => (
<tr key={s.name} className="hover:bg-slate-50">
<td className="px-3 py-2 font-medium text-slate-700">{s.name}</td>
<td className="px-3 py-2 text-right text-slate-600">{s.total_records ?? s.total ?? 0}</td>
<td className="px-3 py-2 text-right text-green-600">{(s.total_records ?? s.total ?? 0) - (s.late_count || 0) - (s.abnormal_count || 0)}</td>
<td className="px-3 py-2 text-right text-orange-600">{s.late_count || 0}</td>
<td className="px-3 py-2 text-right text-orange-400">{s.late_minutes || '-'}</td>
<td className="px-3 py-2 text-right text-purple-600">{s.ot_count || 0}</td>
<td className="px-3 py-2 text-right text-purple-400">{s.ot_minutes || '-'}</td>
<td className="px-3 py-2 text-right text-red-600">{s.abnormal_count || 0}</td>
<td className="px-3 py-2 text-right text-gray-500">{s.missing_count || 0}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
) : (
<div className="text-center py-12 text-slate-400">暫無數據</div>
)}
</div>
)
}
+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>
)
}
+291
View File
@@ -0,0 +1,291 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import { Link } from 'react-router-dom'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend, LineChart, Line } from 'recharts'
import { AlertTriangle, MapPin, Users, FileText, Calendar, TrendingUp, XCircle, CheckCircle2, Download, Search, ChevronUp, ChevronDown, Filter, X, Activity } from 'lucide-react'
import api from '../../api'
import toast from 'react-hot-toast'
const SEVERITY_COLORS = {
'1': { bg: '#D1FAE5', fg: '#065F46', label: '低 Low' },
'2': { bg: '#DBEAFE', fg: '#1E40AF', label: '中 Medium' },
'3': { bg: '#FEF3C7', fg: '#92400E', label: '高 High' },
'4': { bg: '#FED7AA', fg: '#9A3412', label: '嚴重 Severe' },
'5': { bg: '#FEE2E2', fg: '#7F1D1D', label: '極嚴重 Critical' },
}
export default function AccidentDashboard() {
const [summary, setSummary] = useState(null)
const [stats, setStats] = useState(null)
const [records, setRecords] = useState([])
const [loading, setLoading] = useState(true)
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const dashboardRef = useRef(null)
const fmt = (d) => d.toISOString().slice(0, 10)
const weekRange = (offsetWeeks) => {
const now = new Date()
const dow = now.getDay()
const daysFromMonday = (dow + 6) % 7
const start = new Date(now)
start.setDate(now.getDate() - daysFromMonday + offsetWeeks * 7)
const end = new Date(start)
end.setDate(start.getDate() + 6)
return [fmt(start), fmt(end)]
}
const today = new Date().toISOString().slice(0, 10)
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10)
useEffect(() => {
fetchData()
}, [dateFrom, dateTo])
const fetchData = async () => {
setLoading(true)
try {
const params = {}
if (dateFrom) params.date_from = dateFrom
if (dateTo) params.date_to = dateTo
const [sumRes, statRes, recRes] = await Promise.all([
api.get('/api/accident/dashboard/summary', { params }),
api.get('/api/accident/dashboard/stats', { params }),
api.get('/api/accident/records', { params: { ...params, per_page: 1000 } }).catch(() => ({ data: { records: [] } })),
])
setSummary(sumRes.data)
setStats(statRes.data)
setRecords(recRes.data.records || [])
} catch (err) {
toast.error('載入失敗')
console.error(err)
} finally {
setLoading(false)
}
}
const handleExportExcel = async () => {
try {
const params = {}
if (dateFrom) params.date_from = dateFrom
if (dateTo) params.date_to = dateTo
const response = await api.get('/api/accident/export/excel', { params, responseType: 'blob' })
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
let suffix = ''
if (dateFrom && dateTo) suffix = `_${dateFrom}_${dateTo}`
else if (dateFrom) suffix = `_from_${dateFrom}`
else if (dateTo) suffix = `_to_${dateTo}`
link.setAttribute('download', `accident${suffix}.xlsx`)
document.body.appendChild(link)
link.click()
link.remove()
toast.success('Excel 已下載')
} catch (err) {
toast.error('匯出失敗')
}
}
// Build donut chart data (severity distribution)
const severityData = useMemo(() => {
if (!summary?.severity_count) return []
return Object.entries(summary.severity_count)
.filter(([_, count]) => count > 0)
.map(([sev, count]) => ({
name: SEVERITY_COLORS[sev]?.label || `Sev ${sev}`,
value: count,
severity: sev,
color: SEVERITY_COLORS[sev]?.fg || '#6B7280',
}))
}, [summary])
// Build line chart (monthly trend)
const monthlyTrend = useMemo(() => {
const byMonth = {}
for (const r of records) {
if (!r.date) continue
const ym = r.date.substring(0, 7)
byMonth[ym] = (byMonth[ym] || 0) + 1
}
return Object.entries(byMonth)
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, count]) => ({ month, count }))
}, [records])
if (loading && !summary) {
return <div className="p-12 text-center text-slate-500">載入中</div>
}
return (
<div className="space-y-6" ref={dashboardRef}>
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-slate-900">意外事故 Dashboard</h1>
<div className="flex items-center gap-2">
<Link
to="/accident/list"
className="flex items-center gap-2 px-3 py-1.5 border border-slate-300 text-slate-700 rounded-md hover:bg-slate-50 text-sm"
>
<FileText size={16} />
列表
</Link>
<button
onClick={handleExportExcel}
className="flex items-center gap-2 px-3 py-1.5 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
>
<Download size={16} />
匯出 Excel
</button>
</div>
</div>
{/* Date Range Filter */}
<div className="card p-4">
<div className="flex flex-wrap gap-3 items-end">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500">日期範圍:</span>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" />
<span className="text-slate-400">-</span>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" />
</div>
<div className="flex gap-2 flex-wrap">
<button onClick={() => { setDateFrom(today); setDateTo(today) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">今日</button>
<button onClick={() => { const [w0, w1] = weekRange(0); setDateFrom(w0); setDateTo(w1) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">本週</button>
<button onClick={() => { const [w0, w1] = weekRange(-1); setDateFrom(w0); setDateTo(w1) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">上週</button>
<button onClick={() => { setDateFrom(monthStart); setDateTo(today) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">本月</button>
<button onClick={() => { setDateFrom(''); setDateTo('') }} className="text-xs text-slate-500 hover:text-slate-700 px-2 py-2 border border-slate-300 rounded">清除</button>
</div>
</div>
{(dateFrom || dateTo) && (
<p className="text-xs text-slate-400 mt-2">顯示 {dateFrom || '開始'} ~ {dateTo || today} 的數據</p>
)}
</div>
{/* Stats Cards */}
{summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard icon={AlertTriangle} color="bg-red-50 text-red-600" label="總記錄 Total" value={summary.total_records || 0} />
<StatCard icon={Calendar} color="bg-blue-50 text-blue-600" label="本月" value={summary.this_month || 0} />
<StatCard icon={MapPin} color="bg-purple-50 text-purple-600" label="地點 Locations" value={summary.unique_locations || 0} />
<StatCard icon={TrendingUp} color="bg-green-50 text-green-600" label="日均" value={summary.avg_per_day || 0} />
{Object.entries(summary.severity_count || {}).map(([sev, count]) => (
<StatCard
key={sev}
icon={Activity}
color=""
label={`Severity ${sev}`}
value={count}
overrideColor={SEVERITY_COLORS[sev]?.fg}
overrideBg={SEVERITY_COLORS[sev]?.bg}
/>
))}
</div>
)}
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Severity Distribution */}
<div className="card p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3">嚴重程度分佈</h3>
{severityData.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-12">暫無數據</p>
) : (
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie
data={severityData}
dataKey="value"
nameKey="name"
cx="50%" cy="50%"
outerRadius={80}
label={(entry) => `${entry.name}: ${entry.value}`}
>
{severityData.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
)}
</div>
{/* Monthly Trend */}
<div className="card p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3">月度趨勢</h3>
{monthlyTrend.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-12">暫無數據</p>
) : (
<ResponsiveContainer width="100%" height={250}>
<LineChart data={monthlyTrend}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="count" stroke="#2563EB" strokeWidth={2} dot={{ r: 4 }} />
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Leaderboards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<RankCard title="地點 (Top 5)" icon={MapPin} data={stats.by_location?.slice(0, 5) || []} field="count" />
<RankCard title="員工 (Top 5)" icon={Users} data={stats.by_employee?.slice(0, 5) || []} field="count" />
<RankCard title="部門 (Top 5)" icon={FileText} data={stats.by_department?.slice(0, 5) || []} field="count" />
<RankCard title="負責人 (Top 5)" icon={CheckCircle2} data={stats.by_responsible?.slice(0, 5) || []} field="count" />
</div>
)}
</div>
)
}
function StatCard({ icon: Icon, color, label, value, overrideColor, overrideBg }) {
return (
<div
className="card p-4"
style={overrideBg ? { backgroundColor: overrideBg } : undefined}
>
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-slate-500 mb-1">{label}</p>
<p className="text-2xl font-bold" style={overrideColor ? { color: overrideColor } : undefined}>{value}</p>
</div>
{Icon && (
<div className={`p-2 rounded-lg ${color}`}>
<Icon size={20} />
</div>
)}
</div>
</div>
)
}
function RankCard({ title, icon: Icon, data, field }) {
return (
<div className="card p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
<Icon size={16} /> {title}
</h3>
<div className="space-y-1">
{data.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-4">暫無數據</p>
) : data.map((s, i) => (
<div key={s.name} className="py-1.5 border-b border-slate-100 last:border-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold ${i === 0 ? 'bg-yellow-100 text-yellow-700' : i === 1 ? 'bg-slate-100 text-slate-500' : i === 2 ? 'bg-orange-50 text-orange-400' : 'bg-slate-50 text-slate-400'}`}>{i + 1}</span>
<span className="text-sm text-slate-700 truncate max-w-[160px]" title={s.name}>{s.name}</span>
</div>
<div className="text-right">
<span className="text-sm font-bold text-slate-700">{s[field]}</span>
<span className="text-xs text-slate-400 ml-2">avg {s.avg_severity || 0}</span>
</div>
</div>
</div>
))}
</div>
</div>
)
}
+170
View File
@@ -0,0 +1,170 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import api from '../../api'
import toast from 'react-hot-toast'
import { ArrowLeft, FileText, AlertTriangle } from 'lucide-react'
const SEVERITY_COLORS = {
'1': { bg: '#D1FAE5', fg: '#065F46', label: '低 Low' },
'2': { bg: '#DBEAFE', fg: '#1E40AF', label: '中 Medium' },
'3': { bg: '#FEF3C7', fg: '#92400E', label: '高 High' },
'4': { bg: '#FED7AA', fg: '#9A3412', label: '嚴重 Severe' },
'5': { bg: '#FEE2E2', fg: '#7F1D1D', label: '極嚴重 Critical' },
}
const FIELDS = [
{ key: 'date', label: '日期 Date' },
{ key: 'time', label: '時間 Time' },
{ key: 'severity', label: '嚴重程度 Severity', badge: true },
{ key: 'location', label: '地點 Location' },
{ key: 'employee_name', label: '員工/報告人 Employee' },
{ key: 'department', label: '部門 Department' },
{ key: 'description', label: '事件描述 Description' },
{ key: 'medical_report', label: '醫療報告 Medical Report' },
{ key: 'action_taken', label: '處理情況 Action Taken' },
{ key: 'responsible_person', label: '負責人 Responsible Person' },
{ key: 'created_at', label: '建立時間 Created At' },
]
export default function AccidentDetail() {
const { id } = useParams()
const navigate = useNavigate()
const [record, setRecord] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
const numericId = parseInt(id, 10)
if (isNaN(numericId) || numericId <= 0) {
navigate('/accident/list', { replace: true })
return
}
fetchRecord(numericId)
}, [id])
const fetchRecord = async (recordId) => {
setLoading(true)
setError(null)
try {
const { data } = await api.get(`/api/accident/${recordId}`)
setRecord(data)
} catch (err) {
console.error('Error fetching record:', err)
const detail = err.response?.data?.detail
// FastAPI 422 returns detail as array of validation errors — stringify safely
let msg = 'Failed to load record'
if (typeof detail === 'string') {
msg = detail
} else if (Array.isArray(detail)) {
msg = `Invalid request: ${detail.map(d => d.msg || JSON.stringify(d)).join('; ')}`
} else if (detail && typeof detail === 'object') {
msg = JSON.stringify(detail)
} else if (err.response?.status === 404) {
msg = 'Record not found'
}
setError(msg)
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-slate-500">載入中...</div>
</div>
)
}
if (error) {
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<Link
to="/accident/list"
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
>
<ArrowLeft size={20} />
返回列表
</Link>
</div>
<div className="card p-8 text-center">
<AlertTriangle size={48} className="mx-auto text-red-400 mb-2" />
<div className="text-red-500 mb-2">{error}</div>
<button
onClick={() => navigate('/accident/list')}
className="mt-4 px-4 py-2 text-sm text-primary-600 hover:text-primary-700"
>
返回列表
</button>
</div>
</div>
)
}
const sev = String(record.severity || '')
const sevColor = SEVERITY_COLORS[sev] || { bg: '#F3F4F6', fg: '#374151', label: sev || '-' }
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3 mb-4 sm:mb-6">
<div className="flex items-center gap-4">
<Link
to="/accident/list"
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
>
<ArrowLeft size={20} />
返回
</Link>
<h1 className="text-xl sm:text-2xl font-bold text-slate-900">
意外記錄 #{id}
</h1>
</div>
<span
className="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold"
style={{ backgroundColor: sevColor.bg, color: sevColor.fg }}
>
Severity {sev} · {sevColor.label}
</span>
</div>
<div className="card overflow-hidden">
<div className="p-4 border-b border-slate-200 bg-slate-50">
<div className="flex items-center gap-3">
<FileText size={24} className="text-slate-400" />
<div>
<div className="font-medium text-slate-900">記錄詳情</div>
<div className="text-sm text-slate-500">AARS 意外事故記錄</div>
</div>
</div>
</div>
<div className="divide-y divide-slate-100">
{FIELDS.map(({ key, label, badge }) => {
const val = record[key]
const display = val !== null && val !== undefined && val !== '' ? String(val) : '-'
return (
<div key={key} className="grid grid-cols-1 sm:grid-cols-12">
<div className="sm:col-span-3 px-4 py-3 text-sm font-medium text-slate-500 bg-slate-50">
{label}
</div>
<div className="sm:col-span-9 px-4 py-3 text-sm text-slate-900 break-words">
{badge && sev !== '' ? (
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold"
style={{ backgroundColor: sevColor.bg, color: sevColor.fg }}
>
{sev} · {sevColor.label}
</span>
) : (
display
)}
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
+323
View File
@@ -0,0 +1,323 @@
import { useState, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { Link } from 'react-router-dom'
import api from '../../api'
import toast from 'react-hot-toast'
import { Search, Eye, ChevronUp, ChevronDown, Filter, X, Settings2, RotateCcw } from 'lucide-react'
export default function AccidentList() {
const navigate = useNavigate()
const [records, setRecords] = useState([])
const [excelHeaders, setExcelHeaders] = useState({}) // sql_col -> Excel chinese header
const [columnOrder, setColumnOrder] = useState([]) // ordered SQL cols (Excel order)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState('date')
const [sortDir, setSortDir] = useState('desc')
const [filterKey, setFilterKey] = useState('')
const [filterValue, setFilterValue] = useState('')
// Column visibility — localStorage persisted
const STORAGE_KEY = 'aars.accident.visibleColumns.v1'
const [visibleCols, setVisibleCols] = useState(() => {
try {
const s = localStorage.getItem(STORAGE_KEY)
if (s) return JSON.parse(s)
} catch {}
return null // null = show all
})
const [showColumnSettings, setShowColumnSettings] = useState(false)
useEffect(() => {
fetchRecords()
}, [])
useEffect(() => {
if (visibleCols !== null) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(visibleCols))
}
}, [visibleCols])
const fetchRecords = async () => {
setLoading(true)
try {
const { data } = await api.get('/api/accident/records?per_page=1000')
setRecords(data.records || [])
const eh = data.excel_headers || {}
const co = data.column_order || []
setExcelHeaders(eh)
setColumnOrder(co)
// Default sort
if (data.records && data.records.length > 0 && !sortKey) {
setSortKey('date')
setSortDir('desc')
}
} catch (err) {
toast.error('Failed to load records')
console.error(err)
} finally {
setLoading(false)
}
}
// The set of columns to display: filtered by visibleCols (if not null)
const displayedCols = useMemo(() => {
let cols = columnOrder.filter(c => c !== 'id')
if (visibleCols !== null) {
cols = cols.filter(c => visibleCols[c])
}
return cols
}, [columnOrder, visibleCols])
// Helper: get display name for a SQL column (uses Excel header, trims trailing spaces)
const displayName = (col) => {
const h = excelHeaders[col]
return (h ? String(h).trim() : col) || col
}
// Filtered and sorted data
const processedData = useMemo(() => {
let result = [...records]
if (filterKey && filterValue) {
result = result.filter(r => {
const val = String(r[filterKey] || '').toLowerCase()
return val.includes(filterValue.toLowerCase())
})
}
if (search) {
const searchLower = search.toLowerCase()
result = result.filter(r =>
Object.values(r).some(v =>
v && String(v).toLowerCase().includes(searchLower)
)
)
}
if (sortKey) {
result.sort((a, b) => {
const aVal = a[sortKey] || ''
const bVal = b[sortKey] || ''
const aNum = Number(aVal)
const bNum = Number(bVal)
let cmp = 0
if (!isNaN(aNum) && !isNaN(bNum) && aVal !== '' && bVal !== '') {
cmp = aNum - bNum
} else {
cmp = String(aVal).localeCompare(String(bVal))
}
return sortDir === 'asc' ? cmp : -cmp
})
}
return result
}, [records, search, sortKey, sortDir, filterKey, filterValue])
const handleSort = (key) => {
if (sortKey === key) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
} else {
setSortKey(key)
setSortDir('asc')
}
}
const handleFilter = (key) => {
setFilterKey(key)
setFilterValue('')
}
const clearFilter = () => {
setFilterKey('')
setFilterValue('')
}
const getSortIcon = (key) => {
if (sortKey !== key) return null
return sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
}
const toggleColumn = (col) => {
const next = visibleCols ? { ...visibleCols } : {}
// Initialize all columns to true first
columnOrder.forEach(c => { if (next[c] === undefined) next[c] = true })
next[col] = !next[col]
setVisibleCols(next)
}
const resetColumns = () => {
setVisibleCols(null)
localStorage.removeItem(STORAGE_KEY)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-lg sm:text-xl font-bold text-slate-900">意外記錄 Accident</h1>
<div className="flex gap-2">
<button
onClick={() => setShowColumnSettings(!showColumnSettings)}
className={`flex items-center gap-2 px-3 py-2 text-sm rounded-md border ${
showColumnSettings
? 'bg-slate-100 border-slate-400 text-slate-800'
: 'border-slate-300 text-slate-600 hover:bg-slate-50'
}`}
>
<Settings2 size={16} />
欄位 ({displayedCols.length}/{columnOrder.filter(c => c !== 'id').length})
</button>
<Link
to="/upload"
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700"
>
上傳 Excel
</Link>
</div>
</div>
{/* Column Settings Panel */}
{showColumnSettings && (
<div className="card p-4 border-l-4 border-primary-500">
<div className="flex items-center justify-between mb-3">
<div className="text-sm font-semibold text-slate-700">
選擇顯示嘅欄位 (來源: Excel)
</div>
<button
onClick={resetColumns}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-800"
>
<RotateCcw size={12} />
重設全部顯示
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{columnOrder.filter(c => c !== 'id').map(col => {
const isVisible = visibleCols === null || (visibleCols[col] !== false)
return (
<label key={col} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-slate-50 px-2 py-1 rounded">
<input
type="checkbox"
checked={isVisible}
onChange={() => toggleColumn(col)}
className="rounded"
/>
<span className="text-slate-700">{displayName(col)}</span>
<span className="text-slate-400 text-[10px]">({col})</span>
</label>
)
})}
</div>
</div>
)}
{/* Filters */}
<div className="card p-3">
<div className="flex flex-wrap gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜尋所有欄位..."
className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-md"
/>
</div>
<div className="flex items-center gap-2">
<Filter size={16} className="text-slate-400" />
<select
value={filterKey}
onChange={(e) => handleFilter(e.target.value)}
className="text-sm border border-slate-300 rounded-md px-2 py-2"
>
<option value="">篩選欄位</option>
{displayedCols.map(h => (
<option key={h} value={h}>{displayName(h)}</option>
))}
</select>
{filterKey && (
<>
<input
type="text"
value={filterValue}
onChange={(e) => setFilterValue(e.target.value)}
placeholder="輸入篩選值..."
className="text-sm border border-slate-300 rounded-md px-2 py-2 w-32"
/>
<button onClick={clearFilter} className="p-1 text-slate-400 hover:text-slate-600">
<X size={16} />
</button>
</>
)}
</div>
<div className="text-sm text-slate-500 py-2">
{processedData.length} 筆記錄
</div>
</div>
</div>
{/* Table */}
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-100 border-b border-slate-200">
<tr>
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-600 w-12">#</th>
{displayedCols.map(h => (
<th
key={h}
className="px-3 py-2 text-left text-xs font-semibold text-slate-600 cursor-pointer hover:bg-slate-200 whitespace-nowrap"
onClick={() => handleSort(h)}
title={excelHeaders[h] || h}
>
<div className="flex items-center gap-1">
{displayName(h)}
{getSortIcon(h)}
</div>
</th>
))}
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600 w-16">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading ? (
<tr>
<td colSpan={displayedCols.length + 2} className="px-3 py-6 text-center text-slate-400">
載入中...
</td>
</tr>
) : processedData.length === 0 ? (
<tr>
<td colSpan={displayedCols.length + 2} className="px-3 py-6 text-center text-slate-400">
沒有記錄
</td>
</tr>
) : (
processedData.map((record) => (
<tr
key={record.id}
className="hover:bg-slate-50 cursor-pointer"
onClick={() => navigate(`/accident/${record.id}`)}
>
<td className="px-3 py-2 text-xs text-slate-400">{record.id}</td>
{displayedCols.map(h => (
<td key={h} className="px-3 py-2 text-slate-700 max-w-xs truncate" title={record[h]}>
{record[h] !== null && record[h] !== undefined && record[h] !== '' ? String(record[h]) : '-'}
</td>
))}
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
<Link
to={`/accident/${record.id}`}
className="p-1 text-slate-400 hover:text-primary-600 inline-block"
>
<Eye size={16} />
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
+228
View File
@@ -0,0 +1,228 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import api from '../../api'
import toast from 'react-hot-toast'
import { ArrowLeft, Save, X } from 'lucide-react'
export default function AttendanceDetail() {
const { id } = useParams()
const navigate = useNavigate()
const [record, setRecord] = useState(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [editMode, setEditMode] = useState(false)
const [formData, setFormData] = useState({})
const [error, setError] = useState(null)
useEffect(() => {
fetchRecord()
}, [id])
const fetchRecord = async () => {
setLoading(true)
setError(null)
try {
// Fetch all records and find by id
const { data } = await api.get('/api/attendance/records?per_page=1000')
const found = (data.records || []).find(r => r.id === parseInt(id))
if (found) {
setRecord(found)
setFormData({
employee_name: found.employee_name || '',
company: found.company || '',
date: found.date || '',
weekday: found.weekday || '',
shift_code: found.shift_code || '',
status_code: found.status_code || '',
status_text: found.status_text || '',
late_minutes: found.late_minutes || 0,
early_minutes: found.early_minutes || 0,
ot_minutes: found.ot_minutes || 0,
expected_in: found.expected_in || '',
expected_out: found.expected_out || '',
actual_in: found.actual_in || '',
actual_out: found.actual_out || '',
is_manually_edited: found.is_manually_edited || false,
})
} else {
setError('找不到記錄')
}
} catch (err) {
console.error('Error:', err)
setError('載入失敗')
} finally {
setLoading(false)
}
}
const handleSave = async () => {
setSaving(true)
try {
await api.put(`/api/attendance/${id}`, formData)
toast.success('保存成功')
setEditMode(false)
fetchRecord()
} catch (err) {
toast.error('保存失敗')
} finally {
setSaving(false)
}
}
const handleCancel = () => {
setEditMode(false)
if (record) {
setFormData({
employee_name: record.employee_name || '',
company: record.company || '',
date: record.date || '',
weekday: record.weekday || '',
shift_code: record.shift_code || '',
status_code: record.status_code || '',
status_text: record.status_text || '',
late_minutes: record.late_minutes || 0,
early_minutes: record.early_minutes || 0,
ot_minutes: record.ot_minutes || 0,
expected_in: record.expected_in || '',
expected_out: record.expected_out || '',
actual_in: record.actual_in || '',
actual_out: record.actual_out || '',
})
}
}
const getStatusBadge = (code, text) => {
const badges = {
normal: 'bg-green-100 text-green-700',
late: 'bg-orange-100 text-orange-700',
early: 'bg-blue-100 text-blue-700',
ot: 'bg-purple-100 text-purple-700',
abnormal: 'bg-red-100 text-red-700',
missing: 'bg-gray-200 text-gray-600',
late_early: 'bg-orange-100 text-orange-700',
late_ot: 'bg-orange-100 text-orange-700',
early_ot: 'bg-blue-100 text-blue-700',
}
return (
<span className={`px-3 py-1 rounded-full text-xs font-medium ${badges[code] || 'bg-gray-100 text-gray-600'}`}>
{text || code}
</span>
)
}
const displayFields = [
{ key: 'employee_name', label: '員工姓名' },
{ key: 'company', label: '公司' },
{ key: 'date', label: '日期' },
{ key: 'weekday', label: '星期' },
{ key: 'shift_code', label: '班次' },
{ key: 'expected_in', label: '應上班時間' },
{ key: 'expected_out', label: '應下班時間' },
{ key: 'actual_in', label: '實際上班' },
{ key: 'actual_out', label: '實際下班' },
{ key: 'late_minutes', label: '遲到分鐘' },
{ key: 'early_minutes', label: '早退分鐘' },
{ key: 'ot_minutes', label: 'OT分鐘' },
{ key: 'status_code', label: '狀態代碼' },
{ key: 'status_text', label: '狀態' },
]
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-slate-500">載入中...</div>
</div>
)
}
if (error) {
return (
<div className="space-y-4">
<Link to="/attendance" className="flex items-center gap-2 text-slate-600 hover:text-slate-900">
<ArrowLeft size={20} /> 返回列表
</Link>
<div className="card p-8 text-center text-red-500">{error}</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link to="/attendance" className="flex items-center gap-2 text-slate-600 hover:text-slate-900">
<ArrowLeft size={20} /> 返回
</Link>
<h1 className="text-xl font-bold text-slate-900">
出勤記錄 - {record?.employee_name}
</h1>
</div>
<div className="flex items-center gap-3">
{record && getStatusBadge(record.status_code, record.status_text)}
{!editMode ? (
<button
onClick={() => setEditMode(true)}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
編輯
</button>
) : (
<>
<button
onClick={handleCancel}
className="flex items-center gap-2 px-4 py-2 border border-slate-300 text-slate-700 text-sm rounded-md hover:bg-slate-50"
>
<X size={16} /> 取消
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white text-sm rounded-md hover:bg-green-700 disabled:opacity-50"
>
<Save size={16} /> {saving ? '保存中...' : '保存'}
</button>
</>
)}
</div>
</div>
{/* Manual edit warning */}
{record?.is_manually_edited && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg px-4 py-3 text-sm text-yellow-800">
此記錄曾被手動修改之後重新導入 Excel 不會覆蓋此記錄
</div>
)}
{/* Record Details */}
<div className="card">
<div className="p-4 border-b border-slate-200 bg-slate-50">
<h2 className="font-semibold text-slate-700">記錄詳情</h2>
</div>
<div className="divide-y divide-slate-100">
{displayFields.map(({ key, label }) => (
<div key={key} className="grid grid-cols-12 items-center">
<div className="col-span-3 px-4 py-3 text-sm font-medium text-slate-500 bg-slate-50">
{label}
</div>
<div className="col-span-9 px-4 py-3">
{editMode && ['status_code', 'late_minutes', 'early_minutes', 'ot_minutes', 'actual_in', 'actual_out', 'shift_code'].includes(key) ? (
<input
type="text"
value={formData[key]}
onChange={(e) => setFormData({ ...formData, [key]: e.target.value })}
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<span className="text-sm text-slate-900">
{record?.[key] !== null && record?.[key] !== undefined ? String(record[key]) : '-'}
</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
)
}
+309
View File
@@ -0,0 +1,309 @@
import { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom'
import api from '../../api'
import toast from 'react-hot-toast'
import { Search, ChevronUp, ChevronDown, Filter, X, Users, Calendar } from 'lucide-react'
import StatusBadge from '../../components/StatusBadge'
export default function AttendanceList() {
const [records, setRecords] = useState([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [sortBy, setSortBy] = useState('date')
const [sortOrder, setSortOrder] = useState('desc')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [staffFilter, setStaffFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [page, setPage] = useState(1)
const perPage = 50
const [staffList, setStaffList] = useState([])
useEffect(() => {
fetchRecords()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, sortBy, sortOrder, dateFrom, dateTo, staffFilter, statusFilter, search])
const fetchRecords = async () => {
setLoading(true)
try {
const params = new URLSearchParams({
page,
per_page: perPage,
sort_by: sortBy,
sort_order: sortOrder,
})
if (search) params.append('search', search)
if (dateFrom) params.append('date_from', dateFrom)
if (dateTo) params.append('date_to', dateTo)
if (staffFilter) params.append('staff', staffFilter)
if (statusFilter) params.append('status', statusFilter)
const { data } = await api.get(`/api/attendance/records?${params}`)
setRecords(data.records || [])
setTotal(data.total || 0)
// Extract unique staff names
const names = [...new Set((data.records || []).map(r => r.employee_name).filter(Boolean))]
setStaffList(names.sort())
} catch (err) {
toast.error('載入失敗')
console.error(err)
} finally {
setLoading(false)
}
}
const handleSearch = (e) => {
e.preventDefault()
setPage(1)
fetchRecords()
}
const handleSort = (key) => {
if (sortBy === key) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
} else {
setSortBy(key)
setSortOrder('asc')
}
setPage(1)
}
const getSortIcon = (key) => {
if (sortBy !== key) return null
return sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
}
const getStatusBadge = (record) => {
return <StatusBadge code={record.status_code} text={record.status_text} />
}
const getRowClass = (code) => {
if (code === 'abnormal') return 'bg-red-50 hover:bg-red-100'
if (code === 'missing') return 'bg-gray-100 hover:bg-gray-200'
if (code === 'late' || code === 'late_early' || code === 'late_ot') return 'bg-orange-50 hover:bg-orange-100'
if (code === 'early' || code === 'early_ot') return 'bg-blue-50 hover:bg-blue-100'
if (code === 'ot') return 'bg-purple-50 hover:bg-purple-100'
return 'hover:bg-slate-50'
}
const totalPages = Math.ceil(total / perPage)
const columns = [
{ key: 'employee_name', label: '員工' },
{ key: 'date', label: '日期' },
{ key: 'weekday', label: '星期' },
{ key: 'shift_code', label: '班次' },
{ key: 'actual_in', label: '上班' },
{ key: 'actual_out', label: '下班' },
{ key: 'expected_in', label: '應上班' },
{ key: 'expected_out', label: '應下班' },
]
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-lg sm:text-xl font-bold text-slate-900">出勤記錄 Attendance</h1>
<div className="flex gap-2">
<Link to="/" className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 text-sm rounded-md hover:bg-slate-200">
<Calendar size={16} /> Dashboard
</Link>
<Link to="/upload" className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700">
上傳 Excel
</Link>
</div>
</div>
{/* Filters */}
<div className="card p-4 space-y-3">
{/* Search row */}
<form onSubmit={handleSearch} className="flex flex-wrap gap-3 items-end">
<div className="relative flex-1 min-w-[200px]">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜尋員工姓名..."
className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-md"
/>
</div>
<button type="submit" className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700">
搜尋
</button>
</form>
{/* Filter row */}
<div className="flex flex-wrap gap-3 items-end">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">日期:</span>
<input
type="date"
value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
placeholder="由"
/>
<span className="text-slate-400">-</span>
<input
type="date"
value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
placeholder="至"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">員工:</span>
<select
value={staffFilter}
onChange={(e) => { setStaffFilter(e.target.value); setPage(1) }}
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
>
<option value="">全部</option>
{staffList.map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">狀態:</span>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
>
<option value="">全部</option>
<option value="normal">正常</option>
<option value="late">遲到</option>
<option value="early">早退</option>
<option value="ot">OT</option>
<option value="abnormal">異常</option>
<option value="missing">缺勤</option>
</select>
</div>
<button
onClick={() => {
setDateFrom(''); setDateTo(''); setStaffFilter(''); setStatusFilter(''); setSearch(''); setPage(1)
// Force refetch in case useEffect timing misses the state change.
setTimeout(() => fetchRecords(), 0)
}}
className="flex items-center gap-1 px-3 py-1.5 text-xs text-slate-500 hover:text-slate-700 border border-slate-300 rounded-md"
>
<X size={14} /> 清除篩選
</button>
</div>
{/* Stats bar */}
<div className="flex items-center gap-4 text-xs text-slate-500">
<span>{total} 筆記錄</span>
{staffFilter && <span> 員工: {staffFilter}</span>}
{statusFilter && <span> 狀態: {statusFilter}</span>}
{(dateFrom || dateTo) && <span> {dateFrom || '...'} {dateTo || '...'}</span>}
</div>
</div>
{/* Table */}
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-100 border-b border-slate-200">
<tr>
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-600 w-12">#</th>
{columns.map(col => (
<th
key={col.key}
onClick={() => handleSort(col.key)}
className="px-3 py-2.5 text-left text-xs font-semibold text-slate-600 cursor-pointer hover:bg-slate-200"
>
<div className="flex items-center gap-1">
{col.label}
{getSortIcon(col.key)}
</div>
</th>
))}
<th className="px-3 py-2.5 text-center text-xs font-semibold text-slate-600">狀態</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-600 w-16">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading ? (
<tr>
<td colSpan={10} className="px-3 py-8 text-center text-slate-400">
載入中...
</td>
</tr>
) : records.length === 0 ? (
<tr>
<td colSpan={10} className="px-3 py-8 text-center text-slate-400">
沒有記錄
</td>
</tr>
) : (
records.map((record, idx) => (
<tr
key={record.id || idx}
className={getRowClass(record.status_code)}
>
<td className="px-3 py-2.5 text-xs text-slate-400">{(page - 1) * perPage + idx + 1}</td>
<td className="px-3 py-2.5 text-slate-700 font-medium">{record.employee_name || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.date || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.weekday || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.shift_code || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.actual_in || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.actual_out || '-'}</td>
<td className="px-3 py-2.5 text-slate-400 text-xs">{record.expected_in || '-'}</td>
<td className="px-3 py-2.5 text-slate-400 text-xs">{record.expected_out || '-'}</td>
<td className="px-3 py-2.5 text-center">
{getStatusBadge(record)}
</td>
<td className="px-3 py-2.5 text-right">
<Link
to={`/attendance/${record.id}`}
className="p-1 text-slate-400 hover:text-blue-600 inline-block"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
<span className="text-xs text-slate-500">
{page} / {totalPages} {total} 筆記錄
</span>
<div className="flex gap-2">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-3 py-1 text-xs border border-slate-300 rounded-md disabled:opacity-50 hover:bg-slate-100"
>
上一頁
</button>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="px-3 py-1 text-xs border border-slate-300 rounded-md disabled:opacity-50 hover:bg-slate-100"
>
下一頁
</button>
</div>
</div>
)}
</div>
</div>
)
}
+713
View File
@@ -0,0 +1,713 @@
import { useState, useEffect, useRef } from 'react'
import api from '../../api'
import toast from 'react-hot-toast'
import {
Plus, Edit2, Trash2, RefreshCw, Upload, Download, Calendar as CalIcon,
Users, X, ChevronDown, ChevronUp, Filter,
} from 'lucide-react'
import StatusBadge from '../../components/StatusBadge'
const LEAVE_TYPES = [
{ value: 'SL', label: '病假 SL', color: 'pink' },
{ value: 'CL', label: '補鐘 CL', color: 'indigo' },
{ value: 'AL', label: '年假 AL', color: 'emerald' },
]
const CURRENT_YEAR = new Date().getFullYear()
const YEAR_OPTIONS = [CURRENT_YEAR - 1, CURRENT_YEAR, CURRENT_YEAR + 1, CURRENT_YEAR + 2]
export default function HolidaysPage() {
const [tab, setTab] = useState('holidays')
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">假期 Holidays</h1>
<p className="text-sm text-slate-500 mt-1">
管理公眾假期 + 員工請假 (SL 病假 / CL 補鐘 / AL 年假)
</p>
</div>
</div>
{/* Tabs */}
<div className="border-b border-slate-200">
<nav className="flex gap-4">
<button
onClick={() => setTab('holidays')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === 'holidays'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
<CalIcon size={16} className="inline mr-1.5" />
公眾假期
</button>
<button
onClick={() => setTab('leaves')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === 'leaves'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
<Users size={16} className="inline mr-1.5" />
員工請假
</button>
</nav>
</div>
{tab === 'holidays' ? <HolidaysTab /> : <LeavesTab />}
</div>
)
}
// ============ Tab 1: 公眾假期 ============
function HolidaysTab() {
const [year, setYear] = useState(CURRENT_YEAR)
const [holidays, setHolidays] = useState([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState(null) // Holiday object or null
const [showCreate, setShowCreate] = useState(false)
useEffect(() => {
load()
}, [year])
async function load() {
setLoading(true)
try {
const { data } = await api.get(`/api/holidays?year=${year}`)
setHolidays(data)
} catch (err) {
toast.error(err.response?.data?.detail || '載入失敗')
} finally {
setLoading(false)
}
}
async function handleRefresh() {
if (!confirm(`從 gov.hk 重新抓取 ${year} 年假期?\n(手動編輯嘅假期唔會被覆寫)`)) return
try {
const { data } = await api.post(`/api/holidays/refresh?years=${year}`)
toast.success(`${year} 已同步:新增 ${data.added} / 更新 ${data.updated} / 跳過 ${data.skipped}`)
load()
} catch (err) {
toast.error(err.response?.data?.detail || '同步失敗')
}
}
async function handleDelete(h) {
if (!confirm(`確定要刪除 ${h.date}${h.name}」?`)) return
try {
await api.delete(`/api/holidays/${h.id}`)
toast.success('已刪除')
load()
} catch (err) {
toast.error(err.response?.data?.detail || '刪除失敗')
}
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="bg-white border border-slate-200 rounded-lg p-4 flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-slate-700">年份:</label>
<select
value={year}
onChange={(e) => setYear(Number(e.target.value))}
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
>
{YEAR_OPTIONS.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div className="flex-1" />
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700"
>
<Plus size={14} /> 加公假
</button>
<button
onClick={handleRefresh}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-300 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-50"
>
<RefreshCw size={14} /> GovHK 同步
</button>
</div>
{/* Table */}
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
{loading ? (
<div className="p-8 text-center text-slate-400">載入中...</div>
) : holidays.length === 0 ? (
<div className="p-8 text-center text-slate-400">{year} 年冇假期資料</div>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">日期</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">名稱 ()</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">Name (EN)</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">類型</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">來源</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">備註</th>
<th className="px-4 py-2.5 text-right font-medium text-slate-700">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{holidays.map(h => (
<tr key={h.id} className="hover:bg-slate-50">
<td className="px-4 py-2 font-mono text-slate-700">{h.date}</td>
<td className="px-4 py-2 font-medium text-slate-900">{h.name}</td>
<td className="px-4 py-2 text-slate-600">{h.name_en || '-'}</td>
<td className="px-4 py-2">
{h.is_mandatory ? (
<span className="inline-block px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-700">法定</span>
) : (
<span className="inline-block px-2 py-0.5 rounded text-xs bg-slate-100 text-slate-600">銀行/學校</span>
)}
</td>
<td className="px-4 py-2 text-xs text-slate-500">{h.source || '-'}</td>
<td className="px-4 py-2 text-slate-500 text-xs">{h.notes || '-'}</td>
<td className="px-4 py-2 text-right">
<button
onClick={() => setEditing(h)}
className="p-1 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded"
title="編輯"
>
<Edit2 size={14} />
</button>
<button
onClick={() => handleDelete(h)}
className="p-1 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded ml-1"
title="刪除"
>
<Trash2 size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="text-xs text-slate-500 text-center">
{holidays.length} 個公眾假期 · 來源<a className="text-primary-600 underline" href="https://www.gov.hk/en/about/abouthk/holiday/" target="_blank" rel="noreferrer">gov.hk</a>
</div>
{(showCreate || editing) && (
<HolidayModal
holiday={editing}
onClose={() => { setShowCreate(false); setEditing(null) }}
onSaved={() => { setShowCreate(false); setEditing(null); load() }}
/>
)}
</div>
)
}
// ============ Tab 2: 員工請假 ============
function LeavesTab() {
const [leaves, setLeaves] = useState([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState(null)
const [showCreate, setShowCreate] = useState(false)
const [filterType, setFilterType] = useState('')
const [filterEmp, setFilterEmp] = useState('')
const [filterFrom, setFilterFrom] = useState('')
const [filterTo, setFilterTo] = useState('')
const fileInputRef = useRef(null)
useEffect(() => {
load()
}, [])
async function load() {
setLoading(true)
try {
const params = new URLSearchParams()
if (filterType) params.set('leave_type', filterType)
if (filterEmp) params.set('employee_name', filterEmp)
if (filterFrom) params.set('date_from', filterFrom)
if (filterTo) params.set('date_to', filterTo)
const { data } = await api.get(`/api/leave?${params}`)
setLeaves(data)
} catch (err) {
toast.error(err.response?.data?.detail || '載入失敗')
} finally {
setLoading(false)
}
}
async function handleDelete(lv) {
if (!confirm(`確定要刪除 ${lv.employee_name} ${lv.date} ${lv.leave_type}${lv.hours}h`)) return
try {
await api.delete(`/api/leave/${lv.id}`)
toast.success('已刪除')
load()
} catch (err) {
toast.error(err.response?.data?.detail || '刪除失敗')
}
}
async function handleUpload(e) {
const f = e.target.files?.[0]
if (!f) return
const fd = new FormData()
fd.append('file', f)
try {
const { data } = await api.post('/api/leave/upload', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const errMsg = data.errors?.length ? `\n錯誤 (前 ${data.errors.length} 條):\n${data.errors.join('\n')}` : ''
toast.success(`✓ 新增 ${data.added} 條 / 跳過 ${data.skipped}${errMsg}`, { duration: 6000 })
load()
} catch (err) {
toast.error(err.response?.data?.detail || '上傳失敗')
} finally {
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
function downloadTemplate() {
const csv = 'employee_name,date,leave_type,hours,reason,approved_by\nJay Lam,2026-07-15,SL,2.0,睇醫生,\nJay Lam,2026-07-20,AL,8.0,Family trip,Manager A\n'
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'leave_template.csv'
a.click()
URL.revokeObjectURL(url)
}
const uniqueEmps = [...new Set(leaves.map(l => l.employee_name))].sort()
return (
<div className="space-y-4">
{/* Filters */}
<div className="bg-white border border-slate-200 rounded-lg p-4 flex flex-wrap items-center gap-3">
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
>
<option value="">所有類型</option>
{LEAVE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<input
type="date"
value={filterFrom}
onChange={(e) => setFilterFrom(e.target.value)}
placeholder="由"
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
/>
<input
type="date"
value={filterTo}
onChange={(e) => setFilterTo(e.target.value)}
placeholder="至"
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
/>
<button
onClick={load}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-200"
>
<Filter size={14} /> 套用
</button>
<button
onClick={() => { setFilterType(''); setFilterEmp(''); setFilterFrom(''); setFilterTo(''); setTimeout(load, 0) }}
className="inline-flex items-center gap-1 px-2 py-1.5 text-slate-500 hover:text-slate-700 text-sm"
>
<X size={14} />
</button>
<div className="flex-1" />
<button
onClick={downloadTemplate}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-300 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-50"
>
<Download size={14} /> CSV 範本
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-300 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-50"
>
<Upload size={14} /> 上傳 CSV/Excel
</button>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.xls"
onChange={handleUpload}
className="hidden"
/>
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700"
>
<Plus size={14} /> 加請假
</button>
</div>
{/* Table */}
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
{loading ? (
<div className="p-8 text-center text-slate-400">載入中...</div>
) : leaves.length === 0 ? (
<div className="p-8 text-center text-slate-400">冇請假記錄</div>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">員工</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">日期</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">類型</th>
<th className="px-4 py-2.5 text-right font-medium text-slate-700">時數</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">原因</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">審批人</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">來源</th>
<th className="px-4 py-2.5 text-right font-medium text-slate-700">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{leaves.map(lv => (
<tr key={lv.id} className="hover:bg-slate-50">
<td className="px-4 py-2 font-medium text-slate-900">{lv.employee_name}</td>
<td className="px-4 py-2 font-mono text-slate-700">{lv.date}</td>
<td className="px-4 py-2">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${
lv.leave_type === 'SL' ? 'bg-pink-100 text-pink-700' :
lv.leave_type === 'CL' ? 'bg-indigo-100 text-indigo-700' :
'bg-emerald-100 text-emerald-700'
}`}>
{LEAVE_TYPES.find(t => t.value === lv.leave_type)?.label || lv.leave_type}
</span>
</td>
<td className="px-4 py-2 text-right tabular-nums">{lv.hours}h</td>
<td className="px-4 py-2 text-slate-600 text-xs">{lv.reason || '-'}</td>
<td className="px-4 py-2 text-slate-600 text-xs">{lv.approved_by || '-'}</td>
<td className="px-4 py-2 text-xs text-slate-500">{lv.source || '-'}</td>
<td className="px-4 py-2 text-right">
<button
onClick={() => setEditing(lv)}
className="p-1 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded"
title="編輯"
>
<Edit2 size={14} />
</button>
<button
onClick={() => handleDelete(lv)}
className="p-1 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded ml-1"
title="刪除"
>
<Trash2 size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="text-xs text-slate-500 text-center">
{leaves.length} 條請假記錄
</div>
{(showCreate || editing) && (
<LeaveModal
leave={editing}
employees={uniqueEmps}
onClose={() => { setShowCreate(false); setEditing(null) }}
onSaved={() => { setShowCreate(false); setEditing(null); load() }}
/>
)}
</div>
)
}
// ============ Holiday Modal ============
function HolidayModal({ holiday, onClose, onSaved }) {
const isEdit = !!holiday
const [date, setDate] = useState(holiday?.date || '')
const [name, setName] = useState(holiday?.name || '')
const [nameEn, setNameEn] = useState(holiday?.name_en || '')
const [isMandatory, setIsMandatory] = useState(holiday?.is_mandatory ?? true)
const [notes, setNotes] = useState(holiday?.notes || '')
const [saving, setSaving] = useState(false)
async function handleSave() {
if (!date || !name) {
toast.error('日期同名稱係必填')
return
}
setSaving(true)
try {
if (isEdit) {
await api.put(`/api/holidays/${holiday.id}`, {
date, name, name_en: nameEn, is_mandatory: isMandatory, notes,
})
toast.success('已更新')
} else {
await api.post('/api/holidays', {
date, name, name_en: nameEn, is_mandatory: isMandatory, notes,
})
toast.success('已新增')
}
onSaved()
} catch (err) {
toast.error(err.response?.data?.detail || '儲存失敗')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-md" onClick={(e) => e.stopPropagation()}>
<div className="px-5 py-3 border-b border-slate-200 flex items-center justify-between">
<h3 className="font-semibold text-slate-900">{isEdit ? '編輯假期' : '加公假'}</h3>
<button onClick={onClose} className="p-1 text-slate-400 hover:text-slate-700 rounded">
<X size={18} />
</button>
</div>
<div className="p-5 space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">日期 <span className="text-red-500">*</span></label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">名稱 (中文) <span className="text-red-500">*</span></label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="元旦 / 香港回歸紀念日"
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name (English)</label>
<input
type="text"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
placeholder="The first day of January"
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="mandatory"
checked={isMandatory}
onChange={(e) => setIsMandatory(e.target.checked)}
className="rounded"
/>
<label htmlFor="mandatory" className="text-sm text-slate-700">
法定假日 (勞工假)
</label>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">備註</label>
<input
type="text"
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
</div>
<div className="px-5 py-3 border-t border-slate-200 flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-1.5 text-slate-600 hover:bg-slate-100 rounded-md text-sm">
取消
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700 disabled:opacity-50"
>
{saving ? '儲存中...' : (isEdit ? '更新' : '新增')}
</button>
</div>
</div>
</div>
)
}
// ============ Leave Modal ============
function LeaveModal({ leave, employees, onClose, onSaved }) {
const isEdit = !!leave
const [employeeName, setEmployeeName] = useState(leave?.employee_name || '')
const [date, setDate] = useState(leave?.date || '')
const [leaveType, setLeaveType] = useState(leave?.leave_type || 'SL')
const [hours, setHours] = useState(leave?.hours ?? 8)
const [reason, setReason] = useState(leave?.reason || '')
const [approvedBy, setApprovedBy] = useState(leave?.approved_by || '')
const [saving, setSaving] = useState(false)
async function handleSave() {
if (!employeeName || !date || !leaveType) {
toast.error('員工 / 日期 / 類型 係必填')
return
}
if (hours < 0 || hours > 24) {
toast.error('時數必須 0-24')
return
}
setSaving(true)
try {
if (isEdit) {
await api.put(`/api/leave/${leave.id}`, {
employee_name: employeeName, date, leave_type: leaveType,
hours: Number(hours), reason, approved_by: approvedBy,
})
toast.success('已更新')
} else {
await api.post('/api/leave', {
employee_name: employeeName, date, leave_type: leaveType,
hours: Number(hours), reason, approved_by: approvedBy,
})
toast.success('已新增')
}
onSaved()
} catch (err) {
toast.error(err.response?.data?.detail || '儲存失敗')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-md" onClick={(e) => e.stopPropagation()}>
<div className="px-5 py-3 border-b border-slate-200 flex items-center justify-between">
<h3 className="font-semibold text-slate-900">{isEdit ? '編輯請假' : '加請假'}</h3>
<button onClick={onClose} className="p-1 text-slate-400 hover:text-slate-700 rounded">
<X size={18} />
</button>
</div>
<div className="p-5 space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">員工 <span className="text-red-500">*</span></label>
{employees.length > 0 ? (
<select
value={employeeName}
onChange={(e) => setEmployeeName(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
>
<option value="">-- 揀員工 --</option>
{employees.map(e => <option key={e} value={e}>{e}</option>)}
{!employees.includes(employeeName) && employeeName && (
<option value={employeeName}>{employeeName} ()</option>
)}
</select>
) : (
<input
type="text"
value={employeeName}
onChange={(e) => setEmployeeName(e.target.value)}
placeholder="員工姓名"
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">日期 <span className="text-red-500">*</span></label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">類型 <span className="text-red-500">*</span></label>
<div className="flex gap-2">
{LEAVE_TYPES.map(t => (
<button
key={t.value}
type="button"
onClick={() => setLeaveType(t.value)}
className={`flex-1 px-3 py-2 rounded-md text-sm font-medium border transition-colors ${
leaveType === t.value
? t.color === 'pink' ? 'bg-pink-100 border-pink-400 text-pink-700'
: t.color === 'indigo' ? 'bg-indigo-100 border-indigo-400 text-indigo-700'
: 'bg-emerald-100 border-emerald-400 text-emerald-700'
: 'bg-white border-slate-300 text-slate-600 hover:bg-slate-50'
}`}
>
{t.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">時數 (0-24) <span className="text-red-500">*</span></label>
<input
type="number"
step="0.5"
min="0"
max="24"
value={hours}
onChange={(e) => setHours(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
<div className="flex gap-1 mt-1">
{[1, 2, 4, 8].map(h => (
<button
key={h}
type="button"
onClick={() => setHours(h)}
className="px-2 py-0.5 text-xs bg-slate-100 hover:bg-slate-200 rounded"
>
{h}h
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">原因</label>
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="睇醫生 / Family trip / 補鐘"
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">審批人</label>
<input
type="text"
value={approvedBy}
onChange={(e) => setApprovedBy(e.target.value)}
placeholder="Manager A"
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
</div>
<div className="px-5 py-3 border-t border-slate-200 flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-1.5 text-slate-600 hover:bg-slate-100 rounded-md text-sm">
取消
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700 disabled:opacity-50"
>
{saving ? '儲存中...' : (isEdit ? '更新' : '新增')}
</button>
</div>
</div>
</div>
)
}
+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-xl sm:text-2xl font-bold mb-4 sm:mb-6">班次管理 Roster</h1>
{/* Upload Section */}
<div className="card p-6 mb-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Upload size={20} />
上傳班次表
</h2>
{!rosterInfo.uploaded ? (
<form onSubmit={handleUpload} className="flex gap-4 items-end">
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-1">
選擇班次表 Excel 檔案
</label>
<input
type="file"
accept=".xlsx,.xls"
onChange={(e) => setFile(e.target.files[0])}
className="input-field"
/>
</div>
<button type="submit" disabled={!file || uploading} className="btn-primary">
{uploading ? '上傳中...' : '上傳'}
</button>
</form>
) : (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CheckCircle className="text-green-500" size={24} />
<div>
<p className="font-medium">已上傳班次表</p>
<p className="text-sm text-slate-500">
{rosterInfo.rows} 個班次{rosterInfo.columns}
</p>
</div>
</div>
<button onClick={handleDelete} className="btn-danger">
<Trash2 size={16} className="inline mr-1" />
刪除
</button>
</div>
)}
</div>
{/* Roster Table */}
{rosterData.headers.length > 0 && (
<div className="card overflow-hidden">
<div className="p-4 border-b border-slate-200">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Clock size={20} />
班次表
</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-100 border-b border-slate-200">
<tr>
{rosterData.headers.map((header, idx) => (
<th key={idx} className="px-3 py-2 text-left text-xs font-semibold text-slate-600">
{header}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rosterData.rows.map((row, rowIdx) => (
<tr key={rowIdx} className="hover:bg-slate-50">
{rosterData.headers.map((header, colIdx) => (
<td key={colIdx} className="px-3 py-2 text-slate-700">
{row[colIdx] !== null && row[colIdx] !== undefined
? String(row[colIdx])
: '-'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Instructions */}
{!rosterInfo.uploaded && (
<div className="card p-6">
<h2 className="text-lg font-semibold mb-4">如何使用</h2>
<ol className="list-decimal list-inside space-y-2 text-slate-600">
<li>上傳包含班次資料的 Excel 檔案</li>
<li>Excel 需要有班次描述</li>
<li>上傳後可以睇到所有班次</li>
<li> Attendance Excel 入面加班次填入員工所屬班次代碼</li>
</ol>
</div>
)}
</div>
)
}
+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)}
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>
)
}
+29
View File
@@ -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: [],
}
+16
View File
@@ -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,
}
}
}
})