tractatus/public/js/admin/contact-management.js
TheFlow edb1540631 feat(crm): complete Phase 3 multi-project CRM + critical bug fixes
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>
2025-10-24 18:10:14 +13:00

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>
\`;
}