1086 lines
37 KiB
JavaScript
1086 lines
37 KiB
JavaScript
// 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 => `
|
|
<div class="request-card">
|
|
<div class="request-card-header">
|
|
<div>
|
|
<h3 class="request-resource-name">${this.escapeHtml(request.current_name)}</h3>
|
|
<span class="request-date">${this.formatDate(request.created_at)}</span>
|
|
</div>
|
|
<span class="status-badge ${request.status}">${request.status}</span>
|
|
</div>
|
|
<div class="request-card-info">
|
|
<div class="request-submitter">
|
|
<strong>From:</strong> ${this.escapeHtml(request.submitter_email)}
|
|
${request.submitter_name ? ` (${this.escapeHtml(request.submitter_name)})` : ''}
|
|
</div>
|
|
<div class="request-changes">
|
|
<strong>Changes:</strong> ${this.getChangesSummary(request)}
|
|
</div>
|
|
</div>
|
|
<div class="request-card-actions">
|
|
<button class="resource-action-btn view-request-btn" data-request-id="${request.id}">View Details</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load requests:', error);
|
|
loading.classList.add('hidden');
|
|
container.innerHTML = '<div class="error-message">Failed to load requests. Please try again.</div>';
|
|
}
|
|
}
|
|
|
|
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 `
|
|
<div class="diff-row">
|
|
<div class="diff-label">${f.label}</div>
|
|
<div class="diff-values">
|
|
<div class="diff-current">
|
|
<span class="diff-tag">Current</span>
|
|
<span class="diff-value">${this.escapeHtml(current) || '<em>Not set</em>'}</span>
|
|
</div>
|
|
<div class="diff-arrow">→</div>
|
|
<div class="diff-proposed">
|
|
<span class="diff-tag">Proposed</span>
|
|
<span class="diff-value">${this.escapeHtml(proposed)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).filter(html => html !== '').join('');
|
|
|
|
modalBody.innerHTML = `
|
|
<div class="request-detail">
|
|
<div class="request-detail-header">
|
|
<h2>Update Request #${request.id}</h2>
|
|
<span class="status-badge ${request.status}">${request.status}</span>
|
|
</div>
|
|
|
|
<div class="modal-section">
|
|
<div class="modal-section-title">Resource</div>
|
|
<div class="modal-section-content">
|
|
<strong>${this.escapeHtml(request.current_name)}</strong>
|
|
${request.current_city ? `<br>${this.escapeHtml(request.current_city)}` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-section">
|
|
<div class="modal-section-title">Submitted By</div>
|
|
<div class="modal-section-content">
|
|
${this.escapeHtml(request.submitter_email)}
|
|
${request.submitter_name ? ` (${this.escapeHtml(request.submitter_name)})` : ''}
|
|
<br><small>${this.formatDate(request.created_at)}</small>
|
|
</div>
|
|
</div>
|
|
|
|
${changesHtml ? `
|
|
<div class="modal-section">
|
|
<div class="modal-section-title">Proposed Changes</div>
|
|
<div class="diff-container">
|
|
${changesHtml}
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
${request.additional_notes ? `
|
|
<div class="modal-section">
|
|
<div class="modal-section-title">Additional Notes</div>
|
|
<div class="modal-section-content">${this.escapeHtml(request.additional_notes)}</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
${request.status === 'pending' ? `
|
|
<div class="modal-section">
|
|
<div class="modal-section-title">Admin Notes (optional)</div>
|
|
<textarea id="adminNotes" class="admin-notes-input" rows="2" placeholder="Add notes about your decision..."></textarea>
|
|
</div>
|
|
|
|
<div class="modal-actions">
|
|
<button class="resource-action-btn reject-btn" data-request-id="${request.id}">Reject</button>
|
|
<button class="resource-action-btn primary approve-btn" data-request-id="${request.id}">Approve & Apply Changes</button>
|
|
</div>
|
|
` : `
|
|
${request.admin_notes ? `
|
|
<div class="modal-section">
|
|
<div class="modal-section-title">Admin Notes</div>
|
|
<div class="modal-section-content">${this.escapeHtml(request.admin_notes)}</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="modal-section">
|
|
<div class="modal-section-title">Reviewed</div>
|
|
<div class="modal-section-content">
|
|
${request.reviewed_at ? this.formatDate(request.reviewed_at) : 'N/A'}
|
|
${request.reviewed_by ? ` by ${this.escapeHtml(request.reviewed_by)}` : ''}
|
|
</div>
|
|
</div>
|
|
`}
|
|
</div>
|
|
`;
|
|
|
|
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 => `
|
|
<div class="request-card">
|
|
<div class="request-card-header">
|
|
<div>
|
|
<h3 class="request-resource-name">${this.escapeHtml(listing.name)}</h3>
|
|
<span class="request-date">${this.formatDate(listing.created_at)}</span>
|
|
</div>
|
|
<span class="status-badge ${listing.status}">${listing.status}</span>
|
|
</div>
|
|
<div class="request-card-info">
|
|
<div class="request-submitter">
|
|
<strong>From:</strong> ${this.escapeHtml(listing.submitter_email)}
|
|
${listing.submitter_name ? ` (${this.escapeHtml(listing.submitter_name)})` : ''}
|
|
</div>
|
|
<div class="request-changes">
|
|
<strong>Type:</strong> ${this.formatType(listing.resource_type)}
|
|
${listing.city ? ` | <strong>City:</strong> ${this.escapeHtml(listing.city)}` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="request-card-actions">
|
|
<button class="resource-action-btn view-listing-btn" data-listing-id="${listing.id}">View Details</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load listings:', error);
|
|
loading.classList.add('hidden');
|
|
container.innerHTML = '<div class="error-message">Failed to load submissions. Please try again.</div>';
|
|
}
|
|
}
|
|
|
|
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 `
|
|
<div class="listing-field">
|
|
<div class="listing-field-label">${f.label}</div>
|
|
<div class="listing-field-value">${displayValue}</div>
|
|
</div>
|
|
`;
|
|
}).filter(html => html !== '').join('');
|
|
|
|
modalBody.innerHTML = `
|
|
<div class="request-detail">
|
|
<div class="request-detail-header">
|
|
<h2>New Listing Submission #${listing.id}</h2>
|
|
<span class="status-badge ${listing.status}">${listing.status}</span>
|
|
</div>
|
|
|
|
<div class="modal-section">
|
|
<div class="modal-section-title">Submitted By</div>
|
|
<div class="modal-section-content">
|
|
${this.escapeHtml(listing.submitter_email)}
|
|
${listing.submitter_name ? ` (${this.escapeHtml(listing.submitter_name)})` : ''}
|
|
<br><small>${this.formatDate(listing.created_at)}</small>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-section">
|
|
<div class="modal-section-title">Listing Details</div>
|
|
<div class="listing-fields">
|
|
${fieldsHtml}
|
|
</div>
|
|
</div>
|
|
|
|
${listing.additional_notes ? `
|
|
<div class="modal-section">
|
|
<div class="modal-section-title">Additional Notes</div>
|
|
<div class="modal-section-content">${this.escapeHtml(listing.additional_notes)}</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
${listing.status === 'pending' ? `
|
|
<div class="modal-section">
|
|
<div class="modal-section-title">Admin Notes (optional)</div>
|
|
<textarea id="listingAdminNotes" class="admin-notes-input" rows="2" placeholder="Add notes about your decision..."></textarea>
|
|
</div>
|
|
|
|
<div class="modal-actions">
|
|
<button class="resource-action-btn reject-listing-btn" data-listing-id="${listing.id}">Reject</button>
|
|
<button class="resource-action-btn primary approve-listing-btn" data-listing-id="${listing.id}">Approve & Publish</button>
|
|
</div>
|
|
` : `
|
|
${listing.admin_notes ? `
|
|
<div class="modal-section">
|
|
<div class="modal-section-title">Admin Notes</div>
|
|
<div class="modal-section-content">${this.escapeHtml(listing.admin_notes)}</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="modal-section">
|
|
<div class="modal-section-title">Reviewed</div>
|
|
<div class="modal-section-content">
|
|
${listing.reviewed_at ? this.formatDate(listing.reviewed_at) : 'N/A'}
|
|
${listing.reviewed_by ? ` by ${this.escapeHtml(listing.reviewed_by)}` : ''}
|
|
${listing.created_resource_id ? `<br>Created Resource ID: ${listing.created_resource_id}` : ''}
|
|
</div>
|
|
</div>
|
|
`}
|
|
</div>
|
|
`;
|
|
|
|
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 => `
|
|
<div class="request-card">
|
|
<div class="request-card-header">
|
|
<div>
|
|
<h3 class="request-resource-name">${this.escapeHtml(resource.name)}</h3>
|
|
<span class="request-date">${this.escapeHtml(resource.city || 'No city')}</span>
|
|
</div>
|
|
${this.getConfidenceBadge(resource.geocode_confidence, true)}
|
|
</div>
|
|
<div class="request-card-info">
|
|
<div class="request-submitter">
|
|
<strong>Address:</strong> ${this.escapeHtml(resource.address) || '<em>Not set</em>'}
|
|
</div>
|
|
<div class="request-changes">
|
|
<strong>Coords:</strong> ${resource.latitude && resource.longitude
|
|
? `${parseFloat(resource.latitude).toFixed(4)}, ${parseFloat(resource.longitude).toFixed(4)}`
|
|
: '<em>Missing</em>'}
|
|
${resource.geocode_provider ? ` | <strong>Provider:</strong> ${this.escapeHtml(resource.geocode_provider)}` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="request-card-actions">
|
|
<button class="resource-action-btn view-geocoding-btn" data-resource-id="${resource.id}">View & Edit</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load resources:', error);
|
|
loading.classList.add('hidden');
|
|
container.innerHTML = '<div class="error-message">Failed to load resources. Please try again.</div>';
|
|
}
|
|
}
|
|
|
|
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 = `
|
|
<div class="request-detail">
|
|
<div class="request-detail-header">
|
|
<h2>${this.escapeHtml(resource.name)}</h2>
|
|
${this.getConfidenceBadge(confidence, true)}
|
|
</div>
|
|
|
|
<div class="modal-section">
|
|
<div class="modal-section-title">Address Information</div>
|
|
<div class="listing-fields">
|
|
<div class="listing-field">
|
|
<div class="listing-field-label">Address</div>
|
|
<div class="listing-field-value">${this.escapeHtml(resource.address) || '<em>Not set</em>'}</div>
|
|
</div>
|
|
<div class="listing-field">
|
|
<div class="listing-field-label">City</div>
|
|
<div class="listing-field-value">${this.escapeHtml(resource.city) || '<em>Not set</em>'}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-section">
|
|
<div class="modal-section-title">Geocoding Details</div>
|
|
<div class="listing-fields">
|
|
<div class="listing-field">
|
|
<div class="listing-field-label">Latitude</div>
|
|
<div class="listing-field-value">${hasCoords ? parseFloat(resource.latitude).toFixed(6) : '<em>Missing</em>'}</div>
|
|
</div>
|
|
<div class="listing-field">
|
|
<div class="listing-field-label">Longitude</div>
|
|
<div class="listing-field-value">${hasCoords ? parseFloat(resource.longitude).toFixed(6) : '<em>Missing</em>'}</div>
|
|
</div>
|
|
<div class="listing-field">
|
|
<div class="listing-field-label">Confidence</div>
|
|
<div class="listing-field-value">${confidence}%</div>
|
|
</div>
|
|
<div class="listing-field">
|
|
<div class="listing-field-label">Provider</div>
|
|
<div class="listing-field-value">${this.escapeHtml(resource.geocode_provider) || '<em>Unknown</em>'}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-actions">
|
|
<button class="resource-action-btn primary regeocode-btn" data-resource-id="${resource.id}">
|
|
Re-geocode This Resource
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 `<span class="status-badge" style="background-color:${color};color:white;">${label}</span>`;
|
|
}
|
|
}
|
|
|
|
// Initialize admin panel
|
|
const adminPanel = new AdminPanel();
|