513 lines
16 KiB
JavaScript
513 lines
16 KiB
JavaScript
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 = '<option value="">-- skip --</option>';
|
|
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 = '<tr><th>Name</th><th>Amount</th><th>Class Year</th><th>Date</th><th>Message</th><th>Anon</th></tr>';
|
|
|
|
const previewRows = csvRows.slice(0, 5);
|
|
body.innerHTML = previewRows
|
|
.map((row) => {
|
|
const m = getMappedRow(row);
|
|
return `<tr>
|
|
<td>${esc(m.name)}</td>
|
|
<td>${esc(m.amount)}</td>
|
|
<td>${esc(m.classYear)}</td>
|
|
<td>${esc(m.date)}</td>
|
|
<td>${esc(m.message)}</td>
|
|
<td>${normBool(m.anonymous) ? 'Yes' : 'No'}</td>
|
|
</tr>`;
|
|
})
|
|
.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 `<tr>
|
|
<td>${esc(d.name)}${d.anonymous ? ' <span class="anon-badge">Anon</span>' : ''}</td>
|
|
<td>$${(d.amount || 0).toLocaleString()}</td>
|
|
<td>${esc(d.classYear)}</td>
|
|
<td>${date}</td>
|
|
<td class="msg-cell">${esc(d.message)}</td>
|
|
<td class="action-cell">
|
|
<button class="btn-icon btn-edit" data-id="${d.id}" title="Edit">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
</button>
|
|
<button class="btn-icon btn-delete" data-id="${d.id}" title="Delete">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
|
</button>
|
|
</td>
|
|
</tr>`;
|
|
})
|
|
.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);
|
|
}
|