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
+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>
)
}