import React, { useState, useEffect } from 'react'; import { Users, User, CalendarDays, Trophy, CheckCircle2, MapPin, Image as ImageIcon, RefreshCw, Plus, Settings, Camera, X, FileText, Upload, Download, Edit2, LogOut, Trash2, Loader2 } from 'lucide-react'; // ========================================== // 🚀 CLOUDFLARE 部署設定 // ========================================== // 若要串接真實的 Cloudflare Worker,請將下方引號內填入 Worker 網址 // 例如: "https://visit-api.your-subdomain.workers.dev" // 預設留空 ("") 時,系統會在網頁本地運行,方便預覽測試! const API_BASE = "https://visit2026.jayee861103.workers.dev"; // --- Mock Data Initialization (僅供預覽模式或初始使用) --- const initialStaff = [ { id: 's1', name: '王大明' }, { id: 's2', name: '陳小華' }, { id: 's3', name: '林雅婷' } ]; const initialWorkers = Array.from({ length: 30 }).map((_, i) => ({ id: `w${i+1}`, empId: `A${String(i+1).padStart(3, '0')}`, name: `Worker ${String.fromCharCode(65 + (i % 26))}${i}` })); export default function App() { const [currentUser, setCurrentUser] = useState(null); const [activeTab, setActiveTab] = useState('tasks'); const [isLoading, setIsLoading] = useState(false); // 自訂對話框狀態 const [dialog, setDialog] = useState({ isOpen: false, type: 'alert', message: '', onConfirm: null }); const showAlert = (message) => setDialog({ isOpen: true, type: 'alert', message, onConfirm: null }); const showConfirm = (message, onConfirm) => setDialog({ isOpen: true, type: 'confirm', message, onConfirm }); const closeDialog = () => setDialog({ ...dialog, isOpen: false }); const [staffList, setStaffList] = useState(initialStaff); const [workerList, setWorkerList] = useState(initialWorkers); const [schedule, setSchedule] = useState([]); const [currentStaffId, setCurrentStaffId] = useState(''); const [currentMonth, setCurrentMonth] = useState(3); // 初始化與讀取 Cloudflare 資料 useEffect(() => { // 載入 Excel 套件 if (!document.getElementById('xlsx-script')) { const script = document.createElement('script'); script.id = 'xlsx-script'; script.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js'; document.head.appendChild(script); } // 讀取遠端 API (Cloudflare KV) const loadData = async () => { if (!API_BASE) { if (schedule.length === 0) generateSchedule(initialStaff, initialWorkers); return; } try { setIsLoading(true); const res = await fetch(`${API_BASE}/api/data`); // 先將回應當作文字讀取,避免直接 .json() 解析失敗 const textData = await res.text(); try { const data = JSON.parse(textData); if(data.staffList?.length) setStaffList(data.staffList); if(data.workerList?.length) setWorkerList(data.workerList); if(data.schedule?.length) { setSchedule(data.schedule); } else { // 如果遠端沒有排程,使用遠端名單(或本地初始名單)生成一次 generateSchedule(data.staffList?.length ? data.staffList : initialStaff, data.workerList?.length ? data.workerList : initialWorkers); } } catch (parseError) { console.warn("JSON 解析失敗,可能後端回傳錯誤訊息:", textData); // 若解析失敗(例如拿到 Cannot read property...),則使用預設資料並幫忙產生初始排程 if (schedule.length === 0) generateSchedule(initialStaff, initialWorkers); } } catch(e) { console.error("連線 API 失敗,使用本地預設資料", e); if (schedule.length === 0) generateSchedule(initialStaff, initialWorkers); } finally { setIsLoading(false); } }; loadData(); }, []); // 儲存資料至 Cloudflare KV const saveData = async (newStaff, newWorkers, newSchedule) => { if (!API_BASE) return; try { await fetch(`${API_BASE}/api/data`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ staffList: newStaff, workerList: newWorkers, schedule: newSchedule }) }); } catch (e) { console.error("儲存至雲端失敗", e); } }; // --- Login Logic --- const handleLogin = (inputValue) => { const trimmedInput = inputValue.trim(); if (trimmedInput === 'Az123456') { setCurrentUser({ id: 'admin', name: '系統管理員', role: 'admin' }); setCurrentStaffId(staffList[0]?.id || ''); setActiveTab('admin'); return; } const staff = staffList.find(s => s.name === trimmedInput); if (staff) { setCurrentUser({ ...staff, role: 'staff' }); setCurrentStaffId(staff.id); setActiveTab('tasks'); } else { showAlert('找不到此負責人,或密碼輸入錯誤。'); } }; const handleLogout = () => setCurrentUser(null); // --- Core Logic --- // 改寫:接受參數以便在初始化時傳入正確的清單 const generateSchedule = (staffs = staffList, workers = workerList) => { if (staffs.length === 0 || workers.length === 0) return; let shuffledWorkers = [...workers].sort(() => 0.5 - Math.random()); let staffPiles = {}; staffs.forEach(s => staffPiles[s.id] = []); shuffledWorkers.forEach((w, index) => { const staff = staffs[index % staffs.length]; staffPiles[staff.id].push(w); }); const months = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; let newSchedule = []; staffs.forEach(staff => { let pile = staffPiles[staff.id]; pile.forEach((w, idx) => { let m = months[idx % months.length]; newSchedule.push({ id: `sch_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, workerId: w.id, workerEmpId: w.empId, workerName: w.name, staffId: staff.id, month: m, status: 'pending', address: '', imageUrl: null, completeDate: null }); }); }); setSchedule(newSchedule); saveData(staffs, workers, newSchedule); // 如果是手動觸發(已有currentUser)才顯示提示 if (currentUser?.role === 'admin') showAlert("已成功為 2026年 (3-12月) 重新隨機分配訪視名單!"); }; const handleReplace = (scheduleId) => { showConfirm("確定該移工無法訪視(如返鄉)並要進行替換嗎?\n系統將從下個月(或未來月份)抽調一位至本月,並將該移工移至12月份(最後面)。", () => { const currentTask = schedule.find(s => s.id === scheduleId); const futureTask = schedule .filter(s => s.staffId === currentTask.staffId && s.month > currentTask.month && s.status === 'pending') .sort((a, b) => a.month - b.month)[0]; if (futureTask) { const newSchedule = schedule.map(s => { if (s.id === scheduleId) return { ...s, month: 12, status: 'pending' }; if (s.id === futureTask.id) return { ...s, month: currentTask.month }; return s; }); setSchedule(newSchedule); saveData(staffList, workerList, newSchedule); showAlert("已成功替換!該移工已移至 12 月份,並從未來月份抽調一名補上本月。"); } else { const anyOtherTask = schedule.find(s => s.staffId === currentTask.staffId && s.id !== scheduleId && s.status === 'pending'); if (anyOtherTask) { const newSchedule = schedule.map(s => { if (s.id === scheduleId) return { ...s, month: anyOtherTask.month }; if (s.id === anyOtherTask.id) return { ...s, month: currentTask.month }; return s; }); setSchedule(newSchedule); saveData(staffList, workerList, newSchedule); showAlert("已從其他月份抽調一名移工作為替換!"); } else { showAlert("您已經沒有其他待訪視的移工可以替換了。"); } } }); }; const handleComplete = async (scheduleId, address, imageBase64) => { let finalImageUrl = imageBase64; // 儲存至 Cloudflare R2 if (API_BASE && imageBase64.startsWith('data:image')) { try { const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ imageBase64 }) }); const textData = await res.text(); try { const data = JSON.parse(textData); if(data.imageUrl) finalImageUrl = `${API_BASE}${data.imageUrl}`; } catch(e) { console.error("解析上傳回傳值失敗:", textData); showAlert("圖片上傳 R2 發生錯誤,暫存為本地資料。"); } } catch (e) { console.error("Upload error", e); showAlert("圖片上傳連線失敗,暫存為本地資料。"); } } const newSchedule = schedule.map(s => { if (s.id === scheduleId) { return { ...s, status: 'completed', address: address, imageUrl: finalImageUrl, completeDate: s.completeDate || new Date().toISOString() }; } return s; }); setSchedule(newSchedule); saveData(staffList, workerList, newSchedule); }; // 刪除與清理 const handleDeleteStaff = (id) => { showConfirm('確定要刪除此負責人嗎?\n注意:刪除後建議重新產生年度分配表。', () => { const newList = staffList.filter(s => s.id !== id); setStaffList(newList); saveData(newList, workerList, schedule); }); }; const handleDeleteWorker = (id) => { showConfirm('確定要刪除此移工嗎?\n注意:刪除後建議重新產生年度分配表。', () => { const newList = workerList.filter(w => w.id !== id); setWorkerList(newList); saveData(staffList, newList, schedule); }); }; const handleClearStaff = () => { showConfirm('確定要「一鍵清空」所有負責人名單嗎?\n注意:資料刪除後無法復原!', () => { setStaffList([]); saveData([], workerList, schedule); }); }; const handleClearWorkers = () => { showConfirm('確定要「一鍵清空」所有移工名單嗎?\n注意:資料刪除後無法復原!', () => { setWorkerList([]); saveData(staffList, [], schedule); }); }; // --- Views --- const renderContent = () => { if (!currentUser) return ; return (
{/* Desktop Sidebar (綠色系) */} {/* Main Content Area */}
{/* Mobile Header */}

外宿訪視系統

{isLoading ? (
連線至 Cloudflare 讀取資料中...
) : ( <> {activeTab === 'tasks' && ( )} {activeTab === 'leaderboard' && ( )} {activeTab === 'admin' && currentUser.role === 'admin' && ( generateSchedule(staffList, workerList)} handleDeleteStaff={handleDeleteStaff} handleDeleteWorker={handleDeleteWorker} handleClearStaff={handleClearStaff} handleClearWorkers={handleClearWorkers} showAlert={showAlert} saveData={saveData} schedule={schedule} /> )} )}
{/* Mobile Bottom Navigation */}
); }; return ( <> {renderContent()} {/* Custom Dialog */} {dialog.isOpen && (

{dialog.type === 'confirm' ? : } {dialog.type === 'confirm' ? '請確認' : '系統提示'}

{dialog.message}

{dialog.type === 'confirm' && ( )}
)} ); } // --- Login Screen (Green) --- function LoginScreen({ onLogin }) { const [inputValue, setInputValue] = useState(''); const handleSubmit = (e) => { e.preventDefault(); if(inputValue.trim()) onLogin(inputValue); }; return (
{/* Background Decorative Blobs */}

移工外宿訪視系統

請輸入您的姓名登入

setInputValue(e.target.value)} placeholder="姓名 (例: 王大明)" className="w-full px-5 py-4 rounded-xl border border-slate-200 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none transition text-slate-800 bg-white/50 text-center text-lg font-bold placeholder:text-slate-400 placeholder:font-medium shadow-sm" required />
); } // --- Navigation Components --- function DesktopNavButton({ icon, label, active, onClick }) { return ( ); } function MobileNavButton({ icon, label, active, onClick }) { return ( ); } // --- Sub-Views --- // 1. Tasks View (Green Theme) function TasksView({ staffList, currentUser, currentStaffId, setCurrentStaffId, currentMonth, setCurrentMonth, schedule, handleReplace, handleComplete, showAlert }) { const [selectedTask, setSelectedTask] = useState(null); const months = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; const displayStaffId = currentUser.role === 'admin' ? currentStaffId : currentUser.id; const myTasks = schedule.filter(s => s.staffId === displayStaffId && s.month === currentMonth); const completedCount = myTasks.filter(t => t.status === 'completed').length; return (
{currentUser.role === 'admin' && (
)}

2026年度 訪視月份

{months.map(m => ( ))}

本月分配名單

當月任務: {completedCount} / {myTasks.length}
{myTasks.length === 0 ? (

本月份尚無分配任務

) : (
{myTasks.map(task => ( handleReplace(task.id)} onEdit={() => setSelectedTask(task)} /> ))}
)}
{selectedTask && ( setSelectedTask(null)} onSubmit={(address, image) => handleComplete(selectedTask.id, address, image).then(() => setSelectedTask(null))} showAlert={showAlert} /> )}
); } function TaskCard({ task, onReplace, onEdit }) { const isCompleted = task.status === 'completed'; return (

{task.workerName}

工號: {task.workerEmpId}

{isCompleted ? ( 已完成 ) : ( 待訪視 )}
{isCompleted && (

{task.address}

{task.imageUrl && (
對話紀錄
)}
)} {!isCompleted && (
)}
); } function CompleteModal({ task, onClose, onSubmit, showAlert }) { const [address, setAddress] = useState(task.address || ''); const [imagePreview, setImagePreview] = useState(task.imageUrl || null); const [uploading, setUploading] = useState(false); const handleImageChange = (e) => { const file = e.target.files[0]; if (file) { const reader = new FileReader(); reader.onloadend = () => setImagePreview(reader.result); reader.readAsDataURL(file); } }; const handleSubmit = async (e) => { e.preventDefault(); if (!address.trim()) return showAlert('請輸入外宿地址'); if (!imagePreview) return showAlert('請上傳對話紀錄圖片'); setUploading(true); await onSubmit(address, imagePreview); setUploading(false); }; return (

{task.status === 'completed' ? '編輯訪視紀錄' : '完成訪視紀錄'}

訪視對象

{task.workerName} ({task.workerEmpId})

{imagePreview ? ( 預覽 ) : ( )}
); } // 2. Leaderboard View (Green Theme with Gold/Silver/Bronze badges) function LeaderboardView({ staffList, schedule, showAlert }) { const stats = staffList.map(staff => { const staffTasks = schedule.filter(s => s.staffId === staff.id); const completedTasks = staffTasks.filter(s => s.status === 'completed'); const total = staffTasks.length; const completed = completedTasks.length; const rate = total === 0 ? 0 : Math.round((completed / total) * 100); return { ...staff, total, completed, rate }; }).sort((a, b) => b.rate - a.rate || b.completed - a.completed); const exportToExcel = () => { if (!window.XLSX) { showAlert("Excel 處理模組載入中,請稍後再試!"); return; } const XLSX = window.XLSX; const months = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; const data = staffList.map(staff => { const row = { '負責人': staff.name }; let totalAssigned = 0; let totalCompleted = 0; months.forEach(m => { const tasks = schedule.filter(s => s.staffId === staff.id && s.month === m); const completed = tasks.filter(s => s.status === 'completed').length; totalAssigned += tasks.length; totalCompleted += completed; // 匯出為百分比格式 row[`${m}月份`] = tasks.length > 0 ? `${Math.round((completed / tasks.length) * 100)}%` : "0%"; }); row['總完成'] = totalCompleted; row['總分配'] = totalAssigned; row['年度完成率'] = totalAssigned ? `${Math.round((totalCompleted / totalAssigned) * 100)}%` : '0%'; return row; }); const worksheet = XLSX.utils.json_to_sheet(data); const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, "年度完成率"); XLSX.writeFile(workbook, "2026年度訪視完成率報表.xlsx"); }; return (

2026 年度完成度排名

{stats.map((stat, index) => (
{index + 1}

{stat.name}

{stat.rate}%
{stat.completed} / {stat.total}
))}
); } // 3. Admin View (Emerald/Green Theme) function AdminView({ staffList, setStaffList, workerList, setWorkerList, generateSchedule, handleDeleteStaff, handleDeleteWorker, handleClearStaff, handleClearWorkers, showAlert, saveData, schedule }) { const [newStaff, setNewStaff] = useState(''); const [newWorkerEmp, setNewWorkerEmp] = useState(''); const [newWorkerName, setNewWorkerName] = useState(''); const handleAddStaff = () => { if (!newStaff.trim()) return; const newList = [...staffList, { id: `s_${Date.now()}`, name: newStaff.trim() }]; setStaffList(newList); saveData(newList, workerList, schedule); setNewStaff(''); }; const handleAddWorker = () => { if (!newWorkerEmp.trim() || !newWorkerName.trim()) return; const newList = [...workerList, { id: `w_${Date.now()}`, empId: newWorkerEmp.trim(), name: newWorkerName.trim() }]; setWorkerList(newList); saveData(staffList, newList, schedule); setNewWorkerEmp(''); setNewWorkerName(''); }; const handleImportStaffExcel = (e) => { const file = e.target.files[0]; if (!file) return; if (!window.XLSX) return showAlert("Excel 模組載入中,請稍後再試!"); const reader = new FileReader(); reader.onload = (evt) => { const data = new Uint8Array(evt.target.result); const workbook = window.XLSX.read(data, { type: 'array' }); const json = window.XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]]); const newStaff = []; json.forEach((row, i) => { const name = row['姓名'] || row['Name'] || Object.values(row)[0]; if (name) newStaff.push({ id: `s_imp_${Date.now()}_${i}`, name: String(name).trim() }); }); if(newStaff.length > 0) { const newList = [...staffList, ...newStaff]; setStaffList(newList); saveData(newList, workerList, schedule); showAlert(`成功匯入 ${newStaff.length} 位負責人!`); } else showAlert('無法匯入:請確保包含「姓名」欄位。'); e.target.value = ''; }; reader.readAsArrayBuffer(file); }; const handleImportWorkerExcel = (e) => { const file = e.target.files[0]; if (!file) return; if (!window.XLSX) return showAlert("Excel 模組載入中,請稍後再試!"); const reader = new FileReader(); reader.onload = (evt) => { const data = new Uint8Array(evt.target.result); const workbook = window.XLSX.read(data, { type: 'array' }); const json = window.XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]]); const newWorkers = []; json.forEach((row, i) => { const empId = row['工號'] || row['EmpId'] || Object.values(row)[0]; const name = row['英文姓名'] || row['Name'] || Object.values(row)[1]; if (empId && name) newWorkers.push({ id: `w_imp_${Date.now()}_${i}`, empId: String(empId).trim(), name: String(name).trim() }); }); if(newWorkers.length > 0) { const newList = [...workerList, ...newWorkers]; setWorkerList(newList); saveData(staffList, newList, schedule); showAlert(`成功匯入 ${newWorkers.length} 位移工!`); } else showAlert('無法匯入:請確保包含「工號」與「英文姓名」欄位。'); e.target.value = ''; }; reader.readAsArrayBuffer(file); }; return (

系統排程設定

設定好名單後,點擊右方按鈕將任務隨機平均分配至 2026年 3~12 月份。

{/* 負責人 */}

負責人管理 ({staffList.length})

setNewStaff(e.target.value)} placeholder="輸入負責人姓名" className="flex-1 p-3 border border-slate-200 rounded-xl text-sm outline-none focus:ring-1 focus:ring-emerald-500 bg-slate-50"/>
{staffList.map(s => ( {s.name} ))}
{/* 移工 */}

移工名單 ({workerList.length})

setNewWorkerEmp(e.target.value)} placeholder="工號" className="w-1/3 p-3 border border-slate-200 rounded-xl text-sm bg-slate-50"/> setNewWorkerName(e.target.value)} placeholder="英文姓名" className="flex-1 p-3 border border-slate-200 rounded-xl text-sm bg-slate-50"/>
{workerList.map((w, i) => (
{w.empId} {w.name}
))}
); }