Phase 3 Multi-Project CRM Implementation:
- Add UnifiedContact model for cross-project contact linking
- Add Organization model with domain-based auto-detection
- Add ActivityTimeline model for comprehensive interaction tracking
- Add SLATracking model for 24-hour response commitment
- Add ResponseTemplate model with variable substitution
- Add CRM controller with 8 API endpoints
- Add Inbox controller for unified communications
- Add CRM dashboard frontend with tabs (Contacts, Orgs, SLA, Templates)
- Add Contact Management interface (Phase 1)
- Add Unified Inbox interface (Phase 2)
- Integrate CRM routes into main API
Critical Bug Fixes:
- Fix newsletter DELETE button (event handler context issue)
- Fix case submission invisible button (invalid CSS class)
- Fix Chart.js CSP violation (add cdn.jsdelivr.net to policy)
- Fix Chart.js SRI integrity hash mismatch
Technical Details:
- Email-based contact deduplication across projects
- Automatic organization linking via email domain
- Cross-project activity timeline aggregation
- SLA breach detection and alerting system
- Template rendering with {placeholder} substitution
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
447 lines
14 KiB
JavaScript
447 lines
14 KiB
JavaScript
/**
|
|
* Contact Management Admin Page
|
|
* View and manage contact form submissions
|
|
*/
|
|
|
|
let allContacts = [];
|
|
let currentContact = null;
|
|
|
|
// Initialize page
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
await loadStats();
|
|
await loadContacts();
|
|
setupEventListeners();
|
|
});
|
|
|
|
/**
|
|
* Load statistics
|
|
*/
|
|
async function loadStats() {
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
const response = await fetch('/api/contact/admin/stats', {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to load stats');
|
|
|
|
const data = await response.json();
|
|
const stats = data.stats;
|
|
|
|
document.getElementById('stat-total').textContent = stats.total;
|
|
document.getElementById('stat-new').textContent = stats.by_status.new;
|
|
document.getElementById('stat-assigned').textContent = stats.by_status.assigned;
|
|
document.getElementById('stat-responded').textContent = stats.by_status.responded;
|
|
|
|
} catch (error) {
|
|
console.error('Error loading stats:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load contacts
|
|
*/
|
|
async function loadContacts() {
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
const status = document.getElementById('filter-status').value;
|
|
const type = document.getElementById('filter-type').value;
|
|
const priority = document.getElementById('filter-priority').value;
|
|
|
|
let url = '/api/contact/admin/list?limit=50';
|
|
if (status) url += `&status=${status}`;
|
|
if (type) url += `&type=${type}`;
|
|
if (priority) url += `&priority=${priority}`;
|
|
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to load contacts');
|
|
|
|
const data = await response.json();
|
|
allContacts = data.contacts;
|
|
|
|
renderContacts();
|
|
|
|
} catch (error) {
|
|
console.error('Error loading contacts:', error);
|
|
showError('Failed to load contacts');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render contacts list
|
|
*/
|
|
function renderContacts() {
|
|
const container = document.getElementById('contacts-container');
|
|
|
|
if (allContacts.length === 0) {
|
|
container.innerHTML = \`
|
|
<div class="px-6 py-12 text-center text-gray-500">
|
|
<p>No contacts found matching the selected filters.</p>
|
|
</div>
|
|
\`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = allContacts.map(contact => \`
|
|
<div class="px-6 py-4 hover:bg-gray-50 cursor-pointer" data-contact-id="\${contact._id}">
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex-1">
|
|
<div class="flex items-center gap-3 mb-2">
|
|
<h3 class="font-semibold text-gray-900">\${escapeHtml(contact.contact.name)}</h3>
|
|
\${getTypeBadge(contact.type)}
|
|
\${getPriorityBadge(contact.priority)}
|
|
\${getStatusBadge(contact.status)}
|
|
</div>
|
|
<p class="text-sm text-gray-600 mb-2">
|
|
<strong>Email:</strong> \${escapeHtml(contact.contact.email)}
|
|
\${contact.contact.organization ? \` | <strong>Org:</strong> \${escapeHtml(contact.contact.organization)}\` : ''}
|
|
</p>
|
|
\${contact.inquiry.subject ? \`<p class="text-sm text-gray-700 font-medium mb-1">\${escapeHtml(contact.inquiry.subject)}</p>\` : ''}
|
|
<p class="text-sm text-gray-600 line-clamp-2">\${escapeHtml(contact.inquiry.message)}</p>
|
|
</div>
|
|
<div class="text-right text-sm text-gray-500 ml-4">
|
|
<div>\${formatDate(contact.created_at)}</div>
|
|
\${contact.response.sent_at ? \`<div class="text-green-600 text-xs mt-1">Responded \${formatDate(contact.response.sent_at)}</div>\` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
\`).join('');
|
|
|
|
// Add click listeners
|
|
container.querySelectorAll('[data-contact-id]').forEach(el => {
|
|
el.addEventListener('click', () => {
|
|
const contactId = el.getAttribute('data-contact-id');
|
|
showContactDetail(contactId);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ... rest of file continues in next command
|
|
|
|
/**
|
|
* Show contact detail modal
|
|
*/
|
|
async function showContactDetail(contactId) {
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
const response = await fetch(\`/api/contact/admin/\${contactId}\`, {
|
|
headers: {
|
|
'Authorization': \`Bearer \${token}\`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to load contact');
|
|
|
|
const data = await response.json();
|
|
currentContact = data.contact;
|
|
|
|
renderContactDetail(currentContact);
|
|
|
|
document.getElementById('contact-detail-modal').classList.remove('hidden');
|
|
|
|
} catch (error) {
|
|
console.error('Error loading contact detail:', error);
|
|
alert('Failed to load contact details');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render contact detail
|
|
*/
|
|
function renderContactDetail(contact) {
|
|
const content = document.getElementById('contact-detail-content');
|
|
|
|
content.innerHTML = \`
|
|
<div class="space-y-6">
|
|
<!-- Contact Info -->
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Contact Information</h3>
|
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
|
<div>
|
|
<span class="text-gray-500">Name:</span>
|
|
<div class="font-medium">\${escapeHtml(contact.contact.name)}</div>
|
|
</div>
|
|
<div>
|
|
<span class="text-gray-500">Email:</span>
|
|
<div class="font-medium"><a href="mailto:\${escapeHtml(contact.contact.email)}" class="text-blue-600 hover:underline">\${escapeHtml(contact.contact.email)}</a></div>
|
|
</div>
|
|
\${contact.contact.organization ? \`
|
|
<div>
|
|
<span class="text-gray-500">Organization:</span>
|
|
<div class="font-medium">\${escapeHtml(contact.contact.organization)}</div>
|
|
</div>
|
|
\` : ''}
|
|
\${contact.contact.phone ? \`
|
|
<div>
|
|
<span class="text-gray-500">Phone:</span>
|
|
<div class="font-medium">\${escapeHtml(contact.contact.phone)}</div>
|
|
</div>
|
|
\` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Inquiry Details -->
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Inquiry</h3>
|
|
<div class="space-y-3">
|
|
<div class="flex gap-2">
|
|
\${getTypeBadge(contact.type)}
|
|
\${getPriorityBadge(contact.priority)}
|
|
\${getStatusBadge(contact.status)}
|
|
</div>
|
|
\${contact.inquiry.subject ? \`
|
|
<div>
|
|
<span class="text-sm text-gray-500">Subject:</span>
|
|
<div class="font-medium">\${escapeHtml(contact.inquiry.subject)}</div>
|
|
</div>
|
|
\` : ''}
|
|
<div>
|
|
<span class="text-sm text-gray-500">Message:</span>
|
|
<div class="mt-1 p-4 bg-gray-50 rounded border border-gray-200 whitespace-pre-wrap">\${escapeHtml(contact.inquiry.message)}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Metadata -->
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Metadata</h3>
|
|
<div class="text-sm space-y-2">
|
|
<div><span class="text-gray-500">Submitted:</span> \${formatDate(contact.created_at)}</div>
|
|
<div><span class="text-gray-500">Source:</span> \${contact.source}</div>
|
|
\${contact.metadata.source_page ? \`<div><span class="text-gray-500">Source Page:</span> \${contact.metadata.source_page}</div>\` : ''}
|
|
\${contact.metadata.ip ? \`<div><span class="text-gray-500">IP:</span> \${contact.metadata.ip}</div>\` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Response Info -->
|
|
\${contact.response.sent_at ? \`
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Response</h3>
|
|
<div class="text-sm space-y-2">
|
|
<div><span class="text-gray-500">Responded:</span> \${formatDate(contact.response.sent_at)}</div>
|
|
\${contact.response.content ? \`<div class="mt-2 p-4 bg-green-50 rounded border border-green-200 whitespace-pre-wrap">\${escapeHtml(contact.response.content)}</div>\` : ''}
|
|
</div>
|
|
</div>
|
|
\` : ''}
|
|
|
|
<!-- Actions -->
|
|
<div class="flex gap-3">
|
|
\${contact.status === 'new' ? \`
|
|
<button onclick="updateContactStatus('\${contact._id}', 'assigned')" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
|
Mark as Assigned
|
|
</button>
|
|
\` : ''}
|
|
\${contact.status === 'assigned' ? \`
|
|
<button onclick="markAsResponded('\${contact._id}')" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
|
|
Mark as Responded
|
|
</button>
|
|
\` : ''}
|
|
\${contact.status !== 'closed' ? \`
|
|
<button onclick="updateContactStatus('\${contact._id}', 'closed')" class="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700">
|
|
Close
|
|
</button>
|
|
\` : ''}
|
|
<button onclick="deleteContact('\${contact._id}')" class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
\`;
|
|
}
|
|
|
|
/**
|
|
* Update contact status
|
|
*/
|
|
async function updateContactStatus(contactId, newStatus) {
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
const response = await fetch(\`/api/contact/admin/\${contactId}\`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': \`Bearer \${token}\`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ status: newStatus })
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to update status');
|
|
|
|
await loadStats();
|
|
await loadContacts();
|
|
closeDetailModal();
|
|
|
|
} catch (error) {
|
|
console.error('Error updating status:', error);
|
|
alert('Failed to update status');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mark as responded
|
|
*/
|
|
async function markAsResponded(contactId) {
|
|
const responseContent = prompt('Enter response summary (optional):');
|
|
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
const response = await fetch(\`/api/contact/admin/\${contactId}/respond\`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': \`Bearer \${token}\`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ content: responseContent || 'Responded via email' })
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to mark as responded');
|
|
|
|
await loadStats();
|
|
await loadContacts();
|
|
closeDetailModal();
|
|
|
|
} catch (error) {
|
|
console.error('Error marking as responded:', error);
|
|
alert('Failed to mark as responded');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete contact
|
|
*/
|
|
async function deleteContact(contactId) {
|
|
if (!confirm('Are you sure you want to delete this contact? This cannot be undone.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
const response = await fetch(\`/api/contact/admin/\${contactId}\`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': \`Bearer \${token}\`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to delete contact');
|
|
|
|
await loadStats();
|
|
await loadContacts();
|
|
closeDetailModal();
|
|
|
|
} catch (error) {
|
|
console.error('Error deleting contact:', error);
|
|
alert('Failed to delete contact');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Setup event listeners
|
|
*/
|
|
function setupEventListeners() {
|
|
document.getElementById('filter-status').addEventListener('change', loadContacts);
|
|
document.getElementById('filter-type').addEventListener('change', loadContacts);
|
|
document.getElementById('filter-priority').addEventListener('change', loadContacts);
|
|
document.getElementById('refresh-btn').addEventListener('click', () => {
|
|
loadStats();
|
|
loadContacts();
|
|
});
|
|
|
|
document.getElementById('close-detail-modal').addEventListener('click', closeDetailModal);
|
|
document.getElementById('close-detail-btn').addEventListener('click', closeDetailModal);
|
|
|
|
document.getElementById('contact-detail-modal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'contact-detail-modal') {
|
|
closeDetailModal();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Close detail modal
|
|
*/
|
|
function closeDetailModal() {
|
|
document.getElementById('contact-detail-modal').classList.add('hidden');
|
|
currentContact = null;
|
|
}
|
|
|
|
/**
|
|
* Utility: Get type badge
|
|
*/
|
|
function getTypeBadge(type) {
|
|
const colors = {
|
|
general: 'bg-gray-100 text-gray-800',
|
|
partnership: 'bg-purple-100 text-purple-800',
|
|
technical: 'bg-blue-100 text-blue-800',
|
|
feedback: 'bg-green-100 text-green-800'
|
|
};
|
|
return \`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium \${colors[type] || colors.general}">\${type}</span>\`;
|
|
}
|
|
|
|
/**
|
|
* Utility: Get priority badge
|
|
*/
|
|
function getPriorityBadge(priority) {
|
|
const colors = {
|
|
low: 'bg-gray-100 text-gray-600',
|
|
normal: 'bg-blue-100 text-blue-800',
|
|
high: 'bg-red-100 text-red-800'
|
|
};
|
|
return \`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium \${colors[priority] || colors.normal}">\${priority}</span>\`;
|
|
}
|
|
|
|
/**
|
|
* Utility: Get status badge
|
|
*/
|
|
function getStatusBadge(status) {
|
|
const colors = {
|
|
new: 'bg-orange-100 text-orange-800',
|
|
assigned: 'bg-blue-100 text-blue-800',
|
|
responded: 'bg-green-100 text-green-800',
|
|
closed: 'bg-gray-100 text-gray-600'
|
|
};
|
|
return \`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium \${colors[status] || colors.new}">\${status}</span>\`;
|
|
}
|
|
|
|
/**
|
|
* Utility: Format date
|
|
*/
|
|
function formatDate(dateString) {
|
|
if (!dateString) return 'N/A';
|
|
const date = new Date(dateString);
|
|
return date.toLocaleString('en-NZ', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Utility: Escape HTML
|
|
*/
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
/**
|
|
* Utility: Show error
|
|
*/
|
|
function showError(message) {
|
|
const container = document.getElementById('contacts-container');
|
|
container.innerHTML = \`
|
|
<div class="px-6 py-12 text-center">
|
|
<p class="text-red-600">\${escapeHtml(message)}</p>
|
|
</div>
|
|
\`;
|
|
}
|