/** * Modul: Kontakte (Contacts) * Zweck: Kontaktliste mit Kategorie-Filter, Suche, CRUD, tel:/mailto:/maps-Links * Abhängigkeiten: /api.js, /router.js (window.oikos) */ import { api } from '/api.js'; import { openModal as openSharedModal, closeModal } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t } from '/i18n.js'; import { esc } from '/utils/html.js'; // -------------------------------------------------------- // Konstanten // -------------------------------------------------------- const CATEGORIES = ['Arzt', 'Schule/Kita', 'Behörde', 'Versicherung', 'Handwerker', 'Notfall', 'Sonstiges']; const CATEGORY_ICONS = { 'Arzt': '🏥', 'Schule/Kita': '🏫', 'Behörde': '🏛️', 'Versicherung': '🛡️', 'Handwerker': '🔧', 'Notfall': '🚨', 'Sonstiges': '📋', }; function CATEGORY_LABELS() { return { 'Arzt': t('contacts.categoryDoctor'), 'Schule/Kita': t('contacts.categorySchool'), 'Behörde': t('contacts.categoryAuthority'), 'Versicherung': t('contacts.categoryInsurance'), 'Handwerker': t('contacts.categoryCraftsman'), 'Notfall': t('contacts.categoryEmergency'), 'Sonstiges': t('contacts.categoryOther'), }; } // -------------------------------------------------------- // State // -------------------------------------------------------- let state = { contacts: [], activeCategory: null, searchQuery: '', }; let _container = null; // -------------------------------------------------------- // Entry Point // -------------------------------------------------------- export async function render(container, { user }) { _container = container; container.innerHTML = `

${t('contacts.title')}

${CATEGORIES.map((c) => ` `).join('')}
`; if (window.lucide) lucide.createIcons(); const res = await api.get('/contacts'); state.contacts = res.data; renderList(); // Suche let searchTimer; _container.querySelector('#contacts-search').addEventListener('input', (e) => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { state.searchQuery = e.target.value.trim(); renderList(); }, 200); }); // Kategorie-Filter _container.querySelector('#contacts-filters').addEventListener('click', (e) => { const chip = e.target.closest('[data-cat]'); if (!chip) return; _container.querySelectorAll('.contact-filter-chip').forEach((c) => c.classList.toggle('contact-filter-chip--active', c === chip) ); state.activeCategory = chip.dataset.cat || null; renderList(); }); // Neu const addHandler = () => openContactModal({ mode: 'create' }); _container.querySelector('#contacts-add-btn').addEventListener('click', addHandler); _container.querySelector('#fab-new-contact').addEventListener('click', addHandler); // vCard-Import _container.querySelector('#contacts-import-input').addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; e.target.value = ''; try { const text = await file.text(); const contact = parseVCard(text); if (!contact.name) { window.oikos?.showToast(t('contacts.vcardNoName'), 'warning'); return; } const res = await api.post('/contacts', contact); state.contacts.push(res.data); renderList(); window.oikos?.showToast(t('contacts.importedToast', { name: res.data.name }), 'success'); } catch (err) { window.oikos?.showToast(t('contacts.importError', { error: err.message }), 'danger'); } }); } // -------------------------------------------------------- // Liste rendern // -------------------------------------------------------- function filterContacts() { let list = state.contacts; if (state.activeCategory) { list = list.filter((c) => c.category === state.activeCategory); } if (state.searchQuery) { const q = state.searchQuery.toLowerCase(); list = list.filter((c) => c.name.toLowerCase().includes(q) || (c.phone && c.phone.toLowerCase().includes(q)) || (c.email && c.email.toLowerCase().includes(q)) ); } return list; } function renderList() { const container = _container.querySelector('#contacts-list'); if (!container) return; const contacts = filterContacts(); if (!contacts.length) { container.innerHTML = `
${t('contacts.emptyTitle')}
${t('contacts.emptyDescription')}

${t('emptyHint.contacts')}

`; if (window.lucide) lucide.createIcons(); container.querySelector('#empty-cta-contacts')?.addEventListener('click', () => { document.querySelector('.page-fab')?.click(); }); return; } // Nach Kategorie gruppieren const groups = {}; for (const c of contacts) { if (!groups[c.category]) groups[c.category] = []; groups[c.category].push(c); } container.innerHTML = Object.entries(groups) .sort(([a], [b]) => CATEGORIES.indexOf(a) - CATEGORIES.indexOf(b)) .map(([cat, items]) => `
${CATEGORY_ICONS[cat] || ''} ${CATEGORY_LABELS()[cat] || esc(cat)}
${items.map((c) => renderContactItem(c)).join('')}
`).join(''); if (window.lucide) lucide.createIcons(); stagger(container.querySelectorAll('.contact-item')); // Event-Delegation container.addEventListener('click', async (e) => { if (e.target.closest('[data-action="delete"]')) { const id = parseInt(e.target.closest('[data-action="delete"]').dataset.id, 10); await deleteContact(id); return; } const item = e.target.closest('.contact-item[data-id]'); if (item && !e.target.closest('a') && !e.target.closest('[data-action]')) { const c = state.contacts.find((c) => c.id === parseInt(item.dataset.id, 10)); if (c) openContactModal({ mode: 'edit', contact: c }); } }); } function renderContactItem(c) { const phone = c.phone ? `` : ''; const email = c.email ? `` : ''; const maps = c.address ? `` : ''; const meta = [c.phone, c.email].filter(Boolean).join(' · '); return `
${CATEGORY_ICONS[c.category] || '📋'}
${esc(c.name)}
${meta ? `
${esc(meta)}
` : ''}
${phone}${email}${maps} ${!c.family_user_id ? ` ` : ''}
`; } // -------------------------------------------------------- // Modal // -------------------------------------------------------- function openContactModal({ mode, contact = null }) { const isEdit = mode === 'edit'; const v = (field) => esc(isEdit && contact[field] ? contact[field] : ''); const catLabels = CATEGORY_LABELS(); const catOpts = CATEGORIES.map((c) => `` ).join(''); const content = `
`; openSharedModal({ title: isEdit ? t('contacts.editContact') : t('contacts.newContact'), content, size: 'md', onSave(panel) { panel.querySelector('#cm-cancel').addEventListener('click', closeModal); panel.querySelector('#cm-delete')?.addEventListener('click', async () => { closeModal({ force: true }); await deleteContact(contact.id); }); panel.querySelector('#cm-save').addEventListener('click', async () => { const saveBtn = panel.querySelector('#cm-save'); const name = panel.querySelector('#cm-name').value.trim(); const category = panel.querySelector('#cm-category').value; const phone = panel.querySelector('#cm-phone').value.trim() || null; const email = panel.querySelector('#cm-email').value.trim() || null; const address = panel.querySelector('#cm-address').value.trim() || null; const notes = panel.querySelector('#cm-notes').value.trim() || null; if (!name) { window.oikos?.showToast(t('common.nameRequired'), 'error'); return; } saveBtn.disabled = true; saveBtn.textContent = '…'; try { const body = { name, category, phone, email, address, notes }; if (mode === 'create') { const res = await api.post('/contacts', body); state.contacts.push(res.data); state.contacts.sort((a, b) => CATEGORIES.indexOf(a.category) - CATEGORIES.indexOf(b.category) || a.name.localeCompare(b.name) ); } else { const res = await api.put(`/contacts/${contact.id}`, body); const idx = state.contacts.findIndex((c) => c.id === contact.id); if (idx !== -1) state.contacts[idx] = res.data; } closeModal({ force: true }); renderList(); window.oikos?.showToast(mode === 'create' ? t('contacts.savedToast') : t('contacts.updatedToast'), 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); saveBtn.disabled = false; saveBtn.textContent = isEdit ? t('common.save') : t('common.create'); } }); }, }); } async function deleteContact(id) { const contact = state.contacts.find((c) => c.id === id); state.contacts = state.contacts.filter((c) => c.id !== id); renderList(); vibrate([30, 50, 30]); let undone = false; window.oikos?.showToast(t('contacts.deletedToast'), 'default', 5000, () => { undone = true; if (contact) { state.contacts = [...state.contacts, contact].sort((a, b) => a.name.localeCompare(b.name)); renderList(); } }); setTimeout(async () => { if (undone) return; try { await api.delete(`/contacts/${id}`); } catch (err) { if (contact) { state.contacts = [...state.contacts, contact].sort((a, b) => a.name.localeCompare(b.name)); renderList(); } window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'danger'); } }, 5000); } /** * Minimaler vCard 3.0/4.0 Parser. * Gibt { name, phone, email, address, notes, category } zurück. */ function parseVCard(text) { const unescapeVCard = (s) => String(s || '') .replace(/\\n/g, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\\\/g, '\\'); // Zeilenfortsetzungen entfalten (RFC 6350 §3.2) const unfolded = text.replace(/\r?\n[ \t]/g, ''); const get = (prop) => { const re = new RegExp(`^${prop}(?:;[^:]*)?:(.*)$`, 'im'); const m = re.exec(unfolded); return m ? unescapeVCard(m[1].trim()) : null; }; const name = get('FN') || get('N')?.split(';')[0] || null; const phone = get('TEL') || null; const email = get('EMAIL') || null; // ADR: ;;street;city;region;postal;country const adrRaw = get('ADR'); let address = null; if (adrRaw) { const parts = adrRaw.split(';').map((p) => p.trim()).filter(Boolean); address = parts.join(', ') || null; } const notes = get('NOTE') || null; const catRaw = get('CATEGORIES') || null; const category = CATEGORIES.find((c) => catRaw?.toLowerCase().includes(c.toLowerCase())) || 'Sonstiges'; return { name, phone, email, address, notes, category }; }