import React, { useState, useEffect, useRef } from 'react';
import { initializeApp } from 'firebase/app';
import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged } from 'firebase/auth';
import { getFirestore, collection, onSnapshot, addDoc, updateDoc, doc, deleteDoc, query, where, getDocs } from 'firebase/firestore';
import {
Calendar as CalendarIcon, ShieldCheck, Plus, Trash2, Check, X,
UserPlus, LogOut, Leaf, Eye, EyeOff, LogIn, Image as ImageIcon,
CalendarPlus, Share2, ClipboardCopy, Send, MousePointerClick
} from 'lucide-react';
// ==========================================
// 1. Firebase Initialization
// ==========================================
const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : {};
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const db = getFirestore(app);
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
// ==========================================
// 常數與選項設定
// ==========================================
const ALLIANCE_OPTIONS = ['建豪阿詩', '君立惠萍', '殿順宜玲', 'Favor', '顏值爆表', '心不老', 'Revival'];
const SHIFT_OPTIONS = ['導覽人員 (需求 1位)', '招待人員 10:00-13:30 (需求 5-6位)', '招待人員 13:30-17:00 (需求 5-6位)'];
// ==========================================
// 2. 主應用程式 Main App
// ==========================================
export default function App() {
const [fbUser, setFbUser] = useState(null);
const [activeTab, setActiveTab] = useState('home'); // home, admin, dashboard, login
const [registrations, setRegistrations] = useState([]);
const [usersInfo, setUsersInfo] = useState([]);
// 登入狀態管理
const [isAdmin, setIsAdmin] = useState(false);
const [loggedInUser, setLoggedInUser] = useState(null);
// 初始化 Firebase Auth
useEffect(() => {
const initAuth = async () => {
try {
if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) {
await signInWithCustomToken(auth, __initial_auth_token);
} else {
await signInAnonymously(auth);
}
} catch (error) {
console.error("Auth error:", error);
}
};
initAuth();
const unsubscribe = onAuthStateChanged(auth, (u) => setFbUser(u));
return () => unsubscribe();
}, []);
// 取得全域報名資料與使用者資料
useEffect(() => {
if (!fbUser) return;
const regsRef = collection(db, 'artifacts', appId, 'public', 'data', 'volunteer_registrations');
const usersRef = collection(db, 'artifacts', appId, 'public', 'data', 'volunteer_users');
const unsubRegs = onSnapshot(regsRef, (snapshot) => {
const data = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
data.sort((a, b) => b.createdAt - a.createdAt);
setRegistrations(data);
});
const unsubUsers = onSnapshot(usersRef, (snapshot) => {
const data = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
setUsersInfo(data);
});
return () => { unsubRegs(); unsubUsers(); };
}, [fbUser]);
return (
{/* 導覽列 (後台管理移至右上角) */}
{/* 主要內容區 */}
{activeTab === 'home' && }
{activeTab === 'login' && }
{activeTab === 'dashboard' && }
{activeTab === 'admin' && }
);
}
// 導覽列按鈕元件
function NavBtn({ active, onClick, icon, text }) {
return (
);
}
// ==========================================
// 3. 首頁 (月曆與報名表)
// ==========================================
function HomeView({ registrations, db, appId, usersInfo, setActiveTab, setLoggedInUser }) {
const formRef = useRef(null);
const [selectedDate, setSelectedDate] = useState('');
const handleDateSelect = (dateStr) => {
setSelectedDate(dateStr);
// 平滑滾動到報名表單
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
{/* 歡迎與月曆區塊 */}
本月排班動態
點擊下方月曆上有空缺的日期,即可快速填寫報名登記表!
{/* 月曆元件 */}
{/* 報名表單 */}
);
}
// 月曆元件
function CalendarWidget({ registrations, onDateSelect }) {
const today = new Date();
const [currentDate, setCurrentDate] = useState(new Date(today.getFullYear(), today.getMonth(), 1));
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const firstDayOfWeek = new Date(year, month, 1).getDay();
const approvedRegs = registrations.filter(r => r.status === 'approved');
const dayStats = {};
approvedRegs.forEach(reg => {
reg.shifts.forEach(shift => {
if (!dayStats[shift.date]) {
dayStats[shift.date] = { '導覽人員 (需求 1位)': 0, '招待人員 10:00-13:30 (需求 5-6位)': 0, '招待人員 13:30-17:00 (需求 5-6位)': 0 };
}
if (dayStats[shift.date][shift.type] !== undefined) {
dayStats[shift.date][shift.type] += reg.volunteers.length;
}
});
});
const nextMonth = () => setCurrentDate(new Date(year, month + 1, 1));
const prevMonth = () => setCurrentDate(new Date(year, month - 1, 1));
const renderDays = () => {
const blanks = Array(firstDayOfWeek).fill(null);
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
const allCells = [...blanks, ...days];
return allCells.map((day, i) => {
if (!day) return ;
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const stats = dayStats[dateStr] || { '導覽人員 (需求 1位)': 0, '招待人員 10:00-13:30 (需求 5-6位)': 0, '招待人員 13:30-17:00 (需求 5-6位)': 0 };
return (
onDateSelect(dateStr)}
className="bg-white border border-[#E5E0D8] p-1.5 sm:p-3 rounded-lg shadow-sm hover:shadow-md hover:border-[#4A6451] hover:-translate-y-1 transition-all cursor-pointer min-h-[100px] sm:min-h-[130px] flex flex-col group relative"
title={`點擊登記 ${dateStr} 排班`}
>
{day}
{/* Hover 提示點擊遮罩 */}
);
});
};
return (
{year} 年 {month + 1} 月
{renderDays()}
點擊上方月曆日期,可直接快速帶入登記表!
);
}
function StatusBadge({ label, max, current }) {
let bgColor = "bg-[#E5E0D8] text-[#5C6656]"; // 缺額多
if (current >= max) bgColor = "bg-[#D4A373] text-white"; // 額滿
else if (current > 0) bgColor = "bg-[#A3B18A] text-white"; // 有人報名
return (
{label}
{label[0]} {/* 手機版自動縮寫:導, 早, 午 */}
{current}/{max}
);
}
// 報名表單
function RegisterForm({ db, appId, usersInfo, setActiveTab, setLoggedInUser, prefillDate }) {
const [userInfo, setUserInfo] = useState({
name: '', phone: '', email: '', lineId: '',
churchType: '平鎮浸信會', churchOther: '', alliance: ALLIANCE_OPTIONS[0], group: '',
username: '', password: '', confirmPassword: ''
});
const [showPwd, setShowPwd] = useState(false);
const [shifts, setShifts] = useState([{ date: '', type: SHIFT_OPTIONS[0], companion: '' }]);
const [isSubmitting, setIsSubmitting] = useState(false);
// 監聽來自月曆點擊的日期,自動帶入表單第一欄
useEffect(() => {
if (prefillDate) {
setShifts(prev => {
const newShifts = [...prev];
newShifts[0].date = prefillDate;
return newShifts;
});
}
}, [prefillDate]);
const handleUserChange = (f, v) => setUserInfo({...userInfo, [f]: v});
const handleAddShift = () => setShifts([...shifts, { date: '', type: SHIFT_OPTIONS[0], companion: '' }]);
const handleRemoveShift = (i) => setShifts(shifts.filter((_, idx) => idx !== i));
const handleShiftChange = (i, f, v) => {
const newS = [...shifts];
newS[i][f] = v;
setShifts(newS);
};
const handleSubmit = async (e) => {
e.preventDefault();
if (userInfo.password !== userInfo.confirmPassword) {
alert("❌ 密碼與再次確認密碼不相符!"); return;
}
if (usersInfo.some(u => u.username === userInfo.username)) {
alert("❌ 此使用者名稱已被使用,請更換一個。"); return;
}
setIsSubmitting(true);
try {
const finalChurch = userInfo.churchType === '其他' ? userInfo.churchOther : userInfo.churchType;
const userData = {
username: userInfo.username,
password: userInfo.password,
name: userInfo.name,
phone: userInfo.phone,
email: userInfo.email,
lineId: userInfo.lineId,
church: finalChurch,
alliance: userInfo.alliance,
group: userInfo.group,
createdAt: Date.now()
};
await addDoc(collection(db, 'artifacts', appId, 'public', 'data', 'volunteer_users'), userData);
const payload = {
username: userInfo.username,
mainName: userInfo.name,
volunteers: [{ name: userInfo.name, phone: userInfo.phone }],
shifts: shifts.map(s => {
const comps = s.companion.split(',').map(c => c.trim()).filter(c => c);
return { date: s.date, type: s.type, extraVolunteers: comps };
}),
status: 'pending',
createdAt: Date.now()
};
await addDoc(collection(db, 'artifacts', appId, 'public', 'data', 'volunteer_registrations'), payload);
alert('✅ 報名與帳號建立成功!已自動為您登入,請等待管理員審核。');
setLoggedInUser(userData);
setActiveTab('dashboard');
} catch (err) {
console.error(err);
alert('❌ 送出失敗,請稍後再試。');
}
setIsSubmitting(false);
};
return (
報名與帳號建立
免繁瑣註冊,填寫完畢系統將自動為您建立專屬帳號。
);
}
function Input({ label, type = 'text', value, onChange, required, placeholder }) {
return (
onChange(e.target.value)} placeholder={placeholder}
className="w-full px-4 py-3 bg-white border-2 border-[#D5D0C5] rounded-xl focus:border-[#4A6451] outline-none transition-all font-medium text-lg text-[#2A3C2F]"
/>
);
}
// ==========================================
// 4. 志工登入頁面
// ==========================================
function LoginView({ usersInfo, setLoggedInUser, setActiveTab }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPwd, setShowPwd] = useState(false);
const handleLogin = (e) => {
e.preventDefault();
const user = usersInfo.find(u => u.username === username && u.password === password);
if (user) {
setLoggedInUser(user);
setActiveTab('dashboard');
} else {
alert("帳號或密碼錯誤!");
}
};
return (
);
}
// ==========================================
// 5. 志工專屬後台 (Dashboard)
// ==========================================
function DashboardView({ user, registrations, db, appId, setLoggedInUser, setActiveTab }) {
const canvasRef = useRef(null);
const myRegs = registrations.filter(r => r.username === user.username);
const handleLogout = () => {
setLoggedInUser(null);
setActiveTab('home');
};
const generateImage = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1B2A22';
ctx.fillRect(0, 0, 800, 1000);
ctx.fillStyle = '#4A6451';
ctx.beginPath();
ctx.arc(800, 0, 300, 0, 2 * Math.PI);
ctx.fill();
ctx.fillStyle = '#EAE6D9';
ctx.font = 'bold 48px sans-serif';
ctx.fillText('嶺頭志工 排班確認卡', 50, 100);
ctx.fillStyle = '#A3B18A';
ctx.font = 'bold 36px sans-serif';
ctx.fillText(`志工:${user.name}`, 50, 180);
ctx.fillStyle = '#F4F1EA';
ctx.font = '28px sans-serif';
let y = 260;
myRegs.filter(r => r.status === 'approved').forEach(reg => {
reg.shifts.forEach(shift => {
ctx.fillText(`🗓️ ${shift.date} | ${shift.type}`, 50, y);
if (shift.extraVolunteers && shift.extraVolunteers.length > 0) {
y += 40;
ctx.fillStyle = '#C2C9BA';
ctx.font = '24px sans-serif';
ctx.fillText(` 同行: ${shift.extraVolunteers.join(', ')}`, 50, y);
ctx.fillStyle = '#F4F1EA';
ctx.font = '28px sans-serif';
}
y += 60;
});
});
if (y === 260) {
ctx.fillText('尚無已核准的排班。', 50, y);
}
ctx.fillStyle = '#7A8675';
ctx.font = 'italic 24px sans-serif';
ctx.fillText('「感謝您委身服事,願神大大賜福您!」', 50, 900);
const link = document.createElement('a');
link.download = '嶺頭志工排班.png';
link.href = canvas.toDataURL('image/png');
link.click();
};
const getGCalLink = (shift) => {
const dateStr = shift.date.replace(/-/g, '');
let startTime = '100000';
let endTime = '170000';
if(shift.type.includes('10:00')) endTime = '133000';
if(shift.type.includes('13:30')) { startTime = '133000'; endTime = '170000'; }
return `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent('嶺頭志工服事 - ' + shift.type)}&dates=${dateStr}T${startTime}Z/${dateStr}T${endTime}Z&details=${encodeURIComponent('感謝您的服事!')}`;
};
return (
歡迎,{user.name}
{user.church} | {user.alliance} | {user.group}
我的排班紀錄
{myRegs.length === 0 &&
目前尚無排班紀錄。
}
{myRegs.map(reg => (
狀態: {reg.status === 'approved' ? '已核准' : reg.status === 'pending' ? '審核中' : '已退回'}
送出時間:{new Date(reg.createdAt).toLocaleDateString()}
{reg.shifts.map((s, i) => (
-
{s.date} {s.type}
{s.extraVolunteers && s.extraVolunteers.length > 0 && (
同行人員: {s.extraVolunteers.join(', ')}
)}
{reg.status === 'approved' && (
加入 Google 行事曆
)}
))}
))}
);
}
// ==========================================
// 6. 管理員後台
// ==========================================
function AdminView({ registrations, db, appId, isAdmin, setIsAdmin, usersInfo }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPwd, setShowPwd] = useState(false);
const handleLogin = (e) => {
e.preventDefault();
if (username === 'Lingtou' && password === 'lingtou1952') {
setIsAdmin(true);
} else {
alert('帳號或密碼錯誤');
}
};
const updateStatus = async (id, newStatus) => {
try {
await updateDoc(doc(db, 'artifacts', appId, 'public', 'data', 'volunteer_registrations', id), { status: newStatus });
} catch (err) {
console.error(err);
}
};
if (!isAdmin) {
return (
);
}
const pendingRegs = registrations.filter(r => r.status === 'pending');
const approvedRegs = registrations.filter(r => r.status === 'approved');
return (
審核控制台
待審核名單 ({pendingRegs.length})
{pendingRegs.length === 0 &&
目前無待審核資料。
}
{pendingRegs.map(reg => (
updateStatus(reg.id, 'approved')} onReject={() => updateStatus(reg.id, 'rejected')} usersInfo={usersInfo} />
))}
已核准名單 ({approvedRegs.length})
{approvedRegs.map(reg => (
updateStatus(reg.id, 'rejected')} isApproved={true} usersInfo={usersInfo} />
))}
);
}
function AdminCard({ reg, onApprove, onReject, isApproved, usersInfo }) {
const uInfo = usersInfo.find(u => u.username === reg.username) || {};
const generateMessage = () => {
let msg = `【嶺頭志工服事通知】\n親愛的 ${reg.mainName} 平安:\n感謝您報名嶺頭志工服事,您的排班已審核通過!\n\n📌 您的排班日期:\n`;
reg.shifts.forEach(s => {
msg += `- ${s.date} | ${s.type}\n`;
if(s.extraVolunteers && s.extraVolunteers.length > 0) msg += ` (同行: ${s.extraVolunteers.join(', ')})\n`;
});
msg += `\n期待與您一同服事,願神賜福您!🌱`;
return msg;
};
const copyToClipboard = () => {
navigator.clipboard.writeText(generateMessage());
alert('已複製提醒文字!');
};
const shareToLine = () => {
const url = `https://line.me/R/msg/text/?${encodeURIComponent(generateMessage())}`;
window.open(url, '_blank');
};
return (
{reg.mainName}
{uInfo.church || '未知教會'} / {uInfo.alliance || '未知聯盟'}
📞 {uInfo.phone || '無電話'}
LINE: {uInfo.lineId || '無LINE'}
排班時段
{reg.shifts.map((s, i) => (
-
{s.date} {s.type}
{s.extraVolunteers && s.extraVolunteers.length > 0 && (同行: {s.extraVolunteers.join(', ')})}
))}
{!isApproved ? (
<>
>
) : (
<>
>
)}
);
}