279 lines
9.3 KiB
React
279 lines
9.3 KiB
React
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>
|
||
)
|
||
}
|