Nonprofit Operations, Simplified

Manage your volunteers & donors in one place

Built for nonprofits coordinating thousands of volunteers, tracking donor relationships, and running impact operations that scale.

👥

Volunteer Management

Track skills, availability, shift history, and engagement metrics for every volunteer in your network.

❤️

Donor Tracking

Monitor giving patterns, relationship status, and lifetime value for individual and institutional donors.

📅

Shift Scheduling

Create and manage volunteer shifts with real-time capacity tracking across multiple locations.

📊

Real-Time Stats

Instant visibility into active volunteers, donor engagement, open shifts, and donation trends.

MinniesOS
Good morning
Here's what's happening today
🍎
Distributions Today
⚖️
Lbs Distributed
🙋
Volunteers Checked In
⚠️
Items Expiring This Week
Quick Actions
🚀 Start Distribution Day
Activity Feed Most recent first
Loading activity…
Overall Summary
Loading…
Loading briefing…
Loading today's briefing…
Active Volunteers
-
Total Donors
-
Registered Clients
-
Open Shifts
-
Last 30 Days
-
Distributions This Month
-
0 lbs distributed
Current Stock
-
lbs in inventory
Attendance Rate
-
this month
Transfers This Month
-
items transferred
Redistribution Alerts
-
suggestions
Recent Volunteers
Name Status Skills Joined
Loading...
Recent Donations
Donor Amount Type Date
Loading...
📊 Operational Trends
Families Served & Lbs Distributed
Volunteer Hours by Month
Donation Trends
New Client Registrations
Volunteers
Name Contact Status Skills Shifts Hours Goal Joined Actions
Loading...
📧 Communication History
Loading...
📧 Email Volunteers
Select a filter and click Preview to see recipients.
Donors
Name Contact Type Status Total Given Last Gift Last Gift Date Impact Receipt
Loading...
Clients
Name Household Phone Email Registered Status Actions
Loading...
Total In Stock
-
lbs
Total Items
-
individual items
Expiring Soon
-
within 7 days
Distributed Today
-
- items
Distributed This Week
-
- items
Inventory
Item Category Lbs Items Distributed Source Received Expires Status Actions
Loading...
📦 Transfer History
Date Item From → To Qty Lbs Qty Items Transferred By Reason
Loading...
🔄 Redistribution Suggestions
Click 🔄 to generate redistribution suggestions based on current stock levels and expiration dates.
Loading...
Shift Distribution Day
Loading calendar...
🍽️
Meals Served
-
Based on $1 = 1 lb = 3 meals
🕐
Volunteer Hours
-
0 unique volunteers
👨‍👩‍👧‍👦
Families Served
-
Estimated from meal distribution
❤️
Total Donors
-
$0 raised
📦
Pounds Distributed
-
0 visits
👥
Clients Served
-
Unique clients with distributions
Donor Breakdown by Type
Donor Type Count Total Contributed
Loading impact data...
Volunteer Participation
-
Total Shifts
-
Shift Sign-ups
-
Unique Volunteers
-
Total Hours
📋 Board Reports
Monthly summaries for board meetings and grant applications
Previously Generated Reports
Loading saved reports…
📋 USDA TEFAP Compliance Report
Monthly/quarterly reports for USDA TEFAP & Feeding America submissions
📊 Grant Impact Summary
Grant-ready impact narrative with metrics for funding applications
📍 Locations

Manage your facilities and school pantry sites.

Name Type Address Status Actions
Loading locations…
⚙️ Organization Settings
Organization Info
Mailing Address
Receipt Message
👥 User Management
Manage staff roles and access levels
Loading users…
🎯 Fundraising Campaigns
Track fundraising drives and progress toward goals
Loading campaigns…
`; const blob = new Blob([html], { type: 'text/html' }); const url = URL.createObjectURL(blob); window.open(url, '_blank'); } // ── Board Reports ── let currentBoardReport = null; // Set default month to current month on page load // ─── USDA Compliance & Grant Report Functions ───────────────────────────── // Initialize compliance controls on page load (function initComplianceControls() { const n = new Date(); const y = n.getFullYear(); const m = String(n.getMonth() + 1).padStart(2, '0'); const monthEl = document.getElementById('compliance-month-input'); if (monthEl) monthEl.value = y + '-' + m; // Grant default: Jan 1 this year → today const lastDay = new Date(y, n.getMonth() + 1, 0).getDate(); const gsEl = document.getElementById('grant-start-date'); const geEl = document.getElementById('grant-end-date'); if (gsEl) gsEl.value = y + '-01-01'; if (geEl) geEl.value = y + '-' + m + '-' + String(lastDay).padStart(2, '0'); // Load locations for filter fetch('/api/locations').then(r => r.json()).then(data => { const sel = document.getElementById('compliance-location-filter'); if (!sel) return; (data.locations || []).forEach(function(loc) { const opt = document.createElement('option'); opt.value = loc.id; opt.textContent = loc.name; sel.appendChild(opt); }); }).catch(function() {}); })(); function onCompliancePeriodChange() { const period = document.getElementById('compliance-period-type').value; const monthInput = document.getElementById('compliance-month-input'); if (!monthInput) return; const n = new Date(); if (period === 'monthly') { monthInput.type = 'month'; monthInput.value = n.getFullYear() + '-' + String(n.getMonth() + 1).padStart(2, '0'); } else if (period === 'quarterly') { // Use a text input for quarter (YYYY-QN) monthInput.type = 'text'; const q = Math.ceil((n.getMonth() + 1) / 3); monthInput.value = n.getFullYear() + '-Q' + q; monthInput.placeholder = 'e.g. 2026-Q1'; } else if (period === 'yearly') { monthInput.type = 'number'; monthInput.value = n.getFullYear(); monthInput.min = '2020'; monthInput.max = '2099'; } } async function generateComplianceReport() { const periodType = document.getElementById('compliance-period-type').value; const monthVal = document.getElementById('compliance-month-input').value; const locationId = document.getElementById('compliance-location-filter').value; if (!monthVal) { alert('Please select a period.'); return; } const btn = document.getElementById('compliance-generate-btn'); btn.disabled = true; btn.textContent = '⏳ Generating…'; try { let url = '/api/reports/compliance?period=' + periodType + '&month=' + encodeURIComponent(monthVal); if (locationId) url += '&location_id=' + locationId; const resp = await apiFetch(url); const data = await resp.json(); if (!resp.ok || !data.success) throw new Error(data.error || 'Failed to generate report'); renderComplianceReport(data); const view = document.getElementById('compliance-report-view'); view.style.display = ''; view.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) { alert('Error generating compliance report: ' + e.message); } finally { btn.disabled = false; btn.textContent = '📋 Generate Report'; } } function fmtNum(n, decimals) { const num = parseFloat(n) || 0; return decimals !== undefined ? num.toFixed(decimals) : num.toLocaleString(); } function complianceMetricCard(label, value, color) { const c = color || '#1a5276'; return '
' + label + '
' + value + '
'; } function renderComplianceReport(data) { const s = data.summary; const container = document.getElementById('compliance-report-view'); const byLocRows = (data.by_location || []).map(function(r) { return '' + (r.location_name || 'Unknown') + '' + r.families + '' + fmtNum(r.pounds_distributed, 1) + '' + (r.items_distributed || 0) + ''; }).join('') || 'No distribution data for this period'; const byCatRows = (data.by_category || []).map(function(r) { return '' + (r.category || 'other') + '' + fmtNum(r.pounds_distributed, 1) + '' + (r.items_distributed || 0) + ''; }).join('') || 'No category data (requires distribution_items linked to inventory)'; const periodType = document.getElementById('compliance-period-type').value; const monthVal = document.getElementById('compliance-month-input').value; const locationId = document.getElementById('compliance-location-filter').value; const exportUrl = '/api/reports/compliance/export?period=' + periodType + '&month=' + encodeURIComponent(monthVal) + '&format=csv' + (locationId ? '&location_id=' + locationId : ''); container.innerHTML = '
' + '
' + '
Minnie's Food Pantry
' + '
USDA TEFAP Compliance Report
' + '
' + data.period + '  •  ' + data.date_range.start + ' – ' + data.date_range.end + '
' + '
' + '
' + '
Summary Metrics
' + '
' + complianceMetricCard('Families Served', fmtNum(s.families_served)) + complianceMetricCard('Individuals Served', fmtNum(s.individuals_served)) + complianceMetricCard('Lbs Distributed', fmtNum(s.pounds_distributed, 1) + ' lbs') + complianceMetricCard('Items Distributed', fmtNum(s.items_distributed)) + complianceMetricCard('Lbs Received', fmtNum(s.pounds_received, 1) + ' lbs') + complianceMetricCard('Volunteer Hours', fmtNum(s.volunteer_hours, 1) + ' hrs') + complianceMetricCard('Unique Volunteers', fmtNum(s.unique_volunteers)) + complianceMetricCard('Donation Value', ' const n = new Date(); const y = n.getFullYear(); const m = String(n.getMonth() + 1).padStart(2, '0'); const el = document.getElementById('board-month-input'); if (el) el.value = `${y}-${m}`; })(); async function generateBoardReport() { const monthInput = document.getElementById('board-month-input'); const month = monthInput ? monthInput.value : ''; if (!month) { alert('Please select a month.'); return; } const btn = document.getElementById('board-generate-btn'); btn.disabled = true; btn.textContent = '⏳ Generating…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!res.ok || !data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); // Refresh saved list loadSavedBoardReports(); } catch (err) { alert('Error generating report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } function renderBoardReportPreview(r) { const m = r.metrics; document.getElementById('brp-month').textContent = r.month_label; document.getElementById('brp-summary').textContent = r.executive_summary; // Metrics grid const fmt = (v, digits) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: digits || 0 }); const fmtMoney = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaHtml = (d) => { if (d === null || d === undefined) return ''; const cls = d > 0 ? 'up' : d < 0 ? 'down' : 'flat'; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; return `${arrow} ${Math.abs(d)}% vs prior month`; }; const cards = [ { label: 'Families Served', value: fmt(m.clients.unique_served), sub: `${fmt(m.clients.new_this_month)} new clients this month`, delta: m.clients.delta_unique_served }, { label: 'Pounds Distributed', value: fmt(m.distributions.total_lbs, 1), sub: `${fmt(m.distributions.total_visits)} visits · ${fmt(m.distributions.avg_lbs_per_family, 1)} lbs/family avg`, delta: m.distributions.delta_lbs }, { label: 'Active Volunteers', value: fmt(m.volunteers.active), sub: `${fmt(m.volunteers.hours, 1)} hours · ${m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '% attendance' : 'no attendance data'}`, delta: m.volunteers.delta_active }, { label: 'Donations', value: fmtMoney(m.donations.total_usd), sub: `${fmt(m.donations.donor_count)} donors · ${fmt(m.donations.donation_count)} gifts`, delta: m.donations.delta_total }, { label: 'Current Inventory', value: fmt(m.inventory.current_stock_lbs, 1) + ' lbs', sub: `${fmt(m.inventory.received_this_month_lbs, 1)} lbs received · ${fmt(m.inventory.expiring_soon_items)} items expiring soon`, delta: null }, { label: 'Total Active Clients', value: fmt(m.clients.total_active), sub: 'On file in system', delta: null }, ...(m.inventory.donated_items > 0 ? [{ label: 'Donated Inventory', value: fmt(m.inventory.donated_items) + ' items', sub: `${fmt(m.inventory.donated_lbs, 1)} lbs from ${fmt(m.inventory.donated_by_donors)} donor${m.inventory.donated_by_donors !== 1 ? 's' : ''}`, delta: null }] : []) ]; document.getElementById('brp-metrics-grid').innerHTML = cards.map(c => `
${c.label}
${c.value}
${c.sub}
${deltaHtml(c.delta)}
`).join(''); // Highlights const h = r.highlights; const hlCards = []; if (h.top_volunteer) hlCards.push({ icon: '🏆', label: 'Top Volunteer', value: `${h.top_volunteer.name} · ${h.top_volunteer.hours}h` }); if (h.largest_donation) hlCards.push({ icon: '💛', label: 'Largest Donation', value: `${fmtMoney(h.largest_donation.amount)} from ${h.largest_donation.donor}` }); if (h.busiest_day) hlCards.push({ icon: '📅', label: 'Busiest Day', value: `${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })} · ${h.busiest_day.visits} visits · ${(h.busiest_day.lbs || 0).toLocaleString(undefined, { maximumFractionDigits: 1 })} lbs` }); if (!hlCards.length) hlCards.push({ icon: 'ℹ️', label: 'No Activity', value: 'No data recorded for this month' }); document.getElementById('brp-highlights').innerHTML = hlCards.map(c => `
${c.icon}
${c.label}
${c.value}
`).join(''); } function printBoardReport() { if (!currentBoardReport) return; const r = currentBoardReport; const m = r.metrics; const fmtN = (v, d) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: d || 0 }); const fmtM = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaStr = (d) => { if (d === null || d === undefined) return ''; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; const color = d > 0 ? '#065f46' : d < 0 ? '#991b1b' : '#6b7280'; const bg = d > 0 ? '#d1fae5' : d < 0 ? '#fee2e2' : '#f3f4f6'; return `${arrow} ${Math.abs(d)}%`; }; const h = r.highlights; const hlHtml = [ h.top_volunteer ? `🏆 Top Volunteer${h.top_volunteer.name} — ${h.top_volunteer.hours} hours` : '', h.largest_donation ? `💛 Largest Donation${fmtM(h.largest_donation.amount)} from ${h.largest_donation.donor}` : '', h.busiest_day ? `📅 Busiest Day${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', timeZone: 'UTC' })} — ${h.busiest_day.visits} visits, ${fmtN(h.busiest_day.lbs, 1)} lbs` : '' ].filter(Boolean).join(''); const metricsRows = [ ['Families Served (Unique)', fmtN(m.clients.unique_served), deltaStr(m.clients.delta_unique_served)], ['New Clients Registered', fmtN(m.clients.new_this_month), ''], ['Total Distribution Visits', fmtN(m.distributions.total_visits), deltaStr(m.distributions.delta_visits)], ['Total Pounds Distributed', fmtN(m.distributions.total_lbs, 1) + ' lbs', deltaStr(m.distributions.delta_lbs)], ['Avg Lbs per Family', fmtN(m.distributions.avg_lbs_per_family, 1) + ' lbs', ''], ['Active Volunteers', fmtN(m.volunteers.active), deltaStr(m.volunteers.delta_active)], ['Volunteer Hours', fmtN(m.volunteers.hours, 1), deltaStr(m.volunteers.delta_hours)], ['Attendance Rate', m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '%' : 'N/A', ''], ['Total Donations', fmtM(m.donations.total_usd), deltaStr(m.donations.delta_total)], ['Unique Donors', fmtN(m.donations.donor_count), deltaStr(m.donations.delta_donors)], ['Current Inventory', fmtN(m.inventory.current_stock_lbs, 1) + ' lbs', ''], ['Inventory Received This Month', fmtN(m.inventory.received_this_month_lbs, 1) + ' lbs', ''], ['Items Expiring Within 30 Days', fmtN(m.inventory.expiring_soon_items), ''], ...(m.inventory.donated_items > 0 ? [ ['Donated Inventory (Items)', fmtN(m.inventory.donated_items) + ' items (' + fmtN(m.inventory.donated_lbs, 1) + ' lbs)', ''], ['Donation Sources (Donors)', fmtN(m.inventory.donated_by_donors) + ' donor' + (m.inventory.donated_by_donors !== 1 ? 's' : ''), ''] ] : []), ['Total Active Clients on File', fmtN(m.clients.total_active), ''] ].map(([label, val, d]) => `${label}${val}${d}`).join(''); const html = `Board Report — ${r.month_label}
Minnie's Food Pantry
Monthly Board Report
${r.month_label}
${r.executive_summary}
Key Performance Metrics
${metricsRows}
MetricValuevs. Prior Month
${hlHtml ? `
Month Highlights
${hlHtml}
` : ''}
`; const blob = new Blob([html], { type: 'text/html' }); window.open(URL.createObjectURL(blob), '_blank'); } async function loadSavedBoardReports() { const list = document.getElementById('saved-board-reports-list'); try { const res = await apiFetch('/api/reports/monthly/saved'); const data = await res.json(); if (!data.success || !data.reports.length) { list.innerHTML = '
No saved reports yet. Generate your first board report above.
'; return; } list.innerHTML = data.reports.map(r => `
${formatSavedReportMonth(r.report_month)}
Generated ${new Date(r.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
`).join(''); } catch (err) { list.innerHTML = '
Could not load saved reports.
'; } } function formatSavedReportMonth(ym) { const [y, m] = ym.split('-').map(Number); return new Date(y, m - 1, 1).toLocaleString('en-US', { month: 'long', year: 'numeric' }); } async function viewSavedBoardReport(month) { const btn = document.getElementById('board-generate-btn'); const mi = document.getElementById('board-month-input'); if (mi) mi.value = month; btn.disabled = true; btn.textContent = '⏳ Loading…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (err) { alert('Error loading report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } async function deleteSavedBoardReport(id, btnEl) { if (!confirm('Delete this saved report?')) return; try { const res = await apiFetch(`/api/reports/monthly/saved/${id}`, { method: 'DELETE' }); if (res.ok) { loadSavedBoardReports(); if (currentBoardReport) { document.getElementById('board-report-preview').style.display = 'none'; currentBoardReport = null; } } } catch (err) { alert('Delete failed: ' + err.message); } } // Close modals on outside click window.onclick = function(event) { if (event.target.classList.contains('modal')) { event.target.classList.remove('active'); } } // ── CSV Export ── async function exportCSV(type) { const token = localStorage.getItem('mfp_admin_token'); if (!token) { alert('Not authenticated'); return; } // Map type to API path and filename label const pathMap = { volunteers: 'volunteers', donors: 'donors', clients: 'clients', inventory: 'inventory', visits: 'visits' }; const apiPath = pathMap[type]; if (!apiPath) { alert('Unknown export type'); return; } // Build URL with optional date range from the Reports tab pickers (if available) const startEl = document.getElementById('report-start-date'); const endEl = document.getElementById('report-end-date'); let url = `/api/${apiPath}/export?format=csv`; if (startEl && startEl.value) url += `&start=${startEl.value}`; if (endEl && endEl.value) url += `&end=${endEl.value}`; try { const resp = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || `HTTP ${resp.status}`); } const blob = await resp.blob(); const blobUrl = URL.createObjectURL(blob); const today = new Date().toISOString().slice(0, 10); const a = document.createElement('a'); a.href = blobUrl; a.download = `${type}-export-${today}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); } catch (err) { alert('Export failed: ' + err.message); } } // ── CSV Import ── let importType = ''; let importRows = []; const csvFormats = { volunteers: { title: 'Import Volunteers', hint: 'CSV headers: name, email, phone, status, skills
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,phone,status,skills\nJane Smith,jane@example.com,555-1234,active,Logistics' }, donors: { title: 'Import Donors', hint: 'CSV headers: name, email, total_given, last_gift_date, phone, donor_type, status
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,total_given,last_gift_date\nJohn Doe,john@example.com,500.00,2025-12-15' } }; function openImportModal(type) { importType = type; importRows = []; const config = csvFormats[type]; document.getElementById('import-modal-title').textContent = config.title; document.getElementById('csv-format-hint').innerHTML = config.hint; resetImportModal(); document.getElementById('import-csv-modal').classList.add('active'); } function closeImportModal() { document.getElementById('import-csv-modal').classList.remove('active'); importType = ''; importRows = []; } function resetImportModal() { document.getElementById('import-step-upload').style.display = 'block'; document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.remove('active'); document.getElementById('import-file-input').value = ''; importRows = []; } // Drag and drop const dropZone = document.getElementById('import-drop-zone'); ['dragenter', 'dragover'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add('drag-over'); }); }); ['dragleave', 'drop'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('drag-over'); }); }); dropZone.addEventListener('drop', (e) => { const files = e.dataTransfer.files; if (files.length > 0) { processFile(files[0]); } }); function handleFileSelect(e) { if (e.target.files.length > 0) { processFile(e.target.files[0]); } } function processFile(file) { if (!file.name.toLowerCase().endsWith('.csv') && file.type !== 'text/csv') { alert('Please upload a CSV file'); return; } const reader = new FileReader(); reader.onload = (e) => { const text = e.target.result; const parsed = parseCSV(text); if (parsed.length === 0) { alert('No data rows found in CSV'); return; } importRows = parsed; showPreview(file.name, parsed); }; reader.readAsText(file); } function parseCSV(text) { const lines = text.split(/\r?\n/).filter(line => line.trim()); if (lines.length < 2) return []; const headers = parseCSVLine(lines[0]).map(h => h.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_')); const rows = []; for (let i = 1; i < lines.length; i++) { const values = parseCSVLine(lines[i]); if (values.length === 0 || (values.length === 1 && !values[0].trim())) continue; const row = {}; headers.forEach((header, idx) => { row[header] = (values[idx] || '').trim(); }); rows.push(row); } return rows; } function parseCSVLine(line) { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"') { if (i + 1 < line.length && line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = false; } } else { current += ch; } } else { if (ch === '"') { inQuotes = true; } else if (ch === ',') { result.push(current); current = ''; } else { current += ch; } } } result.push(current); return result; } function showPreview(fileName, rows) { document.getElementById('import-step-upload').style.display = 'none'; document.getElementById('import-step-preview').classList.add('active'); document.getElementById('preview-file-name').textContent = fileName; document.getElementById('preview-row-count').textContent = `${rows.length} row${rows.length !== 1 ? 's' : ''}`; const headers = Object.keys(rows[0]); const previewRows = rows.slice(0, 5); const remaining = rows.length - 5; let tableHtml = '' + headers.map(h => `${h}`).join('') + ''; previewRows.forEach(row => { tableHtml += '' + headers.map(h => `${escapeHtml(row[h] || '')}`).join('') + ''; }); tableHtml += ''; document.getElementById('preview-table').innerHTML = tableHtml; if (remaining > 0) { document.getElementById('preview-more-rows').textContent = `+ ${remaining} more row${remaining !== 1 ? 's' : ''}`; document.getElementById('preview-more-rows').style.display = 'block'; } else { document.getElementById('preview-more-rows').style.display = 'none'; } } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } async function executeImport() { document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.add('active'); try { const response = await apiFetch(`/api/${importType}/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rows: importRows }) }); const result = await response.json(); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); if (response.ok && result.success) { const hasErrors = result.errors && result.errors.length > 0; document.getElementById('import-result-icon').textContent = hasErrors ? '⚠️' : '✅'; document.getElementById('import-result-title').textContent = hasErrors ? 'Import completed with some issues' : 'Import successful!'; document.getElementById('import-summary').innerHTML = `
${result.created}
Created
${result.updated}
Updated
${result.errors.length}
Errors
`; if (hasErrors) { const errorsDiv = document.getElementById('import-errors'); errorsDiv.style.display = 'block'; errorsDiv.innerHTML = result.errors.slice(0, 10).map(e => `Row ${e.row}: ${escapeHtml(e.error)}` ).join('
'); if (result.errors.length > 10) { errorsDiv.innerHTML += `
... and ${result.errors.length - 10} more`; } } else { document.getElementById('import-errors').style.display = 'none'; } // Refresh data if (importType === 'volunteers') loadVolunteers(); if (importType === 'donors') loadDonors(); loadDashboardData(); } else { document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = result.error || 'Import failed'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } catch (error) { document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = 'Network error — please try again'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } // ── Locations Management ── const LOCATION_TYPE_LABELS = { main_facility: { label: 'Main Facility', color: '#C44B2B' }, school_pantry: { label: 'School Pantry', color: '#2563EB' }, partner_site: { label: 'Partner Site', color: '#16A34A' }, other: { label: 'Other', color: '#6B7280' } }; async function loadLocationsTab() { try { const data = await apiFetch('/api/locations/all').then(r => r.json()); state.locations.data = data.locations || []; renderLocations(state.locations.data); } catch (e) { document.getElementById('locations-table').innerHTML = '
Error loading locations
'; } } function renderLocations(locations) { const tbody = document.getElementById('locations-table'); if (!locations || locations.length === 0) { tbody.innerHTML = '
📍
No locations found
Add your first location to get started
'; return; } tbody.innerHTML = locations.map(loc => { const typeInfo = LOCATION_TYPE_LABELS[loc.location_type] || LOCATION_TYPE_LABELS.other; return ` ${loc.name} ${typeInfo.label} ${loc.address || '—'} ${loc.is_active ? 'Active' : 'Inactive'} ${loc.is_active ? ` ` : 'Inactive'} `; }).join(''); } function openAddLocationModal() { document.getElementById('location-id').value = ''; document.getElementById('location-modal-title').textContent = 'Add Location'; document.getElementById('location-submit-btn').textContent = 'Add Location'; document.getElementById('location-form').reset(); document.getElementById('location-modal').classList.add('active'); } function openEditLocationModal(id, name, type, address) { document.getElementById('location-id').value = id; document.getElementById('location-modal-title').textContent = 'Edit Location'; document.getElementById('location-submit-btn').textContent = 'Save Changes'; document.getElementById('loc-name').value = name; document.getElementById('loc-type').value = type; document.getElementById('loc-address').value = address; document.getElementById('location-modal').classList.add('active'); } async function saveLocation(e) { e.preventDefault(); const id = document.getElementById('location-id').value; const payload = { name: document.getElementById('loc-name').value.trim(), location_type: document.getElementById('loc-type').value, address: document.getElementById('loc-address').value.trim() || null }; try { const url = id ? `/api/locations/${id}` : '/api/locations'; const method = id ? 'PUT' : 'POST'; const resp = await apiFetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to save location'); return; } closeModal('location-modal'); await loadLocationsTab(); // Refresh the dropdown so the new location appears await initLocationFilter(); } catch (err) { alert('Network error — please try again'); } } async function deleteLocation(id) { if (!confirm('Remove this location? It will be deactivated and hidden from the filter.')) return; try { const resp = await apiFetch(`/api/locations/${id}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove location'); return; } await loadLocationsTab(); await initLocationFilter(); // If the deleted location was selected, reset to "all" if (String(globalLocationId) === String(id)) { globalLocationId = 'all'; localStorage.setItem('minniesos_location_filter', 'all'); } } catch (err) { alert('Network error — please try again'); } } // ── Users Tab ── const ROLE_LABELS = { admin: '🔑 Admin', coordinator: '📋 Coordinator', volunteer: '🙋 Volunteer' }; const ROLE_COLORS = { admin: '#6B2FA0', coordinator: '#2563EB', volunteer: '#16A34A' }; async function loadUsersTab() { const container = document.getElementById('users-list-container'); if (!container) return; container.innerHTML = '
Loading…
'; try { const data = await apiFetch('/api/users/roles').then(r => r.json()); if (!data.users || !data.users.length) { container.innerHTML = '
No users found.
'; return; } let html = ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; // Preload locations for the dropdown let locations = []; try { const locData = await apiFetch('/api/locations/all').then(r => r.json()); locations = locData.locations || locData || []; } catch(e) { /* ignore */ } const locOptions = locations.map(l => ``).join(''); for (const user of data.users) { const roleBadges = (user.roles || []).map(r => { const color = ROLE_COLORS[r.role] || '#888'; const label = ROLE_LABELS[r.role] || r.role; const locPart = r.location_name ? ` @ ${r.location_name}` : ''; return `${label}${locPart} `; }).join('') || 'No roles'; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; } html += '
NameEmailRolesAdd Role
${user.name || '—'}${user.email}${roleBadges}
'; container.innerHTML = html; } catch (err) { container.innerHTML = '
Failed to load users. You may not have admin access.
'; } } async function assignUserRole(event, userId) { event.preventDefault(); const form = event.target; const role = form.role.value; const location_id = form.location_id.value || null; try { const resp = await apiFetch(`/api/users/${userId}/roles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role, location_id: location_id ? parseInt(location_id) : null }) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to assign role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } async function removeUserRole(userId, roleId) { if (!confirm('Remove this role?')) return; try { const resp = await apiFetch(`/api/users/${userId}/roles/${roleId}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } function showAddUserModal() { // For now just redirect to settings or show an alert with instructions // Full user creation is out of scope for this task; uses /api/auth/setup-style endpoint alert('To add a new user, have them visit the app and use the setup flow, or have an admin create their account via the auth setup endpoint.'); } // ───────────────────────────────────────────────────────────────────── // CAMPAIGN TRACKER // ───────────────────────────────────────────────────────────────────── let _campaignsCache = []; let _campaignDetailChart = null; function destroyCampaignDetailChart() { if (_campaignDetailChart) { _campaignDetailChart.destroy(); _campaignDetailChart = null; } } async function loadCampaigns() { document.getElementById('campaigns-list-view').style.display = ''; document.getElementById('campaigns-detail-view').style.display = 'none'; destroyCampaignDetailChart(); const container = document.getElementById('campaigns-cards-container'); container.innerHTML = '
Loading…
'; try { const res = await apiFetch('/api/campaigns'); const data = await res.json(); _campaignsCache = data.campaigns || []; renderCampaignCards(_campaignsCache); } catch (err) { container.innerHTML = '
Error loading campaigns: ' + escapeHtml(err.message) + '
'; } } function renderCampaignCards(campaigns) { const container = document.getElementById('campaigns-cards-container'); if (!campaigns.length) { container.innerHTML = '
🎯
No campaigns yet
Create your first fundraising campaign to start tracking progress.
'; return; } const statusLabel = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft', cancelled: '❌ Cancelled' }; const statusClass = { active: 'active', completed: 'completed', draft: 'draft', cancelled: 'draft' }; container.innerHTML = '
' + campaigns.map(c => { const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const overGoal = goal > 0 && raised >= goal; const pctDisplay = goal > 0 ? (parseFloat(c.progress_pct || 0)).toFixed(0) + '%' : ''; const progressBar = goal > 0 ? '
+ goal.toLocaleString() + '">
' + pctDisplay + ' of + goal.toLocaleString() + ' goal
' : ''; const startD = c.start_date ? c.start_date.split('T')[0] : ''; const endD = c.end_date ? c.end_date.split('T')[0] : ''; return '
' + '
' + '
' + escapeHtml(c.name) + '
' + '' + (statusLabel[c.status] || c.status) + '' + '
' + '
📅 ' + startD + ' → ' + endD + '
' + progressBar + '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:0,maximumFractionDigits:0}) + 'Raised
' + '
' + (c.donor_count || 0) + 'Donors
' + '
' + (c.donation_count || 0) + 'Donations
' + '
' + '
'; }).join('') + '
'; } async function openCampaignDetail(campaignId) { document.getElementById('campaigns-list-view').style.display = 'none'; const detailView = document.getElementById('campaigns-detail-view'); detailView.style.display = ''; document.getElementById('campaigns-detail-body').innerHTML = '
Loading…
'; destroyCampaignDetailChart(); try { const res = await apiFetch('/api/campaigns/' + campaignId); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to load'); const c = data.campaign; const topDonors = data.top_donors || []; const recentDonations = data.recent_donations || []; const timeline = data.daily_timeline || []; const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const pctDisplay = goal > 0 ? pct.toFixed(0) + '%' : 'No goal set'; const overGoal = goal > 0 && raised >= goal; const daysRemaining = parseInt(c.days_remaining || 0); const daysLabel = daysRemaining > 0 ? daysRemaining + ' days left' : daysRemaining === 0 ? 'Ends today' : 'Ended'; // Thermometer const thermoHtml = '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' + (goal > 0 ? '
of + goal.toLocaleString() + ' goal · ' + pctDisplay + '
' : '
No dollar goal set
') + (goal > 0 ? '
' + (pct > 15 ? pctDisplay : '') + '
' : '') + '
'; // 3 stat cards const statsHtml = '
' + '
💰
+ raised.toLocaleString('en-US', {minimumFractionDigits:0}) + '
Raised
' + '
👥
' + (c.donor_count || 0) + '
Donors
' + '
📅
' + daysLabel + '
Timeline
' + '
'; // Top donors leaderboard const topDonorsHtml = topDonors.length === 0 ? '' : '
🏆 Top Donors
' + '
' + topDonors.map((d, i) => '
' + '
' + ['🥇','🥈','🥉','4️⃣','5️⃣'][i] + '' + escapeHtml(d.name) + '
' + ' + parseFloat(d.total).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' ).join('') + '
'; // Recent donations table const recentHtml = recentDonations.length === 0 ? '' : '
📋 Recent Donations
' + '
' + '' + '' + recentDonations.map((d, i) => '' + '' + '' + '' + '' + '' ).join('') + '
DateDonorAmountType
' + (d.donation_date ? d.donation_date.split('T')[0] : '') + '' + escapeHtml(d.donor_name || '') + ' + parseFloat(d.amount).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '' + (d.donation_type || '') + '
'; // Daily chart canvas const chartHtml = timeline.length === 0 ? '' : '
📊 Daily Donations
' + '
' + '
'; // Action buttons const statusLabel2 = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft' }; const actionsHtml = '
' + '' + (c.status === 'active' ? '' : '') + (c.status !== 'cancelled' ? '' : '') + '
'; document.getElementById('campaigns-detail-body').innerHTML = '
' + '' + '
' + '
' + escapeHtml(c.name) + '
' + '
' + (c.description ? escapeHtml(c.description) : '') + '
' + '
' + thermoHtml + statsHtml + actionsHtml + topDonorsHtml + recentHtml + chartHtml; // Render chart if (timeline.length > 0) { setTimeout(() => { const ctx = document.getElementById('campaign-daily-chart'); if (!ctx) return; _campaignDetailChart = new Chart(ctx.getContext('2d'), { type: 'bar', data: { labels: timeline.map(r => r.date), datasets: [{ label: 'Daily Donations ($)', data: timeline.map(r => parseFloat(r.total)), backgroundColor: 'rgba(45,125,70,0.75)', borderColor: '#2D7D46', borderWidth: 1, borderRadius: 4 }] }, options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: v => ' + v.toLocaleString() } } } } }); }, 50); } } catch (err) { document.getElementById('campaigns-detail-body').innerHTML = '
Error: ' + escapeHtml(err.message) + '
'; } } async function submitNewCampaign(e) { e.preventDefault(); const form = e.target; const fd = new FormData(form); const body = Object.fromEntries(fd); // Remove empty optional fields ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) delete body[k]; }); try { const res = await apiFetch('/api/campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to create campaign'); return; } closeModal('new-campaign-modal'); form.reset(); loadCampaigns(); alert('Campaign created!'); } catch (err) { alert('Network error. Please try again.'); } } function openNewCampaignModal() { const form = document.getElementById('new-campaign-form'); if (form) form.reset(); const today = new Date().toISOString().split('T')[0]; const endDate = new Date(); endDate.setDate(endDate.getDate() + 30); const endStr = endDate.toISOString().split('T')[0]; const sd = form.querySelector('[name=start_date]'); const ed = form.querySelector('[name=end_date]'); if (sd) sd.value = today; if (ed) ed.value = endStr; document.getElementById('new-campaign-modal').classList.add('active'); } let _editingCampaignId = null; function openEditCampaignModal(campaignId) { const c = _campaignsCache.find(x => x.id === campaignId) || {}; _editingCampaignId = campaignId; const form = document.getElementById('new-campaign-form'); if (!form) return; form.querySelector('[name=name]').value = c.name || ''; form.querySelector('[name=description]').value = c.description || ''; form.querySelector('[name=start_date]').value = c.start_date ? c.start_date.split('T')[0] : ''; form.querySelector('[name=end_date]').value = c.end_date ? c.end_date.split('T')[0] : ''; form.querySelector('[name=goal_dollars]').value = c.goal_dollars || ''; form.querySelector('[name=goal_lbs]').value = c.goal_lbs || ''; form.querySelector('[name=goal_items]').value = c.goal_items || ''; form.querySelector('[name=status]').value = c.status || 'active'; document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '✏️ Edit Campaign'; document.getElementById('new-campaign-modal').classList.add('active'); // Override submit to PUT form.onsubmit = async (ev) => { ev.preventDefault(); const fd = new FormData(form); const body = Object.fromEntries(fd); ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) body[k] = null; }); try { const res = await apiFetch('/api/campaigns/' + _editingCampaignId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to update'); return; } closeModal('new-campaign-modal'); document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '➕ New Campaign'; form.onsubmit = submitNewCampaign; _editingCampaignId = null; loadCampaigns(); alert('Campaign updated!'); } catch (err) { alert('Network error. Please try again.'); } }; } async function completeCampaign(id) { if (!confirm('Mark this campaign as completed?')) return; await apiFetch('/api/campaigns/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'completed' }) }); loadCampaigns(); } async function cancelCampaign(id) { if (!confirm('Cancel this campaign? This will hide it from the list.')) return; await apiFetch('/api/campaigns/' + id, { method: 'DELETE' }); loadCampaigns(); } // ── Populate campaigns in Add Donation modal ── async function populateDonationCampaigns() { const select = document.getElementById('donation-campaign-select'); if (!select) return; // Clear existing dynamic options while (select.options.length > 1) select.remove(1); try { const res = await apiFetch('/api/campaigns/active'); const data = await res.json(); (data.campaigns || []).forEach(c => { const opt = document.createElement('option'); opt.value = c.id; opt.setAttribute('data-raised', c.total_raised || 0); opt.setAttribute('data-goal', c.goal_dollars || 0); opt.textContent = c.name; select.appendChild(opt); }); } catch (err) { console.warn('Could not load campaigns for donation form:', err.message); } } function showCampaignProgress() { const select = document.getElementById('donation-campaign-select'); const progressDiv = document.getElementById('donation-campaign-progress'); if (!select || !progressDiv) return; const selected = select.options[select.selectedIndex]; if (!selected || !selected.value) { progressDiv.style.display = 'none'; return; } const raised = parseFloat(selected.getAttribute('data-raised') || 0); const goal = parseFloat(selected.getAttribute('data-goal') || 0); if (goal > 0) { const pct = (raised / goal * 100).toFixed(0); progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' of + goal.toLocaleString() + ' raised (' + pct + '%)'; progressDiv.style.display = ''; } else { progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' raised (no goal set)'; progressDiv.style.display = ''; } } + fmtNum(s.donation_value, 2)) + '
' + '
By Location
' + '' + byLocRows + '
LocationFamiliesLbs DistributedItems
' + '
By Food Category
' + '' + byCatRows + '
CategoryLbs DistributedItems
' + '
' + '
' + '' + '📥 Export CSV for USDA' + '
' + ''; } async function generateGrantImpact() { const start = document.getElementById('grant-start-date').value; const end = document.getElementById('grant-end-date').value; if (!start || !end) { alert('Please select start and end dates.'); return; } if (start > end) { alert('Start date must be before end date.'); return; } const btn = document.getElementById('grant-generate-btn'); btn.disabled = true; btn.textContent = '⏳ Generating…'; try { const resp = await apiFetch('/api/reports/grant-impact?start=' + start + '&end=' + end); const data = await resp.json(); if (!resp.ok || !data.success) throw new Error(data.error || 'Failed to generate grant summary'); renderGrantImpact(data); const view = document.getElementById('grant-impact-view'); view.style.display = ''; view.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) { alert('Error generating grant summary: ' + e.message); } finally { btn.disabled = false; btn.textContent = '📊 Generate Summary'; } } function renderGrantImpact(data) { const s = data.summary; const container = document.getElementById('grant-impact-view'); const topDonorRows = (data.top_donors || []).map(function(d, i) { return '' + (i + 1) + '' + (d.name || '—') + '' + (d.donor_type || '—') + '' + (d.donation_count || 0) + ' const n = new Date(); const y = n.getFullYear(); const m = String(n.getMonth() + 1).padStart(2, '0'); const el = document.getElementById('board-month-input'); if (el) el.value = `${y}-${m}`; })(); async function generateBoardReport() { const monthInput = document.getElementById('board-month-input'); const month = monthInput ? monthInput.value : ''; if (!month) { alert('Please select a month.'); return; } const btn = document.getElementById('board-generate-btn'); btn.disabled = true; btn.textContent = '⏳ Generating…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!res.ok || !data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); // Refresh saved list loadSavedBoardReports(); } catch (err) { alert('Error generating report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } function renderBoardReportPreview(r) { const m = r.metrics; document.getElementById('brp-month').textContent = r.month_label; document.getElementById('brp-summary').textContent = r.executive_summary; // Metrics grid const fmt = (v, digits) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: digits || 0 }); const fmtMoney = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaHtml = (d) => { if (d === null || d === undefined) return ''; const cls = d > 0 ? 'up' : d < 0 ? 'down' : 'flat'; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; return `${arrow} ${Math.abs(d)}% vs prior month`; }; const cards = [ { label: 'Families Served', value: fmt(m.clients.unique_served), sub: `${fmt(m.clients.new_this_month)} new clients this month`, delta: m.clients.delta_unique_served }, { label: 'Pounds Distributed', value: fmt(m.distributions.total_lbs, 1), sub: `${fmt(m.distributions.total_visits)} visits · ${fmt(m.distributions.avg_lbs_per_family, 1)} lbs/family avg`, delta: m.distributions.delta_lbs }, { label: 'Active Volunteers', value: fmt(m.volunteers.active), sub: `${fmt(m.volunteers.hours, 1)} hours · ${m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '% attendance' : 'no attendance data'}`, delta: m.volunteers.delta_active }, { label: 'Donations', value: fmtMoney(m.donations.total_usd), sub: `${fmt(m.donations.donor_count)} donors · ${fmt(m.donations.donation_count)} gifts`, delta: m.donations.delta_total }, { label: 'Current Inventory', value: fmt(m.inventory.current_stock_lbs, 1) + ' lbs', sub: `${fmt(m.inventory.received_this_month_lbs, 1)} lbs received · ${fmt(m.inventory.expiring_soon_items)} items expiring soon`, delta: null }, { label: 'Total Active Clients', value: fmt(m.clients.total_active), sub: 'On file in system', delta: null }, ...(m.inventory.donated_items > 0 ? [{ label: 'Donated Inventory', value: fmt(m.inventory.donated_items) + ' items', sub: `${fmt(m.inventory.donated_lbs, 1)} lbs from ${fmt(m.inventory.donated_by_donors)} donor${m.inventory.donated_by_donors !== 1 ? 's' : ''}`, delta: null }] : []) ]; document.getElementById('brp-metrics-grid').innerHTML = cards.map(c => `
${c.label}
${c.value}
${c.sub}
${deltaHtml(c.delta)}
`).join(''); // Highlights const h = r.highlights; const hlCards = []; if (h.top_volunteer) hlCards.push({ icon: '🏆', label: 'Top Volunteer', value: `${h.top_volunteer.name} · ${h.top_volunteer.hours}h` }); if (h.largest_donation) hlCards.push({ icon: '💛', label: 'Largest Donation', value: `${fmtMoney(h.largest_donation.amount)} from ${h.largest_donation.donor}` }); if (h.busiest_day) hlCards.push({ icon: '📅', label: 'Busiest Day', value: `${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })} · ${h.busiest_day.visits} visits · ${(h.busiest_day.lbs || 0).toLocaleString(undefined, { maximumFractionDigits: 1 })} lbs` }); if (!hlCards.length) hlCards.push({ icon: 'ℹ️', label: 'No Activity', value: 'No data recorded for this month' }); document.getElementById('brp-highlights').innerHTML = hlCards.map(c => `
${c.icon}
${c.label}
${c.value}
`).join(''); } function printBoardReport() { if (!currentBoardReport) return; const r = currentBoardReport; const m = r.metrics; const fmtN = (v, d) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: d || 0 }); const fmtM = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaStr = (d) => { if (d === null || d === undefined) return ''; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; const color = d > 0 ? '#065f46' : d < 0 ? '#991b1b' : '#6b7280'; const bg = d > 0 ? '#d1fae5' : d < 0 ? '#fee2e2' : '#f3f4f6'; return `${arrow} ${Math.abs(d)}%`; }; const h = r.highlights; const hlHtml = [ h.top_volunteer ? `🏆 Top Volunteer${h.top_volunteer.name} — ${h.top_volunteer.hours} hours` : '', h.largest_donation ? `💛 Largest Donation${fmtM(h.largest_donation.amount)} from ${h.largest_donation.donor}` : '', h.busiest_day ? `📅 Busiest Day${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', timeZone: 'UTC' })} — ${h.busiest_day.visits} visits, ${fmtN(h.busiest_day.lbs, 1)} lbs` : '' ].filter(Boolean).join(''); const metricsRows = [ ['Families Served (Unique)', fmtN(m.clients.unique_served), deltaStr(m.clients.delta_unique_served)], ['New Clients Registered', fmtN(m.clients.new_this_month), ''], ['Total Distribution Visits', fmtN(m.distributions.total_visits), deltaStr(m.distributions.delta_visits)], ['Total Pounds Distributed', fmtN(m.distributions.total_lbs, 1) + ' lbs', deltaStr(m.distributions.delta_lbs)], ['Avg Lbs per Family', fmtN(m.distributions.avg_lbs_per_family, 1) + ' lbs', ''], ['Active Volunteers', fmtN(m.volunteers.active), deltaStr(m.volunteers.delta_active)], ['Volunteer Hours', fmtN(m.volunteers.hours, 1), deltaStr(m.volunteers.delta_hours)], ['Attendance Rate', m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '%' : 'N/A', ''], ['Total Donations', fmtM(m.donations.total_usd), deltaStr(m.donations.delta_total)], ['Unique Donors', fmtN(m.donations.donor_count), deltaStr(m.donations.delta_donors)], ['Current Inventory', fmtN(m.inventory.current_stock_lbs, 1) + ' lbs', ''], ['Inventory Received This Month', fmtN(m.inventory.received_this_month_lbs, 1) + ' lbs', ''], ['Items Expiring Within 30 Days', fmtN(m.inventory.expiring_soon_items), ''], ...(m.inventory.donated_items > 0 ? [ ['Donated Inventory (Items)', fmtN(m.inventory.donated_items) + ' items (' + fmtN(m.inventory.donated_lbs, 1) + ' lbs)', ''], ['Donation Sources (Donors)', fmtN(m.inventory.donated_by_donors) + ' donor' + (m.inventory.donated_by_donors !== 1 ? 's' : ''), ''] ] : []), ['Total Active Clients on File', fmtN(m.clients.total_active), ''] ].map(([label, val, d]) => `${label}${val}${d}`).join(''); const html = `Board Report — ${r.month_label}
Minnie's Food Pantry
Monthly Board Report
${r.month_label}
${r.executive_summary}
Key Performance Metrics
${metricsRows}
MetricValuevs. Prior Month
${hlHtml ? `
Month Highlights
${hlHtml}
` : ''}
`; const blob = new Blob([html], { type: 'text/html' }); window.open(URL.createObjectURL(blob), '_blank'); } async function loadSavedBoardReports() { const list = document.getElementById('saved-board-reports-list'); try { const res = await apiFetch('/api/reports/monthly/saved'); const data = await res.json(); if (!data.success || !data.reports.length) { list.innerHTML = '
No saved reports yet. Generate your first board report above.
'; return; } list.innerHTML = data.reports.map(r => `
${formatSavedReportMonth(r.report_month)}
Generated ${new Date(r.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
`).join(''); } catch (err) { list.innerHTML = '
Could not load saved reports.
'; } } function formatSavedReportMonth(ym) { const [y, m] = ym.split('-').map(Number); return new Date(y, m - 1, 1).toLocaleString('en-US', { month: 'long', year: 'numeric' }); } async function viewSavedBoardReport(month) { const btn = document.getElementById('board-generate-btn'); const mi = document.getElementById('board-month-input'); if (mi) mi.value = month; btn.disabled = true; btn.textContent = '⏳ Loading…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (err) { alert('Error loading report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } async function deleteSavedBoardReport(id, btnEl) { if (!confirm('Delete this saved report?')) return; try { const res = await apiFetch(`/api/reports/monthly/saved/${id}`, { method: 'DELETE' }); if (res.ok) { loadSavedBoardReports(); if (currentBoardReport) { document.getElementById('board-report-preview').style.display = 'none'; currentBoardReport = null; } } } catch (err) { alert('Delete failed: ' + err.message); } } // Close modals on outside click window.onclick = function(event) { if (event.target.classList.contains('modal')) { event.target.classList.remove('active'); } } // ── CSV Export ── async function exportCSV(type) { const token = localStorage.getItem('mfp_admin_token'); if (!token) { alert('Not authenticated'); return; } // Map type to API path and filename label const pathMap = { volunteers: 'volunteers', donors: 'donors', clients: 'clients', inventory: 'inventory', visits: 'visits' }; const apiPath = pathMap[type]; if (!apiPath) { alert('Unknown export type'); return; } // Build URL with optional date range from the Reports tab pickers (if available) const startEl = document.getElementById('report-start-date'); const endEl = document.getElementById('report-end-date'); let url = `/api/${apiPath}/export?format=csv`; if (startEl && startEl.value) url += `&start=${startEl.value}`; if (endEl && endEl.value) url += `&end=${endEl.value}`; try { const resp = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || `HTTP ${resp.status}`); } const blob = await resp.blob(); const blobUrl = URL.createObjectURL(blob); const today = new Date().toISOString().slice(0, 10); const a = document.createElement('a'); a.href = blobUrl; a.download = `${type}-export-${today}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); } catch (err) { alert('Export failed: ' + err.message); } } // ── CSV Import ── let importType = ''; let importRows = []; const csvFormats = { volunteers: { title: 'Import Volunteers', hint: 'CSV headers: name, email, phone, status, skills
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,phone,status,skills\nJane Smith,jane@example.com,555-1234,active,Logistics' }, donors: { title: 'Import Donors', hint: 'CSV headers: name, email, total_given, last_gift_date, phone, donor_type, status
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,total_given,last_gift_date\nJohn Doe,john@example.com,500.00,2025-12-15' } }; function openImportModal(type) { importType = type; importRows = []; const config = csvFormats[type]; document.getElementById('import-modal-title').textContent = config.title; document.getElementById('csv-format-hint').innerHTML = config.hint; resetImportModal(); document.getElementById('import-csv-modal').classList.add('active'); } function closeImportModal() { document.getElementById('import-csv-modal').classList.remove('active'); importType = ''; importRows = []; } function resetImportModal() { document.getElementById('import-step-upload').style.display = 'block'; document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.remove('active'); document.getElementById('import-file-input').value = ''; importRows = []; } // Drag and drop const dropZone = document.getElementById('import-drop-zone'); ['dragenter', 'dragover'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add('drag-over'); }); }); ['dragleave', 'drop'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('drag-over'); }); }); dropZone.addEventListener('drop', (e) => { const files = e.dataTransfer.files; if (files.length > 0) { processFile(files[0]); } }); function handleFileSelect(e) { if (e.target.files.length > 0) { processFile(e.target.files[0]); } } function processFile(file) { if (!file.name.toLowerCase().endsWith('.csv') && file.type !== 'text/csv') { alert('Please upload a CSV file'); return; } const reader = new FileReader(); reader.onload = (e) => { const text = e.target.result; const parsed = parseCSV(text); if (parsed.length === 0) { alert('No data rows found in CSV'); return; } importRows = parsed; showPreview(file.name, parsed); }; reader.readAsText(file); } function parseCSV(text) { const lines = text.split(/\r?\n/).filter(line => line.trim()); if (lines.length < 2) return []; const headers = parseCSVLine(lines[0]).map(h => h.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_')); const rows = []; for (let i = 1; i < lines.length; i++) { const values = parseCSVLine(lines[i]); if (values.length === 0 || (values.length === 1 && !values[0].trim())) continue; const row = {}; headers.forEach((header, idx) => { row[header] = (values[idx] || '').trim(); }); rows.push(row); } return rows; } function parseCSVLine(line) { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"') { if (i + 1 < line.length && line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = false; } } else { current += ch; } } else { if (ch === '"') { inQuotes = true; } else if (ch === ',') { result.push(current); current = ''; } else { current += ch; } } } result.push(current); return result; } function showPreview(fileName, rows) { document.getElementById('import-step-upload').style.display = 'none'; document.getElementById('import-step-preview').classList.add('active'); document.getElementById('preview-file-name').textContent = fileName; document.getElementById('preview-row-count').textContent = `${rows.length} row${rows.length !== 1 ? 's' : ''}`; const headers = Object.keys(rows[0]); const previewRows = rows.slice(0, 5); const remaining = rows.length - 5; let tableHtml = '' + headers.map(h => `${h}`).join('') + ''; previewRows.forEach(row => { tableHtml += '' + headers.map(h => `${escapeHtml(row[h] || '')}`).join('') + ''; }); tableHtml += ''; document.getElementById('preview-table').innerHTML = tableHtml; if (remaining > 0) { document.getElementById('preview-more-rows').textContent = `+ ${remaining} more row${remaining !== 1 ? 's' : ''}`; document.getElementById('preview-more-rows').style.display = 'block'; } else { document.getElementById('preview-more-rows').style.display = 'none'; } } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } async function executeImport() { document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.add('active'); try { const response = await apiFetch(`/api/${importType}/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rows: importRows }) }); const result = await response.json(); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); if (response.ok && result.success) { const hasErrors = result.errors && result.errors.length > 0; document.getElementById('import-result-icon').textContent = hasErrors ? '⚠️' : '✅'; document.getElementById('import-result-title').textContent = hasErrors ? 'Import completed with some issues' : 'Import successful!'; document.getElementById('import-summary').innerHTML = `
${result.created}
Created
${result.updated}
Updated
${result.errors.length}
Errors
`; if (hasErrors) { const errorsDiv = document.getElementById('import-errors'); errorsDiv.style.display = 'block'; errorsDiv.innerHTML = result.errors.slice(0, 10).map(e => `Row ${e.row}: ${escapeHtml(e.error)}` ).join('
'); if (result.errors.length > 10) { errorsDiv.innerHTML += `
... and ${result.errors.length - 10} more`; } } else { document.getElementById('import-errors').style.display = 'none'; } // Refresh data if (importType === 'volunteers') loadVolunteers(); if (importType === 'donors') loadDonors(); loadDashboardData(); } else { document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = result.error || 'Import failed'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } catch (error) { document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = 'Network error — please try again'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } // ── Locations Management ── const LOCATION_TYPE_LABELS = { main_facility: { label: 'Main Facility', color: '#C44B2B' }, school_pantry: { label: 'School Pantry', color: '#2563EB' }, partner_site: { label: 'Partner Site', color: '#16A34A' }, other: { label: 'Other', color: '#6B7280' } }; async function loadLocationsTab() { try { const data = await apiFetch('/api/locations/all').then(r => r.json()); state.locations.data = data.locations || []; renderLocations(state.locations.data); } catch (e) { document.getElementById('locations-table').innerHTML = '
Error loading locations
'; } } function renderLocations(locations) { const tbody = document.getElementById('locations-table'); if (!locations || locations.length === 0) { tbody.innerHTML = '
📍
No locations found
Add your first location to get started
'; return; } tbody.innerHTML = locations.map(loc => { const typeInfo = LOCATION_TYPE_LABELS[loc.location_type] || LOCATION_TYPE_LABELS.other; return ` ${loc.name} ${typeInfo.label} ${loc.address || '—'} ${loc.is_active ? 'Active' : 'Inactive'} ${loc.is_active ? ` ` : 'Inactive'} `; }).join(''); } function openAddLocationModal() { document.getElementById('location-id').value = ''; document.getElementById('location-modal-title').textContent = 'Add Location'; document.getElementById('location-submit-btn').textContent = 'Add Location'; document.getElementById('location-form').reset(); document.getElementById('location-modal').classList.add('active'); } function openEditLocationModal(id, name, type, address) { document.getElementById('location-id').value = id; document.getElementById('location-modal-title').textContent = 'Edit Location'; document.getElementById('location-submit-btn').textContent = 'Save Changes'; document.getElementById('loc-name').value = name; document.getElementById('loc-type').value = type; document.getElementById('loc-address').value = address; document.getElementById('location-modal').classList.add('active'); } async function saveLocation(e) { e.preventDefault(); const id = document.getElementById('location-id').value; const payload = { name: document.getElementById('loc-name').value.trim(), location_type: document.getElementById('loc-type').value, address: document.getElementById('loc-address').value.trim() || null }; try { const url = id ? `/api/locations/${id}` : '/api/locations'; const method = id ? 'PUT' : 'POST'; const resp = await apiFetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to save location'); return; } closeModal('location-modal'); await loadLocationsTab(); // Refresh the dropdown so the new location appears await initLocationFilter(); } catch (err) { alert('Network error — please try again'); } } async function deleteLocation(id) { if (!confirm('Remove this location? It will be deactivated and hidden from the filter.')) return; try { const resp = await apiFetch(`/api/locations/${id}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove location'); return; } await loadLocationsTab(); await initLocationFilter(); // If the deleted location was selected, reset to "all" if (String(globalLocationId) === String(id)) { globalLocationId = 'all'; localStorage.setItem('minniesos_location_filter', 'all'); } } catch (err) { alert('Network error — please try again'); } } // ── Users Tab ── const ROLE_LABELS = { admin: '🔑 Admin', coordinator: '📋 Coordinator', volunteer: '🙋 Volunteer' }; const ROLE_COLORS = { admin: '#6B2FA0', coordinator: '#2563EB', volunteer: '#16A34A' }; async function loadUsersTab() { const container = document.getElementById('users-list-container'); if (!container) return; container.innerHTML = '
Loading…
'; try { const data = await apiFetch('/api/users/roles').then(r => r.json()); if (!data.users || !data.users.length) { container.innerHTML = '
No users found.
'; return; } let html = ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; // Preload locations for the dropdown let locations = []; try { const locData = await apiFetch('/api/locations/all').then(r => r.json()); locations = locData.locations || locData || []; } catch(e) { /* ignore */ } const locOptions = locations.map(l => ``).join(''); for (const user of data.users) { const roleBadges = (user.roles || []).map(r => { const color = ROLE_COLORS[r.role] || '#888'; const label = ROLE_LABELS[r.role] || r.role; const locPart = r.location_name ? ` @ ${r.location_name}` : ''; return `${label}${locPart} `; }).join('') || 'No roles'; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; } html += '
NameEmailRolesAdd Role
${user.name || '—'}${user.email}${roleBadges}
'; container.innerHTML = html; } catch (err) { container.innerHTML = '
Failed to load users. You may not have admin access.
'; } } async function assignUserRole(event, userId) { event.preventDefault(); const form = event.target; const role = form.role.value; const location_id = form.location_id.value || null; try { const resp = await apiFetch(`/api/users/${userId}/roles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role, location_id: location_id ? parseInt(location_id) : null }) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to assign role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } async function removeUserRole(userId, roleId) { if (!confirm('Remove this role?')) return; try { const resp = await apiFetch(`/api/users/${userId}/roles/${roleId}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } function showAddUserModal() { // For now just redirect to settings or show an alert with instructions // Full user creation is out of scope for this task; uses /api/auth/setup-style endpoint alert('To add a new user, have them visit the app and use the setup flow, or have an admin create their account via the auth setup endpoint.'); } // ───────────────────────────────────────────────────────────────────── // CAMPAIGN TRACKER // ───────────────────────────────────────────────────────────────────── let _campaignsCache = []; let _campaignDetailChart = null; function destroyCampaignDetailChart() { if (_campaignDetailChart) { _campaignDetailChart.destroy(); _campaignDetailChart = null; } } async function loadCampaigns() { document.getElementById('campaigns-list-view').style.display = ''; document.getElementById('campaigns-detail-view').style.display = 'none'; destroyCampaignDetailChart(); const container = document.getElementById('campaigns-cards-container'); container.innerHTML = '
Loading…
'; try { const res = await apiFetch('/api/campaigns'); const data = await res.json(); _campaignsCache = data.campaigns || []; renderCampaignCards(_campaignsCache); } catch (err) { container.innerHTML = '
Error loading campaigns: ' + escapeHtml(err.message) + '
'; } } function renderCampaignCards(campaigns) { const container = document.getElementById('campaigns-cards-container'); if (!campaigns.length) { container.innerHTML = '
🎯
No campaigns yet
Create your first fundraising campaign to start tracking progress.
'; return; } const statusLabel = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft', cancelled: '❌ Cancelled' }; const statusClass = { active: 'active', completed: 'completed', draft: 'draft', cancelled: 'draft' }; container.innerHTML = '
' + campaigns.map(c => { const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const overGoal = goal > 0 && raised >= goal; const pctDisplay = goal > 0 ? (parseFloat(c.progress_pct || 0)).toFixed(0) + '%' : ''; const progressBar = goal > 0 ? '
+ goal.toLocaleString() + '">
' + pctDisplay + ' of + goal.toLocaleString() + ' goal
' : ''; const startD = c.start_date ? c.start_date.split('T')[0] : ''; const endD = c.end_date ? c.end_date.split('T')[0] : ''; return '
' + '
' + '
' + escapeHtml(c.name) + '
' + '' + (statusLabel[c.status] || c.status) + '' + '
' + '
📅 ' + startD + ' → ' + endD + '
' + progressBar + '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:0,maximumFractionDigits:0}) + 'Raised
' + '
' + (c.donor_count || 0) + 'Donors
' + '
' + (c.donation_count || 0) + 'Donations
' + '
' + '
'; }).join('') + ''; } async function openCampaignDetail(campaignId) { document.getElementById('campaigns-list-view').style.display = 'none'; const detailView = document.getElementById('campaigns-detail-view'); detailView.style.display = ''; document.getElementById('campaigns-detail-body').innerHTML = '
Loading…
'; destroyCampaignDetailChart(); try { const res = await apiFetch('/api/campaigns/' + campaignId); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to load'); const c = data.campaign; const topDonors = data.top_donors || []; const recentDonations = data.recent_donations || []; const timeline = data.daily_timeline || []; const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const pctDisplay = goal > 0 ? pct.toFixed(0) + '%' : 'No goal set'; const overGoal = goal > 0 && raised >= goal; const daysRemaining = parseInt(c.days_remaining || 0); const daysLabel = daysRemaining > 0 ? daysRemaining + ' days left' : daysRemaining === 0 ? 'Ends today' : 'Ended'; // Thermometer const thermoHtml = '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' + (goal > 0 ? '
of + goal.toLocaleString() + ' goal · ' + pctDisplay + '
' : '
No dollar goal set
') + (goal > 0 ? '
' + (pct > 15 ? pctDisplay : '') + '
' : '') + '
'; // 3 stat cards const statsHtml = '
' + '
💰
+ raised.toLocaleString('en-US', {minimumFractionDigits:0}) + '
Raised
' + '
👥
' + (c.donor_count || 0) + '
Donors
' + '
📅
' + daysLabel + '
Timeline
' + '
'; // Top donors leaderboard const topDonorsHtml = topDonors.length === 0 ? '' : '
🏆 Top Donors
' + '
' + topDonors.map((d, i) => '
' + '
' + ['🥇','🥈','🥉','4️⃣','5️⃣'][i] + '' + escapeHtml(d.name) + '
' + ' + parseFloat(d.total).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' ).join('') + '
'; // Recent donations table const recentHtml = recentDonations.length === 0 ? '' : '
📋 Recent Donations
' + '
' + '' + '' + recentDonations.map((d, i) => '' + '' + '' + '' + '' + '' ).join('') + '
DateDonorAmountType
' + (d.donation_date ? d.donation_date.split('T')[0] : '') + '' + escapeHtml(d.donor_name || '') + ' + parseFloat(d.amount).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '' + (d.donation_type || '') + '
'; // Daily chart canvas const chartHtml = timeline.length === 0 ? '' : '
📊 Daily Donations
' + '
' + '
'; // Action buttons const statusLabel2 = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft' }; const actionsHtml = '
' + '' + (c.status === 'active' ? '' : '') + (c.status !== 'cancelled' ? '' : '') + '
'; document.getElementById('campaigns-detail-body').innerHTML = '
' + '' + '
' + '
' + escapeHtml(c.name) + '
' + '
' + (c.description ? escapeHtml(c.description) : '') + '
' + '
' + thermoHtml + statsHtml + actionsHtml + topDonorsHtml + recentHtml + chartHtml; // Render chart if (timeline.length > 0) { setTimeout(() => { const ctx = document.getElementById('campaign-daily-chart'); if (!ctx) return; _campaignDetailChart = new Chart(ctx.getContext('2d'), { type: 'bar', data: { labels: timeline.map(r => r.date), datasets: [{ label: 'Daily Donations ($)', data: timeline.map(r => parseFloat(r.total)), backgroundColor: 'rgba(45,125,70,0.75)', borderColor: '#2D7D46', borderWidth: 1, borderRadius: 4 }] }, options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: v => ' + v.toLocaleString() } } } } }); }, 50); } } catch (err) { document.getElementById('campaigns-detail-body').innerHTML = '
Error: ' + escapeHtml(err.message) + '
'; } } async function submitNewCampaign(e) { e.preventDefault(); const form = e.target; const fd = new FormData(form); const body = Object.fromEntries(fd); // Remove empty optional fields ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) delete body[k]; }); try { const res = await apiFetch('/api/campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to create campaign'); return; } closeModal('new-campaign-modal'); form.reset(); loadCampaigns(); alert('Campaign created!'); } catch (err) { alert('Network error. Please try again.'); } } function openNewCampaignModal() { const form = document.getElementById('new-campaign-form'); if (form) form.reset(); const today = new Date().toISOString().split('T')[0]; const endDate = new Date(); endDate.setDate(endDate.getDate() + 30); const endStr = endDate.toISOString().split('T')[0]; const sd = form.querySelector('[name=start_date]'); const ed = form.querySelector('[name=end_date]'); if (sd) sd.value = today; if (ed) ed.value = endStr; document.getElementById('new-campaign-modal').classList.add('active'); } let _editingCampaignId = null; function openEditCampaignModal(campaignId) { const c = _campaignsCache.find(x => x.id === campaignId) || {}; _editingCampaignId = campaignId; const form = document.getElementById('new-campaign-form'); if (!form) return; form.querySelector('[name=name]').value = c.name || ''; form.querySelector('[name=description]').value = c.description || ''; form.querySelector('[name=start_date]').value = c.start_date ? c.start_date.split('T')[0] : ''; form.querySelector('[name=end_date]').value = c.end_date ? c.end_date.split('T')[0] : ''; form.querySelector('[name=goal_dollars]').value = c.goal_dollars || ''; form.querySelector('[name=goal_lbs]').value = c.goal_lbs || ''; form.querySelector('[name=goal_items]').value = c.goal_items || ''; form.querySelector('[name=status]').value = c.status || 'active'; document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '✏️ Edit Campaign'; document.getElementById('new-campaign-modal').classList.add('active'); // Override submit to PUT form.onsubmit = async (ev) => { ev.preventDefault(); const fd = new FormData(form); const body = Object.fromEntries(fd); ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) body[k] = null; }); try { const res = await apiFetch('/api/campaigns/' + _editingCampaignId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to update'); return; } closeModal('new-campaign-modal'); document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '➕ New Campaign'; form.onsubmit = submitNewCampaign; _editingCampaignId = null; loadCampaigns(); alert('Campaign updated!'); } catch (err) { alert('Network error. Please try again.'); } }; } async function completeCampaign(id) { if (!confirm('Mark this campaign as completed?')) return; await apiFetch('/api/campaigns/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'completed' }) }); loadCampaigns(); } async function cancelCampaign(id) { if (!confirm('Cancel this campaign? This will hide it from the list.')) return; await apiFetch('/api/campaigns/' + id, { method: 'DELETE' }); loadCampaigns(); } // ── Populate campaigns in Add Donation modal ── async function populateDonationCampaigns() { const select = document.getElementById('donation-campaign-select'); if (!select) return; // Clear existing dynamic options while (select.options.length > 1) select.remove(1); try { const res = await apiFetch('/api/campaigns/active'); const data = await res.json(); (data.campaigns || []).forEach(c => { const opt = document.createElement('option'); opt.value = c.id; opt.setAttribute('data-raised', c.total_raised || 0); opt.setAttribute('data-goal', c.goal_dollars || 0); opt.textContent = c.name; select.appendChild(opt); }); } catch (err) { console.warn('Could not load campaigns for donation form:', err.message); } } function showCampaignProgress() { const select = document.getElementById('donation-campaign-select'); const progressDiv = document.getElementById('donation-campaign-progress'); if (!select || !progressDiv) return; const selected = select.options[select.selectedIndex]; if (!selected || !selected.value) { progressDiv.style.display = 'none'; return; } const raised = parseFloat(selected.getAttribute('data-raised') || 0); const goal = parseFloat(selected.getAttribute('data-goal') || 0); if (goal > 0) { const pct = (raised / goal * 100).toFixed(0); progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' of + goal.toLocaleString() + ' raised (' + pct + '%)'; progressDiv.style.display = ''; } else { progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' raised (no goal set)'; progressDiv.style.display = ''; } } + fmtNum(d.total_donated, 2) + ''; }).join('') || 'No donor data in this period'; const trendRows = (data.monthly_trends || []).map(function(t) { return '' + t.month + '' + t.families_served + '' + fmtNum(t.lbs_distributed, 1) + ' const n = new Date(); const y = n.getFullYear(); const m = String(n.getMonth() + 1).padStart(2, '0'); const el = document.getElementById('board-month-input'); if (el) el.value = `${y}-${m}`; })(); async function generateBoardReport() { const monthInput = document.getElementById('board-month-input'); const month = monthInput ? monthInput.value : ''; if (!month) { alert('Please select a month.'); return; } const btn = document.getElementById('board-generate-btn'); btn.disabled = true; btn.textContent = '⏳ Generating…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!res.ok || !data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); // Refresh saved list loadSavedBoardReports(); } catch (err) { alert('Error generating report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } function renderBoardReportPreview(r) { const m = r.metrics; document.getElementById('brp-month').textContent = r.month_label; document.getElementById('brp-summary').textContent = r.executive_summary; // Metrics grid const fmt = (v, digits) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: digits || 0 }); const fmtMoney = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaHtml = (d) => { if (d === null || d === undefined) return ''; const cls = d > 0 ? 'up' : d < 0 ? 'down' : 'flat'; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; return `${arrow} ${Math.abs(d)}% vs prior month`; }; const cards = [ { label: 'Families Served', value: fmt(m.clients.unique_served), sub: `${fmt(m.clients.new_this_month)} new clients this month`, delta: m.clients.delta_unique_served }, { label: 'Pounds Distributed', value: fmt(m.distributions.total_lbs, 1), sub: `${fmt(m.distributions.total_visits)} visits · ${fmt(m.distributions.avg_lbs_per_family, 1)} lbs/family avg`, delta: m.distributions.delta_lbs }, { label: 'Active Volunteers', value: fmt(m.volunteers.active), sub: `${fmt(m.volunteers.hours, 1)} hours · ${m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '% attendance' : 'no attendance data'}`, delta: m.volunteers.delta_active }, { label: 'Donations', value: fmtMoney(m.donations.total_usd), sub: `${fmt(m.donations.donor_count)} donors · ${fmt(m.donations.donation_count)} gifts`, delta: m.donations.delta_total }, { label: 'Current Inventory', value: fmt(m.inventory.current_stock_lbs, 1) + ' lbs', sub: `${fmt(m.inventory.received_this_month_lbs, 1)} lbs received · ${fmt(m.inventory.expiring_soon_items)} items expiring soon`, delta: null }, { label: 'Total Active Clients', value: fmt(m.clients.total_active), sub: 'On file in system', delta: null }, ...(m.inventory.donated_items > 0 ? [{ label: 'Donated Inventory', value: fmt(m.inventory.donated_items) + ' items', sub: `${fmt(m.inventory.donated_lbs, 1)} lbs from ${fmt(m.inventory.donated_by_donors)} donor${m.inventory.donated_by_donors !== 1 ? 's' : ''}`, delta: null }] : []) ]; document.getElementById('brp-metrics-grid').innerHTML = cards.map(c => `
${c.label}
${c.value}
${c.sub}
${deltaHtml(c.delta)}
`).join(''); // Highlights const h = r.highlights; const hlCards = []; if (h.top_volunteer) hlCards.push({ icon: '🏆', label: 'Top Volunteer', value: `${h.top_volunteer.name} · ${h.top_volunteer.hours}h` }); if (h.largest_donation) hlCards.push({ icon: '💛', label: 'Largest Donation', value: `${fmtMoney(h.largest_donation.amount)} from ${h.largest_donation.donor}` }); if (h.busiest_day) hlCards.push({ icon: '📅', label: 'Busiest Day', value: `${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })} · ${h.busiest_day.visits} visits · ${(h.busiest_day.lbs || 0).toLocaleString(undefined, { maximumFractionDigits: 1 })} lbs` }); if (!hlCards.length) hlCards.push({ icon: 'ℹ️', label: 'No Activity', value: 'No data recorded for this month' }); document.getElementById('brp-highlights').innerHTML = hlCards.map(c => `
${c.icon}
${c.label}
${c.value}
`).join(''); } function printBoardReport() { if (!currentBoardReport) return; const r = currentBoardReport; const m = r.metrics; const fmtN = (v, d) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: d || 0 }); const fmtM = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaStr = (d) => { if (d === null || d === undefined) return ''; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; const color = d > 0 ? '#065f46' : d < 0 ? '#991b1b' : '#6b7280'; const bg = d > 0 ? '#d1fae5' : d < 0 ? '#fee2e2' : '#f3f4f6'; return `${arrow} ${Math.abs(d)}%`; }; const h = r.highlights; const hlHtml = [ h.top_volunteer ? `🏆 Top Volunteer${h.top_volunteer.name} — ${h.top_volunteer.hours} hours` : '', h.largest_donation ? `💛 Largest Donation${fmtM(h.largest_donation.amount)} from ${h.largest_donation.donor}` : '', h.busiest_day ? `📅 Busiest Day${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', timeZone: 'UTC' })} — ${h.busiest_day.visits} visits, ${fmtN(h.busiest_day.lbs, 1)} lbs` : '' ].filter(Boolean).join(''); const metricsRows = [ ['Families Served (Unique)', fmtN(m.clients.unique_served), deltaStr(m.clients.delta_unique_served)], ['New Clients Registered', fmtN(m.clients.new_this_month), ''], ['Total Distribution Visits', fmtN(m.distributions.total_visits), deltaStr(m.distributions.delta_visits)], ['Total Pounds Distributed', fmtN(m.distributions.total_lbs, 1) + ' lbs', deltaStr(m.distributions.delta_lbs)], ['Avg Lbs per Family', fmtN(m.distributions.avg_lbs_per_family, 1) + ' lbs', ''], ['Active Volunteers', fmtN(m.volunteers.active), deltaStr(m.volunteers.delta_active)], ['Volunteer Hours', fmtN(m.volunteers.hours, 1), deltaStr(m.volunteers.delta_hours)], ['Attendance Rate', m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '%' : 'N/A', ''], ['Total Donations', fmtM(m.donations.total_usd), deltaStr(m.donations.delta_total)], ['Unique Donors', fmtN(m.donations.donor_count), deltaStr(m.donations.delta_donors)], ['Current Inventory', fmtN(m.inventory.current_stock_lbs, 1) + ' lbs', ''], ['Inventory Received This Month', fmtN(m.inventory.received_this_month_lbs, 1) + ' lbs', ''], ['Items Expiring Within 30 Days', fmtN(m.inventory.expiring_soon_items), ''], ...(m.inventory.donated_items > 0 ? [ ['Donated Inventory (Items)', fmtN(m.inventory.donated_items) + ' items (' + fmtN(m.inventory.donated_lbs, 1) + ' lbs)', ''], ['Donation Sources (Donors)', fmtN(m.inventory.donated_by_donors) + ' donor' + (m.inventory.donated_by_donors !== 1 ? 's' : ''), ''] ] : []), ['Total Active Clients on File', fmtN(m.clients.total_active), ''] ].map(([label, val, d]) => `${label}${val}${d}`).join(''); const html = `Board Report — ${r.month_label}
Minnie's Food Pantry
Monthly Board Report
${r.month_label}
${r.executive_summary}
Key Performance Metrics
${metricsRows}
MetricValuevs. Prior Month
${hlHtml ? `
Month Highlights
${hlHtml}
` : ''}
`; const blob = new Blob([html], { type: 'text/html' }); window.open(URL.createObjectURL(blob), '_blank'); } async function loadSavedBoardReports() { const list = document.getElementById('saved-board-reports-list'); try { const res = await apiFetch('/api/reports/monthly/saved'); const data = await res.json(); if (!data.success || !data.reports.length) { list.innerHTML = '
No saved reports yet. Generate your first board report above.
'; return; } list.innerHTML = data.reports.map(r => `
${formatSavedReportMonth(r.report_month)}
Generated ${new Date(r.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
`).join(''); } catch (err) { list.innerHTML = '
Could not load saved reports.
'; } } function formatSavedReportMonth(ym) { const [y, m] = ym.split('-').map(Number); return new Date(y, m - 1, 1).toLocaleString('en-US', { month: 'long', year: 'numeric' }); } async function viewSavedBoardReport(month) { const btn = document.getElementById('board-generate-btn'); const mi = document.getElementById('board-month-input'); if (mi) mi.value = month; btn.disabled = true; btn.textContent = '⏳ Loading…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (err) { alert('Error loading report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } async function deleteSavedBoardReport(id, btnEl) { if (!confirm('Delete this saved report?')) return; try { const res = await apiFetch(`/api/reports/monthly/saved/${id}`, { method: 'DELETE' }); if (res.ok) { loadSavedBoardReports(); if (currentBoardReport) { document.getElementById('board-report-preview').style.display = 'none'; currentBoardReport = null; } } } catch (err) { alert('Delete failed: ' + err.message); } } // Close modals on outside click window.onclick = function(event) { if (event.target.classList.contains('modal')) { event.target.classList.remove('active'); } } // ── CSV Export ── async function exportCSV(type) { const token = localStorage.getItem('mfp_admin_token'); if (!token) { alert('Not authenticated'); return; } // Map type to API path and filename label const pathMap = { volunteers: 'volunteers', donors: 'donors', clients: 'clients', inventory: 'inventory', visits: 'visits' }; const apiPath = pathMap[type]; if (!apiPath) { alert('Unknown export type'); return; } // Build URL with optional date range from the Reports tab pickers (if available) const startEl = document.getElementById('report-start-date'); const endEl = document.getElementById('report-end-date'); let url = `/api/${apiPath}/export?format=csv`; if (startEl && startEl.value) url += `&start=${startEl.value}`; if (endEl && endEl.value) url += `&end=${endEl.value}`; try { const resp = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || `HTTP ${resp.status}`); } const blob = await resp.blob(); const blobUrl = URL.createObjectURL(blob); const today = new Date().toISOString().slice(0, 10); const a = document.createElement('a'); a.href = blobUrl; a.download = `${type}-export-${today}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); } catch (err) { alert('Export failed: ' + err.message); } } // ── CSV Import ── let importType = ''; let importRows = []; const csvFormats = { volunteers: { title: 'Import Volunteers', hint: 'CSV headers: name, email, phone, status, skills
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,phone,status,skills\nJane Smith,jane@example.com,555-1234,active,Logistics' }, donors: { title: 'Import Donors', hint: 'CSV headers: name, email, total_given, last_gift_date, phone, donor_type, status
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,total_given,last_gift_date\nJohn Doe,john@example.com,500.00,2025-12-15' } }; function openImportModal(type) { importType = type; importRows = []; const config = csvFormats[type]; document.getElementById('import-modal-title').textContent = config.title; document.getElementById('csv-format-hint').innerHTML = config.hint; resetImportModal(); document.getElementById('import-csv-modal').classList.add('active'); } function closeImportModal() { document.getElementById('import-csv-modal').classList.remove('active'); importType = ''; importRows = []; } function resetImportModal() { document.getElementById('import-step-upload').style.display = 'block'; document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.remove('active'); document.getElementById('import-file-input').value = ''; importRows = []; } // Drag and drop const dropZone = document.getElementById('import-drop-zone'); ['dragenter', 'dragover'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add('drag-over'); }); }); ['dragleave', 'drop'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('drag-over'); }); }); dropZone.addEventListener('drop', (e) => { const files = e.dataTransfer.files; if (files.length > 0) { processFile(files[0]); } }); function handleFileSelect(e) { if (e.target.files.length > 0) { processFile(e.target.files[0]); } } function processFile(file) { if (!file.name.toLowerCase().endsWith('.csv') && file.type !== 'text/csv') { alert('Please upload a CSV file'); return; } const reader = new FileReader(); reader.onload = (e) => { const text = e.target.result; const parsed = parseCSV(text); if (parsed.length === 0) { alert('No data rows found in CSV'); return; } importRows = parsed; showPreview(file.name, parsed); }; reader.readAsText(file); } function parseCSV(text) { const lines = text.split(/\r?\n/).filter(line => line.trim()); if (lines.length < 2) return []; const headers = parseCSVLine(lines[0]).map(h => h.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_')); const rows = []; for (let i = 1; i < lines.length; i++) { const values = parseCSVLine(lines[i]); if (values.length === 0 || (values.length === 1 && !values[0].trim())) continue; const row = {}; headers.forEach((header, idx) => { row[header] = (values[idx] || '').trim(); }); rows.push(row); } return rows; } function parseCSVLine(line) { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"') { if (i + 1 < line.length && line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = false; } } else { current += ch; } } else { if (ch === '"') { inQuotes = true; } else if (ch === ',') { result.push(current); current = ''; } else { current += ch; } } } result.push(current); return result; } function showPreview(fileName, rows) { document.getElementById('import-step-upload').style.display = 'none'; document.getElementById('import-step-preview').classList.add('active'); document.getElementById('preview-file-name').textContent = fileName; document.getElementById('preview-row-count').textContent = `${rows.length} row${rows.length !== 1 ? 's' : ''}`; const headers = Object.keys(rows[0]); const previewRows = rows.slice(0, 5); const remaining = rows.length - 5; let tableHtml = '' + headers.map(h => `${h}`).join('') + ''; previewRows.forEach(row => { tableHtml += '' + headers.map(h => `${escapeHtml(row[h] || '')}`).join('') + ''; }); tableHtml += ''; document.getElementById('preview-table').innerHTML = tableHtml; if (remaining > 0) { document.getElementById('preview-more-rows').textContent = `+ ${remaining} more row${remaining !== 1 ? 's' : ''}`; document.getElementById('preview-more-rows').style.display = 'block'; } else { document.getElementById('preview-more-rows').style.display = 'none'; } } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } async function executeImport() { document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.add('active'); try { const response = await apiFetch(`/api/${importType}/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rows: importRows }) }); const result = await response.json(); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); if (response.ok && result.success) { const hasErrors = result.errors && result.errors.length > 0; document.getElementById('import-result-icon').textContent = hasErrors ? '⚠️' : '✅'; document.getElementById('import-result-title').textContent = hasErrors ? 'Import completed with some issues' : 'Import successful!'; document.getElementById('import-summary').innerHTML = `
${result.created}
Created
${result.updated}
Updated
${result.errors.length}
Errors
`; if (hasErrors) { const errorsDiv = document.getElementById('import-errors'); errorsDiv.style.display = 'block'; errorsDiv.innerHTML = result.errors.slice(0, 10).map(e => `Row ${e.row}: ${escapeHtml(e.error)}` ).join('
'); if (result.errors.length > 10) { errorsDiv.innerHTML += `
... and ${result.errors.length - 10} more`; } } else { document.getElementById('import-errors').style.display = 'none'; } // Refresh data if (importType === 'volunteers') loadVolunteers(); if (importType === 'donors') loadDonors(); loadDashboardData(); } else { document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = result.error || 'Import failed'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } catch (error) { document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = 'Network error — please try again'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } // ── Locations Management ── const LOCATION_TYPE_LABELS = { main_facility: { label: 'Main Facility', color: '#C44B2B' }, school_pantry: { label: 'School Pantry', color: '#2563EB' }, partner_site: { label: 'Partner Site', color: '#16A34A' }, other: { label: 'Other', color: '#6B7280' } }; async function loadLocationsTab() { try { const data = await apiFetch('/api/locations/all').then(r => r.json()); state.locations.data = data.locations || []; renderLocations(state.locations.data); } catch (e) { document.getElementById('locations-table').innerHTML = '
Error loading locations
'; } } function renderLocations(locations) { const tbody = document.getElementById('locations-table'); if (!locations || locations.length === 0) { tbody.innerHTML = '
📍
No locations found
Add your first location to get started
'; return; } tbody.innerHTML = locations.map(loc => { const typeInfo = LOCATION_TYPE_LABELS[loc.location_type] || LOCATION_TYPE_LABELS.other; return ` ${loc.name} ${typeInfo.label} ${loc.address || '—'} ${loc.is_active ? 'Active' : 'Inactive'} ${loc.is_active ? ` ` : 'Inactive'} `; }).join(''); } function openAddLocationModal() { document.getElementById('location-id').value = ''; document.getElementById('location-modal-title').textContent = 'Add Location'; document.getElementById('location-submit-btn').textContent = 'Add Location'; document.getElementById('location-form').reset(); document.getElementById('location-modal').classList.add('active'); } function openEditLocationModal(id, name, type, address) { document.getElementById('location-id').value = id; document.getElementById('location-modal-title').textContent = 'Edit Location'; document.getElementById('location-submit-btn').textContent = 'Save Changes'; document.getElementById('loc-name').value = name; document.getElementById('loc-type').value = type; document.getElementById('loc-address').value = address; document.getElementById('location-modal').classList.add('active'); } async function saveLocation(e) { e.preventDefault(); const id = document.getElementById('location-id').value; const payload = { name: document.getElementById('loc-name').value.trim(), location_type: document.getElementById('loc-type').value, address: document.getElementById('loc-address').value.trim() || null }; try { const url = id ? `/api/locations/${id}` : '/api/locations'; const method = id ? 'PUT' : 'POST'; const resp = await apiFetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to save location'); return; } closeModal('location-modal'); await loadLocationsTab(); // Refresh the dropdown so the new location appears await initLocationFilter(); } catch (err) { alert('Network error — please try again'); } } async function deleteLocation(id) { if (!confirm('Remove this location? It will be deactivated and hidden from the filter.')) return; try { const resp = await apiFetch(`/api/locations/${id}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove location'); return; } await loadLocationsTab(); await initLocationFilter(); // If the deleted location was selected, reset to "all" if (String(globalLocationId) === String(id)) { globalLocationId = 'all'; localStorage.setItem('minniesos_location_filter', 'all'); } } catch (err) { alert('Network error — please try again'); } } // ── Users Tab ── const ROLE_LABELS = { admin: '🔑 Admin', coordinator: '📋 Coordinator', volunteer: '🙋 Volunteer' }; const ROLE_COLORS = { admin: '#6B2FA0', coordinator: '#2563EB', volunteer: '#16A34A' }; async function loadUsersTab() { const container = document.getElementById('users-list-container'); if (!container) return; container.innerHTML = '
Loading…
'; try { const data = await apiFetch('/api/users/roles').then(r => r.json()); if (!data.users || !data.users.length) { container.innerHTML = '
No users found.
'; return; } let html = ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; // Preload locations for the dropdown let locations = []; try { const locData = await apiFetch('/api/locations/all').then(r => r.json()); locations = locData.locations || locData || []; } catch(e) { /* ignore */ } const locOptions = locations.map(l => ``).join(''); for (const user of data.users) { const roleBadges = (user.roles || []).map(r => { const color = ROLE_COLORS[r.role] || '#888'; const label = ROLE_LABELS[r.role] || r.role; const locPart = r.location_name ? ` @ ${r.location_name}` : ''; return `${label}${locPart} `; }).join('') || 'No roles'; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; } html += '
NameEmailRolesAdd Role
${user.name || '—'}${user.email}${roleBadges}
'; container.innerHTML = html; } catch (err) { container.innerHTML = '
Failed to load users. You may not have admin access.
'; } } async function assignUserRole(event, userId) { event.preventDefault(); const form = event.target; const role = form.role.value; const location_id = form.location_id.value || null; try { const resp = await apiFetch(`/api/users/${userId}/roles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role, location_id: location_id ? parseInt(location_id) : null }) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to assign role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } async function removeUserRole(userId, roleId) { if (!confirm('Remove this role?')) return; try { const resp = await apiFetch(`/api/users/${userId}/roles/${roleId}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } function showAddUserModal() { // For now just redirect to settings or show an alert with instructions // Full user creation is out of scope for this task; uses /api/auth/setup-style endpoint alert('To add a new user, have them visit the app and use the setup flow, or have an admin create their account via the auth setup endpoint.'); } // ───────────────────────────────────────────────────────────────────── // CAMPAIGN TRACKER // ───────────────────────────────────────────────────────────────────── let _campaignsCache = []; let _campaignDetailChart = null; function destroyCampaignDetailChart() { if (_campaignDetailChart) { _campaignDetailChart.destroy(); _campaignDetailChart = null; } } async function loadCampaigns() { document.getElementById('campaigns-list-view').style.display = ''; document.getElementById('campaigns-detail-view').style.display = 'none'; destroyCampaignDetailChart(); const container = document.getElementById('campaigns-cards-container'); container.innerHTML = '
Loading…
'; try { const res = await apiFetch('/api/campaigns'); const data = await res.json(); _campaignsCache = data.campaigns || []; renderCampaignCards(_campaignsCache); } catch (err) { container.innerHTML = '
Error loading campaigns: ' + escapeHtml(err.message) + '
'; } } function renderCampaignCards(campaigns) { const container = document.getElementById('campaigns-cards-container'); if (!campaigns.length) { container.innerHTML = '
🎯
No campaigns yet
Create your first fundraising campaign to start tracking progress.
'; return; } const statusLabel = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft', cancelled: '❌ Cancelled' }; const statusClass = { active: 'active', completed: 'completed', draft: 'draft', cancelled: 'draft' }; container.innerHTML = '
' + campaigns.map(c => { const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const overGoal = goal > 0 && raised >= goal; const pctDisplay = goal > 0 ? (parseFloat(c.progress_pct || 0)).toFixed(0) + '%' : ''; const progressBar = goal > 0 ? '
+ goal.toLocaleString() + '">
' + pctDisplay + ' of + goal.toLocaleString() + ' goal
' : ''; const startD = c.start_date ? c.start_date.split('T')[0] : ''; const endD = c.end_date ? c.end_date.split('T')[0] : ''; return '
' + '
' + '
' + escapeHtml(c.name) + '
' + '' + (statusLabel[c.status] || c.status) + '' + '
' + '
📅 ' + startD + ' → ' + endD + '
' + progressBar + '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:0,maximumFractionDigits:0}) + 'Raised
' + '
' + (c.donor_count || 0) + 'Donors
' + '
' + (c.donation_count || 0) + 'Donations
' + '
' + '
'; }).join('') + ''; } async function openCampaignDetail(campaignId) { document.getElementById('campaigns-list-view').style.display = 'none'; const detailView = document.getElementById('campaigns-detail-view'); detailView.style.display = ''; document.getElementById('campaigns-detail-body').innerHTML = '
Loading…
'; destroyCampaignDetailChart(); try { const res = await apiFetch('/api/campaigns/' + campaignId); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to load'); const c = data.campaign; const topDonors = data.top_donors || []; const recentDonations = data.recent_donations || []; const timeline = data.daily_timeline || []; const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const pctDisplay = goal > 0 ? pct.toFixed(0) + '%' : 'No goal set'; const overGoal = goal > 0 && raised >= goal; const daysRemaining = parseInt(c.days_remaining || 0); const daysLabel = daysRemaining > 0 ? daysRemaining + ' days left' : daysRemaining === 0 ? 'Ends today' : 'Ended'; // Thermometer const thermoHtml = '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' + (goal > 0 ? '
of + goal.toLocaleString() + ' goal · ' + pctDisplay + '
' : '
No dollar goal set
') + (goal > 0 ? '
' + (pct > 15 ? pctDisplay : '') + '
' : '') + '
'; // 3 stat cards const statsHtml = '
' + '
💰
+ raised.toLocaleString('en-US', {minimumFractionDigits:0}) + '
Raised
' + '
👥
' + (c.donor_count || 0) + '
Donors
' + '
📅
' + daysLabel + '
Timeline
' + '
'; // Top donors leaderboard const topDonorsHtml = topDonors.length === 0 ? '' : '
🏆 Top Donors
' + '
' + topDonors.map((d, i) => '
' + '
' + ['🥇','🥈','🥉','4️⃣','5️⃣'][i] + '' + escapeHtml(d.name) + '
' + ' + parseFloat(d.total).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' ).join('') + '
'; // Recent donations table const recentHtml = recentDonations.length === 0 ? '' : '
📋 Recent Donations
' + '
' + '' + '' + recentDonations.map((d, i) => '' + '' + '' + '' + '' + '' ).join('') + '
DateDonorAmountType
' + (d.donation_date ? d.donation_date.split('T')[0] : '') + '' + escapeHtml(d.donor_name || '') + ' + parseFloat(d.amount).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '' + (d.donation_type || '') + '
'; // Daily chart canvas const chartHtml = timeline.length === 0 ? '' : '
📊 Daily Donations
' + '
' + '
'; // Action buttons const statusLabel2 = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft' }; const actionsHtml = '
' + '' + (c.status === 'active' ? '' : '') + (c.status !== 'cancelled' ? '' : '') + '
'; document.getElementById('campaigns-detail-body').innerHTML = '
' + '' + '
' + '
' + escapeHtml(c.name) + '
' + '
' + (c.description ? escapeHtml(c.description) : '') + '
' + '
' + thermoHtml + statsHtml + actionsHtml + topDonorsHtml + recentHtml + chartHtml; // Render chart if (timeline.length > 0) { setTimeout(() => { const ctx = document.getElementById('campaign-daily-chart'); if (!ctx) return; _campaignDetailChart = new Chart(ctx.getContext('2d'), { type: 'bar', data: { labels: timeline.map(r => r.date), datasets: [{ label: 'Daily Donations ($)', data: timeline.map(r => parseFloat(r.total)), backgroundColor: 'rgba(45,125,70,0.75)', borderColor: '#2D7D46', borderWidth: 1, borderRadius: 4 }] }, options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: v => ' + v.toLocaleString() } } } } }); }, 50); } } catch (err) { document.getElementById('campaigns-detail-body').innerHTML = '
Error: ' + escapeHtml(err.message) + '
'; } } async function submitNewCampaign(e) { e.preventDefault(); const form = e.target; const fd = new FormData(form); const body = Object.fromEntries(fd); // Remove empty optional fields ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) delete body[k]; }); try { const res = await apiFetch('/api/campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to create campaign'); return; } closeModal('new-campaign-modal'); form.reset(); loadCampaigns(); alert('Campaign created!'); } catch (err) { alert('Network error. Please try again.'); } } function openNewCampaignModal() { const form = document.getElementById('new-campaign-form'); if (form) form.reset(); const today = new Date().toISOString().split('T')[0]; const endDate = new Date(); endDate.setDate(endDate.getDate() + 30); const endStr = endDate.toISOString().split('T')[0]; const sd = form.querySelector('[name=start_date]'); const ed = form.querySelector('[name=end_date]'); if (sd) sd.value = today; if (ed) ed.value = endStr; document.getElementById('new-campaign-modal').classList.add('active'); } let _editingCampaignId = null; function openEditCampaignModal(campaignId) { const c = _campaignsCache.find(x => x.id === campaignId) || {}; _editingCampaignId = campaignId; const form = document.getElementById('new-campaign-form'); if (!form) return; form.querySelector('[name=name]').value = c.name || ''; form.querySelector('[name=description]').value = c.description || ''; form.querySelector('[name=start_date]').value = c.start_date ? c.start_date.split('T')[0] : ''; form.querySelector('[name=end_date]').value = c.end_date ? c.end_date.split('T')[0] : ''; form.querySelector('[name=goal_dollars]').value = c.goal_dollars || ''; form.querySelector('[name=goal_lbs]').value = c.goal_lbs || ''; form.querySelector('[name=goal_items]').value = c.goal_items || ''; form.querySelector('[name=status]').value = c.status || 'active'; document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '✏️ Edit Campaign'; document.getElementById('new-campaign-modal').classList.add('active'); // Override submit to PUT form.onsubmit = async (ev) => { ev.preventDefault(); const fd = new FormData(form); const body = Object.fromEntries(fd); ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) body[k] = null; }); try { const res = await apiFetch('/api/campaigns/' + _editingCampaignId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to update'); return; } closeModal('new-campaign-modal'); document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '➕ New Campaign'; form.onsubmit = submitNewCampaign; _editingCampaignId = null; loadCampaigns(); alert('Campaign updated!'); } catch (err) { alert('Network error. Please try again.'); } }; } async function completeCampaign(id) { if (!confirm('Mark this campaign as completed?')) return; await apiFetch('/api/campaigns/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'completed' }) }); loadCampaigns(); } async function cancelCampaign(id) { if (!confirm('Cancel this campaign? This will hide it from the list.')) return; await apiFetch('/api/campaigns/' + id, { method: 'DELETE' }); loadCampaigns(); } // ── Populate campaigns in Add Donation modal ── async function populateDonationCampaigns() { const select = document.getElementById('donation-campaign-select'); if (!select) return; // Clear existing dynamic options while (select.options.length > 1) select.remove(1); try { const res = await apiFetch('/api/campaigns/active'); const data = await res.json(); (data.campaigns || []).forEach(c => { const opt = document.createElement('option'); opt.value = c.id; opt.setAttribute('data-raised', c.total_raised || 0); opt.setAttribute('data-goal', c.goal_dollars || 0); opt.textContent = c.name; select.appendChild(opt); }); } catch (err) { console.warn('Could not load campaigns for donation form:', err.message); } } function showCampaignProgress() { const select = document.getElementById('donation-campaign-select'); const progressDiv = document.getElementById('donation-campaign-progress'); if (!select || !progressDiv) return; const selected = select.options[select.selectedIndex]; if (!selected || !selected.value) { progressDiv.style.display = 'none'; return; } const raised = parseFloat(selected.getAttribute('data-raised') || 0); const goal = parseFloat(selected.getAttribute('data-goal') || 0); if (goal > 0) { const pct = (raised / goal * 100).toFixed(0); progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' of + goal.toLocaleString() + ' raised (' + pct + '%)'; progressDiv.style.display = ''; } else { progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' raised (no goal set)'; progressDiv.style.display = ''; } } + fmtNum(t.donation_value, 0) + '' + fmtNum(t.volunteer_hours, 1) + ''; }).join('') || 'No trend data available'; const costDisplay = s.cost_per_meal > 0 ? ' const n = new Date(); const y = n.getFullYear(); const m = String(n.getMonth() + 1).padStart(2, '0'); const el = document.getElementById('board-month-input'); if (el) el.value = `${y}-${m}`; })(); async function generateBoardReport() { const monthInput = document.getElementById('board-month-input'); const month = monthInput ? monthInput.value : ''; if (!month) { alert('Please select a month.'); return; } const btn = document.getElementById('board-generate-btn'); btn.disabled = true; btn.textContent = '⏳ Generating…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!res.ok || !data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); // Refresh saved list loadSavedBoardReports(); } catch (err) { alert('Error generating report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } function renderBoardReportPreview(r) { const m = r.metrics; document.getElementById('brp-month').textContent = r.month_label; document.getElementById('brp-summary').textContent = r.executive_summary; // Metrics grid const fmt = (v, digits) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: digits || 0 }); const fmtMoney = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaHtml = (d) => { if (d === null || d === undefined) return ''; const cls = d > 0 ? 'up' : d < 0 ? 'down' : 'flat'; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; return `${arrow} ${Math.abs(d)}% vs prior month`; }; const cards = [ { label: 'Families Served', value: fmt(m.clients.unique_served), sub: `${fmt(m.clients.new_this_month)} new clients this month`, delta: m.clients.delta_unique_served }, { label: 'Pounds Distributed', value: fmt(m.distributions.total_lbs, 1), sub: `${fmt(m.distributions.total_visits)} visits · ${fmt(m.distributions.avg_lbs_per_family, 1)} lbs/family avg`, delta: m.distributions.delta_lbs }, { label: 'Active Volunteers', value: fmt(m.volunteers.active), sub: `${fmt(m.volunteers.hours, 1)} hours · ${m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '% attendance' : 'no attendance data'}`, delta: m.volunteers.delta_active }, { label: 'Donations', value: fmtMoney(m.donations.total_usd), sub: `${fmt(m.donations.donor_count)} donors · ${fmt(m.donations.donation_count)} gifts`, delta: m.donations.delta_total }, { label: 'Current Inventory', value: fmt(m.inventory.current_stock_lbs, 1) + ' lbs', sub: `${fmt(m.inventory.received_this_month_lbs, 1)} lbs received · ${fmt(m.inventory.expiring_soon_items)} items expiring soon`, delta: null }, { label: 'Total Active Clients', value: fmt(m.clients.total_active), sub: 'On file in system', delta: null }, ...(m.inventory.donated_items > 0 ? [{ label: 'Donated Inventory', value: fmt(m.inventory.donated_items) + ' items', sub: `${fmt(m.inventory.donated_lbs, 1)} lbs from ${fmt(m.inventory.donated_by_donors)} donor${m.inventory.donated_by_donors !== 1 ? 's' : ''}`, delta: null }] : []) ]; document.getElementById('brp-metrics-grid').innerHTML = cards.map(c => `
${c.label}
${c.value}
${c.sub}
${deltaHtml(c.delta)}
`).join(''); // Highlights const h = r.highlights; const hlCards = []; if (h.top_volunteer) hlCards.push({ icon: '🏆', label: 'Top Volunteer', value: `${h.top_volunteer.name} · ${h.top_volunteer.hours}h` }); if (h.largest_donation) hlCards.push({ icon: '💛', label: 'Largest Donation', value: `${fmtMoney(h.largest_donation.amount)} from ${h.largest_donation.donor}` }); if (h.busiest_day) hlCards.push({ icon: '📅', label: 'Busiest Day', value: `${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })} · ${h.busiest_day.visits} visits · ${(h.busiest_day.lbs || 0).toLocaleString(undefined, { maximumFractionDigits: 1 })} lbs` }); if (!hlCards.length) hlCards.push({ icon: 'ℹ️', label: 'No Activity', value: 'No data recorded for this month' }); document.getElementById('brp-highlights').innerHTML = hlCards.map(c => `
${c.icon}
${c.label}
${c.value}
`).join(''); } function printBoardReport() { if (!currentBoardReport) return; const r = currentBoardReport; const m = r.metrics; const fmtN = (v, d) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: d || 0 }); const fmtM = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaStr = (d) => { if (d === null || d === undefined) return ''; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; const color = d > 0 ? '#065f46' : d < 0 ? '#991b1b' : '#6b7280'; const bg = d > 0 ? '#d1fae5' : d < 0 ? '#fee2e2' : '#f3f4f6'; return `${arrow} ${Math.abs(d)}%`; }; const h = r.highlights; const hlHtml = [ h.top_volunteer ? `🏆 Top Volunteer${h.top_volunteer.name} — ${h.top_volunteer.hours} hours` : '', h.largest_donation ? `💛 Largest Donation${fmtM(h.largest_donation.amount)} from ${h.largest_donation.donor}` : '', h.busiest_day ? `📅 Busiest Day${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', timeZone: 'UTC' })} — ${h.busiest_day.visits} visits, ${fmtN(h.busiest_day.lbs, 1)} lbs` : '' ].filter(Boolean).join(''); const metricsRows = [ ['Families Served (Unique)', fmtN(m.clients.unique_served), deltaStr(m.clients.delta_unique_served)], ['New Clients Registered', fmtN(m.clients.new_this_month), ''], ['Total Distribution Visits', fmtN(m.distributions.total_visits), deltaStr(m.distributions.delta_visits)], ['Total Pounds Distributed', fmtN(m.distributions.total_lbs, 1) + ' lbs', deltaStr(m.distributions.delta_lbs)], ['Avg Lbs per Family', fmtN(m.distributions.avg_lbs_per_family, 1) + ' lbs', ''], ['Active Volunteers', fmtN(m.volunteers.active), deltaStr(m.volunteers.delta_active)], ['Volunteer Hours', fmtN(m.volunteers.hours, 1), deltaStr(m.volunteers.delta_hours)], ['Attendance Rate', m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '%' : 'N/A', ''], ['Total Donations', fmtM(m.donations.total_usd), deltaStr(m.donations.delta_total)], ['Unique Donors', fmtN(m.donations.donor_count), deltaStr(m.donations.delta_donors)], ['Current Inventory', fmtN(m.inventory.current_stock_lbs, 1) + ' lbs', ''], ['Inventory Received This Month', fmtN(m.inventory.received_this_month_lbs, 1) + ' lbs', ''], ['Items Expiring Within 30 Days', fmtN(m.inventory.expiring_soon_items), ''], ...(m.inventory.donated_items > 0 ? [ ['Donated Inventory (Items)', fmtN(m.inventory.donated_items) + ' items (' + fmtN(m.inventory.donated_lbs, 1) + ' lbs)', ''], ['Donation Sources (Donors)', fmtN(m.inventory.donated_by_donors) + ' donor' + (m.inventory.donated_by_donors !== 1 ? 's' : ''), ''] ] : []), ['Total Active Clients on File', fmtN(m.clients.total_active), ''] ].map(([label, val, d]) => `${label}${val}${d}`).join(''); const html = `Board Report — ${r.month_label}
Minnie's Food Pantry
Monthly Board Report
${r.month_label}
${r.executive_summary}
Key Performance Metrics
${metricsRows}
MetricValuevs. Prior Month
${hlHtml ? `
Month Highlights
${hlHtml}
` : ''}
`; const blob = new Blob([html], { type: 'text/html' }); window.open(URL.createObjectURL(blob), '_blank'); } async function loadSavedBoardReports() { const list = document.getElementById('saved-board-reports-list'); try { const res = await apiFetch('/api/reports/monthly/saved'); const data = await res.json(); if (!data.success || !data.reports.length) { list.innerHTML = '
No saved reports yet. Generate your first board report above.
'; return; } list.innerHTML = data.reports.map(r => `
${formatSavedReportMonth(r.report_month)}
Generated ${new Date(r.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
`).join(''); } catch (err) { list.innerHTML = '
Could not load saved reports.
'; } } function formatSavedReportMonth(ym) { const [y, m] = ym.split('-').map(Number); return new Date(y, m - 1, 1).toLocaleString('en-US', { month: 'long', year: 'numeric' }); } async function viewSavedBoardReport(month) { const btn = document.getElementById('board-generate-btn'); const mi = document.getElementById('board-month-input'); if (mi) mi.value = month; btn.disabled = true; btn.textContent = '⏳ Loading…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (err) { alert('Error loading report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } async function deleteSavedBoardReport(id, btnEl) { if (!confirm('Delete this saved report?')) return; try { const res = await apiFetch(`/api/reports/monthly/saved/${id}`, { method: 'DELETE' }); if (res.ok) { loadSavedBoardReports(); if (currentBoardReport) { document.getElementById('board-report-preview').style.display = 'none'; currentBoardReport = null; } } } catch (err) { alert('Delete failed: ' + err.message); } } // Close modals on outside click window.onclick = function(event) { if (event.target.classList.contains('modal')) { event.target.classList.remove('active'); } } // ── CSV Export ── async function exportCSV(type) { const token = localStorage.getItem('mfp_admin_token'); if (!token) { alert('Not authenticated'); return; } // Map type to API path and filename label const pathMap = { volunteers: 'volunteers', donors: 'donors', clients: 'clients', inventory: 'inventory', visits: 'visits' }; const apiPath = pathMap[type]; if (!apiPath) { alert('Unknown export type'); return; } // Build URL with optional date range from the Reports tab pickers (if available) const startEl = document.getElementById('report-start-date'); const endEl = document.getElementById('report-end-date'); let url = `/api/${apiPath}/export?format=csv`; if (startEl && startEl.value) url += `&start=${startEl.value}`; if (endEl && endEl.value) url += `&end=${endEl.value}`; try { const resp = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || `HTTP ${resp.status}`); } const blob = await resp.blob(); const blobUrl = URL.createObjectURL(blob); const today = new Date().toISOString().slice(0, 10); const a = document.createElement('a'); a.href = blobUrl; a.download = `${type}-export-${today}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); } catch (err) { alert('Export failed: ' + err.message); } } // ── CSV Import ── let importType = ''; let importRows = []; const csvFormats = { volunteers: { title: 'Import Volunteers', hint: 'CSV headers: name, email, phone, status, skills
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,phone,status,skills\nJane Smith,jane@example.com,555-1234,active,Logistics' }, donors: { title: 'Import Donors', hint: 'CSV headers: name, email, total_given, last_gift_date, phone, donor_type, status
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,total_given,last_gift_date\nJohn Doe,john@example.com,500.00,2025-12-15' } }; function openImportModal(type) { importType = type; importRows = []; const config = csvFormats[type]; document.getElementById('import-modal-title').textContent = config.title; document.getElementById('csv-format-hint').innerHTML = config.hint; resetImportModal(); document.getElementById('import-csv-modal').classList.add('active'); } function closeImportModal() { document.getElementById('import-csv-modal').classList.remove('active'); importType = ''; importRows = []; } function resetImportModal() { document.getElementById('import-step-upload').style.display = 'block'; document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.remove('active'); document.getElementById('import-file-input').value = ''; importRows = []; } // Drag and drop const dropZone = document.getElementById('import-drop-zone'); ['dragenter', 'dragover'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add('drag-over'); }); }); ['dragleave', 'drop'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('drag-over'); }); }); dropZone.addEventListener('drop', (e) => { const files = e.dataTransfer.files; if (files.length > 0) { processFile(files[0]); } }); function handleFileSelect(e) { if (e.target.files.length > 0) { processFile(e.target.files[0]); } } function processFile(file) { if (!file.name.toLowerCase().endsWith('.csv') && file.type !== 'text/csv') { alert('Please upload a CSV file'); return; } const reader = new FileReader(); reader.onload = (e) => { const text = e.target.result; const parsed = parseCSV(text); if (parsed.length === 0) { alert('No data rows found in CSV'); return; } importRows = parsed; showPreview(file.name, parsed); }; reader.readAsText(file); } function parseCSV(text) { const lines = text.split(/\r?\n/).filter(line => line.trim()); if (lines.length < 2) return []; const headers = parseCSVLine(lines[0]).map(h => h.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_')); const rows = []; for (let i = 1; i < lines.length; i++) { const values = parseCSVLine(lines[i]); if (values.length === 0 || (values.length === 1 && !values[0].trim())) continue; const row = {}; headers.forEach((header, idx) => { row[header] = (values[idx] || '').trim(); }); rows.push(row); } return rows; } function parseCSVLine(line) { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"') { if (i + 1 < line.length && line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = false; } } else { current += ch; } } else { if (ch === '"') { inQuotes = true; } else if (ch === ',') { result.push(current); current = ''; } else { current += ch; } } } result.push(current); return result; } function showPreview(fileName, rows) { document.getElementById('import-step-upload').style.display = 'none'; document.getElementById('import-step-preview').classList.add('active'); document.getElementById('preview-file-name').textContent = fileName; document.getElementById('preview-row-count').textContent = `${rows.length} row${rows.length !== 1 ? 's' : ''}`; const headers = Object.keys(rows[0]); const previewRows = rows.slice(0, 5); const remaining = rows.length - 5; let tableHtml = '' + headers.map(h => `${h}`).join('') + ''; previewRows.forEach(row => { tableHtml += '' + headers.map(h => `${escapeHtml(row[h] || '')}`).join('') + ''; }); tableHtml += ''; document.getElementById('preview-table').innerHTML = tableHtml; if (remaining > 0) { document.getElementById('preview-more-rows').textContent = `+ ${remaining} more row${remaining !== 1 ? 's' : ''}`; document.getElementById('preview-more-rows').style.display = 'block'; } else { document.getElementById('preview-more-rows').style.display = 'none'; } } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } async function executeImport() { document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.add('active'); try { const response = await apiFetch(`/api/${importType}/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rows: importRows }) }); const result = await response.json(); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); if (response.ok && result.success) { const hasErrors = result.errors && result.errors.length > 0; document.getElementById('import-result-icon').textContent = hasErrors ? '⚠️' : '✅'; document.getElementById('import-result-title').textContent = hasErrors ? 'Import completed with some issues' : 'Import successful!'; document.getElementById('import-summary').innerHTML = `
${result.created}
Created
${result.updated}
Updated
${result.errors.length}
Errors
`; if (hasErrors) { const errorsDiv = document.getElementById('import-errors'); errorsDiv.style.display = 'block'; errorsDiv.innerHTML = result.errors.slice(0, 10).map(e => `Row ${e.row}: ${escapeHtml(e.error)}` ).join('
'); if (result.errors.length > 10) { errorsDiv.innerHTML += `
... and ${result.errors.length - 10} more`; } } else { document.getElementById('import-errors').style.display = 'none'; } // Refresh data if (importType === 'volunteers') loadVolunteers(); if (importType === 'donors') loadDonors(); loadDashboardData(); } else { document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = result.error || 'Import failed'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } catch (error) { document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = 'Network error — please try again'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } // ── Locations Management ── const LOCATION_TYPE_LABELS = { main_facility: { label: 'Main Facility', color: '#C44B2B' }, school_pantry: { label: 'School Pantry', color: '#2563EB' }, partner_site: { label: 'Partner Site', color: '#16A34A' }, other: { label: 'Other', color: '#6B7280' } }; async function loadLocationsTab() { try { const data = await apiFetch('/api/locations/all').then(r => r.json()); state.locations.data = data.locations || []; renderLocations(state.locations.data); } catch (e) { document.getElementById('locations-table').innerHTML = '
Error loading locations
'; } } function renderLocations(locations) { const tbody = document.getElementById('locations-table'); if (!locations || locations.length === 0) { tbody.innerHTML = '
📍
No locations found
Add your first location to get started
'; return; } tbody.innerHTML = locations.map(loc => { const typeInfo = LOCATION_TYPE_LABELS[loc.location_type] || LOCATION_TYPE_LABELS.other; return ` ${loc.name} ${typeInfo.label} ${loc.address || '—'} ${loc.is_active ? 'Active' : 'Inactive'} ${loc.is_active ? ` ` : 'Inactive'} `; }).join(''); } function openAddLocationModal() { document.getElementById('location-id').value = ''; document.getElementById('location-modal-title').textContent = 'Add Location'; document.getElementById('location-submit-btn').textContent = 'Add Location'; document.getElementById('location-form').reset(); document.getElementById('location-modal').classList.add('active'); } function openEditLocationModal(id, name, type, address) { document.getElementById('location-id').value = id; document.getElementById('location-modal-title').textContent = 'Edit Location'; document.getElementById('location-submit-btn').textContent = 'Save Changes'; document.getElementById('loc-name').value = name; document.getElementById('loc-type').value = type; document.getElementById('loc-address').value = address; document.getElementById('location-modal').classList.add('active'); } async function saveLocation(e) { e.preventDefault(); const id = document.getElementById('location-id').value; const payload = { name: document.getElementById('loc-name').value.trim(), location_type: document.getElementById('loc-type').value, address: document.getElementById('loc-address').value.trim() || null }; try { const url = id ? `/api/locations/${id}` : '/api/locations'; const method = id ? 'PUT' : 'POST'; const resp = await apiFetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to save location'); return; } closeModal('location-modal'); await loadLocationsTab(); // Refresh the dropdown so the new location appears await initLocationFilter(); } catch (err) { alert('Network error — please try again'); } } async function deleteLocation(id) { if (!confirm('Remove this location? It will be deactivated and hidden from the filter.')) return; try { const resp = await apiFetch(`/api/locations/${id}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove location'); return; } await loadLocationsTab(); await initLocationFilter(); // If the deleted location was selected, reset to "all" if (String(globalLocationId) === String(id)) { globalLocationId = 'all'; localStorage.setItem('minniesos_location_filter', 'all'); } } catch (err) { alert('Network error — please try again'); } } // ── Users Tab ── const ROLE_LABELS = { admin: '🔑 Admin', coordinator: '📋 Coordinator', volunteer: '🙋 Volunteer' }; const ROLE_COLORS = { admin: '#6B2FA0', coordinator: '#2563EB', volunteer: '#16A34A' }; async function loadUsersTab() { const container = document.getElementById('users-list-container'); if (!container) return; container.innerHTML = '
Loading…
'; try { const data = await apiFetch('/api/users/roles').then(r => r.json()); if (!data.users || !data.users.length) { container.innerHTML = '
No users found.
'; return; } let html = ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; // Preload locations for the dropdown let locations = []; try { const locData = await apiFetch('/api/locations/all').then(r => r.json()); locations = locData.locations || locData || []; } catch(e) { /* ignore */ } const locOptions = locations.map(l => ``).join(''); for (const user of data.users) { const roleBadges = (user.roles || []).map(r => { const color = ROLE_COLORS[r.role] || '#888'; const label = ROLE_LABELS[r.role] || r.role; const locPart = r.location_name ? ` @ ${r.location_name}` : ''; return `${label}${locPart} `; }).join('') || 'No roles'; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; } html += '
NameEmailRolesAdd Role
${user.name || '—'}${user.email}${roleBadges}
'; container.innerHTML = html; } catch (err) { container.innerHTML = '
Failed to load users. You may not have admin access.
'; } } async function assignUserRole(event, userId) { event.preventDefault(); const form = event.target; const role = form.role.value; const location_id = form.location_id.value || null; try { const resp = await apiFetch(`/api/users/${userId}/roles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role, location_id: location_id ? parseInt(location_id) : null }) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to assign role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } async function removeUserRole(userId, roleId) { if (!confirm('Remove this role?')) return; try { const resp = await apiFetch(`/api/users/${userId}/roles/${roleId}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } function showAddUserModal() { // For now just redirect to settings or show an alert with instructions // Full user creation is out of scope for this task; uses /api/auth/setup-style endpoint alert('To add a new user, have them visit the app and use the setup flow, or have an admin create their account via the auth setup endpoint.'); } // ───────────────────────────────────────────────────────────────────── // CAMPAIGN TRACKER // ───────────────────────────────────────────────────────────────────── let _campaignsCache = []; let _campaignDetailChart = null; function destroyCampaignDetailChart() { if (_campaignDetailChart) { _campaignDetailChart.destroy(); _campaignDetailChart = null; } } async function loadCampaigns() { document.getElementById('campaigns-list-view').style.display = ''; document.getElementById('campaigns-detail-view').style.display = 'none'; destroyCampaignDetailChart(); const container = document.getElementById('campaigns-cards-container'); container.innerHTML = '
Loading…
'; try { const res = await apiFetch('/api/campaigns'); const data = await res.json(); _campaignsCache = data.campaigns || []; renderCampaignCards(_campaignsCache); } catch (err) { container.innerHTML = '
Error loading campaigns: ' + escapeHtml(err.message) + '
'; } } function renderCampaignCards(campaigns) { const container = document.getElementById('campaigns-cards-container'); if (!campaigns.length) { container.innerHTML = '
🎯
No campaigns yet
Create your first fundraising campaign to start tracking progress.
'; return; } const statusLabel = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft', cancelled: '❌ Cancelled' }; const statusClass = { active: 'active', completed: 'completed', draft: 'draft', cancelled: 'draft' }; container.innerHTML = '
' + campaigns.map(c => { const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const overGoal = goal > 0 && raised >= goal; const pctDisplay = goal > 0 ? (parseFloat(c.progress_pct || 0)).toFixed(0) + '%' : ''; const progressBar = goal > 0 ? '
+ goal.toLocaleString() + '">
' + pctDisplay + ' of + goal.toLocaleString() + ' goal
' : ''; const startD = c.start_date ? c.start_date.split('T')[0] : ''; const endD = c.end_date ? c.end_date.split('T')[0] : ''; return '
' + '
' + '
' + escapeHtml(c.name) + '
' + '' + (statusLabel[c.status] || c.status) + '' + '
' + '
📅 ' + startD + ' → ' + endD + '
' + progressBar + '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:0,maximumFractionDigits:0}) + 'Raised
' + '
' + (c.donor_count || 0) + 'Donors
' + '
' + (c.donation_count || 0) + 'Donations
' + '
' + '
'; }).join('') + ''; } async function openCampaignDetail(campaignId) { document.getElementById('campaigns-list-view').style.display = 'none'; const detailView = document.getElementById('campaigns-detail-view'); detailView.style.display = ''; document.getElementById('campaigns-detail-body').innerHTML = '
Loading…
'; destroyCampaignDetailChart(); try { const res = await apiFetch('/api/campaigns/' + campaignId); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to load'); const c = data.campaign; const topDonors = data.top_donors || []; const recentDonations = data.recent_donations || []; const timeline = data.daily_timeline || []; const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const pctDisplay = goal > 0 ? pct.toFixed(0) + '%' : 'No goal set'; const overGoal = goal > 0 && raised >= goal; const daysRemaining = parseInt(c.days_remaining || 0); const daysLabel = daysRemaining > 0 ? daysRemaining + ' days left' : daysRemaining === 0 ? 'Ends today' : 'Ended'; // Thermometer const thermoHtml = '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' + (goal > 0 ? '
of + goal.toLocaleString() + ' goal · ' + pctDisplay + '
' : '
No dollar goal set
') + (goal > 0 ? '
' + (pct > 15 ? pctDisplay : '') + '
' : '') + '
'; // 3 stat cards const statsHtml = '
' + '
💰
+ raised.toLocaleString('en-US', {minimumFractionDigits:0}) + '
Raised
' + '
👥
' + (c.donor_count || 0) + '
Donors
' + '
📅
' + daysLabel + '
Timeline
' + '
'; // Top donors leaderboard const topDonorsHtml = topDonors.length === 0 ? '' : '
🏆 Top Donors
' + '
' + topDonors.map((d, i) => '
' + '
' + ['🥇','🥈','🥉','4️⃣','5️⃣'][i] + '' + escapeHtml(d.name) + '
' + ' + parseFloat(d.total).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' ).join('') + '
'; // Recent donations table const recentHtml = recentDonations.length === 0 ? '' : '
📋 Recent Donations
' + '
' + '' + '' + recentDonations.map((d, i) => '' + '' + '' + '' + '' + '' ).join('') + '
DateDonorAmountType
' + (d.donation_date ? d.donation_date.split('T')[0] : '') + '' + escapeHtml(d.donor_name || '') + ' + parseFloat(d.amount).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '' + (d.donation_type || '') + '
'; // Daily chart canvas const chartHtml = timeline.length === 0 ? '' : '
📊 Daily Donations
' + '
' + '
'; // Action buttons const statusLabel2 = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft' }; const actionsHtml = '
' + '' + (c.status === 'active' ? '' : '') + (c.status !== 'cancelled' ? '' : '') + '
'; document.getElementById('campaigns-detail-body').innerHTML = '
' + '' + '
' + '
' + escapeHtml(c.name) + '
' + '
' + (c.description ? escapeHtml(c.description) : '') + '
' + '
' + thermoHtml + statsHtml + actionsHtml + topDonorsHtml + recentHtml + chartHtml; // Render chart if (timeline.length > 0) { setTimeout(() => { const ctx = document.getElementById('campaign-daily-chart'); if (!ctx) return; _campaignDetailChart = new Chart(ctx.getContext('2d'), { type: 'bar', data: { labels: timeline.map(r => r.date), datasets: [{ label: 'Daily Donations ($)', data: timeline.map(r => parseFloat(r.total)), backgroundColor: 'rgba(45,125,70,0.75)', borderColor: '#2D7D46', borderWidth: 1, borderRadius: 4 }] }, options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: v => ' + v.toLocaleString() } } } } }); }, 50); } } catch (err) { document.getElementById('campaigns-detail-body').innerHTML = '
Error: ' + escapeHtml(err.message) + '
'; } } async function submitNewCampaign(e) { e.preventDefault(); const form = e.target; const fd = new FormData(form); const body = Object.fromEntries(fd); // Remove empty optional fields ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) delete body[k]; }); try { const res = await apiFetch('/api/campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to create campaign'); return; } closeModal('new-campaign-modal'); form.reset(); loadCampaigns(); alert('Campaign created!'); } catch (err) { alert('Network error. Please try again.'); } } function openNewCampaignModal() { const form = document.getElementById('new-campaign-form'); if (form) form.reset(); const today = new Date().toISOString().split('T')[0]; const endDate = new Date(); endDate.setDate(endDate.getDate() + 30); const endStr = endDate.toISOString().split('T')[0]; const sd = form.querySelector('[name=start_date]'); const ed = form.querySelector('[name=end_date]'); if (sd) sd.value = today; if (ed) ed.value = endStr; document.getElementById('new-campaign-modal').classList.add('active'); } let _editingCampaignId = null; function openEditCampaignModal(campaignId) { const c = _campaignsCache.find(x => x.id === campaignId) || {}; _editingCampaignId = campaignId; const form = document.getElementById('new-campaign-form'); if (!form) return; form.querySelector('[name=name]').value = c.name || ''; form.querySelector('[name=description]').value = c.description || ''; form.querySelector('[name=start_date]').value = c.start_date ? c.start_date.split('T')[0] : ''; form.querySelector('[name=end_date]').value = c.end_date ? c.end_date.split('T')[0] : ''; form.querySelector('[name=goal_dollars]').value = c.goal_dollars || ''; form.querySelector('[name=goal_lbs]').value = c.goal_lbs || ''; form.querySelector('[name=goal_items]').value = c.goal_items || ''; form.querySelector('[name=status]').value = c.status || 'active'; document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '✏️ Edit Campaign'; document.getElementById('new-campaign-modal').classList.add('active'); // Override submit to PUT form.onsubmit = async (ev) => { ev.preventDefault(); const fd = new FormData(form); const body = Object.fromEntries(fd); ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) body[k] = null; }); try { const res = await apiFetch('/api/campaigns/' + _editingCampaignId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to update'); return; } closeModal('new-campaign-modal'); document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '➕ New Campaign'; form.onsubmit = submitNewCampaign; _editingCampaignId = null; loadCampaigns(); alert('Campaign updated!'); } catch (err) { alert('Network error. Please try again.'); } }; } async function completeCampaign(id) { if (!confirm('Mark this campaign as completed?')) return; await apiFetch('/api/campaigns/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'completed' }) }); loadCampaigns(); } async function cancelCampaign(id) { if (!confirm('Cancel this campaign? This will hide it from the list.')) return; await apiFetch('/api/campaigns/' + id, { method: 'DELETE' }); loadCampaigns(); } // ── Populate campaigns in Add Donation modal ── async function populateDonationCampaigns() { const select = document.getElementById('donation-campaign-select'); if (!select) return; // Clear existing dynamic options while (select.options.length > 1) select.remove(1); try { const res = await apiFetch('/api/campaigns/active'); const data = await res.json(); (data.campaigns || []).forEach(c => { const opt = document.createElement('option'); opt.value = c.id; opt.setAttribute('data-raised', c.total_raised || 0); opt.setAttribute('data-goal', c.goal_dollars || 0); opt.textContent = c.name; select.appendChild(opt); }); } catch (err) { console.warn('Could not load campaigns for donation form:', err.message); } } function showCampaignProgress() { const select = document.getElementById('donation-campaign-select'); const progressDiv = document.getElementById('donation-campaign-progress'); if (!select || !progressDiv) return; const selected = select.options[select.selectedIndex]; if (!selected || !selected.value) { progressDiv.style.display = 'none'; return; } const raised = parseFloat(selected.getAttribute('data-raised') || 0); const goal = parseFloat(selected.getAttribute('data-goal') || 0); if (goal > 0) { const pct = (raised / goal * 100).toFixed(0); progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' of + goal.toLocaleString() + ' raised (' + pct + '%)'; progressDiv.style.display = ''; } else { progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' raised (no goal set)'; progressDiv.style.display = ''; } } + s.cost_per_meal.toFixed(2) : 'N/A'; const narrativeParts = [ 'During this period, Minnie's Food Pantry served ' + fmtNum(s.total_families_served) + ' families and distributed', '' + fmtNum(s.total_lbs_distributed, 1) + ' pounds of food, providing approximately', '' + fmtNum(s.total_meals_served) + ' meals.', '' + fmtNum(s.unique_volunteers) + ' volunteers contributed', '' + fmtNum(s.total_volunteer_hours, 0) + ' hours of service.', 'Families averaged ' + fmtNum(s.avg_visits_per_family, 1) + ' visits per person during this period.' ]; if (s.cost_per_meal > 0) narrativeParts.push('Estimated cost per meal: ' + costDisplay + '.'); container.innerHTML = '
' + '
' + '
Minnie's Food Pantry
' + '
Grant Impact Summary
' + '
' + data.period.start + ' – ' + data.period.end + '
' + '
' + '
' + '
' + narrativeParts.join(' ') + '
' + '
Key Impact Metrics
' + '
' + complianceMetricCard('Families Served', fmtNum(s.total_families_served), '#1e8449') + complianceMetricCard('Meals Provided', fmtNum(s.total_meals_served), '#1e8449') + complianceMetricCard('Lbs Distributed', fmtNum(s.total_lbs_distributed, 1) + ' lbs', '#1e8449') + complianceMetricCard('Unique Volunteers', fmtNum(s.unique_volunteers), '#1e8449') + complianceMetricCard('Volunteer Hours', fmtNum(s.total_volunteer_hours, 1) + ' hrs', '#1e8449') + complianceMetricCard('Total Donations', ' const n = new Date(); const y = n.getFullYear(); const m = String(n.getMonth() + 1).padStart(2, '0'); const el = document.getElementById('board-month-input'); if (el) el.value = `${y}-${m}`; })(); async function generateBoardReport() { const monthInput = document.getElementById('board-month-input'); const month = monthInput ? monthInput.value : ''; if (!month) { alert('Please select a month.'); return; } const btn = document.getElementById('board-generate-btn'); btn.disabled = true; btn.textContent = '⏳ Generating…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!res.ok || !data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); // Refresh saved list loadSavedBoardReports(); } catch (err) { alert('Error generating report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } function renderBoardReportPreview(r) { const m = r.metrics; document.getElementById('brp-month').textContent = r.month_label; document.getElementById('brp-summary').textContent = r.executive_summary; // Metrics grid const fmt = (v, digits) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: digits || 0 }); const fmtMoney = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaHtml = (d) => { if (d === null || d === undefined) return ''; const cls = d > 0 ? 'up' : d < 0 ? 'down' : 'flat'; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; return `${arrow} ${Math.abs(d)}% vs prior month`; }; const cards = [ { label: 'Families Served', value: fmt(m.clients.unique_served), sub: `${fmt(m.clients.new_this_month)} new clients this month`, delta: m.clients.delta_unique_served }, { label: 'Pounds Distributed', value: fmt(m.distributions.total_lbs, 1), sub: `${fmt(m.distributions.total_visits)} visits · ${fmt(m.distributions.avg_lbs_per_family, 1)} lbs/family avg`, delta: m.distributions.delta_lbs }, { label: 'Active Volunteers', value: fmt(m.volunteers.active), sub: `${fmt(m.volunteers.hours, 1)} hours · ${m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '% attendance' : 'no attendance data'}`, delta: m.volunteers.delta_active }, { label: 'Donations', value: fmtMoney(m.donations.total_usd), sub: `${fmt(m.donations.donor_count)} donors · ${fmt(m.donations.donation_count)} gifts`, delta: m.donations.delta_total }, { label: 'Current Inventory', value: fmt(m.inventory.current_stock_lbs, 1) + ' lbs', sub: `${fmt(m.inventory.received_this_month_lbs, 1)} lbs received · ${fmt(m.inventory.expiring_soon_items)} items expiring soon`, delta: null }, { label: 'Total Active Clients', value: fmt(m.clients.total_active), sub: 'On file in system', delta: null }, ...(m.inventory.donated_items > 0 ? [{ label: 'Donated Inventory', value: fmt(m.inventory.donated_items) + ' items', sub: `${fmt(m.inventory.donated_lbs, 1)} lbs from ${fmt(m.inventory.donated_by_donors)} donor${m.inventory.donated_by_donors !== 1 ? 's' : ''}`, delta: null }] : []) ]; document.getElementById('brp-metrics-grid').innerHTML = cards.map(c => `
${c.label}
${c.value}
${c.sub}
${deltaHtml(c.delta)}
`).join(''); // Highlights const h = r.highlights; const hlCards = []; if (h.top_volunteer) hlCards.push({ icon: '🏆', label: 'Top Volunteer', value: `${h.top_volunteer.name} · ${h.top_volunteer.hours}h` }); if (h.largest_donation) hlCards.push({ icon: '💛', label: 'Largest Donation', value: `${fmtMoney(h.largest_donation.amount)} from ${h.largest_donation.donor}` }); if (h.busiest_day) hlCards.push({ icon: '📅', label: 'Busiest Day', value: `${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })} · ${h.busiest_day.visits} visits · ${(h.busiest_day.lbs || 0).toLocaleString(undefined, { maximumFractionDigits: 1 })} lbs` }); if (!hlCards.length) hlCards.push({ icon: 'ℹ️', label: 'No Activity', value: 'No data recorded for this month' }); document.getElementById('brp-highlights').innerHTML = hlCards.map(c => `
${c.icon}
${c.label}
${c.value}
`).join(''); } function printBoardReport() { if (!currentBoardReport) return; const r = currentBoardReport; const m = r.metrics; const fmtN = (v, d) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: d || 0 }); const fmtM = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaStr = (d) => { if (d === null || d === undefined) return ''; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; const color = d > 0 ? '#065f46' : d < 0 ? '#991b1b' : '#6b7280'; const bg = d > 0 ? '#d1fae5' : d < 0 ? '#fee2e2' : '#f3f4f6'; return `${arrow} ${Math.abs(d)}%`; }; const h = r.highlights; const hlHtml = [ h.top_volunteer ? `🏆 Top Volunteer${h.top_volunteer.name} — ${h.top_volunteer.hours} hours` : '', h.largest_donation ? `💛 Largest Donation${fmtM(h.largest_donation.amount)} from ${h.largest_donation.donor}` : '', h.busiest_day ? `📅 Busiest Day${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', timeZone: 'UTC' })} — ${h.busiest_day.visits} visits, ${fmtN(h.busiest_day.lbs, 1)} lbs` : '' ].filter(Boolean).join(''); const metricsRows = [ ['Families Served (Unique)', fmtN(m.clients.unique_served), deltaStr(m.clients.delta_unique_served)], ['New Clients Registered', fmtN(m.clients.new_this_month), ''], ['Total Distribution Visits', fmtN(m.distributions.total_visits), deltaStr(m.distributions.delta_visits)], ['Total Pounds Distributed', fmtN(m.distributions.total_lbs, 1) + ' lbs', deltaStr(m.distributions.delta_lbs)], ['Avg Lbs per Family', fmtN(m.distributions.avg_lbs_per_family, 1) + ' lbs', ''], ['Active Volunteers', fmtN(m.volunteers.active), deltaStr(m.volunteers.delta_active)], ['Volunteer Hours', fmtN(m.volunteers.hours, 1), deltaStr(m.volunteers.delta_hours)], ['Attendance Rate', m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '%' : 'N/A', ''], ['Total Donations', fmtM(m.donations.total_usd), deltaStr(m.donations.delta_total)], ['Unique Donors', fmtN(m.donations.donor_count), deltaStr(m.donations.delta_donors)], ['Current Inventory', fmtN(m.inventory.current_stock_lbs, 1) + ' lbs', ''], ['Inventory Received This Month', fmtN(m.inventory.received_this_month_lbs, 1) + ' lbs', ''], ['Items Expiring Within 30 Days', fmtN(m.inventory.expiring_soon_items), ''], ...(m.inventory.donated_items > 0 ? [ ['Donated Inventory (Items)', fmtN(m.inventory.donated_items) + ' items (' + fmtN(m.inventory.donated_lbs, 1) + ' lbs)', ''], ['Donation Sources (Donors)', fmtN(m.inventory.donated_by_donors) + ' donor' + (m.inventory.donated_by_donors !== 1 ? 's' : ''), ''] ] : []), ['Total Active Clients on File', fmtN(m.clients.total_active), ''] ].map(([label, val, d]) => `${label}${val}${d}`).join(''); const html = `Board Report — ${r.month_label}
Minnie's Food Pantry
Monthly Board Report
${r.month_label}
${r.executive_summary}
Key Performance Metrics
${metricsRows}
MetricValuevs. Prior Month
${hlHtml ? `
Month Highlights
${hlHtml}
` : ''}
`; const blob = new Blob([html], { type: 'text/html' }); window.open(URL.createObjectURL(blob), '_blank'); } async function loadSavedBoardReports() { const list = document.getElementById('saved-board-reports-list'); try { const res = await apiFetch('/api/reports/monthly/saved'); const data = await res.json(); if (!data.success || !data.reports.length) { list.innerHTML = '
No saved reports yet. Generate your first board report above.
'; return; } list.innerHTML = data.reports.map(r => `
${formatSavedReportMonth(r.report_month)}
Generated ${new Date(r.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
`).join(''); } catch (err) { list.innerHTML = '
Could not load saved reports.
'; } } function formatSavedReportMonth(ym) { const [y, m] = ym.split('-').map(Number); return new Date(y, m - 1, 1).toLocaleString('en-US', { month: 'long', year: 'numeric' }); } async function viewSavedBoardReport(month) { const btn = document.getElementById('board-generate-btn'); const mi = document.getElementById('board-month-input'); if (mi) mi.value = month; btn.disabled = true; btn.textContent = '⏳ Loading…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (err) { alert('Error loading report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } async function deleteSavedBoardReport(id, btnEl) { if (!confirm('Delete this saved report?')) return; try { const res = await apiFetch(`/api/reports/monthly/saved/${id}`, { method: 'DELETE' }); if (res.ok) { loadSavedBoardReports(); if (currentBoardReport) { document.getElementById('board-report-preview').style.display = 'none'; currentBoardReport = null; } } } catch (err) { alert('Delete failed: ' + err.message); } } // Close modals on outside click window.onclick = function(event) { if (event.target.classList.contains('modal')) { event.target.classList.remove('active'); } } // ── CSV Export ── async function exportCSV(type) { const token = localStorage.getItem('mfp_admin_token'); if (!token) { alert('Not authenticated'); return; } // Map type to API path and filename label const pathMap = { volunteers: 'volunteers', donors: 'donors', clients: 'clients', inventory: 'inventory', visits: 'visits' }; const apiPath = pathMap[type]; if (!apiPath) { alert('Unknown export type'); return; } // Build URL with optional date range from the Reports tab pickers (if available) const startEl = document.getElementById('report-start-date'); const endEl = document.getElementById('report-end-date'); let url = `/api/${apiPath}/export?format=csv`; if (startEl && startEl.value) url += `&start=${startEl.value}`; if (endEl && endEl.value) url += `&end=${endEl.value}`; try { const resp = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || `HTTP ${resp.status}`); } const blob = await resp.blob(); const blobUrl = URL.createObjectURL(blob); const today = new Date().toISOString().slice(0, 10); const a = document.createElement('a'); a.href = blobUrl; a.download = `${type}-export-${today}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); } catch (err) { alert('Export failed: ' + err.message); } } // ── CSV Import ── let importType = ''; let importRows = []; const csvFormats = { volunteers: { title: 'Import Volunteers', hint: 'CSV headers: name, email, phone, status, skills
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,phone,status,skills\nJane Smith,jane@example.com,555-1234,active,Logistics' }, donors: { title: 'Import Donors', hint: 'CSV headers: name, email, total_given, last_gift_date, phone, donor_type, status
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,total_given,last_gift_date\nJohn Doe,john@example.com,500.00,2025-12-15' } }; function openImportModal(type) { importType = type; importRows = []; const config = csvFormats[type]; document.getElementById('import-modal-title').textContent = config.title; document.getElementById('csv-format-hint').innerHTML = config.hint; resetImportModal(); document.getElementById('import-csv-modal').classList.add('active'); } function closeImportModal() { document.getElementById('import-csv-modal').classList.remove('active'); importType = ''; importRows = []; } function resetImportModal() { document.getElementById('import-step-upload').style.display = 'block'; document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.remove('active'); document.getElementById('import-file-input').value = ''; importRows = []; } // Drag and drop const dropZone = document.getElementById('import-drop-zone'); ['dragenter', 'dragover'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add('drag-over'); }); }); ['dragleave', 'drop'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('drag-over'); }); }); dropZone.addEventListener('drop', (e) => { const files = e.dataTransfer.files; if (files.length > 0) { processFile(files[0]); } }); function handleFileSelect(e) { if (e.target.files.length > 0) { processFile(e.target.files[0]); } } function processFile(file) { if (!file.name.toLowerCase().endsWith('.csv') && file.type !== 'text/csv') { alert('Please upload a CSV file'); return; } const reader = new FileReader(); reader.onload = (e) => { const text = e.target.result; const parsed = parseCSV(text); if (parsed.length === 0) { alert('No data rows found in CSV'); return; } importRows = parsed; showPreview(file.name, parsed); }; reader.readAsText(file); } function parseCSV(text) { const lines = text.split(/\r?\n/).filter(line => line.trim()); if (lines.length < 2) return []; const headers = parseCSVLine(lines[0]).map(h => h.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_')); const rows = []; for (let i = 1; i < lines.length; i++) { const values = parseCSVLine(lines[i]); if (values.length === 0 || (values.length === 1 && !values[0].trim())) continue; const row = {}; headers.forEach((header, idx) => { row[header] = (values[idx] || '').trim(); }); rows.push(row); } return rows; } function parseCSVLine(line) { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"') { if (i + 1 < line.length && line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = false; } } else { current += ch; } } else { if (ch === '"') { inQuotes = true; } else if (ch === ',') { result.push(current); current = ''; } else { current += ch; } } } result.push(current); return result; } function showPreview(fileName, rows) { document.getElementById('import-step-upload').style.display = 'none'; document.getElementById('import-step-preview').classList.add('active'); document.getElementById('preview-file-name').textContent = fileName; document.getElementById('preview-row-count').textContent = `${rows.length} row${rows.length !== 1 ? 's' : ''}`; const headers = Object.keys(rows[0]); const previewRows = rows.slice(0, 5); const remaining = rows.length - 5; let tableHtml = '' + headers.map(h => `${h}`).join('') + ''; previewRows.forEach(row => { tableHtml += '' + headers.map(h => `${escapeHtml(row[h] || '')}`).join('') + ''; }); tableHtml += ''; document.getElementById('preview-table').innerHTML = tableHtml; if (remaining > 0) { document.getElementById('preview-more-rows').textContent = `+ ${remaining} more row${remaining !== 1 ? 's' : ''}`; document.getElementById('preview-more-rows').style.display = 'block'; } else { document.getElementById('preview-more-rows').style.display = 'none'; } } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } async function executeImport() { document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.add('active'); try { const response = await apiFetch(`/api/${importType}/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rows: importRows }) }); const result = await response.json(); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); if (response.ok && result.success) { const hasErrors = result.errors && result.errors.length > 0; document.getElementById('import-result-icon').textContent = hasErrors ? '⚠️' : '✅'; document.getElementById('import-result-title').textContent = hasErrors ? 'Import completed with some issues' : 'Import successful!'; document.getElementById('import-summary').innerHTML = `
${result.created}
Created
${result.updated}
Updated
${result.errors.length}
Errors
`; if (hasErrors) { const errorsDiv = document.getElementById('import-errors'); errorsDiv.style.display = 'block'; errorsDiv.innerHTML = result.errors.slice(0, 10).map(e => `Row ${e.row}: ${escapeHtml(e.error)}` ).join('
'); if (result.errors.length > 10) { errorsDiv.innerHTML += `
... and ${result.errors.length - 10} more`; } } else { document.getElementById('import-errors').style.display = 'none'; } // Refresh data if (importType === 'volunteers') loadVolunteers(); if (importType === 'donors') loadDonors(); loadDashboardData(); } else { document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = result.error || 'Import failed'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } catch (error) { document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = 'Network error — please try again'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } // ── Locations Management ── const LOCATION_TYPE_LABELS = { main_facility: { label: 'Main Facility', color: '#C44B2B' }, school_pantry: { label: 'School Pantry', color: '#2563EB' }, partner_site: { label: 'Partner Site', color: '#16A34A' }, other: { label: 'Other', color: '#6B7280' } }; async function loadLocationsTab() { try { const data = await apiFetch('/api/locations/all').then(r => r.json()); state.locations.data = data.locations || []; renderLocations(state.locations.data); } catch (e) { document.getElementById('locations-table').innerHTML = '
Error loading locations
'; } } function renderLocations(locations) { const tbody = document.getElementById('locations-table'); if (!locations || locations.length === 0) { tbody.innerHTML = '
📍
No locations found
Add your first location to get started
'; return; } tbody.innerHTML = locations.map(loc => { const typeInfo = LOCATION_TYPE_LABELS[loc.location_type] || LOCATION_TYPE_LABELS.other; return ` ${loc.name} ${typeInfo.label} ${loc.address || '—'} ${loc.is_active ? 'Active' : 'Inactive'} ${loc.is_active ? ` ` : 'Inactive'} `; }).join(''); } function openAddLocationModal() { document.getElementById('location-id').value = ''; document.getElementById('location-modal-title').textContent = 'Add Location'; document.getElementById('location-submit-btn').textContent = 'Add Location'; document.getElementById('location-form').reset(); document.getElementById('location-modal').classList.add('active'); } function openEditLocationModal(id, name, type, address) { document.getElementById('location-id').value = id; document.getElementById('location-modal-title').textContent = 'Edit Location'; document.getElementById('location-submit-btn').textContent = 'Save Changes'; document.getElementById('loc-name').value = name; document.getElementById('loc-type').value = type; document.getElementById('loc-address').value = address; document.getElementById('location-modal').classList.add('active'); } async function saveLocation(e) { e.preventDefault(); const id = document.getElementById('location-id').value; const payload = { name: document.getElementById('loc-name').value.trim(), location_type: document.getElementById('loc-type').value, address: document.getElementById('loc-address').value.trim() || null }; try { const url = id ? `/api/locations/${id}` : '/api/locations'; const method = id ? 'PUT' : 'POST'; const resp = await apiFetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to save location'); return; } closeModal('location-modal'); await loadLocationsTab(); // Refresh the dropdown so the new location appears await initLocationFilter(); } catch (err) { alert('Network error — please try again'); } } async function deleteLocation(id) { if (!confirm('Remove this location? It will be deactivated and hidden from the filter.')) return; try { const resp = await apiFetch(`/api/locations/${id}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove location'); return; } await loadLocationsTab(); await initLocationFilter(); // If the deleted location was selected, reset to "all" if (String(globalLocationId) === String(id)) { globalLocationId = 'all'; localStorage.setItem('minniesos_location_filter', 'all'); } } catch (err) { alert('Network error — please try again'); } } // ── Users Tab ── const ROLE_LABELS = { admin: '🔑 Admin', coordinator: '📋 Coordinator', volunteer: '🙋 Volunteer' }; const ROLE_COLORS = { admin: '#6B2FA0', coordinator: '#2563EB', volunteer: '#16A34A' }; async function loadUsersTab() { const container = document.getElementById('users-list-container'); if (!container) return; container.innerHTML = '
Loading…
'; try { const data = await apiFetch('/api/users/roles').then(r => r.json()); if (!data.users || !data.users.length) { container.innerHTML = '
No users found.
'; return; } let html = ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; // Preload locations for the dropdown let locations = []; try { const locData = await apiFetch('/api/locations/all').then(r => r.json()); locations = locData.locations || locData || []; } catch(e) { /* ignore */ } const locOptions = locations.map(l => ``).join(''); for (const user of data.users) { const roleBadges = (user.roles || []).map(r => { const color = ROLE_COLORS[r.role] || '#888'; const label = ROLE_LABELS[r.role] || r.role; const locPart = r.location_name ? ` @ ${r.location_name}` : ''; return `${label}${locPart} `; }).join('') || 'No roles'; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; } html += '
NameEmailRolesAdd Role
${user.name || '—'}${user.email}${roleBadges}
'; container.innerHTML = html; } catch (err) { container.innerHTML = '
Failed to load users. You may not have admin access.
'; } } async function assignUserRole(event, userId) { event.preventDefault(); const form = event.target; const role = form.role.value; const location_id = form.location_id.value || null; try { const resp = await apiFetch(`/api/users/${userId}/roles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role, location_id: location_id ? parseInt(location_id) : null }) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to assign role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } async function removeUserRole(userId, roleId) { if (!confirm('Remove this role?')) return; try { const resp = await apiFetch(`/api/users/${userId}/roles/${roleId}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } function showAddUserModal() { // For now just redirect to settings or show an alert with instructions // Full user creation is out of scope for this task; uses /api/auth/setup-style endpoint alert('To add a new user, have them visit the app and use the setup flow, or have an admin create their account via the auth setup endpoint.'); } // ───────────────────────────────────────────────────────────────────── // CAMPAIGN TRACKER // ───────────────────────────────────────────────────────────────────── let _campaignsCache = []; let _campaignDetailChart = null; function destroyCampaignDetailChart() { if (_campaignDetailChart) { _campaignDetailChart.destroy(); _campaignDetailChart = null; } } async function loadCampaigns() { document.getElementById('campaigns-list-view').style.display = ''; document.getElementById('campaigns-detail-view').style.display = 'none'; destroyCampaignDetailChart(); const container = document.getElementById('campaigns-cards-container'); container.innerHTML = '
Loading…
'; try { const res = await apiFetch('/api/campaigns'); const data = await res.json(); _campaignsCache = data.campaigns || []; renderCampaignCards(_campaignsCache); } catch (err) { container.innerHTML = '
Error loading campaigns: ' + escapeHtml(err.message) + '
'; } } function renderCampaignCards(campaigns) { const container = document.getElementById('campaigns-cards-container'); if (!campaigns.length) { container.innerHTML = '
🎯
No campaigns yet
Create your first fundraising campaign to start tracking progress.
'; return; } const statusLabel = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft', cancelled: '❌ Cancelled' }; const statusClass = { active: 'active', completed: 'completed', draft: 'draft', cancelled: 'draft' }; container.innerHTML = '
' + campaigns.map(c => { const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const overGoal = goal > 0 && raised >= goal; const pctDisplay = goal > 0 ? (parseFloat(c.progress_pct || 0)).toFixed(0) + '%' : ''; const progressBar = goal > 0 ? '
+ goal.toLocaleString() + '">
' + pctDisplay + ' of + goal.toLocaleString() + ' goal
' : ''; const startD = c.start_date ? c.start_date.split('T')[0] : ''; const endD = c.end_date ? c.end_date.split('T')[0] : ''; return '
' + '
' + '
' + escapeHtml(c.name) + '
' + '' + (statusLabel[c.status] || c.status) + '' + '
' + '
📅 ' + startD + ' → ' + endD + '
' + progressBar + '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:0,maximumFractionDigits:0}) + 'Raised
' + '
' + (c.donor_count || 0) + 'Donors
' + '
' + (c.donation_count || 0) + 'Donations
' + '
' + '
'; }).join('') + '
'; } async function openCampaignDetail(campaignId) { document.getElementById('campaigns-list-view').style.display = 'none'; const detailView = document.getElementById('campaigns-detail-view'); detailView.style.display = ''; document.getElementById('campaigns-detail-body').innerHTML = '
Loading…
'; destroyCampaignDetailChart(); try { const res = await apiFetch('/api/campaigns/' + campaignId); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to load'); const c = data.campaign; const topDonors = data.top_donors || []; const recentDonations = data.recent_donations || []; const timeline = data.daily_timeline || []; const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const pctDisplay = goal > 0 ? pct.toFixed(0) + '%' : 'No goal set'; const overGoal = goal > 0 && raised >= goal; const daysRemaining = parseInt(c.days_remaining || 0); const daysLabel = daysRemaining > 0 ? daysRemaining + ' days left' : daysRemaining === 0 ? 'Ends today' : 'Ended'; // Thermometer const thermoHtml = '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' + (goal > 0 ? '
of + goal.toLocaleString() + ' goal · ' + pctDisplay + '
' : '
No dollar goal set
') + (goal > 0 ? '
' + (pct > 15 ? pctDisplay : '') + '
' : '') + '
'; // 3 stat cards const statsHtml = '
' + '
💰
+ raised.toLocaleString('en-US', {minimumFractionDigits:0}) + '
Raised
' + '
👥
' + (c.donor_count || 0) + '
Donors
' + '
📅
' + daysLabel + '
Timeline
' + '
'; // Top donors leaderboard const topDonorsHtml = topDonors.length === 0 ? '' : '
🏆 Top Donors
' + '
' + topDonors.map((d, i) => '
' + '
' + ['🥇','🥈','🥉','4️⃣','5️⃣'][i] + '' + escapeHtml(d.name) + '
' + ' + parseFloat(d.total).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' ).join('') + '
'; // Recent donations table const recentHtml = recentDonations.length === 0 ? '' : '
📋 Recent Donations
' + '
' + '' + '' + recentDonations.map((d, i) => '' + '' + '' + '' + '' + '' ).join('') + '
DateDonorAmountType
' + (d.donation_date ? d.donation_date.split('T')[0] : '') + '' + escapeHtml(d.donor_name || '') + ' + parseFloat(d.amount).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '' + (d.donation_type || '') + '
'; // Daily chart canvas const chartHtml = timeline.length === 0 ? '' : '
📊 Daily Donations
' + '
' + '
'; // Action buttons const statusLabel2 = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft' }; const actionsHtml = '
' + '' + (c.status === 'active' ? '' : '') + (c.status !== 'cancelled' ? '' : '') + '
'; document.getElementById('campaigns-detail-body').innerHTML = '
' + '' + '
' + '
' + escapeHtml(c.name) + '
' + '
' + (c.description ? escapeHtml(c.description) : '') + '
' + '
' + thermoHtml + statsHtml + actionsHtml + topDonorsHtml + recentHtml + chartHtml; // Render chart if (timeline.length > 0) { setTimeout(() => { const ctx = document.getElementById('campaign-daily-chart'); if (!ctx) return; _campaignDetailChart = new Chart(ctx.getContext('2d'), { type: 'bar', data: { labels: timeline.map(r => r.date), datasets: [{ label: 'Daily Donations ($)', data: timeline.map(r => parseFloat(r.total)), backgroundColor: 'rgba(45,125,70,0.75)', borderColor: '#2D7D46', borderWidth: 1, borderRadius: 4 }] }, options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: v => ' + v.toLocaleString() } } } } }); }, 50); } } catch (err) { document.getElementById('campaigns-detail-body').innerHTML = '
Error: ' + escapeHtml(err.message) + '
'; } } async function submitNewCampaign(e) { e.preventDefault(); const form = e.target; const fd = new FormData(form); const body = Object.fromEntries(fd); // Remove empty optional fields ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) delete body[k]; }); try { const res = await apiFetch('/api/campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to create campaign'); return; } closeModal('new-campaign-modal'); form.reset(); loadCampaigns(); alert('Campaign created!'); } catch (err) { alert('Network error. Please try again.'); } } function openNewCampaignModal() { const form = document.getElementById('new-campaign-form'); if (form) form.reset(); const today = new Date().toISOString().split('T')[0]; const endDate = new Date(); endDate.setDate(endDate.getDate() + 30); const endStr = endDate.toISOString().split('T')[0]; const sd = form.querySelector('[name=start_date]'); const ed = form.querySelector('[name=end_date]'); if (sd) sd.value = today; if (ed) ed.value = endStr; document.getElementById('new-campaign-modal').classList.add('active'); } let _editingCampaignId = null; function openEditCampaignModal(campaignId) { const c = _campaignsCache.find(x => x.id === campaignId) || {}; _editingCampaignId = campaignId; const form = document.getElementById('new-campaign-form'); if (!form) return; form.querySelector('[name=name]').value = c.name || ''; form.querySelector('[name=description]').value = c.description || ''; form.querySelector('[name=start_date]').value = c.start_date ? c.start_date.split('T')[0] : ''; form.querySelector('[name=end_date]').value = c.end_date ? c.end_date.split('T')[0] : ''; form.querySelector('[name=goal_dollars]').value = c.goal_dollars || ''; form.querySelector('[name=goal_lbs]').value = c.goal_lbs || ''; form.querySelector('[name=goal_items]').value = c.goal_items || ''; form.querySelector('[name=status]').value = c.status || 'active'; document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '✏️ Edit Campaign'; document.getElementById('new-campaign-modal').classList.add('active'); // Override submit to PUT form.onsubmit = async (ev) => { ev.preventDefault(); const fd = new FormData(form); const body = Object.fromEntries(fd); ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) body[k] = null; }); try { const res = await apiFetch('/api/campaigns/' + _editingCampaignId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to update'); return; } closeModal('new-campaign-modal'); document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '➕ New Campaign'; form.onsubmit = submitNewCampaign; _editingCampaignId = null; loadCampaigns(); alert('Campaign updated!'); } catch (err) { alert('Network error. Please try again.'); } }; } async function completeCampaign(id) { if (!confirm('Mark this campaign as completed?')) return; await apiFetch('/api/campaigns/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'completed' }) }); loadCampaigns(); } async function cancelCampaign(id) { if (!confirm('Cancel this campaign? This will hide it from the list.')) return; await apiFetch('/api/campaigns/' + id, { method: 'DELETE' }); loadCampaigns(); } // ── Populate campaigns in Add Donation modal ── async function populateDonationCampaigns() { const select = document.getElementById('donation-campaign-select'); if (!select) return; // Clear existing dynamic options while (select.options.length > 1) select.remove(1); try { const res = await apiFetch('/api/campaigns/active'); const data = await res.json(); (data.campaigns || []).forEach(c => { const opt = document.createElement('option'); opt.value = c.id; opt.setAttribute('data-raised', c.total_raised || 0); opt.setAttribute('data-goal', c.goal_dollars || 0); opt.textContent = c.name; select.appendChild(opt); }); } catch (err) { console.warn('Could not load campaigns for donation form:', err.message); } } function showCampaignProgress() { const select = document.getElementById('donation-campaign-select'); const progressDiv = document.getElementById('donation-campaign-progress'); if (!select || !progressDiv) return; const selected = select.options[select.selectedIndex]; if (!selected || !selected.value) { progressDiv.style.display = 'none'; return; } const raised = parseFloat(selected.getAttribute('data-raised') || 0); const goal = parseFloat(selected.getAttribute('data-goal') || 0); if (goal > 0) { const pct = (raised / goal * 100).toFixed(0); progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' of + goal.toLocaleString() + ' raised (' + pct + '%)'; progressDiv.style.display = ''; } else { progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' raised (no goal set)'; progressDiv.style.display = ''; } } + fmtNum(s.total_donation_value, 2), '#1e8449') + complianceMetricCard('Avg Visits/Family', fmtNum(s.avg_visits_per_family, 1), '#1e8449') + complianceMetricCard('Cost Per Meal', costDisplay, '#1e8449') + '
' + '
Top Donors by Impact
' + '' + topDonorRows + '
#NameTypeDonationsTotal Given
' + '
Month-over-Month Trends
' + '' + trendRows + '
MonthFamiliesLbs DistributedDonationsVol. Hours
' + '
' + '
' + '' + '
' + ''; } (function initBoardMonthInput() { const n = new Date(); const y = n.getFullYear(); const m = String(n.getMonth() + 1).padStart(2, '0'); const el = document.getElementById('board-month-input'); if (el) el.value = `${y}-${m}`; })(); async function generateBoardReport() { const monthInput = document.getElementById('board-month-input'); const month = monthInput ? monthInput.value : ''; if (!month) { alert('Please select a month.'); return; } const btn = document.getElementById('board-generate-btn'); btn.disabled = true; btn.textContent = '⏳ Generating…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!res.ok || !data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); // Refresh saved list loadSavedBoardReports(); } catch (err) { alert('Error generating report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } function renderBoardReportPreview(r) { const m = r.metrics; document.getElementById('brp-month').textContent = r.month_label; document.getElementById('brp-summary').textContent = r.executive_summary; // Metrics grid const fmt = (v, digits) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: digits || 0 }); const fmtMoney = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaHtml = (d) => { if (d === null || d === undefined) return ''; const cls = d > 0 ? 'up' : d < 0 ? 'down' : 'flat'; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; return `${arrow} ${Math.abs(d)}% vs prior month`; }; const cards = [ { label: 'Families Served', value: fmt(m.clients.unique_served), sub: `${fmt(m.clients.new_this_month)} new clients this month`, delta: m.clients.delta_unique_served }, { label: 'Pounds Distributed', value: fmt(m.distributions.total_lbs, 1), sub: `${fmt(m.distributions.total_visits)} visits · ${fmt(m.distributions.avg_lbs_per_family, 1)} lbs/family avg`, delta: m.distributions.delta_lbs }, { label: 'Active Volunteers', value: fmt(m.volunteers.active), sub: `${fmt(m.volunteers.hours, 1)} hours · ${m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '% attendance' : 'no attendance data'}`, delta: m.volunteers.delta_active }, { label: 'Donations', value: fmtMoney(m.donations.total_usd), sub: `${fmt(m.donations.donor_count)} donors · ${fmt(m.donations.donation_count)} gifts`, delta: m.donations.delta_total }, { label: 'Current Inventory', value: fmt(m.inventory.current_stock_lbs, 1) + ' lbs', sub: `${fmt(m.inventory.received_this_month_lbs, 1)} lbs received · ${fmt(m.inventory.expiring_soon_items)} items expiring soon`, delta: null }, { label: 'Total Active Clients', value: fmt(m.clients.total_active), sub: 'On file in system', delta: null }, ...(m.inventory.donated_items > 0 ? [{ label: 'Donated Inventory', value: fmt(m.inventory.donated_items) + ' items', sub: `${fmt(m.inventory.donated_lbs, 1)} lbs from ${fmt(m.inventory.donated_by_donors)} donor${m.inventory.donated_by_donors !== 1 ? 's' : ''}`, delta: null }] : []) ]; document.getElementById('brp-metrics-grid').innerHTML = cards.map(c => `
${c.label}
${c.value}
${c.sub}
${deltaHtml(c.delta)}
`).join(''); // Highlights const h = r.highlights; const hlCards = []; if (h.top_volunteer) hlCards.push({ icon: '🏆', label: 'Top Volunteer', value: `${h.top_volunteer.name} · ${h.top_volunteer.hours}h` }); if (h.largest_donation) hlCards.push({ icon: '💛', label: 'Largest Donation', value: `${fmtMoney(h.largest_donation.amount)} from ${h.largest_donation.donor}` }); if (h.busiest_day) hlCards.push({ icon: '📅', label: 'Busiest Day', value: `${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })} · ${h.busiest_day.visits} visits · ${(h.busiest_day.lbs || 0).toLocaleString(undefined, { maximumFractionDigits: 1 })} lbs` }); if (!hlCards.length) hlCards.push({ icon: 'ℹ️', label: 'No Activity', value: 'No data recorded for this month' }); document.getElementById('brp-highlights').innerHTML = hlCards.map(c => `
${c.icon}
${c.label}
${c.value}
`).join(''); } function printBoardReport() { if (!currentBoardReport) return; const r = currentBoardReport; const m = r.metrics; const fmtN = (v, d) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: d || 0 }); const fmtM = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaStr = (d) => { if (d === null || d === undefined) return ''; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; const color = d > 0 ? '#065f46' : d < 0 ? '#991b1b' : '#6b7280'; const bg = d > 0 ? '#d1fae5' : d < 0 ? '#fee2e2' : '#f3f4f6'; return `${arrow} ${Math.abs(d)}%`; }; const h = r.highlights; const hlHtml = [ h.top_volunteer ? `🏆 Top Volunteer${h.top_volunteer.name} — ${h.top_volunteer.hours} hours` : '', h.largest_donation ? `💛 Largest Donation${fmtM(h.largest_donation.amount)} from ${h.largest_donation.donor}` : '', h.busiest_day ? `📅 Busiest Day${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', timeZone: 'UTC' })} — ${h.busiest_day.visits} visits, ${fmtN(h.busiest_day.lbs, 1)} lbs` : '' ].filter(Boolean).join(''); const metricsRows = [ ['Families Served (Unique)', fmtN(m.clients.unique_served), deltaStr(m.clients.delta_unique_served)], ['New Clients Registered', fmtN(m.clients.new_this_month), ''], ['Total Distribution Visits', fmtN(m.distributions.total_visits), deltaStr(m.distributions.delta_visits)], ['Total Pounds Distributed', fmtN(m.distributions.total_lbs, 1) + ' lbs', deltaStr(m.distributions.delta_lbs)], ['Avg Lbs per Family', fmtN(m.distributions.avg_lbs_per_family, 1) + ' lbs', ''], ['Active Volunteers', fmtN(m.volunteers.active), deltaStr(m.volunteers.delta_active)], ['Volunteer Hours', fmtN(m.volunteers.hours, 1), deltaStr(m.volunteers.delta_hours)], ['Attendance Rate', m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '%' : 'N/A', ''], ['Total Donations', fmtM(m.donations.total_usd), deltaStr(m.donations.delta_total)], ['Unique Donors', fmtN(m.donations.donor_count), deltaStr(m.donations.delta_donors)], ['Current Inventory', fmtN(m.inventory.current_stock_lbs, 1) + ' lbs', ''], ['Inventory Received This Month', fmtN(m.inventory.received_this_month_lbs, 1) + ' lbs', ''], ['Items Expiring Within 30 Days', fmtN(m.inventory.expiring_soon_items), ''], ...(m.inventory.donated_items > 0 ? [ ['Donated Inventory (Items)', fmtN(m.inventory.donated_items) + ' items (' + fmtN(m.inventory.donated_lbs, 1) + ' lbs)', ''], ['Donation Sources (Donors)', fmtN(m.inventory.donated_by_donors) + ' donor' + (m.inventory.donated_by_donors !== 1 ? 's' : ''), ''] ] : []), ['Total Active Clients on File', fmtN(m.clients.total_active), ''] ].map(([label, val, d]) => `${label}${val}${d}`).join(''); const html = `Board Report — ${r.month_label}
Minnie's Food Pantry
Monthly Board Report
${r.month_label}
${r.executive_summary}
Key Performance Metrics
${metricsRows}
MetricValuevs. Prior Month
${hlHtml ? `
Month Highlights
${hlHtml}
` : ''}
`; const blob = new Blob([html], { type: 'text/html' }); window.open(URL.createObjectURL(blob), '_blank'); } async function loadSavedBoardReports() { const list = document.getElementById('saved-board-reports-list'); try { const res = await apiFetch('/api/reports/monthly/saved'); const data = await res.json(); if (!data.success || !data.reports.length) { list.innerHTML = '
No saved reports yet. Generate your first board report above.
'; return; } list.innerHTML = data.reports.map(r => `
${formatSavedReportMonth(r.report_month)}
Generated ${new Date(r.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
`).join(''); } catch (err) { list.innerHTML = '
Could not load saved reports.
'; } } function formatSavedReportMonth(ym) { const [y, m] = ym.split('-').map(Number); return new Date(y, m - 1, 1).toLocaleString('en-US', { month: 'long', year: 'numeric' }); } async function viewSavedBoardReport(month) { const btn = document.getElementById('board-generate-btn'); const mi = document.getElementById('board-month-input'); if (mi) mi.value = month; btn.disabled = true; btn.textContent = '⏳ Loading…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (err) { alert('Error loading report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } async function deleteSavedBoardReport(id, btnEl) { if (!confirm('Delete this saved report?')) return; try { const res = await apiFetch(`/api/reports/monthly/saved/${id}`, { method: 'DELETE' }); if (res.ok) { loadSavedBoardReports(); if (currentBoardReport) { document.getElementById('board-report-preview').style.display = 'none'; currentBoardReport = null; } } } catch (err) { alert('Delete failed: ' + err.message); } } // Close modals on outside click window.onclick = function(event) { if (event.target.classList.contains('modal')) { event.target.classList.remove('active'); } } // ── CSV Export ── async function exportCSV(type) { const token = localStorage.getItem('mfp_admin_token'); if (!token) { alert('Not authenticated'); return; } // Map type to API path and filename label const pathMap = { volunteers: 'volunteers', donors: 'donors', clients: 'clients', inventory: 'inventory', visits: 'visits' }; const apiPath = pathMap[type]; if (!apiPath) { alert('Unknown export type'); return; } // Build URL with optional date range from the Reports tab pickers (if available) const startEl = document.getElementById('report-start-date'); const endEl = document.getElementById('report-end-date'); let url = `/api/${apiPath}/export?format=csv`; if (startEl && startEl.value) url += `&start=${startEl.value}`; if (endEl && endEl.value) url += `&end=${endEl.value}`; try { const resp = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || `HTTP ${resp.status}`); } const blob = await resp.blob(); const blobUrl = URL.createObjectURL(blob); const today = new Date().toISOString().slice(0, 10); const a = document.createElement('a'); a.href = blobUrl; a.download = `${type}-export-${today}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); } catch (err) { alert('Export failed: ' + err.message); } } // ── CSV Import ── let importType = ''; let importRows = []; const csvFormats = { volunteers: { title: 'Import Volunteers', hint: 'CSV headers: name, email, phone, status, skills
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,phone,status,skills\nJane Smith,jane@example.com,555-1234,active,Logistics' }, donors: { title: 'Import Donors', hint: 'CSV headers: name, email, total_given, last_gift_date, phone, donor_type, status
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,total_given,last_gift_date\nJohn Doe,john@example.com,500.00,2025-12-15' } }; function openImportModal(type) { importType = type; importRows = []; const config = csvFormats[type]; document.getElementById('import-modal-title').textContent = config.title; document.getElementById('csv-format-hint').innerHTML = config.hint; resetImportModal(); document.getElementById('import-csv-modal').classList.add('active'); } function closeImportModal() { document.getElementById('import-csv-modal').classList.remove('active'); importType = ''; importRows = []; } function resetImportModal() { document.getElementById('import-step-upload').style.display = 'block'; document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.remove('active'); document.getElementById('import-file-input').value = ''; importRows = []; } // Drag and drop const dropZone = document.getElementById('import-drop-zone'); ['dragenter', 'dragover'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add('drag-over'); }); }); ['dragleave', 'drop'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('drag-over'); }); }); dropZone.addEventListener('drop', (e) => { const files = e.dataTransfer.files; if (files.length > 0) { processFile(files[0]); } }); function handleFileSelect(e) { if (e.target.files.length > 0) { processFile(e.target.files[0]); } } function processFile(file) { if (!file.name.toLowerCase().endsWith('.csv') && file.type !== 'text/csv') { alert('Please upload a CSV file'); return; } const reader = new FileReader(); reader.onload = (e) => { const text = e.target.result; const parsed = parseCSV(text); if (parsed.length === 0) { alert('No data rows found in CSV'); return; } importRows = parsed; showPreview(file.name, parsed); }; reader.readAsText(file); } function parseCSV(text) { const lines = text.split(/\r?\n/).filter(line => line.trim()); if (lines.length < 2) return []; const headers = parseCSVLine(lines[0]).map(h => h.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_')); const rows = []; for (let i = 1; i < lines.length; i++) { const values = parseCSVLine(lines[i]); if (values.length === 0 || (values.length === 1 && !values[0].trim())) continue; const row = {}; headers.forEach((header, idx) => { row[header] = (values[idx] || '').trim(); }); rows.push(row); } return rows; } function parseCSVLine(line) { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"') { if (i + 1 < line.length && line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = false; } } else { current += ch; } } else { if (ch === '"') { inQuotes = true; } else if (ch === ',') { result.push(current); current = ''; } else { current += ch; } } } result.push(current); return result; } function showPreview(fileName, rows) { document.getElementById('import-step-upload').style.display = 'none'; document.getElementById('import-step-preview').classList.add('active'); document.getElementById('preview-file-name').textContent = fileName; document.getElementById('preview-row-count').textContent = `${rows.length} row${rows.length !== 1 ? 's' : ''}`; const headers = Object.keys(rows[0]); const previewRows = rows.slice(0, 5); const remaining = rows.length - 5; let tableHtml = '' + headers.map(h => `${h}`).join('') + ''; previewRows.forEach(row => { tableHtml += '' + headers.map(h => `${escapeHtml(row[h] || '')}`).join('') + ''; }); tableHtml += ''; document.getElementById('preview-table').innerHTML = tableHtml; if (remaining > 0) { document.getElementById('preview-more-rows').textContent = `+ ${remaining} more row${remaining !== 1 ? 's' : ''}`; document.getElementById('preview-more-rows').style.display = 'block'; } else { document.getElementById('preview-more-rows').style.display = 'none'; } } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } async function executeImport() { document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.add('active'); try { const response = await apiFetch(`/api/${importType}/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rows: importRows }) }); const result = await response.json(); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); if (response.ok && result.success) { const hasErrors = result.errors && result.errors.length > 0; document.getElementById('import-result-icon').textContent = hasErrors ? '⚠️' : '✅'; document.getElementById('import-result-title').textContent = hasErrors ? 'Import completed with some issues' : 'Import successful!'; document.getElementById('import-summary').innerHTML = `
${result.created}
Created
${result.updated}
Updated
${result.errors.length}
Errors
`; if (hasErrors) { const errorsDiv = document.getElementById('import-errors'); errorsDiv.style.display = 'block'; errorsDiv.innerHTML = result.errors.slice(0, 10).map(e => `Row ${e.row}: ${escapeHtml(e.error)}` ).join('
'); if (result.errors.length > 10) { errorsDiv.innerHTML += `
... and ${result.errors.length - 10} more`; } } else { document.getElementById('import-errors').style.display = 'none'; } // Refresh data if (importType === 'volunteers') loadVolunteers(); if (importType === 'donors') loadDonors(); loadDashboardData(); } else { document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = result.error || 'Import failed'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } catch (error) { document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = 'Network error — please try again'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } // ── Locations Management ── const LOCATION_TYPE_LABELS = { main_facility: { label: 'Main Facility', color: '#C44B2B' }, school_pantry: { label: 'School Pantry', color: '#2563EB' }, partner_site: { label: 'Partner Site', color: '#16A34A' }, other: { label: 'Other', color: '#6B7280' } }; async function loadLocationsTab() { try { const data = await apiFetch('/api/locations/all').then(r => r.json()); state.locations.data = data.locations || []; renderLocations(state.locations.data); } catch (e) { document.getElementById('locations-table').innerHTML = '
Error loading locations
'; } } function renderLocations(locations) { const tbody = document.getElementById('locations-table'); if (!locations || locations.length === 0) { tbody.innerHTML = '
📍
No locations found
Add your first location to get started
'; return; } tbody.innerHTML = locations.map(loc => { const typeInfo = LOCATION_TYPE_LABELS[loc.location_type] || LOCATION_TYPE_LABELS.other; return ` ${loc.name} ${typeInfo.label} ${loc.address || '—'} ${loc.is_active ? 'Active' : 'Inactive'} ${loc.is_active ? ` ` : 'Inactive'} `; }).join(''); } function openAddLocationModal() { document.getElementById('location-id').value = ''; document.getElementById('location-modal-title').textContent = 'Add Location'; document.getElementById('location-submit-btn').textContent = 'Add Location'; document.getElementById('location-form').reset(); document.getElementById('location-modal').classList.add('active'); } function openEditLocationModal(id, name, type, address) { document.getElementById('location-id').value = id; document.getElementById('location-modal-title').textContent = 'Edit Location'; document.getElementById('location-submit-btn').textContent = 'Save Changes'; document.getElementById('loc-name').value = name; document.getElementById('loc-type').value = type; document.getElementById('loc-address').value = address; document.getElementById('location-modal').classList.add('active'); } async function saveLocation(e) { e.preventDefault(); const id = document.getElementById('location-id').value; const payload = { name: document.getElementById('loc-name').value.trim(), location_type: document.getElementById('loc-type').value, address: document.getElementById('loc-address').value.trim() || null }; try { const url = id ? `/api/locations/${id}` : '/api/locations'; const method = id ? 'PUT' : 'POST'; const resp = await apiFetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to save location'); return; } closeModal('location-modal'); await loadLocationsTab(); // Refresh the dropdown so the new location appears await initLocationFilter(); } catch (err) { alert('Network error — please try again'); } } async function deleteLocation(id) { if (!confirm('Remove this location? It will be deactivated and hidden from the filter.')) return; try { const resp = await apiFetch(`/api/locations/${id}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove location'); return; } await loadLocationsTab(); await initLocationFilter(); // If the deleted location was selected, reset to "all" if (String(globalLocationId) === String(id)) { globalLocationId = 'all'; localStorage.setItem('minniesos_location_filter', 'all'); } } catch (err) { alert('Network error — please try again'); } } // ── Users Tab ── const ROLE_LABELS = { admin: '🔑 Admin', coordinator: '📋 Coordinator', volunteer: '🙋 Volunteer' }; const ROLE_COLORS = { admin: '#6B2FA0', coordinator: '#2563EB', volunteer: '#16A34A' }; async function loadUsersTab() { const container = document.getElementById('users-list-container'); if (!container) return; container.innerHTML = '
Loading…
'; try { const data = await apiFetch('/api/users/roles').then(r => r.json()); if (!data.users || !data.users.length) { container.innerHTML = '
No users found.
'; return; } let html = ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; // Preload locations for the dropdown let locations = []; try { const locData = await apiFetch('/api/locations/all').then(r => r.json()); locations = locData.locations || locData || []; } catch(e) { /* ignore */ } const locOptions = locations.map(l => ``).join(''); for (const user of data.users) { const roleBadges = (user.roles || []).map(r => { const color = ROLE_COLORS[r.role] || '#888'; const label = ROLE_LABELS[r.role] || r.role; const locPart = r.location_name ? ` @ ${r.location_name}` : ''; return `${label}${locPart} `; }).join('') || 'No roles'; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; } html += '
NameEmailRolesAdd Role
${user.name || '—'}${user.email}${roleBadges}
'; container.innerHTML = html; } catch (err) { container.innerHTML = '
Failed to load users. You may not have admin access.
'; } } async function assignUserRole(event, userId) { event.preventDefault(); const form = event.target; const role = form.role.value; const location_id = form.location_id.value || null; try { const resp = await apiFetch(`/api/users/${userId}/roles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role, location_id: location_id ? parseInt(location_id) : null }) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to assign role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } async function removeUserRole(userId, roleId) { if (!confirm('Remove this role?')) return; try { const resp = await apiFetch(`/api/users/${userId}/roles/${roleId}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } function showAddUserModal() { // For now just redirect to settings or show an alert with instructions // Full user creation is out of scope for this task; uses /api/auth/setup-style endpoint alert('To add a new user, have them visit the app and use the setup flow, or have an admin create their account via the auth setup endpoint.'); } // ───────────────────────────────────────────────────────────────────── // CAMPAIGN TRACKER // ───────────────────────────────────────────────────────────────────── let _campaignsCache = []; let _campaignDetailChart = null; function destroyCampaignDetailChart() { if (_campaignDetailChart) { _campaignDetailChart.destroy(); _campaignDetailChart = null; } } async function loadCampaigns() { document.getElementById('campaigns-list-view').style.display = ''; document.getElementById('campaigns-detail-view').style.display = 'none'; destroyCampaignDetailChart(); const container = document.getElementById('campaigns-cards-container'); container.innerHTML = '
Loading…
'; try { const res = await apiFetch('/api/campaigns'); const data = await res.json(); _campaignsCache = data.campaigns || []; renderCampaignCards(_campaignsCache); } catch (err) { container.innerHTML = '
Error loading campaigns: ' + escapeHtml(err.message) + '
'; } } function renderCampaignCards(campaigns) { const container = document.getElementById('campaigns-cards-container'); if (!campaigns.length) { container.innerHTML = '
🎯
No campaigns yet
Create your first fundraising campaign to start tracking progress.
'; return; } const statusLabel = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft', cancelled: '❌ Cancelled' }; const statusClass = { active: 'active', completed: 'completed', draft: 'draft', cancelled: 'draft' }; container.innerHTML = '
' + campaigns.map(c => { const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const overGoal = goal > 0 && raised >= goal; const pctDisplay = goal > 0 ? (parseFloat(c.progress_pct || 0)).toFixed(0) + '%' : ''; const progressBar = goal > 0 ? '
+ goal.toLocaleString() + '">
' + pctDisplay + ' of + goal.toLocaleString() + ' goal
' : ''; const startD = c.start_date ? c.start_date.split('T')[0] : ''; const endD = c.end_date ? c.end_date.split('T')[0] : ''; return '
' + '
' + '
' + escapeHtml(c.name) + '
' + '' + (statusLabel[c.status] || c.status) + '' + '
' + '
📅 ' + startD + ' → ' + endD + '
' + progressBar + '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:0,maximumFractionDigits:0}) + 'Raised
' + '
' + (c.donor_count || 0) + 'Donors
' + '
' + (c.donation_count || 0) + 'Donations
' + '
' + '
'; }).join('') + ''; } async function openCampaignDetail(campaignId) { document.getElementById('campaigns-list-view').style.display = 'none'; const detailView = document.getElementById('campaigns-detail-view'); detailView.style.display = ''; document.getElementById('campaigns-detail-body').innerHTML = '
Loading…
'; destroyCampaignDetailChart(); try { const res = await apiFetch('/api/campaigns/' + campaignId); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to load'); const c = data.campaign; const topDonors = data.top_donors || []; const recentDonations = data.recent_donations || []; const timeline = data.daily_timeline || []; const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const pctDisplay = goal > 0 ? pct.toFixed(0) + '%' : 'No goal set'; const overGoal = goal > 0 && raised >= goal; const daysRemaining = parseInt(c.days_remaining || 0); const daysLabel = daysRemaining > 0 ? daysRemaining + ' days left' : daysRemaining === 0 ? 'Ends today' : 'Ended'; // Thermometer const thermoHtml = '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' + (goal > 0 ? '
of + goal.toLocaleString() + ' goal · ' + pctDisplay + '
' : '
No dollar goal set
') + (goal > 0 ? '
' + (pct > 15 ? pctDisplay : '') + '
' : '') + '
'; // 3 stat cards const statsHtml = '
' + '
💰
+ raised.toLocaleString('en-US', {minimumFractionDigits:0}) + '
Raised
' + '
👥
' + (c.donor_count || 0) + '
Donors
' + '
📅
' + daysLabel + '
Timeline
' + '
'; // Top donors leaderboard const topDonorsHtml = topDonors.length === 0 ? '' : '
🏆 Top Donors
' + '
' + topDonors.map((d, i) => '
' + '
' + ['🥇','🥈','🥉','4️⃣','5️⃣'][i] + '' + escapeHtml(d.name) + '
' + ' + parseFloat(d.total).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' ).join('') + '
'; // Recent donations table const recentHtml = recentDonations.length === 0 ? '' : '
📋 Recent Donations
' + '
' + '' + '' + recentDonations.map((d, i) => '' + '' + '' + '' + '' + '' ).join('') + '
DateDonorAmountType
' + (d.donation_date ? d.donation_date.split('T')[0] : '') + '' + escapeHtml(d.donor_name || '') + ' + parseFloat(d.amount).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '' + (d.donation_type || '') + '
'; // Daily chart canvas const chartHtml = timeline.length === 0 ? '' : '
📊 Daily Donations
' + '
' + '
'; // Action buttons const statusLabel2 = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft' }; const actionsHtml = '
' + '' + (c.status === 'active' ? '' : '') + (c.status !== 'cancelled' ? '' : '') + '
'; document.getElementById('campaigns-detail-body').innerHTML = '
' + '' + '
' + '
' + escapeHtml(c.name) + '
' + '
' + (c.description ? escapeHtml(c.description) : '') + '
' + '
' + thermoHtml + statsHtml + actionsHtml + topDonorsHtml + recentHtml + chartHtml; // Render chart if (timeline.length > 0) { setTimeout(() => { const ctx = document.getElementById('campaign-daily-chart'); if (!ctx) return; _campaignDetailChart = new Chart(ctx.getContext('2d'), { type: 'bar', data: { labels: timeline.map(r => r.date), datasets: [{ label: 'Daily Donations ($)', data: timeline.map(r => parseFloat(r.total)), backgroundColor: 'rgba(45,125,70,0.75)', borderColor: '#2D7D46', borderWidth: 1, borderRadius: 4 }] }, options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: v => ' + v.toLocaleString() } } } } }); }, 50); } } catch (err) { document.getElementById('campaigns-detail-body').innerHTML = '
Error: ' + escapeHtml(err.message) + '
'; } } async function submitNewCampaign(e) { e.preventDefault(); const form = e.target; const fd = new FormData(form); const body = Object.fromEntries(fd); // Remove empty optional fields ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) delete body[k]; }); try { const res = await apiFetch('/api/campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to create campaign'); return; } closeModal('new-campaign-modal'); form.reset(); loadCampaigns(); alert('Campaign created!'); } catch (err) { alert('Network error. Please try again.'); } } function openNewCampaignModal() { const form = document.getElementById('new-campaign-form'); if (form) form.reset(); const today = new Date().toISOString().split('T')[0]; const endDate = new Date(); endDate.setDate(endDate.getDate() + 30); const endStr = endDate.toISOString().split('T')[0]; const sd = form.querySelector('[name=start_date]'); const ed = form.querySelector('[name=end_date]'); if (sd) sd.value = today; if (ed) ed.value = endStr; document.getElementById('new-campaign-modal').classList.add('active'); } let _editingCampaignId = null; function openEditCampaignModal(campaignId) { const c = _campaignsCache.find(x => x.id === campaignId) || {}; _editingCampaignId = campaignId; const form = document.getElementById('new-campaign-form'); if (!form) return; form.querySelector('[name=name]').value = c.name || ''; form.querySelector('[name=description]').value = c.description || ''; form.querySelector('[name=start_date]').value = c.start_date ? c.start_date.split('T')[0] : ''; form.querySelector('[name=end_date]').value = c.end_date ? c.end_date.split('T')[0] : ''; form.querySelector('[name=goal_dollars]').value = c.goal_dollars || ''; form.querySelector('[name=goal_lbs]').value = c.goal_lbs || ''; form.querySelector('[name=goal_items]').value = c.goal_items || ''; form.querySelector('[name=status]').value = c.status || 'active'; document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '✏️ Edit Campaign'; document.getElementById('new-campaign-modal').classList.add('active'); // Override submit to PUT form.onsubmit = async (ev) => { ev.preventDefault(); const fd = new FormData(form); const body = Object.fromEntries(fd); ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) body[k] = null; }); try { const res = await apiFetch('/api/campaigns/' + _editingCampaignId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to update'); return; } closeModal('new-campaign-modal'); document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '➕ New Campaign'; form.onsubmit = submitNewCampaign; _editingCampaignId = null; loadCampaigns(); alert('Campaign updated!'); } catch (err) { alert('Network error. Please try again.'); } }; } async function completeCampaign(id) { if (!confirm('Mark this campaign as completed?')) return; await apiFetch('/api/campaigns/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'completed' }) }); loadCampaigns(); } async function cancelCampaign(id) { if (!confirm('Cancel this campaign? This will hide it from the list.')) return; await apiFetch('/api/campaigns/' + id, { method: 'DELETE' }); loadCampaigns(); } // ── Populate campaigns in Add Donation modal ── async function populateDonationCampaigns() { const select = document.getElementById('donation-campaign-select'); if (!select) return; // Clear existing dynamic options while (select.options.length > 1) select.remove(1); try { const res = await apiFetch('/api/campaigns/active'); const data = await res.json(); (data.campaigns || []).forEach(c => { const opt = document.createElement('option'); opt.value = c.id; opt.setAttribute('data-raised', c.total_raised || 0); opt.setAttribute('data-goal', c.goal_dollars || 0); opt.textContent = c.name; select.appendChild(opt); }); } catch (err) { console.warn('Could not load campaigns for donation form:', err.message); } } function showCampaignProgress() { const select = document.getElementById('donation-campaign-select'); const progressDiv = document.getElementById('donation-campaign-progress'); if (!select || !progressDiv) return; const selected = select.options[select.selectedIndex]; if (!selected || !selected.value) { progressDiv.style.display = 'none'; return; } const raised = parseFloat(selected.getAttribute('data-raised') || 0); const goal = parseFloat(selected.getAttribute('data-goal') || 0); if (goal > 0) { const pct = (raised / goal * 100).toFixed(0); progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' of + goal.toLocaleString() + ' raised (' + pct + '%)'; progressDiv.style.display = ''; } else { progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' raised (no goal set)'; progressDiv.style.display = ''; } } <\!-- ════════════════════════════════════════════════════════════ SHIFT GAP ALERTS MODAL ════════════════════════════════════════════════════════════ --> + volunteers.dollar_value.toLocaleString(), color: '#0369A1' }, { label: 'Pre-Reg Rate', value: pre_reg.conversion_rate + '%', color: '#B45309' }, { label: 'Active Locations',value: location_count.toString(), color: '#4A235A' }, ]; const kpiHtml = kpis.map(k => `
${k.value}
${k.label}
`).join(''); // Change badges let changeHtml = ''; if (changes) { if (changes.mom_families_pct !== null && changes.mom_families_pct !== undefined) { const dir = changes.mom_families_pct >= 0 ? '▲' : '▼'; const col = changes.mom_families_pct >= 0 ? '#1a6b4a' : '#c0392b'; changeHtml += `${dir} ${Math.abs(changes.mom_families_pct)}% vs prior month`; } if (changes.yoy_families_pct !== null && changes.yoy_families_pct !== undefined) { const dir = changes.yoy_families_pct >= 0 ? '▲' : '▼'; const col = changes.yoy_families_pct >= 0 ? '#1a6b4a' : '#c0392b'; changeHtml += `${dir} ${Math.abs(changes.yoy_families_pct)}% vs prior year`; } if (changes.qoq_families_pct !== null && changes.qoq_families_pct !== undefined) { const dir = changes.qoq_families_pct >= 0 ? '▲' : '▼'; const col = changes.qoq_families_pct >= 0 ? '#1a6b4a' : '#c0392b'; changeHtml += `${dir} ${Math.abs(changes.qoq_families_pct)}% vs prior quarter`; } } // Trajectory bars (quarterly only) let trajectoryHtml = ''; if (monthly_trajectory && monthly_trajectory.length === 3) { const maxFam = Math.max(...monthly_trajectory.map(m => m.families_served), 1); trajectoryHtml = `
Monthly Trajectory
${monthly_trajectory.map(m => { const pct = Math.round((m.families_served / maxFam) * 100); return `
${m.label.slice(0,3)}
${m.families_served}
`; }).join('')}
`; } // Location table rows const locRows = locations.slice(0, 10).map(l => ` ${l.name || 'Unknown'} ${l.families_served.toLocaleString()} ${l.volunteer_hours.toLocaleString()} `).join(''); const preview = document.getElementById('gr-preview-content'); preview.innerHTML = `

📊 ${period_label}

${changeHtml}
${narrative && narrative.length > 0 ? `
${narrative.map(s => '

' + s + '

').join('')}
` : ''}
${kpiHtml}
${trajectoryHtml} ${locRows ? `
Top Locations
${locRows}
Location Families Vol. Hours
` : ''} `; document.getElementById('gr-loading').style.display = 'none'; document.getElementById('gr-empty').style.display = 'none'; document.getElementById('gr-preview-content').style.display = 'block'; // Enable export tab document.getElementById('gr-export-disabled').style.display = 'none'; document.getElementById('gr-export-actions').style.display = 'block'; } function openGrantReportPrintView() { if (!_grCurrentPeriodKey) return; window.open('/api/admin/grant-report/export/' + _grCurrentPeriodKey, '_blank'); } function downloadGrantReportCSV() { if (!_grCurrentPeriodKey) return; const a = document.createElement('a'); a.href = '/api/admin/grant-report/export/' + _grCurrentPeriodKey + '?format=csv'; a.download = 'grant-report-' + _grCurrentPeriodKey + '.csv'; document.body.appendChild(a); a.click(); document.body.removeChild(a); } // Close modal on backdrop click document.getElementById('grant-report-modal').addEventListener('click', function(e) { if (e.target === this) closeGrantReportModal(); }); // ── END Grant Report Modal ──────────────────────────────────────────────────── '; var win = window.open('', '_blank'); win.document.write(printContent); win.document.close(); win.print(); } // ── END Scorecard Modal ──────────────────────────────────────────────────────── `; const blob = new Blob([html], { type: 'text/html' }); const url = URL.createObjectURL(blob); window.open(url, '_blank'); } // ── Board Reports ── let currentBoardReport = null; // Set default month to current month on page load // ─── USDA Compliance & Grant Report Functions ───────────────────────────── // Initialize compliance controls on page load (function initComplianceControls() { const n = new Date(); const y = n.getFullYear(); const m = String(n.getMonth() + 1).padStart(2, '0'); const monthEl = document.getElementById('compliance-month-input'); if (monthEl) monthEl.value = y + '-' + m; // Grant default: Jan 1 this year → today const lastDay = new Date(y, n.getMonth() + 1, 0).getDate(); const gsEl = document.getElementById('grant-start-date'); const geEl = document.getElementById('grant-end-date'); if (gsEl) gsEl.value = y + '-01-01'; if (geEl) geEl.value = y + '-' + m + '-' + String(lastDay).padStart(2, '0'); // Load locations for filter fetch('/api/locations').then(r => r.json()).then(data => { const sel = document.getElementById('compliance-location-filter'); if (!sel) return; (data.locations || []).forEach(function(loc) { const opt = document.createElement('option'); opt.value = loc.id; opt.textContent = loc.name; sel.appendChild(opt); }); }).catch(function() {}); })(); function onCompliancePeriodChange() { const period = document.getElementById('compliance-period-type').value; const monthInput = document.getElementById('compliance-month-input'); if (!monthInput) return; const n = new Date(); if (period === 'monthly') { monthInput.type = 'month'; monthInput.value = n.getFullYear() + '-' + String(n.getMonth() + 1).padStart(2, '0'); } else if (period === 'quarterly') { // Use a text input for quarter (YYYY-QN) monthInput.type = 'text'; const q = Math.ceil((n.getMonth() + 1) / 3); monthInput.value = n.getFullYear() + '-Q' + q; monthInput.placeholder = 'e.g. 2026-Q1'; } else if (period === 'yearly') { monthInput.type = 'number'; monthInput.value = n.getFullYear(); monthInput.min = '2020'; monthInput.max = '2099'; } } async function generateComplianceReport() { const periodType = document.getElementById('compliance-period-type').value; const monthVal = document.getElementById('compliance-month-input').value; const locationId = document.getElementById('compliance-location-filter').value; if (!monthVal) { alert('Please select a period.'); return; } const btn = document.getElementById('compliance-generate-btn'); btn.disabled = true; btn.textContent = '⏳ Generating…'; try { let url = '/api/reports/compliance?period=' + periodType + '&month=' + encodeURIComponent(monthVal); if (locationId) url += '&location_id=' + locationId; const resp = await apiFetch(url); const data = await resp.json(); if (!resp.ok || !data.success) throw new Error(data.error || 'Failed to generate report'); renderComplianceReport(data); const view = document.getElementById('compliance-report-view'); view.style.display = ''; view.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) { alert('Error generating compliance report: ' + e.message); } finally { btn.disabled = false; btn.textContent = '📋 Generate Report'; } } function fmtNum(n, decimals) { const num = parseFloat(n) || 0; return decimals !== undefined ? num.toFixed(decimals) : num.toLocaleString(); } function complianceMetricCard(label, value, color) { const c = color || '#1a5276'; return '
' + label + '
' + value + '
'; } function renderComplianceReport(data) { const s = data.summary; const container = document.getElementById('compliance-report-view'); const byLocRows = (data.by_location || []).map(function(r) { return '' + (r.location_name || 'Unknown') + '' + r.families + '' + fmtNum(r.pounds_distributed, 1) + '' + (r.items_distributed || 0) + ''; }).join('') || 'No distribution data for this period'; const byCatRows = (data.by_category || []).map(function(r) { return '' + (r.category || 'other') + '' + fmtNum(r.pounds_distributed, 1) + '' + (r.items_distributed || 0) + ''; }).join('') || 'No category data (requires distribution_items linked to inventory)'; const periodType = document.getElementById('compliance-period-type').value; const monthVal = document.getElementById('compliance-month-input').value; const locationId = document.getElementById('compliance-location-filter').value; const exportUrl = '/api/reports/compliance/export?period=' + periodType + '&month=' + encodeURIComponent(monthVal) + '&format=csv' + (locationId ? '&location_id=' + locationId : ''); container.innerHTML = '
' + '
' + '
Minnie's Food Pantry
' + '
USDA TEFAP Compliance Report
' + '
' + data.period + '  •  ' + data.date_range.start + ' – ' + data.date_range.end + '
' + '
' + '
' + '
Summary Metrics
' + '
' + complianceMetricCard('Families Served', fmtNum(s.families_served)) + complianceMetricCard('Individuals Served', fmtNum(s.individuals_served)) + complianceMetricCard('Lbs Distributed', fmtNum(s.pounds_distributed, 1) + ' lbs') + complianceMetricCard('Items Distributed', fmtNum(s.items_distributed)) + complianceMetricCard('Lbs Received', fmtNum(s.pounds_received, 1) + ' lbs') + complianceMetricCard('Volunteer Hours', fmtNum(s.volunteer_hours, 1) + ' hrs') + complianceMetricCard('Unique Volunteers', fmtNum(s.unique_volunteers)) + complianceMetricCard('Donation Value', ' const n = new Date(); const y = n.getFullYear(); const m = String(n.getMonth() + 1).padStart(2, '0'); const el = document.getElementById('board-month-input'); if (el) el.value = `${y}-${m}`; })(); async function generateBoardReport() { const monthInput = document.getElementById('board-month-input'); const month = monthInput ? monthInput.value : ''; if (!month) { alert('Please select a month.'); return; } const btn = document.getElementById('board-generate-btn'); btn.disabled = true; btn.textContent = '⏳ Generating…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!res.ok || !data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); // Refresh saved list loadSavedBoardReports(); } catch (err) { alert('Error generating report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } function renderBoardReportPreview(r) { const m = r.metrics; document.getElementById('brp-month').textContent = r.month_label; document.getElementById('brp-summary').textContent = r.executive_summary; // Metrics grid const fmt = (v, digits) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: digits || 0 }); const fmtMoney = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaHtml = (d) => { if (d === null || d === undefined) return ''; const cls = d > 0 ? 'up' : d < 0 ? 'down' : 'flat'; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; return `${arrow} ${Math.abs(d)}% vs prior month`; }; const cards = [ { label: 'Families Served', value: fmt(m.clients.unique_served), sub: `${fmt(m.clients.new_this_month)} new clients this month`, delta: m.clients.delta_unique_served }, { label: 'Pounds Distributed', value: fmt(m.distributions.total_lbs, 1), sub: `${fmt(m.distributions.total_visits)} visits · ${fmt(m.distributions.avg_lbs_per_family, 1)} lbs/family avg`, delta: m.distributions.delta_lbs }, { label: 'Active Volunteers', value: fmt(m.volunteers.active), sub: `${fmt(m.volunteers.hours, 1)} hours · ${m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '% attendance' : 'no attendance data'}`, delta: m.volunteers.delta_active }, { label: 'Donations', value: fmtMoney(m.donations.total_usd), sub: `${fmt(m.donations.donor_count)} donors · ${fmt(m.donations.donation_count)} gifts`, delta: m.donations.delta_total }, { label: 'Current Inventory', value: fmt(m.inventory.current_stock_lbs, 1) + ' lbs', sub: `${fmt(m.inventory.received_this_month_lbs, 1)} lbs received · ${fmt(m.inventory.expiring_soon_items)} items expiring soon`, delta: null }, { label: 'Total Active Clients', value: fmt(m.clients.total_active), sub: 'On file in system', delta: null }, ...(m.inventory.donated_items > 0 ? [{ label: 'Donated Inventory', value: fmt(m.inventory.donated_items) + ' items', sub: `${fmt(m.inventory.donated_lbs, 1)} lbs from ${fmt(m.inventory.donated_by_donors)} donor${m.inventory.donated_by_donors !== 1 ? 's' : ''}`, delta: null }] : []) ]; document.getElementById('brp-metrics-grid').innerHTML = cards.map(c => `
${c.label}
${c.value}
${c.sub}
${deltaHtml(c.delta)}
`).join(''); // Highlights const h = r.highlights; const hlCards = []; if (h.top_volunteer) hlCards.push({ icon: '🏆', label: 'Top Volunteer', value: `${h.top_volunteer.name} · ${h.top_volunteer.hours}h` }); if (h.largest_donation) hlCards.push({ icon: '💛', label: 'Largest Donation', value: `${fmtMoney(h.largest_donation.amount)} from ${h.largest_donation.donor}` }); if (h.busiest_day) hlCards.push({ icon: '📅', label: 'Busiest Day', value: `${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })} · ${h.busiest_day.visits} visits · ${(h.busiest_day.lbs || 0).toLocaleString(undefined, { maximumFractionDigits: 1 })} lbs` }); if (!hlCards.length) hlCards.push({ icon: 'ℹ️', label: 'No Activity', value: 'No data recorded for this month' }); document.getElementById('brp-highlights').innerHTML = hlCards.map(c => `
${c.icon}
${c.label}
${c.value}
`).join(''); } function printBoardReport() { if (!currentBoardReport) return; const r = currentBoardReport; const m = r.metrics; const fmtN = (v, d) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: d || 0 }); const fmtM = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaStr = (d) => { if (d === null || d === undefined) return ''; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; const color = d > 0 ? '#065f46' : d < 0 ? '#991b1b' : '#6b7280'; const bg = d > 0 ? '#d1fae5' : d < 0 ? '#fee2e2' : '#f3f4f6'; return `${arrow} ${Math.abs(d)}%`; }; const h = r.highlights; const hlHtml = [ h.top_volunteer ? `🏆 Top Volunteer${h.top_volunteer.name} — ${h.top_volunteer.hours} hours` : '', h.largest_donation ? `💛 Largest Donation${fmtM(h.largest_donation.amount)} from ${h.largest_donation.donor}` : '', h.busiest_day ? `📅 Busiest Day${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', timeZone: 'UTC' })} — ${h.busiest_day.visits} visits, ${fmtN(h.busiest_day.lbs, 1)} lbs` : '' ].filter(Boolean).join(''); const metricsRows = [ ['Families Served (Unique)', fmtN(m.clients.unique_served), deltaStr(m.clients.delta_unique_served)], ['New Clients Registered', fmtN(m.clients.new_this_month), ''], ['Total Distribution Visits', fmtN(m.distributions.total_visits), deltaStr(m.distributions.delta_visits)], ['Total Pounds Distributed', fmtN(m.distributions.total_lbs, 1) + ' lbs', deltaStr(m.distributions.delta_lbs)], ['Avg Lbs per Family', fmtN(m.distributions.avg_lbs_per_family, 1) + ' lbs', ''], ['Active Volunteers', fmtN(m.volunteers.active), deltaStr(m.volunteers.delta_active)], ['Volunteer Hours', fmtN(m.volunteers.hours, 1), deltaStr(m.volunteers.delta_hours)], ['Attendance Rate', m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '%' : 'N/A', ''], ['Total Donations', fmtM(m.donations.total_usd), deltaStr(m.donations.delta_total)], ['Unique Donors', fmtN(m.donations.donor_count), deltaStr(m.donations.delta_donors)], ['Current Inventory', fmtN(m.inventory.current_stock_lbs, 1) + ' lbs', ''], ['Inventory Received This Month', fmtN(m.inventory.received_this_month_lbs, 1) + ' lbs', ''], ['Items Expiring Within 30 Days', fmtN(m.inventory.expiring_soon_items), ''], ...(m.inventory.donated_items > 0 ? [ ['Donated Inventory (Items)', fmtN(m.inventory.donated_items) + ' items (' + fmtN(m.inventory.donated_lbs, 1) + ' lbs)', ''], ['Donation Sources (Donors)', fmtN(m.inventory.donated_by_donors) + ' donor' + (m.inventory.donated_by_donors !== 1 ? 's' : ''), ''] ] : []), ['Total Active Clients on File', fmtN(m.clients.total_active), ''] ].map(([label, val, d]) => `${label}${val}${d}`).join(''); const html = `Board Report — ${r.month_label}
Minnie's Food Pantry
Monthly Board Report
${r.month_label}
${r.executive_summary}
Key Performance Metrics
${metricsRows}
MetricValuevs. Prior Month
${hlHtml ? `
Month Highlights
${hlHtml}
` : ''}
`; const blob = new Blob([html], { type: 'text/html' }); window.open(URL.createObjectURL(blob), '_blank'); } async function loadSavedBoardReports() { const list = document.getElementById('saved-board-reports-list'); try { const res = await apiFetch('/api/reports/monthly/saved'); const data = await res.json(); if (!data.success || !data.reports.length) { list.innerHTML = '
No saved reports yet. Generate your first board report above.
'; return; } list.innerHTML = data.reports.map(r => `
${formatSavedReportMonth(r.report_month)}
Generated ${new Date(r.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
`).join(''); } catch (err) { list.innerHTML = '
Could not load saved reports.
'; } } function formatSavedReportMonth(ym) { const [y, m] = ym.split('-').map(Number); return new Date(y, m - 1, 1).toLocaleString('en-US', { month: 'long', year: 'numeric' }); } async function viewSavedBoardReport(month) { const btn = document.getElementById('board-generate-btn'); const mi = document.getElementById('board-month-input'); if (mi) mi.value = month; btn.disabled = true; btn.textContent = '⏳ Loading…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (err) { alert('Error loading report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } async function deleteSavedBoardReport(id, btnEl) { if (!confirm('Delete this saved report?')) return; try { const res = await apiFetch(`/api/reports/monthly/saved/${id}`, { method: 'DELETE' }); if (res.ok) { loadSavedBoardReports(); if (currentBoardReport) { document.getElementById('board-report-preview').style.display = 'none'; currentBoardReport = null; } } } catch (err) { alert('Delete failed: ' + err.message); } } // Close modals on outside click window.onclick = function(event) { if (event.target.classList.contains('modal')) { event.target.classList.remove('active'); } } // ── CSV Export ── async function exportCSV(type) { const token = localStorage.getItem('mfp_admin_token'); if (!token) { alert('Not authenticated'); return; } // Map type to API path and filename label const pathMap = { volunteers: 'volunteers', donors: 'donors', clients: 'clients', inventory: 'inventory', visits: 'visits' }; const apiPath = pathMap[type]; if (!apiPath) { alert('Unknown export type'); return; } // Build URL with optional date range from the Reports tab pickers (if available) const startEl = document.getElementById('report-start-date'); const endEl = document.getElementById('report-end-date'); let url = `/api/${apiPath}/export?format=csv`; if (startEl && startEl.value) url += `&start=${startEl.value}`; if (endEl && endEl.value) url += `&end=${endEl.value}`; try { const resp = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || `HTTP ${resp.status}`); } const blob = await resp.blob(); const blobUrl = URL.createObjectURL(blob); const today = new Date().toISOString().slice(0, 10); const a = document.createElement('a'); a.href = blobUrl; a.download = `${type}-export-${today}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); } catch (err) { alert('Export failed: ' + err.message); } } // ── CSV Import ── let importType = ''; let importRows = []; const csvFormats = { volunteers: { title: 'Import Volunteers', hint: 'CSV headers: name, email, phone, status, skills
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,phone,status,skills\nJane Smith,jane@example.com,555-1234,active,Logistics' }, donors: { title: 'Import Donors', hint: 'CSV headers: name, email, total_given, last_gift_date, phone, donor_type, status
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,total_given,last_gift_date\nJohn Doe,john@example.com,500.00,2025-12-15' } }; function openImportModal(type) { importType = type; importRows = []; const config = csvFormats[type]; document.getElementById('import-modal-title').textContent = config.title; document.getElementById('csv-format-hint').innerHTML = config.hint; resetImportModal(); document.getElementById('import-csv-modal').classList.add('active'); } function closeImportModal() { document.getElementById('import-csv-modal').classList.remove('active'); importType = ''; importRows = []; } function resetImportModal() { document.getElementById('import-step-upload').style.display = 'block'; document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.remove('active'); document.getElementById('import-file-input').value = ''; importRows = []; } // Drag and drop const dropZone = document.getElementById('import-drop-zone'); ['dragenter', 'dragover'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add('drag-over'); }); }); ['dragleave', 'drop'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('drag-over'); }); }); dropZone.addEventListener('drop', (e) => { const files = e.dataTransfer.files; if (files.length > 0) { processFile(files[0]); } }); function handleFileSelect(e) { if (e.target.files.length > 0) { processFile(e.target.files[0]); } } function processFile(file) { if (!file.name.toLowerCase().endsWith('.csv') && file.type !== 'text/csv') { alert('Please upload a CSV file'); return; } const reader = new FileReader(); reader.onload = (e) => { const text = e.target.result; const parsed = parseCSV(text); if (parsed.length === 0) { alert('No data rows found in CSV'); return; } importRows = parsed; showPreview(file.name, parsed); }; reader.readAsText(file); } function parseCSV(text) { const lines = text.split(/\r?\n/).filter(line => line.trim()); if (lines.length < 2) return []; const headers = parseCSVLine(lines[0]).map(h => h.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_')); const rows = []; for (let i = 1; i < lines.length; i++) { const values = parseCSVLine(lines[i]); if (values.length === 0 || (values.length === 1 && !values[0].trim())) continue; const row = {}; headers.forEach((header, idx) => { row[header] = (values[idx] || '').trim(); }); rows.push(row); } return rows; } function parseCSVLine(line) { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"') { if (i + 1 < line.length && line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = false; } } else { current += ch; } } else { if (ch === '"') { inQuotes = true; } else if (ch === ',') { result.push(current); current = ''; } else { current += ch; } } } result.push(current); return result; } function showPreview(fileName, rows) { document.getElementById('import-step-upload').style.display = 'none'; document.getElementById('import-step-preview').classList.add('active'); document.getElementById('preview-file-name').textContent = fileName; document.getElementById('preview-row-count').textContent = `${rows.length} row${rows.length !== 1 ? 's' : ''}`; const headers = Object.keys(rows[0]); const previewRows = rows.slice(0, 5); const remaining = rows.length - 5; let tableHtml = '' + headers.map(h => `${h}`).join('') + ''; previewRows.forEach(row => { tableHtml += '' + headers.map(h => `${escapeHtml(row[h] || '')}`).join('') + ''; }); tableHtml += ''; document.getElementById('preview-table').innerHTML = tableHtml; if (remaining > 0) { document.getElementById('preview-more-rows').textContent = `+ ${remaining} more row${remaining !== 1 ? 's' : ''}`; document.getElementById('preview-more-rows').style.display = 'block'; } else { document.getElementById('preview-more-rows').style.display = 'none'; } } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } async function executeImport() { document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.add('active'); try { const response = await apiFetch(`/api/${importType}/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rows: importRows }) }); const result = await response.json(); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); if (response.ok && result.success) { const hasErrors = result.errors && result.errors.length > 0; document.getElementById('import-result-icon').textContent = hasErrors ? '⚠️' : '✅'; document.getElementById('import-result-title').textContent = hasErrors ? 'Import completed with some issues' : 'Import successful!'; document.getElementById('import-summary').innerHTML = `
${result.created}
Created
${result.updated}
Updated
${result.errors.length}
Errors
`; if (hasErrors) { const errorsDiv = document.getElementById('import-errors'); errorsDiv.style.display = 'block'; errorsDiv.innerHTML = result.errors.slice(0, 10).map(e => `Row ${e.row}: ${escapeHtml(e.error)}` ).join('
'); if (result.errors.length > 10) { errorsDiv.innerHTML += `
... and ${result.errors.length - 10} more`; } } else { document.getElementById('import-errors').style.display = 'none'; } // Refresh data if (importType === 'volunteers') loadVolunteers(); if (importType === 'donors') loadDonors(); loadDashboardData(); } else { document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = result.error || 'Import failed'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } catch (error) { document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = 'Network error — please try again'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } // ── Locations Management ── const LOCATION_TYPE_LABELS = { main_facility: { label: 'Main Facility', color: '#C44B2B' }, school_pantry: { label: 'School Pantry', color: '#2563EB' }, partner_site: { label: 'Partner Site', color: '#16A34A' }, other: { label: 'Other', color: '#6B7280' } }; async function loadLocationsTab() { try { const data = await apiFetch('/api/locations/all').then(r => r.json()); state.locations.data = data.locations || []; renderLocations(state.locations.data); } catch (e) { document.getElementById('locations-table').innerHTML = '
Error loading locations
'; } } function renderLocations(locations) { const tbody = document.getElementById('locations-table'); if (!locations || locations.length === 0) { tbody.innerHTML = '
📍
No locations found
Add your first location to get started
'; return; } tbody.innerHTML = locations.map(loc => { const typeInfo = LOCATION_TYPE_LABELS[loc.location_type] || LOCATION_TYPE_LABELS.other; return ` ${loc.name} ${typeInfo.label} ${loc.address || '—'} ${loc.is_active ? 'Active' : 'Inactive'} ${loc.is_active ? ` ` : 'Inactive'} `; }).join(''); } function openAddLocationModal() { document.getElementById('location-id').value = ''; document.getElementById('location-modal-title').textContent = 'Add Location'; document.getElementById('location-submit-btn').textContent = 'Add Location'; document.getElementById('location-form').reset(); document.getElementById('location-modal').classList.add('active'); } function openEditLocationModal(id, name, type, address) { document.getElementById('location-id').value = id; document.getElementById('location-modal-title').textContent = 'Edit Location'; document.getElementById('location-submit-btn').textContent = 'Save Changes'; document.getElementById('loc-name').value = name; document.getElementById('loc-type').value = type; document.getElementById('loc-address').value = address; document.getElementById('location-modal').classList.add('active'); } async function saveLocation(e) { e.preventDefault(); const id = document.getElementById('location-id').value; const payload = { name: document.getElementById('loc-name').value.trim(), location_type: document.getElementById('loc-type').value, address: document.getElementById('loc-address').value.trim() || null }; try { const url = id ? `/api/locations/${id}` : '/api/locations'; const method = id ? 'PUT' : 'POST'; const resp = await apiFetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to save location'); return; } closeModal('location-modal'); await loadLocationsTab(); // Refresh the dropdown so the new location appears await initLocationFilter(); } catch (err) { alert('Network error — please try again'); } } async function deleteLocation(id) { if (!confirm('Remove this location? It will be deactivated and hidden from the filter.')) return; try { const resp = await apiFetch(`/api/locations/${id}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove location'); return; } await loadLocationsTab(); await initLocationFilter(); // If the deleted location was selected, reset to "all" if (String(globalLocationId) === String(id)) { globalLocationId = 'all'; localStorage.setItem('minniesos_location_filter', 'all'); } } catch (err) { alert('Network error — please try again'); } } // ── Users Tab ── const ROLE_LABELS = { admin: '🔑 Admin', coordinator: '📋 Coordinator', volunteer: '🙋 Volunteer' }; const ROLE_COLORS = { admin: '#6B2FA0', coordinator: '#2563EB', volunteer: '#16A34A' }; async function loadUsersTab() { const container = document.getElementById('users-list-container'); if (!container) return; container.innerHTML = '
Loading…
'; try { const data = await apiFetch('/api/users/roles').then(r => r.json()); if (!data.users || !data.users.length) { container.innerHTML = '
No users found.
'; return; } let html = ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; // Preload locations for the dropdown let locations = []; try { const locData = await apiFetch('/api/locations/all').then(r => r.json()); locations = locData.locations || locData || []; } catch(e) { /* ignore */ } const locOptions = locations.map(l => ``).join(''); for (const user of data.users) { const roleBadges = (user.roles || []).map(r => { const color = ROLE_COLORS[r.role] || '#888'; const label = ROLE_LABELS[r.role] || r.role; const locPart = r.location_name ? ` @ ${r.location_name}` : ''; return `${label}${locPart} `; }).join('') || 'No roles'; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; } html += '
NameEmailRolesAdd Role
${user.name || '—'}${user.email}${roleBadges}
'; container.innerHTML = html; } catch (err) { container.innerHTML = '
Failed to load users. You may not have admin access.
'; } } async function assignUserRole(event, userId) { event.preventDefault(); const form = event.target; const role = form.role.value; const location_id = form.location_id.value || null; try { const resp = await apiFetch(`/api/users/${userId}/roles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role, location_id: location_id ? parseInt(location_id) : null }) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to assign role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } async function removeUserRole(userId, roleId) { if (!confirm('Remove this role?')) return; try { const resp = await apiFetch(`/api/users/${userId}/roles/${roleId}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } function showAddUserModal() { // For now just redirect to settings or show an alert with instructions // Full user creation is out of scope for this task; uses /api/auth/setup-style endpoint alert('To add a new user, have them visit the app and use the setup flow, or have an admin create their account via the auth setup endpoint.'); } // ───────────────────────────────────────────────────────────────────── // CAMPAIGN TRACKER // ───────────────────────────────────────────────────────────────────── let _campaignsCache = []; let _campaignDetailChart = null; function destroyCampaignDetailChart() { if (_campaignDetailChart) { _campaignDetailChart.destroy(); _campaignDetailChart = null; } } async function loadCampaigns() { document.getElementById('campaigns-list-view').style.display = ''; document.getElementById('campaigns-detail-view').style.display = 'none'; destroyCampaignDetailChart(); const container = document.getElementById('campaigns-cards-container'); container.innerHTML = '
Loading…
'; try { const res = await apiFetch('/api/campaigns'); const data = await res.json(); _campaignsCache = data.campaigns || []; renderCampaignCards(_campaignsCache); } catch (err) { container.innerHTML = '
Error loading campaigns: ' + escapeHtml(err.message) + '
'; } } function renderCampaignCards(campaigns) { const container = document.getElementById('campaigns-cards-container'); if (!campaigns.length) { container.innerHTML = '
🎯
No campaigns yet
Create your first fundraising campaign to start tracking progress.
'; return; } const statusLabel = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft', cancelled: '❌ Cancelled' }; const statusClass = { active: 'active', completed: 'completed', draft: 'draft', cancelled: 'draft' }; container.innerHTML = '
' + campaigns.map(c => { const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const overGoal = goal > 0 && raised >= goal; const pctDisplay = goal > 0 ? (parseFloat(c.progress_pct || 0)).toFixed(0) + '%' : ''; const progressBar = goal > 0 ? '
+ goal.toLocaleString() + '">
' + pctDisplay + ' of + goal.toLocaleString() + ' goal
' : ''; const startD = c.start_date ? c.start_date.split('T')[0] : ''; const endD = c.end_date ? c.end_date.split('T')[0] : ''; return '
' + '
' + '
' + escapeHtml(c.name) + '
' + '' + (statusLabel[c.status] || c.status) + '' + '
' + '
📅 ' + startD + ' → ' + endD + '
' + progressBar + '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:0,maximumFractionDigits:0}) + 'Raised
' + '
' + (c.donor_count || 0) + 'Donors
' + '
' + (c.donation_count || 0) + 'Donations
' + '
' + '
'; }).join('') + '
'; } async function openCampaignDetail(campaignId) { document.getElementById('campaigns-list-view').style.display = 'none'; const detailView = document.getElementById('campaigns-detail-view'); detailView.style.display = ''; document.getElementById('campaigns-detail-body').innerHTML = '
Loading…
'; destroyCampaignDetailChart(); try { const res = await apiFetch('/api/campaigns/' + campaignId); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to load'); const c = data.campaign; const topDonors = data.top_donors || []; const recentDonations = data.recent_donations || []; const timeline = data.daily_timeline || []; const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const pctDisplay = goal > 0 ? pct.toFixed(0) + '%' : 'No goal set'; const overGoal = goal > 0 && raised >= goal; const daysRemaining = parseInt(c.days_remaining || 0); const daysLabel = daysRemaining > 0 ? daysRemaining + ' days left' : daysRemaining === 0 ? 'Ends today' : 'Ended'; // Thermometer const thermoHtml = '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' + (goal > 0 ? '
of + goal.toLocaleString() + ' goal · ' + pctDisplay + '
' : '
No dollar goal set
') + (goal > 0 ? '
' + (pct > 15 ? pctDisplay : '') + '
' : '') + '
'; // 3 stat cards const statsHtml = '
' + '
💰
+ raised.toLocaleString('en-US', {minimumFractionDigits:0}) + '
Raised
' + '
👥
' + (c.donor_count || 0) + '
Donors
' + '
📅
' + daysLabel + '
Timeline
' + '
'; // Top donors leaderboard const topDonorsHtml = topDonors.length === 0 ? '' : '
🏆 Top Donors
' + '
' + topDonors.map((d, i) => '
' + '
' + ['🥇','🥈','🥉','4️⃣','5️⃣'][i] + '' + escapeHtml(d.name) + '
' + ' + parseFloat(d.total).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' ).join('') + '
'; // Recent donations table const recentHtml = recentDonations.length === 0 ? '' : '
📋 Recent Donations
' + '
' + '' + '' + recentDonations.map((d, i) => '' + '' + '' + '' + '' + '' ).join('') + '
DateDonorAmountType
' + (d.donation_date ? d.donation_date.split('T')[0] : '') + '' + escapeHtml(d.donor_name || '') + ' + parseFloat(d.amount).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '' + (d.donation_type || '') + '
'; // Daily chart canvas const chartHtml = timeline.length === 0 ? '' : '
📊 Daily Donations
' + '
' + '
'; // Action buttons const statusLabel2 = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft' }; const actionsHtml = '
' + '' + (c.status === 'active' ? '' : '') + (c.status !== 'cancelled' ? '' : '') + '
'; document.getElementById('campaigns-detail-body').innerHTML = '
' + '' + '
' + '
' + escapeHtml(c.name) + '
' + '
' + (c.description ? escapeHtml(c.description) : '') + '
' + '
' + thermoHtml + statsHtml + actionsHtml + topDonorsHtml + recentHtml + chartHtml; // Render chart if (timeline.length > 0) { setTimeout(() => { const ctx = document.getElementById('campaign-daily-chart'); if (!ctx) return; _campaignDetailChart = new Chart(ctx.getContext('2d'), { type: 'bar', data: { labels: timeline.map(r => r.date), datasets: [{ label: 'Daily Donations ($)', data: timeline.map(r => parseFloat(r.total)), backgroundColor: 'rgba(45,125,70,0.75)', borderColor: '#2D7D46', borderWidth: 1, borderRadius: 4 }] }, options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: v => ' + v.toLocaleString() } } } } }); }, 50); } } catch (err) { document.getElementById('campaigns-detail-body').innerHTML = '
Error: ' + escapeHtml(err.message) + '
'; } } async function submitNewCampaign(e) { e.preventDefault(); const form = e.target; const fd = new FormData(form); const body = Object.fromEntries(fd); // Remove empty optional fields ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) delete body[k]; }); try { const res = await apiFetch('/api/campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to create campaign'); return; } closeModal('new-campaign-modal'); form.reset(); loadCampaigns(); alert('Campaign created!'); } catch (err) { alert('Network error. Please try again.'); } } function openNewCampaignModal() { const form = document.getElementById('new-campaign-form'); if (form) form.reset(); const today = new Date().toISOString().split('T')[0]; const endDate = new Date(); endDate.setDate(endDate.getDate() + 30); const endStr = endDate.toISOString().split('T')[0]; const sd = form.querySelector('[name=start_date]'); const ed = form.querySelector('[name=end_date]'); if (sd) sd.value = today; if (ed) ed.value = endStr; document.getElementById('new-campaign-modal').classList.add('active'); } let _editingCampaignId = null; function openEditCampaignModal(campaignId) { const c = _campaignsCache.find(x => x.id === campaignId) || {}; _editingCampaignId = campaignId; const form = document.getElementById('new-campaign-form'); if (!form) return; form.querySelector('[name=name]').value = c.name || ''; form.querySelector('[name=description]').value = c.description || ''; form.querySelector('[name=start_date]').value = c.start_date ? c.start_date.split('T')[0] : ''; form.querySelector('[name=end_date]').value = c.end_date ? c.end_date.split('T')[0] : ''; form.querySelector('[name=goal_dollars]').value = c.goal_dollars || ''; form.querySelector('[name=goal_lbs]').value = c.goal_lbs || ''; form.querySelector('[name=goal_items]').value = c.goal_items || ''; form.querySelector('[name=status]').value = c.status || 'active'; document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '✏️ Edit Campaign'; document.getElementById('new-campaign-modal').classList.add('active'); // Override submit to PUT form.onsubmit = async (ev) => { ev.preventDefault(); const fd = new FormData(form); const body = Object.fromEntries(fd); ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) body[k] = null; }); try { const res = await apiFetch('/api/campaigns/' + _editingCampaignId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to update'); return; } closeModal('new-campaign-modal'); document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '➕ New Campaign'; form.onsubmit = submitNewCampaign; _editingCampaignId = null; loadCampaigns(); alert('Campaign updated!'); } catch (err) { alert('Network error. Please try again.'); } }; } async function completeCampaign(id) { if (!confirm('Mark this campaign as completed?')) return; await apiFetch('/api/campaigns/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'completed' }) }); loadCampaigns(); } async function cancelCampaign(id) { if (!confirm('Cancel this campaign? This will hide it from the list.')) return; await apiFetch('/api/campaigns/' + id, { method: 'DELETE' }); loadCampaigns(); } // ── Populate campaigns in Add Donation modal ── async function populateDonationCampaigns() { const select = document.getElementById('donation-campaign-select'); if (!select) return; // Clear existing dynamic options while (select.options.length > 1) select.remove(1); try { const res = await apiFetch('/api/campaigns/active'); const data = await res.json(); (data.campaigns || []).forEach(c => { const opt = document.createElement('option'); opt.value = c.id; opt.setAttribute('data-raised', c.total_raised || 0); opt.setAttribute('data-goal', c.goal_dollars || 0); opt.textContent = c.name; select.appendChild(opt); }); } catch (err) { console.warn('Could not load campaigns for donation form:', err.message); } } function showCampaignProgress() { const select = document.getElementById('donation-campaign-select'); const progressDiv = document.getElementById('donation-campaign-progress'); if (!select || !progressDiv) return; const selected = select.options[select.selectedIndex]; if (!selected || !selected.value) { progressDiv.style.display = 'none'; return; } const raised = parseFloat(selected.getAttribute('data-raised') || 0); const goal = parseFloat(selected.getAttribute('data-goal') || 0); if (goal > 0) { const pct = (raised / goal * 100).toFixed(0); progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' of + goal.toLocaleString() + ' raised (' + pct + '%)'; progressDiv.style.display = ''; } else { progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' raised (no goal set)'; progressDiv.style.display = ''; } } + fmtNum(s.donation_value, 2)) + '
' + '
By Location
' + '' + byLocRows + '
LocationFamiliesLbs DistributedItems
' + '
By Food Category
' + '' + byCatRows + '
CategoryLbs DistributedItems
' + '
' + '
' + '' + '📥 Export CSV for USDA' + '
' + ''; } async function generateGrantImpact() { const start = document.getElementById('grant-start-date').value; const end = document.getElementById('grant-end-date').value; if (!start || !end) { alert('Please select start and end dates.'); return; } if (start > end) { alert('Start date must be before end date.'); return; } const btn = document.getElementById('grant-generate-btn'); btn.disabled = true; btn.textContent = '⏳ Generating…'; try { const resp = await apiFetch('/api/reports/grant-impact?start=' + start + '&end=' + end); const data = await resp.json(); if (!resp.ok || !data.success) throw new Error(data.error || 'Failed to generate grant summary'); renderGrantImpact(data); const view = document.getElementById('grant-impact-view'); view.style.display = ''; view.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) { alert('Error generating grant summary: ' + e.message); } finally { btn.disabled = false; btn.textContent = '📊 Generate Summary'; } } function renderGrantImpact(data) { const s = data.summary; const container = document.getElementById('grant-impact-view'); const topDonorRows = (data.top_donors || []).map(function(d, i) { return '' + (i + 1) + '' + (d.name || '—') + '' + (d.donor_type || '—') + '' + (d.donation_count || 0) + ' const n = new Date(); const y = n.getFullYear(); const m = String(n.getMonth() + 1).padStart(2, '0'); const el = document.getElementById('board-month-input'); if (el) el.value = `${y}-${m}`; })(); async function generateBoardReport() { const monthInput = document.getElementById('board-month-input'); const month = monthInput ? monthInput.value : ''; if (!month) { alert('Please select a month.'); return; } const btn = document.getElementById('board-generate-btn'); btn.disabled = true; btn.textContent = '⏳ Generating…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!res.ok || !data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); // Refresh saved list loadSavedBoardReports(); } catch (err) { alert('Error generating report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } function renderBoardReportPreview(r) { const m = r.metrics; document.getElementById('brp-month').textContent = r.month_label; document.getElementById('brp-summary').textContent = r.executive_summary; // Metrics grid const fmt = (v, digits) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: digits || 0 }); const fmtMoney = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaHtml = (d) => { if (d === null || d === undefined) return ''; const cls = d > 0 ? 'up' : d < 0 ? 'down' : 'flat'; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; return `${arrow} ${Math.abs(d)}% vs prior month`; }; const cards = [ { label: 'Families Served', value: fmt(m.clients.unique_served), sub: `${fmt(m.clients.new_this_month)} new clients this month`, delta: m.clients.delta_unique_served }, { label: 'Pounds Distributed', value: fmt(m.distributions.total_lbs, 1), sub: `${fmt(m.distributions.total_visits)} visits · ${fmt(m.distributions.avg_lbs_per_family, 1)} lbs/family avg`, delta: m.distributions.delta_lbs }, { label: 'Active Volunteers', value: fmt(m.volunteers.active), sub: `${fmt(m.volunteers.hours, 1)} hours · ${m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '% attendance' : 'no attendance data'}`, delta: m.volunteers.delta_active }, { label: 'Donations', value: fmtMoney(m.donations.total_usd), sub: `${fmt(m.donations.donor_count)} donors · ${fmt(m.donations.donation_count)} gifts`, delta: m.donations.delta_total }, { label: 'Current Inventory', value: fmt(m.inventory.current_stock_lbs, 1) + ' lbs', sub: `${fmt(m.inventory.received_this_month_lbs, 1)} lbs received · ${fmt(m.inventory.expiring_soon_items)} items expiring soon`, delta: null }, { label: 'Total Active Clients', value: fmt(m.clients.total_active), sub: 'On file in system', delta: null }, ...(m.inventory.donated_items > 0 ? [{ label: 'Donated Inventory', value: fmt(m.inventory.donated_items) + ' items', sub: `${fmt(m.inventory.donated_lbs, 1)} lbs from ${fmt(m.inventory.donated_by_donors)} donor${m.inventory.donated_by_donors !== 1 ? 's' : ''}`, delta: null }] : []) ]; document.getElementById('brp-metrics-grid').innerHTML = cards.map(c => `
${c.label}
${c.value}
${c.sub}
${deltaHtml(c.delta)}
`).join(''); // Highlights const h = r.highlights; const hlCards = []; if (h.top_volunteer) hlCards.push({ icon: '🏆', label: 'Top Volunteer', value: `${h.top_volunteer.name} · ${h.top_volunteer.hours}h` }); if (h.largest_donation) hlCards.push({ icon: '💛', label: 'Largest Donation', value: `${fmtMoney(h.largest_donation.amount)} from ${h.largest_donation.donor}` }); if (h.busiest_day) hlCards.push({ icon: '📅', label: 'Busiest Day', value: `${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })} · ${h.busiest_day.visits} visits · ${(h.busiest_day.lbs || 0).toLocaleString(undefined, { maximumFractionDigits: 1 })} lbs` }); if (!hlCards.length) hlCards.push({ icon: 'ℹ️', label: 'No Activity', value: 'No data recorded for this month' }); document.getElementById('brp-highlights').innerHTML = hlCards.map(c => `
${c.icon}
${c.label}
${c.value}
`).join(''); } function printBoardReport() { if (!currentBoardReport) return; const r = currentBoardReport; const m = r.metrics; const fmtN = (v, d) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: d || 0 }); const fmtM = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaStr = (d) => { if (d === null || d === undefined) return ''; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; const color = d > 0 ? '#065f46' : d < 0 ? '#991b1b' : '#6b7280'; const bg = d > 0 ? '#d1fae5' : d < 0 ? '#fee2e2' : '#f3f4f6'; return `${arrow} ${Math.abs(d)}%`; }; const h = r.highlights; const hlHtml = [ h.top_volunteer ? `🏆 Top Volunteer${h.top_volunteer.name} — ${h.top_volunteer.hours} hours` : '', h.largest_donation ? `💛 Largest Donation${fmtM(h.largest_donation.amount)} from ${h.largest_donation.donor}` : '', h.busiest_day ? `📅 Busiest Day${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', timeZone: 'UTC' })} — ${h.busiest_day.visits} visits, ${fmtN(h.busiest_day.lbs, 1)} lbs` : '' ].filter(Boolean).join(''); const metricsRows = [ ['Families Served (Unique)', fmtN(m.clients.unique_served), deltaStr(m.clients.delta_unique_served)], ['New Clients Registered', fmtN(m.clients.new_this_month), ''], ['Total Distribution Visits', fmtN(m.distributions.total_visits), deltaStr(m.distributions.delta_visits)], ['Total Pounds Distributed', fmtN(m.distributions.total_lbs, 1) + ' lbs', deltaStr(m.distributions.delta_lbs)], ['Avg Lbs per Family', fmtN(m.distributions.avg_lbs_per_family, 1) + ' lbs', ''], ['Active Volunteers', fmtN(m.volunteers.active), deltaStr(m.volunteers.delta_active)], ['Volunteer Hours', fmtN(m.volunteers.hours, 1), deltaStr(m.volunteers.delta_hours)], ['Attendance Rate', m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '%' : 'N/A', ''], ['Total Donations', fmtM(m.donations.total_usd), deltaStr(m.donations.delta_total)], ['Unique Donors', fmtN(m.donations.donor_count), deltaStr(m.donations.delta_donors)], ['Current Inventory', fmtN(m.inventory.current_stock_lbs, 1) + ' lbs', ''], ['Inventory Received This Month', fmtN(m.inventory.received_this_month_lbs, 1) + ' lbs', ''], ['Items Expiring Within 30 Days', fmtN(m.inventory.expiring_soon_items), ''], ...(m.inventory.donated_items > 0 ? [ ['Donated Inventory (Items)', fmtN(m.inventory.donated_items) + ' items (' + fmtN(m.inventory.donated_lbs, 1) + ' lbs)', ''], ['Donation Sources (Donors)', fmtN(m.inventory.donated_by_donors) + ' donor' + (m.inventory.donated_by_donors !== 1 ? 's' : ''), ''] ] : []), ['Total Active Clients on File', fmtN(m.clients.total_active), ''] ].map(([label, val, d]) => `${label}${val}${d}`).join(''); const html = `Board Report — ${r.month_label}
Minnie's Food Pantry
Monthly Board Report
${r.month_label}
${r.executive_summary}
Key Performance Metrics
${metricsRows}
MetricValuevs. Prior Month
${hlHtml ? `
Month Highlights
${hlHtml}
` : ''}
`; const blob = new Blob([html], { type: 'text/html' }); window.open(URL.createObjectURL(blob), '_blank'); } async function loadSavedBoardReports() { const list = document.getElementById('saved-board-reports-list'); try { const res = await apiFetch('/api/reports/monthly/saved'); const data = await res.json(); if (!data.success || !data.reports.length) { list.innerHTML = '
No saved reports yet. Generate your first board report above.
'; return; } list.innerHTML = data.reports.map(r => `
${formatSavedReportMonth(r.report_month)}
Generated ${new Date(r.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
`).join(''); } catch (err) { list.innerHTML = '
Could not load saved reports.
'; } } function formatSavedReportMonth(ym) { const [y, m] = ym.split('-').map(Number); return new Date(y, m - 1, 1).toLocaleString('en-US', { month: 'long', year: 'numeric' }); } async function viewSavedBoardReport(month) { const btn = document.getElementById('board-generate-btn'); const mi = document.getElementById('board-month-input'); if (mi) mi.value = month; btn.disabled = true; btn.textContent = '⏳ Loading…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (err) { alert('Error loading report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } async function deleteSavedBoardReport(id, btnEl) { if (!confirm('Delete this saved report?')) return; try { const res = await apiFetch(`/api/reports/monthly/saved/${id}`, { method: 'DELETE' }); if (res.ok) { loadSavedBoardReports(); if (currentBoardReport) { document.getElementById('board-report-preview').style.display = 'none'; currentBoardReport = null; } } } catch (err) { alert('Delete failed: ' + err.message); } } // Close modals on outside click window.onclick = function(event) { if (event.target.classList.contains('modal')) { event.target.classList.remove('active'); } } // ── CSV Export ── async function exportCSV(type) { const token = localStorage.getItem('mfp_admin_token'); if (!token) { alert('Not authenticated'); return; } // Map type to API path and filename label const pathMap = { volunteers: 'volunteers', donors: 'donors', clients: 'clients', inventory: 'inventory', visits: 'visits' }; const apiPath = pathMap[type]; if (!apiPath) { alert('Unknown export type'); return; } // Build URL with optional date range from the Reports tab pickers (if available) const startEl = document.getElementById('report-start-date'); const endEl = document.getElementById('report-end-date'); let url = `/api/${apiPath}/export?format=csv`; if (startEl && startEl.value) url += `&start=${startEl.value}`; if (endEl && endEl.value) url += `&end=${endEl.value}`; try { const resp = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || `HTTP ${resp.status}`); } const blob = await resp.blob(); const blobUrl = URL.createObjectURL(blob); const today = new Date().toISOString().slice(0, 10); const a = document.createElement('a'); a.href = blobUrl; a.download = `${type}-export-${today}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); } catch (err) { alert('Export failed: ' + err.message); } } // ── CSV Import ── let importType = ''; let importRows = []; const csvFormats = { volunteers: { title: 'Import Volunteers', hint: 'CSV headers: name, email, phone, status, skills
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,phone,status,skills\nJane Smith,jane@example.com,555-1234,active,Logistics' }, donors: { title: 'Import Donors', hint: 'CSV headers: name, email, total_given, last_gift_date, phone, donor_type, status
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,total_given,last_gift_date\nJohn Doe,john@example.com,500.00,2025-12-15' } }; function openImportModal(type) { importType = type; importRows = []; const config = csvFormats[type]; document.getElementById('import-modal-title').textContent = config.title; document.getElementById('csv-format-hint').innerHTML = config.hint; resetImportModal(); document.getElementById('import-csv-modal').classList.add('active'); } function closeImportModal() { document.getElementById('import-csv-modal').classList.remove('active'); importType = ''; importRows = []; } function resetImportModal() { document.getElementById('import-step-upload').style.display = 'block'; document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.remove('active'); document.getElementById('import-file-input').value = ''; importRows = []; } // Drag and drop const dropZone = document.getElementById('import-drop-zone'); ['dragenter', 'dragover'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add('drag-over'); }); }); ['dragleave', 'drop'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('drag-over'); }); }); dropZone.addEventListener('drop', (e) => { const files = e.dataTransfer.files; if (files.length > 0) { processFile(files[0]); } }); function handleFileSelect(e) { if (e.target.files.length > 0) { processFile(e.target.files[0]); } } function processFile(file) { if (!file.name.toLowerCase().endsWith('.csv') && file.type !== 'text/csv') { alert('Please upload a CSV file'); return; } const reader = new FileReader(); reader.onload = (e) => { const text = e.target.result; const parsed = parseCSV(text); if (parsed.length === 0) { alert('No data rows found in CSV'); return; } importRows = parsed; showPreview(file.name, parsed); }; reader.readAsText(file); } function parseCSV(text) { const lines = text.split(/\r?\n/).filter(line => line.trim()); if (lines.length < 2) return []; const headers = parseCSVLine(lines[0]).map(h => h.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_')); const rows = []; for (let i = 1; i < lines.length; i++) { const values = parseCSVLine(lines[i]); if (values.length === 0 || (values.length === 1 && !values[0].trim())) continue; const row = {}; headers.forEach((header, idx) => { row[header] = (values[idx] || '').trim(); }); rows.push(row); } return rows; } function parseCSVLine(line) { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"') { if (i + 1 < line.length && line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = false; } } else { current += ch; } } else { if (ch === '"') { inQuotes = true; } else if (ch === ',') { result.push(current); current = ''; } else { current += ch; } } } result.push(current); return result; } function showPreview(fileName, rows) { document.getElementById('import-step-upload').style.display = 'none'; document.getElementById('import-step-preview').classList.add('active'); document.getElementById('preview-file-name').textContent = fileName; document.getElementById('preview-row-count').textContent = `${rows.length} row${rows.length !== 1 ? 's' : ''}`; const headers = Object.keys(rows[0]); const previewRows = rows.slice(0, 5); const remaining = rows.length - 5; let tableHtml = '' + headers.map(h => `${h}`).join('') + ''; previewRows.forEach(row => { tableHtml += '' + headers.map(h => `${escapeHtml(row[h] || '')}`).join('') + ''; }); tableHtml += ''; document.getElementById('preview-table').innerHTML = tableHtml; if (remaining > 0) { document.getElementById('preview-more-rows').textContent = `+ ${remaining} more row${remaining !== 1 ? 's' : ''}`; document.getElementById('preview-more-rows').style.display = 'block'; } else { document.getElementById('preview-more-rows').style.display = 'none'; } } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } async function executeImport() { document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.add('active'); try { const response = await apiFetch(`/api/${importType}/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rows: importRows }) }); const result = await response.json(); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); if (response.ok && result.success) { const hasErrors = result.errors && result.errors.length > 0; document.getElementById('import-result-icon').textContent = hasErrors ? '⚠️' : '✅'; document.getElementById('import-result-title').textContent = hasErrors ? 'Import completed with some issues' : 'Import successful!'; document.getElementById('import-summary').innerHTML = `
${result.created}
Created
${result.updated}
Updated
${result.errors.length}
Errors
`; if (hasErrors) { const errorsDiv = document.getElementById('import-errors'); errorsDiv.style.display = 'block'; errorsDiv.innerHTML = result.errors.slice(0, 10).map(e => `Row ${e.row}: ${escapeHtml(e.error)}` ).join('
'); if (result.errors.length > 10) { errorsDiv.innerHTML += `
... and ${result.errors.length - 10} more`; } } else { document.getElementById('import-errors').style.display = 'none'; } // Refresh data if (importType === 'volunteers') loadVolunteers(); if (importType === 'donors') loadDonors(); loadDashboardData(); } else { document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = result.error || 'Import failed'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } catch (error) { document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = 'Network error — please try again'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } // ── Locations Management ── const LOCATION_TYPE_LABELS = { main_facility: { label: 'Main Facility', color: '#C44B2B' }, school_pantry: { label: 'School Pantry', color: '#2563EB' }, partner_site: { label: 'Partner Site', color: '#16A34A' }, other: { label: 'Other', color: '#6B7280' } }; async function loadLocationsTab() { try { const data = await apiFetch('/api/locations/all').then(r => r.json()); state.locations.data = data.locations || []; renderLocations(state.locations.data); } catch (e) { document.getElementById('locations-table').innerHTML = '
Error loading locations
'; } } function renderLocations(locations) { const tbody = document.getElementById('locations-table'); if (!locations || locations.length === 0) { tbody.innerHTML = '
📍
No locations found
Add your first location to get started
'; return; } tbody.innerHTML = locations.map(loc => { const typeInfo = LOCATION_TYPE_LABELS[loc.location_type] || LOCATION_TYPE_LABELS.other; return ` ${loc.name} ${typeInfo.label} ${loc.address || '—'} ${loc.is_active ? 'Active' : 'Inactive'} ${loc.is_active ? ` ` : 'Inactive'} `; }).join(''); } function openAddLocationModal() { document.getElementById('location-id').value = ''; document.getElementById('location-modal-title').textContent = 'Add Location'; document.getElementById('location-submit-btn').textContent = 'Add Location'; document.getElementById('location-form').reset(); document.getElementById('location-modal').classList.add('active'); } function openEditLocationModal(id, name, type, address) { document.getElementById('location-id').value = id; document.getElementById('location-modal-title').textContent = 'Edit Location'; document.getElementById('location-submit-btn').textContent = 'Save Changes'; document.getElementById('loc-name').value = name; document.getElementById('loc-type').value = type; document.getElementById('loc-address').value = address; document.getElementById('location-modal').classList.add('active'); } async function saveLocation(e) { e.preventDefault(); const id = document.getElementById('location-id').value; const payload = { name: document.getElementById('loc-name').value.trim(), location_type: document.getElementById('loc-type').value, address: document.getElementById('loc-address').value.trim() || null }; try { const url = id ? `/api/locations/${id}` : '/api/locations'; const method = id ? 'PUT' : 'POST'; const resp = await apiFetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to save location'); return; } closeModal('location-modal'); await loadLocationsTab(); // Refresh the dropdown so the new location appears await initLocationFilter(); } catch (err) { alert('Network error — please try again'); } } async function deleteLocation(id) { if (!confirm('Remove this location? It will be deactivated and hidden from the filter.')) return; try { const resp = await apiFetch(`/api/locations/${id}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove location'); return; } await loadLocationsTab(); await initLocationFilter(); // If the deleted location was selected, reset to "all" if (String(globalLocationId) === String(id)) { globalLocationId = 'all'; localStorage.setItem('minniesos_location_filter', 'all'); } } catch (err) { alert('Network error — please try again'); } } // ── Users Tab ── const ROLE_LABELS = { admin: '🔑 Admin', coordinator: '📋 Coordinator', volunteer: '🙋 Volunteer' }; const ROLE_COLORS = { admin: '#6B2FA0', coordinator: '#2563EB', volunteer: '#16A34A' }; async function loadUsersTab() { const container = document.getElementById('users-list-container'); if (!container) return; container.innerHTML = '
Loading…
'; try { const data = await apiFetch('/api/users/roles').then(r => r.json()); if (!data.users || !data.users.length) { container.innerHTML = '
No users found.
'; return; } let html = ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; // Preload locations for the dropdown let locations = []; try { const locData = await apiFetch('/api/locations/all').then(r => r.json()); locations = locData.locations || locData || []; } catch(e) { /* ignore */ } const locOptions = locations.map(l => ``).join(''); for (const user of data.users) { const roleBadges = (user.roles || []).map(r => { const color = ROLE_COLORS[r.role] || '#888'; const label = ROLE_LABELS[r.role] || r.role; const locPart = r.location_name ? ` @ ${r.location_name}` : ''; return `${label}${locPart} `; }).join('') || 'No roles'; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; } html += '
NameEmailRolesAdd Role
${user.name || '—'}${user.email}${roleBadges}
'; container.innerHTML = html; } catch (err) { container.innerHTML = '
Failed to load users. You may not have admin access.
'; } } async function assignUserRole(event, userId) { event.preventDefault(); const form = event.target; const role = form.role.value; const location_id = form.location_id.value || null; try { const resp = await apiFetch(`/api/users/${userId}/roles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role, location_id: location_id ? parseInt(location_id) : null }) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to assign role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } async function removeUserRole(userId, roleId) { if (!confirm('Remove this role?')) return; try { const resp = await apiFetch(`/api/users/${userId}/roles/${roleId}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } function showAddUserModal() { // For now just redirect to settings or show an alert with instructions // Full user creation is out of scope for this task; uses /api/auth/setup-style endpoint alert('To add a new user, have them visit the app and use the setup flow, or have an admin create their account via the auth setup endpoint.'); } // ───────────────────────────────────────────────────────────────────── // CAMPAIGN TRACKER // ───────────────────────────────────────────────────────────────────── let _campaignsCache = []; let _campaignDetailChart = null; function destroyCampaignDetailChart() { if (_campaignDetailChart) { _campaignDetailChart.destroy(); _campaignDetailChart = null; } } async function loadCampaigns() { document.getElementById('campaigns-list-view').style.display = ''; document.getElementById('campaigns-detail-view').style.display = 'none'; destroyCampaignDetailChart(); const container = document.getElementById('campaigns-cards-container'); container.innerHTML = '
Loading…
'; try { const res = await apiFetch('/api/campaigns'); const data = await res.json(); _campaignsCache = data.campaigns || []; renderCampaignCards(_campaignsCache); } catch (err) { container.innerHTML = '
Error loading campaigns: ' + escapeHtml(err.message) + '
'; } } function renderCampaignCards(campaigns) { const container = document.getElementById('campaigns-cards-container'); if (!campaigns.length) { container.innerHTML = '
🎯
No campaigns yet
Create your first fundraising campaign to start tracking progress.
'; return; } const statusLabel = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft', cancelled: '❌ Cancelled' }; const statusClass = { active: 'active', completed: 'completed', draft: 'draft', cancelled: 'draft' }; container.innerHTML = '
' + campaigns.map(c => { const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const overGoal = goal > 0 && raised >= goal; const pctDisplay = goal > 0 ? (parseFloat(c.progress_pct || 0)).toFixed(0) + '%' : ''; const progressBar = goal > 0 ? '
+ goal.toLocaleString() + '">
' + pctDisplay + ' of + goal.toLocaleString() + ' goal
' : ''; const startD = c.start_date ? c.start_date.split('T')[0] : ''; const endD = c.end_date ? c.end_date.split('T')[0] : ''; return '
' + '
' + '
' + escapeHtml(c.name) + '
' + '' + (statusLabel[c.status] || c.status) + '' + '
' + '
📅 ' + startD + ' → ' + endD + '
' + progressBar + '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:0,maximumFractionDigits:0}) + 'Raised
' + '
' + (c.donor_count || 0) + 'Donors
' + '
' + (c.donation_count || 0) + 'Donations
' + '
' + '
'; }).join('') + ''; } async function openCampaignDetail(campaignId) { document.getElementById('campaigns-list-view').style.display = 'none'; const detailView = document.getElementById('campaigns-detail-view'); detailView.style.display = ''; document.getElementById('campaigns-detail-body').innerHTML = '
Loading…
'; destroyCampaignDetailChart(); try { const res = await apiFetch('/api/campaigns/' + campaignId); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to load'); const c = data.campaign; const topDonors = data.top_donors || []; const recentDonations = data.recent_donations || []; const timeline = data.daily_timeline || []; const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const pctDisplay = goal > 0 ? pct.toFixed(0) + '%' : 'No goal set'; const overGoal = goal > 0 && raised >= goal; const daysRemaining = parseInt(c.days_remaining || 0); const daysLabel = daysRemaining > 0 ? daysRemaining + ' days left' : daysRemaining === 0 ? 'Ends today' : 'Ended'; // Thermometer const thermoHtml = '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' + (goal > 0 ? '
of + goal.toLocaleString() + ' goal · ' + pctDisplay + '
' : '
No dollar goal set
') + (goal > 0 ? '
' + (pct > 15 ? pctDisplay : '') + '
' : '') + '
'; // 3 stat cards const statsHtml = '
' + '
💰
+ raised.toLocaleString('en-US', {minimumFractionDigits:0}) + '
Raised
' + '
👥
' + (c.donor_count || 0) + '
Donors
' + '
📅
' + daysLabel + '
Timeline
' + '
'; // Top donors leaderboard const topDonorsHtml = topDonors.length === 0 ? '' : '
🏆 Top Donors
' + '
' + topDonors.map((d, i) => '
' + '
' + ['🥇','🥈','🥉','4️⃣','5️⃣'][i] + '' + escapeHtml(d.name) + '
' + ' + parseFloat(d.total).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' ).join('') + '
'; // Recent donations table const recentHtml = recentDonations.length === 0 ? '' : '
📋 Recent Donations
' + '
' + '' + '' + recentDonations.map((d, i) => '' + '' + '' + '' + '' + '' ).join('') + '
DateDonorAmountType
' + (d.donation_date ? d.donation_date.split('T')[0] : '') + '' + escapeHtml(d.donor_name || '') + ' + parseFloat(d.amount).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '' + (d.donation_type || '') + '
'; // Daily chart canvas const chartHtml = timeline.length === 0 ? '' : '
📊 Daily Donations
' + '
' + '
'; // Action buttons const statusLabel2 = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft' }; const actionsHtml = '
' + '' + (c.status === 'active' ? '' : '') + (c.status !== 'cancelled' ? '' : '') + '
'; document.getElementById('campaigns-detail-body').innerHTML = '
' + '' + '
' + '
' + escapeHtml(c.name) + '
' + '
' + (c.description ? escapeHtml(c.description) : '') + '
' + '
' + thermoHtml + statsHtml + actionsHtml + topDonorsHtml + recentHtml + chartHtml; // Render chart if (timeline.length > 0) { setTimeout(() => { const ctx = document.getElementById('campaign-daily-chart'); if (!ctx) return; _campaignDetailChart = new Chart(ctx.getContext('2d'), { type: 'bar', data: { labels: timeline.map(r => r.date), datasets: [{ label: 'Daily Donations ($)', data: timeline.map(r => parseFloat(r.total)), backgroundColor: 'rgba(45,125,70,0.75)', borderColor: '#2D7D46', borderWidth: 1, borderRadius: 4 }] }, options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: v => ' + v.toLocaleString() } } } } }); }, 50); } } catch (err) { document.getElementById('campaigns-detail-body').innerHTML = '
Error: ' + escapeHtml(err.message) + '
'; } } async function submitNewCampaign(e) { e.preventDefault(); const form = e.target; const fd = new FormData(form); const body = Object.fromEntries(fd); // Remove empty optional fields ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) delete body[k]; }); try { const res = await apiFetch('/api/campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to create campaign'); return; } closeModal('new-campaign-modal'); form.reset(); loadCampaigns(); alert('Campaign created!'); } catch (err) { alert('Network error. Please try again.'); } } function openNewCampaignModal() { const form = document.getElementById('new-campaign-form'); if (form) form.reset(); const today = new Date().toISOString().split('T')[0]; const endDate = new Date(); endDate.setDate(endDate.getDate() + 30); const endStr = endDate.toISOString().split('T')[0]; const sd = form.querySelector('[name=start_date]'); const ed = form.querySelector('[name=end_date]'); if (sd) sd.value = today; if (ed) ed.value = endStr; document.getElementById('new-campaign-modal').classList.add('active'); } let _editingCampaignId = null; function openEditCampaignModal(campaignId) { const c = _campaignsCache.find(x => x.id === campaignId) || {}; _editingCampaignId = campaignId; const form = document.getElementById('new-campaign-form'); if (!form) return; form.querySelector('[name=name]').value = c.name || ''; form.querySelector('[name=description]').value = c.description || ''; form.querySelector('[name=start_date]').value = c.start_date ? c.start_date.split('T')[0] : ''; form.querySelector('[name=end_date]').value = c.end_date ? c.end_date.split('T')[0] : ''; form.querySelector('[name=goal_dollars]').value = c.goal_dollars || ''; form.querySelector('[name=goal_lbs]').value = c.goal_lbs || ''; form.querySelector('[name=goal_items]').value = c.goal_items || ''; form.querySelector('[name=status]').value = c.status || 'active'; document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '✏️ Edit Campaign'; document.getElementById('new-campaign-modal').classList.add('active'); // Override submit to PUT form.onsubmit = async (ev) => { ev.preventDefault(); const fd = new FormData(form); const body = Object.fromEntries(fd); ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) body[k] = null; }); try { const res = await apiFetch('/api/campaigns/' + _editingCampaignId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to update'); return; } closeModal('new-campaign-modal'); document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '➕ New Campaign'; form.onsubmit = submitNewCampaign; _editingCampaignId = null; loadCampaigns(); alert('Campaign updated!'); } catch (err) { alert('Network error. Please try again.'); } }; } async function completeCampaign(id) { if (!confirm('Mark this campaign as completed?')) return; await apiFetch('/api/campaigns/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'completed' }) }); loadCampaigns(); } async function cancelCampaign(id) { if (!confirm('Cancel this campaign? This will hide it from the list.')) return; await apiFetch('/api/campaigns/' + id, { method: 'DELETE' }); loadCampaigns(); } // ── Populate campaigns in Add Donation modal ── async function populateDonationCampaigns() { const select = document.getElementById('donation-campaign-select'); if (!select) return; // Clear existing dynamic options while (select.options.length > 1) select.remove(1); try { const res = await apiFetch('/api/campaigns/active'); const data = await res.json(); (data.campaigns || []).forEach(c => { const opt = document.createElement('option'); opt.value = c.id; opt.setAttribute('data-raised', c.total_raised || 0); opt.setAttribute('data-goal', c.goal_dollars || 0); opt.textContent = c.name; select.appendChild(opt); }); } catch (err) { console.warn('Could not load campaigns for donation form:', err.message); } } function showCampaignProgress() { const select = document.getElementById('donation-campaign-select'); const progressDiv = document.getElementById('donation-campaign-progress'); if (!select || !progressDiv) return; const selected = select.options[select.selectedIndex]; if (!selected || !selected.value) { progressDiv.style.display = 'none'; return; } const raised = parseFloat(selected.getAttribute('data-raised') || 0); const goal = parseFloat(selected.getAttribute('data-goal') || 0); if (goal > 0) { const pct = (raised / goal * 100).toFixed(0); progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' of + goal.toLocaleString() + ' raised (' + pct + '%)'; progressDiv.style.display = ''; } else { progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' raised (no goal set)'; progressDiv.style.display = ''; } } + fmtNum(d.total_donated, 2) + ''; }).join('') || 'No donor data in this period'; const trendRows = (data.monthly_trends || []).map(function(t) { return '' + t.month + '' + t.families_served + '' + fmtNum(t.lbs_distributed, 1) + ' const n = new Date(); const y = n.getFullYear(); const m = String(n.getMonth() + 1).padStart(2, '0'); const el = document.getElementById('board-month-input'); if (el) el.value = `${y}-${m}`; })(); async function generateBoardReport() { const monthInput = document.getElementById('board-month-input'); const month = monthInput ? monthInput.value : ''; if (!month) { alert('Please select a month.'); return; } const btn = document.getElementById('board-generate-btn'); btn.disabled = true; btn.textContent = '⏳ Generating…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!res.ok || !data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); // Refresh saved list loadSavedBoardReports(); } catch (err) { alert('Error generating report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } function renderBoardReportPreview(r) { const m = r.metrics; document.getElementById('brp-month').textContent = r.month_label; document.getElementById('brp-summary').textContent = r.executive_summary; // Metrics grid const fmt = (v, digits) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: digits || 0 }); const fmtMoney = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaHtml = (d) => { if (d === null || d === undefined) return ''; const cls = d > 0 ? 'up' : d < 0 ? 'down' : 'flat'; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; return `${arrow} ${Math.abs(d)}% vs prior month`; }; const cards = [ { label: 'Families Served', value: fmt(m.clients.unique_served), sub: `${fmt(m.clients.new_this_month)} new clients this month`, delta: m.clients.delta_unique_served }, { label: 'Pounds Distributed', value: fmt(m.distributions.total_lbs, 1), sub: `${fmt(m.distributions.total_visits)} visits · ${fmt(m.distributions.avg_lbs_per_family, 1)} lbs/family avg`, delta: m.distributions.delta_lbs }, { label: 'Active Volunteers', value: fmt(m.volunteers.active), sub: `${fmt(m.volunteers.hours, 1)} hours · ${m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '% attendance' : 'no attendance data'}`, delta: m.volunteers.delta_active }, { label: 'Donations', value: fmtMoney(m.donations.total_usd), sub: `${fmt(m.donations.donor_count)} donors · ${fmt(m.donations.donation_count)} gifts`, delta: m.donations.delta_total }, { label: 'Current Inventory', value: fmt(m.inventory.current_stock_lbs, 1) + ' lbs', sub: `${fmt(m.inventory.received_this_month_lbs, 1)} lbs received · ${fmt(m.inventory.expiring_soon_items)} items expiring soon`, delta: null }, { label: 'Total Active Clients', value: fmt(m.clients.total_active), sub: 'On file in system', delta: null }, ...(m.inventory.donated_items > 0 ? [{ label: 'Donated Inventory', value: fmt(m.inventory.donated_items) + ' items', sub: `${fmt(m.inventory.donated_lbs, 1)} lbs from ${fmt(m.inventory.donated_by_donors)} donor${m.inventory.donated_by_donors !== 1 ? 's' : ''}`, delta: null }] : []) ]; document.getElementById('brp-metrics-grid').innerHTML = cards.map(c => `
${c.label}
${c.value}
${c.sub}
${deltaHtml(c.delta)}
`).join(''); // Highlights const h = r.highlights; const hlCards = []; if (h.top_volunteer) hlCards.push({ icon: '🏆', label: 'Top Volunteer', value: `${h.top_volunteer.name} · ${h.top_volunteer.hours}h` }); if (h.largest_donation) hlCards.push({ icon: '💛', label: 'Largest Donation', value: `${fmtMoney(h.largest_donation.amount)} from ${h.largest_donation.donor}` }); if (h.busiest_day) hlCards.push({ icon: '📅', label: 'Busiest Day', value: `${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })} · ${h.busiest_day.visits} visits · ${(h.busiest_day.lbs || 0).toLocaleString(undefined, { maximumFractionDigits: 1 })} lbs` }); if (!hlCards.length) hlCards.push({ icon: 'ℹ️', label: 'No Activity', value: 'No data recorded for this month' }); document.getElementById('brp-highlights').innerHTML = hlCards.map(c => `
${c.icon}
${c.label}
${c.value}
`).join(''); } function printBoardReport() { if (!currentBoardReport) return; const r = currentBoardReport; const m = r.metrics; const fmtN = (v, d) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: d || 0 }); const fmtM = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaStr = (d) => { if (d === null || d === undefined) return ''; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; const color = d > 0 ? '#065f46' : d < 0 ? '#991b1b' : '#6b7280'; const bg = d > 0 ? '#d1fae5' : d < 0 ? '#fee2e2' : '#f3f4f6'; return `${arrow} ${Math.abs(d)}%`; }; const h = r.highlights; const hlHtml = [ h.top_volunteer ? `🏆 Top Volunteer${h.top_volunteer.name} — ${h.top_volunteer.hours} hours` : '', h.largest_donation ? `💛 Largest Donation${fmtM(h.largest_donation.amount)} from ${h.largest_donation.donor}` : '', h.busiest_day ? `📅 Busiest Day${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', timeZone: 'UTC' })} — ${h.busiest_day.visits} visits, ${fmtN(h.busiest_day.lbs, 1)} lbs` : '' ].filter(Boolean).join(''); const metricsRows = [ ['Families Served (Unique)', fmtN(m.clients.unique_served), deltaStr(m.clients.delta_unique_served)], ['New Clients Registered', fmtN(m.clients.new_this_month), ''], ['Total Distribution Visits', fmtN(m.distributions.total_visits), deltaStr(m.distributions.delta_visits)], ['Total Pounds Distributed', fmtN(m.distributions.total_lbs, 1) + ' lbs', deltaStr(m.distributions.delta_lbs)], ['Avg Lbs per Family', fmtN(m.distributions.avg_lbs_per_family, 1) + ' lbs', ''], ['Active Volunteers', fmtN(m.volunteers.active), deltaStr(m.volunteers.delta_active)], ['Volunteer Hours', fmtN(m.volunteers.hours, 1), deltaStr(m.volunteers.delta_hours)], ['Attendance Rate', m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '%' : 'N/A', ''], ['Total Donations', fmtM(m.donations.total_usd), deltaStr(m.donations.delta_total)], ['Unique Donors', fmtN(m.donations.donor_count), deltaStr(m.donations.delta_donors)], ['Current Inventory', fmtN(m.inventory.current_stock_lbs, 1) + ' lbs', ''], ['Inventory Received This Month', fmtN(m.inventory.received_this_month_lbs, 1) + ' lbs', ''], ['Items Expiring Within 30 Days', fmtN(m.inventory.expiring_soon_items), ''], ...(m.inventory.donated_items > 0 ? [ ['Donated Inventory (Items)', fmtN(m.inventory.donated_items) + ' items (' + fmtN(m.inventory.donated_lbs, 1) + ' lbs)', ''], ['Donation Sources (Donors)', fmtN(m.inventory.donated_by_donors) + ' donor' + (m.inventory.donated_by_donors !== 1 ? 's' : ''), ''] ] : []), ['Total Active Clients on File', fmtN(m.clients.total_active), ''] ].map(([label, val, d]) => `${label}${val}${d}`).join(''); const html = `Board Report — ${r.month_label}
Minnie's Food Pantry
Monthly Board Report
${r.month_label}
${r.executive_summary}
Key Performance Metrics
${metricsRows}
MetricValuevs. Prior Month
${hlHtml ? `
Month Highlights
${hlHtml}
` : ''}
`; const blob = new Blob([html], { type: 'text/html' }); window.open(URL.createObjectURL(blob), '_blank'); } async function loadSavedBoardReports() { const list = document.getElementById('saved-board-reports-list'); try { const res = await apiFetch('/api/reports/monthly/saved'); const data = await res.json(); if (!data.success || !data.reports.length) { list.innerHTML = '
No saved reports yet. Generate your first board report above.
'; return; } list.innerHTML = data.reports.map(r => `
${formatSavedReportMonth(r.report_month)}
Generated ${new Date(r.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
`).join(''); } catch (err) { list.innerHTML = '
Could not load saved reports.
'; } } function formatSavedReportMonth(ym) { const [y, m] = ym.split('-').map(Number); return new Date(y, m - 1, 1).toLocaleString('en-US', { month: 'long', year: 'numeric' }); } async function viewSavedBoardReport(month) { const btn = document.getElementById('board-generate-btn'); const mi = document.getElementById('board-month-input'); if (mi) mi.value = month; btn.disabled = true; btn.textContent = '⏳ Loading…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (err) { alert('Error loading report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } async function deleteSavedBoardReport(id, btnEl) { if (!confirm('Delete this saved report?')) return; try { const res = await apiFetch(`/api/reports/monthly/saved/${id}`, { method: 'DELETE' }); if (res.ok) { loadSavedBoardReports(); if (currentBoardReport) { document.getElementById('board-report-preview').style.display = 'none'; currentBoardReport = null; } } } catch (err) { alert('Delete failed: ' + err.message); } } // Close modals on outside click window.onclick = function(event) { if (event.target.classList.contains('modal')) { event.target.classList.remove('active'); } } // ── CSV Export ── async function exportCSV(type) { const token = localStorage.getItem('mfp_admin_token'); if (!token) { alert('Not authenticated'); return; } // Map type to API path and filename label const pathMap = { volunteers: 'volunteers', donors: 'donors', clients: 'clients', inventory: 'inventory', visits: 'visits' }; const apiPath = pathMap[type]; if (!apiPath) { alert('Unknown export type'); return; } // Build URL with optional date range from the Reports tab pickers (if available) const startEl = document.getElementById('report-start-date'); const endEl = document.getElementById('report-end-date'); let url = `/api/${apiPath}/export?format=csv`; if (startEl && startEl.value) url += `&start=${startEl.value}`; if (endEl && endEl.value) url += `&end=${endEl.value}`; try { const resp = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || `HTTP ${resp.status}`); } const blob = await resp.blob(); const blobUrl = URL.createObjectURL(blob); const today = new Date().toISOString().slice(0, 10); const a = document.createElement('a'); a.href = blobUrl; a.download = `${type}-export-${today}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); } catch (err) { alert('Export failed: ' + err.message); } } // ── CSV Import ── let importType = ''; let importRows = []; const csvFormats = { volunteers: { title: 'Import Volunteers', hint: 'CSV headers: name, email, phone, status, skills
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,phone,status,skills\nJane Smith,jane@example.com,555-1234,active,Logistics' }, donors: { title: 'Import Donors', hint: 'CSV headers: name, email, total_given, last_gift_date, phone, donor_type, status
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,total_given,last_gift_date\nJohn Doe,john@example.com,500.00,2025-12-15' } }; function openImportModal(type) { importType = type; importRows = []; const config = csvFormats[type]; document.getElementById('import-modal-title').textContent = config.title; document.getElementById('csv-format-hint').innerHTML = config.hint; resetImportModal(); document.getElementById('import-csv-modal').classList.add('active'); } function closeImportModal() { document.getElementById('import-csv-modal').classList.remove('active'); importType = ''; importRows = []; } function resetImportModal() { document.getElementById('import-step-upload').style.display = 'block'; document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.remove('active'); document.getElementById('import-file-input').value = ''; importRows = []; } // Drag and drop const dropZone = document.getElementById('import-drop-zone'); ['dragenter', 'dragover'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add('drag-over'); }); }); ['dragleave', 'drop'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('drag-over'); }); }); dropZone.addEventListener('drop', (e) => { const files = e.dataTransfer.files; if (files.length > 0) { processFile(files[0]); } }); function handleFileSelect(e) { if (e.target.files.length > 0) { processFile(e.target.files[0]); } } function processFile(file) { if (!file.name.toLowerCase().endsWith('.csv') && file.type !== 'text/csv') { alert('Please upload a CSV file'); return; } const reader = new FileReader(); reader.onload = (e) => { const text = e.target.result; const parsed = parseCSV(text); if (parsed.length === 0) { alert('No data rows found in CSV'); return; } importRows = parsed; showPreview(file.name, parsed); }; reader.readAsText(file); } function parseCSV(text) { const lines = text.split(/\r?\n/).filter(line => line.trim()); if (lines.length < 2) return []; const headers = parseCSVLine(lines[0]).map(h => h.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_')); const rows = []; for (let i = 1; i < lines.length; i++) { const values = parseCSVLine(lines[i]); if (values.length === 0 || (values.length === 1 && !values[0].trim())) continue; const row = {}; headers.forEach((header, idx) => { row[header] = (values[idx] || '').trim(); }); rows.push(row); } return rows; } function parseCSVLine(line) { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"') { if (i + 1 < line.length && line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = false; } } else { current += ch; } } else { if (ch === '"') { inQuotes = true; } else if (ch === ',') { result.push(current); current = ''; } else { current += ch; } } } result.push(current); return result; } function showPreview(fileName, rows) { document.getElementById('import-step-upload').style.display = 'none'; document.getElementById('import-step-preview').classList.add('active'); document.getElementById('preview-file-name').textContent = fileName; document.getElementById('preview-row-count').textContent = `${rows.length} row${rows.length !== 1 ? 's' : ''}`; const headers = Object.keys(rows[0]); const previewRows = rows.slice(0, 5); const remaining = rows.length - 5; let tableHtml = '' + headers.map(h => `${h}`).join('') + ''; previewRows.forEach(row => { tableHtml += '' + headers.map(h => `${escapeHtml(row[h] || '')}`).join('') + ''; }); tableHtml += ''; document.getElementById('preview-table').innerHTML = tableHtml; if (remaining > 0) { document.getElementById('preview-more-rows').textContent = `+ ${remaining} more row${remaining !== 1 ? 's' : ''}`; document.getElementById('preview-more-rows').style.display = 'block'; } else { document.getElementById('preview-more-rows').style.display = 'none'; } } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } async function executeImport() { document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.add('active'); try { const response = await apiFetch(`/api/${importType}/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rows: importRows }) }); const result = await response.json(); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); if (response.ok && result.success) { const hasErrors = result.errors && result.errors.length > 0; document.getElementById('import-result-icon').textContent = hasErrors ? '⚠️' : '✅'; document.getElementById('import-result-title').textContent = hasErrors ? 'Import completed with some issues' : 'Import successful!'; document.getElementById('import-summary').innerHTML = `
${result.created}
Created
${result.updated}
Updated
${result.errors.length}
Errors
`; if (hasErrors) { const errorsDiv = document.getElementById('import-errors'); errorsDiv.style.display = 'block'; errorsDiv.innerHTML = result.errors.slice(0, 10).map(e => `Row ${e.row}: ${escapeHtml(e.error)}` ).join('
'); if (result.errors.length > 10) { errorsDiv.innerHTML += `
... and ${result.errors.length - 10} more`; } } else { document.getElementById('import-errors').style.display = 'none'; } // Refresh data if (importType === 'volunteers') loadVolunteers(); if (importType === 'donors') loadDonors(); loadDashboardData(); } else { document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = result.error || 'Import failed'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } catch (error) { document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = 'Network error — please try again'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } // ── Locations Management ── const LOCATION_TYPE_LABELS = { main_facility: { label: 'Main Facility', color: '#C44B2B' }, school_pantry: { label: 'School Pantry', color: '#2563EB' }, partner_site: { label: 'Partner Site', color: '#16A34A' }, other: { label: 'Other', color: '#6B7280' } }; async function loadLocationsTab() { try { const data = await apiFetch('/api/locations/all').then(r => r.json()); state.locations.data = data.locations || []; renderLocations(state.locations.data); } catch (e) { document.getElementById('locations-table').innerHTML = '
Error loading locations
'; } } function renderLocations(locations) { const tbody = document.getElementById('locations-table'); if (!locations || locations.length === 0) { tbody.innerHTML = '
📍
No locations found
Add your first location to get started
'; return; } tbody.innerHTML = locations.map(loc => { const typeInfo = LOCATION_TYPE_LABELS[loc.location_type] || LOCATION_TYPE_LABELS.other; return ` ${loc.name} ${typeInfo.label} ${loc.address || '—'} ${loc.is_active ? 'Active' : 'Inactive'} ${loc.is_active ? ` ` : 'Inactive'} `; }).join(''); } function openAddLocationModal() { document.getElementById('location-id').value = ''; document.getElementById('location-modal-title').textContent = 'Add Location'; document.getElementById('location-submit-btn').textContent = 'Add Location'; document.getElementById('location-form').reset(); document.getElementById('location-modal').classList.add('active'); } function openEditLocationModal(id, name, type, address) { document.getElementById('location-id').value = id; document.getElementById('location-modal-title').textContent = 'Edit Location'; document.getElementById('location-submit-btn').textContent = 'Save Changes'; document.getElementById('loc-name').value = name; document.getElementById('loc-type').value = type; document.getElementById('loc-address').value = address; document.getElementById('location-modal').classList.add('active'); } async function saveLocation(e) { e.preventDefault(); const id = document.getElementById('location-id').value; const payload = { name: document.getElementById('loc-name').value.trim(), location_type: document.getElementById('loc-type').value, address: document.getElementById('loc-address').value.trim() || null }; try { const url = id ? `/api/locations/${id}` : '/api/locations'; const method = id ? 'PUT' : 'POST'; const resp = await apiFetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to save location'); return; } closeModal('location-modal'); await loadLocationsTab(); // Refresh the dropdown so the new location appears await initLocationFilter(); } catch (err) { alert('Network error — please try again'); } } async function deleteLocation(id) { if (!confirm('Remove this location? It will be deactivated and hidden from the filter.')) return; try { const resp = await apiFetch(`/api/locations/${id}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove location'); return; } await loadLocationsTab(); await initLocationFilter(); // If the deleted location was selected, reset to "all" if (String(globalLocationId) === String(id)) { globalLocationId = 'all'; localStorage.setItem('minniesos_location_filter', 'all'); } } catch (err) { alert('Network error — please try again'); } } // ── Users Tab ── const ROLE_LABELS = { admin: '🔑 Admin', coordinator: '📋 Coordinator', volunteer: '🙋 Volunteer' }; const ROLE_COLORS = { admin: '#6B2FA0', coordinator: '#2563EB', volunteer: '#16A34A' }; async function loadUsersTab() { const container = document.getElementById('users-list-container'); if (!container) return; container.innerHTML = '
Loading…
'; try { const data = await apiFetch('/api/users/roles').then(r => r.json()); if (!data.users || !data.users.length) { container.innerHTML = '
No users found.
'; return; } let html = ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; // Preload locations for the dropdown let locations = []; try { const locData = await apiFetch('/api/locations/all').then(r => r.json()); locations = locData.locations || locData || []; } catch(e) { /* ignore */ } const locOptions = locations.map(l => ``).join(''); for (const user of data.users) { const roleBadges = (user.roles || []).map(r => { const color = ROLE_COLORS[r.role] || '#888'; const label = ROLE_LABELS[r.role] || r.role; const locPart = r.location_name ? ` @ ${r.location_name}` : ''; return `${label}${locPart} `; }).join('') || 'No roles'; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; } html += '
NameEmailRolesAdd Role
${user.name || '—'}${user.email}${roleBadges}
'; container.innerHTML = html; } catch (err) { container.innerHTML = '
Failed to load users. You may not have admin access.
'; } } async function assignUserRole(event, userId) { event.preventDefault(); const form = event.target; const role = form.role.value; const location_id = form.location_id.value || null; try { const resp = await apiFetch(`/api/users/${userId}/roles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role, location_id: location_id ? parseInt(location_id) : null }) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to assign role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } async function removeUserRole(userId, roleId) { if (!confirm('Remove this role?')) return; try { const resp = await apiFetch(`/api/users/${userId}/roles/${roleId}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } function showAddUserModal() { // For now just redirect to settings or show an alert with instructions // Full user creation is out of scope for this task; uses /api/auth/setup-style endpoint alert('To add a new user, have them visit the app and use the setup flow, or have an admin create their account via the auth setup endpoint.'); } // ───────────────────────────────────────────────────────────────────── // CAMPAIGN TRACKER // ───────────────────────────────────────────────────────────────────── let _campaignsCache = []; let _campaignDetailChart = null; function destroyCampaignDetailChart() { if (_campaignDetailChart) { _campaignDetailChart.destroy(); _campaignDetailChart = null; } } async function loadCampaigns() { document.getElementById('campaigns-list-view').style.display = ''; document.getElementById('campaigns-detail-view').style.display = 'none'; destroyCampaignDetailChart(); const container = document.getElementById('campaigns-cards-container'); container.innerHTML = '
Loading…
'; try { const res = await apiFetch('/api/campaigns'); const data = await res.json(); _campaignsCache = data.campaigns || []; renderCampaignCards(_campaignsCache); } catch (err) { container.innerHTML = '
Error loading campaigns: ' + escapeHtml(err.message) + '
'; } } function renderCampaignCards(campaigns) { const container = document.getElementById('campaigns-cards-container'); if (!campaigns.length) { container.innerHTML = '
🎯
No campaigns yet
Create your first fundraising campaign to start tracking progress.
'; return; } const statusLabel = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft', cancelled: '❌ Cancelled' }; const statusClass = { active: 'active', completed: 'completed', draft: 'draft', cancelled: 'draft' }; container.innerHTML = '
' + campaigns.map(c => { const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const overGoal = goal > 0 && raised >= goal; const pctDisplay = goal > 0 ? (parseFloat(c.progress_pct || 0)).toFixed(0) + '%' : ''; const progressBar = goal > 0 ? '
+ goal.toLocaleString() + '">
' + pctDisplay + ' of + goal.toLocaleString() + ' goal
' : ''; const startD = c.start_date ? c.start_date.split('T')[0] : ''; const endD = c.end_date ? c.end_date.split('T')[0] : ''; return '
' + '
' + '
' + escapeHtml(c.name) + '
' + '' + (statusLabel[c.status] || c.status) + '' + '
' + '
📅 ' + startD + ' → ' + endD + '
' + progressBar + '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:0,maximumFractionDigits:0}) + 'Raised
' + '
' + (c.donor_count || 0) + 'Donors
' + '
' + (c.donation_count || 0) + 'Donations
' + '
' + '
'; }).join('') + ''; } async function openCampaignDetail(campaignId) { document.getElementById('campaigns-list-view').style.display = 'none'; const detailView = document.getElementById('campaigns-detail-view'); detailView.style.display = ''; document.getElementById('campaigns-detail-body').innerHTML = '
Loading…
'; destroyCampaignDetailChart(); try { const res = await apiFetch('/api/campaigns/' + campaignId); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to load'); const c = data.campaign; const topDonors = data.top_donors || []; const recentDonations = data.recent_donations || []; const timeline = data.daily_timeline || []; const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const pctDisplay = goal > 0 ? pct.toFixed(0) + '%' : 'No goal set'; const overGoal = goal > 0 && raised >= goal; const daysRemaining = parseInt(c.days_remaining || 0); const daysLabel = daysRemaining > 0 ? daysRemaining + ' days left' : daysRemaining === 0 ? 'Ends today' : 'Ended'; // Thermometer const thermoHtml = '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' + (goal > 0 ? '
of + goal.toLocaleString() + ' goal · ' + pctDisplay + '
' : '
No dollar goal set
') + (goal > 0 ? '
' + (pct > 15 ? pctDisplay : '') + '
' : '') + '
'; // 3 stat cards const statsHtml = '
' + '
💰
+ raised.toLocaleString('en-US', {minimumFractionDigits:0}) + '
Raised
' + '
👥
' + (c.donor_count || 0) + '
Donors
' + '
📅
' + daysLabel + '
Timeline
' + '
'; // Top donors leaderboard const topDonorsHtml = topDonors.length === 0 ? '' : '
🏆 Top Donors
' + '
' + topDonors.map((d, i) => '
' + '
' + ['🥇','🥈','🥉','4️⃣','5️⃣'][i] + '' + escapeHtml(d.name) + '
' + ' + parseFloat(d.total).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' ).join('') + '
'; // Recent donations table const recentHtml = recentDonations.length === 0 ? '' : '
📋 Recent Donations
' + '
' + '' + '' + recentDonations.map((d, i) => '' + '' + '' + '' + '' + '' ).join('') + '
DateDonorAmountType
' + (d.donation_date ? d.donation_date.split('T')[0] : '') + '' + escapeHtml(d.donor_name || '') + ' + parseFloat(d.amount).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '' + (d.donation_type || '') + '
'; // Daily chart canvas const chartHtml = timeline.length === 0 ? '' : '
📊 Daily Donations
' + '
' + '
'; // Action buttons const statusLabel2 = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft' }; const actionsHtml = '
' + '' + (c.status === 'active' ? '' : '') + (c.status !== 'cancelled' ? '' : '') + '
'; document.getElementById('campaigns-detail-body').innerHTML = '
' + '' + '
' + '
' + escapeHtml(c.name) + '
' + '
' + (c.description ? escapeHtml(c.description) : '') + '
' + '
' + thermoHtml + statsHtml + actionsHtml + topDonorsHtml + recentHtml + chartHtml; // Render chart if (timeline.length > 0) { setTimeout(() => { const ctx = document.getElementById('campaign-daily-chart'); if (!ctx) return; _campaignDetailChart = new Chart(ctx.getContext('2d'), { type: 'bar', data: { labels: timeline.map(r => r.date), datasets: [{ label: 'Daily Donations ($)', data: timeline.map(r => parseFloat(r.total)), backgroundColor: 'rgba(45,125,70,0.75)', borderColor: '#2D7D46', borderWidth: 1, borderRadius: 4 }] }, options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: v => ' + v.toLocaleString() } } } } }); }, 50); } } catch (err) { document.getElementById('campaigns-detail-body').innerHTML = '
Error: ' + escapeHtml(err.message) + '
'; } } async function submitNewCampaign(e) { e.preventDefault(); const form = e.target; const fd = new FormData(form); const body = Object.fromEntries(fd); // Remove empty optional fields ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) delete body[k]; }); try { const res = await apiFetch('/api/campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to create campaign'); return; } closeModal('new-campaign-modal'); form.reset(); loadCampaigns(); alert('Campaign created!'); } catch (err) { alert('Network error. Please try again.'); } } function openNewCampaignModal() { const form = document.getElementById('new-campaign-form'); if (form) form.reset(); const today = new Date().toISOString().split('T')[0]; const endDate = new Date(); endDate.setDate(endDate.getDate() + 30); const endStr = endDate.toISOString().split('T')[0]; const sd = form.querySelector('[name=start_date]'); const ed = form.querySelector('[name=end_date]'); if (sd) sd.value = today; if (ed) ed.value = endStr; document.getElementById('new-campaign-modal').classList.add('active'); } let _editingCampaignId = null; function openEditCampaignModal(campaignId) { const c = _campaignsCache.find(x => x.id === campaignId) || {}; _editingCampaignId = campaignId; const form = document.getElementById('new-campaign-form'); if (!form) return; form.querySelector('[name=name]').value = c.name || ''; form.querySelector('[name=description]').value = c.description || ''; form.querySelector('[name=start_date]').value = c.start_date ? c.start_date.split('T')[0] : ''; form.querySelector('[name=end_date]').value = c.end_date ? c.end_date.split('T')[0] : ''; form.querySelector('[name=goal_dollars]').value = c.goal_dollars || ''; form.querySelector('[name=goal_lbs]').value = c.goal_lbs || ''; form.querySelector('[name=goal_items]').value = c.goal_items || ''; form.querySelector('[name=status]').value = c.status || 'active'; document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '✏️ Edit Campaign'; document.getElementById('new-campaign-modal').classList.add('active'); // Override submit to PUT form.onsubmit = async (ev) => { ev.preventDefault(); const fd = new FormData(form); const body = Object.fromEntries(fd); ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) body[k] = null; }); try { const res = await apiFetch('/api/campaigns/' + _editingCampaignId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to update'); return; } closeModal('new-campaign-modal'); document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '➕ New Campaign'; form.onsubmit = submitNewCampaign; _editingCampaignId = null; loadCampaigns(); alert('Campaign updated!'); } catch (err) { alert('Network error. Please try again.'); } }; } async function completeCampaign(id) { if (!confirm('Mark this campaign as completed?')) return; await apiFetch('/api/campaigns/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'completed' }) }); loadCampaigns(); } async function cancelCampaign(id) { if (!confirm('Cancel this campaign? This will hide it from the list.')) return; await apiFetch('/api/campaigns/' + id, { method: 'DELETE' }); loadCampaigns(); } // ── Populate campaigns in Add Donation modal ── async function populateDonationCampaigns() { const select = document.getElementById('donation-campaign-select'); if (!select) return; // Clear existing dynamic options while (select.options.length > 1) select.remove(1); try { const res = await apiFetch('/api/campaigns/active'); const data = await res.json(); (data.campaigns || []).forEach(c => { const opt = document.createElement('option'); opt.value = c.id; opt.setAttribute('data-raised', c.total_raised || 0); opt.setAttribute('data-goal', c.goal_dollars || 0); opt.textContent = c.name; select.appendChild(opt); }); } catch (err) { console.warn('Could not load campaigns for donation form:', err.message); } } function showCampaignProgress() { const select = document.getElementById('donation-campaign-select'); const progressDiv = document.getElementById('donation-campaign-progress'); if (!select || !progressDiv) return; const selected = select.options[select.selectedIndex]; if (!selected || !selected.value) { progressDiv.style.display = 'none'; return; } const raised = parseFloat(selected.getAttribute('data-raised') || 0); const goal = parseFloat(selected.getAttribute('data-goal') || 0); if (goal > 0) { const pct = (raised / goal * 100).toFixed(0); progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' of + goal.toLocaleString() + ' raised (' + pct + '%)'; progressDiv.style.display = ''; } else { progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' raised (no goal set)'; progressDiv.style.display = ''; } } + fmtNum(t.donation_value, 0) + '' + fmtNum(t.volunteer_hours, 1) + ''; }).join('') || 'No trend data available'; const costDisplay = s.cost_per_meal > 0 ? ' const n = new Date(); const y = n.getFullYear(); const m = String(n.getMonth() + 1).padStart(2, '0'); const el = document.getElementById('board-month-input'); if (el) el.value = `${y}-${m}`; })(); async function generateBoardReport() { const monthInput = document.getElementById('board-month-input'); const month = monthInput ? monthInput.value : ''; if (!month) { alert('Please select a month.'); return; } const btn = document.getElementById('board-generate-btn'); btn.disabled = true; btn.textContent = '⏳ Generating…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!res.ok || !data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); // Refresh saved list loadSavedBoardReports(); } catch (err) { alert('Error generating report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } function renderBoardReportPreview(r) { const m = r.metrics; document.getElementById('brp-month').textContent = r.month_label; document.getElementById('brp-summary').textContent = r.executive_summary; // Metrics grid const fmt = (v, digits) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: digits || 0 }); const fmtMoney = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaHtml = (d) => { if (d === null || d === undefined) return ''; const cls = d > 0 ? 'up' : d < 0 ? 'down' : 'flat'; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; return `${arrow} ${Math.abs(d)}% vs prior month`; }; const cards = [ { label: 'Families Served', value: fmt(m.clients.unique_served), sub: `${fmt(m.clients.new_this_month)} new clients this month`, delta: m.clients.delta_unique_served }, { label: 'Pounds Distributed', value: fmt(m.distributions.total_lbs, 1), sub: `${fmt(m.distributions.total_visits)} visits · ${fmt(m.distributions.avg_lbs_per_family, 1)} lbs/family avg`, delta: m.distributions.delta_lbs }, { label: 'Active Volunteers', value: fmt(m.volunteers.active), sub: `${fmt(m.volunteers.hours, 1)} hours · ${m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '% attendance' : 'no attendance data'}`, delta: m.volunteers.delta_active }, { label: 'Donations', value: fmtMoney(m.donations.total_usd), sub: `${fmt(m.donations.donor_count)} donors · ${fmt(m.donations.donation_count)} gifts`, delta: m.donations.delta_total }, { label: 'Current Inventory', value: fmt(m.inventory.current_stock_lbs, 1) + ' lbs', sub: `${fmt(m.inventory.received_this_month_lbs, 1)} lbs received · ${fmt(m.inventory.expiring_soon_items)} items expiring soon`, delta: null }, { label: 'Total Active Clients', value: fmt(m.clients.total_active), sub: 'On file in system', delta: null }, ...(m.inventory.donated_items > 0 ? [{ label: 'Donated Inventory', value: fmt(m.inventory.donated_items) + ' items', sub: `${fmt(m.inventory.donated_lbs, 1)} lbs from ${fmt(m.inventory.donated_by_donors)} donor${m.inventory.donated_by_donors !== 1 ? 's' : ''}`, delta: null }] : []) ]; document.getElementById('brp-metrics-grid').innerHTML = cards.map(c => `
${c.label}
${c.value}
${c.sub}
${deltaHtml(c.delta)}
`).join(''); // Highlights const h = r.highlights; const hlCards = []; if (h.top_volunteer) hlCards.push({ icon: '🏆', label: 'Top Volunteer', value: `${h.top_volunteer.name} · ${h.top_volunteer.hours}h` }); if (h.largest_donation) hlCards.push({ icon: '💛', label: 'Largest Donation', value: `${fmtMoney(h.largest_donation.amount)} from ${h.largest_donation.donor}` }); if (h.busiest_day) hlCards.push({ icon: '📅', label: 'Busiest Day', value: `${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })} · ${h.busiest_day.visits} visits · ${(h.busiest_day.lbs || 0).toLocaleString(undefined, { maximumFractionDigits: 1 })} lbs` }); if (!hlCards.length) hlCards.push({ icon: 'ℹ️', label: 'No Activity', value: 'No data recorded for this month' }); document.getElementById('brp-highlights').innerHTML = hlCards.map(c => `
${c.icon}
${c.label}
${c.value}
`).join(''); } function printBoardReport() { if (!currentBoardReport) return; const r = currentBoardReport; const m = r.metrics; const fmtN = (v, d) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: d || 0 }); const fmtM = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaStr = (d) => { if (d === null || d === undefined) return ''; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; const color = d > 0 ? '#065f46' : d < 0 ? '#991b1b' : '#6b7280'; const bg = d > 0 ? '#d1fae5' : d < 0 ? '#fee2e2' : '#f3f4f6'; return `${arrow} ${Math.abs(d)}%`; }; const h = r.highlights; const hlHtml = [ h.top_volunteer ? `🏆 Top Volunteer${h.top_volunteer.name} — ${h.top_volunteer.hours} hours` : '', h.largest_donation ? `💛 Largest Donation${fmtM(h.largest_donation.amount)} from ${h.largest_donation.donor}` : '', h.busiest_day ? `📅 Busiest Day${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', timeZone: 'UTC' })} — ${h.busiest_day.visits} visits, ${fmtN(h.busiest_day.lbs, 1)} lbs` : '' ].filter(Boolean).join(''); const metricsRows = [ ['Families Served (Unique)', fmtN(m.clients.unique_served), deltaStr(m.clients.delta_unique_served)], ['New Clients Registered', fmtN(m.clients.new_this_month), ''], ['Total Distribution Visits', fmtN(m.distributions.total_visits), deltaStr(m.distributions.delta_visits)], ['Total Pounds Distributed', fmtN(m.distributions.total_lbs, 1) + ' lbs', deltaStr(m.distributions.delta_lbs)], ['Avg Lbs per Family', fmtN(m.distributions.avg_lbs_per_family, 1) + ' lbs', ''], ['Active Volunteers', fmtN(m.volunteers.active), deltaStr(m.volunteers.delta_active)], ['Volunteer Hours', fmtN(m.volunteers.hours, 1), deltaStr(m.volunteers.delta_hours)], ['Attendance Rate', m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '%' : 'N/A', ''], ['Total Donations', fmtM(m.donations.total_usd), deltaStr(m.donations.delta_total)], ['Unique Donors', fmtN(m.donations.donor_count), deltaStr(m.donations.delta_donors)], ['Current Inventory', fmtN(m.inventory.current_stock_lbs, 1) + ' lbs', ''], ['Inventory Received This Month', fmtN(m.inventory.received_this_month_lbs, 1) + ' lbs', ''], ['Items Expiring Within 30 Days', fmtN(m.inventory.expiring_soon_items), ''], ...(m.inventory.donated_items > 0 ? [ ['Donated Inventory (Items)', fmtN(m.inventory.donated_items) + ' items (' + fmtN(m.inventory.donated_lbs, 1) + ' lbs)', ''], ['Donation Sources (Donors)', fmtN(m.inventory.donated_by_donors) + ' donor' + (m.inventory.donated_by_donors !== 1 ? 's' : ''), ''] ] : []), ['Total Active Clients on File', fmtN(m.clients.total_active), ''] ].map(([label, val, d]) => `${label}${val}${d}`).join(''); const html = `Board Report — ${r.month_label}
Minnie's Food Pantry
Monthly Board Report
${r.month_label}
${r.executive_summary}
Key Performance Metrics
${metricsRows}
MetricValuevs. Prior Month
${hlHtml ? `
Month Highlights
${hlHtml}
` : ''}
`; const blob = new Blob([html], { type: 'text/html' }); window.open(URL.createObjectURL(blob), '_blank'); } async function loadSavedBoardReports() { const list = document.getElementById('saved-board-reports-list'); try { const res = await apiFetch('/api/reports/monthly/saved'); const data = await res.json(); if (!data.success || !data.reports.length) { list.innerHTML = '
No saved reports yet. Generate your first board report above.
'; return; } list.innerHTML = data.reports.map(r => `
${formatSavedReportMonth(r.report_month)}
Generated ${new Date(r.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
`).join(''); } catch (err) { list.innerHTML = '
Could not load saved reports.
'; } } function formatSavedReportMonth(ym) { const [y, m] = ym.split('-').map(Number); return new Date(y, m - 1, 1).toLocaleString('en-US', { month: 'long', year: 'numeric' }); } async function viewSavedBoardReport(month) { const btn = document.getElementById('board-generate-btn'); const mi = document.getElementById('board-month-input'); if (mi) mi.value = month; btn.disabled = true; btn.textContent = '⏳ Loading…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (err) { alert('Error loading report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } async function deleteSavedBoardReport(id, btnEl) { if (!confirm('Delete this saved report?')) return; try { const res = await apiFetch(`/api/reports/monthly/saved/${id}`, { method: 'DELETE' }); if (res.ok) { loadSavedBoardReports(); if (currentBoardReport) { document.getElementById('board-report-preview').style.display = 'none'; currentBoardReport = null; } } } catch (err) { alert('Delete failed: ' + err.message); } } // Close modals on outside click window.onclick = function(event) { if (event.target.classList.contains('modal')) { event.target.classList.remove('active'); } } // ── CSV Export ── async function exportCSV(type) { const token = localStorage.getItem('mfp_admin_token'); if (!token) { alert('Not authenticated'); return; } // Map type to API path and filename label const pathMap = { volunteers: 'volunteers', donors: 'donors', clients: 'clients', inventory: 'inventory', visits: 'visits' }; const apiPath = pathMap[type]; if (!apiPath) { alert('Unknown export type'); return; } // Build URL with optional date range from the Reports tab pickers (if available) const startEl = document.getElementById('report-start-date'); const endEl = document.getElementById('report-end-date'); let url = `/api/${apiPath}/export?format=csv`; if (startEl && startEl.value) url += `&start=${startEl.value}`; if (endEl && endEl.value) url += `&end=${endEl.value}`; try { const resp = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || `HTTP ${resp.status}`); } const blob = await resp.blob(); const blobUrl = URL.createObjectURL(blob); const today = new Date().toISOString().slice(0, 10); const a = document.createElement('a'); a.href = blobUrl; a.download = `${type}-export-${today}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); } catch (err) { alert('Export failed: ' + err.message); } } // ── CSV Import ── let importType = ''; let importRows = []; const csvFormats = { volunteers: { title: 'Import Volunteers', hint: 'CSV headers: name, email, phone, status, skills
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,phone,status,skills\nJane Smith,jane@example.com,555-1234,active,Logistics' }, donors: { title: 'Import Donors', hint: 'CSV headers: name, email, total_given, last_gift_date, phone, donor_type, status
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,total_given,last_gift_date\nJohn Doe,john@example.com,500.00,2025-12-15' } }; function openImportModal(type) { importType = type; importRows = []; const config = csvFormats[type]; document.getElementById('import-modal-title').textContent = config.title; document.getElementById('csv-format-hint').innerHTML = config.hint; resetImportModal(); document.getElementById('import-csv-modal').classList.add('active'); } function closeImportModal() { document.getElementById('import-csv-modal').classList.remove('active'); importType = ''; importRows = []; } function resetImportModal() { document.getElementById('import-step-upload').style.display = 'block'; document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.remove('active'); document.getElementById('import-file-input').value = ''; importRows = []; } // Drag and drop const dropZone = document.getElementById('import-drop-zone'); ['dragenter', 'dragover'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add('drag-over'); }); }); ['dragleave', 'drop'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('drag-over'); }); }); dropZone.addEventListener('drop', (e) => { const files = e.dataTransfer.files; if (files.length > 0) { processFile(files[0]); } }); function handleFileSelect(e) { if (e.target.files.length > 0) { processFile(e.target.files[0]); } } function processFile(file) { if (!file.name.toLowerCase().endsWith('.csv') && file.type !== 'text/csv') { alert('Please upload a CSV file'); return; } const reader = new FileReader(); reader.onload = (e) => { const text = e.target.result; const parsed = parseCSV(text); if (parsed.length === 0) { alert('No data rows found in CSV'); return; } importRows = parsed; showPreview(file.name, parsed); }; reader.readAsText(file); } function parseCSV(text) { const lines = text.split(/\r?\n/).filter(line => line.trim()); if (lines.length < 2) return []; const headers = parseCSVLine(lines[0]).map(h => h.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_')); const rows = []; for (let i = 1; i < lines.length; i++) { const values = parseCSVLine(lines[i]); if (values.length === 0 || (values.length === 1 && !values[0].trim())) continue; const row = {}; headers.forEach((header, idx) => { row[header] = (values[idx] || '').trim(); }); rows.push(row); } return rows; } function parseCSVLine(line) { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"') { if (i + 1 < line.length && line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = false; } } else { current += ch; } } else { if (ch === '"') { inQuotes = true; } else if (ch === ',') { result.push(current); current = ''; } else { current += ch; } } } result.push(current); return result; } function showPreview(fileName, rows) { document.getElementById('import-step-upload').style.display = 'none'; document.getElementById('import-step-preview').classList.add('active'); document.getElementById('preview-file-name').textContent = fileName; document.getElementById('preview-row-count').textContent = `${rows.length} row${rows.length !== 1 ? 's' : ''}`; const headers = Object.keys(rows[0]); const previewRows = rows.slice(0, 5); const remaining = rows.length - 5; let tableHtml = '' + headers.map(h => `${h}`).join('') + ''; previewRows.forEach(row => { tableHtml += '' + headers.map(h => `${escapeHtml(row[h] || '')}`).join('') + ''; }); tableHtml += ''; document.getElementById('preview-table').innerHTML = tableHtml; if (remaining > 0) { document.getElementById('preview-more-rows').textContent = `+ ${remaining} more row${remaining !== 1 ? 's' : ''}`; document.getElementById('preview-more-rows').style.display = 'block'; } else { document.getElementById('preview-more-rows').style.display = 'none'; } } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } async function executeImport() { document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.add('active'); try { const response = await apiFetch(`/api/${importType}/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rows: importRows }) }); const result = await response.json(); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); if (response.ok && result.success) { const hasErrors = result.errors && result.errors.length > 0; document.getElementById('import-result-icon').textContent = hasErrors ? '⚠️' : '✅'; document.getElementById('import-result-title').textContent = hasErrors ? 'Import completed with some issues' : 'Import successful!'; document.getElementById('import-summary').innerHTML = `
${result.created}
Created
${result.updated}
Updated
${result.errors.length}
Errors
`; if (hasErrors) { const errorsDiv = document.getElementById('import-errors'); errorsDiv.style.display = 'block'; errorsDiv.innerHTML = result.errors.slice(0, 10).map(e => `Row ${e.row}: ${escapeHtml(e.error)}` ).join('
'); if (result.errors.length > 10) { errorsDiv.innerHTML += `
... and ${result.errors.length - 10} more`; } } else { document.getElementById('import-errors').style.display = 'none'; } // Refresh data if (importType === 'volunteers') loadVolunteers(); if (importType === 'donors') loadDonors(); loadDashboardData(); } else { document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = result.error || 'Import failed'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } catch (error) { document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = 'Network error — please try again'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } // ── Locations Management ── const LOCATION_TYPE_LABELS = { main_facility: { label: 'Main Facility', color: '#C44B2B' }, school_pantry: { label: 'School Pantry', color: '#2563EB' }, partner_site: { label: 'Partner Site', color: '#16A34A' }, other: { label: 'Other', color: '#6B7280' } }; async function loadLocationsTab() { try { const data = await apiFetch('/api/locations/all').then(r => r.json()); state.locations.data = data.locations || []; renderLocations(state.locations.data); } catch (e) { document.getElementById('locations-table').innerHTML = '
Error loading locations
'; } } function renderLocations(locations) { const tbody = document.getElementById('locations-table'); if (!locations || locations.length === 0) { tbody.innerHTML = '
📍
No locations found
Add your first location to get started
'; return; } tbody.innerHTML = locations.map(loc => { const typeInfo = LOCATION_TYPE_LABELS[loc.location_type] || LOCATION_TYPE_LABELS.other; return ` ${loc.name} ${typeInfo.label} ${loc.address || '—'} ${loc.is_active ? 'Active' : 'Inactive'} ${loc.is_active ? ` ` : 'Inactive'} `; }).join(''); } function openAddLocationModal() { document.getElementById('location-id').value = ''; document.getElementById('location-modal-title').textContent = 'Add Location'; document.getElementById('location-submit-btn').textContent = 'Add Location'; document.getElementById('location-form').reset(); document.getElementById('location-modal').classList.add('active'); } function openEditLocationModal(id, name, type, address) { document.getElementById('location-id').value = id; document.getElementById('location-modal-title').textContent = 'Edit Location'; document.getElementById('location-submit-btn').textContent = 'Save Changes'; document.getElementById('loc-name').value = name; document.getElementById('loc-type').value = type; document.getElementById('loc-address').value = address; document.getElementById('location-modal').classList.add('active'); } async function saveLocation(e) { e.preventDefault(); const id = document.getElementById('location-id').value; const payload = { name: document.getElementById('loc-name').value.trim(), location_type: document.getElementById('loc-type').value, address: document.getElementById('loc-address').value.trim() || null }; try { const url = id ? `/api/locations/${id}` : '/api/locations'; const method = id ? 'PUT' : 'POST'; const resp = await apiFetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to save location'); return; } closeModal('location-modal'); await loadLocationsTab(); // Refresh the dropdown so the new location appears await initLocationFilter(); } catch (err) { alert('Network error — please try again'); } } async function deleteLocation(id) { if (!confirm('Remove this location? It will be deactivated and hidden from the filter.')) return; try { const resp = await apiFetch(`/api/locations/${id}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove location'); return; } await loadLocationsTab(); await initLocationFilter(); // If the deleted location was selected, reset to "all" if (String(globalLocationId) === String(id)) { globalLocationId = 'all'; localStorage.setItem('minniesos_location_filter', 'all'); } } catch (err) { alert('Network error — please try again'); } } // ── Users Tab ── const ROLE_LABELS = { admin: '🔑 Admin', coordinator: '📋 Coordinator', volunteer: '🙋 Volunteer' }; const ROLE_COLORS = { admin: '#6B2FA0', coordinator: '#2563EB', volunteer: '#16A34A' }; async function loadUsersTab() { const container = document.getElementById('users-list-container'); if (!container) return; container.innerHTML = '
Loading…
'; try { const data = await apiFetch('/api/users/roles').then(r => r.json()); if (!data.users || !data.users.length) { container.innerHTML = '
No users found.
'; return; } let html = ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; // Preload locations for the dropdown let locations = []; try { const locData = await apiFetch('/api/locations/all').then(r => r.json()); locations = locData.locations || locData || []; } catch(e) { /* ignore */ } const locOptions = locations.map(l => ``).join(''); for (const user of data.users) { const roleBadges = (user.roles || []).map(r => { const color = ROLE_COLORS[r.role] || '#888'; const label = ROLE_LABELS[r.role] || r.role; const locPart = r.location_name ? ` @ ${r.location_name}` : ''; return `${label}${locPart} `; }).join('') || 'No roles'; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; } html += '
NameEmailRolesAdd Role
${user.name || '—'}${user.email}${roleBadges}
'; container.innerHTML = html; } catch (err) { container.innerHTML = '
Failed to load users. You may not have admin access.
'; } } async function assignUserRole(event, userId) { event.preventDefault(); const form = event.target; const role = form.role.value; const location_id = form.location_id.value || null; try { const resp = await apiFetch(`/api/users/${userId}/roles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role, location_id: location_id ? parseInt(location_id) : null }) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to assign role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } async function removeUserRole(userId, roleId) { if (!confirm('Remove this role?')) return; try { const resp = await apiFetch(`/api/users/${userId}/roles/${roleId}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } function showAddUserModal() { // For now just redirect to settings or show an alert with instructions // Full user creation is out of scope for this task; uses /api/auth/setup-style endpoint alert('To add a new user, have them visit the app and use the setup flow, or have an admin create their account via the auth setup endpoint.'); } // ───────────────────────────────────────────────────────────────────── // CAMPAIGN TRACKER // ───────────────────────────────────────────────────────────────────── let _campaignsCache = []; let _campaignDetailChart = null; function destroyCampaignDetailChart() { if (_campaignDetailChart) { _campaignDetailChart.destroy(); _campaignDetailChart = null; } } async function loadCampaigns() { document.getElementById('campaigns-list-view').style.display = ''; document.getElementById('campaigns-detail-view').style.display = 'none'; destroyCampaignDetailChart(); const container = document.getElementById('campaigns-cards-container'); container.innerHTML = '
Loading…
'; try { const res = await apiFetch('/api/campaigns'); const data = await res.json(); _campaignsCache = data.campaigns || []; renderCampaignCards(_campaignsCache); } catch (err) { container.innerHTML = '
Error loading campaigns: ' + escapeHtml(err.message) + '
'; } } function renderCampaignCards(campaigns) { const container = document.getElementById('campaigns-cards-container'); if (!campaigns.length) { container.innerHTML = '
🎯
No campaigns yet
Create your first fundraising campaign to start tracking progress.
'; return; } const statusLabel = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft', cancelled: '❌ Cancelled' }; const statusClass = { active: 'active', completed: 'completed', draft: 'draft', cancelled: 'draft' }; container.innerHTML = '
' + campaigns.map(c => { const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const overGoal = goal > 0 && raised >= goal; const pctDisplay = goal > 0 ? (parseFloat(c.progress_pct || 0)).toFixed(0) + '%' : ''; const progressBar = goal > 0 ? '
+ goal.toLocaleString() + '">
' + pctDisplay + ' of + goal.toLocaleString() + ' goal
' : ''; const startD = c.start_date ? c.start_date.split('T')[0] : ''; const endD = c.end_date ? c.end_date.split('T')[0] : ''; return '
' + '
' + '
' + escapeHtml(c.name) + '
' + '' + (statusLabel[c.status] || c.status) + '' + '
' + '
📅 ' + startD + ' → ' + endD + '
' + progressBar + '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:0,maximumFractionDigits:0}) + 'Raised
' + '
' + (c.donor_count || 0) + 'Donors
' + '
' + (c.donation_count || 0) + 'Donations
' + '
' + '
'; }).join('') + ''; } async function openCampaignDetail(campaignId) { document.getElementById('campaigns-list-view').style.display = 'none'; const detailView = document.getElementById('campaigns-detail-view'); detailView.style.display = ''; document.getElementById('campaigns-detail-body').innerHTML = '
Loading…
'; destroyCampaignDetailChart(); try { const res = await apiFetch('/api/campaigns/' + campaignId); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to load'); const c = data.campaign; const topDonors = data.top_donors || []; const recentDonations = data.recent_donations || []; const timeline = data.daily_timeline || []; const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const pctDisplay = goal > 0 ? pct.toFixed(0) + '%' : 'No goal set'; const overGoal = goal > 0 && raised >= goal; const daysRemaining = parseInt(c.days_remaining || 0); const daysLabel = daysRemaining > 0 ? daysRemaining + ' days left' : daysRemaining === 0 ? 'Ends today' : 'Ended'; // Thermometer const thermoHtml = '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' + (goal > 0 ? '
of + goal.toLocaleString() + ' goal · ' + pctDisplay + '
' : '
No dollar goal set
') + (goal > 0 ? '
' + (pct > 15 ? pctDisplay : '') + '
' : '') + '
'; // 3 stat cards const statsHtml = '
' + '
💰
+ raised.toLocaleString('en-US', {minimumFractionDigits:0}) + '
Raised
' + '
👥
' + (c.donor_count || 0) + '
Donors
' + '
📅
' + daysLabel + '
Timeline
' + '
'; // Top donors leaderboard const topDonorsHtml = topDonors.length === 0 ? '' : '
🏆 Top Donors
' + '
' + topDonors.map((d, i) => '
' + '
' + ['🥇','🥈','🥉','4️⃣','5️⃣'][i] + '' + escapeHtml(d.name) + '
' + ' + parseFloat(d.total).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' ).join('') + '
'; // Recent donations table const recentHtml = recentDonations.length === 0 ? '' : '
📋 Recent Donations
' + '
' + '' + '' + recentDonations.map((d, i) => '' + '' + '' + '' + '' + '' ).join('') + '
DateDonorAmountType
' + (d.donation_date ? d.donation_date.split('T')[0] : '') + '' + escapeHtml(d.donor_name || '') + ' + parseFloat(d.amount).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '' + (d.donation_type || '') + '
'; // Daily chart canvas const chartHtml = timeline.length === 0 ? '' : '
📊 Daily Donations
' + '
' + '
'; // Action buttons const statusLabel2 = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft' }; const actionsHtml = '
' + '' + (c.status === 'active' ? '' : '') + (c.status !== 'cancelled' ? '' : '') + '
'; document.getElementById('campaigns-detail-body').innerHTML = '
' + '' + '
' + '
' + escapeHtml(c.name) + '
' + '
' + (c.description ? escapeHtml(c.description) : '') + '
' + '
' + thermoHtml + statsHtml + actionsHtml + topDonorsHtml + recentHtml + chartHtml; // Render chart if (timeline.length > 0) { setTimeout(() => { const ctx = document.getElementById('campaign-daily-chart'); if (!ctx) return; _campaignDetailChart = new Chart(ctx.getContext('2d'), { type: 'bar', data: { labels: timeline.map(r => r.date), datasets: [{ label: 'Daily Donations ($)', data: timeline.map(r => parseFloat(r.total)), backgroundColor: 'rgba(45,125,70,0.75)', borderColor: '#2D7D46', borderWidth: 1, borderRadius: 4 }] }, options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: v => ' + v.toLocaleString() } } } } }); }, 50); } } catch (err) { document.getElementById('campaigns-detail-body').innerHTML = '
Error: ' + escapeHtml(err.message) + '
'; } } async function submitNewCampaign(e) { e.preventDefault(); const form = e.target; const fd = new FormData(form); const body = Object.fromEntries(fd); // Remove empty optional fields ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) delete body[k]; }); try { const res = await apiFetch('/api/campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to create campaign'); return; } closeModal('new-campaign-modal'); form.reset(); loadCampaigns(); alert('Campaign created!'); } catch (err) { alert('Network error. Please try again.'); } } function openNewCampaignModal() { const form = document.getElementById('new-campaign-form'); if (form) form.reset(); const today = new Date().toISOString().split('T')[0]; const endDate = new Date(); endDate.setDate(endDate.getDate() + 30); const endStr = endDate.toISOString().split('T')[0]; const sd = form.querySelector('[name=start_date]'); const ed = form.querySelector('[name=end_date]'); if (sd) sd.value = today; if (ed) ed.value = endStr; document.getElementById('new-campaign-modal').classList.add('active'); } let _editingCampaignId = null; function openEditCampaignModal(campaignId) { const c = _campaignsCache.find(x => x.id === campaignId) || {}; _editingCampaignId = campaignId; const form = document.getElementById('new-campaign-form'); if (!form) return; form.querySelector('[name=name]').value = c.name || ''; form.querySelector('[name=description]').value = c.description || ''; form.querySelector('[name=start_date]').value = c.start_date ? c.start_date.split('T')[0] : ''; form.querySelector('[name=end_date]').value = c.end_date ? c.end_date.split('T')[0] : ''; form.querySelector('[name=goal_dollars]').value = c.goal_dollars || ''; form.querySelector('[name=goal_lbs]').value = c.goal_lbs || ''; form.querySelector('[name=goal_items]').value = c.goal_items || ''; form.querySelector('[name=status]').value = c.status || 'active'; document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '✏️ Edit Campaign'; document.getElementById('new-campaign-modal').classList.add('active'); // Override submit to PUT form.onsubmit = async (ev) => { ev.preventDefault(); const fd = new FormData(form); const body = Object.fromEntries(fd); ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) body[k] = null; }); try { const res = await apiFetch('/api/campaigns/' + _editingCampaignId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to update'); return; } closeModal('new-campaign-modal'); document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '➕ New Campaign'; form.onsubmit = submitNewCampaign; _editingCampaignId = null; loadCampaigns(); alert('Campaign updated!'); } catch (err) { alert('Network error. Please try again.'); } }; } async function completeCampaign(id) { if (!confirm('Mark this campaign as completed?')) return; await apiFetch('/api/campaigns/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'completed' }) }); loadCampaigns(); } async function cancelCampaign(id) { if (!confirm('Cancel this campaign? This will hide it from the list.')) return; await apiFetch('/api/campaigns/' + id, { method: 'DELETE' }); loadCampaigns(); } // ── Populate campaigns in Add Donation modal ── async function populateDonationCampaigns() { const select = document.getElementById('donation-campaign-select'); if (!select) return; // Clear existing dynamic options while (select.options.length > 1) select.remove(1); try { const res = await apiFetch('/api/campaigns/active'); const data = await res.json(); (data.campaigns || []).forEach(c => { const opt = document.createElement('option'); opt.value = c.id; opt.setAttribute('data-raised', c.total_raised || 0); opt.setAttribute('data-goal', c.goal_dollars || 0); opt.textContent = c.name; select.appendChild(opt); }); } catch (err) { console.warn('Could not load campaigns for donation form:', err.message); } } function showCampaignProgress() { const select = document.getElementById('donation-campaign-select'); const progressDiv = document.getElementById('donation-campaign-progress'); if (!select || !progressDiv) return; const selected = select.options[select.selectedIndex]; if (!selected || !selected.value) { progressDiv.style.display = 'none'; return; } const raised = parseFloat(selected.getAttribute('data-raised') || 0); const goal = parseFloat(selected.getAttribute('data-goal') || 0); if (goal > 0) { const pct = (raised / goal * 100).toFixed(0); progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' of + goal.toLocaleString() + ' raised (' + pct + '%)'; progressDiv.style.display = ''; } else { progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' raised (no goal set)'; progressDiv.style.display = ''; } } + s.cost_per_meal.toFixed(2) : 'N/A'; const narrativeParts = [ 'During this period, Minnie's Food Pantry served ' + fmtNum(s.total_families_served) + ' families and distributed', '' + fmtNum(s.total_lbs_distributed, 1) + ' pounds of food, providing approximately', '' + fmtNum(s.total_meals_served) + ' meals.', '' + fmtNum(s.unique_volunteers) + ' volunteers contributed', '' + fmtNum(s.total_volunteer_hours, 0) + ' hours of service.', 'Families averaged ' + fmtNum(s.avg_visits_per_family, 1) + ' visits per person during this period.' ]; if (s.cost_per_meal > 0) narrativeParts.push('Estimated cost per meal: ' + costDisplay + '.'); container.innerHTML = '
' + '
' + '
Minnie's Food Pantry
' + '
Grant Impact Summary
' + '
' + data.period.start + ' – ' + data.period.end + '
' + '
' + '
' + '
' + narrativeParts.join(' ') + '
' + '
Key Impact Metrics
' + '
' + complianceMetricCard('Families Served', fmtNum(s.total_families_served), '#1e8449') + complianceMetricCard('Meals Provided', fmtNum(s.total_meals_served), '#1e8449') + complianceMetricCard('Lbs Distributed', fmtNum(s.total_lbs_distributed, 1) + ' lbs', '#1e8449') + complianceMetricCard('Unique Volunteers', fmtNum(s.unique_volunteers), '#1e8449') + complianceMetricCard('Volunteer Hours', fmtNum(s.total_volunteer_hours, 1) + ' hrs', '#1e8449') + complianceMetricCard('Total Donations', ' const n = new Date(); const y = n.getFullYear(); const m = String(n.getMonth() + 1).padStart(2, '0'); const el = document.getElementById('board-month-input'); if (el) el.value = `${y}-${m}`; })(); async function generateBoardReport() { const monthInput = document.getElementById('board-month-input'); const month = monthInput ? monthInput.value : ''; if (!month) { alert('Please select a month.'); return; } const btn = document.getElementById('board-generate-btn'); btn.disabled = true; btn.textContent = '⏳ Generating…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!res.ok || !data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); // Refresh saved list loadSavedBoardReports(); } catch (err) { alert('Error generating report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } function renderBoardReportPreview(r) { const m = r.metrics; document.getElementById('brp-month').textContent = r.month_label; document.getElementById('brp-summary').textContent = r.executive_summary; // Metrics grid const fmt = (v, digits) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: digits || 0 }); const fmtMoney = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaHtml = (d) => { if (d === null || d === undefined) return ''; const cls = d > 0 ? 'up' : d < 0 ? 'down' : 'flat'; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; return `${arrow} ${Math.abs(d)}% vs prior month`; }; const cards = [ { label: 'Families Served', value: fmt(m.clients.unique_served), sub: `${fmt(m.clients.new_this_month)} new clients this month`, delta: m.clients.delta_unique_served }, { label: 'Pounds Distributed', value: fmt(m.distributions.total_lbs, 1), sub: `${fmt(m.distributions.total_visits)} visits · ${fmt(m.distributions.avg_lbs_per_family, 1)} lbs/family avg`, delta: m.distributions.delta_lbs }, { label: 'Active Volunteers', value: fmt(m.volunteers.active), sub: `${fmt(m.volunteers.hours, 1)} hours · ${m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '% attendance' : 'no attendance data'}`, delta: m.volunteers.delta_active }, { label: 'Donations', value: fmtMoney(m.donations.total_usd), sub: `${fmt(m.donations.donor_count)} donors · ${fmt(m.donations.donation_count)} gifts`, delta: m.donations.delta_total }, { label: 'Current Inventory', value: fmt(m.inventory.current_stock_lbs, 1) + ' lbs', sub: `${fmt(m.inventory.received_this_month_lbs, 1)} lbs received · ${fmt(m.inventory.expiring_soon_items)} items expiring soon`, delta: null }, { label: 'Total Active Clients', value: fmt(m.clients.total_active), sub: 'On file in system', delta: null }, ...(m.inventory.donated_items > 0 ? [{ label: 'Donated Inventory', value: fmt(m.inventory.donated_items) + ' items', sub: `${fmt(m.inventory.donated_lbs, 1)} lbs from ${fmt(m.inventory.donated_by_donors)} donor${m.inventory.donated_by_donors !== 1 ? 's' : ''}`, delta: null }] : []) ]; document.getElementById('brp-metrics-grid').innerHTML = cards.map(c => `
${c.label}
${c.value}
${c.sub}
${deltaHtml(c.delta)}
`).join(''); // Highlights const h = r.highlights; const hlCards = []; if (h.top_volunteer) hlCards.push({ icon: '🏆', label: 'Top Volunteer', value: `${h.top_volunteer.name} · ${h.top_volunteer.hours}h` }); if (h.largest_donation) hlCards.push({ icon: '💛', label: 'Largest Donation', value: `${fmtMoney(h.largest_donation.amount)} from ${h.largest_donation.donor}` }); if (h.busiest_day) hlCards.push({ icon: '📅', label: 'Busiest Day', value: `${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })} · ${h.busiest_day.visits} visits · ${(h.busiest_day.lbs || 0).toLocaleString(undefined, { maximumFractionDigits: 1 })} lbs` }); if (!hlCards.length) hlCards.push({ icon: 'ℹ️', label: 'No Activity', value: 'No data recorded for this month' }); document.getElementById('brp-highlights').innerHTML = hlCards.map(c => `
${c.icon}
${c.label}
${c.value}
`).join(''); } function printBoardReport() { if (!currentBoardReport) return; const r = currentBoardReport; const m = r.metrics; const fmtN = (v, d) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: d || 0 }); const fmtM = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaStr = (d) => { if (d === null || d === undefined) return ''; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; const color = d > 0 ? '#065f46' : d < 0 ? '#991b1b' : '#6b7280'; const bg = d > 0 ? '#d1fae5' : d < 0 ? '#fee2e2' : '#f3f4f6'; return `${arrow} ${Math.abs(d)}%`; }; const h = r.highlights; const hlHtml = [ h.top_volunteer ? `🏆 Top Volunteer${h.top_volunteer.name} — ${h.top_volunteer.hours} hours` : '', h.largest_donation ? `💛 Largest Donation${fmtM(h.largest_donation.amount)} from ${h.largest_donation.donor}` : '', h.busiest_day ? `📅 Busiest Day${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', timeZone: 'UTC' })} — ${h.busiest_day.visits} visits, ${fmtN(h.busiest_day.lbs, 1)} lbs` : '' ].filter(Boolean).join(''); const metricsRows = [ ['Families Served (Unique)', fmtN(m.clients.unique_served), deltaStr(m.clients.delta_unique_served)], ['New Clients Registered', fmtN(m.clients.new_this_month), ''], ['Total Distribution Visits', fmtN(m.distributions.total_visits), deltaStr(m.distributions.delta_visits)], ['Total Pounds Distributed', fmtN(m.distributions.total_lbs, 1) + ' lbs', deltaStr(m.distributions.delta_lbs)], ['Avg Lbs per Family', fmtN(m.distributions.avg_lbs_per_family, 1) + ' lbs', ''], ['Active Volunteers', fmtN(m.volunteers.active), deltaStr(m.volunteers.delta_active)], ['Volunteer Hours', fmtN(m.volunteers.hours, 1), deltaStr(m.volunteers.delta_hours)], ['Attendance Rate', m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '%' : 'N/A', ''], ['Total Donations', fmtM(m.donations.total_usd), deltaStr(m.donations.delta_total)], ['Unique Donors', fmtN(m.donations.donor_count), deltaStr(m.donations.delta_donors)], ['Current Inventory', fmtN(m.inventory.current_stock_lbs, 1) + ' lbs', ''], ['Inventory Received This Month', fmtN(m.inventory.received_this_month_lbs, 1) + ' lbs', ''], ['Items Expiring Within 30 Days', fmtN(m.inventory.expiring_soon_items), ''], ...(m.inventory.donated_items > 0 ? [ ['Donated Inventory (Items)', fmtN(m.inventory.donated_items) + ' items (' + fmtN(m.inventory.donated_lbs, 1) + ' lbs)', ''], ['Donation Sources (Donors)', fmtN(m.inventory.donated_by_donors) + ' donor' + (m.inventory.donated_by_donors !== 1 ? 's' : ''), ''] ] : []), ['Total Active Clients on File', fmtN(m.clients.total_active), ''] ].map(([label, val, d]) => `${label}${val}${d}`).join(''); const html = `Board Report — ${r.month_label}
Minnie's Food Pantry
Monthly Board Report
${r.month_label}
${r.executive_summary}
Key Performance Metrics
${metricsRows}
MetricValuevs. Prior Month
${hlHtml ? `
Month Highlights
${hlHtml}
` : ''}
`; const blob = new Blob([html], { type: 'text/html' }); window.open(URL.createObjectURL(blob), '_blank'); } async function loadSavedBoardReports() { const list = document.getElementById('saved-board-reports-list'); try { const res = await apiFetch('/api/reports/monthly/saved'); const data = await res.json(); if (!data.success || !data.reports.length) { list.innerHTML = '
No saved reports yet. Generate your first board report above.
'; return; } list.innerHTML = data.reports.map(r => `
${formatSavedReportMonth(r.report_month)}
Generated ${new Date(r.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
`).join(''); } catch (err) { list.innerHTML = '
Could not load saved reports.
'; } } function formatSavedReportMonth(ym) { const [y, m] = ym.split('-').map(Number); return new Date(y, m - 1, 1).toLocaleString('en-US', { month: 'long', year: 'numeric' }); } async function viewSavedBoardReport(month) { const btn = document.getElementById('board-generate-btn'); const mi = document.getElementById('board-month-input'); if (mi) mi.value = month; btn.disabled = true; btn.textContent = '⏳ Loading…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (err) { alert('Error loading report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } async function deleteSavedBoardReport(id, btnEl) { if (!confirm('Delete this saved report?')) return; try { const res = await apiFetch(`/api/reports/monthly/saved/${id}`, { method: 'DELETE' }); if (res.ok) { loadSavedBoardReports(); if (currentBoardReport) { document.getElementById('board-report-preview').style.display = 'none'; currentBoardReport = null; } } } catch (err) { alert('Delete failed: ' + err.message); } } // Close modals on outside click window.onclick = function(event) { if (event.target.classList.contains('modal')) { event.target.classList.remove('active'); } } // ── CSV Export ── async function exportCSV(type) { const token = localStorage.getItem('mfp_admin_token'); if (!token) { alert('Not authenticated'); return; } // Map type to API path and filename label const pathMap = { volunteers: 'volunteers', donors: 'donors', clients: 'clients', inventory: 'inventory', visits: 'visits' }; const apiPath = pathMap[type]; if (!apiPath) { alert('Unknown export type'); return; } // Build URL with optional date range from the Reports tab pickers (if available) const startEl = document.getElementById('report-start-date'); const endEl = document.getElementById('report-end-date'); let url = `/api/${apiPath}/export?format=csv`; if (startEl && startEl.value) url += `&start=${startEl.value}`; if (endEl && endEl.value) url += `&end=${endEl.value}`; try { const resp = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || `HTTP ${resp.status}`); } const blob = await resp.blob(); const blobUrl = URL.createObjectURL(blob); const today = new Date().toISOString().slice(0, 10); const a = document.createElement('a'); a.href = blobUrl; a.download = `${type}-export-${today}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); } catch (err) { alert('Export failed: ' + err.message); } } // ── CSV Import ── let importType = ''; let importRows = []; const csvFormats = { volunteers: { title: 'Import Volunteers', hint: 'CSV headers: name, email, phone, status, skills
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,phone,status,skills\nJane Smith,jane@example.com,555-1234,active,Logistics' }, donors: { title: 'Import Donors', hint: 'CSV headers: name, email, total_given, last_gift_date, phone, donor_type, status
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,total_given,last_gift_date\nJohn Doe,john@example.com,500.00,2025-12-15' } }; function openImportModal(type) { importType = type; importRows = []; const config = csvFormats[type]; document.getElementById('import-modal-title').textContent = config.title; document.getElementById('csv-format-hint').innerHTML = config.hint; resetImportModal(); document.getElementById('import-csv-modal').classList.add('active'); } function closeImportModal() { document.getElementById('import-csv-modal').classList.remove('active'); importType = ''; importRows = []; } function resetImportModal() { document.getElementById('import-step-upload').style.display = 'block'; document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.remove('active'); document.getElementById('import-file-input').value = ''; importRows = []; } // Drag and drop const dropZone = document.getElementById('import-drop-zone'); ['dragenter', 'dragover'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add('drag-over'); }); }); ['dragleave', 'drop'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('drag-over'); }); }); dropZone.addEventListener('drop', (e) => { const files = e.dataTransfer.files; if (files.length > 0) { processFile(files[0]); } }); function handleFileSelect(e) { if (e.target.files.length > 0) { processFile(e.target.files[0]); } } function processFile(file) { if (!file.name.toLowerCase().endsWith('.csv') && file.type !== 'text/csv') { alert('Please upload a CSV file'); return; } const reader = new FileReader(); reader.onload = (e) => { const text = e.target.result; const parsed = parseCSV(text); if (parsed.length === 0) { alert('No data rows found in CSV'); return; } importRows = parsed; showPreview(file.name, parsed); }; reader.readAsText(file); } function parseCSV(text) { const lines = text.split(/\r?\n/).filter(line => line.trim()); if (lines.length < 2) return []; const headers = parseCSVLine(lines[0]).map(h => h.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_')); const rows = []; for (let i = 1; i < lines.length; i++) { const values = parseCSVLine(lines[i]); if (values.length === 0 || (values.length === 1 && !values[0].trim())) continue; const row = {}; headers.forEach((header, idx) => { row[header] = (values[idx] || '').trim(); }); rows.push(row); } return rows; } function parseCSVLine(line) { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"') { if (i + 1 < line.length && line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = false; } } else { current += ch; } } else { if (ch === '"') { inQuotes = true; } else if (ch === ',') { result.push(current); current = ''; } else { current += ch; } } } result.push(current); return result; } function showPreview(fileName, rows) { document.getElementById('import-step-upload').style.display = 'none'; document.getElementById('import-step-preview').classList.add('active'); document.getElementById('preview-file-name').textContent = fileName; document.getElementById('preview-row-count').textContent = `${rows.length} row${rows.length !== 1 ? 's' : ''}`; const headers = Object.keys(rows[0]); const previewRows = rows.slice(0, 5); const remaining = rows.length - 5; let tableHtml = '' + headers.map(h => `${h}`).join('') + ''; previewRows.forEach(row => { tableHtml += '' + headers.map(h => `${escapeHtml(row[h] || '')}`).join('') + ''; }); tableHtml += ''; document.getElementById('preview-table').innerHTML = tableHtml; if (remaining > 0) { document.getElementById('preview-more-rows').textContent = `+ ${remaining} more row${remaining !== 1 ? 's' : ''}`; document.getElementById('preview-more-rows').style.display = 'block'; } else { document.getElementById('preview-more-rows').style.display = 'none'; } } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } async function executeImport() { document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.add('active'); try { const response = await apiFetch(`/api/${importType}/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rows: importRows }) }); const result = await response.json(); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); if (response.ok && result.success) { const hasErrors = result.errors && result.errors.length > 0; document.getElementById('import-result-icon').textContent = hasErrors ? '⚠️' : '✅'; document.getElementById('import-result-title').textContent = hasErrors ? 'Import completed with some issues' : 'Import successful!'; document.getElementById('import-summary').innerHTML = `
${result.created}
Created
${result.updated}
Updated
${result.errors.length}
Errors
`; if (hasErrors) { const errorsDiv = document.getElementById('import-errors'); errorsDiv.style.display = 'block'; errorsDiv.innerHTML = result.errors.slice(0, 10).map(e => `Row ${e.row}: ${escapeHtml(e.error)}` ).join('
'); if (result.errors.length > 10) { errorsDiv.innerHTML += `
... and ${result.errors.length - 10} more`; } } else { document.getElementById('import-errors').style.display = 'none'; } // Refresh data if (importType === 'volunteers') loadVolunteers(); if (importType === 'donors') loadDonors(); loadDashboardData(); } else { document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = result.error || 'Import failed'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } catch (error) { document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = 'Network error — please try again'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } // ── Locations Management ── const LOCATION_TYPE_LABELS = { main_facility: { label: 'Main Facility', color: '#C44B2B' }, school_pantry: { label: 'School Pantry', color: '#2563EB' }, partner_site: { label: 'Partner Site', color: '#16A34A' }, other: { label: 'Other', color: '#6B7280' } }; async function loadLocationsTab() { try { const data = await apiFetch('/api/locations/all').then(r => r.json()); state.locations.data = data.locations || []; renderLocations(state.locations.data); } catch (e) { document.getElementById('locations-table').innerHTML = '
Error loading locations
'; } } function renderLocations(locations) { const tbody = document.getElementById('locations-table'); if (!locations || locations.length === 0) { tbody.innerHTML = '
📍
No locations found
Add your first location to get started
'; return; } tbody.innerHTML = locations.map(loc => { const typeInfo = LOCATION_TYPE_LABELS[loc.location_type] || LOCATION_TYPE_LABELS.other; return ` ${loc.name} ${typeInfo.label} ${loc.address || '—'} ${loc.is_active ? 'Active' : 'Inactive'} ${loc.is_active ? ` ` : 'Inactive'} `; }).join(''); } function openAddLocationModal() { document.getElementById('location-id').value = ''; document.getElementById('location-modal-title').textContent = 'Add Location'; document.getElementById('location-submit-btn').textContent = 'Add Location'; document.getElementById('location-form').reset(); document.getElementById('location-modal').classList.add('active'); } function openEditLocationModal(id, name, type, address) { document.getElementById('location-id').value = id; document.getElementById('location-modal-title').textContent = 'Edit Location'; document.getElementById('location-submit-btn').textContent = 'Save Changes'; document.getElementById('loc-name').value = name; document.getElementById('loc-type').value = type; document.getElementById('loc-address').value = address; document.getElementById('location-modal').classList.add('active'); } async function saveLocation(e) { e.preventDefault(); const id = document.getElementById('location-id').value; const payload = { name: document.getElementById('loc-name').value.trim(), location_type: document.getElementById('loc-type').value, address: document.getElementById('loc-address').value.trim() || null }; try { const url = id ? `/api/locations/${id}` : '/api/locations'; const method = id ? 'PUT' : 'POST'; const resp = await apiFetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to save location'); return; } closeModal('location-modal'); await loadLocationsTab(); // Refresh the dropdown so the new location appears await initLocationFilter(); } catch (err) { alert('Network error — please try again'); } } async function deleteLocation(id) { if (!confirm('Remove this location? It will be deactivated and hidden from the filter.')) return; try { const resp = await apiFetch(`/api/locations/${id}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove location'); return; } await loadLocationsTab(); await initLocationFilter(); // If the deleted location was selected, reset to "all" if (String(globalLocationId) === String(id)) { globalLocationId = 'all'; localStorage.setItem('minniesos_location_filter', 'all'); } } catch (err) { alert('Network error — please try again'); } } // ── Users Tab ── const ROLE_LABELS = { admin: '🔑 Admin', coordinator: '📋 Coordinator', volunteer: '🙋 Volunteer' }; const ROLE_COLORS = { admin: '#6B2FA0', coordinator: '#2563EB', volunteer: '#16A34A' }; async function loadUsersTab() { const container = document.getElementById('users-list-container'); if (!container) return; container.innerHTML = '
Loading…
'; try { const data = await apiFetch('/api/users/roles').then(r => r.json()); if (!data.users || !data.users.length) { container.innerHTML = '
No users found.
'; return; } let html = ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; // Preload locations for the dropdown let locations = []; try { const locData = await apiFetch('/api/locations/all').then(r => r.json()); locations = locData.locations || locData || []; } catch(e) { /* ignore */ } const locOptions = locations.map(l => ``).join(''); for (const user of data.users) { const roleBadges = (user.roles || []).map(r => { const color = ROLE_COLORS[r.role] || '#888'; const label = ROLE_LABELS[r.role] || r.role; const locPart = r.location_name ? ` @ ${r.location_name}` : ''; return `${label}${locPart} `; }).join('') || 'No roles'; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; } html += '
NameEmailRolesAdd Role
${user.name || '—'}${user.email}${roleBadges}
'; container.innerHTML = html; } catch (err) { container.innerHTML = '
Failed to load users. You may not have admin access.
'; } } async function assignUserRole(event, userId) { event.preventDefault(); const form = event.target; const role = form.role.value; const location_id = form.location_id.value || null; try { const resp = await apiFetch(`/api/users/${userId}/roles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role, location_id: location_id ? parseInt(location_id) : null }) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to assign role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } async function removeUserRole(userId, roleId) { if (!confirm('Remove this role?')) return; try { const resp = await apiFetch(`/api/users/${userId}/roles/${roleId}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } function showAddUserModal() { // For now just redirect to settings or show an alert with instructions // Full user creation is out of scope for this task; uses /api/auth/setup-style endpoint alert('To add a new user, have them visit the app and use the setup flow, or have an admin create their account via the auth setup endpoint.'); } // ───────────────────────────────────────────────────────────────────── // CAMPAIGN TRACKER // ───────────────────────────────────────────────────────────────────── let _campaignsCache = []; let _campaignDetailChart = null; function destroyCampaignDetailChart() { if (_campaignDetailChart) { _campaignDetailChart.destroy(); _campaignDetailChart = null; } } async function loadCampaigns() { document.getElementById('campaigns-list-view').style.display = ''; document.getElementById('campaigns-detail-view').style.display = 'none'; destroyCampaignDetailChart(); const container = document.getElementById('campaigns-cards-container'); container.innerHTML = '
Loading…
'; try { const res = await apiFetch('/api/campaigns'); const data = await res.json(); _campaignsCache = data.campaigns || []; renderCampaignCards(_campaignsCache); } catch (err) { container.innerHTML = '
Error loading campaigns: ' + escapeHtml(err.message) + '
'; } } function renderCampaignCards(campaigns) { const container = document.getElementById('campaigns-cards-container'); if (!campaigns.length) { container.innerHTML = '
🎯
No campaigns yet
Create your first fundraising campaign to start tracking progress.
'; return; } const statusLabel = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft', cancelled: '❌ Cancelled' }; const statusClass = { active: 'active', completed: 'completed', draft: 'draft', cancelled: 'draft' }; container.innerHTML = '
' + campaigns.map(c => { const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const overGoal = goal > 0 && raised >= goal; const pctDisplay = goal > 0 ? (parseFloat(c.progress_pct || 0)).toFixed(0) + '%' : ''; const progressBar = goal > 0 ? '
+ goal.toLocaleString() + '">
' + pctDisplay + ' of + goal.toLocaleString() + ' goal
' : ''; const startD = c.start_date ? c.start_date.split('T')[0] : ''; const endD = c.end_date ? c.end_date.split('T')[0] : ''; return '
' + '
' + '
' + escapeHtml(c.name) + '
' + '' + (statusLabel[c.status] || c.status) + '' + '
' + '
📅 ' + startD + ' → ' + endD + '
' + progressBar + '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:0,maximumFractionDigits:0}) + 'Raised
' + '
' + (c.donor_count || 0) + 'Donors
' + '
' + (c.donation_count || 0) + 'Donations
' + '
' + '
'; }).join('') + '
'; } async function openCampaignDetail(campaignId) { document.getElementById('campaigns-list-view').style.display = 'none'; const detailView = document.getElementById('campaigns-detail-view'); detailView.style.display = ''; document.getElementById('campaigns-detail-body').innerHTML = '
Loading…
'; destroyCampaignDetailChart(); try { const res = await apiFetch('/api/campaigns/' + campaignId); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to load'); const c = data.campaign; const topDonors = data.top_donors || []; const recentDonations = data.recent_donations || []; const timeline = data.daily_timeline || []; const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const pctDisplay = goal > 0 ? pct.toFixed(0) + '%' : 'No goal set'; const overGoal = goal > 0 && raised >= goal; const daysRemaining = parseInt(c.days_remaining || 0); const daysLabel = daysRemaining > 0 ? daysRemaining + ' days left' : daysRemaining === 0 ? 'Ends today' : 'Ended'; // Thermometer const thermoHtml = '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' + (goal > 0 ? '
of + goal.toLocaleString() + ' goal · ' + pctDisplay + '
' : '
No dollar goal set
') + (goal > 0 ? '
' + (pct > 15 ? pctDisplay : '') + '
' : '') + '
'; // 3 stat cards const statsHtml = '
' + '
💰
+ raised.toLocaleString('en-US', {minimumFractionDigits:0}) + '
Raised
' + '
👥
' + (c.donor_count || 0) + '
Donors
' + '
📅
' + daysLabel + '
Timeline
' + '
'; // Top donors leaderboard const topDonorsHtml = topDonors.length === 0 ? '' : '
🏆 Top Donors
' + '
' + topDonors.map((d, i) => '
' + '
' + ['🥇','🥈','🥉','4️⃣','5️⃣'][i] + '' + escapeHtml(d.name) + '
' + ' + parseFloat(d.total).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' ).join('') + '
'; // Recent donations table const recentHtml = recentDonations.length === 0 ? '' : '
📋 Recent Donations
' + '
' + '' + '' + recentDonations.map((d, i) => '' + '' + '' + '' + '' + '' ).join('') + '
DateDonorAmountType
' + (d.donation_date ? d.donation_date.split('T')[0] : '') + '' + escapeHtml(d.donor_name || '') + ' + parseFloat(d.amount).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '' + (d.donation_type || '') + '
'; // Daily chart canvas const chartHtml = timeline.length === 0 ? '' : '
📊 Daily Donations
' + '
' + '
'; // Action buttons const statusLabel2 = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft' }; const actionsHtml = '
' + '' + (c.status === 'active' ? '' : '') + (c.status !== 'cancelled' ? '' : '') + '
'; document.getElementById('campaigns-detail-body').innerHTML = '
' + '' + '
' + '
' + escapeHtml(c.name) + '
' + '
' + (c.description ? escapeHtml(c.description) : '') + '
' + '
' + thermoHtml + statsHtml + actionsHtml + topDonorsHtml + recentHtml + chartHtml; // Render chart if (timeline.length > 0) { setTimeout(() => { const ctx = document.getElementById('campaign-daily-chart'); if (!ctx) return; _campaignDetailChart = new Chart(ctx.getContext('2d'), { type: 'bar', data: { labels: timeline.map(r => r.date), datasets: [{ label: 'Daily Donations ($)', data: timeline.map(r => parseFloat(r.total)), backgroundColor: 'rgba(45,125,70,0.75)', borderColor: '#2D7D46', borderWidth: 1, borderRadius: 4 }] }, options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: v => ' + v.toLocaleString() } } } } }); }, 50); } } catch (err) { document.getElementById('campaigns-detail-body').innerHTML = '
Error: ' + escapeHtml(err.message) + '
'; } } async function submitNewCampaign(e) { e.preventDefault(); const form = e.target; const fd = new FormData(form); const body = Object.fromEntries(fd); // Remove empty optional fields ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) delete body[k]; }); try { const res = await apiFetch('/api/campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to create campaign'); return; } closeModal('new-campaign-modal'); form.reset(); loadCampaigns(); alert('Campaign created!'); } catch (err) { alert('Network error. Please try again.'); } } function openNewCampaignModal() { const form = document.getElementById('new-campaign-form'); if (form) form.reset(); const today = new Date().toISOString().split('T')[0]; const endDate = new Date(); endDate.setDate(endDate.getDate() + 30); const endStr = endDate.toISOString().split('T')[0]; const sd = form.querySelector('[name=start_date]'); const ed = form.querySelector('[name=end_date]'); if (sd) sd.value = today; if (ed) ed.value = endStr; document.getElementById('new-campaign-modal').classList.add('active'); } let _editingCampaignId = null; function openEditCampaignModal(campaignId) { const c = _campaignsCache.find(x => x.id === campaignId) || {}; _editingCampaignId = campaignId; const form = document.getElementById('new-campaign-form'); if (!form) return; form.querySelector('[name=name]').value = c.name || ''; form.querySelector('[name=description]').value = c.description || ''; form.querySelector('[name=start_date]').value = c.start_date ? c.start_date.split('T')[0] : ''; form.querySelector('[name=end_date]').value = c.end_date ? c.end_date.split('T')[0] : ''; form.querySelector('[name=goal_dollars]').value = c.goal_dollars || ''; form.querySelector('[name=goal_lbs]').value = c.goal_lbs || ''; form.querySelector('[name=goal_items]').value = c.goal_items || ''; form.querySelector('[name=status]').value = c.status || 'active'; document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '✏️ Edit Campaign'; document.getElementById('new-campaign-modal').classList.add('active'); // Override submit to PUT form.onsubmit = async (ev) => { ev.preventDefault(); const fd = new FormData(form); const body = Object.fromEntries(fd); ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) body[k] = null; }); try { const res = await apiFetch('/api/campaigns/' + _editingCampaignId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to update'); return; } closeModal('new-campaign-modal'); document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '➕ New Campaign'; form.onsubmit = submitNewCampaign; _editingCampaignId = null; loadCampaigns(); alert('Campaign updated!'); } catch (err) { alert('Network error. Please try again.'); } }; } async function completeCampaign(id) { if (!confirm('Mark this campaign as completed?')) return; await apiFetch('/api/campaigns/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'completed' }) }); loadCampaigns(); } async function cancelCampaign(id) { if (!confirm('Cancel this campaign? This will hide it from the list.')) return; await apiFetch('/api/campaigns/' + id, { method: 'DELETE' }); loadCampaigns(); } // ── Populate campaigns in Add Donation modal ── async function populateDonationCampaigns() { const select = document.getElementById('donation-campaign-select'); if (!select) return; // Clear existing dynamic options while (select.options.length > 1) select.remove(1); try { const res = await apiFetch('/api/campaigns/active'); const data = await res.json(); (data.campaigns || []).forEach(c => { const opt = document.createElement('option'); opt.value = c.id; opt.setAttribute('data-raised', c.total_raised || 0); opt.setAttribute('data-goal', c.goal_dollars || 0); opt.textContent = c.name; select.appendChild(opt); }); } catch (err) { console.warn('Could not load campaigns for donation form:', err.message); } } function showCampaignProgress() { const select = document.getElementById('donation-campaign-select'); const progressDiv = document.getElementById('donation-campaign-progress'); if (!select || !progressDiv) return; const selected = select.options[select.selectedIndex]; if (!selected || !selected.value) { progressDiv.style.display = 'none'; return; } const raised = parseFloat(selected.getAttribute('data-raised') || 0); const goal = parseFloat(selected.getAttribute('data-goal') || 0); if (goal > 0) { const pct = (raised / goal * 100).toFixed(0); progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' of + goal.toLocaleString() + ' raised (' + pct + '%)'; progressDiv.style.display = ''; } else { progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' raised (no goal set)'; progressDiv.style.display = ''; } } + fmtNum(s.total_donation_value, 2), '#1e8449') + complianceMetricCard('Avg Visits/Family', fmtNum(s.avg_visits_per_family, 1), '#1e8449') + complianceMetricCard('Cost Per Meal', costDisplay, '#1e8449') + '
' + '
Top Donors by Impact
' + '' + topDonorRows + '
#NameTypeDonationsTotal Given
' + '
Month-over-Month Trends
' + '' + trendRows + '
MonthFamiliesLbs DistributedDonationsVol. Hours
' + '
' + '
' + '' + '
' + ''; } (function initBoardMonthInput() { const n = new Date(); const y = n.getFullYear(); const m = String(n.getMonth() + 1).padStart(2, '0'); const el = document.getElementById('board-month-input'); if (el) el.value = `${y}-${m}`; })(); async function generateBoardReport() { const monthInput = document.getElementById('board-month-input'); const month = monthInput ? monthInput.value : ''; if (!month) { alert('Please select a month.'); return; } const btn = document.getElementById('board-generate-btn'); btn.disabled = true; btn.textContent = '⏳ Generating…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!res.ok || !data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); // Refresh saved list loadSavedBoardReports(); } catch (err) { alert('Error generating report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } function renderBoardReportPreview(r) { const m = r.metrics; document.getElementById('brp-month').textContent = r.month_label; document.getElementById('brp-summary').textContent = r.executive_summary; // Metrics grid const fmt = (v, digits) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: digits || 0 }); const fmtMoney = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaHtml = (d) => { if (d === null || d === undefined) return ''; const cls = d > 0 ? 'up' : d < 0 ? 'down' : 'flat'; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; return `${arrow} ${Math.abs(d)}% vs prior month`; }; const cards = [ { label: 'Families Served', value: fmt(m.clients.unique_served), sub: `${fmt(m.clients.new_this_month)} new clients this month`, delta: m.clients.delta_unique_served }, { label: 'Pounds Distributed', value: fmt(m.distributions.total_lbs, 1), sub: `${fmt(m.distributions.total_visits)} visits · ${fmt(m.distributions.avg_lbs_per_family, 1)} lbs/family avg`, delta: m.distributions.delta_lbs }, { label: 'Active Volunteers', value: fmt(m.volunteers.active), sub: `${fmt(m.volunteers.hours, 1)} hours · ${m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '% attendance' : 'no attendance data'}`, delta: m.volunteers.delta_active }, { label: 'Donations', value: fmtMoney(m.donations.total_usd), sub: `${fmt(m.donations.donor_count)} donors · ${fmt(m.donations.donation_count)} gifts`, delta: m.donations.delta_total }, { label: 'Current Inventory', value: fmt(m.inventory.current_stock_lbs, 1) + ' lbs', sub: `${fmt(m.inventory.received_this_month_lbs, 1)} lbs received · ${fmt(m.inventory.expiring_soon_items)} items expiring soon`, delta: null }, { label: 'Total Active Clients', value: fmt(m.clients.total_active), sub: 'On file in system', delta: null }, ...(m.inventory.donated_items > 0 ? [{ label: 'Donated Inventory', value: fmt(m.inventory.donated_items) + ' items', sub: `${fmt(m.inventory.donated_lbs, 1)} lbs from ${fmt(m.inventory.donated_by_donors)} donor${m.inventory.donated_by_donors !== 1 ? 's' : ''}`, delta: null }] : []) ]; document.getElementById('brp-metrics-grid').innerHTML = cards.map(c => `
${c.label}
${c.value}
${c.sub}
${deltaHtml(c.delta)}
`).join(''); // Highlights const h = r.highlights; const hlCards = []; if (h.top_volunteer) hlCards.push({ icon: '🏆', label: 'Top Volunteer', value: `${h.top_volunteer.name} · ${h.top_volunteer.hours}h` }); if (h.largest_donation) hlCards.push({ icon: '💛', label: 'Largest Donation', value: `${fmtMoney(h.largest_donation.amount)} from ${h.largest_donation.donor}` }); if (h.busiest_day) hlCards.push({ icon: '📅', label: 'Busiest Day', value: `${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })} · ${h.busiest_day.visits} visits · ${(h.busiest_day.lbs || 0).toLocaleString(undefined, { maximumFractionDigits: 1 })} lbs` }); if (!hlCards.length) hlCards.push({ icon: 'ℹ️', label: 'No Activity', value: 'No data recorded for this month' }); document.getElementById('brp-highlights').innerHTML = hlCards.map(c => `
${c.icon}
${c.label}
${c.value}
`).join(''); } function printBoardReport() { if (!currentBoardReport) return; const r = currentBoardReport; const m = r.metrics; const fmtN = (v, d) => (v || 0).toLocaleString(undefined, { maximumFractionDigits: d || 0 }); const fmtM = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const deltaStr = (d) => { if (d === null || d === undefined) return ''; const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '→'; const color = d > 0 ? '#065f46' : d < 0 ? '#991b1b' : '#6b7280'; const bg = d > 0 ? '#d1fae5' : d < 0 ? '#fee2e2' : '#f3f4f6'; return `${arrow} ${Math.abs(d)}%`; }; const h = r.highlights; const hlHtml = [ h.top_volunteer ? `🏆 Top Volunteer${h.top_volunteer.name} — ${h.top_volunteer.hours} hours` : '', h.largest_donation ? `💛 Largest Donation${fmtM(h.largest_donation.amount)} from ${h.largest_donation.donor}` : '', h.busiest_day ? `📅 Busiest Day${new Date(h.busiest_day.date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', timeZone: 'UTC' })} — ${h.busiest_day.visits} visits, ${fmtN(h.busiest_day.lbs, 1)} lbs` : '' ].filter(Boolean).join(''); const metricsRows = [ ['Families Served (Unique)', fmtN(m.clients.unique_served), deltaStr(m.clients.delta_unique_served)], ['New Clients Registered', fmtN(m.clients.new_this_month), ''], ['Total Distribution Visits', fmtN(m.distributions.total_visits), deltaStr(m.distributions.delta_visits)], ['Total Pounds Distributed', fmtN(m.distributions.total_lbs, 1) + ' lbs', deltaStr(m.distributions.delta_lbs)], ['Avg Lbs per Family', fmtN(m.distributions.avg_lbs_per_family, 1) + ' lbs', ''], ['Active Volunteers', fmtN(m.volunteers.active), deltaStr(m.volunteers.delta_active)], ['Volunteer Hours', fmtN(m.volunteers.hours, 1), deltaStr(m.volunteers.delta_hours)], ['Attendance Rate', m.volunteers.attendance_rate !== null ? m.volunteers.attendance_rate + '%' : 'N/A', ''], ['Total Donations', fmtM(m.donations.total_usd), deltaStr(m.donations.delta_total)], ['Unique Donors', fmtN(m.donations.donor_count), deltaStr(m.donations.delta_donors)], ['Current Inventory', fmtN(m.inventory.current_stock_lbs, 1) + ' lbs', ''], ['Inventory Received This Month', fmtN(m.inventory.received_this_month_lbs, 1) + ' lbs', ''], ['Items Expiring Within 30 Days', fmtN(m.inventory.expiring_soon_items), ''], ...(m.inventory.donated_items > 0 ? [ ['Donated Inventory (Items)', fmtN(m.inventory.donated_items) + ' items (' + fmtN(m.inventory.donated_lbs, 1) + ' lbs)', ''], ['Donation Sources (Donors)', fmtN(m.inventory.donated_by_donors) + ' donor' + (m.inventory.donated_by_donors !== 1 ? 's' : ''), ''] ] : []), ['Total Active Clients on File', fmtN(m.clients.total_active), ''] ].map(([label, val, d]) => `${label}${val}${d}`).join(''); const html = `Board Report — ${r.month_label}
Minnie's Food Pantry
Monthly Board Report
${r.month_label}
${r.executive_summary}
Key Performance Metrics
${metricsRows}
MetricValuevs. Prior Month
${hlHtml ? `
Month Highlights
${hlHtml}
` : ''}
`; const blob = new Blob([html], { type: 'text/html' }); window.open(URL.createObjectURL(blob), '_blank'); } async function loadSavedBoardReports() { const list = document.getElementById('saved-board-reports-list'); try { const res = await apiFetch('/api/reports/monthly/saved'); const data = await res.json(); if (!data.success || !data.reports.length) { list.innerHTML = '
No saved reports yet. Generate your first board report above.
'; return; } list.innerHTML = data.reports.map(r => `
${formatSavedReportMonth(r.report_month)}
Generated ${new Date(r.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
`).join(''); } catch (err) { list.innerHTML = '
Could not load saved reports.
'; } } function formatSavedReportMonth(ym) { const [y, m] = ym.split('-').map(Number); return new Date(y, m - 1, 1).toLocaleString('en-US', { month: 'long', year: 'numeric' }); } async function viewSavedBoardReport(month) { const btn = document.getElementById('board-generate-btn'); const mi = document.getElementById('board-month-input'); if (mi) mi.value = month; btn.disabled = true; btn.textContent = '⏳ Loading…'; try { const res = await apiFetch(`/api/reports/monthly?month=${month}`); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed'); currentBoardReport = data.report; renderBoardReportPreview(data.report); document.getElementById('board-report-preview').style.display = 'block'; document.getElementById('board-report-preview').scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (err) { alert('Error loading report: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = '✨ Generate Report'; } } async function deleteSavedBoardReport(id, btnEl) { if (!confirm('Delete this saved report?')) return; try { const res = await apiFetch(`/api/reports/monthly/saved/${id}`, { method: 'DELETE' }); if (res.ok) { loadSavedBoardReports(); if (currentBoardReport) { document.getElementById('board-report-preview').style.display = 'none'; currentBoardReport = null; } } } catch (err) { alert('Delete failed: ' + err.message); } } // Close modals on outside click window.onclick = function(event) { if (event.target.classList.contains('modal')) { event.target.classList.remove('active'); } } // ── CSV Export ── async function exportCSV(type) { const token = localStorage.getItem('mfp_admin_token'); if (!token) { alert('Not authenticated'); return; } // Map type to API path and filename label const pathMap = { volunteers: 'volunteers', donors: 'donors', clients: 'clients', inventory: 'inventory', visits: 'visits' }; const apiPath = pathMap[type]; if (!apiPath) { alert('Unknown export type'); return; } // Build URL with optional date range from the Reports tab pickers (if available) const startEl = document.getElementById('report-start-date'); const endEl = document.getElementById('report-end-date'); let url = `/api/${apiPath}/export?format=csv`; if (startEl && startEl.value) url += `&start=${startEl.value}`; if (endEl && endEl.value) url += `&end=${endEl.value}`; try { const resp = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || `HTTP ${resp.status}`); } const blob = await resp.blob(); const blobUrl = URL.createObjectURL(blob); const today = new Date().toISOString().slice(0, 10); const a = document.createElement('a'); a.href = blobUrl; a.download = `${type}-export-${today}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); } catch (err) { alert('Export failed: ' + err.message); } } // ── CSV Import ── let importType = ''; let importRows = []; const csvFormats = { volunteers: { title: 'Import Volunteers', hint: 'CSV headers: name, email, phone, status, skills
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,phone,status,skills\nJane Smith,jane@example.com,555-1234,active,Logistics' }, donors: { title: 'Import Donors', hint: 'CSV headers: name, email, total_given, last_gift_date, phone, donor_type, status
Required: name. Duplicates matched by email — existing records will be updated.', example: 'name,email,total_given,last_gift_date\nJohn Doe,john@example.com,500.00,2025-12-15' } }; function openImportModal(type) { importType = type; importRows = []; const config = csvFormats[type]; document.getElementById('import-modal-title').textContent = config.title; document.getElementById('csv-format-hint').innerHTML = config.hint; resetImportModal(); document.getElementById('import-csv-modal').classList.add('active'); } function closeImportModal() { document.getElementById('import-csv-modal').classList.remove('active'); importType = ''; importRows = []; } function resetImportModal() { document.getElementById('import-step-upload').style.display = 'block'; document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.remove('active'); document.getElementById('import-file-input').value = ''; importRows = []; } // Drag and drop const dropZone = document.getElementById('import-drop-zone'); ['dragenter', 'dragover'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add('drag-over'); }); }); ['dragleave', 'drop'].forEach(evt => { dropZone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('drag-over'); }); }); dropZone.addEventListener('drop', (e) => { const files = e.dataTransfer.files; if (files.length > 0) { processFile(files[0]); } }); function handleFileSelect(e) { if (e.target.files.length > 0) { processFile(e.target.files[0]); } } function processFile(file) { if (!file.name.toLowerCase().endsWith('.csv') && file.type !== 'text/csv') { alert('Please upload a CSV file'); return; } const reader = new FileReader(); reader.onload = (e) => { const text = e.target.result; const parsed = parseCSV(text); if (parsed.length === 0) { alert('No data rows found in CSV'); return; } importRows = parsed; showPreview(file.name, parsed); }; reader.readAsText(file); } function parseCSV(text) { const lines = text.split(/\r?\n/).filter(line => line.trim()); if (lines.length < 2) return []; const headers = parseCSVLine(lines[0]).map(h => h.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_')); const rows = []; for (let i = 1; i < lines.length; i++) { const values = parseCSVLine(lines[i]); if (values.length === 0 || (values.length === 1 && !values[0].trim())) continue; const row = {}; headers.forEach((header, idx) => { row[header] = (values[idx] || '').trim(); }); rows.push(row); } return rows; } function parseCSVLine(line) { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"') { if (i + 1 < line.length && line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = false; } } else { current += ch; } } else { if (ch === '"') { inQuotes = true; } else if (ch === ',') { result.push(current); current = ''; } else { current += ch; } } } result.push(current); return result; } function showPreview(fileName, rows) { document.getElementById('import-step-upload').style.display = 'none'; document.getElementById('import-step-preview').classList.add('active'); document.getElementById('preview-file-name').textContent = fileName; document.getElementById('preview-row-count').textContent = `${rows.length} row${rows.length !== 1 ? 's' : ''}`; const headers = Object.keys(rows[0]); const previewRows = rows.slice(0, 5); const remaining = rows.length - 5; let tableHtml = '' + headers.map(h => `${h}`).join('') + ''; previewRows.forEach(row => { tableHtml += '' + headers.map(h => `${escapeHtml(row[h] || '')}`).join('') + ''; }); tableHtml += ''; document.getElementById('preview-table').innerHTML = tableHtml; if (remaining > 0) { document.getElementById('preview-more-rows').textContent = `+ ${remaining} more row${remaining !== 1 ? 's' : ''}`; document.getElementById('preview-more-rows').style.display = 'block'; } else { document.getElementById('preview-more-rows').style.display = 'none'; } } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } async function executeImport() { document.getElementById('import-step-preview').classList.remove('active'); document.getElementById('import-step-progress').classList.add('active'); try { const response = await apiFetch(`/api/${importType}/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rows: importRows }) }); const result = await response.json(); document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); if (response.ok && result.success) { const hasErrors = result.errors && result.errors.length > 0; document.getElementById('import-result-icon').textContent = hasErrors ? '⚠️' : '✅'; document.getElementById('import-result-title').textContent = hasErrors ? 'Import completed with some issues' : 'Import successful!'; document.getElementById('import-summary').innerHTML = `
${result.created}
Created
${result.updated}
Updated
${result.errors.length}
Errors
`; if (hasErrors) { const errorsDiv = document.getElementById('import-errors'); errorsDiv.style.display = 'block'; errorsDiv.innerHTML = result.errors.slice(0, 10).map(e => `Row ${e.row}: ${escapeHtml(e.error)}` ).join('
'); if (result.errors.length > 10) { errorsDiv.innerHTML += `
... and ${result.errors.length - 10} more`; } } else { document.getElementById('import-errors').style.display = 'none'; } // Refresh data if (importType === 'volunteers') loadVolunteers(); if (importType === 'donors') loadDonors(); loadDashboardData(); } else { document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = result.error || 'Import failed'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } catch (error) { document.getElementById('import-step-progress').classList.remove('active'); document.getElementById('import-step-result').classList.add('active'); document.getElementById('import-result-icon').textContent = '❌'; document.getElementById('import-result-title').textContent = 'Network error — please try again'; document.getElementById('import-summary').innerHTML = ''; document.getElementById('import-errors').style.display = 'none'; } } // ── Locations Management ── const LOCATION_TYPE_LABELS = { main_facility: { label: 'Main Facility', color: '#C44B2B' }, school_pantry: { label: 'School Pantry', color: '#2563EB' }, partner_site: { label: 'Partner Site', color: '#16A34A' }, other: { label: 'Other', color: '#6B7280' } }; async function loadLocationsTab() { try { const data = await apiFetch('/api/locations/all').then(r => r.json()); state.locations.data = data.locations || []; renderLocations(state.locations.data); } catch (e) { document.getElementById('locations-table').innerHTML = '
Error loading locations
'; } } function renderLocations(locations) { const tbody = document.getElementById('locations-table'); if (!locations || locations.length === 0) { tbody.innerHTML = '
📍
No locations found
Add your first location to get started
'; return; } tbody.innerHTML = locations.map(loc => { const typeInfo = LOCATION_TYPE_LABELS[loc.location_type] || LOCATION_TYPE_LABELS.other; return ` ${loc.name} ${typeInfo.label} ${loc.address || '—'} ${loc.is_active ? 'Active' : 'Inactive'} ${loc.is_active ? ` ` : 'Inactive'} `; }).join(''); } function openAddLocationModal() { document.getElementById('location-id').value = ''; document.getElementById('location-modal-title').textContent = 'Add Location'; document.getElementById('location-submit-btn').textContent = 'Add Location'; document.getElementById('location-form').reset(); document.getElementById('location-modal').classList.add('active'); } function openEditLocationModal(id, name, type, address) { document.getElementById('location-id').value = id; document.getElementById('location-modal-title').textContent = 'Edit Location'; document.getElementById('location-submit-btn').textContent = 'Save Changes'; document.getElementById('loc-name').value = name; document.getElementById('loc-type').value = type; document.getElementById('loc-address').value = address; document.getElementById('location-modal').classList.add('active'); } async function saveLocation(e) { e.preventDefault(); const id = document.getElementById('location-id').value; const payload = { name: document.getElementById('loc-name').value.trim(), location_type: document.getElementById('loc-type').value, address: document.getElementById('loc-address').value.trim() || null }; try { const url = id ? `/api/locations/${id}` : '/api/locations'; const method = id ? 'PUT' : 'POST'; const resp = await apiFetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to save location'); return; } closeModal('location-modal'); await loadLocationsTab(); // Refresh the dropdown so the new location appears await initLocationFilter(); } catch (err) { alert('Network error — please try again'); } } async function deleteLocation(id) { if (!confirm('Remove this location? It will be deactivated and hidden from the filter.')) return; try { const resp = await apiFetch(`/api/locations/${id}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove location'); return; } await loadLocationsTab(); await initLocationFilter(); // If the deleted location was selected, reset to "all" if (String(globalLocationId) === String(id)) { globalLocationId = 'all'; localStorage.setItem('minniesos_location_filter', 'all'); } } catch (err) { alert('Network error — please try again'); } } // ── Users Tab ── const ROLE_LABELS = { admin: '🔑 Admin', coordinator: '📋 Coordinator', volunteer: '🙋 Volunteer' }; const ROLE_COLORS = { admin: '#6B2FA0', coordinator: '#2563EB', volunteer: '#16A34A' }; async function loadUsersTab() { const container = document.getElementById('users-list-container'); if (!container) return; container.innerHTML = '
Loading…
'; try { const data = await apiFetch('/api/users/roles').then(r => r.json()); if (!data.users || !data.users.length) { container.innerHTML = '
No users found.
'; return; } let html = ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; // Preload locations for the dropdown let locations = []; try { const locData = await apiFetch('/api/locations/all').then(r => r.json()); locations = locData.locations || locData || []; } catch(e) { /* ignore */ } const locOptions = locations.map(l => ``).join(''); for (const user of data.users) { const roleBadges = (user.roles || []).map(r => { const color = ROLE_COLORS[r.role] || '#888'; const label = ROLE_LABELS[r.role] || r.role; const locPart = r.location_name ? ` @ ${r.location_name}` : ''; return `${label}${locPart} `; }).join('') || 'No roles'; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; } html += '
NameEmailRolesAdd Role
${user.name || '—'}${user.email}${roleBadges}
'; container.innerHTML = html; } catch (err) { container.innerHTML = '
Failed to load users. You may not have admin access.
'; } } async function assignUserRole(event, userId) { event.preventDefault(); const form = event.target; const role = form.role.value; const location_id = form.location_id.value || null; try { const resp = await apiFetch(`/api/users/${userId}/roles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role, location_id: location_id ? parseInt(location_id) : null }) }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to assign role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } async function removeUserRole(userId, roleId) { if (!confirm('Remove this role?')) return; try { const resp = await apiFetch(`/api/users/${userId}/roles/${roleId}`, { method: 'DELETE' }); if (!resp.ok) { alert('Failed to remove role'); return; } loadUsersTab(); } catch (err) { alert('Network error — please try again'); } } function showAddUserModal() { // For now just redirect to settings or show an alert with instructions // Full user creation is out of scope for this task; uses /api/auth/setup-style endpoint alert('To add a new user, have them visit the app and use the setup flow, or have an admin create their account via the auth setup endpoint.'); } // ───────────────────────────────────────────────────────────────────── // CAMPAIGN TRACKER // ───────────────────────────────────────────────────────────────────── let _campaignsCache = []; let _campaignDetailChart = null; function destroyCampaignDetailChart() { if (_campaignDetailChart) { _campaignDetailChart.destroy(); _campaignDetailChart = null; } } async function loadCampaigns() { document.getElementById('campaigns-list-view').style.display = ''; document.getElementById('campaigns-detail-view').style.display = 'none'; destroyCampaignDetailChart(); const container = document.getElementById('campaigns-cards-container'); container.innerHTML = '
Loading…
'; try { const res = await apiFetch('/api/campaigns'); const data = await res.json(); _campaignsCache = data.campaigns || []; renderCampaignCards(_campaignsCache); } catch (err) { container.innerHTML = '
Error loading campaigns: ' + escapeHtml(err.message) + '
'; } } function renderCampaignCards(campaigns) { const container = document.getElementById('campaigns-cards-container'); if (!campaigns.length) { container.innerHTML = '
🎯
No campaigns yet
Create your first fundraising campaign to start tracking progress.
'; return; } const statusLabel = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft', cancelled: '❌ Cancelled' }; const statusClass = { active: 'active', completed: 'completed', draft: 'draft', cancelled: 'draft' }; container.innerHTML = '
' + campaigns.map(c => { const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const overGoal = goal > 0 && raised >= goal; const pctDisplay = goal > 0 ? (parseFloat(c.progress_pct || 0)).toFixed(0) + '%' : ''; const progressBar = goal > 0 ? '
+ goal.toLocaleString() + '">
' + pctDisplay + ' of + goal.toLocaleString() + ' goal
' : ''; const startD = c.start_date ? c.start_date.split('T')[0] : ''; const endD = c.end_date ? c.end_date.split('T')[0] : ''; return '
' + '
' + '
' + escapeHtml(c.name) + '
' + '' + (statusLabel[c.status] || c.status) + '' + '
' + '
📅 ' + startD + ' → ' + endD + '
' + progressBar + '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:0,maximumFractionDigits:0}) + 'Raised
' + '
' + (c.donor_count || 0) + 'Donors
' + '
' + (c.donation_count || 0) + 'Donations
' + '
' + '
'; }).join('') + ''; } async function openCampaignDetail(campaignId) { document.getElementById('campaigns-list-view').style.display = 'none'; const detailView = document.getElementById('campaigns-detail-view'); detailView.style.display = ''; document.getElementById('campaigns-detail-body').innerHTML = '
Loading…
'; destroyCampaignDetailChart(); try { const res = await apiFetch('/api/campaigns/' + campaignId); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to load'); const c = data.campaign; const topDonors = data.top_donors || []; const recentDonations = data.recent_donations || []; const timeline = data.daily_timeline || []; const raised = parseFloat(c.total_raised || 0); const goal = parseFloat(c.goal_dollars || 0); const pct = goal > 0 ? Math.min((raised / goal * 100), 100) : 0; const pctDisplay = goal > 0 ? pct.toFixed(0) + '%' : 'No goal set'; const overGoal = goal > 0 && raised >= goal; const daysRemaining = parseInt(c.days_remaining || 0); const daysLabel = daysRemaining > 0 ? daysRemaining + ' days left' : daysRemaining === 0 ? 'Ends today' : 'Ended'; // Thermometer const thermoHtml = '
' + '
+ raised.toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' + (goal > 0 ? '
of + goal.toLocaleString() + ' goal · ' + pctDisplay + '
' : '
No dollar goal set
') + (goal > 0 ? '
' + (pct > 15 ? pctDisplay : '') + '
' : '') + '
'; // 3 stat cards const statsHtml = '
' + '
💰
+ raised.toLocaleString('en-US', {minimumFractionDigits:0}) + '
Raised
' + '
👥
' + (c.donor_count || 0) + '
Donors
' + '
📅
' + daysLabel + '
Timeline
' + '
'; // Top donors leaderboard const topDonorsHtml = topDonors.length === 0 ? '' : '
🏆 Top Donors
' + '
' + topDonors.map((d, i) => '
' + '
' + ['🥇','🥈','🥉','4️⃣','5️⃣'][i] + '' + escapeHtml(d.name) + '
' + ' + parseFloat(d.total).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '
' ).join('') + '
'; // Recent donations table const recentHtml = recentDonations.length === 0 ? '' : '
📋 Recent Donations
' + '
' + '' + '' + recentDonations.map((d, i) => '' + '' + '' + '' + '' + '' ).join('') + '
DateDonorAmountType
' + (d.donation_date ? d.donation_date.split('T')[0] : '') + '' + escapeHtml(d.donor_name || '') + ' + parseFloat(d.amount).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}) + '' + (d.donation_type || '') + '
'; // Daily chart canvas const chartHtml = timeline.length === 0 ? '' : '
📊 Daily Donations
' + '
' + '
'; // Action buttons const statusLabel2 = { active: '🟢 Active', completed: '✅ Completed', draft: '📝 Draft' }; const actionsHtml = '
' + '' + (c.status === 'active' ? '' : '') + (c.status !== 'cancelled' ? '' : '') + '
'; document.getElementById('campaigns-detail-body').innerHTML = '
' + '' + '
' + '
' + escapeHtml(c.name) + '
' + '
' + (c.description ? escapeHtml(c.description) : '') + '
' + '
' + thermoHtml + statsHtml + actionsHtml + topDonorsHtml + recentHtml + chartHtml; // Render chart if (timeline.length > 0) { setTimeout(() => { const ctx = document.getElementById('campaign-daily-chart'); if (!ctx) return; _campaignDetailChart = new Chart(ctx.getContext('2d'), { type: 'bar', data: { labels: timeline.map(r => r.date), datasets: [{ label: 'Daily Donations ($)', data: timeline.map(r => parseFloat(r.total)), backgroundColor: 'rgba(45,125,70,0.75)', borderColor: '#2D7D46', borderWidth: 1, borderRadius: 4 }] }, options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: v => ' + v.toLocaleString() } } } } }); }, 50); } } catch (err) { document.getElementById('campaigns-detail-body').innerHTML = '
Error: ' + escapeHtml(err.message) + '
'; } } async function submitNewCampaign(e) { e.preventDefault(); const form = e.target; const fd = new FormData(form); const body = Object.fromEntries(fd); // Remove empty optional fields ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) delete body[k]; }); try { const res = await apiFetch('/api/campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to create campaign'); return; } closeModal('new-campaign-modal'); form.reset(); loadCampaigns(); alert('Campaign created!'); } catch (err) { alert('Network error. Please try again.'); } } function openNewCampaignModal() { const form = document.getElementById('new-campaign-form'); if (form) form.reset(); const today = new Date().toISOString().split('T')[0]; const endDate = new Date(); endDate.setDate(endDate.getDate() + 30); const endStr = endDate.toISOString().split('T')[0]; const sd = form.querySelector('[name=start_date]'); const ed = form.querySelector('[name=end_date]'); if (sd) sd.value = today; if (ed) ed.value = endStr; document.getElementById('new-campaign-modal').classList.add('active'); } let _editingCampaignId = null; function openEditCampaignModal(campaignId) { const c = _campaignsCache.find(x => x.id === campaignId) || {}; _editingCampaignId = campaignId; const form = document.getElementById('new-campaign-form'); if (!form) return; form.querySelector('[name=name]').value = c.name || ''; form.querySelector('[name=description]').value = c.description || ''; form.querySelector('[name=start_date]').value = c.start_date ? c.start_date.split('T')[0] : ''; form.querySelector('[name=end_date]').value = c.end_date ? c.end_date.split('T')[0] : ''; form.querySelector('[name=goal_dollars]').value = c.goal_dollars || ''; form.querySelector('[name=goal_lbs]').value = c.goal_lbs || ''; form.querySelector('[name=goal_items]').value = c.goal_items || ''; form.querySelector('[name=status]').value = c.status || 'active'; document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '✏️ Edit Campaign'; document.getElementById('new-campaign-modal').classList.add('active'); // Override submit to PUT form.onsubmit = async (ev) => { ev.preventDefault(); const fd = new FormData(form); const body = Object.fromEntries(fd); ['goal_dollars','goal_lbs','goal_items','description'].forEach(k => { if (!body[k]) body[k] = null; }); try { const res = await apiFetch('/api/campaigns/' + _editingCampaignId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) { alert(data.error || 'Failed to update'); return; } closeModal('new-campaign-modal'); document.getElementById('new-campaign-modal').querySelector('.modal-title').textContent = '➕ New Campaign'; form.onsubmit = submitNewCampaign; _editingCampaignId = null; loadCampaigns(); alert('Campaign updated!'); } catch (err) { alert('Network error. Please try again.'); } }; } async function completeCampaign(id) { if (!confirm('Mark this campaign as completed?')) return; await apiFetch('/api/campaigns/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'completed' }) }); loadCampaigns(); } async function cancelCampaign(id) { if (!confirm('Cancel this campaign? This will hide it from the list.')) return; await apiFetch('/api/campaigns/' + id, { method: 'DELETE' }); loadCampaigns(); } // ── Populate campaigns in Add Donation modal ── async function populateDonationCampaigns() { const select = document.getElementById('donation-campaign-select'); if (!select) return; // Clear existing dynamic options while (select.options.length > 1) select.remove(1); try { const res = await apiFetch('/api/campaigns/active'); const data = await res.json(); (data.campaigns || []).forEach(c => { const opt = document.createElement('option'); opt.value = c.id; opt.setAttribute('data-raised', c.total_raised || 0); opt.setAttribute('data-goal', c.goal_dollars || 0); opt.textContent = c.name; select.appendChild(opt); }); } catch (err) { console.warn('Could not load campaigns for donation form:', err.message); } } function showCampaignProgress() { const select = document.getElementById('donation-campaign-select'); const progressDiv = document.getElementById('donation-campaign-progress'); if (!select || !progressDiv) return; const selected = select.options[select.selectedIndex]; if (!selected || !selected.value) { progressDiv.style.display = 'none'; return; } const raised = parseFloat(selected.getAttribute('data-raised') || 0); const goal = parseFloat(selected.getAttribute('data-goal') || 0); if (goal > 0) { const pct = (raised / goal * 100).toFixed(0); progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' of + goal.toLocaleString() + ' raised (' + pct + '%)'; progressDiv.style.display = ''; } else { progressDiv.textContent = ' + raised.toLocaleString('en-US', {minimumFractionDigits:0}) + ' raised (no goal set)'; progressDiv.style.display = ''; } } <\!-- ════════════════════════════════════════════════════════════ SHIFT GAP ALERTS MODAL ════════════════════════════════════════════════════════════ -->