diff --git a/.claude/settings.local.json b/.claude/settings.local.json index fb024fe..b067396 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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)" ] } } diff --git a/httpdocs/assets/scripts/account.js b/httpdocs/assets/scripts/account.js index 16db77c..4bb3b60 100644 --- a/httpdocs/assets/scripts/account.js +++ b/httpdocs/assets/scripts/account.js @@ -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'); diff --git a/httpdocs/assets/scripts/crud.js b/httpdocs/assets/scripts/crud.js index aa26e0e..85c497e 100644 --- a/httpdocs/assets/scripts/crud.js +++ b/httpdocs/assets/scripts/crud.js @@ -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 = ''; + + 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 = `${c} ${c === 1 ? t('projectSingular') : t('projectPlural')}`; - editFields = ` + + const lexCheckbox = hasLexoffice() ? ` + +
+ +
` : ''; + + const lexSelect = hasLexoffice() ? ` + + ` : ''; + + editFields = `${lexCheckbox}${lexSelect} -
+
@@ -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 ?? '')}">
@@ -531,4 +852,6 @@ document.addEventListener('DOMContentLoaded', () => { initList(); initTabs(); initRateModeToggles(); + initLexofficeCreateToggle(); + initLexofficeEditToggles(); }); diff --git a/httpdocs/assets/scripts/searchable-select.js b/httpdocs/assets/scripts/searchable-select.js new file mode 100644 index 0000000..7af3f57 --- /dev/null +++ b/httpdocs/assets/scripts/searchable-select.js @@ -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 = ` + + `; + + 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 += `
${esc(g.label)}
`; + } + for (const item of filtered) { + const active = String(item.id) === this.value ? ' ss__item--active' : ''; + const hl = idx === this.highlightIdx ? ' ss__item--highlight' : ''; + html += `
${esc(item.name)}
`; + visibleItems.push(item); + idx++; + } + } + + this.list.innerHTML = html || `
`; + 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' }); + } +} diff --git a/httpdocs/assets/scripts/stopwatch.js b/httpdocs/assets/scripts/stopwatch.js index 27e2e83..5d36dbf 100644 --- a/httpdocs/assets/scripts/stopwatch.js +++ b/httpdocs/assets/scripts/stopwatch.js @@ -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 = ` - - `; - - 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 += `
${esc(g.label)}
`; - for (const item of filtered) { - const active = String(item.id) === this.value ? ' ss__item--active' : ''; - const hl = idx === this.highlightIdx ? ' ss__item--highlight' : ''; - html += `
${esc(item.name)}
`; - visibleItems.push(item); - idx++; - } - } - - this.list.innerHTML = html || `
`; - 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; } diff --git a/httpdocs/assets/styles/atoms/_inputs.scss b/httpdocs/assets/styles/atoms/_inputs.scss index 8287e96..3ab3ec4 100644 --- a/httpdocs/assets/styles/atoms/_inputs.scss +++ b/httpdocs/assets/styles/atoms/_inputs.scss @@ -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 ───────────────────────────────────────────────────────────── diff --git a/httpdocs/assets/styles/components/_account.scss b/httpdocs/assets/styles/components/_account.scss index 1d485ad..0b3f6e2 100644 --- a/httpdocs/assets/styles/components/_account.scss +++ b/httpdocs/assets/styles/components/_account.scss @@ -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; diff --git a/httpdocs/assets/styles/components/_crud.scss b/httpdocs/assets/styles/components/_crud.scss index 29967e8..c1b4ec9 100644 --- a/httpdocs/assets/styles/components/_crud.scss +++ b/httpdocs/assets/styles/components/_crud.scss @@ -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; diff --git a/httpdocs/composer.json b/httpdocs/composer.json index cf89f09..84e3d87 100644 --- a/httpdocs/composer.json +++ b/httpdocs/composer.json @@ -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.*", diff --git a/httpdocs/composer.lock b/httpdocs/composer.lock index 1b387d4..3901ea0 100644 --- a/httpdocs/composer.lock +++ b/httpdocs/composer.lock @@ -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", diff --git a/httpdocs/config/reference.php b/httpdocs/config/reference.php index 13f6b54..6b20da6 100644 --- a/httpdocs/config/reference.php +++ b/httpdocs/config/reference.php @@ -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, diff --git a/httpdocs/migrations/central/Version20260617100000.php b/httpdocs/migrations/central/Version20260617100000.php new file mode 100644 index 0000000..77c1515 --- /dev/null +++ b/httpdocs/migrations/central/Version20260617100000.php @@ -0,0 +1,26 @@ +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'); + } +} diff --git a/httpdocs/migrations/tenant/Version20260617100000.php b/httpdocs/migrations/tenant/Version20260617100000.php new file mode 100644 index 0000000..0e71e55 --- /dev/null +++ b/httpdocs/migrations/tenant/Version20260617100000.php @@ -0,0 +1,26 @@ +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'); + } +} diff --git a/httpdocs/src/Controller/AccountController.php b/httpdocs/src/Controller/AccountController.php index 7bfdb83..8a3e003 100644 --- a/httpdocs/src/Controller/AccountController.php +++ b/httpdocs/src/Controller/AccountController.php @@ -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()]); diff --git a/httpdocs/src/Controller/ClientController.php b/httpdocs/src/Controller/ClientController.php index 60cb78d..05226ca 100644 --- a/httpdocs/src/Controller/ClientController.php +++ b/httpdocs/src/Controller/ClientController.php @@ -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(), ]; } } diff --git a/httpdocs/src/Controller/LexofficeController.php b/httpdocs/src/Controller/LexofficeController.php new file mode 100644 index 0000000..c3fecbd --- /dev/null +++ b/httpdocs/src/Controller/LexofficeController.php @@ -0,0 +1,69 @@ +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); + } +} diff --git a/httpdocs/src/Entity/Central/Account.php b/httpdocs/src/Entity/Central/Account.php index 1ce1f76..587f408 100644 --- a/httpdocs/src/Entity/Central/Account.php +++ b/httpdocs/src/Entity/Central/Account.php @@ -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; } diff --git a/httpdocs/src/Entity/Tenant/Client.php b/httpdocs/src/Entity/Tenant/Client.php index 93da328..92dbf83 100644 --- a/httpdocs/src/Entity/Tenant/Client.php +++ b/httpdocs/src/Entity/Tenant/Client.php @@ -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; } diff --git a/httpdocs/src/Service/LexofficeService.php b/httpdocs/src/Service/LexofficeService.php new file mode 100644 index 0000000..926cd29 --- /dev/null +++ b/httpdocs/src/Service/LexofficeService.php @@ -0,0 +1,102 @@ +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); + } +} diff --git a/httpdocs/templates/account/index.html.twig b/httpdocs/templates/account/index.html.twig index 739fa3d..75fcddb 100644 --- a/httpdocs/templates/account/index.html.twig +++ b/httpdocs/templates/account/index.html.twig @@ -93,6 +93,25 @@
{% endif %} + + + + +