Skip to main content

max / goingson

33.5 KB · 828 lines History Blame Raw
1 /**
2 * GoingsOn - Contacts Module
3 * Contact CRUD, card grid, detail modal, sub-collection management
4 */
5
6 (function() {
7 'use strict';
8 const esc = GoingsOn.utils.escapeHtml;
9 const escAttr = GoingsOn.utils.escapeAttr;
10
11 // ============ Selection State ============
12
13 const selectedIds = new Set();
14
15 function toggleSelection(id, event) {
16 if (event) event.stopPropagation();
17 if (selectedIds.has(id)) {
18 selectedIds.delete(id);
19 } else {
20 selectedIds.add(id);
21 }
22 updateSelectionUI();
23 }
24
25 function selectAll() {
26 const contacts = GoingsOn.state.contacts || [];
27 contacts.forEach(c => selectedIds.add(c.id));
28 updateSelectionUI();
29 }
30
31 function clearSelection() {
32 selectedIds.clear();
33 updateSelectionUI();
34 }
35
36 function updateSelectionUI() {
37 // Update checkbox states
38 document.querySelectorAll('.contact-select-cb').forEach(cb => {
39 cb.checked = selectedIds.has(cb.dataset.id);
40 });
41 // Show/hide bulk bar
42 const bar = document.getElementById('contacts-bulk-bar');
43 if (bar) {
44 bar.classList.toggle('hidden', selectedIds.size === 0);
45 const count = bar.querySelector('.bulk-count');
46 if (count) count.textContent = `${selectedIds.size} selected`;
47 }
48 }
49
50 async function bulkDelete() {
51 const count = selectedIds.size;
52 if (count === 0) return;
53 if (!await GoingsOn.ui.confirmDelete(`${count} contact${count > 1 ? 's' : ''}`)) return;
54
55 const ids = [...selectedIds];
56 GoingsOn.cache.invalidate('contacts');
57 try {
58 await GoingsOn.api.contacts.bulkDelete(ids);
59 selectedIds.clear();
60 GoingsOn.ui.showToast(`${count} contact${count > 1 ? 's' : ''} deleted`, 'success');
61 load();
62 } catch (err) {
63 GoingsOn.ui.showToast('Failed to delete contacts: ' + GoingsOn.utils.getErrorMessage(err), 'error');
64 }
65 }
66
67 async function bulkTag() {
68 const count = selectedIds.size;
69 if (count === 0) return;
70
71 const tag = await GoingsOn.ui.showPromptDialog(
72 `Tag ${count} contact${count !== 1 ? 's' : ''}`,
73 'Tag to add:',
74 { placeholder: 'e.g. follow-up', confirmText: 'Add tag' }
75 );
76 if (!tag) return;
77
78 const ids = [...selectedIds];
79 GoingsOn.cache.invalidate('contacts');
80
81 GoingsOn.ui.bulkActionWithUndo({
82 ids,
83 label: `Tagged "${tag}" on`,
84 itemType: 'contact',
85 apply: (ids) => {
86 const idSet = new Set(ids);
87 const cached = GoingsOn.state.contacts || [];
88 // Capture per-contact whether the tag was already present, so revert
89 // only removes the tag from contacts that didn't already have it.
90 const newlyTagged = new Set();
91 const next = cached.map(c => {
92 if (!idSet.has(c.id)) return c;
93 const tags = c.tags || [];
94 if (tags.includes(tag)) return c;
95 newlyTagged.add(c.id);
96 return { ...c, tags: [...tags, tag] };
97 });
98 GoingsOn.state.set('contacts', next);
99 selectedIds.clear();
100 load();
101 return newlyTagged;
102 },
103 revert: (newlyTagged) => {
104 const cached = GoingsOn.state.contacts || [];
105 GoingsOn.state.set('contacts', cached.map(c =>
106 newlyTagged.has(c.id)
107 ? { ...c, tags: (c.tags || []).filter(t => t !== tag) }
108 : c
109 ));
110 load();
111 },
112 commit: async (ids) => {
113 const affected = await GoingsOn.api.contacts.bulkTag(ids, tag);
114 if (typeof affected === 'number' && affected !== ids.length) {
115 GoingsOn.ui.showToast(`Tagged ${affected} contact${affected !== 1 ? 's' : ''}`, 'info');
116 }
117 load();
118 },
119 errorMessage: 'Failed to tag contacts',
120 });
121 }
122
123 // ============ Sub-Collection Configuration ============
124
125 const SUB_COLLECTIONS = {
126 email: {
127 formId: 'add-contact-email-form',
128 modalTitle: 'Add Email Address',
129 fields: [
130 { name: 'address', label: 'Email Address', type: 'email', required: true, id: 'ce-address', placeholder: 'jane@example.com' },
131 { name: 'label', label: 'Label', type: 'text', required: false, id: 'ce-label', placeholder: 'Work, Personal, etc.' },
132 { name: 'is_primary', label: 'Primary email', type: 'checkbox', required: false, id: 'ce-primary' },
133 ],
134 collectData: (form) => ({
135 address: form.address.value.trim(),
136 label: form.label.value.trim() || null,
137 isPrimary: form.is_primary.checked,
138 }),
139 validate: (form) => {
140 if (!form.address.value.trim()) return 'Email address is required';
141 return null;
142 },
143 addCommand: 'addEmail',
144 removeCommand: 'removeEmail',
145 updateCommand: 'updateEmail',
146 entityLabel: 'email',
147 deleteLabel: 'this email address',
148 submitButtonText: 'Add Email',
149 editModalTitle: 'Edit Email Address',
150 editButtonText: 'Save Email',
151 prefill: (form, row) => {
152 form.address.value = row.address || '';
153 form.label.value = row.label || '';
154 form.is_primary.checked = !!row.isPrimary;
155 },
156 },
157 phone: {
158 formId: 'add-contact-phone-form',
159 modalTitle: 'Add Phone Number',
160 fields: [
161 { name: 'number', label: 'Phone Number', type: 'tel', required: true, id: 'cp-number', placeholder: '+1 555-123-4567' },
162 { name: 'label', label: 'Label', type: 'text', required: false, id: 'cp-label', placeholder: 'Mobile, Work, Home' },
163 { name: 'is_primary', label: 'Primary phone', type: 'checkbox', required: false, id: 'cp-primary' },
164 ],
165 collectData: (form) => ({
166 number: form.number.value.trim(),
167 label: form.label.value.trim() || null,
168 isPrimary: form.is_primary.checked,
169 }),
170 validate: (form) => {
171 if (!form.number.value.trim()) return 'Phone number is required';
172 return null;
173 },
174 addCommand: 'addPhone',
175 removeCommand: 'removePhone',
176 updateCommand: 'updatePhone',
177 entityLabel: 'phone',
178 deleteLabel: 'this phone number',
179 submitButtonText: 'Add Phone',
180 editModalTitle: 'Edit Phone Number',
181 editButtonText: 'Save Phone',
182 prefill: (form, row) => {
183 form.number.value = row.number || '';
184 form.label.value = row.label || '';
185 form.is_primary.checked = !!row.isPrimary;
186 },
187 },
188 social: {
189 formId: 'add-contact-social-form',
190 modalTitle: 'Add Social Handle',
191 fields: [
192 { name: 'platform', label: 'Platform', type: 'text', required: true, id: 'cs-platform', placeholder: 'Twitter, LinkedIn, GitHub...' },
193 { name: 'handle', label: 'Handle', type: 'text', required: true, id: 'cs-handle', placeholder: '@username' },
194 { name: 'url', label: 'Profile URL (optional)', type: 'url', required: false, id: 'cs-url', placeholder: 'https://twitter.com/username' },
195 ],
196 collectData: (form) => ({
197 platform: form.platform.value.trim(),
198 handle: form.handle.value.trim(),
199 url: form.url.value.trim() || null,
200 }),
201 validate: (form) => {
202 if (!form.platform.value.trim() || !form.handle.value.trim()) return 'Platform and handle are required';
203 return null;
204 },
205 addCommand: 'addSocialHandle',
206 removeCommand: 'removeSocialHandle',
207 updateCommand: 'updateSocialHandle',
208 entityLabel: 'social handle',
209 submitButtonText: 'Add Handle',
210 editModalTitle: 'Edit Social Handle',
211 editButtonText: 'Save Handle',
212 prefill: (form, row) => {
213 form.platform.value = row.platform || '';
214 form.handle.value = row.handle || '';
215 form.url.value = row.url || '';
216 },
217 },
218 customField: {
219 formId: 'add-contact-custom-field-form',
220 modalTitle: 'Add Custom Field',
221 fields: [
222 { name: 'label', label: 'Label', type: 'text', required: true, id: 'cf-label', placeholder: 'Reddit, Portfolio, etc.' },
223 { name: 'value', label: 'Value', type: 'text', required: true, id: 'cf-value', placeholder: 'username or display text' },
224 { name: 'url', label: 'URL (optional)', type: 'url', required: false, id: 'cf-url', placeholder: 'https://reddit.com/u/username' },
225 ],
226 collectData: (form) => ({
227 label: form.label.value.trim(),
228 value: form.value.value.trim(),
229 url: form.url.value.trim() || null,
230 }),
231 validate: (form) => {
232 if (!form.label.value.trim() || !form.value.value.trim()) return 'Label and value are required';
233 return null;
234 },
235 addCommand: 'addCustomField',
236 removeCommand: 'removeCustomField',
237 updateCommand: 'updateCustomField',
238 entityLabel: 'custom field',
239 submitButtonText: 'Add Field',
240 editModalTitle: 'Edit Custom Field',
241 editButtonText: 'Save Field',
242 prefill: (form, row) => {
243 form.label.value = row.label || '';
244 form.value.value = row.value || '';
245 form.url.value = row.url || '';
246 },
247 },
248 };
249
250 // ============ Generic Sub-Collection Functions ============
251
252 const ADD_SUBMIT_FN = {
253 email: 'submitAddEmail',
254 phone: 'submitAddPhone',
255 social: 'submitAddSocial',
256 customField: 'submitAddCustomField',
257 };
258 const EDIT_SUBMIT_FN = {
259 email: 'submitEditEmail',
260 phone: 'submitEditPhone',
261 social: 'submitEditSocial',
262 customField: 'submitEditCustomField',
263 };
264
265 /**
266 * Build the HTML form for adding or editing a sub-collection item.
267 * @param {string} type - Sub-collection type key from SUB_COLLECTIONS
268 * @param {string} contactId - Parent contact ID
269 * @param {string|null} editingId - When set, render as edit form (submit routes to update).
270 * @returns {string} HTML string for the form
271 */
272 function buildSubCollectionFormHtml(type, contactId, editingId = null) {
273 const config = SUB_COLLECTIONS[type];
274 const fieldHtml = config.fields.map(f => {
275 if (f.type === 'checkbox') {
276 return `
277 <div class="form-group">
278 <label class="filter-checkbox">
279 <input type="checkbox" id="${escAttr(f.id)}" name="${escAttr(f.name)}">
280 ${esc(f.label)}
281 </label>
282 </div>`;
283 }
284 return `
285 <div class="form-group">
286 <label class="form-label" for="${escAttr(f.id)}">${esc(f.label)}</label>
287 <input type="${escAttr(f.type)}" class="form-input" id="${escAttr(f.id)}" name="${escAttr(f.name)}"${f.required ? ' required' : ''}${f.placeholder ? ` placeholder="${escAttr(f.placeholder)}"` : ''}>
288 </div>`;
289 }).join('');
290
291 const isEdit = !!editingId;
292 const submitFn = isEdit ? EDIT_SUBMIT_FN[type] : ADD_SUBMIT_FN[type];
293 const submitText = isEdit ? (config.editButtonText || 'Save') : config.submitButtonText;
294 const onclick = isEdit
295 ? `GoingsOn.contacts.${submitFn}('${escAttr(contactId)}', '${escAttr(editingId)}')`
296 : `GoingsOn.contacts.${submitFn}('${escAttr(contactId)}')`;
297
298 return `
299 <form id="${escAttr(config.formId)}">
300 ${fieldHtml}
301 <div class="form-actions">
302 <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button>
303 <button type="button" class="btn btn-primary" onclick="${onclick}">${esc(submitText)}</button>
304 </div>
305 </form>
306 `;
307 }
308
309 /**
310 * Open a modal to add a sub-collection item to a contact.
311 * @param {string} type - Sub-collection type ('email', 'phone', 'social', 'customField')
312 * @param {string} contactId - Parent contact ID
313 */
314 function openAddSubCollection(type, contactId) {
315 const config = SUB_COLLECTIONS[type];
316 const content = buildSubCollectionFormHtml(type, contactId);
317 GoingsOn.ui.openModal(config.modalTitle, content);
318 }
319
320 /**
321 * Open a modal to edit an existing sub-collection item, prefilled with its current values.
322 * @param {string} type - Sub-collection type ('email', 'phone', 'social', 'customField')
323 * @param {string} contactId - Parent contact ID
324 * @param {Object} row - Existing sub-collection row (the JSON shape returned by the backend)
325 */
326 function openEditSubCollection(type, contactId, row) {
327 const config = SUB_COLLECTIONS[type];
328 if (!row || !row.id) return;
329 const content = buildSubCollectionFormHtml(type, contactId, row.id);
330 GoingsOn.ui.openModal(config.editModalTitle || config.modalTitle, content);
331 // openModal injects content synchronously; the form fields are now in the DOM.
332 const form = document.getElementById(config.formId);
333 if (form && config.prefill) config.prefill(form, row);
334 }
335
336 /**
337 * Validate and submit a sub-collection add form.
338 * @param {string} type - Sub-collection type ('email', 'phone', 'social', 'customField')
339 * @param {string} contactId - Parent contact ID
340 */
341 async function submitSubCollection(type, contactId) {
342 const config = SUB_COLLECTIONS[type];
343 const form = document.getElementById(config.formId);
344 if (!form) return;
345
346 const error = config.validate ? config.validate(form) : null;
347 if (error) {
348 GoingsOn.ui.showToast(error, 'error');
349 return;
350 }
351
352 const input = config.collectData(form);
353
354 GoingsOn.cache.invalidate('contacts');
355 await GoingsOn.ui.apiCall(GoingsOn.api.contacts[config.addCommand](contactId, input), {
356 successMessage: `${config.entityLabel.charAt(0).toUpperCase() + config.entityLabel.slice(1)} added!`,
357 errorMessage: `Failed to add ${config.entityLabel}`,
358 reload: async () => { await load(); openEdit(contactId); },
359 });
360 }
361
362 /**
363 * Validate and submit a sub-collection edit form.
364 * @param {string} type - Sub-collection type ('email', 'phone', 'social', 'customField')
365 * @param {string} contactId - Parent contact ID
366 * @param {string} itemId - Sub-collection row ID being updated
367 */
368 async function submitEditSubCollection(type, contactId, itemId) {
369 const config = SUB_COLLECTIONS[type];
370 const form = document.getElementById(config.formId);
371 if (!form) return;
372
373 const error = config.validate ? config.validate(form) : null;
374 if (error) {
375 GoingsOn.ui.showToast(error, 'error');
376 return;
377 }
378
379 const input = config.collectData(form);
380
381 GoingsOn.cache.invalidate('contacts');
382 await GoingsOn.ui.apiCall(GoingsOn.api.contacts[config.updateCommand](itemId, input), {
383 successMessage: `${config.entityLabel.charAt(0).toUpperCase() + config.entityLabel.slice(1)} updated`,
384 errorMessage: `Failed to update ${config.entityLabel}`,
385 reload: async () => { await load(); openEdit(contactId); },
386 });
387 }
388
389 /**
390 * Remove a sub-collection item from a contact with confirmation.
391 * @param {string} type - Sub-collection type ('email', 'phone', 'social', 'customField')
392 * @param {string} contactId - Parent contact ID
393 * @param {string} itemId - Sub-collection item ID to remove
394 */
395 async function removeSubCollection(type, contactId, itemId) {
396 const config = SUB_COLLECTIONS[type];
397 if (!await GoingsOn.ui.confirmDelete(config.deleteLabel || `this ${config.entityLabel}`)) return;
398 GoingsOn.cache.invalidate('contacts');
399 await GoingsOn.ui.apiCall(GoingsOn.api.contacts[config.removeCommand](itemId), {
400 successMessage: `${config.entityLabel.charAt(0).toUpperCase() + config.entityLabel.slice(1)} removed`,
401 errorMessage: `Failed to remove ${config.entityLabel}`,
402 reload: async () => { await load(); openEdit(contactId); },
403 });
404 }
405
406 // ============ Sub-Collection Wrappers (backward-compatible) ============
407
408 function openAddEmail(cid) { openAddSubCollection('email', cid); }
409 function submitAddEmail(cid) { submitSubCollection('email', cid); }
410 function removeEmail(cid, id) { removeSubCollection('email', cid, id); }
411 function openEditEmail(cid, id) { openEditSubCollection('email', cid, findSubRow(cid, 'emails', id)); }
412 function submitEditEmail(cid, id) { submitEditSubCollection('email', cid, id); }
413
414 function openAddPhone(cid) { openAddSubCollection('phone', cid); }
415 function submitAddPhone(cid) { submitSubCollection('phone', cid); }
416 function removePhone(cid, id) { removeSubCollection('phone', cid, id); }
417 function openEditPhone(cid, id) { openEditSubCollection('phone', cid, findSubRow(cid, 'phones', id)); }
418 function submitEditPhone(cid, id) { submitEditSubCollection('phone', cid, id); }
419
420 function openAddSocial(cid) { openAddSubCollection('social', cid); }
421 function submitAddSocial(cid) { submitSubCollection('social', cid); }
422 function removeSocialHandle(cid, id) { removeSubCollection('social', cid, id); }
423 function openEditSocial(cid, id) { openEditSubCollection('social', cid, findSubRow(cid, 'socialHandles', id)); }
424 function submitEditSocial(cid, id) { submitEditSubCollection('social', cid, id); }
425
426 function openAddCustomField(cid) { openAddSubCollection('customField', cid); }
427 function submitAddCustomField(cid) { submitSubCollection('customField', cid); }
428 function removeCustomField(cid, id) { removeSubCollection('customField', cid, id); }
429 function openEditCustomField(cid, id) { openEditSubCollection('customField', cid, findSubRow(cid, 'customFields', id)); }
430 function submitEditCustomField(cid, id) { submitEditSubCollection('customField', cid, id); }
431
432 /**
433 * Look up a sub-collection row by id from the cached `GoingsOn.state.contacts`
434 * list. Keeps the inline edit buttons honest with the current data without
435 * passing JSON payloads through HTML attributes.
436 */
437 function findSubRow(contactId, field, rowId) {
438 const contact = (GoingsOn.state.contacts || []).find(c => c.id === contactId);
439 if (!contact) return null;
440 const list = contact[field] || [];
441 return list.find(r => r.id === rowId) || null;
442 }
443
444 // ============ Form Field Definitions ============
445
446 /**
447 * Build form field definitions for the contact create/edit modal.
448 * @param {Object|null} contact - Existing contact for edit mode, or null for create
449 * @returns {FormField[]} Array of form field definitions
450 */
451 function getContactFormFields(contact = null) {
452 return [
453 {
454 name: 'display_name',
455 type: 'text',
456 label: 'Name',
457 placeholder: 'Jane Smith',
458 required: true,
459 value: contact?.displayName || '',
460 },
461 {
462 name: 'nickname',
463 type: 'text',
464 label: 'Nickname',
465 placeholder: 'Optional nickname',
466 value: contact?.nickname || '',
467 },
468 {
469 name: 'company',
470 type: 'text',
471 label: 'Company',
472 placeholder: 'Acme Corp',
473 value: contact?.company || '',
474 },
475 {
476 name: 'title',
477 type: 'text',
478 label: 'Title',
479 placeholder: 'Software Engineer',
480 value: contact?.title || '',
481 },
482 {
483 name: 'tags',
484 type: 'text',
485 label: 'Tags (comma-separated)',
486 placeholder: 'friend, coworker',
487 value: contact?.tags?.join(', ') || '',
488 },
489 {
490 name: 'birthday',
491 type: 'text',
492 label: 'Birthday (YYYY-MM-DD)',
493 placeholder: '1990-01-15',
494 value: contact?.birthday || '',
495 },
496 {
497 name: 'timezone',
498 type: 'text',
499 label: 'Timezone',
500 placeholder: 'America/New_York',
501 value: contact?.timezone || '',
502 },
503 {
504 name: 'notes',
505 type: 'textarea',
506 label: 'Notes',
507 placeholder: 'Any notes about this contact...',
508 value: contact?.notes || '',
509 },
510 ];
511 }
512
513 // ============ Helpers ============
514
515 function parseTags(tagString) {
516 return GoingsOn.utils.normalizeTags(tagString);
517 }
518
519 /**
520 * Extract all unique tags from a list of contacts, sorted alphabetically.
521 * @param {Array<Object>} contacts - Contact objects with optional tags arrays
522 * @returns {string[]} Sorted array of unique tag strings
523 */
524 function getAllTags(contacts) {
525 const tagSet = new Set();
526 contacts.forEach(c => (c.tags || []).forEach(t => tagSet.add(t)));
527 return [...tagSet].sort();
528 }
529
530 // ============ Rendering (delegated to contacts-render.js) ============
531
532 const renderCard = GoingsOn.contactsRender.renderCard;
533
534 function updateTagFilter(contacts) {
535 const select = document.getElementById('contacts-tag-filter');
536 if (!select) return;
537 const tags = getAllTags(contacts);
538 const current = select.value;
539 select.innerHTML = '<option value="">All Tags</option>' +
540 tags.map(t => `<option value="${escAttr(t)}" ${t === current ? 'selected' : ''}>${esc(t)}</option>`).join('');
541 }
542
543 // ============ Core Functions ============
544
545 async function load() {
546 if (GoingsOn.cache.isFresh('contacts')) return;
547
548 // Phase 7 Tier 4 — restore filter state from URL.
549 restoreFiltersFromUrl();
550
551 const grid = document.getElementById('contacts-grid');
552 try {
553 const sq = GoingsOn.state.contactsSearchQuery || '';
554 const tf = GoingsOn.state.contactsTagFilter || '';
555 const contacts = await GoingsOn.api.contacts.listFiltered(sq, tf);
556 GoingsOn.state.set('contacts', contacts);
557 // Update tag filter from full list (no search/tag filter) if needed
558 if (!sq && !tf) {
559 updateTagFilter(contacts);
560 }
561 render(contacts);
562 GoingsOn.cache.markLoaded('contacts');
563 } catch (err) {
564 GoingsOn.utils.showError(grid, err, 'Failed to load contacts');
565 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load contacts'), 'error', {
566 action: { label: 'Retry', fn: () => { GoingsOn.cache.invalidate('contacts'); load(); } },
567 duration: 8000,
568 });
569 }
570 }
571
572 function render(contacts) {
573 const grid = document.getElementById('contacts-grid');
574 if (!contacts) contacts = GoingsOn.state.contacts || [];
575
576 const filtered = contacts;
577
578 if (filtered.length === 0) {
579 if (contacts.length === 0) {
580 grid.innerHTML = GoingsOn.ui.renderEmptyState('No contacts yet.', 'Add Contact', 'GoingsOn.contacts.openNew()', 'contacts');
581 } else {
582 grid.innerHTML = GoingsOn.ui.renderEmptyState('No contacts match your filters.');
583 }
584 return;
585 }
586
587 grid.innerHTML = filtered.map(renderCard).join('');
588 }
589
590 // ============ CRUD ============
591
592 function openNew() {
593 GoingsOn.ui.openFormModal({
594 title: 'New Contact',
595 entityType: 'contact',
596 isEdit: false,
597 fields: getContactFormFields(),
598 onSubmit: create,
599 });
600 }
601
602 async function create(data) {
603 const input = {
604 displayName: data.display_name,
605 nickname: data.nickname || null,
606 company: data.company || null,
607 title: data.title || null,
608 tags: parseTags(data.tags),
609 birthday: data.birthday || null,
610 timezone: data.timezone || null,
611 notes: data.notes || '',
612 };
613
614 GoingsOn.cache.invalidate('contacts');
615 await GoingsOn.ui.apiCall(GoingsOn.api.contacts.create(input), {
616 successMessage: 'Contact created!',
617 errorMessage: 'Failed to create contact',
618 reload: load,
619 });
620 }
621
622 async function openEdit(id) {
623 const contact = (GoingsOn.state.contacts || []).find(c => c.id === id);
624 if (!contact) return;
625
626 // Build sub-collection summaries for the edit form. Each row carries
627 // inline Edit / Remove buttons; data is looked up by id from
628 // `GoingsOn.state.contacts` rather than smuggled through HTML attrs.
629 const rowActions = (kind, rowId) => `
630 <span class="sub-item-actions">
631 <button type="button" class="btn btn-sm btn-secondary" onclick="GoingsOn.contacts.openEdit${kind}('${escAttr(id)}', '${escAttr(rowId)}')" title="Edit">Edit</button>
632 <button type="button" class="btn btn-sm btn-danger" onclick="GoingsOn.contacts.remove${kind === 'Social' ? 'SocialHandle' : kind}('${escAttr(id)}', '${escAttr(rowId)}')" title="Remove">x</button>
633 </span>
634 `;
635
636 const emailSummary = (contact.emails || []).map(e =>
637 `<div class="sub-item-compact"><span>${esc(e.address)}${e.label ? ` <small>(${esc(e.label)})</small>` : ''}${e.isPrimary ? ' <strong>Primary</strong>' : ''}</span>${rowActions('Email', e.id)}</div>`
638 ).join('') || '<span class="text-muted">None</span>';
639
640 const phoneSummary = (contact.phones || []).map(p =>
641 `<div class="sub-item-compact"><span>${esc(p.number)}${p.label ? ` <small>(${esc(p.label)})</small>` : ''}${p.isPrimary ? ' <strong>Primary</strong>' : ''}</span>${rowActions('Phone', p.id)}</div>`
642 ).join('') || '<span class="text-muted">None</span>';
643
644 const socialSummary = (contact.socialHandles || []).map(s =>
645 `<div class="sub-item-compact"><span><strong>${esc(s.platform)}:</strong> ${esc(s.handle)}</span>${rowActions('Social', s.id)}</div>`
646 ).join('') || '<span class="text-muted">None</span>';
647
648 const customFieldSummary = (contact.customFields || []).map(f =>
649 `<div class="sub-item-compact"><span><strong>${esc(f.label)}:</strong> ${esc(f.value)}</span>${rowActions('CustomField', f.id)}</div>`
650 ).join('') || '<span class="text-muted">None</span>';
651
652 GoingsOn.ui.openFormModal({
653 title: 'Edit Contact',
654 entityType: 'contact',
655 isEdit: true,
656 entityId: id,
657 fields: getContactFormFields(contact),
658 onSubmit: (data) => update(id, data),
659 extraContent: `
660 <div class="edit-sub-collections">
661 <div class="edit-sub-section">
662 <div class="edit-sub-header">
663 <strong>Emails</strong>
664 <button type="button" class="btn btn-sm btn-primary" onclick="GoingsOn.contacts.openAddEmail('${escAttr(id)}')">+ Add</button>
665 </div>
666 ${emailSummary}
667 </div>
668 <div class="edit-sub-section">
669 <div class="edit-sub-header">
670 <strong>Phones</strong>
671 <button type="button" class="btn btn-sm btn-primary" onclick="GoingsOn.contacts.openAddPhone('${escAttr(id)}')">+ Add</button>
672 </div>
673 ${phoneSummary}
674 </div>
675 <div class="edit-sub-section">
676 <div class="edit-sub-header">
677 <strong>Social</strong>
678 <button type="button" class="btn btn-sm btn-primary" onclick="GoingsOn.contacts.openAddSocial('${escAttr(id)}')">+ Add</button>
679 </div>
680 ${socialSummary}
681 </div>
682 <div class="edit-sub-section">
683 <div class="edit-sub-header">
684 <strong>Custom Fields</strong>
685 <button type="button" class="btn btn-sm btn-primary" onclick="GoingsOn.contacts.openAddCustomField('${escAttr(id)}')">+ Add</button>
686 </div>
687 ${customFieldSummary}
688 </div>
689 <div style="margin-top: 0.5rem;">
690 <button type="button" class="btn btn-sm btn-secondary" onclick="GoingsOn.contacts.open('${escAttr(id)}')">View Full Detail</button>
691 </div>
692 </div>
693 <div style="margin-top: 1rem;">
694 <button type="button" class="btn btn-danger" onclick="GoingsOn.contacts.deleteContact('${escAttr(id)}')">Delete Contact</button>
695 </div>
696 `,
697 });
698 }
699
700 async function update(id, data) {
701 const input = {
702 displayName: data.display_name,
703 nickname: data.nickname || null,
704 company: data.company || null,
705 title: data.title || null,
706 tags: parseTags(data.tags),
707 birthday: data.birthday || null,
708 timezone: data.timezone || null,
709 notes: data.notes || '',
710 };
711
712 GoingsOn.cache.invalidate('contacts');
713 await GoingsOn.ui.apiCall(GoingsOn.api.contacts.update(id, input), {
714 successMessage: 'Contact updated!',
715 errorMessage: 'Failed to update contact',
716 reload: load,
717 });
718 }
719
720 async function deleteContact(id) {
721 if (!await GoingsOn.ui.confirmDelete('contact')) return;
722
723 GoingsOn.cache.invalidate('contacts');
724 await GoingsOn.ui.apiCall(GoingsOn.api.contacts.delete(id), {
725 successMessage: 'Contact deleted!',
726 errorMessage: 'Failed to delete contact',
727 reload: load,
728 });
729 }
730
731 // ============ Detail Modal ============
732
733 /**
734 * Fetch a contact by ID and open its detail modal.
735 * @param {string} id - Contact ID to open
736 */
737 async function open(id) {
738 GoingsOn.contactDashboard.open(id);
739 }
740
741 function showDetailModal(contact) {
742 GoingsOn.contactsRender.showDetailModal(contact);
743 }
744
745 // ============ Filtering ============
746
747 /**
748 * Filter contacts by search query (server-side filtering).
749 * @param {string} query - Search text to filter by
750 */
751 function filterBySearch(query) {
752 const trimmed = query.trim();
753 GoingsOn.state.set('contactsSearchQuery', trimmed);
754 GoingsOn.queryState?.write('q', trimmed);
755 GoingsOn.cache.invalidate('contacts');
756 load();
757 }
758
759 /**
760 * Filter contacts by tag (server-side filtering).
761 * @param {string} tag - Tag to filter by, or empty string for all
762 */
763 function filterByTag(tag) {
764 GoingsOn.state.set('contactsTagFilter', tag);
765 GoingsOn.queryState?.write('tag', tag);
766 GoingsOn.cache.invalidate('contacts');
767 load();
768 }
769
770 /**
771 * Phase 7 Tier 4 — restore search / tag filter from URL on first load.
772 */
773 function restoreFiltersFromUrl() {
774 if (!GoingsOn.queryState) return;
775 const q = GoingsOn.queryState.readMany(['q', 'tag']);
776 if (q.q) {
777 GoingsOn.state.set('contactsSearchQuery', q.q);
778 const input = document.getElementById('contacts-search');
779 if (input) input.value = q.q;
780 }
781 if (q.tag) {
782 GoingsOn.state.set('contactsTagFilter', q.tag);
783 const sel = document.getElementById('contacts-tag-filter');
784 if (sel) sel.value = q.tag;
785 }
786 }
787
788 // ============ Populate GoingsOn.contacts Namespace ============
789
790 GoingsOn.contacts = {
791 load,
792 openNew,
793 open,
794 openEdit,
795 deleteContact,
796 filterBySearch,
797 filterByTag,
798 // Bulk operations
799 toggleSelection,
800 selectAll,
801 clearSelection,
802 bulkDelete,
803 bulkTag,
804 // Sub-collection
805 openAddEmail,
806 submitAddEmail,
807 removeEmail,
808 openEditEmail,
809 submitEditEmail,
810 openAddPhone,
811 submitAddPhone,
812 removePhone,
813 openEditPhone,
814 submitEditPhone,
815 openAddSocial,
816 submitAddSocial,
817 removeSocialHandle,
818 openEditSocial,
819 submitEditSocial,
820 openAddCustomField,
821 submitAddCustomField,
822 removeCustomField,
823 openEditCustomField,
824 submitEditCustomField,
825 };
826
827 })();
828