// Free Alberta Food - Admin Panel class AdminPanel { constructor() { this.password = null; this.currentSection = 'updates'; this.currentStatus = 'pending'; this.listingStatus = 'pending'; this.geocodingFilter = 'all'; this.requests = []; this.listings = []; this.geoResources = []; this.init(); } init() { this.bindEvents(); this.checkAuth(); } bindEvents() { // Login form document.getElementById('loginForm').addEventListener('submit', (e) => { e.preventDefault(); this.login(); }); // Logout button document.getElementById('logoutBtn').addEventListener('click', () => this.logout()); // Section tabs (Update Requests vs New Listings) document.querySelectorAll('.admin-section-tab').forEach(tab => { tab.addEventListener('click', (e) => { this.switchSection(e.target.dataset.section); }); }); // Status filter tabs document.querySelectorAll('.admin-tab').forEach(tab => { tab.addEventListener('click', (e) => { const section = e.target.dataset.section; const status = e.target.dataset.status; const filter = e.target.dataset.filter; if (section === 'updates') { this.switchTab(status); } else if (section === 'listings') { this.switchListingTab(status); } else if (section === 'geocoding') { this.switchGeocodingTab(filter); } }); }); // Request modal document.querySelectorAll('#requestModal .modal-overlay, #requestModal .modal-close').forEach(el => { el.addEventListener('click', () => this.closeModal('requestModal')); }); // Listing modal document.querySelectorAll('#listingModal .modal-overlay, #listingModal .modal-close').forEach(el => { el.addEventListener('click', () => this.closeModal('listingModal')); }); // Geocoding modal document.querySelectorAll('#geocodingModal .modal-overlay, #geocodingModal .modal-close').forEach(el => { el.addEventListener('click', () => this.closeModal('geocodingModal')); }); // Escape key to close modals document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { this.closeModal('requestModal'); this.closeModal('listingModal'); this.closeModal('geocodingModal'); } }); // Event delegation for requests list document.getElementById('requestsList').addEventListener('click', (e) => { const viewBtn = e.target.closest('.view-request-btn'); if (viewBtn) { const requestId = parseInt(viewBtn.dataset.requestId); this.openRequestModal(requestId); } }); // Event delegation for listings list document.getElementById('listingsList').addEventListener('click', (e) => { const viewBtn = e.target.closest('.view-listing-btn'); if (viewBtn) { const listingId = parseInt(viewBtn.dataset.listingId); this.openListingModal(listingId); } }); // Event delegation for request modal actions document.getElementById('requestModalBody').addEventListener('click', (e) => { const approveBtn = e.target.closest('.approve-btn'); if (approveBtn) { const requestId = parseInt(approveBtn.dataset.requestId); this.approveRequest(requestId); return; } const rejectBtn = e.target.closest('.reject-btn'); if (rejectBtn) { const requestId = parseInt(rejectBtn.dataset.requestId); this.rejectRequest(requestId); } }); // Event delegation for listing modal actions document.getElementById('listingModalBody').addEventListener('click', (e) => { const approveBtn = e.target.closest('.approve-listing-btn'); if (approveBtn) { const listingId = parseInt(approveBtn.dataset.listingId); this.approveListingSubmission(listingId); return; } const rejectBtn = e.target.closest('.reject-listing-btn'); if (rejectBtn) { const listingId = parseInt(rejectBtn.dataset.listingId); this.rejectListingSubmission(listingId); } }); // Event delegation for geocoding list document.getElementById('geocodingList').addEventListener('click', (e) => { const viewBtn = e.target.closest('.view-geocoding-btn'); if (viewBtn) { const resourceId = parseInt(viewBtn.dataset.resourceId); this.openGeocodingModal(resourceId); } }); // Event delegation for geocoding modal actions document.getElementById('geocodingModalBody').addEventListener('click', (e) => { const regeocodeBtn = e.target.closest('.regeocode-btn'); if (regeocodeBtn) { const resourceId = parseInt(regeocodeBtn.dataset.resourceId); this.regeocodeResource(resourceId, regeocodeBtn); } }); } switchSection(section) { this.currentSection = section; // Update section tab active states document.querySelectorAll('.admin-section-tab').forEach(tab => { tab.classList.toggle('active', tab.dataset.section === section); }); // Show/hide sections document.getElementById('updatesSection').classList.toggle('hidden', section !== 'updates'); document.getElementById('listingsSection').classList.toggle('hidden', section !== 'listings'); document.getElementById('geocodingSection').classList.toggle('hidden', section !== 'geocoding'); // Load data for the active section if (section === 'updates') { this.loadCounts(); this.loadRequests(); } else if (section === 'listings') { this.loadListingCounts(); this.loadListings(); } else if (section === 'geocoding') { this.loadGeocodingStats(); this.loadGeoResources(); } } checkAuth() { const savedPassword = sessionStorage.getItem('adminPassword'); if (savedPassword) { this.password = savedPassword; this.showDashboard(); } } async login() { const passwordInput = document.getElementById('adminPassword'); const password = passwordInput.value; const errorDiv = document.getElementById('loginError'); try { const response = await fetch('/api/admin/auth', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }); if (response.ok) { this.password = password; sessionStorage.setItem('adminPassword', password); errorDiv.classList.add('hidden'); this.showDashboard(); } else { const data = await response.json(); errorDiv.textContent = data.error || 'Invalid password'; errorDiv.classList.remove('hidden'); } } catch (error) { console.error('Login failed:', error); errorDiv.textContent = 'Login failed. Please try again.'; errorDiv.classList.remove('hidden'); } } logout() { this.password = null; sessionStorage.removeItem('adminPassword'); document.getElementById('loginSection').classList.remove('hidden'); document.getElementById('adminDashboard').classList.add('hidden'); document.getElementById('logoutBtn').classList.add('hidden'); document.getElementById('adminPassword').value = ''; } showDashboard() { document.getElementById('loginSection').classList.add('hidden'); document.getElementById('adminDashboard').classList.remove('hidden'); document.getElementById('logoutBtn').classList.remove('hidden'); // Load data for the current section if (this.currentSection === 'updates') { this.loadCounts(); this.loadRequests(); } else if (this.currentSection === 'listings') { this.loadListingCounts(); this.loadListings(); } else if (this.currentSection === 'geocoding') { this.loadGeocodingStats(); this.loadGeoResources(); } } async loadCounts() { try { const response = await fetch('/api/admin/update-requests/counts', { headers: { 'Authorization': `Bearer ${this.password}` } }); if (response.ok) { const data = await response.json(); document.getElementById('pendingCount').textContent = data.counts.pending; document.getElementById('approvedCount').textContent = data.counts.approved; document.getElementById('rejectedCount').textContent = data.counts.rejected; } } catch (error) { console.error('Failed to load counts:', error); } } async loadRequests() { const container = document.getElementById('requestsList'); const noResults = document.getElementById('noRequests'); const loading = document.getElementById('loadingRequests'); container.innerHTML = ''; noResults.classList.add('hidden'); loading.classList.remove('hidden'); try { const response = await fetch(`/api/admin/update-requests?status=${this.currentStatus}`, { headers: { 'Authorization': `Bearer ${this.password}` } }); if (response.status === 401) { this.logout(); return; } const data = await response.json(); this.requests = data.requests; loading.classList.add('hidden'); if (this.requests.length === 0) { noResults.classList.remove('hidden'); return; } container.innerHTML = this.requests.map(request => `

${this.escapeHtml(request.current_name)}

${this.formatDate(request.created_at)}
${request.status}
From: ${this.escapeHtml(request.submitter_email)} ${request.submitter_name ? ` (${this.escapeHtml(request.submitter_name)})` : ''}
Changes: ${this.getChangesSummary(request)}
`).join(''); } catch (error) { console.error('Failed to load requests:', error); loading.classList.add('hidden'); container.innerHTML = '
Failed to load requests. Please try again.
'; } } switchTab(status) { this.currentStatus = status; // Update active tab (only for updates section) document.querySelectorAll('.admin-tab[data-section="updates"]').forEach(tab => { tab.classList.toggle('active', tab.dataset.status === status); }); this.loadRequests(); } switchListingTab(status) { this.listingStatus = status; // Update active tab (only for listings section) document.querySelectorAll('.admin-tab[data-section="listings"]').forEach(tab => { tab.classList.toggle('active', tab.dataset.status === status); }); this.loadListings(); } getChangesSummary(request) { const fields = [ { key: 'proposed_name', label: 'Name' }, { key: 'proposed_resource_type', label: 'Type' }, { key: 'proposed_address', label: 'Address' }, { key: 'proposed_city', label: 'City' }, { key: 'proposed_phone', label: 'Phone' }, { key: 'proposed_email', label: 'Email' }, { key: 'proposed_website', label: 'Website' }, { key: 'proposed_hours_of_operation', label: 'Hours' }, { key: 'proposed_description', label: 'Description' }, { key: 'proposed_eligibility', label: 'Eligibility' }, { key: 'proposed_services_offered', label: 'Services' } ]; const changes = fields .filter(f => request[f.key] !== null) .map(f => f.label); if (changes.length === 0) { return request.additional_notes ? 'Notes only' : 'No changes specified'; } return changes.join(', '); } openRequestModal(requestId) { const request = this.requests.find(r => r.id === requestId); if (!request) return; const modalBody = document.getElementById('requestModalBody'); const fields = [ { key: 'name', label: 'Name' }, { key: 'resource_type', label: 'Type' }, { key: 'address', label: 'Address' }, { key: 'city', label: 'City' }, { key: 'phone', label: 'Phone' }, { key: 'email', label: 'Email' }, { key: 'website', label: 'Website' }, { key: 'hours_of_operation', label: 'Hours of Operation' }, { key: 'description', label: 'Description' }, { key: 'eligibility', label: 'Eligibility' }, { key: 'services_offered', label: 'Services Offered' } ]; const changesHtml = fields.map(f => { const current = request[`current_${f.key}`]; const proposed = request[`proposed_${f.key}`]; if (proposed === null) return ''; return `
${f.label}
Current ${this.escapeHtml(current) || 'Not set'}
Proposed ${this.escapeHtml(proposed)}
`; }).filter(html => html !== '').join(''); modalBody.innerHTML = `

Update Request #${request.id}

${request.status}
${changesHtml ? ` ` : ''} ${request.additional_notes ? ` ` : ''} ${request.status === 'pending' ? ` ` : ` ${request.admin_notes ? ` ` : ''} `}
`; document.getElementById('requestModal').classList.remove('hidden'); document.body.style.overflow = 'hidden'; } closeModal(modalId) { document.getElementById(modalId).classList.add('hidden'); document.body.style.overflow = ''; } async approveRequest(requestId) { const adminNotes = document.getElementById('adminNotes')?.value.trim() || null; if (!confirm('Are you sure you want to approve this request and apply the changes?')) { return; } try { const response = await fetch(`/api/admin/update-requests/${requestId}/approve`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.password}` }, body: JSON.stringify({ admin_notes: adminNotes }) }); if (response.ok) { alert('Request approved and changes applied successfully!'); this.closeModal(); this.loadCounts(); this.loadRequests(); } else { const data = await response.json(); alert(data.error || 'Failed to approve request'); } } catch (error) { console.error('Failed to approve request:', error); alert('Failed to approve request. Please try again.'); } } async rejectRequest(requestId) { const adminNotes = document.getElementById('adminNotes')?.value.trim() || null; if (!confirm('Are you sure you want to reject this request?')) { return; } try { const response = await fetch(`/api/admin/update-requests/${requestId}/reject`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.password}` }, body: JSON.stringify({ admin_notes: adminNotes }) }); if (response.ok) { alert('Request rejected.'); this.closeModal(); this.loadCounts(); this.loadRequests(); } else { const data = await response.json(); alert(data.error || 'Failed to reject request'); } } catch (error) { console.error('Failed to reject request:', error); alert('Failed to reject request. Please try again.'); } } // ========================================== // Listing Submissions Methods // ========================================== async loadListingCounts() { try { const response = await fetch('/api/admin/listing-submissions/counts', { headers: { 'Authorization': `Bearer ${this.password}` } }); if (response.ok) { const data = await response.json(); document.getElementById('listingPendingCount').textContent = data.counts.pending; document.getElementById('listingApprovedCount').textContent = data.counts.approved; document.getElementById('listingRejectedCount').textContent = data.counts.rejected; } } catch (error) { console.error('Failed to load listing counts:', error); } } async loadListings() { const container = document.getElementById('listingsList'); const noResults = document.getElementById('noListings'); const loading = document.getElementById('loadingListings'); container.innerHTML = ''; noResults.classList.add('hidden'); loading.classList.remove('hidden'); try { const response = await fetch(`/api/admin/listing-submissions?status=${this.listingStatus}`, { headers: { 'Authorization': `Bearer ${this.password}` } }); if (response.status === 401) { this.logout(); return; } const data = await response.json(); this.listings = data.submissions; loading.classList.add('hidden'); if (this.listings.length === 0) { noResults.classList.remove('hidden'); return; } container.innerHTML = this.listings.map(listing => `

${this.escapeHtml(listing.name)}

${this.formatDate(listing.created_at)}
${listing.status}
From: ${this.escapeHtml(listing.submitter_email)} ${listing.submitter_name ? ` (${this.escapeHtml(listing.submitter_name)})` : ''}
Type: ${this.formatType(listing.resource_type)} ${listing.city ? ` | City: ${this.escapeHtml(listing.city)}` : ''}
`).join(''); } catch (error) { console.error('Failed to load listings:', error); loading.classList.add('hidden'); container.innerHTML = '
Failed to load submissions. Please try again.
'; } } openListingModal(listingId) { const listing = this.listings.find(l => l.id === listingId); if (!listing) return; const modalBody = document.getElementById('listingModalBody'); const fields = [ { key: 'name', label: 'Name' }, { key: 'resource_type', label: 'Type', format: (v) => this.formatType(v) }, { key: 'address', label: 'Address' }, { key: 'city', label: 'City' }, { key: 'phone', label: 'Phone' }, { key: 'email', label: 'Email' }, { key: 'website', label: 'Website' }, { key: 'hours_of_operation', label: 'Hours of Operation' }, { key: 'description', label: 'Description' }, { key: 'eligibility', label: 'Eligibility' }, { key: 'services_offered', label: 'Services Offered' } ]; const fieldsHtml = fields.map(f => { const value = listing[f.key]; if (!value) return ''; const displayValue = f.format ? f.format(value) : this.escapeHtml(value); return `
${f.label}
${displayValue}
`; }).filter(html => html !== '').join(''); modalBody.innerHTML = `

New Listing Submission #${listing.id}

${listing.status}
${listing.additional_notes ? ` ` : ''} ${listing.status === 'pending' ? ` ` : ` ${listing.admin_notes ? ` ` : ''} `}
`; document.getElementById('listingModal').classList.remove('hidden'); document.body.style.overflow = 'hidden'; } async approveListingSubmission(listingId) { const adminNotes = document.getElementById('listingAdminNotes')?.value.trim() || null; if (!confirm('Are you sure you want to approve this listing and publish it?')) { return; } try { const response = await fetch(`/api/admin/listing-submissions/${listingId}/approve`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.password}` }, body: JSON.stringify({ admin_notes: adminNotes }) }); if (response.ok) { const data = await response.json(); alert(`Listing approved and published! New Resource ID: ${data.resourceId}`); this.closeModal('listingModal'); this.loadListingCounts(); this.loadListings(); } else { const data = await response.json(); alert(data.error || 'Failed to approve listing'); } } catch (error) { console.error('Failed to approve listing:', error); alert('Failed to approve listing. Please try again.'); } } async rejectListingSubmission(listingId) { const adminNotes = document.getElementById('listingAdminNotes')?.value.trim() || null; if (!confirm('Are you sure you want to reject this listing?')) { return; } try { const response = await fetch(`/api/admin/listing-submissions/${listingId}/reject`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.password}` }, body: JSON.stringify({ admin_notes: adminNotes }) }); if (response.ok) { alert('Listing submission rejected.'); this.closeModal('listingModal'); this.loadListingCounts(); this.loadListings(); } else { const data = await response.json(); alert(data.error || 'Failed to reject listing'); } } catch (error) { console.error('Failed to reject listing:', error); alert('Failed to reject listing. Please try again.'); } } formatType(type) { const types = { 'food_bank': 'Food Bank', 'community_meal': 'Community Meal', 'hamper': 'Food Hamper', 'pantry': 'Food Pantry', 'soup_kitchen': 'Soup Kitchen', 'mobile_food': 'Mobile Food', 'grocery_program': 'Grocery Program', 'other': 'Other' }; return types[type] || type; } formatDate(dateStr) { const date = new Date(dateStr); return date.toLocaleDateString('en-CA', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // ========================================== // Geocoding Management Methods // ========================================== switchGeocodingTab(filter) { this.geocodingFilter = filter; // Update active tab (only for geocoding section) document.querySelectorAll('.admin-tab[data-section="geocoding"]').forEach(tab => { tab.classList.toggle('active', tab.dataset.filter === filter); }); this.loadGeoResources(); } async loadGeocodingStats() { try { const response = await fetch('/api/resources/map', { headers: { 'Authorization': `Bearer ${this.password}` } }); if (response.ok) { const data = await response.json(); const resources = data.resources || []; // Calculate stats let total = 0, high = 0, medium = 0, low = 0; resources.forEach(r => { total++; const confidence = r.geocode_confidence || 0; if (confidence >= 80) { high++; } else if (confidence >= 50) { medium++; } else { low++; } }); document.getElementById('geoTotalCount').textContent = total; document.getElementById('geoHighCount').textContent = high; document.getElementById('geoMediumCount').textContent = medium; document.getElementById('geoLowCount').textContent = low; } } catch (error) { console.error('Failed to load geocoding stats:', error); } } async loadGeoResources() { const container = document.getElementById('geocodingList'); const noResults = document.getElementById('noGeoResources'); const loading = document.getElementById('loadingGeoResources'); container.innerHTML = ''; noResults.classList.add('hidden'); loading.classList.remove('hidden'); try { const response = await fetch('/api/resources/map', { headers: { 'Authorization': `Bearer ${this.password}` } }); if (response.status === 401) { this.logout(); return; } const data = await response.json(); let resources = data.resources || []; // Apply filter if (this.geocodingFilter === 'low') { resources = resources.filter(r => (r.geocode_confidence || 0) < 50); } else if (this.geocodingFilter === 'missing') { resources = resources.filter(r => !r.latitude || !r.longitude); } // Sort by confidence (lowest first) for better workflow resources.sort((a, b) => (a.geocode_confidence || 0) - (b.geocode_confidence || 0)); this.geoResources = resources; loading.classList.add('hidden'); if (resources.length === 0) { noResults.classList.remove('hidden'); return; } container.innerHTML = resources.map(resource => `

${this.escapeHtml(resource.name)}

${this.escapeHtml(resource.city || 'No city')}
${this.getConfidenceBadge(resource.geocode_confidence, true)}
Address: ${this.escapeHtml(resource.address) || 'Not set'}
Coords: ${resource.latitude && resource.longitude ? `${parseFloat(resource.latitude).toFixed(4)}, ${parseFloat(resource.longitude).toFixed(4)}` : 'Missing'} ${resource.geocode_provider ? ` | Provider: ${this.escapeHtml(resource.geocode_provider)}` : ''}
`).join(''); } catch (error) { console.error('Failed to load resources:', error); loading.classList.add('hidden'); container.innerHTML = '
Failed to load resources. Please try again.
'; } } openGeocodingModal(resourceId) { const resource = this.geoResources.find(r => r.id === resourceId); if (!resource) return; const modalBody = document.getElementById('geocodingModalBody'); const hasCoords = resource.latitude && resource.longitude; const confidence = resource.geocode_confidence || 0; modalBody.innerHTML = `

${this.escapeHtml(resource.name)}

${this.getConfidenceBadge(confidence, true)}
`; document.getElementById('geocodingModal').classList.remove('hidden'); document.body.style.overflow = 'hidden'; } async regeocodeResource(resourceId, button) { const originalText = button.textContent; button.textContent = 'Geocoding...'; button.disabled = true; try { const response = await fetch(`/api/admin/resources/${resourceId}/regeocode`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.password}` } }); const data = await response.json(); if (response.ok && data.success) { const result = data.result; alert(`Successfully re-geocoded!\n\nLatitude: ${result.latitude.toFixed(6)}\nLongitude: ${result.longitude.toFixed(6)}\nConfidence: ${result.confidence}%\nProvider: ${result.provider}${result.warnings?.length ? '\n\nWarnings:\n' + result.warnings.join('\n') : ''}`); // Refresh the geocoding data this.loadGeocodingStats(); this.loadGeoResources(); this.closeModal('geocodingModal'); } else { alert(data.error || 'Failed to re-geocode resource'); button.textContent = originalText; button.disabled = false; } } catch (error) { console.error('Failed to re-geocode resource:', error); alert('Failed to re-geocode resource. Please try again.'); button.textContent = originalText; button.disabled = false; } } getConfidenceBadge(confidence, showLabel = false) { const conf = confidence || 0; let level, color; if (conf >= 80) { level = 'High'; color = '#10b981'; } else if (conf >= 50) { level = 'Medium'; color = '#f59e0b'; } else { level = 'Low'; color = '#ef4444'; } const label = showLabel ? `${level} (${conf}%)` : `${conf}%`; return `${label}`; } } // Initialize admin panel const adminPanel = new AdminPanel();