import './admin.css'; import { initAuth, onAuthChange, loginWithGoogle, logout } from './auth.js'; import { db } from './firebase.js'; import { doc, getDoc, setDoc } from 'firebase/firestore'; // Hardcoded admin allowlist — used to bootstrap the Firestore config/admins document // on first login if it doesn't exist yet. const SEED_ADMINS = ['pcgurudm@gmail.com']; import { getDonors, addDonorWithDate, addDonorsBatch, updateDonor, deleteDonor, calculateTotal, } from './donors.js'; let allDonors = []; // ===== Init ===== document.addEventListener('DOMContentLoaded', () => { initAuth(); initAuthUI(); initTabs(); initAddForm(); initCSVImport(); initEditModal(); }); // ===== Auth ===== function initAuthUI() { document.getElementById('loginGoogle').addEventListener('click', loginWithGoogle); document.getElementById('logoutBtn').addEventListener('click', logout); document.getElementById('unauthorizedLogout').addEventListener('click', logout); onAuthChange(handleAuth); } async function handleAuth(user) { const authSection = document.getElementById('authSection'); const unauthorizedSection = document.getElementById('unauthorizedSection'); const adminContent = document.getElementById('adminContent'); const logoutBtn = document.getElementById('logoutBtn'); if (!user) { authSection.classList.remove('hidden'); unauthorizedSection.classList.add('hidden'); adminContent.classList.add('hidden'); logoutBtn.style.display = 'none'; return; } logoutBtn.style.display = ''; // Check admin allowlist const isAdmin = await checkAdmin(user.email); if (!isAdmin) { authSection.classList.add('hidden'); unauthorizedSection.classList.remove('hidden'); adminContent.classList.add('hidden'); return; } authSection.classList.add('hidden'); unauthorizedSection.classList.add('hidden'); adminContent.classList.remove('hidden'); loadDonors(); } async function checkAdmin(email) { try { const adminDocRef = doc(db, 'config', 'admins'); const adminDoc = await getDoc(adminDocRef); if (!adminDoc.exists()) { // No allowlist yet — bootstrap it if this user is in the seed list if (SEED_ADMINS.includes(email)) { await setDoc(adminDocRef, { emails: SEED_ADMINS }); console.log('Admin allowlist created with seed admins.'); return true; } // No doc and not a seed admin — deny return false; } const emails = adminDoc.data()?.emails || []; return emails.includes(email); } catch (err) { console.error('Admin check failed:', err); return false; } } // ===== Tabs ===== function initTabs() { document.querySelectorAll('.admin-tab').forEach((btn) => { btn.addEventListener('click', () => { document.querySelectorAll('.admin-tab').forEach((b) => b.classList.remove('active')); document.querySelectorAll('.admin-panel').forEach((p) => p.classList.remove('active')); btn.classList.add('active'); document.getElementById(`panel-${btn.dataset.tab}`).classList.add('active'); }); }); } // ===== Load Donors ===== async function loadDonors() { try { allDonors = await getDonors(); updateStats(); renderDonorTable(); } catch (err) { console.error('Failed to load donors:', err); showToast('Failed to load donors.', 'error'); } } function updateStats() { const total = calculateTotal(allDonors); document.getElementById('statTotal').textContent = `$${total.toLocaleString()}`; document.getElementById('statCount').textContent = allDonors.length; } // ===== Add Donor Form ===== function initAddForm() { const form = document.getElementById('addDonorForm'); form.addEventListener('submit', async (e) => { e.preventDefault(); const btn = document.getElementById('addDonorBtn'); btn.disabled = true; btn.textContent = 'Adding...'; try { await addDonorWithDate({ name: document.getElementById('donorName').value.trim(), amount: document.getElementById('donorAmount').value, classYear: document.getElementById('donorClassYear').value.trim(), message: document.getElementById('donorMessage').value.trim(), anonymous: document.getElementById('donorAnonymous').checked, date: document.getElementById('donorDate').value || null, }); showToast('Donor added!', 'success'); form.reset(); await loadDonors(); } catch (err) { console.error('Failed to add donor:', err); showToast('Failed to add donor.', 'error'); } finally { btn.disabled = false; btn.textContent = 'Add Donor'; } }); } // ===== CSV Import ===== let csvRows = []; let csvHeaders = []; function initCSVImport() { const fileInput = document.getElementById('csvFile'); fileInput.addEventListener('change', handleFileSelect); document.getElementById('importBtn').addEventListener('click', runImport); document.getElementById('csvCancelBtn').addEventListener('click', resetCSV); // Update preview when mappings change document.querySelectorAll('.mapping-grid select').forEach((sel) => { sel.addEventListener('change', updatePreview); }); } function handleFileSelect(e) { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (evt) => { const text = evt.target.result; const parsed = parseCSV(text); if (parsed.length < 2) { showToast('CSV file appears empty or has no data rows.', 'error'); return; } csvHeaders = parsed[0]; csvRows = parsed.slice(1); setupMapping(); document.getElementById('csvMapping').classList.remove('hidden'); }; reader.readAsText(file); } function parseCSV(text) { const rows = []; let current = []; let field = ''; let inQuotes = false; for (let i = 0; i < text.length; i++) { const ch = text[i]; const next = text[i + 1]; if (inQuotes) { if (ch === '"' && next === '"') { field += '"'; i++; } else if (ch === '"') { inQuotes = false; } else { field += ch; } } else { if (ch === '"') { inQuotes = true; } else if (ch === ',') { current.push(field.trim()); field = ''; } else if (ch === '\n' || (ch === '\r' && next === '\n')) { current.push(field.trim()); if (current.some((c) => c !== '')) rows.push(current); current = []; field = ''; if (ch === '\r') i++; } else { field += ch; } } } // Last field/row current.push(field.trim()); if (current.some((c) => c !== '')) rows.push(current); return rows; } function setupMapping() { const fields = ['mapName', 'mapAmount', 'mapClassYear', 'mapMessage', 'mapDate', 'mapAnonymous']; const hints = { mapName: ['name', 'donor', 'first', 'full'], mapAmount: ['amount', 'donation', 'gift', 'total', 'sum'], mapClassYear: ['class', 'year', 'grad'], mapMessage: ['message', 'note', 'comment'], mapDate: ['date', 'time', 'when'], mapAnonymous: ['anonymous', 'anon'], }; for (const fieldId of fields) { const select = document.getElementById(fieldId); select.innerHTML = ''; csvHeaders.forEach((h, i) => { const opt = document.createElement('option'); opt.value = i; opt.textContent = h; select.appendChild(opt); }); // Auto-map by fuzzy match const keywords = hints[fieldId]; const match = csvHeaders.findIndex((h) => keywords.some((k) => h.toLowerCase().includes(k)) ); if (match !== -1) select.value = match; } updatePreview(); } function getMappedRow(row) { const get = (id) => { const val = document.getElementById(id).value; return val !== '' ? (row[Number(val)] || '') : ''; }; return { name: get('mapName'), amount: get('mapAmount'), classYear: get('mapClassYear'), message: get('mapMessage'), date: get('mapDate'), anonymous: get('mapAnonymous'), }; } function updatePreview() { const head = document.getElementById('csvPreviewHead'); const body = document.getElementById('csvPreviewBody'); head.innerHTML = 'NameAmountClass YearDateMessageAnon'; const previewRows = csvRows.slice(0, 5); body.innerHTML = previewRows .map((row) => { const m = getMappedRow(row); return ` ${esc(m.name)} ${esc(m.amount)} ${esc(m.classYear)} ${esc(m.date)} ${esc(m.message)} ${normBool(m.anonymous) ? 'Yes' : 'No'} `; }) .join(''); document.getElementById('previewCount').textContent = `(${csvRows.length} rows total)`; } function normBool(val) { if (!val) return false; const v = val.toString().toLowerCase().trim(); return v === 'true' || v === 'yes' || v === '1'; } async function runImport() { const importBtn = document.getElementById('importBtn'); const progressEl = document.getElementById('importProgress'); const progressFill = document.getElementById('progressFill'); const progressText = document.getElementById('progressText'); const donors = csvRows .map((row) => { const m = getMappedRow(row); const amount = parseFloat(m.amount.replace(/[^0-9.\-]/g, '')); if (!m.name && !normBool(m.anonymous)) return null; if (isNaN(amount) || amount <= 0) return null; return { name: m.name || 'Anonymous', amount, classYear: m.classYear, message: m.message, date: m.date || null, anonymous: normBool(m.anonymous), }; }) .filter(Boolean); if (donors.length === 0) { showToast('No valid rows to import. Check your column mapping.', 'error'); return; } if (!confirm(`Import ${donors.length} donors? (${csvRows.length - donors.length} rows will be skipped due to missing name/amount)`)) { return; } importBtn.disabled = true; progressEl.classList.remove('hidden'); try { // Process in chunks of 500 with progress const chunkSize = 500; let imported = 0; for (let i = 0; i < donors.length; i += chunkSize) { const chunk = donors.slice(i, i + chunkSize); await addDonorsBatch(chunk); imported += chunk.length; const pct = Math.round((imported / donors.length) * 100); progressFill.style.width = pct + '%'; progressText.textContent = `Imported ${imported} of ${donors.length}...`; } showToast(`Successfully imported ${imported} donors!`, 'success'); resetCSV(); await loadDonors(); } catch (err) { console.error('Import failed:', err); showToast('Import failed. Some rows may have been imported.', 'error'); } finally { importBtn.disabled = false; progressEl.classList.add('hidden'); progressFill.style.width = '0%'; } } function resetCSV() { csvRows = []; csvHeaders = []; document.getElementById('csvFile').value = ''; document.getElementById('csvMapping').classList.add('hidden'); } // ===== Donor Table ===== function renderDonorTable(filter = '') { const tbody = document.getElementById('donorTableBody'); const emptyState = document.getElementById('donorListEmpty'); const filtered = filter ? allDonors.filter((d) => d.name.toLowerCase().includes(filter.toLowerCase())) : allDonors; if (filtered.length === 0) { tbody.innerHTML = ''; emptyState.classList.remove('hidden'); return; } emptyState.classList.add('hidden'); tbody.innerHTML = filtered .map((d) => { const date = d.date?.toDate ? d.date.toDate().toLocaleDateString() : ''; return ` ${esc(d.name)}${d.anonymous ? ' Anon' : ''} $${(d.amount || 0).toLocaleString()} ${esc(d.classYear)} ${date} ${esc(d.message)} `; }) .join(''); // Attach handlers tbody.querySelectorAll('.btn-edit').forEach((btn) => { btn.addEventListener('click', () => openEditModal(btn.dataset.id)); }); tbody.querySelectorAll('.btn-delete').forEach((btn) => { btn.addEventListener('click', () => handleDelete(btn.dataset.id)); }); // Search const searchInput = document.getElementById('donorSearch'); searchInput.removeEventListener('input', searchHandler); searchInput.addEventListener('input', searchHandler); } function searchHandler(e) { renderDonorTable(e.target.value.trim()); } async function handleDelete(id) { if (!confirm('Delete this donor? This cannot be undone.')) return; try { await deleteDonor(id); showToast('Donor deleted.', 'success'); await loadDonors(); } catch (err) { console.error('Delete failed:', err); showToast('Failed to delete donor.', 'error'); } } // ===== Edit Modal ===== function initEditModal() { const modal = document.getElementById('editModal'); document.getElementById('editModalClose').addEventListener('click', () => modal.classList.add('hidden')); modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.add('hidden'); }); document.getElementById('editDonorForm').addEventListener('submit', async (e) => { e.preventDefault(); const id = document.getElementById('editDonorId').value; const btn = e.target.querySelector('button[type="submit"]'); btn.disabled = true; btn.textContent = 'Saving...'; try { await updateDonor(id, { name: document.getElementById('editAnonymous').checked ? 'Anonymous' : document.getElementById('editName').value.trim(), amount: Number(document.getElementById('editAmount').value), classYear: document.getElementById('editClassYear').value.trim(), message: document.getElementById('editMessage').value.trim(), anonymous: document.getElementById('editAnonymous').checked, }); showToast('Donor updated!', 'success'); modal.classList.add('hidden'); await loadDonors(); } catch (err) { console.error('Update failed:', err); showToast('Failed to update donor.', 'error'); } finally { btn.disabled = false; btn.textContent = 'Save Changes'; } }); } function openEditModal(id) { const donor = allDonors.find((d) => d.id === id); if (!donor) return; document.getElementById('editDonorId').value = id; document.getElementById('editName').value = donor.name || ''; document.getElementById('editAmount').value = donor.amount || ''; document.getElementById('editClassYear').value = donor.classYear || ''; document.getElementById('editMessage').value = donor.message || ''; document.getElementById('editAnonymous').checked = !!donor.anonymous; document.getElementById('editModal').classList.remove('hidden'); } // ===== Utilities ===== function esc(str) { const div = document.createElement('div'); div.textContent = str || ''; return div.innerHTML; } function showToast(message, type = 'info') { const existing = document.querySelector('.toast'); if (existing) existing.remove(); const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => toast.remove(), 4000); }