Initial commit: AARS backend + frontend + holiday/leave phase 1+2
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user