| 1 |
|
| 2 |
* GoingsOn - Contact Dashboard Module |
| 3 |
* |
| 4 |
* Full-page dashboard for a contact, showing linked tasks, events, and emails |
| 5 |
* as a unified activity timeline. Modeled after the task overview page. |
| 6 |
|
| 7 |
|
| 8 |
(function() { |
| 9 |
'use strict'; |
| 10 |
const esc = GoingsOn.utils.escapeHtml; |
| 11 |
const escAttr = GoingsOn.utils.escapeAttr; |
| 12 |
|
| 13 |
let currentContactId = null; |
| 14 |
|
| 15 |
|
| 16 |
* Open the contact dashboard for a given contact. |
| 17 |
* @param {string} contactId |
| 18 |
|
| 19 |
async function open(contactId) { |
| 20 |
currentContactId = contactId; |
| 21 |
GoingsOn.navigation.switchView('contact-dashboard'); |
| 22 |
|
| 23 |
const content = document.getElementById('contact-dashboard-content'); |
| 24 |
const titleEl = document.getElementById('contact-dashboard-title'); |
| 25 |
const actionsEl = document.getElementById('contact-dashboard-actions'); |
| 26 |
|
| 27 |
try { |
| 28 |
const [contact, tasks, events, emails] = await Promise.all([ |
| 29 |
GoingsOn.api.contacts.get(contactId), |
| 30 |
GoingsOn.api.contacts.listTasksForContact(contactId), |
| 31 |
GoingsOn.api.contacts.listEventsForContact(contactId), |
| 32 |
GoingsOn.api.contacts.listEmailsForContact(contactId), |
| 33 |
]); |
| 34 |
|
| 35 |
titleEl.textContent = contact.displayName || contact.display_name; |
| 36 |
render(content, actionsEl, contact, tasks, events, emails); |
| 37 |
} catch (err) { |
| 38 |
content.innerHTML = `<p class="text-danger">Failed to load contact: ${esc(GoingsOn.utils.getErrorMessage(err))}</p>`; |
| 39 |
} |
| 40 |
} |
| 41 |
|
| 42 |
function close() { |
| 43 |
currentContactId = null; |
| 44 |
GoingsOn.navigation.switchView('contacts'); |
| 45 |
} |
| 46 |
|
| 47 |
function render(container, actionsEl, contact, tasks, events, emails) { |
| 48 |
const name = contact.displayName || contact.display_name; |
| 49 |
const initials = contact.initials || name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase(); |
| 50 |
|
| 51 |
|
| 52 |
if (contact.isImplicit) { |
| 53 |
actionsEl.innerHTML = ` |
| 54 |
<button class="btn btn-primary" onclick="GoingsOn.contactDashboard.promote('${escAttr(contact.id)}')">Save as Contact</button> |
| 55 |
`; |
| 56 |
} else { |
| 57 |
actionsEl.innerHTML = ` |
| 58 |
<button class="btn btn-secondary" onclick="GoingsOn.contacts.openEdit('${escAttr(contact.id)}')">Edit</button> |
| 59 |
<button class="btn btn-secondary text-accent-red" onclick="GoingsOn.contacts.delete('${escAttr(contact.id)}')">Delete</button> |
| 60 |
`; |
| 61 |
} |
| 62 |
|
| 63 |
|
| 64 |
const company = contact.company ? `<span class="text-secondary">${esc(contact.company)}</span>` : ''; |
| 65 |
const title = contact.title ? `<span class="text-secondary">${esc(contact.title)}</span>` : ''; |
| 66 |
const companyTitle = [company, title].filter(Boolean).join(' · '); |
| 67 |
|
| 68 |
const tags = (contact.tags || []).map(t => `<span class="tag">${esc(t)}</span>`).join(' '); |
| 69 |
|
| 70 |
let headerHtml = ` |
| 71 |
<div class="contact-header-card"> |
| 72 |
<div class="avatar avatar--lg">${esc(initials)}</div> |
| 73 |
<div class="contact-header-info"> |
| 74 |
<h3 style="margin: 0;">${esc(name)}</h3> |
| 75 |
${companyTitle ? `<div style="margin-top: 0.25rem;">${companyTitle}</div>` : ''} |
| 76 |
${tags ? `<div style="margin-top: 0.5rem;">${tags}</div>` : ''} |
| 77 |
</div> |
| 78 |
</div> |
| 79 |
`; |
| 80 |
|
| 81 |
|
| 82 |
let infoHtml = ''; |
| 83 |
const infoItems = []; |
| 84 |
for (const e of contact.emails || []) { |
| 85 |
infoItems.push(`<span class="text-secondary">Email:</span> ${esc(e.address)}${e.label ? ' <span class="tag">' + esc(e.label) + '</span>' : ''}`); |
| 86 |
} |
| 87 |
for (const p of contact.phones || []) { |
| 88 |
infoItems.push(`<span class="text-secondary">Phone:</span> ${esc(p.number)}${p.label ? ' <span class="tag">' + esc(p.label) + '</span>' : ''}`); |
| 89 |
} |
| 90 |
for (const s of contact.socialHandles || contact.social_handles || []) { |
| 91 |
infoItems.push(`<span class="text-secondary">${esc(s.platform)}:</span> ${esc(s.handle)}`); |
| 92 |
} |
| 93 |
if (infoItems.length > 0) { |
| 94 |
infoHtml = `<div class="card card--muted contact-info-section">${infoItems.map(i => `<div class="contact-info-item">${i}</div>`).join('')}</div>`; |
| 95 |
} |
| 96 |
|
| 97 |
|
| 98 |
const timeline = []; |
| 99 |
for (const t of tasks) { |
| 100 |
timeline.push({ |
| 101 |
type: 'task', |
| 102 |
date: new Date(t.createdAt || t.created_at), |
| 103 |
title: t.description, |
| 104 |
status: t.statusDisplay || t.status, |
| 105 |
id: t.id, |
| 106 |
}); |
| 107 |
} |
| 108 |
for (const e of events) { |
| 109 |
timeline.push({ |
| 110 |
type: 'event', |
| 111 |
date: new Date(e.startTime || e.start_time), |
| 112 |
title: e.displayTitle || e.title, |
| 113 |
id: e.id, |
| 114 |
}); |
| 115 |
} |
| 116 |
for (const e of emails) { |
| 117 |
timeline.push({ |
| 118 |
type: 'email', |
| 119 |
date: new Date(e.receivedAt || e.received_at), |
| 120 |
title: e.subject || '(no subject)', |
| 121 |
from: e.from, |
| 122 |
isOutgoing: e.isOutgoing || e.is_outgoing || false, |
| 123 |
id: e.id, |
| 124 |
}); |
| 125 |
} |
| 126 |
timeline.sort((a, b) => b.date - a.date); |
| 127 |
|
| 128 |
const INITIAL_SHOW = 20; |
| 129 |
const totalCount = timeline.length; |
| 130 |
const visibleTimeline = timeline.slice(0, INITIAL_SHOW); |
| 131 |
|
| 132 |
let timelineHtml = ''; |
| 133 |
if (totalCount === 0) { |
| 134 |
timelineHtml = '<p class="empty-italic">No interactions yet.</p>'; |
| 135 |
} else { |
| 136 |
const items = visibleTimeline.map(item => { |
| 137 |
const dateStr = item.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); |
| 138 |
const icon = item.type === 'task' ? '☑' : item.type === 'event' ? '📅' : (item.isOutgoing ? '✉︎→' : '←✉︎'); |
| 139 |
const badge = item.type === 'task' && item.status ? `<span class="tag status-${(item.status || '').toLowerCase()}">${esc(item.status)}</span>` : ''; |
| 140 |
|
| 141 |
let onclick = ''; |
| 142 |
if (item.type === 'task') onclick = `GoingsOn.taskOverview.open('${escAttr(item.id)}')`; |
| 143 |
else if (item.type === 'event') onclick = `GoingsOn.events.open('${escAttr(item.id)}')`; |
| 144 |
else if (item.type === 'email') onclick = `GoingsOn.emails.open('${escAttr(item.id)}')`; |
| 145 |
|
| 146 |
return ` |
| 147 |
<div class="contact-timeline-item row-flex row-flex-3" onclick="${onclick}" role="button" tabindex="0"> |
| 148 |
<span class="contact-timeline-icon">${icon}</span> |
| 149 |
<span class="contact-timeline-title">${esc(item.title)}</span> |
| 150 |
${badge} |
| 151 |
<span class="contact-timeline-date">${dateStr}</span> |
| 152 |
</div> |
| 153 |
`; |
| 154 |
}).join(''); |
| 155 |
|
| 156 |
const showAllBtn = totalCount > INITIAL_SHOW |
| 157 |
? `<button class="btn btn-secondary btn-sm" id="contact-timeline-show-all" onclick="GoingsOn.contactDashboard.showAllTimeline()">Show all ${totalCount} items</button>` |
| 158 |
: ''; |
| 159 |
|
| 160 |
timelineHtml = ` |
| 161 |
<div class="contact-timeline" id="contact-timeline">${items}</div> |
| 162 |
${showAllBtn} |
| 163 |
`; |
| 164 |
} |
| 165 |
|
| 166 |
|
| 167 |
const notesHtml = contact.notes |
| 168 |
? `<div class="settings-section"><h3 class="settings-heading">Notes</h3><p style="white-space: pre-wrap;">${esc(contact.notes)}</p></div>` |
| 169 |
: ''; |
| 170 |
|
| 171 |
|
| 172 |
const taskCount = tasks.length; |
| 173 |
const eventCount = events.length; |
| 174 |
const emailCount = emails.length; |
| 175 |
|
| 176 |
const summaryHtml = ` |
| 177 |
<div class="contact-dashboard-summary"> |
| 178 |
<div class="contact-summary-stat"> |
| 179 |
<span class="contact-summary-count">${taskCount}</span> |
| 180 |
<span class="contact-summary-label">Tasks</span> |
| 181 |
</div> |
| 182 |
<div class="contact-summary-stat"> |
| 183 |
<span class="contact-summary-count">${eventCount}</span> |
| 184 |
<span class="contact-summary-label">Events</span> |
| 185 |
</div> |
| 186 |
<div class="contact-summary-stat"> |
| 187 |
<span class="contact-summary-count">${emailCount}</span> |
| 188 |
<span class="contact-summary-label">Emails</span> |
| 189 |
</div> |
| 190 |
</div> |
| 191 |
`; |
| 192 |
|
| 193 |
container.innerHTML = headerHtml + infoHtml + summaryHtml + ` |
| 194 |
<div class="settings-section"> |
| 195 |
<h3 class="settings-heading">Activity</h3> |
| 196 |
${timelineHtml} |
| 197 |
</div> |
| 198 |
` + notesHtml; |
| 199 |
|
| 200 |
|
| 201 |
container._fullTimeline = timeline; |
| 202 |
} |
| 203 |
|
| 204 |
function showAllTimeline() { |
| 205 |
const container = document.getElementById('contact-dashboard-content'); |
| 206 |
const timeline = container?._fullTimeline; |
| 207 |
if (!timeline) return; |
| 208 |
|
| 209 |
const timelineEl = document.getElementById('contact-timeline'); |
| 210 |
const showAllBtn = document.getElementById('contact-timeline-show-all'); |
| 211 |
if (showAllBtn) showAllBtn.remove(); |
| 212 |
|
| 213 |
|
| 214 |
timelineEl.innerHTML = timeline.map(item => { |
| 215 |
const dateStr = item.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); |
| 216 |
const icon = item.type === 'task' ? '☑' : item.type === 'event' ? '📅' : (item.isOutgoing ? '✉︎→' : '←✉︎'); |
| 217 |
const badge = item.type === 'task' && item.status ? `<span class="tag status-${(item.status || '').toLowerCase()}">${esc(item.status)}</span>` : ''; |
| 218 |
|
| 219 |
let onclick = ''; |
| 220 |
if (item.type === 'task') onclick = `GoingsOn.taskOverview.open('${escAttr(item.id)}')`; |
| 221 |
else if (item.type === 'event') onclick = `GoingsOn.events.open('${escAttr(item.id)}')`; |
| 222 |
else if (item.type === 'email') onclick = `GoingsOn.emails.open('${escAttr(item.id)}')`; |
| 223 |
|
| 224 |
return ` |
| 225 |
<div class="contact-timeline-item row-flex row-flex-3" onclick="${onclick}" role="button" tabindex="0"> |
| 226 |
<span class="contact-timeline-icon">${icon}</span> |
| 227 |
<span class="contact-timeline-title">${esc(item.title)}</span> |
| 228 |
${badge} |
| 229 |
<span class="contact-timeline-date">${dateStr}</span> |
| 230 |
</div> |
| 231 |
`; |
| 232 |
}).join(''); |
| 233 |
} |
| 234 |
|
| 235 |
async function promote(contactId) { |
| 236 |
try { |
| 237 |
await GoingsOn.api.contacts.promoteContact(contactId); |
| 238 |
GoingsOn.cache.invalidate('contacts'); |
| 239 |
GoingsOn.autocomplete.refresh(); |
| 240 |
GoingsOn.ui.showToast('Contact saved!', 'success'); |
| 241 |
open(contactId); |
| 242 |
} catch (err) { |
| 243 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save contact'), 'error'); |
| 244 |
} |
| 245 |
} |
| 246 |
|
| 247 |
GoingsOn.contactDashboard = { open, close, promote, showAllTimeline }; |
| 248 |
})(); |
| 249 |
|