Procházet zdrojové kódy

lexware office integration

master
FlorianEisenmenger před 1 dnem
rodič
revize
dedfc4e1b6
22 změnil soubory, kde provedl 1133 přidání a 167 odebrání
  1. +4
    -1
      .claude/settings.local.json
  2. +22
    -0
      httpdocs/assets/scripts/account.js
  3. +328
    -5
      httpdocs/assets/scripts/crud.js
  4. +152
    -0
      httpdocs/assets/scripts/searchable-select.js
  5. +5
    -150
      httpdocs/assets/scripts/stopwatch.js
  6. +6
    -0
      httpdocs/assets/styles/atoms/_inputs.scss
  7. +13
    -0
      httpdocs/assets/styles/components/_account.scss
  8. +33
    -0
      httpdocs/assets/styles/components/_crud.scss
  9. +1
    -0
      httpdocs/composer.json
  10. +184
    -1
      httpdocs/composer.lock
  11. +1
    -1
      httpdocs/config/reference.php
  12. +26
    -0
      httpdocs/migrations/central/Version20260617100000.php
  13. +26
    -0
      httpdocs/migrations/tenant/Version20260617100000.php
  14. +5
    -0
      httpdocs/src/Controller/AccountController.php
  15. +58
    -7
      httpdocs/src/Controller/ClientController.php
  16. +69
    -0
      httpdocs/src/Controller/LexofficeController.php
  17. +7
    -0
      httpdocs/src/Entity/Central/Account.php
  18. +7
    -0
      httpdocs/src/Entity/Tenant/Client.php
  19. +102
    -0
      httpdocs/src/Service/LexofficeService.php
  20. +19
    -0
      httpdocs/templates/account/index.html.twig
  21. +50
    -2
      httpdocs/templates/client/index.html.twig
  22. +15
    -0
      httpdocs/translations/messages.de.yaml

+ 4
- 1
.claude/settings.local.json Zobrazit soubor

@@ -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)"
]
}
}

+ 22
- 0
httpdocs/assets/scripts/account.js Zobrazit soubor

@@ -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');


+ 328
- 5
httpdocs/assets/scripts/crud.js Zobrazit soubor

@@ -1,6 +1,7 @@
// assets/scripts/crud.js

import { esc, createTranslator, ANIMATION_MS, removeWithAnimation, animateIn } from './utils.js';
import { SearchableSelect } from './searchable-select.js';

const api = window.CRUD?.apiBase ?? '';

@@ -25,6 +26,258 @@ function rowPrefix() {
return 'row';
}

// ── Lexoffice Integration ────────────────────────────────────────────────────

const isClientPage = () => location.pathname.includes('/clients');
const hasLexoffice = () => window.CRUD?.hasLexofficeApiKey === true && isClientPage();

let lexofficeContacts = null;
let lexofficeLoading = false;

async function loadLexofficeContacts() {
if (lexofficeContacts !== null) return lexofficeContacts;
if (lexofficeLoading) {
return new Promise(resolve => {
const check = setInterval(() => {
if (!lexofficeLoading) { clearInterval(check); resolve(lexofficeContacts); }
}, 100);
});
}

lexofficeLoading = true;
try {
const res = await fetch('/api/lexoffice/contacts');
if (!res.ok) throw new Error();
lexofficeContacts = await res.json();
return lexofficeContacts;
} catch {
alert(t('lexofficeErrorLoad'));
return null;
} finally {
lexofficeLoading = false;
}
}

function buildLexofficeSelectInstance(container) {
const ss = new SearchableSelect(container, { searchPlaceholder: t('lexofficeSearch') });
return ss;
}

function getUsedLexofficeIds(excludeRowId) {
const ids = new Set();
document.querySelectorAll('#crud-list .crud-row').forEach(row => {
if (excludeRowId && row.dataset.id === String(excludeRowId)) return;
const id = row.dataset.lexofficeContactId;
if (id) ids.add(id);
});
return ids;
}

async function populateLexofficeSelect(ss, contactId, excludeRowId) {
const contacts = await loadLexofficeContacts();
if (!contacts) return;

const usedIds = getUsedLexofficeIds(excludeRowId);
const filtered = contacts.filter(c => !usedIds.has(c.id) || c.id === contactId);

ss.setGroups([{ label: '', items: filtered }]);
if (contactId) ss.setValue(contactId);
}

function initLexofficeCreateToggle() {
if (!hasLexoffice()) return;

const checkbox = document.getElementById('create-lexoffice');
const nameInput = document.getElementById('create-name');
const selectWrap = document.getElementById('create-lexoffice-select');
const selectLabel = document.getElementById('create-lexoffice-select-label');
const selectField = document.getElementById('create-lexoffice-select-field');
if (!checkbox || !nameInput || !selectWrap) return;

let ss = null;

checkbox.addEventListener('change', async () => {
if (checkbox.checked) {
nameInput.disabled = true;
nameInput.value = '';
if (selectLabel) selectLabel.hidden = false;
if (selectField) selectField.hidden = false;
if (!ss) {
ss = buildLexofficeSelectInstance(selectWrap);
ss.onSelect = (val, label) => { nameInput.value = label; };
selectWrap._ss = ss;
}
await populateLexofficeSelect(ss, null, null);
ss.focus();
} else {
nameInput.disabled = false;
nameInput.value = '';
if (selectLabel) selectLabel.hidden = true;
if (selectField) selectField.hidden = true;
}
});
}

function initLexofficeEditToggles() {
if (!hasLexoffice()) return;

document.querySelectorAll('.crud-row').forEach(row => {
initLexofficeEditToggle(row);
});
}

function initLexofficeEditToggle(row) {
const checkbox = row.querySelector('.edit-lexoffice');
const nameInput = row.querySelector('.edit-name');
const selectWrap = row.querySelector('.edit-lexoffice-select');
const selectLabel = row.querySelector('.edit-lexoffice-select-label');
const selectField = row.querySelector('.edit-lexoffice-select-field');
if (!checkbox || !nameInput) return;

const contactId = (selectWrap?.dataset.contactId) || '';
const rowId = row.dataset.id;
let ss = null;
let fullyLoaded = false;

function ensureSelect() {
if (!ss && selectWrap) {
ss = buildLexofficeSelectInstance(selectWrap);
ss.onSelect = (val, label) => { nameInput.value = label; };
selectWrap._ss = ss;

const origOpen = ss.openDropdown.bind(ss);
ss.openDropdown = async function () {
if (!fullyLoaded) {
await populateLexofficeSelect(ss, contactId, rowId);
fullyLoaded = true;
}
origOpen();
};
}
return ss;
}

if (contactId) {
nameInput.disabled = true;
ensureSelect();
if (ss) {
ss.setGroups([{ label: '', items: [{ id: contactId, name: nameInput.value }] }]);
ss.setValue(contactId);
}
addReloadButton(selectWrap, row);
}

checkbox.addEventListener('change', async () => {
if (checkbox.checked) {
nameInput.disabled = true;
nameInput.value = '';
if (selectLabel) selectLabel.hidden = false;
if (selectField) selectField.hidden = false;
ensureSelect();
if (ss) {
await populateLexofficeSelect(ss, null, rowId);
fullyLoaded = true;
ss.focus();
}
} else {
nameInput.disabled = false;
nameInput.value = '';
if (selectLabel) selectLabel.hidden = true;
if (selectField) selectField.hidden = true;
removeReloadButton(selectWrap);
}
});
}

function addReloadButton(container, row) {
if (!container || container.querySelector('.lexoffice-reload')) return;

const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'lexoffice-reload';
btn.title = t('lexofficeReload');
btn.innerHTML = '<svg viewBox="0 0 16 16" fill="none"><path d="M2 8a6 6 0 1 1 1.5 3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M2 13V9h4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>';

btn.addEventListener('click', async (e) => {
e.preventDefault();
btn.disabled = true;
try {
const res = await fetch(`${api}/${row.dataset.id}/lexoffice-refresh`, { method: 'PATCH' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err.error ?? t('lexofficeErrorApi'));
return;
}
const data = await res.json();
updateRowDisplay(row, data);
const nameInput = row.querySelector('.edit-name');
if (nameInput) nameInput.value = data.name;
} catch {
alert(t('lexofficeErrorApi'));
} finally {
btn.disabled = false;
}
});

container.appendChild(btn);
}

function removeReloadButton(container) {
container?.querySelector('.lexoffice-reload')?.remove();
}

function syncLexofficeAfterSave(row, data) {
if (!hasLexoffice()) return;

const selectWrap = row.querySelector('.edit-lexoffice-select');
const nameInput = row.querySelector('.edit-name');
const selectLabel = row.querySelector('.edit-lexoffice-select-label');
const selectField = row.querySelector('.edit-lexoffice-select-field');
const checkbox = row.querySelector('.edit-lexoffice');
const hasId = data.lexofficeContactId != null && data.lexofficeContactId !== '';

if (hasId) {
if (nameInput) nameInput.disabled = true;
if (checkbox) checkbox.checked = true;
if (selectLabel) selectLabel.hidden = false;
if (selectField) selectField.hidden = false;
if (selectWrap) {
const ss = selectWrap._ss ?? (() => {
const s = buildLexofficeSelectInstance(selectWrap);
s.onSelect = (val, label) => { if (nameInput) nameInput.value = label; };
selectWrap._ss = s;
return s;
})();

ss.setGroups([{ label: '', items: [{ id: data.lexofficeContactId, name: data.name }] }]);
ss.setValue(data.lexofficeContactId);
selectWrap.dataset.contactId = data.lexofficeContactId;

let fullyLoaded = false;
const rowId = row.dataset.id;
const origOpen = ss.openDropdown.bind(ss);
ss.openDropdown = async function () {
if (!fullyLoaded) {
await populateLexofficeSelect(ss, data.lexofficeContactId, rowId);
fullyLoaded = true;
}
origOpen();
};

addReloadButton(selectWrap, row);
}
} else {
if (nameInput) nameInput.disabled = false;
if (checkbox) checkbox.checked = false;
if (selectLabel) selectLabel.hidden = true;
if (selectField) selectField.hidden = true;
if (selectWrap) {
removeReloadButton(selectWrap);
selectWrap.dataset.contactId = '';
}
}
}

// ── Rate-Mode Radio Toggle ───────────────────────────────────────────────────

function initRateModeToggles() {
@@ -69,7 +322,7 @@ function initCreateForm() {
function resetCreateForm() {
['create-name', 'create-note'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
if (el) { el.value = ''; el.disabled = false; }
});
const rate = document.getElementById('create-rate');
if (rate) rate.value = '';
@@ -84,6 +337,13 @@ function resetCreateForm() {
const rateInput = defaultRadio.closest('.rate-mode')?.querySelector('.rate-mode__input');
if (rateInput) rateInput.hidden = true;
}

const lexofficeCheckbox = document.getElementById('create-lexoffice');
if (lexofficeCheckbox) {
lexofficeCheckbox.checked = false;
const selectWrap = document.getElementById('create-lexoffice-select');
if (selectWrap) selectWrap.hidden = true;
}
}

async function createEntity() {
@@ -138,6 +398,14 @@ function buildCreateBody() {
const billable = document.getElementById('create-billable');
if (billable) body.billable = billable.checked;

const lexofficeCheckbox = document.getElementById('create-lexoffice');
const lexofficeSelect = document.getElementById('create-lexoffice-select');
if (lexofficeCheckbox?.checked && lexofficeSelect?._ss) {
body.lexofficeContactId = lexofficeSelect._ss.getValue() || null;
} else if (lexofficeCheckbox && !lexofficeCheckbox.checked) {
body.lexofficeContactId = null;
}

return body;
}

@@ -198,7 +466,10 @@ async function saveEdit(row) {

const data = await res.json();
updateRowDisplay(row, data);
syncLexofficeAfterSave(row, data);
closeEdit(row);
const list = document.getElementById('crud-list');
if (list && data.billable === undefined) insertRowSorted(list, row);
} catch {
alert(t('errorSave'));
} finally {
@@ -226,6 +497,14 @@ function buildEditBody(row) {
const billable = row.querySelector('.edit-billable');
if (billable) body.billable = billable.checked;

const lexofficeCheckbox = row.querySelector('.edit-lexoffice');
const lexofficeSelect = row.querySelector('.edit-lexoffice-select');
if (lexofficeCheckbox?.checked && lexofficeSelect?._ss) {
body.lexofficeContactId = lexofficeSelect._ss.getValue() || null;
} else if (lexofficeCheckbox && !lexofficeCheckbox.checked) {
body.lexofficeContactId = null;
}

return body;
}

@@ -241,6 +520,7 @@ function updateRowDisplay(row, data) {
if (data.clientId !== undefined) row.dataset.clientId = data.clientId;
if (data.billable !== undefined) row.dataset.billable = data.billable ? '1' : '0';
if (data.note !== undefined) row.dataset.note = data.note ?? '';
if (data.lexofficeContactId !== undefined) row.dataset.lexofficeContactId = data.lexofficeContactId ?? '';

const editName = row.querySelector('.edit-name');
if (editName) editName.value = data.name;
@@ -364,6 +644,22 @@ function initTabs() {
});
}

// ── Sortierte Einfügung ─────────────────────────────────────────────────────

function insertRowSorted(container, row) {
const name = (row.dataset.name || '').toLowerCase();
const rows = container.querySelectorAll('.crud-row:not(.crud-row--archived)');

for (const existing of rows) {
if (existing === row) continue;
if ((existing.dataset.name || '').toLowerCase() > name) {
existing.before(row);
return;
}
}
container.appendChild(row);
}

// ── Neue Zeile einfügen ──────────────────────────────────────────────────────

function appendRowToList(data) {
@@ -401,7 +697,12 @@ function appendRowToList(data) {

const prefix = rowPrefix();
const el = document.getElementById(`${prefix}-${data.id}`);
if (el) animateIn(el, 'crud-row--new');
if (el) {
if (data.billable === undefined) insertRowSorted(list, el);
animateIn(el, 'crud-row--new');
if (hasLexoffice()) initLexofficeEditToggle(el);
syncLexofficeAfterSave(el, data);
}
}

function buildRowHTML(data) {
@@ -412,10 +713,29 @@ function buildRowHTML(data) {
if (data.projectCount !== undefined) {
const c = data.projectCount;
const hasCustomRate = data.hourlyRate != null && data.hourlyRate !== '';
const isLex = data.lexofficeContactId != null && data.lexofficeContactId !== '';
metaHtml = `<span class="crud-row__meta">${c} ${c === 1 ? t('projectSingular') : t('projectPlural')}</span>`;
editFields = `

const lexCheckbox = hasLexoffice() ? `
<label class="entry-form__label">&nbsp;</label>
<div class="entry-form__field">
<label class="crud-checkbox-label">
<input type="checkbox" class="edit-lexoffice" ${isLex ? 'checked' : ''} />
<span>${t('lexofficeCheckbox')}</span>
</label>
</div>` : '';

const lexSelect = hasLexoffice() ? `
<label class="entry-form__label edit-lexoffice-select-label" hidden>${t('lexofficeSelect')}</label>
<div class="entry-form__field edit-lexoffice-select-field" hidden>
<div class="lexoffice-select-wrap edit-lexoffice-select"
data-placeholder="${t('lexofficeSelect')}"
data-contact-id="${esc(data.lexofficeContactId ?? '')}"></div>
</div>` : '';

editFields = `${lexCheckbox}${lexSelect}
<label class="entry-form__label">${t('labelName')}</label>
<div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div>
<div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" ${isLex ? 'disabled' : ''} /></div>
<label class="entry-form__label">${t('labelRate')}</label>
<div class="entry-form__field">
<div class="rate-mode">
@@ -495,7 +815,8 @@ function buildRowHTML(data) {
${data.hourlyRate !== undefined ? `data-rate="${esc(data.hourlyRate ?? '')}"` : ''}
${data.clientId !== undefined ? `data-client-id="${data.clientId}"` : ''}
${data.billable !== undefined ? `data-billable="${data.billable ? '1' : '0'}"` : ''}
data-note="${esc(data.note ?? '')}">
data-note="${esc(data.note ?? '')}"
data-lexoffice-contact-id="${esc(data.lexofficeContactId ?? '')}">

<div class="crud-row__display">
<div class="crud-row__info">
@@ -531,4 +852,6 @@ document.addEventListener('DOMContentLoaded', () => {
initList();
initTabs();
initRateModeToggles();
initLexofficeCreateToggle();
initLexofficeEditToggles();
});

+ 152
- 0
httpdocs/assets/scripts/searchable-select.js Zobrazit soubor

@@ -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' });
}
}

+ 5
- 150
httpdocs/assets/scripts/stopwatch.js Zobrazit soubor

@@ -1,6 +1,7 @@
// assets/scripts/stopwatch.js

import { esc, createTranslator } from './utils.js';
import { SearchableSelect } from './searchable-select.js';

const LS_KEY = 'tt_timer_state';
const LAST_PROJECT_KEY = 'tt_last_project_id';
@@ -28,154 +29,7 @@ async function apiCall(url, options = {}) {
return { ok: res.ok, status: res.status, data };
}

// ── Searchable Select ─────────────────────────────────────────────────────────

class SearchableSelect {
constructor(container) {
this.container = container;
this.value = '';
this.label = '';
this.groups = [];
this.open = false;
this.highlightIdx = -1;
this.placeholder = container.dataset.placeholder || '...';

this.container.innerHTML = `
<button type="button" class="ss__trigger">
<span class="ss__value">${esc(this.placeholder)}</span>
<svg class="ss__arrow" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round"/></svg>
</button>
<div class="ss__dropdown" hidden>
<input type="text" class="ss__search" placeholder="${esc(t('search'))}">
<div class="ss__list"></div>
</div>`;

this.trigger = container.querySelector('.ss__trigger');
this.valueEl = container.querySelector('.ss__value');
this.dropdown = container.querySelector('.ss__dropdown');
this.search = container.querySelector('.ss__search');
this.list = container.querySelector('.ss__list');

this.trigger.addEventListener('click', (e) => {
e.stopPropagation();
this.toggle();
});
this.search.addEventListener('input', () => this.render());
this.search.addEventListener('keydown', (e) => this.onKeydown(e));
this.list.addEventListener('click', (e) => {
const item = e.target.closest('[data-value]');
if (item) this.select(item.dataset.value, item.dataset.label);
});
document.addEventListener('click', (e) => {
if (this.open && !this.container.contains(e.target)) this.close();
});
}

setGroups(groups) {
this.groups = groups;
}

setValue(val) {
for (const g of this.groups) {
for (const item of g.items) {
if (String(item.id) === String(val)) {
this.value = String(item.id);
this.label = item.name;
this.valueEl.textContent = item.name;
this.valueEl.classList.add('ss__value--selected');
return;
}
}
}
this.value = '';
this.label = '';
this.valueEl.textContent = this.placeholder;
this.valueEl.classList.remove('ss__value--selected');
}

getValue() { return this.value; }

toggle() {
this.open ? this.close() : this.openDropdown();
}

openDropdown() {
this.open = true;
this.dropdown.hidden = false;
this.search.value = '';
this.highlightIdx = -1;
this.render();
this.search.focus();
}

close() {
this.open = false;
this.dropdown.hidden = true;
}

select(val, label) {
this.value = val;
this.label = label;
this.valueEl.textContent = label;
this.valueEl.classList.add('ss__value--selected');
this.close();
}

focus() {
this.openDropdown();
}

render() {
const q = this.search.value.toLowerCase().trim();
let html = '';
let idx = 0;
const visibleItems = [];

for (const g of this.groups) {
const filtered = g.items.filter(item =>
!q || item.name.toLowerCase().includes(q) || g.label.toLowerCase().includes(q)
);
if (!filtered.length) continue;

html += `<div class="ss__group">${esc(g.label)}</div>`;
for (const item of filtered) {
const active = String(item.id) === this.value ? ' ss__item--active' : '';
const hl = idx === this.highlightIdx ? ' ss__item--highlight' : '';
html += `<div class="ss__item${active}${hl}" data-value="${item.id}" data-label="${esc(item.name)}" data-idx="${idx}">${esc(item.name)}</div>`;
visibleItems.push(item);
idx++;
}
}

this.list.innerHTML = html || `<div class="ss__empty">–</div>`;
this.visibleCount = visibleItems.length;
}

onKeydown(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
this.highlightIdx = Math.min(this.highlightIdx + 1, this.visibleCount - 1);
this.render();
this.scrollToHighlight();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.highlightIdx = Math.max(this.highlightIdx - 1, 0);
this.render();
this.scrollToHighlight();
} else if (e.key === 'Enter') {
e.preventDefault();
const el = this.list.querySelector(`[data-idx="${this.highlightIdx}"]`);
if (el) this.select(el.dataset.value, el.dataset.label);
} else if (e.key === 'Escape') {
this.close();
}
}

scrollToHighlight() {
const el = this.list.querySelector('.ss__item--highlight');
if (el) el.scrollIntoView({ block: 'nearest' });
}
}
// SearchableSelect ist jetzt in searchable-select.js extrahiert und wird oben importiert

// ── StopwatchManager ──────────────────────────────────────────────────────────

@@ -232,8 +86,9 @@ class StopwatchManager {

const projEl = document.getElementById(projectId);
const svcEl = document.getElementById(serviceId);
if (projEl) ctx.projectSelect = new SearchableSelect(projEl);
if (svcEl) ctx.serviceSelect = new SearchableSelect(svcEl);
const ssOpts = { searchPlaceholder: t('search') };
if (projEl) ctx.projectSelect = new SearchableSelect(projEl, ssOpts);
if (svcEl) ctx.serviceSelect = new SearchableSelect(svcEl, ssOpts);

return ctx;
}


+ 6
- 0
httpdocs/assets/styles/atoms/_inputs.scss Zobrazit soubor

@@ -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 ─────────────────────────────────────────────────────────────


+ 13
- 0
httpdocs/assets/styles/components/_account.scss Zobrazit soubor

@@ -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;


+ 33
- 0
httpdocs/assets/styles/components/_crud.scss Zobrazit soubor

@@ -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;


+ 1
- 0
httpdocs/composer.json Zobrazit soubor

@@ -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.*",


+ 184
- 1
httpdocs/composer.lock Zobrazit soubor

@@ -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",


+ 1
- 1
httpdocs/config/reference.php Zobrazit soubor

@@ -472,7 +472,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
* http_client?: bool|array{ // HTTP Client configuration
* enabled?: bool|Param, // Default: false
* enabled?: bool|Param, // Default: true
* max_host_connections?: int|Param, // The maximum number of connections to a single host.
* default_options?: array{
* headers?: array<string, mixed>,


+ 26
- 0
httpdocs/migrations/central/Version20260617100000.php Zobrazit soubor

@@ -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');
}
}

+ 26
- 0
httpdocs/migrations/tenant/Version20260617100000.php Zobrazit soubor

@@ -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');
}
}

+ 5
- 0
httpdocs/src/Controller/AccountController.php Zobrazit soubor

@@ -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()]);


+ 58
- 7
httpdocs/src/Controller/ClientController.php Zobrazit soubor

@@ -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(),
];
}
}

+ 69
- 0
httpdocs/src/Controller/LexofficeController.php Zobrazit soubor

@@ -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);
}
}

+ 7
- 0
httpdocs/src/Entity/Central/Account.php Zobrazit soubor

@@ -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; }


+ 7
- 0
httpdocs/src/Entity/Tenant/Client.php Zobrazit soubor

@@ -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; }


+ 102
- 0
httpdocs/src/Service/LexofficeService.php Zobrazit soubor

@@ -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);
}
}

+ 19
- 0
httpdocs/templates/account/index.html.twig Zobrazit soubor

@@ -93,6 +93,25 @@
</div>
{% endif %}

<div class="account-form__divider-row">
<hr class="account-form__divider">
</div>

<label class="account-form__label">{{ 'app.account.label_lexoffice_key'|trans }}</label>
<div class="account-form__field">
{% if account.hasLexofficeApiKey() %}
<span class="account-form__key-status" id="lexoffice-key-status">
<span class="account-form__key-mask">••••••••</span>
<a href="#" class="account-form__link" id="btn-lexoffice-key-change">{{ 'app.account.change_label'|trans }}</a>
</span>
{% endif %}
<input type="text" id="account-lexoffice-key" class="input"
{{ account.hasLexofficeApiKey() ? 'hidden' : '' }}
placeholder="{{ 'app.account.placeholder_lexoffice_key'|trans }}"
autocomplete="off" data-1p-ignore data-lpignore="true" />
<span class="account-form__hint">{{ 'app.account.hint_lexoffice_key'|trans }}</span>
</div>

<div class="account-form__actions">
<button type="button" class="btn btn-primary" id="btn-account-save">{{ 'app.entry.btn_save'|trans }}</button>
<a href="{{ path('account_index', {tab: 'account'}) }}" class="btn btn-secondary">{{ 'app.entry.btn_cancel'|trans }}</a>


+ 50
- 2
httpdocs/templates/client/index.html.twig Zobrazit soubor

@@ -9,6 +9,7 @@
window.CRUD = {
apiBase: '/api/clients',
clients: null,
hasLexofficeApiKey: {{ hasLexofficeApiKey ? 'true' : 'false' }},
i18n: {
confirmDelete: {{ 'app.crud.confirm_delete'|trans|json_encode|raw }},
confirmArchive: {{ 'app.crud.confirm_archive'|trans|json_encode|raw }},
@@ -35,6 +36,14 @@ window.CRUD = {
projectPlural: {{ 'app.crud.project_plural'|trans|json_encode|raw }},
rateModeDefault: {{ 'app.crud.rate_mode_default'|trans|json_encode|raw }},
rateModeCustom: {{ 'app.crud.rate_mode_custom'|trans|json_encode|raw }},
lexofficeCheckbox: {{ 'app.lexoffice.checkbox_label'|trans|json_encode|raw }},
lexofficeSelect: {{ 'app.lexoffice.select_contact'|trans|json_encode|raw }},
lexofficeSearch: {{ 'app.lexoffice.search'|trans|json_encode|raw }},
lexofficeReload: {{ 'app.lexoffice.reload'|trans|json_encode|raw }},
lexofficeLoading: {{ 'app.lexoffice.loading'|trans|json_encode|raw }},
lexofficeErrorLoad: {{ 'app.lexoffice.error_load_contacts'|trans|json_encode|raw }},
lexofficeRefreshed: {{ 'app.lexoffice.refreshed'|trans|json_encode|raw }},
lexofficeErrorApi: {{ 'app.lexoffice.error_api'|trans|json_encode|raw }},
},
};
</script>
@@ -49,6 +58,24 @@ window.CRUD = {
<div class="crud-create" id="crud-create">
<div class="entry-form__grid">

{% if hasLexofficeApiKey %}
<label class="entry-form__label">&nbsp;</label>
<div class="entry-form__field">
<label class="crud-checkbox-label">
<input type="checkbox" id="create-lexoffice" />
<span>{{ 'app.lexoffice.checkbox_label'|trans }}</span>
</label>
</div>
{% endif %}

{% if hasLexofficeApiKey %}
<label class="entry-form__label" id="create-lexoffice-select-label" hidden>{{ 'app.lexoffice.select_contact'|trans }}</label>
<div class="entry-form__field" id="create-lexoffice-select-field" hidden>
<div class="lexoffice-select-wrap" id="create-lexoffice-select"
data-placeholder="{{ 'app.lexoffice.select_contact'|trans }}"></div>
</div>
{% endif %}

<label class="entry-form__label">{{ 'app.crud.label_name'|trans }}</label>
<div class="entry-form__field">
<input type="text" id="create-name" class="input" placeholder="{{ 'app.client.placeholder_name'|trans }}" />
@@ -98,7 +125,8 @@ window.CRUD = {
data-archived="{{ client.isArchived() ? '1' : '0' }}"
data-name="{{ client.name|e('html_attr') }}"
data-rate="{{ client.hourlyRate|default('') }}"
data-note="{{ client.note|default('')|e('html_attr') }}">
data-note="{{ client.note|default('')|e('html_attr') }}"
data-lexoffice-contact-id="{{ client.lexofficeContactId|default('') }}">

<div class="crud-row__display">
<div class="crud-row__info">
@@ -128,9 +156,29 @@ window.CRUD = {
<div class="crud-row__edit" hidden>
<div class="entry-form__grid entry-form__grid--inline">

{% if hasLexofficeApiKey %}
<label class="entry-form__label">&nbsp;</label>
<div class="entry-form__field">
<label class="crud-checkbox-label">
<input type="checkbox" class="edit-lexoffice" {{ client.isLexofficeClient() ? 'checked' : '' }} />
<span>{{ 'app.lexoffice.checkbox_label'|trans }}</span>
</label>
</div>

<label class="entry-form__label edit-lexoffice-select-label"
{{ not client.isLexofficeClient() ? 'hidden' : '' }}>{{ 'app.lexoffice.select_contact'|trans }}</label>
<div class="entry-form__field edit-lexoffice-select-field"
{{ not client.isLexofficeClient() ? 'hidden' : '' }}>
<div class="lexoffice-select-wrap edit-lexoffice-select"
data-placeholder="{{ 'app.lexoffice.select_contact'|trans }}"
data-contact-id="{{ client.lexofficeContactId|default('') }}"></div>
</div>
{% endif %}

<label class="entry-form__label">{{ 'app.crud.label_name'|trans }}</label>
<div class="entry-form__field">
<input type="text" class="input edit-name" value="{{ client.name }}" />
<input type="text" class="input edit-name" value="{{ client.name }}"
{{ client.isLexofficeClient() ? 'disabled' : '' }} />
</div>

<label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label>


+ 15
- 0
httpdocs/translations/messages.de.yaml Zobrazit soubor

@@ -132,6 +132,18 @@ app:
placeholder_name: "Kundenname"
empty: "Noch keine Kunden angelegt."

lexoffice:
checkbox_label: "Lexware Office Kunde"
select_contact: "Lexware-Kontakt"
search: "Suchen…"
reload: "Daten aus Lexware Office aktualisieren"
loading: "Lexware-Kontakte werden geladen…"
error_no_api_key: "Kein Lexware Office API-Key hinterlegt. Bitte in den Account-Einstellungen konfigurieren."
error_api: "Fehler bei der Kommunikation mit Lexware Office."
error_not_linked: "Dieser Kunde ist nicht mit Lexware Office verknüpft."
error_load_contacts: "Fehler beim Laden der Lexware-Kontakte."
refreshed: "Kundendaten aus Lexware Office aktualisiert."

project:
page_title: "Projekte"
btn_new: "Neues Projekt"
@@ -280,6 +292,9 @@ app:
new_owner_must_be_admin: "Der Benutzer muss aktiver Administrator sein."
deactivated: "Dein Konto wurde deaktiviert."
deactivated_api: "Konto deaktiviert."
label_lexoffice_key: "Lexware Office API-Key"
placeholder_lexoffice_key: "API-Key eingeben"
hint_lexoffice_key: "Erstelle den API-Key unter https://app.lexware.de/addons → Weitere Apps → 'Public API'. Oder direkt unter https://app.lexware.de/addons/public-api. Damit können Kunden mit Lexware-Kontakten verknüpft werden."
interval_minutes: "Minuten"
interval_quarter: "Viertelstunde"
interval_half: "Halbe Stunde"


Načítá se…
Zrušit
Uložit