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>
521 lines
16 KiB
JavaScript
521 lines
16 KiB
JavaScript
/**
|
|
* Unified Inbox Admin Page
|
|
* View and manage all communications: contacts, media inquiries, case submissions
|
|
*/
|
|
|
|
let allItems = [];
|
|
let currentItem = null;
|
|
|
|
// Initialize page
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
await loadStats();
|
|
await loadInbox();
|
|
setupEventListeners();
|
|
});
|
|
|
|
/**
|
|
* Load statistics
|
|
*/
|
|
async function loadStats() {
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
const response = await fetch('/api/inbox/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.all;
|
|
document.getElementById('stat-new').textContent = stats.total.new;
|
|
document.getElementById('stat-assigned').textContent = stats.total.assigned;
|
|
document.getElementById('stat-responded').textContent = stats.total.responded;
|
|
|
|
} catch (error) {
|
|
console.error('Error loading stats:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load inbox items
|
|
*/
|
|
async function loadInbox() {
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
const type = document.getElementById('filter-type').value;
|
|
const status = document.getElementById('filter-status').value;
|
|
const priority = document.getElementById('filter-priority').value;
|
|
|
|
let url = '/api/inbox?limit=50';
|
|
if (type && type !== 'all') url += `&type=${type}`;
|
|
if (status) url += `&status=${status}`;
|
|
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to load inbox');
|
|
|
|
const data = await response.json();
|
|
allItems = data.items;
|
|
|
|
// Apply client-side priority filter if needed
|
|
if (priority) {
|
|
allItems = allItems.filter(item => item._priority === priority);
|
|
}
|
|
|
|
renderInbox();
|
|
|
|
} catch (error) {
|
|
console.error('Error loading inbox:', error);
|
|
showError('Failed to load inbox');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render inbox items list
|
|
*/
|
|
function renderInbox() {
|
|
const container = document.getElementById('inbox-container');
|
|
|
|
if (allItems.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="px-6 py-12 text-center text-gray-500">
|
|
<p>No items found matching the selected filters.</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = allItems.map(item => renderInboxItem(item)).join('');
|
|
|
|
// Add click listeners
|
|
container.querySelectorAll('[data-item-id]').forEach(el => {
|
|
el.addEventListener('click', () => {
|
|
const itemId = el.getAttribute('data-item-id');
|
|
const itemType = el.getAttribute('data-item-type');
|
|
showItemDetail(itemId, itemType);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Render single inbox item
|
|
*/
|
|
function renderInboxItem(item) {
|
|
const typeBadge = getTypeBadge(item._type);
|
|
const statusBadge = getStatusBadge(item._displayStatus);
|
|
const priorityBadge = getPriorityBadge(item._priority);
|
|
|
|
// Extract display data based on type
|
|
let title, subtitle, preview;
|
|
|
|
if (item._type === 'contact') {
|
|
title = item.contact.name;
|
|
subtitle = item.contact.email;
|
|
preview = item.inquiry.subject || item.inquiry.message;
|
|
} else if (item._type === 'media') {
|
|
title = item.contact.name;
|
|
subtitle = `${item.contact.outlet} • ${item.contact.email}`;
|
|
preview = item.inquiry.subject;
|
|
} else if (item._type === 'case') {
|
|
title = item.submitter.name;
|
|
subtitle = item.submitter.email;
|
|
preview = item.case_study.title;
|
|
}
|
|
|
|
return `
|
|
<div class="px-6 py-4 hover:bg-gray-50 cursor-pointer" data-item-id="${item._id}" data-item-type="${item._type}">
|
|
<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(title)}</h3>
|
|
${typeBadge}
|
|
${priorityBadge}
|
|
${statusBadge}
|
|
</div>
|
|
<p class="text-sm text-gray-600 mb-2">${escapeHtml(subtitle)}</p>
|
|
<p class="text-sm text-gray-700 font-medium line-clamp-2">${escapeHtml(preview)}</p>
|
|
</div>
|
|
<div class="text-right text-sm text-gray-500 ml-4">
|
|
<div>${formatDate(item._created)}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Show item detail modal
|
|
*/
|
|
async function showItemDetail(itemId, itemType) {
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
let endpoint;
|
|
|
|
if (itemType === 'contact') {
|
|
endpoint = `/api/contact/admin/${itemId}`;
|
|
} else if (itemType === 'media') {
|
|
endpoint = `/api/media/inquiries/${itemId}`;
|
|
} else if (itemType === 'case') {
|
|
endpoint = `/api/cases/submissions/${itemId}`;
|
|
}
|
|
|
|
const response = await fetch(endpoint, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to load item');
|
|
|
|
const data = await response.json();
|
|
currentItem = { ...data.contact || data.inquiry || data.submission, _type: itemType };
|
|
|
|
renderItemDetail(currentItem);
|
|
|
|
document.getElementById('item-detail-modal').classList.remove('hidden');
|
|
|
|
} catch (error) {
|
|
console.error('Error loading item detail:', error);
|
|
alert('Failed to load item details');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render item detail based on type
|
|
*/
|
|
function renderItemDetail(item) {
|
|
const content = document.getElementById('item-detail-content');
|
|
|
|
if (item._type === 'contact') {
|
|
content.innerHTML = renderContactDetail(item);
|
|
} else if (item._type === 'media') {
|
|
content.innerHTML = renderMediaDetail(item);
|
|
} else if (item._type === 'case') {
|
|
content.innerHTML = renderCaseDetail(item);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render contact detail
|
|
*/
|
|
function renderContactDetail(contact) {
|
|
return `
|
|
<div class="space-y-6">
|
|
<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>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Inquiry</h3>
|
|
<div class="space-y-3">
|
|
${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>
|
|
|
|
<div class="flex gap-3 pt-4 border-t">
|
|
<a href="/admin/contact-management.html" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
|
Manage in Contact Manager
|
|
</a>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Render media inquiry detail
|
|
*/
|
|
function renderMediaDetail(media) {
|
|
return `
|
|
<div class="space-y-6">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Media Contact</h3>
|
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
|
<div>
|
|
<span class="text-gray-500">Name:</span>
|
|
<div class="font-medium">${escapeHtml(media.contact.name)}</div>
|
|
</div>
|
|
<div>
|
|
<span class="text-gray-500">Email:</span>
|
|
<div class="font-medium"><a href="mailto:${escapeHtml(media.contact.email)}" class="text-blue-600 hover:underline">${escapeHtml(media.contact.email)}</a></div>
|
|
</div>
|
|
<div>
|
|
<span class="text-gray-500">Outlet:</span>
|
|
<div class="font-medium">${escapeHtml(media.contact.outlet)}</div>
|
|
</div>
|
|
${media.contact.phone ? `
|
|
<div>
|
|
<span class="text-gray-500">Phone:</span>
|
|
<div class="font-medium">${escapeHtml(media.contact.phone)}</div>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Inquiry</h3>
|
|
<div class="space-y-3">
|
|
<div>
|
|
<span class="text-sm text-gray-500">Subject:</span>
|
|
<div class="font-medium">${escapeHtml(media.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(media.inquiry.message)}</div>
|
|
</div>
|
|
${media.inquiry.deadline ? `
|
|
<div>
|
|
<span class="text-sm text-gray-500">Deadline:</span>
|
|
<div class="font-medium text-red-600">${formatDate(media.inquiry.deadline)}</div>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
${media.ai_triage ? `
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">AI Triage</h3>
|
|
<div class="space-y-2 text-sm">
|
|
<div><span class="text-gray-500">Urgency:</span> ${getUrgencyBadge(media.ai_triage.urgency)}</div>
|
|
${media.ai_triage.claude_summary ? `
|
|
<div>
|
|
<span class="text-gray-500">Summary:</span>
|
|
<div class="mt-1 p-3 bg-blue-50 rounded border border-blue-200 text-sm">${escapeHtml(media.ai_triage.claude_summary)}</div>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="flex gap-3 pt-4 border-t">
|
|
<a href="/admin/media-triage.html" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
|
Manage in Media Triage
|
|
</a>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Render case submission detail
|
|
*/
|
|
function renderCaseDetail(caseItem) {
|
|
return `
|
|
<div class="space-y-6">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Submitter</h3>
|
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
|
<div>
|
|
<span class="text-gray-500">Name:</span>
|
|
<div class="font-medium">${escapeHtml(caseItem.submitter.name)}</div>
|
|
</div>
|
|
<div>
|
|
<span class="text-gray-500">Email:</span>
|
|
<div class="font-medium"><a href="mailto:${escapeHtml(caseItem.submitter.email)}" class="text-blue-600 hover:underline">${escapeHtml(caseItem.submitter.email)}</a></div>
|
|
</div>
|
|
${caseItem.submitter.organization ? `
|
|
<div>
|
|
<span class="text-gray-500">Organization:</span>
|
|
<div class="font-medium">${escapeHtml(caseItem.submitter.organization)}</div>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Case Study</h3>
|
|
<div class="space-y-3">
|
|
<div>
|
|
<span class="text-sm text-gray-500">Title:</span>
|
|
<div class="font-medium">${escapeHtml(caseItem.case_study.title)}</div>
|
|
</div>
|
|
<div>
|
|
<span class="text-sm text-gray-500">Description:</span>
|
|
<div class="mt-1 p-4 bg-gray-50 rounded border border-gray-200 whitespace-pre-wrap">${escapeHtml(caseItem.case_study.description)}</div>
|
|
</div>
|
|
<div>
|
|
<span class="text-sm text-gray-500">Failure Mode:</span>
|
|
<div class="font-medium">${escapeHtml(caseItem.case_study.failure_mode)}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
${caseItem.ai_review && caseItem.ai_review.relevance_score ? `
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">AI Review</h3>
|
|
<div class="space-y-2 text-sm">
|
|
<div><span class="text-gray-500">Relevance Score:</span> ${Math.round(caseItem.ai_review.relevance_score * 100)}%</div>
|
|
${caseItem.ai_review.recommended_category ? `
|
|
<div><span class="text-gray-500">Recommended Category:</span> ${escapeHtml(caseItem.ai_review.recommended_category)}</div>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="flex gap-3 pt-4 border-t">
|
|
<a href="/admin/case-moderation.html" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
|
Manage in Case Moderation
|
|
</a>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Setup event listeners
|
|
*/
|
|
function setupEventListeners() {
|
|
document.getElementById('filter-type').addEventListener('change', loadInbox);
|
|
document.getElementById('filter-status').addEventListener('change', loadInbox);
|
|
document.getElementById('filter-priority').addEventListener('change', loadInbox);
|
|
document.getElementById('refresh-btn').addEventListener('click', () => {
|
|
loadStats();
|
|
loadInbox();
|
|
});
|
|
|
|
document.getElementById('close-detail-modal').addEventListener('click', closeDetailModal);
|
|
document.getElementById('close-detail-btn').addEventListener('click', closeDetailModal);
|
|
|
|
document.getElementById('item-detail-modal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'item-detail-modal') {
|
|
closeDetailModal();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Close detail modal
|
|
*/
|
|
function closeDetailModal() {
|
|
document.getElementById('item-detail-modal').classList.add('hidden');
|
|
currentItem = null;
|
|
}
|
|
|
|
/**
|
|
* Utility: Get type badge
|
|
*/
|
|
function getTypeBadge(type) {
|
|
const colors = {
|
|
contact: 'bg-blue-100 text-blue-800',
|
|
media: 'bg-purple-100 text-purple-800',
|
|
case: 'bg-green-100 text-green-800'
|
|
};
|
|
const labels = {
|
|
contact: '📬 Contact',
|
|
media: '📰 Media',
|
|
case: '📋 Case'
|
|
};
|
|
return `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[type] || colors.contact}">${labels[type] || 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',
|
|
medium: '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',
|
|
pending: 'bg-orange-100 text-orange-800',
|
|
assigned: 'bg-blue-100 text-blue-800',
|
|
triaged: 'bg-blue-100 text-blue-800',
|
|
responded: 'bg-green-100 text-green-800',
|
|
closed: 'bg-gray-100 text-gray-600',
|
|
approved: 'bg-green-100 text-green-800',
|
|
rejected: 'bg-red-100 text-red-800',
|
|
needs_info: 'bg-yellow-100 text-yellow-800'
|
|
};
|
|
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: Get urgency badge
|
|
*/
|
|
function getUrgencyBadge(urgency) {
|
|
const colors = {
|
|
low: 'bg-gray-100 text-gray-800',
|
|
medium: '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[urgency] || colors.medium}">${urgency}</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('inbox-container');
|
|
container.innerHTML = `
|
|
<div class="px-6 py-12 text-center">
|
|
<p class="text-red-600">${escapeHtml(message)}</p>
|
|
</div>
|
|
`;
|
|
}
|