// assets/scripts/crud.js import { esc, createTranslator, ANIMATION_MS, removeWithAnimation, animateIn } from './utils.js'; import { SearchableSelect } from './searchable-select.js'; const api = window.CRUD?.apiBase ?? ''; const t = createTranslator('CRUD'); // ── Hilfsfunktionen ────────────────────────────────────────────────────────── function buildClientOptions(selectedId = null) { const clients = window.CRUD?.clients ?? []; let html = ``; clients.forEach(c => { const sel = String(c.id) === String(selectedId) ? ' selected' : ''; html += ``; }); return html; } function rowPrefix() { if (location.pathname.includes('/clients')) return 'client'; if (location.pathname.includes('/projects')) return 'project'; if (location.pathname.includes('/services')) return 'service'; return 'row'; } // ── Lexoffice Integration ──────────────────────────────────────────────────── const isClientPage = () => location.pathname.includes('/clients'); const hasLexoffice = () => window.CRUD?.hasLexofficeApiKey === true && isClientPage(); let lexofficeContacts = null; let lexofficeLoading = false; async function loadLexofficeContacts() { if (lexofficeContacts !== null) return lexofficeContacts; if (lexofficeLoading) { return new Promise(resolve => { const check = setInterval(() => { if (!lexofficeLoading) { clearInterval(check); resolve(lexofficeContacts); } }, 100); }); } lexofficeLoading = true; try { const res = await fetch('/api/lexoffice/contacts'); if (!res.ok) throw new Error(); lexofficeContacts = await res.json(); return lexofficeContacts; } catch { alert(t('lexofficeErrorLoad')); return null; } finally { lexofficeLoading = false; } } function buildLexofficeSelectInstance(container) { const ss = new SearchableSelect(container, { searchPlaceholder: t('lexofficeSearch') }); return ss; } function getUsedLexofficeIds(excludeRowId) { const ids = new Set(); document.querySelectorAll('#crud-list .crud-row').forEach(row => { if (excludeRowId && row.dataset.id === String(excludeRowId)) return; const id = row.dataset.lexofficeContactId; if (id) ids.add(id); }); return ids; } async function populateLexofficeSelect(ss, contactId, excludeRowId) { const contacts = await loadLexofficeContacts(); if (!contacts) return; const usedIds = getUsedLexofficeIds(excludeRowId); const filtered = contacts.filter(c => !usedIds.has(c.id) || c.id === contactId); ss.setGroups([{ label: '', items: filtered }]); if (contactId) ss.setValue(contactId); } function initLexofficeCreateToggle() { if (!hasLexoffice()) return; const checkbox = document.getElementById('create-lexoffice'); const nameInput = document.getElementById('create-name'); const selectWrap = document.getElementById('create-lexoffice-select'); const selectLabel = document.getElementById('create-lexoffice-select-label'); const selectField = document.getElementById('create-lexoffice-select-field'); if (!checkbox || !nameInput || !selectWrap) return; let ss = null; checkbox.addEventListener('change', async () => { if (checkbox.checked) { nameInput.disabled = true; nameInput.value = ''; if (selectLabel) selectLabel.hidden = false; if (selectField) selectField.hidden = false; if (!ss) { ss = buildLexofficeSelectInstance(selectWrap); ss.onSelect = (val, label) => { nameInput.value = label; }; selectWrap._ss = ss; } await populateLexofficeSelect(ss, null, null); ss.focus(); } else { nameInput.disabled = false; nameInput.value = ''; if (selectLabel) selectLabel.hidden = true; if (selectField) selectField.hidden = true; } }); } function initLexofficeEditToggles() { if (!hasLexoffice()) return; document.querySelectorAll('.crud-row').forEach(row => { initLexofficeEditToggle(row); }); } function initLexofficeEditToggle(row) { const checkbox = row.querySelector('.edit-lexoffice'); const nameInput = row.querySelector('.edit-name'); const selectWrap = row.querySelector('.edit-lexoffice-select'); const selectLabel = row.querySelector('.edit-lexoffice-select-label'); const selectField = row.querySelector('.edit-lexoffice-select-field'); if (!checkbox || !nameInput) return; const contactId = (selectWrap?.dataset.contactId) || ''; const rowId = row.dataset.id; let ss = null; let fullyLoaded = false; function ensureSelect() { if (!ss && selectWrap) { ss = buildLexofficeSelectInstance(selectWrap); ss.onSelect = (val, label) => { nameInput.value = label; }; selectWrap._ss = ss; const origOpen = ss.openDropdown.bind(ss); ss.openDropdown = async function () { if (!fullyLoaded) { await populateLexofficeSelect(ss, contactId, rowId); fullyLoaded = true; } origOpen(); }; } return ss; } if (contactId) { nameInput.disabled = true; ensureSelect(); if (ss) { ss.setGroups([{ label: '', items: [{ id: contactId, name: nameInput.value }] }]); ss.setValue(contactId); } addReloadButton(selectWrap, row); } checkbox.addEventListener('change', async () => { if (checkbox.checked) { nameInput.disabled = true; nameInput.value = ''; if (selectLabel) selectLabel.hidden = false; if (selectField) selectField.hidden = false; ensureSelect(); if (ss) { await populateLexofficeSelect(ss, null, rowId); fullyLoaded = true; ss.focus(); } } else { nameInput.disabled = false; nameInput.value = ''; if (selectLabel) selectLabel.hidden = true; if (selectField) selectField.hidden = true; removeReloadButton(selectWrap); } }); } function addReloadButton(container, row) { if (!container || container.querySelector('.lexoffice-reload')) return; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'lexoffice-reload'; btn.title = t('lexofficeReload'); btn.innerHTML = ''; btn.addEventListener('click', async (e) => { e.preventDefault(); btn.disabled = true; try { const res = await fetch(`${api}/${row.dataset.id}/lexoffice-refresh`, { method: 'PATCH' }); if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err.error ?? t('lexofficeErrorApi')); return; } const data = await res.json(); updateRowDisplay(row, data); const nameInput = row.querySelector('.edit-name'); if (nameInput) nameInput.value = data.name; } catch { alert(t('lexofficeErrorApi')); } finally { btn.disabled = false; } }); container.appendChild(btn); } function removeReloadButton(container) { container?.querySelector('.lexoffice-reload')?.remove(); } function syncLexofficeAfterSave(row, data) { if (!hasLexoffice()) return; const selectWrap = row.querySelector('.edit-lexoffice-select'); const nameInput = row.querySelector('.edit-name'); const selectLabel = row.querySelector('.edit-lexoffice-select-label'); const selectField = row.querySelector('.edit-lexoffice-select-field'); const checkbox = row.querySelector('.edit-lexoffice'); const hasId = data.lexofficeContactId != null && data.lexofficeContactId !== ''; if (hasId) { if (nameInput) nameInput.disabled = true; if (checkbox) checkbox.checked = true; if (selectLabel) selectLabel.hidden = false; if (selectField) selectField.hidden = false; if (selectWrap) { const ss = selectWrap._ss ?? (() => { const s = buildLexofficeSelectInstance(selectWrap); s.onSelect = (val, label) => { if (nameInput) nameInput.value = label; }; selectWrap._ss = s; return s; })(); ss.setGroups([{ label: '', items: [{ id: data.lexofficeContactId, name: data.name }] }]); ss.setValue(data.lexofficeContactId); selectWrap.dataset.contactId = data.lexofficeContactId; let fullyLoaded = false; const rowId = row.dataset.id; const origOpen = ss.openDropdown.bind(ss); ss.openDropdown = async function () { if (!fullyLoaded) { await populateLexofficeSelect(ss, data.lexofficeContactId, rowId); fullyLoaded = true; } origOpen(); }; addReloadButton(selectWrap, row); } } else { if (nameInput) nameInput.disabled = false; if (checkbox) checkbox.checked = false; if (selectLabel) selectLabel.hidden = true; if (selectField) selectField.hidden = true; if (selectWrap) { removeReloadButton(selectWrap); selectWrap.dataset.contactId = ''; } } } // ── Rate-Mode Radio Toggle ─────────────────────────────────────────────────── function initRateModeToggles() { document.addEventListener('change', e => { if (e.target.type !== 'radio') return; const container = e.target.closest('.rate-mode'); if (!container) return; const inputWrap = container.querySelector('.rate-mode__input'); if (!inputWrap) return; inputWrap.hidden = e.target.value !== 'custom'; if (e.target.value === 'custom') { inputWrap.querySelector('input')?.focus(); } }); } // ── Create-Formular ────────────────────────────────────────────────────────── function initCreateForm() { const btnNew = document.getElementById('btn-new'); const form = document.getElementById('crud-create'); const btnSave = document.getElementById('btn-create-save'); const btnCancel = document.getElementById('btn-create-cancel'); if (!btnNew || !form) return; btnNew.addEventListener('click', () => { form.classList.toggle('crud-create--visible'); document.getElementById('create-name')?.focus(); }); btnCancel?.addEventListener('click', () => { form.classList.remove('crud-create--visible'); resetCreateForm(); }); btnSave?.addEventListener('click', () => createEntity()); } function resetCreateForm() { ['create-name', 'create-note'].forEach(id => { const el = document.getElementById(id); if (el) { el.value = ''; el.disabled = false; } }); const rate = document.getElementById('create-rate'); if (rate) rate.value = ''; const billable = document.getElementById('create-billable'); if (billable) billable.checked = true; const client = document.getElementById('create-client'); if (client) client.value = ''; const defaultRadio = document.querySelector('[name="create-rate-mode"][value="default"]'); if (defaultRadio) { defaultRadio.checked = true; const rateInput = defaultRadio.closest('.rate-mode')?.querySelector('.rate-mode__input'); if (rateInput) rateInput.hidden = true; } const lexofficeCheckbox = document.getElementById('create-lexoffice'); if (lexofficeCheckbox) { lexofficeCheckbox.checked = false; const selectWrap = document.getElementById('create-lexoffice-select'); if (selectWrap) selectWrap.hidden = true; } } async function createEntity() { const name = document.getElementById('create-name')?.value?.trim(); if (!name) { alert(t('errorNoName')); return; } const btn = document.getElementById('btn-create-save'); const body = buildCreateBody(); if (btn) btn.disabled = true; try { const res = await fetch(api, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err.error ?? t('errorSave')); return; } const data = await res.json(); appendRowToList(data); document.getElementById('crud-create')?.classList.remove('crud-create--visible'); resetCreateForm(); } catch { alert(t('errorSave')); } finally { if (btn) btn.disabled = false; } } function buildCreateBody() { const body = { name: document.getElementById('create-name')?.value?.trim(), note: document.getElementById('create-note')?.value || null, }; const rateMode = document.querySelector('[name="create-rate-mode"]:checked'); const rate = document.getElementById('create-rate'); if (rateMode) { body.hourlyRate = rateMode.value === 'custom' && rate?.value ? rate.value : null; } else if (rate) { body.hourlyRate = rate.value || null; } const client = document.getElementById('create-client'); if (client) body.clientId = parseInt(client.value, 10) || null; const billable = document.getElementById('create-billable'); if (billable) body.billable = billable.checked; const lexofficeCheckbox = document.getElementById('create-lexoffice'); const lexofficeSelect = document.getElementById('create-lexoffice-select'); if (lexofficeCheckbox?.checked && lexofficeSelect?._ss) { body.lexofficeContactId = lexofficeSelect._ss.getValue() || null; } else if (lexofficeCheckbox && !lexofficeCheckbox.checked) { body.lexofficeContactId = null; } return body; } // ── Liste: Event Delegation ────────────────────────────────────────────────── function initList() { const list = document.getElementById('crud-list'); if (!list) return; list.addEventListener('click', e => { const actionEl = e.target.closest('[data-action]'); if (!actionEl) return; const row = e.target.closest('.crud-row'); if (!row) return; switch (actionEl.dataset.action) { case 'edit': openEdit(row); break; case 'delete': deleteRow(row); break; case 'save': saveEdit(row); break; case 'cancel': closeEdit(row); break; case 'unarchive': unarchiveRow(row); break; } }); } // ── Inline Edit ────────────────────────────────────────────────────────────── function openEdit(row) { row.querySelector('.crud-row__display').hidden = true; row.querySelector('.crud-row__edit').hidden = false; row.querySelector('.edit-name')?.focus(); } function closeEdit(row) { row.querySelector('.crud-row__display').hidden = false; row.querySelector('.crud-row__edit').hidden = true; } async function saveEdit(row) { const saveBtn = row.querySelector('[data-action="save"]'); if (saveBtn?.disabled) return; const name = row.querySelector('.edit-name')?.value?.trim(); if (!name) { alert(t('errorNoName')); return; } const body = buildEditBody(row); if (saveBtn) saveBtn.disabled = true; try { const res = await fetch(`${api}/${row.dataset.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { alert(t('errorSave')); return; } const data = await res.json(); updateRowDisplay(row, data); syncLexofficeAfterSave(row, data); closeEdit(row); const list = document.getElementById('crud-list'); if (list && data.billable === undefined) insertRowSorted(list, row); } catch { alert(t('errorSave')); } finally { if (saveBtn) saveBtn.disabled = false; } } function buildEditBody(row) { const body = { name: row.querySelector('.edit-name')?.value?.trim(), note: row.querySelector('.edit-note')?.value || null, }; const rateMode = row.querySelector('.rate-mode input[type="radio"][value="custom"]'); const rate = row.querySelector('.edit-rate'); if (rateMode) { body.hourlyRate = rateMode.checked && rate?.value ? rate.value : null; } else if (rate) { body.hourlyRate = rate.value || null; } const client = row.querySelector('.edit-client'); if (client) body.clientId = parseInt(client.value, 10) || null; const billable = row.querySelector('.edit-billable'); if (billable) body.billable = billable.checked; const lexofficeCheckbox = row.querySelector('.edit-lexoffice'); const lexofficeSelect = row.querySelector('.edit-lexoffice-select'); if (lexofficeCheckbox?.checked && lexofficeSelect?._ss) { body.lexofficeContactId = lexofficeSelect._ss.getValue() || null; } else if (lexofficeCheckbox && !lexofficeCheckbox.checked) { body.lexofficeContactId = null; } return body; } function updateRowDisplay(row, data) { const nameEl = row.querySelector('.crud-row__name'); const metaEl = row.querySelector('.crud-row__meta'); if (nameEl) nameEl.textContent = data.name; if (data.clientName && metaEl) metaEl.textContent = data.clientName; row.dataset.name = data.name; if (data.hourlyRate !== undefined) row.dataset.rate = data.hourlyRate ?? ''; if (data.clientId !== undefined) row.dataset.clientId = data.clientId; if (data.billable !== undefined) row.dataset.billable = data.billable ? '1' : '0'; if (data.note !== undefined) row.dataset.note = data.note ?? ''; if (data.lexofficeContactId !== undefined) row.dataset.lexofficeContactId = data.lexofficeContactId ?? ''; const editName = row.querySelector('.edit-name'); if (editName) editName.value = data.name; const editNote = row.querySelector('.edit-note'); if (editNote) editNote.value = data.note ?? ''; const editRate = row.querySelector('.edit-rate'); if (editRate) editRate.value = data.hourlyRate ?? ''; const rateMode = row.querySelector('.rate-mode'); if (rateMode) { const hasCustom = data.hourlyRate != null && data.hourlyRate !== ''; const defaultRadio = rateMode.querySelector('input[value="default"]'); const customRadio = rateMode.querySelector('input[value="custom"]'); const inputWrap = rateMode.querySelector('.rate-mode__input'); if (defaultRadio) defaultRadio.checked = !hasCustom; if (customRadio) customRadio.checked = hasCustom; if (inputWrap) inputWrap.hidden = !hasCustom; } const editBillable = row.querySelector('.edit-billable'); if (editBillable) editBillable.checked = !!data.billable; } // ── Delete ─────────────────────────────────────────────────────────────────── async function deleteRow(row) { if (!confirm(t('confirmDelete'))) return; try { const res = await fetch(`${api}/${row.dataset.id}`, { method: 'DELETE' }); if (res.status === 409) { if (confirm(t('confirmArchive'))) { await archiveRow(row); } return; } if (!res.ok) { alert(t('errorDelete')); return; } removeWithAnimation(row, 'crud-row--removing'); } catch { alert(t('errorDelete')); } } async function archiveRow(row) { try { const res = await fetch(`${api}/${row.dataset.id}/archive`, { method: 'PATCH' }); if (!res.ok) { alert(t('errorArchive')); return; } row.dataset.archived = '1'; row.classList.add('crud-row--archived'); updateRowArchivedState(row, true); filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active'); } catch { alert(t('errorArchive')); } } async function unarchiveRow(row) { try { const res = await fetch(`${api}/${row.dataset.id}/unarchive`, { method: 'PATCH' }); if (!res.ok) { alert(t('errorRestore')); return; } row.dataset.archived = '0'; row.classList.remove('crud-row--archived'); updateRowArchivedState(row, false); filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active'); } catch { alert(t('errorRestore')); } } function updateRowArchivedState(row, archived) { const actions = row.querySelector('.crud-row__actions'); if (!actions) return; if (archived) { actions.innerHTML = ` `; row.querySelector('.crud-row__edit')?.remove(); } else { actions.innerHTML = ` `; } } function filterByTab(tab) { document.querySelectorAll('#crud-list .crud-row').forEach(row => { row.hidden = tab === 'active' ? row.dataset.archived === '1' : row.dataset.archived === '0'; }); } function initTabs() { const tabs = document.querySelectorAll('.crud-tab'); if (!tabs.length) return; filterByTab('active'); tabs.forEach(tab => { tab.addEventListener('click', () => { tabs.forEach(t => t.classList.remove('crud-tab--active')); tab.classList.add('crud-tab--active'); filterByTab(tab.dataset.tab); const btnNew = document.getElementById('btn-new'); if (btnNew) btnNew.hidden = tab.dataset.tab === 'archived'; }); }); } // ── Sortierte Einfügung ───────────────────────────────────────────────────── function insertRowSorted(container, row) { const name = (row.dataset.name || '').toLowerCase(); const rows = container.querySelectorAll('.crud-row:not(.crud-row--archived)'); for (const existing of rows) { if (existing === row) continue; if ((existing.dataset.name || '').toLowerCase() > name) { existing.before(row); return; } } container.appendChild(row); } // ── Neue Zeile einfügen ────────────────────────────────────────────────────── function appendRowToList(data) { const list = document.getElementById('crud-list'); if (!list) return; const html = buildRowHTML(data); if (data.billable !== undefined) { const groupLabel = data.billable ? t('groupBillable') : t('groupNotBillable'); let targetGroup = null; list.querySelectorAll('.crud-list__group').forEach(g => { if (g.querySelector('.crud-list__group-label')?.textContent === groupLabel) { targetGroup = g; } }); if (targetGroup) { targetGroup.insertAdjacentHTML('beforeend', html); } else { const groupHtml = `
${esc(groupLabel)}
${html}
`; if (!data.billable) { list.insertAdjacentHTML('beforeend', groupHtml); } else { const firstGroup = list.querySelector('.crud-list__group'); firstGroup ? firstGroup.insertAdjacentHTML('beforebegin', groupHtml) : list.insertAdjacentHTML('beforeend', groupHtml); } } } else { list.insertAdjacentHTML('beforeend', html); } const prefix = rowPrefix(); const el = document.getElementById(`${prefix}-${data.id}`); if (el) { if (data.billable === undefined) insertRowSorted(list, el); animateIn(el, 'crud-row--new'); if (hasLexoffice()) initLexofficeEditToggle(el); syncLexofficeAfterSave(el, data); } } function buildRowHTML(data) { const prefix = rowPrefix(); let metaHtml = ''; let editFields = ''; if (data.projectCount !== undefined) { const c = data.projectCount; const hasCustomRate = data.hourlyRate != null && data.hourlyRate !== ''; const isLex = data.lexofficeContactId != null && data.lexofficeContactId !== ''; metaHtml = `${c} ${c === 1 ? t('projectSingular') : t('projectPlural')}`; const lexCheckbox = hasLexoffice() ? `
` : ''; const lexSelect = hasLexoffice() ? ` ` : ''; editFields = `${lexCheckbox}${lexSelect}
`; } if (data.clientName !== undefined && data.projectCount === undefined) { const hasCustomRate = data.hourlyRate != null && data.hourlyRate !== ''; metaHtml = `${esc(data.clientName)}`; editFields = `
`; } if (data.billable !== undefined) { editFields = `
`; } return `
${esc(data.name)} ${metaHtml}
`; } // ── Init ───────────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { initCreateForm(); initList(); initTabs(); initRateModeToggles(); initLexofficeCreateToggle(); initLexofficeEditToggles(); });