| @@ -13,7 +13,10 @@ | |||||
| "Bash(php -l src/Controller/AccountController.php)", | "Bash(php -l src/Controller/AccountController.php)", | ||||
| "Bash(php -l src/Controller/ClientController.php)", | "Bash(php -l src/Controller/ClientController.php)", | ||||
| "Bash(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 || ''; | 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; | btnAccountSave.disabled = true; | ||||
| try { | try { | ||||
| await patchJson('/api/account', payload); | 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 ─────────────────────────────────────────────────────── | // ── Passwort-Toggle ─────────────────────────────────────────────────────── | ||||
| const btnPwToggle = document.getElementById('btn-pw-toggle'); | const btnPwToggle = document.getElementById('btn-pw-toggle'); | ||||
| @@ -1,6 +1,7 @@ | |||||
| // assets/scripts/crud.js | // assets/scripts/crud.js | ||||
| import { esc, createTranslator, ANIMATION_MS, removeWithAnimation, animateIn } from './utils.js'; | import { esc, createTranslator, ANIMATION_MS, removeWithAnimation, animateIn } from './utils.js'; | ||||
| import { SearchableSelect } from './searchable-select.js'; | |||||
| const api = window.CRUD?.apiBase ?? ''; | const api = window.CRUD?.apiBase ?? ''; | ||||
| @@ -25,6 +26,258 @@ function rowPrefix() { | |||||
| return 'row'; | 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 ─────────────────────────────────────────────────── | // ── Rate-Mode Radio Toggle ─────────────────────────────────────────────────── | ||||
| function initRateModeToggles() { | function initRateModeToggles() { | ||||
| @@ -69,7 +322,7 @@ function initCreateForm() { | |||||
| function resetCreateForm() { | function resetCreateForm() { | ||||
| ['create-name', 'create-note'].forEach(id => { | ['create-name', 'create-note'].forEach(id => { | ||||
| const el = document.getElementById(id); | const el = document.getElementById(id); | ||||
| if (el) el.value = ''; | |||||
| if (el) { el.value = ''; el.disabled = false; } | |||||
| }); | }); | ||||
| const rate = document.getElementById('create-rate'); | const rate = document.getElementById('create-rate'); | ||||
| if (rate) rate.value = ''; | if (rate) rate.value = ''; | ||||
| @@ -84,6 +337,13 @@ function resetCreateForm() { | |||||
| const rateInput = defaultRadio.closest('.rate-mode')?.querySelector('.rate-mode__input'); | const rateInput = defaultRadio.closest('.rate-mode')?.querySelector('.rate-mode__input'); | ||||
| if (rateInput) rateInput.hidden = true; | 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() { | async function createEntity() { | ||||
| @@ -138,6 +398,14 @@ function buildCreateBody() { | |||||
| const billable = document.getElementById('create-billable'); | const billable = document.getElementById('create-billable'); | ||||
| if (billable) body.billable = billable.checked; | 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; | return body; | ||||
| } | } | ||||
| @@ -198,7 +466,10 @@ async function saveEdit(row) { | |||||
| const data = await res.json(); | const data = await res.json(); | ||||
| updateRowDisplay(row, data); | updateRowDisplay(row, data); | ||||
| syncLexofficeAfterSave(row, data); | |||||
| closeEdit(row); | closeEdit(row); | ||||
| const list = document.getElementById('crud-list'); | |||||
| if (list && data.billable === undefined) insertRowSorted(list, row); | |||||
| } catch { | } catch { | ||||
| alert(t('errorSave')); | alert(t('errorSave')); | ||||
| } finally { | } finally { | ||||
| @@ -226,6 +497,14 @@ function buildEditBody(row) { | |||||
| const billable = row.querySelector('.edit-billable'); | const billable = row.querySelector('.edit-billable'); | ||||
| if (billable) body.billable = billable.checked; | 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; | return body; | ||||
| } | } | ||||
| @@ -241,6 +520,7 @@ function updateRowDisplay(row, data) { | |||||
| if (data.clientId !== undefined) row.dataset.clientId = data.clientId; | if (data.clientId !== undefined) row.dataset.clientId = data.clientId; | ||||
| if (data.billable !== undefined) row.dataset.billable = data.billable ? '1' : '0'; | if (data.billable !== undefined) row.dataset.billable = data.billable ? '1' : '0'; | ||||
| if (data.note !== undefined) row.dataset.note = data.note ?? ''; | if (data.note !== undefined) row.dataset.note = data.note ?? ''; | ||||
| if (data.lexofficeContactId !== undefined) row.dataset.lexofficeContactId = data.lexofficeContactId ?? ''; | |||||
| const editName = row.querySelector('.edit-name'); | const editName = row.querySelector('.edit-name'); | ||||
| if (editName) editName.value = data.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 ────────────────────────────────────────────────────── | // ── Neue Zeile einfügen ────────────────────────────────────────────────────── | ||||
| function appendRowToList(data) { | function appendRowToList(data) { | ||||
| @@ -401,7 +697,12 @@ function appendRowToList(data) { | |||||
| const prefix = rowPrefix(); | const prefix = rowPrefix(); | ||||
| const el = document.getElementById(`${prefix}-${data.id}`); | 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) { | function buildRowHTML(data) { | ||||
| @@ -412,10 +713,29 @@ function buildRowHTML(data) { | |||||
| if (data.projectCount !== undefined) { | if (data.projectCount !== undefined) { | ||||
| const c = data.projectCount; | const c = data.projectCount; | ||||
| const hasCustomRate = data.hourlyRate != null && data.hourlyRate !== ''; | 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>`; | 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> | <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> | <label class="entry-form__label">${t('labelRate')}</label> | ||||
| <div class="entry-form__field"> | <div class="entry-form__field"> | ||||
| <div class="rate-mode"> | <div class="rate-mode"> | ||||
| @@ -495,7 +815,8 @@ function buildRowHTML(data) { | |||||
| ${data.hourlyRate !== undefined ? `data-rate="${esc(data.hourlyRate ?? '')}"` : ''} | ${data.hourlyRate !== undefined ? `data-rate="${esc(data.hourlyRate ?? '')}"` : ''} | ||||
| ${data.clientId !== undefined ? `data-client-id="${data.clientId}"` : ''} | ${data.clientId !== undefined ? `data-client-id="${data.clientId}"` : ''} | ||||
| ${data.billable !== undefined ? `data-billable="${data.billable ? '1' : '0'}"` : ''} | ${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__display"> | ||||
| <div class="crud-row__info"> | <div class="crud-row__info"> | ||||
| @@ -531,4 +852,6 @@ document.addEventListener('DOMContentLoaded', () => { | |||||
| initList(); | initList(); | ||||
| initTabs(); | initTabs(); | ||||
| initRateModeToggles(); | 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 | // assets/scripts/stopwatch.js | ||||
| import { esc, createTranslator } from './utils.js'; | import { esc, createTranslator } from './utils.js'; | ||||
| import { SearchableSelect } from './searchable-select.js'; | |||||
| const LS_KEY = 'tt_timer_state'; | const LS_KEY = 'tt_timer_state'; | ||||
| const LAST_PROJECT_KEY = 'tt_last_project_id'; | 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 }; | 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 ────────────────────────────────────────────────────────── | // ── StopwatchManager ────────────────────────────────────────────────────────── | ||||
| @@ -232,8 +86,9 @@ class StopwatchManager { | |||||
| const projEl = document.getElementById(projectId); | const projEl = document.getElementById(projectId); | ||||
| const svcEl = document.getElementById(serviceId); | 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; | return ctx; | ||||
| } | } | ||||
| @@ -29,6 +29,12 @@ | |||||
| border-color: var(--color-primary); | border-color: var(--color-primary); | ||||
| box-shadow: $shadow-focus; | box-shadow: $shadow-focus; | ||||
| } | } | ||||
| &:disabled { | |||||
| background-color: $color-card; | |||||
| color: $color-text-muted; | |||||
| cursor: not-allowed; | |||||
| } | |||||
| } | } | ||||
| // ─── Input Sizes ───────────────────────────────────────────────────────────── | // ─── Input Sizes ───────────────────────────────────────────────────────────── | ||||
| @@ -142,6 +142,19 @@ | |||||
| &:hover { color: $color-text-dark; text-decoration: underline; } | &: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 ───────────────────────────────────────────────────────────────── | // ─── Farbfeld ───────────────────────────────────────────────────────────────── | ||||
| .account-color-field { | .account-color-field { | ||||
| display: flex; | display: flex; | ||||
| @@ -183,6 +183,39 @@ | |||||
| border-bottom: 1px solid rgba($color-border, 0.5); | 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) ──────────────────────────────────────── | // ─── Checkbox-Label (Verrechenbar-Feld) ──────────────────────────────────────── | ||||
| .crud-checkbox-label { | .crud-checkbox-label { | ||||
| display: flex; | display: flex; | ||||
| @@ -19,6 +19,7 @@ | |||||
| "symfony/flex": "^2.10", | "symfony/flex": "^2.10", | ||||
| "symfony/form": "7.4.*", | "symfony/form": "7.4.*", | ||||
| "symfony/framework-bundle": "7.4.*", | "symfony/framework-bundle": "7.4.*", | ||||
| "symfony/http-client": "7.4.*", | |||||
| "symfony/mailer": "7.4.*", | "symfony/mailer": "7.4.*", | ||||
| "symfony/monolog-bundle": "^4.0.2", | "symfony/monolog-bundle": "^4.0.2", | ||||
| "symfony/runtime": "7.4.*", | "symfony/runtime": "7.4.*", | ||||
| @@ -4,7 +4,7 @@ | |||||
| "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", | ||||
| "This file is @generated automatically" | "This file is @generated automatically" | ||||
| ], | ], | ||||
| "content-hash": "6a52005068f345beb15a732e99cbb73a", | |||||
| "content-hash": "13f7eb9a1c6bbad1d88d6c9cd35e8aa2", | |||||
| "packages": [ | "packages": [ | ||||
| { | { | ||||
| "name": "composer/pcre", | "name": "composer/pcre", | ||||
| @@ -3840,6 +3840,189 @@ | |||||
| ], | ], | ||||
| "time": "2026-05-13T12:04:42+00:00" | "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", | "name": "symfony/http-foundation", | ||||
| "version": "v7.4.8", | "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 | * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true | ||||
| * http_client?: bool|array{ // HTTP Client configuration | * 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. | * max_host_connections?: int|Param, // The maximum number of connections to a single host. | ||||
| * default_options?: array{ | * default_options?: array{ | ||||
| * headers?: array<string, mixed>, | * 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); | $account->setPrimaryColor($hex); | ||||
| } | } | ||||
| if (array_key_exists('lexofficeApiKey', $data)) { | |||||
| $key = trim($data['lexofficeApiKey'] ?? ''); | |||||
| $account->setLexofficeApiKey($key !== '' ? $key : null); | |||||
| } | |||||
| $this->em->flush(); | $this->em->flush(); | ||||
| return $this->json(['ok' => true, 'name' => $account->getName()]); | 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\ClientRepository; | ||||
| use App\Repository\Tenant\TimeEntryRepository; | use App\Repository\Tenant\TimeEntryRepository; | ||||
| use App\Service\AccountRoleHelper; | use App\Service\AccountRoleHelper; | ||||
| use App\Service\LexofficeService; | |||||
| use App\Service\TenantContext; | |||||
| use Doctrine\ORM\EntityManagerInterface; | use Doctrine\ORM\EntityManagerInterface; | ||||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | ||||
| use Symfony\Component\HttpFoundation\JsonResponse; | use Symfony\Component\HttpFoundation\JsonResponse; | ||||
| @@ -22,6 +24,8 @@ class ClientController extends AbstractController | |||||
| private readonly TimeEntryRepository $timeEntryRepo, | private readonly TimeEntryRepository $timeEntryRepo, | ||||
| private readonly AccountRoleHelper $roleHelper, | private readonly AccountRoleHelper $roleHelper, | ||||
| private readonly TranslatorInterface $translator, | private readonly TranslatorInterface $translator, | ||||
| private readonly TenantContext $tenantContext, | |||||
| private readonly LexofficeService $lexofficeService, | |||||
| ) {} | ) {} | ||||
| #[Route('/clients', name: 'client_index')] | #[Route('/clients', name: 'client_index')] | ||||
| @@ -30,8 +34,12 @@ class ClientController extends AbstractController | |||||
| if ($this->roleHelper->isTracker()) { | if ($this->roleHelper->isTracker()) { | ||||
| throw $this->createAccessDeniedException(); | throw $this->createAccessDeniedException(); | ||||
| } | } | ||||
| $account = $this->tenantContext->getAccount(); | |||||
| return $this->render('client/index.html.twig', [ | 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->setName(trim($data['name'])); | ||||
| $client->setHourlyRate(!empty($data['hourlyRate']) ? $data['hourlyRate'] : null); | $client->setHourlyRate(!empty($data['hourlyRate']) ? $data['hourlyRate'] : null); | ||||
| $client->setNote(!empty($data['note']) ? $data['note'] : null); | $client->setNote(!empty($data['note']) ? $data['note'] : null); | ||||
| $client->setLexofficeContactId(!empty($data['lexofficeContactId']) ? $data['lexofficeContactId'] : null); | |||||
| $this->em->persist($client); | $this->em->persist($client); | ||||
| $this->em->flush(); | $this->em->flush(); | ||||
| @@ -77,6 +86,10 @@ class ClientController extends AbstractController | |||||
| $client->setHourlyRate(!empty($data['hourlyRate']) ? $data['hourlyRate'] : null); | $client->setHourlyRate(!empty($data['hourlyRate']) ? $data['hourlyRate'] : null); | ||||
| $client->setNote(!empty($data['note']) ? $data['note'] : 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(); | $this->em->flush(); | ||||
| return $this->json($this->clientToArray($client)); | return $this->json($this->clientToArray($client)); | ||||
| @@ -131,15 +144,53 @@ class ClientController extends AbstractController | |||||
| return $this->json($this->clientToArray($client)); | 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 | private function clientToArray(Client $client): array | ||||
| { | { | ||||
| return [ | 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)] | #[ORM\Column(length: 7, nullable: true)] | ||||
| private ?string $primaryColor = null; | private ?string $primaryColor = null; | ||||
| #[ORM\Column(length: 255, nullable: true)] | |||||
| private ?string $lexofficeApiKey = null; | |||||
| #[ORM\Column] | #[ORM\Column] | ||||
| private \DateTimeImmutable $createdAt; | private \DateTimeImmutable $createdAt; | ||||
| @@ -60,6 +63,10 @@ class Account | |||||
| public function getPrimaryColor(): ?string { return $this->primaryColor; } | public function getPrimaryColor(): ?string { return $this->primaryColor; } | ||||
| public function setPrimaryColor(?string $c): static { $this->primaryColor = $c; return $this; } | 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 getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } | ||||
| public function getSuperAdminUser(): ?User { return $this->superAdminUser; } | public function getSuperAdminUser(): ?User { return $this->superAdminUser; } | ||||
| @@ -28,6 +28,9 @@ class Client | |||||
| #[ORM\Column(type: Types::TEXT, nullable: true)] | #[ORM\Column(type: Types::TEXT, nullable: true)] | ||||
| private ?string $note = null; | private ?string $note = null; | ||||
| #[ORM\Column(length: 36, nullable: true)] | |||||
| private ?string $lexofficeContactId = null; | |||||
| #[ORM\Column(nullable: true)] | #[ORM\Column(nullable: true)] | ||||
| private ?\DateTimeImmutable $archivedAt = null; | private ?\DateTimeImmutable $archivedAt = null; | ||||
| @@ -51,6 +54,10 @@ class Client | |||||
| public function getNote(): ?string { return $this->note; } | public function getNote(): ?string { return $this->note; } | ||||
| public function setNote(?string $note): static { $this->note = $note; return $this; } | 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 getArchivedAt(): ?\DateTimeImmutable { return $this->archivedAt; } | ||||
| public function setArchivedAt(?\DateTimeImmutable $archivedAt): static { $this->archivedAt = $archivedAt; return $this; } | public function setArchivedAt(?\DateTimeImmutable $archivedAt): static { $this->archivedAt = $archivedAt; return $this; } | ||||
| public function isArchived(): bool { return $this->archivedAt !== null; } | 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> | </div> | ||||
| {% endif %} | {% 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"> | <div class="account-form__actions"> | ||||
| <button type="button" class="btn btn-primary" id="btn-account-save">{{ 'app.entry.btn_save'|trans }}</button> | <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> | <a href="{{ path('account_index', {tab: 'account'}) }}" class="btn btn-secondary">{{ 'app.entry.btn_cancel'|trans }}</a> | ||||
| @@ -9,6 +9,7 @@ | |||||
| window.CRUD = { | window.CRUD = { | ||||
| apiBase: '/api/clients', | apiBase: '/api/clients', | ||||
| clients: null, | clients: null, | ||||
| hasLexofficeApiKey: {{ hasLexofficeApiKey ? 'true' : 'false' }}, | |||||
| i18n: { | i18n: { | ||||
| confirmDelete: {{ 'app.crud.confirm_delete'|trans|json_encode|raw }}, | confirmDelete: {{ 'app.crud.confirm_delete'|trans|json_encode|raw }}, | ||||
| confirmArchive: {{ 'app.crud.confirm_archive'|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 }}, | projectPlural: {{ 'app.crud.project_plural'|trans|json_encode|raw }}, | ||||
| rateModeDefault: {{ 'app.crud.rate_mode_default'|trans|json_encode|raw }}, | rateModeDefault: {{ 'app.crud.rate_mode_default'|trans|json_encode|raw }}, | ||||
| rateModeCustom: {{ 'app.crud.rate_mode_custom'|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> | </script> | ||||
| @@ -49,6 +58,24 @@ window.CRUD = { | |||||
| <div class="crud-create" id="crud-create"> | <div class="crud-create" id="crud-create"> | ||||
| <div class="entry-form__grid"> | <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> | <label class="entry-form__label">{{ 'app.crud.label_name'|trans }}</label> | ||||
| <div class="entry-form__field"> | <div class="entry-form__field"> | ||||
| <input type="text" id="create-name" class="input" placeholder="{{ 'app.client.placeholder_name'|trans }}" /> | <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-archived="{{ client.isArchived() ? '1' : '0' }}" | ||||
| data-name="{{ client.name|e('html_attr') }}" | data-name="{{ client.name|e('html_attr') }}" | ||||
| data-rate="{{ client.hourlyRate|default('') }}" | 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__display"> | ||||
| <div class="crud-row__info"> | <div class="crud-row__info"> | ||||
| @@ -128,9 +156,29 @@ window.CRUD = { | |||||
| <div class="crud-row__edit" hidden> | <div class="crud-row__edit" hidden> | ||||
| <div class="entry-form__grid entry-form__grid--inline"> | <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> | <label class="entry-form__label">{{ 'app.crud.label_name'|trans }}</label> | ||||
| <div class="entry-form__field"> | <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> | </div> | ||||
| <label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label> | <label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label> | ||||
| @@ -132,6 +132,18 @@ app: | |||||
| placeholder_name: "Kundenname" | placeholder_name: "Kundenname" | ||||
| empty: "Noch keine Kunden angelegt." | 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: | project: | ||||
| page_title: "Projekte" | page_title: "Projekte" | ||||
| btn_new: "Neues Projekt" | btn_new: "Neues Projekt" | ||||
| @@ -280,6 +292,9 @@ app: | |||||
| new_owner_must_be_admin: "Der Benutzer muss aktiver Administrator sein." | new_owner_must_be_admin: "Der Benutzer muss aktiver Administrator sein." | ||||
| deactivated: "Dein Konto wurde deaktiviert." | deactivated: "Dein Konto wurde deaktiviert." | ||||
| deactivated_api: "Konto 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_minutes: "Minuten" | ||||
| interval_quarter: "Viertelstunde" | interval_quarter: "Viertelstunde" | ||||
| interval_half: "Halbe Stunde" | interval_half: "Halbe Stunde" | ||||