Skip to main content

max / goingson

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