| @@ -13,7 +13,10 @@ | |||
| "Bash(php -l src/Controller/AccountController.php)", | |||
| "Bash(php -l src/Controller/ClientController.php)", | |||
| "Bash(php *)", | |||
| "Bash(echo \"exit: $?\")" | |||
| "Bash(echo \"exit: $?\")", | |||
| "WebSearch", | |||
| "WebFetch(domain:developers.lexoffice.io)", | |||
| "WebFetch(domain:developers.lexware.io)" | |||
| ] | |||
| } | |||
| } | |||
| @@ -77,6 +77,12 @@ document.addEventListener('DOMContentLoaded', () => { | |||
| payload.primaryColor = hex || ''; | |||
| } | |||
| const lexofficeKey = document.getElementById('account-lexoffice-key'); | |||
| if (lexofficeKey && !lexofficeKey.hidden) { | |||
| const keyVal = lexofficeKey.value.trim(); | |||
| if (keyVal) payload.lexofficeApiKey = keyVal; | |||
| } | |||
| btnAccountSave.disabled = true; | |||
| try { | |||
| await patchJson('/api/account', payload); | |||
| @@ -116,6 +122,22 @@ document.addEventListener('DOMContentLoaded', () => { | |||
| }); | |||
| } | |||
| // ── Lexoffice-Key-Toggle ─────────────────────────────────────────────────── | |||
| const btnLexKeyChange = document.getElementById('btn-lexoffice-key-change'); | |||
| const lexKeyInput = document.getElementById('account-lexoffice-key'); | |||
| const lexKeyStatus = document.getElementById('lexoffice-key-status'); | |||
| if (btnLexKeyChange && lexKeyInput && lexKeyStatus) { | |||
| btnLexKeyChange.addEventListener('click', (e) => { | |||
| e.preventDefault(); | |||
| const showing = !lexKeyInput.hidden; | |||
| lexKeyInput.hidden = showing; | |||
| lexKeyStatus.querySelector('.account-form__key-mask').hidden = !showing; | |||
| btnLexKeyChange.textContent = showing ? t('changeLabel') : t('cancelLabel'); | |||
| if (!showing) lexKeyInput.focus(); | |||
| }); | |||
| } | |||
| // ── Passwort-Toggle ─────────────────────────────────────────────────────── | |||
| const btnPwToggle = document.getElementById('btn-pw-toggle'); | |||
| @@ -1,6 +1,7 @@ | |||
| // 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 ?? ''; | |||
| @@ -25,6 +26,258 @@ function rowPrefix() { | |||
| 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 = '<svg viewBox="0 0 16 16" fill="none"><path d="M2 8a6 6 0 1 1 1.5 3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M2 13V9h4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>'; | |||
| 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() { | |||
| @@ -69,7 +322,7 @@ function initCreateForm() { | |||
| function resetCreateForm() { | |||
| ['create-name', 'create-note'].forEach(id => { | |||
| const el = document.getElementById(id); | |||
| if (el) el.value = ''; | |||
| if (el) { el.value = ''; el.disabled = false; } | |||
| }); | |||
| const rate = document.getElementById('create-rate'); | |||
| if (rate) rate.value = ''; | |||
| @@ -84,6 +337,13 @@ function resetCreateForm() { | |||
| 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() { | |||
| @@ -138,6 +398,14 @@ function buildCreateBody() { | |||
| 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; | |||
| } | |||
| @@ -198,7 +466,10 @@ async function saveEdit(row) { | |||
| 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 { | |||
| @@ -226,6 +497,14 @@ function buildEditBody(row) { | |||
| 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; | |||
| } | |||
| @@ -241,6 +520,7 @@ function updateRowDisplay(row, data) { | |||
| 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; | |||
| @@ -364,6 +644,22 @@ function initTabs() { | |||
| }); | |||
| } | |||
| // ── 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) { | |||
| @@ -401,7 +697,12 @@ function appendRowToList(data) { | |||
| const prefix = rowPrefix(); | |||
| const el = document.getElementById(`${prefix}-${data.id}`); | |||
| if (el) animateIn(el, 'crud-row--new'); | |||
| if (el) { | |||
| if (data.billable === undefined) insertRowSorted(list, el); | |||
| animateIn(el, 'crud-row--new'); | |||
| if (hasLexoffice()) initLexofficeEditToggle(el); | |||
| syncLexofficeAfterSave(el, data); | |||
| } | |||
| } | |||
| function buildRowHTML(data) { | |||
| @@ -412,10 +713,29 @@ function buildRowHTML(data) { | |||
| if (data.projectCount !== undefined) { | |||
| const c = data.projectCount; | |||
| const hasCustomRate = data.hourlyRate != null && data.hourlyRate !== ''; | |||
| const isLex = data.lexofficeContactId != null && data.lexofficeContactId !== ''; | |||
| metaHtml = `<span class="crud-row__meta">${c} ${c === 1 ? t('projectSingular') : t('projectPlural')}</span>`; | |||
| editFields = ` | |||
| const lexCheckbox = hasLexoffice() ? ` | |||
| <label class="entry-form__label"> </label> | |||
| <div class="entry-form__field"> | |||
| <label class="crud-checkbox-label"> | |||
| <input type="checkbox" class="edit-lexoffice" ${isLex ? 'checked' : ''} /> | |||
| <span>${t('lexofficeCheckbox')}</span> | |||
| </label> | |||
| </div>` : ''; | |||
| const lexSelect = hasLexoffice() ? ` | |||
| <label class="entry-form__label edit-lexoffice-select-label" hidden>${t('lexofficeSelect')}</label> | |||
| <div class="entry-form__field edit-lexoffice-select-field" hidden> | |||
| <div class="lexoffice-select-wrap edit-lexoffice-select" | |||
| data-placeholder="${t('lexofficeSelect')}" | |||
| data-contact-id="${esc(data.lexofficeContactId ?? '')}"></div> | |||
| </div>` : ''; | |||
| editFields = `${lexCheckbox}${lexSelect} | |||
| <label class="entry-form__label">${t('labelName')}</label> | |||
| <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div> | |||
| <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" ${isLex ? 'disabled' : ''} /></div> | |||
| <label class="entry-form__label">${t('labelRate')}</label> | |||
| <div class="entry-form__field"> | |||
| <div class="rate-mode"> | |||
| @@ -495,7 +815,8 @@ function buildRowHTML(data) { | |||
| ${data.hourlyRate !== undefined ? `data-rate="${esc(data.hourlyRate ?? '')}"` : ''} | |||
| ${data.clientId !== undefined ? `data-client-id="${data.clientId}"` : ''} | |||
| ${data.billable !== undefined ? `data-billable="${data.billable ? '1' : '0'}"` : ''} | |||
| data-note="${esc(data.note ?? '')}"> | |||
| data-note="${esc(data.note ?? '')}" | |||
| data-lexoffice-contact-id="${esc(data.lexofficeContactId ?? '')}"> | |||
| <div class="crud-row__display"> | |||
| <div class="crud-row__info"> | |||
| @@ -531,4 +852,6 @@ document.addEventListener('DOMContentLoaded', () => { | |||
| initList(); | |||
| initTabs(); | |||
| initRateModeToggles(); | |||
| initLexofficeCreateToggle(); | |||
| initLexofficeEditToggles(); | |||
| }); | |||
| @@ -0,0 +1,152 @@ | |||
| import { esc } from './utils.js'; | |||
| export class SearchableSelect { | |||
| constructor(container, { searchPlaceholder = '...' } = {}) { | |||
| this.container = container; | |||
| this.value = ''; | |||
| this.label = ''; | |||
| this.groups = []; | |||
| this.open = false; | |||
| this.highlightIdx = -1; | |||
| this.placeholder = container.dataset.placeholder || '...'; | |||
| this.onSelect = null; | |||
| this.container.innerHTML = ` | |||
| <button type="button" class="ss__trigger"> | |||
| <span class="ss__value">${esc(this.placeholder)}</span> | |||
| <svg class="ss__arrow" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round"/></svg> | |||
| </button> | |||
| <div class="ss__dropdown" hidden> | |||
| <input type="text" class="ss__search" placeholder="${esc(searchPlaceholder)}"> | |||
| <div class="ss__list"></div> | |||
| </div>`; | |||
| this.trigger = container.querySelector('.ss__trigger'); | |||
| this.valueEl = container.querySelector('.ss__value'); | |||
| this.dropdown = container.querySelector('.ss__dropdown'); | |||
| this.search = container.querySelector('.ss__search'); | |||
| this.list = container.querySelector('.ss__list'); | |||
| this.trigger.addEventListener('click', (e) => { | |||
| e.stopPropagation(); | |||
| this.toggle(); | |||
| }); | |||
| this.search.addEventListener('input', () => this.render()); | |||
| this.search.addEventListener('keydown', (e) => this.onKeydown(e)); | |||
| this.list.addEventListener('click', (e) => { | |||
| const item = e.target.closest('[data-value]'); | |||
| if (item) this.select(item.dataset.value, item.dataset.label); | |||
| }); | |||
| document.addEventListener('click', (e) => { | |||
| if (this.open && !this.container.contains(e.target)) this.close(); | |||
| }); | |||
| } | |||
| setGroups(groups) { | |||
| this.groups = groups; | |||
| } | |||
| setValue(val) { | |||
| for (const g of this.groups) { | |||
| for (const item of g.items) { | |||
| if (String(item.id) === String(val)) { | |||
| this.value = String(item.id); | |||
| this.label = item.name; | |||
| this.valueEl.textContent = item.name; | |||
| this.valueEl.classList.add('ss__value--selected'); | |||
| return; | |||
| } | |||
| } | |||
| } | |||
| this.value = ''; | |||
| this.label = ''; | |||
| this.valueEl.textContent = this.placeholder; | |||
| this.valueEl.classList.remove('ss__value--selected'); | |||
| } | |||
| getValue() { return this.value; } | |||
| toggle() { | |||
| this.open ? this.close() : this.openDropdown(); | |||
| } | |||
| openDropdown() { | |||
| this.open = true; | |||
| this.dropdown.hidden = false; | |||
| this.search.value = ''; | |||
| this.highlightIdx = -1; | |||
| this.render(); | |||
| this.search.focus(); | |||
| } | |||
| close() { | |||
| this.open = false; | |||
| this.dropdown.hidden = true; | |||
| } | |||
| select(val, label) { | |||
| this.value = val; | |||
| this.label = label; | |||
| this.valueEl.textContent = label; | |||
| this.valueEl.classList.add('ss__value--selected'); | |||
| this.close(); | |||
| if (this.onSelect) this.onSelect(val, label); | |||
| } | |||
| focus() { | |||
| this.openDropdown(); | |||
| } | |||
| render() { | |||
| const q = this.search.value.toLowerCase().trim(); | |||
| let html = ''; | |||
| let idx = 0; | |||
| const visibleItems = []; | |||
| for (const g of this.groups) { | |||
| const filtered = g.items.filter(item => | |||
| !q || item.name.toLowerCase().includes(q) || g.label.toLowerCase().includes(q) | |||
| ); | |||
| if (!filtered.length) continue; | |||
| if (g.label) { | |||
| html += `<div class="ss__group">${esc(g.label)}</div>`; | |||
| } | |||
| for (const item of filtered) { | |||
| const active = String(item.id) === this.value ? ' ss__item--active' : ''; | |||
| const hl = idx === this.highlightIdx ? ' ss__item--highlight' : ''; | |||
| html += `<div class="ss__item${active}${hl}" data-value="${item.id}" data-label="${esc(item.name)}" data-idx="${idx}">${esc(item.name)}</div>`; | |||
| visibleItems.push(item); | |||
| idx++; | |||
| } | |||
| } | |||
| this.list.innerHTML = html || `<div class="ss__empty">–</div>`; | |||
| this.visibleCount = visibleItems.length; | |||
| } | |||
| onKeydown(e) { | |||
| if (e.key === 'ArrowDown') { | |||
| e.preventDefault(); | |||
| this.highlightIdx = Math.min(this.highlightIdx + 1, this.visibleCount - 1); | |||
| this.render(); | |||
| this.scrollToHighlight(); | |||
| } else if (e.key === 'ArrowUp') { | |||
| e.preventDefault(); | |||
| this.highlightIdx = Math.max(this.highlightIdx - 1, 0); | |||
| this.render(); | |||
| this.scrollToHighlight(); | |||
| } else if (e.key === 'Enter') { | |||
| e.preventDefault(); | |||
| const el = this.list.querySelector(`[data-idx="${this.highlightIdx}"]`); | |||
| if (el) this.select(el.dataset.value, el.dataset.label); | |||
| } else if (e.key === 'Escape') { | |||
| this.close(); | |||
| } | |||
| } | |||
| scrollToHighlight() { | |||
| const el = this.list.querySelector('.ss__item--highlight'); | |||
| if (el) el.scrollIntoView({ block: 'nearest' }); | |||
| } | |||
| } | |||
| @@ -1,6 +1,7 @@ | |||
| // assets/scripts/stopwatch.js | |||
| import { esc, createTranslator } from './utils.js'; | |||
| import { SearchableSelect } from './searchable-select.js'; | |||
| const LS_KEY = 'tt_timer_state'; | |||
| const LAST_PROJECT_KEY = 'tt_last_project_id'; | |||
| @@ -28,154 +29,7 @@ async function apiCall(url, options = {}) { | |||
| return { ok: res.ok, status: res.status, data }; | |||
| } | |||
| // ── Searchable Select ───────────────────────────────────────────────────────── | |||
| class SearchableSelect { | |||
| constructor(container) { | |||
| this.container = container; | |||
| this.value = ''; | |||
| this.label = ''; | |||
| this.groups = []; | |||
| this.open = false; | |||
| this.highlightIdx = -1; | |||
| this.placeholder = container.dataset.placeholder || '...'; | |||
| this.container.innerHTML = ` | |||
| <button type="button" class="ss__trigger"> | |||
| <span class="ss__value">${esc(this.placeholder)}</span> | |||
| <svg class="ss__arrow" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round"/></svg> | |||
| </button> | |||
| <div class="ss__dropdown" hidden> | |||
| <input type="text" class="ss__search" placeholder="${esc(t('search'))}"> | |||
| <div class="ss__list"></div> | |||
| </div>`; | |||
| this.trigger = container.querySelector('.ss__trigger'); | |||
| this.valueEl = container.querySelector('.ss__value'); | |||
| this.dropdown = container.querySelector('.ss__dropdown'); | |||
| this.search = container.querySelector('.ss__search'); | |||
| this.list = container.querySelector('.ss__list'); | |||
| this.trigger.addEventListener('click', (e) => { | |||
| e.stopPropagation(); | |||
| this.toggle(); | |||
| }); | |||
| this.search.addEventListener('input', () => this.render()); | |||
| this.search.addEventListener('keydown', (e) => this.onKeydown(e)); | |||
| this.list.addEventListener('click', (e) => { | |||
| const item = e.target.closest('[data-value]'); | |||
| if (item) this.select(item.dataset.value, item.dataset.label); | |||
| }); | |||
| document.addEventListener('click', (e) => { | |||
| if (this.open && !this.container.contains(e.target)) this.close(); | |||
| }); | |||
| } | |||
| setGroups(groups) { | |||
| this.groups = groups; | |||
| } | |||
| setValue(val) { | |||
| for (const g of this.groups) { | |||
| for (const item of g.items) { | |||
| if (String(item.id) === String(val)) { | |||
| this.value = String(item.id); | |||
| this.label = item.name; | |||
| this.valueEl.textContent = item.name; | |||
| this.valueEl.classList.add('ss__value--selected'); | |||
| return; | |||
| } | |||
| } | |||
| } | |||
| this.value = ''; | |||
| this.label = ''; | |||
| this.valueEl.textContent = this.placeholder; | |||
| this.valueEl.classList.remove('ss__value--selected'); | |||
| } | |||
| getValue() { return this.value; } | |||
| toggle() { | |||
| this.open ? this.close() : this.openDropdown(); | |||
| } | |||
| openDropdown() { | |||
| this.open = true; | |||
| this.dropdown.hidden = false; | |||
| this.search.value = ''; | |||
| this.highlightIdx = -1; | |||
| this.render(); | |||
| this.search.focus(); | |||
| } | |||
| close() { | |||
| this.open = false; | |||
| this.dropdown.hidden = true; | |||
| } | |||
| select(val, label) { | |||
| this.value = val; | |||
| this.label = label; | |||
| this.valueEl.textContent = label; | |||
| this.valueEl.classList.add('ss__value--selected'); | |||
| this.close(); | |||
| } | |||
| focus() { | |||
| this.openDropdown(); | |||
| } | |||
| render() { | |||
| const q = this.search.value.toLowerCase().trim(); | |||
| let html = ''; | |||
| let idx = 0; | |||
| const visibleItems = []; | |||
| for (const g of this.groups) { | |||
| const filtered = g.items.filter(item => | |||
| !q || item.name.toLowerCase().includes(q) || g.label.toLowerCase().includes(q) | |||
| ); | |||
| if (!filtered.length) continue; | |||
| html += `<div class="ss__group">${esc(g.label)}</div>`; | |||
| for (const item of filtered) { | |||
| const active = String(item.id) === this.value ? ' ss__item--active' : ''; | |||
| const hl = idx === this.highlightIdx ? ' ss__item--highlight' : ''; | |||
| html += `<div class="ss__item${active}${hl}" data-value="${item.id}" data-label="${esc(item.name)}" data-idx="${idx}">${esc(item.name)}</div>`; | |||
| visibleItems.push(item); | |||
| idx++; | |||
| } | |||
| } | |||
| this.list.innerHTML = html || `<div class="ss__empty">–</div>`; | |||
| this.visibleCount = visibleItems.length; | |||
| } | |||
| onKeydown(e) { | |||
| if (e.key === 'ArrowDown') { | |||
| e.preventDefault(); | |||
| this.highlightIdx = Math.min(this.highlightIdx + 1, this.visibleCount - 1); | |||
| this.render(); | |||
| this.scrollToHighlight(); | |||
| } else if (e.key === 'ArrowUp') { | |||
| e.preventDefault(); | |||
| this.highlightIdx = Math.max(this.highlightIdx - 1, 0); | |||
| this.render(); | |||
| this.scrollToHighlight(); | |||
| } else if (e.key === 'Enter') { | |||
| e.preventDefault(); | |||
| const el = this.list.querySelector(`[data-idx="${this.highlightIdx}"]`); | |||
| if (el) this.select(el.dataset.value, el.dataset.label); | |||
| } else if (e.key === 'Escape') { | |||
| this.close(); | |||
| } | |||
| } | |||
| scrollToHighlight() { | |||
| const el = this.list.querySelector('.ss__item--highlight'); | |||
| if (el) el.scrollIntoView({ block: 'nearest' }); | |||
| } | |||
| } | |||
| // SearchableSelect ist jetzt in searchable-select.js extrahiert und wird oben importiert | |||
| // ── StopwatchManager ────────────────────────────────────────────────────────── | |||
| @@ -232,8 +86,9 @@ class StopwatchManager { | |||
| const projEl = document.getElementById(projectId); | |||
| const svcEl = document.getElementById(serviceId); | |||
| if (projEl) ctx.projectSelect = new SearchableSelect(projEl); | |||
| if (svcEl) ctx.serviceSelect = new SearchableSelect(svcEl); | |||
| const ssOpts = { searchPlaceholder: t('search') }; | |||
| if (projEl) ctx.projectSelect = new SearchableSelect(projEl, ssOpts); | |||
| if (svcEl) ctx.serviceSelect = new SearchableSelect(svcEl, ssOpts); | |||
| return ctx; | |||
| } | |||
| @@ -29,6 +29,12 @@ | |||
| border-color: var(--color-primary); | |||
| box-shadow: $shadow-focus; | |||
| } | |||
| &:disabled { | |||
| background-color: $color-card; | |||
| color: $color-text-muted; | |||
| cursor: not-allowed; | |||
| } | |||
| } | |||
| // ─── Input Sizes ───────────────────────────────────────────────────────────── | |||
| @@ -142,6 +142,19 @@ | |||
| &:hover { color: $color-text-dark; text-decoration: underline; } | |||
| } | |||
| .account-form__key-status { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: $space-3; | |||
| padding: $space-2 0; | |||
| } | |||
| .account-form__key-mask { | |||
| font-size: $font-size-base; | |||
| color: $color-text-muted; | |||
| letter-spacing: 0.1em; | |||
| } | |||
| // ─── Farbfeld ───────────────────────────────────────────────────────────────── | |||
| .account-color-field { | |||
| display: flex; | |||
| @@ -183,6 +183,39 @@ | |||
| border-bottom: 1px solid rgba($color-border, 0.5); | |||
| } | |||
| // ─── Lexoffice ──────────────────────────────────────────────────────────────── | |||
| .lexoffice-select-wrap { | |||
| position: relative; | |||
| display: flex; | |||
| align-items: flex-start; | |||
| gap: $space-2; | |||
| width: 100%; | |||
| > :first-child { | |||
| flex: 1; | |||
| min-width: 0; | |||
| } | |||
| } | |||
| .lexoffice-reload { | |||
| @include icon-btn; | |||
| flex-shrink: 0; | |||
| color: $color-text-muted; | |||
| margin-top: 2px; | |||
| svg { width: $icon-svg-size; height: $icon-svg-size; } | |||
| &:hover { | |||
| background: rgba(var(--color-primary-rgb), 0.1); | |||
| color: var(--color-primary); | |||
| } | |||
| &:disabled { | |||
| opacity: 0.4; | |||
| pointer-events: none; | |||
| } | |||
| } | |||
| // ─── Checkbox-Label (Verrechenbar-Feld) ──────────────────────────────────────── | |||
| .crud-checkbox-label { | |||
| display: flex; | |||
| @@ -19,6 +19,7 @@ | |||
| "symfony/flex": "^2.10", | |||
| "symfony/form": "7.4.*", | |||
| "symfony/framework-bundle": "7.4.*", | |||
| "symfony/http-client": "7.4.*", | |||
| "symfony/mailer": "7.4.*", | |||
| "symfony/monolog-bundle": "^4.0.2", | |||
| "symfony/runtime": "7.4.*", | |||
| @@ -4,7 +4,7 @@ | |||
| "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", | |||
| "This file is @generated automatically" | |||
| ], | |||
| "content-hash": "6a52005068f345beb15a732e99cbb73a", | |||
| "content-hash": "13f7eb9a1c6bbad1d88d6c9cd35e8aa2", | |||
| "packages": [ | |||
| { | |||
| "name": "composer/pcre", | |||
| @@ -3840,6 +3840,189 @@ | |||
| ], | |||
| "time": "2026-05-13T12:04:42+00:00" | |||
| }, | |||
| { | |||
| "name": "symfony/http-client", | |||
| "version": "v7.4.13", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/symfony/http-client.git", | |||
| "reference": "e8a112b8415707265a7e614278136a9d92989a6a" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/symfony/http-client/zipball/e8a112b8415707265a7e614278136a9d92989a6a", | |||
| "reference": "e8a112b8415707265a7e614278136a9d92989a6a", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "php": ">=8.2", | |||
| "psr/log": "^1|^2|^3", | |||
| "symfony/deprecation-contracts": "^2.5|^3", | |||
| "symfony/http-client-contracts": "~3.4.4|^3.5.2", | |||
| "symfony/polyfill-php83": "^1.29", | |||
| "symfony/service-contracts": "^2.5|^3" | |||
| }, | |||
| "conflict": { | |||
| "amphp/amp": "<2.5", | |||
| "amphp/socket": "<1.1", | |||
| "php-http/discovery": "<1.15", | |||
| "symfony/http-foundation": "<6.4" | |||
| }, | |||
| "provide": { | |||
| "php-http/async-client-implementation": "*", | |||
| "php-http/client-implementation": "*", | |||
| "psr/http-client-implementation": "1.0", | |||
| "symfony/http-client-implementation": "3.0" | |||
| }, | |||
| "require-dev": { | |||
| "amphp/http-client": "^4.2.1|^5.0", | |||
| "amphp/http-tunnel": "^1.0|^2.0", | |||
| "guzzlehttp/promises": "^1.4|^2.0", | |||
| "nyholm/psr7": "^1.0", | |||
| "php-http/httplug": "^1.0|^2.0", | |||
| "psr/http-client": "^1.0", | |||
| "symfony/amphp-http-client-meta": "^1.0|^2.0", | |||
| "symfony/cache": "^6.4|^7.0|^8.0", | |||
| "symfony/dependency-injection": "^6.4|^7.0|^8.0", | |||
| "symfony/http-kernel": "^6.4|^7.0|^8.0", | |||
| "symfony/messenger": "^6.4|^7.0|^8.0", | |||
| "symfony/process": "^6.4|^7.0|^8.0", | |||
| "symfony/rate-limiter": "^6.4|^7.0|^8.0", | |||
| "symfony/stopwatch": "^6.4|^7.0|^8.0" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Symfony\\Component\\HttpClient\\": "" | |||
| }, | |||
| "exclude-from-classmap": [ | |||
| "/Tests/" | |||
| ] | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Nicolas Grekas", | |||
| "email": "p@tchwork.com" | |||
| }, | |||
| { | |||
| "name": "Symfony Community", | |||
| "homepage": "https://symfony.com/contributors" | |||
| } | |||
| ], | |||
| "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", | |||
| "homepage": "https://symfony.com", | |||
| "keywords": [ | |||
| "http" | |||
| ], | |||
| "support": { | |||
| "source": "https://github.com/symfony/http-client/tree/v7.4.13" | |||
| }, | |||
| "funding": [ | |||
| { | |||
| "url": "https://symfony.com/sponsor", | |||
| "type": "custom" | |||
| }, | |||
| { | |||
| "url": "https://github.com/fabpot", | |||
| "type": "github" | |||
| }, | |||
| { | |||
| "url": "https://github.com/nicolas-grekas", | |||
| "type": "github" | |||
| }, | |||
| { | |||
| "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", | |||
| "type": "tidelift" | |||
| } | |||
| ], | |||
| "time": "2026-05-24T09:57:54+00:00" | |||
| }, | |||
| { | |||
| "name": "symfony/http-client-contracts", | |||
| "version": "v3.7.0", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/symfony/http-client-contracts.git", | |||
| "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", | |||
| "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "php": ">=8.1" | |||
| }, | |||
| "type": "library", | |||
| "extra": { | |||
| "thanks": { | |||
| "url": "https://github.com/symfony/contracts", | |||
| "name": "symfony/contracts" | |||
| }, | |||
| "branch-alias": { | |||
| "dev-main": "3.7-dev" | |||
| } | |||
| }, | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Symfony\\Contracts\\HttpClient\\": "" | |||
| }, | |||
| "exclude-from-classmap": [ | |||
| "/Test/" | |||
| ] | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Nicolas Grekas", | |||
| "email": "p@tchwork.com" | |||
| }, | |||
| { | |||
| "name": "Symfony Community", | |||
| "homepage": "https://symfony.com/contributors" | |||
| } | |||
| ], | |||
| "description": "Generic abstractions related to HTTP clients", | |||
| "homepage": "https://symfony.com", | |||
| "keywords": [ | |||
| "abstractions", | |||
| "contracts", | |||
| "decoupling", | |||
| "interfaces", | |||
| "interoperability", | |||
| "standards" | |||
| ], | |||
| "support": { | |||
| "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" | |||
| }, | |||
| "funding": [ | |||
| { | |||
| "url": "https://symfony.com/sponsor", | |||
| "type": "custom" | |||
| }, | |||
| { | |||
| "url": "https://github.com/fabpot", | |||
| "type": "github" | |||
| }, | |||
| { | |||
| "url": "https://github.com/nicolas-grekas", | |||
| "type": "github" | |||
| }, | |||
| { | |||
| "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", | |||
| "type": "tidelift" | |||
| } | |||
| ], | |||
| "time": "2026-03-06T13:17:50+00:00" | |||
| }, | |||
| { | |||
| "name": "symfony/http-foundation", | |||
| "version": "v7.4.8", | |||
| @@ -472,7 +472,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; | |||
| * }, | |||
| * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true | |||
| * http_client?: bool|array{ // HTTP Client configuration | |||
| * enabled?: bool|Param, // Default: false | |||
| * enabled?: bool|Param, // Default: true | |||
| * max_host_connections?: int|Param, // The maximum number of connections to a single host. | |||
| * default_options?: array{ | |||
| * headers?: array<string, mixed>, | |||
| @@ -0,0 +1,26 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace DoctrineMigrations; | |||
| use Doctrine\DBAL\Schema\Schema; | |||
| use Doctrine\Migrations\AbstractMigration; | |||
| final class Version20260617100000 extends AbstractMigration | |||
| { | |||
| public function getDescription(): string | |||
| { | |||
| return 'Add lexoffice_api_key to account'; | |||
| } | |||
| public function up(Schema $schema): void | |||
| { | |||
| $this->addSql('ALTER TABLE `account` ADD lexoffice_api_key VARCHAR(255) DEFAULT NULL'); | |||
| } | |||
| public function down(Schema $schema): void | |||
| { | |||
| $this->addSql('ALTER TABLE `account` DROP COLUMN lexoffice_api_key'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,26 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace DoctrineMigrations; | |||
| use Doctrine\DBAL\Schema\Schema; | |||
| use Doctrine\Migrations\AbstractMigration; | |||
| final class Version20260617100000 extends AbstractMigration | |||
| { | |||
| public function getDescription(): string | |||
| { | |||
| return 'Add lexoffice_contact_id to client'; | |||
| } | |||
| public function up(Schema $schema): void | |||
| { | |||
| $this->addSql('ALTER TABLE `client` ADD lexoffice_contact_id VARCHAR(36) DEFAULT NULL'); | |||
| } | |||
| public function down(Schema $schema): void | |||
| { | |||
| $this->addSql('ALTER TABLE `client` DROP COLUMN lexoffice_contact_id'); | |||
| } | |||
| } | |||
| @@ -98,6 +98,11 @@ class AccountController extends AbstractController | |||
| $account->setPrimaryColor($hex); | |||
| } | |||
| if (array_key_exists('lexofficeApiKey', $data)) { | |||
| $key = trim($data['lexofficeApiKey'] ?? ''); | |||
| $account->setLexofficeApiKey($key !== '' ? $key : null); | |||
| } | |||
| $this->em->flush(); | |||
| return $this->json(['ok' => true, 'name' => $account->getName()]); | |||
| @@ -6,6 +6,8 @@ use App\Entity\Tenant\Client; | |||
| use App\Repository\Tenant\ClientRepository; | |||
| use App\Repository\Tenant\TimeEntryRepository; | |||
| use App\Service\AccountRoleHelper; | |||
| use App\Service\LexofficeService; | |||
| use App\Service\TenantContext; | |||
| use Doctrine\ORM\EntityManagerInterface; | |||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | |||
| use Symfony\Component\HttpFoundation\JsonResponse; | |||
| @@ -22,6 +24,8 @@ class ClientController extends AbstractController | |||
| private readonly TimeEntryRepository $timeEntryRepo, | |||
| private readonly AccountRoleHelper $roleHelper, | |||
| private readonly TranslatorInterface $translator, | |||
| private readonly TenantContext $tenantContext, | |||
| private readonly LexofficeService $lexofficeService, | |||
| ) {} | |||
| #[Route('/clients', name: 'client_index')] | |||
| @@ -30,8 +34,12 @@ class ClientController extends AbstractController | |||
| if ($this->roleHelper->isTracker()) { | |||
| throw $this->createAccessDeniedException(); | |||
| } | |||
| $account = $this->tenantContext->getAccount(); | |||
| return $this->render('client/index.html.twig', [ | |||
| 'clients' => $this->clientRepo->findAllOrderedByName(), | |||
| 'clients' => $this->clientRepo->findAllOrderedByName(), | |||
| 'hasLexofficeApiKey' => $account?->hasLexofficeApiKey() ?? false, | |||
| ]); | |||
| } | |||
| @@ -51,6 +59,7 @@ class ClientController extends AbstractController | |||
| $client->setName(trim($data['name'])); | |||
| $client->setHourlyRate(!empty($data['hourlyRate']) ? $data['hourlyRate'] : null); | |||
| $client->setNote(!empty($data['note']) ? $data['note'] : null); | |||
| $client->setLexofficeContactId(!empty($data['lexofficeContactId']) ? $data['lexofficeContactId'] : null); | |||
| $this->em->persist($client); | |||
| $this->em->flush(); | |||
| @@ -77,6 +86,10 @@ class ClientController extends AbstractController | |||
| $client->setHourlyRate(!empty($data['hourlyRate']) ? $data['hourlyRate'] : null); | |||
| $client->setNote(!empty($data['note']) ? $data['note'] : null); | |||
| if (array_key_exists('lexofficeContactId', $data)) { | |||
| $client->setLexofficeContactId(!empty($data['lexofficeContactId']) ? $data['lexofficeContactId'] : null); | |||
| } | |||
| $this->em->flush(); | |||
| return $this->json($this->clientToArray($client)); | |||
| @@ -131,15 +144,53 @@ class ClientController extends AbstractController | |||
| return $this->json($this->clientToArray($client)); | |||
| } | |||
| #[Route('/api/clients/{id}/lexoffice-refresh', name: 'api_client_lexoffice_refresh', methods: ['PATCH'])] | |||
| public function lexofficeRefresh(int $id): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $client = $this->clientRepo->find($id); | |||
| if (!$client) { | |||
| return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| } | |||
| if (!$client->isLexofficeClient()) { | |||
| return $this->json(['error' => $this->translator->trans('app.lexoffice.error_not_linked')], 400); | |||
| } | |||
| $account = $this->tenantContext->getAccount(); | |||
| if (!$account?->hasLexofficeApiKey()) { | |||
| return $this->json(['error' => $this->translator->trans('app.lexoffice.error_no_api_key')], 400); | |||
| } | |||
| try { | |||
| $contact = $this->lexofficeService->getContact($account->getLexofficeApiKey(), $client->getLexofficeContactId()); | |||
| } catch (\Throwable) { | |||
| return $this->json(['error' => $this->translator->trans('app.lexoffice.error_api')], 502); | |||
| } | |||
| if ($contact === null) { | |||
| return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| } | |||
| $client->setName($contact['name']); | |||
| $this->em->flush(); | |||
| return $this->json($this->clientToArray($client)); | |||
| } | |||
| private function clientToArray(Client $client): array | |||
| { | |||
| return [ | |||
| 'id' => $client->getId(), | |||
| 'name' => $client->getName(), | |||
| 'hourlyRate' => $client->getHourlyRate(), | |||
| 'note' => $client->getNote(), | |||
| 'projectCount' => $client->getProjects()->count(), | |||
| 'archived' => $client->isArchived(), | |||
| 'id' => $client->getId(), | |||
| 'name' => $client->getName(), | |||
| 'hourlyRate' => $client->getHourlyRate(), | |||
| 'note' => $client->getNote(), | |||
| 'projectCount' => $client->getProjects()->count(), | |||
| 'archived' => $client->isArchived(), | |||
| 'lexofficeContactId' => $client->getLexofficeContactId(), | |||
| ]; | |||
| } | |||
| } | |||
| @@ -0,0 +1,69 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Controller; | |||
| use App\Service\AccountRoleHelper; | |||
| use App\Service\LexofficeService; | |||
| use App\Service\TenantContext; | |||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | |||
| use Symfony\Component\HttpFoundation\JsonResponse; | |||
| use Symfony\Component\Routing\Attribute\Route; | |||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||
| class LexofficeController extends AbstractController | |||
| { | |||
| public function __construct( | |||
| private readonly TenantContext $tenantContext, | |||
| private readonly LexofficeService $lexofficeService, | |||
| private readonly AccountRoleHelper $roleHelper, | |||
| private readonly TranslatorInterface $translator, | |||
| ) {} | |||
| #[Route('/api/lexoffice/contacts', name: 'api_lexoffice_contacts', methods: ['GET'])] | |||
| public function contacts(): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $account = $this->tenantContext->getAccount(); | |||
| if (!$account?->hasLexofficeApiKey()) { | |||
| return $this->json(['error' => $this->translator->trans('app.lexoffice.error_no_api_key')], 400); | |||
| } | |||
| try { | |||
| $contacts = $this->lexofficeService->getCustomerContacts($account->getLexofficeApiKey()); | |||
| } catch (\Throwable) { | |||
| return $this->json(['error' => $this->translator->trans('app.lexoffice.error_api')], 502); | |||
| } | |||
| return $this->json($contacts); | |||
| } | |||
| #[Route('/api/lexoffice/contacts/{contactId}', name: 'api_lexoffice_contact', methods: ['GET'])] | |||
| public function contact(string $contactId): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $account = $this->tenantContext->getAccount(); | |||
| if (!$account?->hasLexofficeApiKey()) { | |||
| return $this->json(['error' => $this->translator->trans('app.lexoffice.error_no_api_key')], 400); | |||
| } | |||
| try { | |||
| $contact = $this->lexofficeService->getContact($account->getLexofficeApiKey(), $contactId); | |||
| } catch (\Throwable) { | |||
| return $this->json(['error' => $this->translator->trans('app.lexoffice.error_api')], 502); | |||
| } | |||
| if ($contact === null) { | |||
| return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| } | |||
| return $this->json($contact); | |||
| } | |||
| } | |||
| @@ -29,6 +29,9 @@ class Account | |||
| #[ORM\Column(length: 7, nullable: true)] | |||
| private ?string $primaryColor = null; | |||
| #[ORM\Column(length: 255, nullable: true)] | |||
| private ?string $lexofficeApiKey = null; | |||
| #[ORM\Column] | |||
| private \DateTimeImmutable $createdAt; | |||
| @@ -60,6 +63,10 @@ class Account | |||
| public function getPrimaryColor(): ?string { return $this->primaryColor; } | |||
| public function setPrimaryColor(?string $c): static { $this->primaryColor = $c; return $this; } | |||
| public function getLexofficeApiKey(): ?string { return $this->lexofficeApiKey; } | |||
| public function setLexofficeApiKey(?string $key): static { $this->lexofficeApiKey = $key; return $this; } | |||
| public function hasLexofficeApiKey(): bool { return $this->lexofficeApiKey !== null && $this->lexofficeApiKey !== ''; } | |||
| public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } | |||
| public function getSuperAdminUser(): ?User { return $this->superAdminUser; } | |||
| @@ -28,6 +28,9 @@ class Client | |||
| #[ORM\Column(type: Types::TEXT, nullable: true)] | |||
| private ?string $note = null; | |||
| #[ORM\Column(length: 36, nullable: true)] | |||
| private ?string $lexofficeContactId = null; | |||
| #[ORM\Column(nullable: true)] | |||
| private ?\DateTimeImmutable $archivedAt = null; | |||
| @@ -51,6 +54,10 @@ class Client | |||
| public function getNote(): ?string { return $this->note; } | |||
| public function setNote(?string $note): static { $this->note = $note; return $this; } | |||
| public function getLexofficeContactId(): ?string { return $this->lexofficeContactId; } | |||
| public function setLexofficeContactId(?string $id): static { $this->lexofficeContactId = $id; return $this; } | |||
| public function isLexofficeClient(): bool { return $this->lexofficeContactId !== null && $this->lexofficeContactId !== ''; } | |||
| public function getArchivedAt(): ?\DateTimeImmutable { return $this->archivedAt; } | |||
| public function setArchivedAt(?\DateTimeImmutable $archivedAt): static { $this->archivedAt = $archivedAt; return $this; } | |||
| public function isArchived(): bool { return $this->archivedAt !== null; } | |||
| @@ -0,0 +1,102 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Service; | |||
| use Symfony\Contracts\HttpClient\HttpClientInterface; | |||
| class LexofficeService | |||
| { | |||
| private const BASE_URL = 'https://api.lexware.io/v1'; | |||
| public function __construct( | |||
| private readonly HttpClientInterface $httpClient, | |||
| ) {} | |||
| /** | |||
| * @return array{id: string, name: string}[] | |||
| */ | |||
| public function getCustomerContacts(string $apiKey): array | |||
| { | |||
| $contacts = []; | |||
| $page = 0; | |||
| do { | |||
| $data = $this->requestWithRetry('GET', self::BASE_URL . '/contacts', $apiKey, [ | |||
| 'customer' => 'true', | |||
| 'page' => $page, | |||
| ]); | |||
| foreach ($data['content'] ?? [] as $contact) { | |||
| $name = $this->extractContactName($contact); | |||
| if ($name !== '') { | |||
| $contacts[] = ['id' => $contact['id'], 'name' => $name]; | |||
| } | |||
| } | |||
| $page++; | |||
| } while (!($data['last'] ?? true)); | |||
| usort($contacts, fn(array $a, array $b) => strcasecmp($a['name'], $b['name'])); | |||
| return $contacts; | |||
| } | |||
| /** | |||
| * @return array{id: string, name: string}|null | |||
| */ | |||
| public function getContact(string $apiKey, string $contactId): ?array | |||
| { | |||
| $response = $this->httpClient->request('GET', self::BASE_URL . '/contacts/' . $contactId, [ | |||
| 'headers' => ['Authorization' => 'Bearer ' . $apiKey, 'Accept' => 'application/json'], | |||
| ]); | |||
| if ($response->getStatusCode() !== 200) { | |||
| return null; | |||
| } | |||
| $contact = $response->toArray(); | |||
| $name = $this->extractContactName($contact); | |||
| return $name !== '' ? ['id' => $contact['id'], 'name' => $name] : null; | |||
| } | |||
| private function requestWithRetry(string $method, string $url, string $apiKey, array $query = []): array | |||
| { | |||
| $maxRetries = 3; | |||
| for ($attempt = 0; $attempt <= $maxRetries; $attempt++) { | |||
| $response = $this->httpClient->request($method, $url, [ | |||
| 'headers' => ['Authorization' => 'Bearer ' . $apiKey, 'Accept' => 'application/json'], | |||
| 'query' => $query, | |||
| ]); | |||
| $statusCode = $response->getStatusCode(); | |||
| if ($statusCode === 429) { | |||
| $retryAfter = (int) ($response->getHeaders(false)['retry-after'][0] ?? ($attempt + 1)); | |||
| usleep(max($retryAfter, $attempt + 1) * 1_000_000); | |||
| continue; | |||
| } | |||
| return $response->toArray(); | |||
| } | |||
| throw new \RuntimeException('Lexware API rate limit exceeded after retries'); | |||
| } | |||
| private function extractContactName(array $contact): string | |||
| { | |||
| if (!empty($contact['company']['name'])) { | |||
| return $contact['company']['name']; | |||
| } | |||
| $parts = array_filter([ | |||
| $contact['person']['firstName'] ?? '', | |||
| $contact['person']['lastName'] ?? '', | |||
| ]); | |||
| return implode(' ', $parts); | |||
| } | |||
| } | |||
| @@ -93,6 +93,25 @@ | |||
| </div> | |||
| {% endif %} | |||
| <div class="account-form__divider-row"> | |||
| <hr class="account-form__divider"> | |||
| </div> | |||
| <label class="account-form__label">{{ 'app.account.label_lexoffice_key'|trans }}</label> | |||
| <div class="account-form__field"> | |||
| {% if account.hasLexofficeApiKey() %} | |||
| <span class="account-form__key-status" id="lexoffice-key-status"> | |||
| <span class="account-form__key-mask">••••••••</span> | |||
| <a href="#" class="account-form__link" id="btn-lexoffice-key-change">{{ 'app.account.change_label'|trans }}</a> | |||
| </span> | |||
| {% endif %} | |||
| <input type="text" id="account-lexoffice-key" class="input" | |||
| {{ account.hasLexofficeApiKey() ? 'hidden' : '' }} | |||
| placeholder="{{ 'app.account.placeholder_lexoffice_key'|trans }}" | |||
| autocomplete="off" data-1p-ignore data-lpignore="true" /> | |||
| <span class="account-form__hint">{{ 'app.account.hint_lexoffice_key'|trans }}</span> | |||
| </div> | |||
| <div class="account-form__actions"> | |||
| <button type="button" class="btn btn-primary" id="btn-account-save">{{ 'app.entry.btn_save'|trans }}</button> | |||
| <a href="{{ path('account_index', {tab: 'account'}) }}" class="btn btn-secondary">{{ 'app.entry.btn_cancel'|trans }}</a> | |||
| @@ -9,6 +9,7 @@ | |||
| window.CRUD = { | |||
| apiBase: '/api/clients', | |||
| clients: null, | |||
| hasLexofficeApiKey: {{ hasLexofficeApiKey ? 'true' : 'false' }}, | |||
| i18n: { | |||
| confirmDelete: {{ 'app.crud.confirm_delete'|trans|json_encode|raw }}, | |||
| confirmArchive: {{ 'app.crud.confirm_archive'|trans|json_encode|raw }}, | |||
| @@ -35,6 +36,14 @@ window.CRUD = { | |||
| projectPlural: {{ 'app.crud.project_plural'|trans|json_encode|raw }}, | |||
| rateModeDefault: {{ 'app.crud.rate_mode_default'|trans|json_encode|raw }}, | |||
| rateModeCustom: {{ 'app.crud.rate_mode_custom'|trans|json_encode|raw }}, | |||
| lexofficeCheckbox: {{ 'app.lexoffice.checkbox_label'|trans|json_encode|raw }}, | |||
| lexofficeSelect: {{ 'app.lexoffice.select_contact'|trans|json_encode|raw }}, | |||
| lexofficeSearch: {{ 'app.lexoffice.search'|trans|json_encode|raw }}, | |||
| lexofficeReload: {{ 'app.lexoffice.reload'|trans|json_encode|raw }}, | |||
| lexofficeLoading: {{ 'app.lexoffice.loading'|trans|json_encode|raw }}, | |||
| lexofficeErrorLoad: {{ 'app.lexoffice.error_load_contacts'|trans|json_encode|raw }}, | |||
| lexofficeRefreshed: {{ 'app.lexoffice.refreshed'|trans|json_encode|raw }}, | |||
| lexofficeErrorApi: {{ 'app.lexoffice.error_api'|trans|json_encode|raw }}, | |||
| }, | |||
| }; | |||
| </script> | |||
| @@ -49,6 +58,24 @@ window.CRUD = { | |||
| <div class="crud-create" id="crud-create"> | |||
| <div class="entry-form__grid"> | |||
| {% if hasLexofficeApiKey %} | |||
| <label class="entry-form__label"> </label> | |||
| <div class="entry-form__field"> | |||
| <label class="crud-checkbox-label"> | |||
| <input type="checkbox" id="create-lexoffice" /> | |||
| <span>{{ 'app.lexoffice.checkbox_label'|trans }}</span> | |||
| </label> | |||
| </div> | |||
| {% endif %} | |||
| {% if hasLexofficeApiKey %} | |||
| <label class="entry-form__label" id="create-lexoffice-select-label" hidden>{{ 'app.lexoffice.select_contact'|trans }}</label> | |||
| <div class="entry-form__field" id="create-lexoffice-select-field" hidden> | |||
| <div class="lexoffice-select-wrap" id="create-lexoffice-select" | |||
| data-placeholder="{{ 'app.lexoffice.select_contact'|trans }}"></div> | |||
| </div> | |||
| {% endif %} | |||
| <label class="entry-form__label">{{ 'app.crud.label_name'|trans }}</label> | |||
| <div class="entry-form__field"> | |||
| <input type="text" id="create-name" class="input" placeholder="{{ 'app.client.placeholder_name'|trans }}" /> | |||
| @@ -98,7 +125,8 @@ window.CRUD = { | |||
| data-archived="{{ client.isArchived() ? '1' : '0' }}" | |||
| data-name="{{ client.name|e('html_attr') }}" | |||
| data-rate="{{ client.hourlyRate|default('') }}" | |||
| data-note="{{ client.note|default('')|e('html_attr') }}"> | |||
| data-note="{{ client.note|default('')|e('html_attr') }}" | |||
| data-lexoffice-contact-id="{{ client.lexofficeContactId|default('') }}"> | |||
| <div class="crud-row__display"> | |||
| <div class="crud-row__info"> | |||
| @@ -128,9 +156,29 @@ window.CRUD = { | |||
| <div class="crud-row__edit" hidden> | |||
| <div class="entry-form__grid entry-form__grid--inline"> | |||
| {% if hasLexofficeApiKey %} | |||
| <label class="entry-form__label"> </label> | |||
| <div class="entry-form__field"> | |||
| <label class="crud-checkbox-label"> | |||
| <input type="checkbox" class="edit-lexoffice" {{ client.isLexofficeClient() ? 'checked' : '' }} /> | |||
| <span>{{ 'app.lexoffice.checkbox_label'|trans }}</span> | |||
| </label> | |||
| </div> | |||
| <label class="entry-form__label edit-lexoffice-select-label" | |||
| {{ not client.isLexofficeClient() ? 'hidden' : '' }}>{{ 'app.lexoffice.select_contact'|trans }}</label> | |||
| <div class="entry-form__field edit-lexoffice-select-field" | |||
| {{ not client.isLexofficeClient() ? 'hidden' : '' }}> | |||
| <div class="lexoffice-select-wrap edit-lexoffice-select" | |||
| data-placeholder="{{ 'app.lexoffice.select_contact'|trans }}" | |||
| data-contact-id="{{ client.lexofficeContactId|default('') }}"></div> | |||
| </div> | |||
| {% endif %} | |||
| <label class="entry-form__label">{{ 'app.crud.label_name'|trans }}</label> | |||
| <div class="entry-form__field"> | |||
| <input type="text" class="input edit-name" value="{{ client.name }}" /> | |||
| <input type="text" class="input edit-name" value="{{ client.name }}" | |||
| {{ client.isLexofficeClient() ? 'disabled' : '' }} /> | |||
| </div> | |||
| <label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label> | |||
| @@ -132,6 +132,18 @@ app: | |||
| placeholder_name: "Kundenname" | |||
| empty: "Noch keine Kunden angelegt." | |||
| lexoffice: | |||
| checkbox_label: "Lexware Office Kunde" | |||
| select_contact: "Lexware-Kontakt" | |||
| search: "Suchen…" | |||
| reload: "Daten aus Lexware Office aktualisieren" | |||
| loading: "Lexware-Kontakte werden geladen…" | |||
| error_no_api_key: "Kein Lexware Office API-Key hinterlegt. Bitte in den Account-Einstellungen konfigurieren." | |||
| error_api: "Fehler bei der Kommunikation mit Lexware Office." | |||
| error_not_linked: "Dieser Kunde ist nicht mit Lexware Office verknüpft." | |||
| error_load_contacts: "Fehler beim Laden der Lexware-Kontakte." | |||
| refreshed: "Kundendaten aus Lexware Office aktualisiert." | |||
| project: | |||
| page_title: "Projekte" | |||
| btn_new: "Neues Projekt" | |||
| @@ -280,6 +292,9 @@ app: | |||
| new_owner_must_be_admin: "Der Benutzer muss aktiver Administrator sein." | |||
| deactivated: "Dein Konto wurde deaktiviert." | |||
| deactivated_api: "Konto deaktiviert." | |||
| label_lexoffice_key: "Lexware Office API-Key" | |||
| placeholder_lexoffice_key: "API-Key eingeben" | |||
| hint_lexoffice_key: "Erstelle den API-Key unter https://app.lexware.de/addons → Weitere Apps → 'Public API'. Oder direkt unter https://app.lexware.de/addons/public-api. Damit können Kunden mit Lexware-Kontakten verknüpft werden." | |||
| interval_minutes: "Minuten" | |||
| interval_quarter: "Viertelstunde" | |||
| interval_half: "Halbe Stunde" | |||