tractatus/public/js/admin/unified-inbox.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

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