| 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.escapeAttrValue; |
| 12 |
const escArg = GoingsOn.utils.escapeHandlerArg; |
| 13 |
|
| 14 |
let currentContactId = null; |
| 15 |
|
| 16 |
|
| 17 |
const INITIAL_ACTIVITY_LIMIT = 20; |
| 18 |
|
| 19 |
|
| 20 |
* Open the contact dashboard for a given contact. |
| 21 |
* @param {string} contactId |
| 22 |
|
| 23 |
async function open(contactId) { |
| 24 |
currentContactId = contactId; |
| 25 |
GoingsOn.navigation.switchView('contact-dashboard'); |
| 26 |
|
| 27 |
const content = document.getElementById('contact-dashboard-content'); |
| 28 |
const titleEl = document.getElementById('contact-dashboard-title'); |
| 29 |
const actionsEl = document.getElementById('contact-dashboard-actions'); |
| 30 |
|
| 31 |
try { |
| 32 |
const [contact, activity] = await Promise.all([ |
| 33 |
GoingsOn.api.contacts.get(contactId), |
| 34 |
GoingsOn.api.contacts.getActivity(contactId, INITIAL_ACTIVITY_LIMIT), |
| 35 |
]); |
| 36 |
|
| 37 |
titleEl.textContent = contact.displayName || contact.display_name; |
| 38 |
render(content, actionsEl, contact, activity); |
| 39 |
} catch (err) { |
| 40 |
content.innerHTML = `<p class="text-danger">Failed to load contact: ${esc(GoingsOn.utils.getErrorMessage(err))}</p>`; |
| 41 |
} |
| 42 |
} |
| 43 |
|
| 44 |
function close() { |
| 45 |
currentContactId = null; |
| 46 |
GoingsOn.navigation.switchView('contacts'); |
| 47 |
} |
| 48 |
|
| 49 |
|
| 50 |
* Render one pre-computed activity row. All fields (kind, title, date, |
| 51 |
* status, direction) are supplied by Rust — the JS only maps them to markup. |
| 52 |
* @param {{kind: string, id: string, title: string, dateFormatted: string, status: (string|null), isOutgoing: boolean}} item |
| 53 |
|
| 54 |
function renderTimelineItem(item) { |
| 55 |
const icon = item.kind === 'task' ? '☑' |
| 56 |
: item.kind === 'event' ? '📅' |
| 57 |
: (item.isOutgoing ? '✉︎→' : '←✉︎'); |
| 58 |
const badge = item.kind === 'task' && item.status |
| 59 |
? `<span class="tag status-${(item.status || '').toLowerCase()}">${esc(item.status)}</span>` |
| 60 |
: ''; |
| 61 |
|
| 62 |
let act = ''; |
| 63 |
if (item.kind === 'task') act = 'taskOverview.open'; |
| 64 |
else if (item.kind === 'event') act = 'events.open'; |
| 65 |
else if (item.kind === 'email') act = 'emails.open'; |
| 66 |
|
| 67 |
return ` |
| 68 |
<div class="contact-timeline-item row-flex row-flex-3" ${act ? `data-act="${act}" data-a1="${escAttr(item.id)}"` : ''} role="button" tabindex="0"> |
| 69 |
<span class="contact-timeline-icon">${icon}</span> |
| 70 |
<span class="contact-timeline-title">${esc(item.title)}</span> |
| 71 |
${badge} |
| 72 |
<span class="contact-timeline-date">${esc(item.dateFormatted)}</span> |
| 73 |
</div> |
| 74 |
`; |
| 75 |
} |
| 76 |
|
| 77 |
function render(container, actionsEl, contact, activity) { |
| 78 |
const name = contact.displayName || contact.display_name; |
| 79 |
const initials = contact.initials || name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase(); |
| 80 |
|
| 81 |
|
| 82 |
if (contact.isImplicit) { |
| 83 |
actionsEl.innerHTML = ` |
| 84 |
<button class="btn btn-primary" data-act="contactDashboard.promote" data-a1="${escAttr(contact.id)}">Save as Contact</button> |
| 85 |
`; |
| 86 |
} else { |
| 87 |
actionsEl.innerHTML = ` |
| 88 |
<button class="btn btn-secondary" data-act="contacts.openEdit" data-a1="${escAttr(contact.id)}">Edit</button> |
| 89 |
<button class="btn btn-secondary text-accent-red" data-act="contacts.delete" data-a1="${escAttr(contact.id)}">Delete</button> |
| 90 |
`; |
| 91 |
} |
| 92 |
|
| 93 |
|
| 94 |
const company = contact.company ? `<span class="text-secondary">${esc(contact.company)}</span>` : ''; |
| 95 |
const title = contact.title ? `<span class="text-secondary">${esc(contact.title)}</span>` : ''; |
| 96 |
const companyTitle = [company, title].filter(Boolean).join(' · '); |
| 97 |
|
| 98 |
const tags = (contact.tags || []).map(t => `<span class="tag">${esc(t)}</span>`).join(' '); |
| 99 |
|
| 100 |
let headerHtml = ` |
| 101 |
<div class="contact-header-card"> |
| 102 |
<div class="avatar avatar--lg">${esc(initials)}</div> |
| 103 |
<div class="contact-header-info"> |
| 104 |
<h3 style="margin: 0;">${esc(name)}</h3> |
| 105 |
${companyTitle ? `<div style="margin-top: 0.25rem;">${companyTitle}</div>` : ''} |
| 106 |
${tags ? `<div style="margin-top: 0.5rem;">${tags}</div>` : ''} |
| 107 |
</div> |
| 108 |
</div> |
| 109 |
`; |
| 110 |
|
| 111 |
|
| 112 |
let infoHtml = ''; |
| 113 |
const infoItems = []; |
| 114 |
for (const e of contact.emails || []) { |
| 115 |
infoItems.push(`<span class="text-secondary">Email:</span> ${esc(e.address)}${e.label ? ' <span class="tag">' + esc(e.label) + '</span>' : ''}`); |
| 116 |
} |
| 117 |
for (const p of contact.phones || []) { |
| 118 |
infoItems.push(`<span class="text-secondary">Phone:</span> ${esc(p.number)}${p.label ? ' <span class="tag">' + esc(p.label) + '</span>' : ''}`); |
| 119 |
} |
| 120 |
for (const s of contact.socialHandles || contact.social_handles || []) { |
| 121 |
infoItems.push(`<span class="text-secondary">${esc(s.platform)}:</span> ${esc(s.handle)}`); |
| 122 |
} |
| 123 |
if (infoItems.length > 0) { |
| 124 |
infoHtml = `<div class="card card--muted contact-info-section">${infoItems.map(i => `<div class="contact-info-item">${i}</div>`).join('')}</div>`; |
| 125 |
} |
| 126 |
|
| 127 |
|
| 128 |
|
| 129 |
const timelineItems = activity.items || []; |
| 130 |
|
| 131 |
const totalActivity = (activity.taskCount || 0) + (activity.eventCount || 0) + (activity.emailCount || 0); |
| 132 |
|
| 133 |
let timelineHtml = ''; |
| 134 |
if (timelineItems.length === 0) { |
| 135 |
timelineHtml = '<p class="empty-italic">No interactions yet.</p>'; |
| 136 |
} else { |
| 137 |
const items = timelineItems.map(renderTimelineItem).join(''); |
| 138 |
|
| 139 |
const showAll = totalActivity > timelineItems.length |
| 140 |
? `<button class="btn btn-link" data-act="contactDashboard.showAllActivity">Show all ${totalActivity} interactions</button>` |
| 141 |
: ''; |
| 142 |
timelineHtml = `<div class="contact-timeline" id="contact-timeline">${items}</div>${showAll}`; |
| 143 |
} |
| 144 |
|
| 145 |
|
| 146 |
const notesHtml = contact.notes |
| 147 |
? `<div class="settings-section"><h3 class="settings-heading">Notes</h3><p style="white-space: pre-wrap;">${esc(contact.notes)}</p></div>` |
| 148 |
: ''; |
| 149 |
|
| 150 |
|
| 151 |
const taskCount = activity.taskCount; |
| 152 |
const eventCount = activity.eventCount; |
| 153 |
const emailCount = activity.emailCount; |
| 154 |
|
| 155 |
const summaryHtml = ` |
| 156 |
<div class="contact-dashboard-summary"> |
| 157 |
<div class="contact-summary-stat"> |
| 158 |
<span class="contact-summary-count">${taskCount}</span> |
| 159 |
<span class="contact-summary-label">Tasks</span> |
| 160 |
</div> |
| 161 |
<div class="contact-summary-stat"> |
| 162 |
<span class="contact-summary-count">${eventCount}</span> |
| 163 |
<span class="contact-summary-label">Events</span> |
| 164 |
</div> |
| 165 |
<div class="contact-summary-stat"> |
| 166 |
<span class="contact-summary-count">${emailCount}</span> |
| 167 |
<span class="contact-summary-label">Emails</span> |
| 168 |
</div> |
| 169 |
</div> |
| 170 |
`; |
| 171 |
|
| 172 |
container.innerHTML = headerHtml + infoHtml + summaryHtml + ` |
| 173 |
<div class="settings-section"> |
| 174 |
<h3 class="settings-heading">Activity</h3> |
| 175 |
${timelineHtml} |
| 176 |
</div> |
| 177 |
` + notesHtml; |
| 178 |
} |
| 179 |
|
| 180 |
async function promote(contactId) { |
| 181 |
try { |
| 182 |
await GoingsOn.api.contacts.promoteContact(contactId); |
| 183 |
GoingsOn.cache.invalidate('contacts'); |
| 184 |
GoingsOn.autocomplete.refresh(); |
| 185 |
GoingsOn.ui.showToast('Contact saved!', 'success'); |
| 186 |
open(contactId); |
| 187 |
} catch (err) { |
| 188 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save contact'), 'error'); |
| 189 |
} |
| 190 |
} |
| 191 |
|
| 192 |
|
| 193 |
* Load and render the full activity feed (no server-side cap), replacing the |
| 194 |
* capped list and the "Show all" button in place. Rust still merges/sorts/formats. |
| 195 |
|
| 196 |
async function showAllActivity() { |
| 197 |
if (!currentContactId) return; |
| 198 |
const timeline = document.getElementById('contact-timeline'); |
| 199 |
if (!timeline) return; |
| 200 |
try { |
| 201 |
const activity = await GoingsOn.api.contacts.getActivity(currentContactId); |
| 202 |
const items = (activity.items || []).map(renderTimelineItem).join(''); |
| 203 |
timeline.innerHTML = items; |
| 204 |
const btn = timeline.nextElementSibling; |
| 205 |
if (btn && btn.tagName === 'BUTTON') btn.remove(); |
| 206 |
} catch (err) { |
| 207 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load activity'), 'error'); |
| 208 |
} |
| 209 |
} |
| 210 |
|
| 211 |
GoingsOn.contactDashboard = { open, close, promote, showAllActivity }; |
| 212 |
})(); |
| 213 |
|