@@ -531,4 +852,6 @@ document.addEventListener('DOMContentLoaded', () => {
initList();
initTabs();
initRateModeToggles();
+ initLexofficeCreateToggle();
+ initLexofficeEditToggles();
});
diff --git a/httpdocs/assets/scripts/searchable-select.js b/httpdocs/assets/scripts/searchable-select.js
new file mode 100644
index 0000000..7af3f57
--- /dev/null
+++ b/httpdocs/assets/scripts/searchable-select.js
@@ -0,0 +1,152 @@
+import { esc } from './utils.js';
+
+export class SearchableSelect {
+ constructor(container, { searchPlaceholder = '...' } = {}) {
+ this.container = container;
+ this.value = '';
+ this.label = '';
+ this.groups = [];
+ this.open = false;
+ this.highlightIdx = -1;
+ this.placeholder = container.dataset.placeholder || '...';
+ this.onSelect = null;
+
+ this.container.innerHTML = `
+
+
`;
+
+ this.trigger = container.querySelector('.ss__trigger');
+ this.valueEl = container.querySelector('.ss__value');
+ this.dropdown = container.querySelector('.ss__dropdown');
+ this.search = container.querySelector('.ss__search');
+ this.list = container.querySelector('.ss__list');
+
+ this.trigger.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.toggle();
+ });
+ this.search.addEventListener('input', () => this.render());
+ this.search.addEventListener('keydown', (e) => this.onKeydown(e));
+ this.list.addEventListener('click', (e) => {
+ const item = e.target.closest('[data-value]');
+ if (item) this.select(item.dataset.value, item.dataset.label);
+ });
+ document.addEventListener('click', (e) => {
+ if (this.open && !this.container.contains(e.target)) this.close();
+ });
+ }
+
+ setGroups(groups) {
+ this.groups = groups;
+ }
+
+ setValue(val) {
+ for (const g of this.groups) {
+ for (const item of g.items) {
+ if (String(item.id) === String(val)) {
+ this.value = String(item.id);
+ this.label = item.name;
+ this.valueEl.textContent = item.name;
+ this.valueEl.classList.add('ss__value--selected');
+ return;
+ }
+ }
+ }
+ this.value = '';
+ this.label = '';
+ this.valueEl.textContent = this.placeholder;
+ this.valueEl.classList.remove('ss__value--selected');
+ }
+
+ getValue() { return this.value; }
+
+ toggle() {
+ this.open ? this.close() : this.openDropdown();
+ }
+
+ openDropdown() {
+ this.open = true;
+ this.dropdown.hidden = false;
+ this.search.value = '';
+ this.highlightIdx = -1;
+ this.render();
+ this.search.focus();
+ }
+
+ close() {
+ this.open = false;
+ this.dropdown.hidden = true;
+ }
+
+ select(val, label) {
+ this.value = val;
+ this.label = label;
+ this.valueEl.textContent = label;
+ this.valueEl.classList.add('ss__value--selected');
+ this.close();
+ if (this.onSelect) this.onSelect(val, label);
+ }
+
+ focus() {
+ this.openDropdown();
+ }
+
+ render() {
+ const q = this.search.value.toLowerCase().trim();
+ let html = '';
+ let idx = 0;
+ const visibleItems = [];
+
+ for (const g of this.groups) {
+ const filtered = g.items.filter(item =>
+ !q || item.name.toLowerCase().includes(q) || g.label.toLowerCase().includes(q)
+ );
+ if (!filtered.length) continue;
+
+ if (g.label) {
+ html += `
${esc(g.label)}
`;
+ }
+ for (const item of filtered) {
+ const active = String(item.id) === this.value ? ' ss__item--active' : '';
+ const hl = idx === this.highlightIdx ? ' ss__item--highlight' : '';
+ html += `
${esc(item.name)}
`;
+ visibleItems.push(item);
+ idx++;
+ }
+ }
+
+ this.list.innerHTML = html || `
–
`;
+ this.visibleCount = visibleItems.length;
+ }
+
+ onKeydown(e) {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ this.highlightIdx = Math.min(this.highlightIdx + 1, this.visibleCount - 1);
+ this.render();
+ this.scrollToHighlight();
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ this.highlightIdx = Math.max(this.highlightIdx - 1, 0);
+ this.render();
+ this.scrollToHighlight();
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ const el = this.list.querySelector(`[data-idx="${this.highlightIdx}"]`);
+ if (el) this.select(el.dataset.value, el.dataset.label);
+ } else if (e.key === 'Escape') {
+ this.close();
+ }
+ }
+
+ scrollToHighlight() {
+ const el = this.list.querySelector('.ss__item--highlight');
+ if (el) el.scrollIntoView({ block: 'nearest' });
+ }
+}
diff --git a/httpdocs/assets/scripts/stopwatch.js b/httpdocs/assets/scripts/stopwatch.js
index 27e2e83..5d36dbf 100644
--- a/httpdocs/assets/scripts/stopwatch.js
+++ b/httpdocs/assets/scripts/stopwatch.js
@@ -1,6 +1,7 @@
// assets/scripts/stopwatch.js
import { esc, createTranslator } from './utils.js';
+import { SearchableSelect } from './searchable-select.js';
const LS_KEY = 'tt_timer_state';
const LAST_PROJECT_KEY = 'tt_last_project_id';
@@ -28,154 +29,7 @@ async function apiCall(url, options = {}) {
return { ok: res.ok, status: res.status, data };
}
-// ── Searchable Select ─────────────────────────────────────────────────────────
-
-class SearchableSelect {
- constructor(container) {
- this.container = container;
- this.value = '';
- this.label = '';
- this.groups = [];
- this.open = false;
- this.highlightIdx = -1;
- this.placeholder = container.dataset.placeholder || '...';
-
- this.container.innerHTML = `
-
-
`;
-
- this.trigger = container.querySelector('.ss__trigger');
- this.valueEl = container.querySelector('.ss__value');
- this.dropdown = container.querySelector('.ss__dropdown');
- this.search = container.querySelector('.ss__search');
- this.list = container.querySelector('.ss__list');
-
- this.trigger.addEventListener('click', (e) => {
- e.stopPropagation();
- this.toggle();
- });
- this.search.addEventListener('input', () => this.render());
- this.search.addEventListener('keydown', (e) => this.onKeydown(e));
- this.list.addEventListener('click', (e) => {
- const item = e.target.closest('[data-value]');
- if (item) this.select(item.dataset.value, item.dataset.label);
- });
- document.addEventListener('click', (e) => {
- if (this.open && !this.container.contains(e.target)) this.close();
- });
- }
-
- setGroups(groups) {
- this.groups = groups;
- }
-
- setValue(val) {
- for (const g of this.groups) {
- for (const item of g.items) {
- if (String(item.id) === String(val)) {
- this.value = String(item.id);
- this.label = item.name;
- this.valueEl.textContent = item.name;
- this.valueEl.classList.add('ss__value--selected');
- return;
- }
- }
- }
- this.value = '';
- this.label = '';
- this.valueEl.textContent = this.placeholder;
- this.valueEl.classList.remove('ss__value--selected');
- }
-
- getValue() { return this.value; }
-
- toggle() {
- this.open ? this.close() : this.openDropdown();
- }
-
- openDropdown() {
- this.open = true;
- this.dropdown.hidden = false;
- this.search.value = '';
- this.highlightIdx = -1;
- this.render();
- this.search.focus();
- }
-
- close() {
- this.open = false;
- this.dropdown.hidden = true;
- }
-
- select(val, label) {
- this.value = val;
- this.label = label;
- this.valueEl.textContent = label;
- this.valueEl.classList.add('ss__value--selected');
- this.close();
- }
-
- focus() {
- this.openDropdown();
- }
-
- render() {
- const q = this.search.value.toLowerCase().trim();
- let html = '';
- let idx = 0;
- const visibleItems = [];
-
- for (const g of this.groups) {
- const filtered = g.items.filter(item =>
- !q || item.name.toLowerCase().includes(q) || g.label.toLowerCase().includes(q)
- );
- if (!filtered.length) continue;
-
- html += `
${esc(g.label)}
`;
- for (const item of filtered) {
- const active = String(item.id) === this.value ? ' ss__item--active' : '';
- const hl = idx === this.highlightIdx ? ' ss__item--highlight' : '';
- html += `
${esc(item.name)}
`;
- visibleItems.push(item);
- idx++;
- }
- }
-
- this.list.innerHTML = html || `
–
`;
- this.visibleCount = visibleItems.length;
- }
-
- onKeydown(e) {
- if (e.key === 'ArrowDown') {
- e.preventDefault();
- this.highlightIdx = Math.min(this.highlightIdx + 1, this.visibleCount - 1);
- this.render();
- this.scrollToHighlight();
- } else if (e.key === 'ArrowUp') {
- e.preventDefault();
- this.highlightIdx = Math.max(this.highlightIdx - 1, 0);
- this.render();
- this.scrollToHighlight();
- } else if (e.key === 'Enter') {
- e.preventDefault();
- const el = this.list.querySelector(`[data-idx="${this.highlightIdx}"]`);
- if (el) this.select(el.dataset.value, el.dataset.label);
- } else if (e.key === 'Escape') {
- this.close();
- }
- }
-
- scrollToHighlight() {
- const el = this.list.querySelector('.ss__item--highlight');
- if (el) el.scrollIntoView({ block: 'nearest' });
- }
-}
+// SearchableSelect ist jetzt in searchable-select.js extrahiert und wird oben importiert
// ── StopwatchManager ──────────────────────────────────────────────────────────
@@ -232,8 +86,9 @@ class StopwatchManager {
const projEl = document.getElementById(projectId);
const svcEl = document.getElementById(serviceId);
- if (projEl) ctx.projectSelect = new SearchableSelect(projEl);
- if (svcEl) ctx.serviceSelect = new SearchableSelect(svcEl);
+ const ssOpts = { searchPlaceholder: t('search') };
+ if (projEl) ctx.projectSelect = new SearchableSelect(projEl, ssOpts);
+ if (svcEl) ctx.serviceSelect = new SearchableSelect(svcEl, ssOpts);
return ctx;
}
diff --git a/httpdocs/assets/styles/atoms/_inputs.scss b/httpdocs/assets/styles/atoms/_inputs.scss
index 8287e96..3ab3ec4 100644
--- a/httpdocs/assets/styles/atoms/_inputs.scss
+++ b/httpdocs/assets/styles/atoms/_inputs.scss
@@ -29,6 +29,12 @@
border-color: var(--color-primary);
box-shadow: $shadow-focus;
}
+
+ &:disabled {
+ background-color: $color-card;
+ color: $color-text-muted;
+ cursor: not-allowed;
+ }
}
// ─── Input Sizes ─────────────────────────────────────────────────────────────
diff --git a/httpdocs/assets/styles/components/_account.scss b/httpdocs/assets/styles/components/_account.scss
index 1d485ad..0b3f6e2 100644
--- a/httpdocs/assets/styles/components/_account.scss
+++ b/httpdocs/assets/styles/components/_account.scss
@@ -142,6 +142,19 @@
&:hover { color: $color-text-dark; text-decoration: underline; }
}
+.account-form__key-status {
+ display: flex;
+ align-items: center;
+ gap: $space-3;
+ padding: $space-2 0;
+}
+
+.account-form__key-mask {
+ font-size: $font-size-base;
+ color: $color-text-muted;
+ letter-spacing: 0.1em;
+}
+
// ─── Farbfeld ─────────────────────────────────────────────────────────────────
.account-color-field {
display: flex;
diff --git a/httpdocs/assets/styles/components/_crud.scss b/httpdocs/assets/styles/components/_crud.scss
index 29967e8..c1b4ec9 100644
--- a/httpdocs/assets/styles/components/_crud.scss
+++ b/httpdocs/assets/styles/components/_crud.scss
@@ -183,6 +183,39 @@
border-bottom: 1px solid rgba($color-border, 0.5);
}
+// ─── Lexoffice ────────────────────────────────────────────────────────────────
+.lexoffice-select-wrap {
+ position: relative;
+ display: flex;
+ align-items: flex-start;
+ gap: $space-2;
+ width: 100%;
+
+ > :first-child {
+ flex: 1;
+ min-width: 0;
+ }
+}
+
+.lexoffice-reload {
+ @include icon-btn;
+ flex-shrink: 0;
+ color: $color-text-muted;
+ margin-top: 2px;
+
+ svg { width: $icon-svg-size; height: $icon-svg-size; }
+
+ &:hover {
+ background: rgba(var(--color-primary-rgb), 0.1);
+ color: var(--color-primary);
+ }
+
+ &:disabled {
+ opacity: 0.4;
+ pointer-events: none;
+ }
+}
+
// ─── Checkbox-Label (Verrechenbar-Feld) ────────────────────────────────────────
.crud-checkbox-label {
display: flex;
diff --git a/httpdocs/composer.json b/httpdocs/composer.json
index cf89f09..84e3d87 100644
--- a/httpdocs/composer.json
+++ b/httpdocs/composer.json
@@ -19,6 +19,7 @@
"symfony/flex": "^2.10",
"symfony/form": "7.4.*",
"symfony/framework-bundle": "7.4.*",
+ "symfony/http-client": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/monolog-bundle": "^4.0.2",
"symfony/runtime": "7.4.*",
diff --git a/httpdocs/composer.lock b/httpdocs/composer.lock
index 1b387d4..3901ea0 100644
--- a/httpdocs/composer.lock
+++ b/httpdocs/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "6a52005068f345beb15a732e99cbb73a",
+ "content-hash": "13f7eb9a1c6bbad1d88d6c9cd35e8aa2",
"packages": [
{
"name": "composer/pcre",
@@ -3840,6 +3840,189 @@
],
"time": "2026-05-13T12:04:42+00:00"
},
+ {
+ "name": "symfony/http-client",
+ "version": "v7.4.13",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/http-client.git",
+ "reference": "e8a112b8415707265a7e614278136a9d92989a6a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/http-client/zipball/e8a112b8415707265a7e614278136a9d92989a6a",
+ "reference": "e8a112b8415707265a7e614278136a9d92989a6a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "psr/log": "^1|^2|^3",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/http-client-contracts": "~3.4.4|^3.5.2",
+ "symfony/polyfill-php83": "^1.29",
+ "symfony/service-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "amphp/amp": "<2.5",
+ "amphp/socket": "<1.1",
+ "php-http/discovery": "<1.15",
+ "symfony/http-foundation": "<6.4"
+ },
+ "provide": {
+ "php-http/async-client-implementation": "*",
+ "php-http/client-implementation": "*",
+ "psr/http-client-implementation": "1.0",
+ "symfony/http-client-implementation": "3.0"
+ },
+ "require-dev": {
+ "amphp/http-client": "^4.2.1|^5.0",
+ "amphp/http-tunnel": "^1.0|^2.0",
+ "guzzlehttp/promises": "^1.4|^2.0",
+ "nyholm/psr7": "^1.0",
+ "php-http/httplug": "^1.0|^2.0",
+ "psr/http-client": "^1.0",
+ "symfony/amphp-http-client-meta": "^1.0|^2.0",
+ "symfony/cache": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/messenger": "^6.4|^7.0|^8.0",
+ "symfony/process": "^6.4|^7.0|^8.0",
+ "symfony/rate-limiter": "^6.4|^7.0|^8.0",
+ "symfony/stopwatch": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\HttpClient\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "http"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/http-client/tree/v7.4.13"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-24T09:57:54+00:00"
+ },
+ {
+ "name": "symfony/http-client-contracts",
+ "version": "v3.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/http-client-contracts.git",
+ "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d",
+ "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.7-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\HttpClient\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Test/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to HTTP clients",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-06T13:17:50+00:00"
+ },
{
"name": "symfony/http-foundation",
"version": "v7.4.8",
diff --git a/httpdocs/config/reference.php b/httpdocs/config/reference.php
index 13f6b54..6b20da6 100644
--- a/httpdocs/config/reference.php
+++ b/httpdocs/config/reference.php
@@ -472,7 +472,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
* http_client?: bool|array{ // HTTP Client configuration
- * enabled?: bool|Param, // Default: false
+ * enabled?: bool|Param, // Default: true
* max_host_connections?: int|Param, // The maximum number of connections to a single host.
* default_options?: array{
* headers?: array
,
diff --git a/httpdocs/migrations/central/Version20260617100000.php b/httpdocs/migrations/central/Version20260617100000.php
new file mode 100644
index 0000000..77c1515
--- /dev/null
+++ b/httpdocs/migrations/central/Version20260617100000.php
@@ -0,0 +1,26 @@
+addSql('ALTER TABLE `account` ADD lexoffice_api_key VARCHAR(255) DEFAULT NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE `account` DROP COLUMN lexoffice_api_key');
+ }
+}
diff --git a/httpdocs/migrations/tenant/Version20260617100000.php b/httpdocs/migrations/tenant/Version20260617100000.php
new file mode 100644
index 0000000..0e71e55
--- /dev/null
+++ b/httpdocs/migrations/tenant/Version20260617100000.php
@@ -0,0 +1,26 @@
+addSql('ALTER TABLE `client` ADD lexoffice_contact_id VARCHAR(36) DEFAULT NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE `client` DROP COLUMN lexoffice_contact_id');
+ }
+}
diff --git a/httpdocs/src/Controller/AccountController.php b/httpdocs/src/Controller/AccountController.php
index 7bfdb83..8a3e003 100644
--- a/httpdocs/src/Controller/AccountController.php
+++ b/httpdocs/src/Controller/AccountController.php
@@ -98,6 +98,11 @@ class AccountController extends AbstractController
$account->setPrimaryColor($hex);
}
+ if (array_key_exists('lexofficeApiKey', $data)) {
+ $key = trim($data['lexofficeApiKey'] ?? '');
+ $account->setLexofficeApiKey($key !== '' ? $key : null);
+ }
+
$this->em->flush();
return $this->json(['ok' => true, 'name' => $account->getName()]);
diff --git a/httpdocs/src/Controller/ClientController.php b/httpdocs/src/Controller/ClientController.php
index 60cb78d..05226ca 100644
--- a/httpdocs/src/Controller/ClientController.php
+++ b/httpdocs/src/Controller/ClientController.php
@@ -6,6 +6,8 @@ use App\Entity\Tenant\Client;
use App\Repository\Tenant\ClientRepository;
use App\Repository\Tenant\TimeEntryRepository;
use App\Service\AccountRoleHelper;
+use App\Service\LexofficeService;
+use App\Service\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -22,6 +24,8 @@ class ClientController extends AbstractController
private readonly TimeEntryRepository $timeEntryRepo,
private readonly AccountRoleHelper $roleHelper,
private readonly TranslatorInterface $translator,
+ private readonly TenantContext $tenantContext,
+ private readonly LexofficeService $lexofficeService,
) {}
#[Route('/clients', name: 'client_index')]
@@ -30,8 +34,12 @@ class ClientController extends AbstractController
if ($this->roleHelper->isTracker()) {
throw $this->createAccessDeniedException();
}
+
+ $account = $this->tenantContext->getAccount();
+
return $this->render('client/index.html.twig', [
- 'clients' => $this->clientRepo->findAllOrderedByName(),
+ 'clients' => $this->clientRepo->findAllOrderedByName(),
+ 'hasLexofficeApiKey' => $account?->hasLexofficeApiKey() ?? false,
]);
}
@@ -51,6 +59,7 @@ class ClientController extends AbstractController
$client->setName(trim($data['name']));
$client->setHourlyRate(!empty($data['hourlyRate']) ? $data['hourlyRate'] : null);
$client->setNote(!empty($data['note']) ? $data['note'] : null);
+ $client->setLexofficeContactId(!empty($data['lexofficeContactId']) ? $data['lexofficeContactId'] : null);
$this->em->persist($client);
$this->em->flush();
@@ -77,6 +86,10 @@ class ClientController extends AbstractController
$client->setHourlyRate(!empty($data['hourlyRate']) ? $data['hourlyRate'] : null);
$client->setNote(!empty($data['note']) ? $data['note'] : null);
+ if (array_key_exists('lexofficeContactId', $data)) {
+ $client->setLexofficeContactId(!empty($data['lexofficeContactId']) ? $data['lexofficeContactId'] : null);
+ }
+
$this->em->flush();
return $this->json($this->clientToArray($client));
@@ -131,15 +144,53 @@ class ClientController extends AbstractController
return $this->json($this->clientToArray($client));
}
+ #[Route('/api/clients/{id}/lexoffice-refresh', name: 'api_client_lexoffice_refresh', methods: ['PATCH'])]
+ public function lexofficeRefresh(int $id): JsonResponse
+ {
+ if ($this->roleHelper->isTracker()) {
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
+ }
+
+ $client = $this->clientRepo->find($id);
+ if (!$client) {
+ return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
+ }
+
+ if (!$client->isLexofficeClient()) {
+ return $this->json(['error' => $this->translator->trans('app.lexoffice.error_not_linked')], 400);
+ }
+
+ $account = $this->tenantContext->getAccount();
+ if (!$account?->hasLexofficeApiKey()) {
+ return $this->json(['error' => $this->translator->trans('app.lexoffice.error_no_api_key')], 400);
+ }
+
+ try {
+ $contact = $this->lexofficeService->getContact($account->getLexofficeApiKey(), $client->getLexofficeContactId());
+ } catch (\Throwable) {
+ return $this->json(['error' => $this->translator->trans('app.lexoffice.error_api')], 502);
+ }
+
+ if ($contact === null) {
+ return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
+ }
+
+ $client->setName($contact['name']);
+ $this->em->flush();
+
+ return $this->json($this->clientToArray($client));
+ }
+
private function clientToArray(Client $client): array
{
return [
- 'id' => $client->getId(),
- 'name' => $client->getName(),
- 'hourlyRate' => $client->getHourlyRate(),
- 'note' => $client->getNote(),
- 'projectCount' => $client->getProjects()->count(),
- 'archived' => $client->isArchived(),
+ 'id' => $client->getId(),
+ 'name' => $client->getName(),
+ 'hourlyRate' => $client->getHourlyRate(),
+ 'note' => $client->getNote(),
+ 'projectCount' => $client->getProjects()->count(),
+ 'archived' => $client->isArchived(),
+ 'lexofficeContactId' => $client->getLexofficeContactId(),
];
}
}
diff --git a/httpdocs/src/Controller/LexofficeController.php b/httpdocs/src/Controller/LexofficeController.php
new file mode 100644
index 0000000..c3fecbd
--- /dev/null
+++ b/httpdocs/src/Controller/LexofficeController.php
@@ -0,0 +1,69 @@
+roleHelper->isTracker()) {
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
+ }
+
+ $account = $this->tenantContext->getAccount();
+ if (!$account?->hasLexofficeApiKey()) {
+ return $this->json(['error' => $this->translator->trans('app.lexoffice.error_no_api_key')], 400);
+ }
+
+ try {
+ $contacts = $this->lexofficeService->getCustomerContacts($account->getLexofficeApiKey());
+ } catch (\Throwable) {
+ return $this->json(['error' => $this->translator->trans('app.lexoffice.error_api')], 502);
+ }
+
+ return $this->json($contacts);
+ }
+
+ #[Route('/api/lexoffice/contacts/{contactId}', name: 'api_lexoffice_contact', methods: ['GET'])]
+ public function contact(string $contactId): JsonResponse
+ {
+ if ($this->roleHelper->isTracker()) {
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
+ }
+
+ $account = $this->tenantContext->getAccount();
+ if (!$account?->hasLexofficeApiKey()) {
+ return $this->json(['error' => $this->translator->trans('app.lexoffice.error_no_api_key')], 400);
+ }
+
+ try {
+ $contact = $this->lexofficeService->getContact($account->getLexofficeApiKey(), $contactId);
+ } catch (\Throwable) {
+ return $this->json(['error' => $this->translator->trans('app.lexoffice.error_api')], 502);
+ }
+
+ if ($contact === null) {
+ return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
+ }
+
+ return $this->json($contact);
+ }
+}
diff --git a/httpdocs/src/Entity/Central/Account.php b/httpdocs/src/Entity/Central/Account.php
index 1ce1f76..587f408 100644
--- a/httpdocs/src/Entity/Central/Account.php
+++ b/httpdocs/src/Entity/Central/Account.php
@@ -29,6 +29,9 @@ class Account
#[ORM\Column(length: 7, nullable: true)]
private ?string $primaryColor = null;
+ #[ORM\Column(length: 255, nullable: true)]
+ private ?string $lexofficeApiKey = null;
+
#[ORM\Column]
private \DateTimeImmutable $createdAt;
@@ -60,6 +63,10 @@ class Account
public function getPrimaryColor(): ?string { return $this->primaryColor; }
public function setPrimaryColor(?string $c): static { $this->primaryColor = $c; return $this; }
+ public function getLexofficeApiKey(): ?string { return $this->lexofficeApiKey; }
+ public function setLexofficeApiKey(?string $key): static { $this->lexofficeApiKey = $key; return $this; }
+ public function hasLexofficeApiKey(): bool { return $this->lexofficeApiKey !== null && $this->lexofficeApiKey !== ''; }
+
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
public function getSuperAdminUser(): ?User { return $this->superAdminUser; }
diff --git a/httpdocs/src/Entity/Tenant/Client.php b/httpdocs/src/Entity/Tenant/Client.php
index 93da328..92dbf83 100644
--- a/httpdocs/src/Entity/Tenant/Client.php
+++ b/httpdocs/src/Entity/Tenant/Client.php
@@ -28,6 +28,9 @@ class Client
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $note = null;
+ #[ORM\Column(length: 36, nullable: true)]
+ private ?string $lexofficeContactId = null;
+
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $archivedAt = null;
@@ -51,6 +54,10 @@ class Client
public function getNote(): ?string { return $this->note; }
public function setNote(?string $note): static { $this->note = $note; return $this; }
+ public function getLexofficeContactId(): ?string { return $this->lexofficeContactId; }
+ public function setLexofficeContactId(?string $id): static { $this->lexofficeContactId = $id; return $this; }
+ public function isLexofficeClient(): bool { return $this->lexofficeContactId !== null && $this->lexofficeContactId !== ''; }
+
public function getArchivedAt(): ?\DateTimeImmutable { return $this->archivedAt; }
public function setArchivedAt(?\DateTimeImmutable $archivedAt): static { $this->archivedAt = $archivedAt; return $this; }
public function isArchived(): bool { return $this->archivedAt !== null; }
diff --git a/httpdocs/src/Service/LexofficeService.php b/httpdocs/src/Service/LexofficeService.php
new file mode 100644
index 0000000..926cd29
--- /dev/null
+++ b/httpdocs/src/Service/LexofficeService.php
@@ -0,0 +1,102 @@
+requestWithRetry('GET', self::BASE_URL . '/contacts', $apiKey, [
+ 'customer' => 'true',
+ 'page' => $page,
+ ]);
+
+ foreach ($data['content'] ?? [] as $contact) {
+ $name = $this->extractContactName($contact);
+ if ($name !== '') {
+ $contacts[] = ['id' => $contact['id'], 'name' => $name];
+ }
+ }
+
+ $page++;
+ } while (!($data['last'] ?? true));
+
+ usort($contacts, fn(array $a, array $b) => strcasecmp($a['name'], $b['name']));
+
+ return $contacts;
+ }
+
+ /**
+ * @return array{id: string, name: string}|null
+ */
+ public function getContact(string $apiKey, string $contactId): ?array
+ {
+ $response = $this->httpClient->request('GET', self::BASE_URL . '/contacts/' . $contactId, [
+ 'headers' => ['Authorization' => 'Bearer ' . $apiKey, 'Accept' => 'application/json'],
+ ]);
+
+ if ($response->getStatusCode() !== 200) {
+ return null;
+ }
+
+ $contact = $response->toArray();
+ $name = $this->extractContactName($contact);
+
+ return $name !== '' ? ['id' => $contact['id'], 'name' => $name] : null;
+ }
+
+ private function requestWithRetry(string $method, string $url, string $apiKey, array $query = []): array
+ {
+ $maxRetries = 3;
+
+ for ($attempt = 0; $attempt <= $maxRetries; $attempt++) {
+ $response = $this->httpClient->request($method, $url, [
+ 'headers' => ['Authorization' => 'Bearer ' . $apiKey, 'Accept' => 'application/json'],
+ 'query' => $query,
+ ]);
+
+ $statusCode = $response->getStatusCode();
+
+ if ($statusCode === 429) {
+ $retryAfter = (int) ($response->getHeaders(false)['retry-after'][0] ?? ($attempt + 1));
+ usleep(max($retryAfter, $attempt + 1) * 1_000_000);
+ continue;
+ }
+
+ return $response->toArray();
+ }
+
+ throw new \RuntimeException('Lexware API rate limit exceeded after retries');
+ }
+
+ private function extractContactName(array $contact): string
+ {
+ if (!empty($contact['company']['name'])) {
+ return $contact['company']['name'];
+ }
+
+ $parts = array_filter([
+ $contact['person']['firstName'] ?? '',
+ $contact['person']['lastName'] ?? '',
+ ]);
+
+ return implode(' ', $parts);
+ }
+}
diff --git a/httpdocs/templates/account/index.html.twig b/httpdocs/templates/account/index.html.twig
index 739fa3d..75fcddb 100644
--- a/httpdocs/templates/account/index.html.twig
+++ b/httpdocs/templates/account/index.html.twig
@@ -93,6 +93,25 @@