diff --git a/httpdocs/assets/scripts/report.js b/httpdocs/assets/scripts/report.js index 354c632..890dc1d 100644 --- a/httpdocs/assets/scripts/report.js +++ b/httpdocs/assets/scripts/report.js @@ -190,9 +190,11 @@ document.addEventListener('DOMContentLoaded', () => { }); } + initSortHeaders(); new ReportFilter().init(); initExportButtons(); initPrintButton(); + initLexofficeInvoiceButton(); }); // ── ReportFilter ───────────────────────────────────────────────────────────── @@ -224,6 +226,10 @@ class ReportFilter { el.addEventListener('mousedown', () => this.activateRowByControl(el)); }); + this.panel.querySelectorAll('.filter-row__control-group--radio').forEach(group => { + group.addEventListener('click', () => this.activateRowByControl(group)); + }); + this.periodSel?.addEventListener('change', () => { this.activateRowByControl(this.periodSel); this.toggleCustomDates(this.periodSel.value === 'custom'); @@ -418,6 +424,19 @@ class ReportFilter { } } +// ── Sortierung ─────────────────────────────────────────────────────────────── + +function initSortHeaders() { + document.querySelectorAll('.report-table__cell--sortable').forEach(cell => { + cell.addEventListener('click', () => { + const params = new URLSearchParams(window.location.search); + params.set('sort', cell.dataset.sort); + params.set('dir', cell.dataset.dir); + window.location.href = `/reports/times?${params}`; + }); + }); +} + // ── Export ──────────────────────────────────────────────────────────────────── function initExportButtons() { @@ -435,3 +454,53 @@ function initPrintButton() { window.print(); }); } + +// ── Lexoffice Invoice ──────────────────────────────────────────────────────── + +function initLexofficeInvoiceButton() { + const btn = document.getElementById('btn-lexoffice-invoice'); + if (!btn) return; + + const originalTitle = btn.title; + + btn.addEventListener('click', async () => { + if (btn.disabled) return; + + const contactId = btn.dataset.contactId; + const clientName = btn.dataset.clientName; + const dateFrom = btn.dataset.dateFrom; + const dateTo = btn.dataset.dateTo; + + btn.disabled = true; + btn.title = t('invoiceCreating'); + + try { + const res = await fetch('/api/lexoffice/invoices', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ contactId, dateFrom, dateTo }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.error ?? t('invoiceError')); + return; + } + + const data = await res.json(); + const invoiceId = data.id; + + const msg = t('invoiceSuccess').replace('%client%', clientName); + const openInLexoffice = confirm(`${msg}\n\n${t('invoiceOpen')}?`); + + if (openInLexoffice && invoiceId) { + window.open(`https://app.lexware.de/permalink/invoices/edit/${invoiceId}`, '_blank'); + } + } catch { + alert(t('invoiceError')); + } finally { + btn.disabled = false; + btn.title = originalTitle; + } + }); +} diff --git a/httpdocs/assets/styles/sections/_report.scss b/httpdocs/assets/styles/sections/_report.scss index a7d6bd6..d1b2001 100644 --- a/httpdocs/assets/styles/sections/_report.scss +++ b/httpdocs/assets/styles/sections/_report.scss @@ -249,6 +249,21 @@ } } +.report-table__cell--sortable { + cursor: pointer; + user-select: none; + + &:hover .report-table__cell-label { color: var(--color-primary); } +} + +.report-table__cell--sorted .report-table__cell-label { + color: var(--color-primary); +} + +.report-table__cell-label { + transition: color $transition-fast; +} + .report-table__sort-icon { margin-left: 2px; font-size: $font-size-xs; diff --git a/httpdocs/src/Controller/LexofficeController.php b/httpdocs/src/Controller/LexofficeController.php index c3fecbd..d68fdbc 100644 --- a/httpdocs/src/Controller/LexofficeController.php +++ b/httpdocs/src/Controller/LexofficeController.php @@ -9,6 +9,7 @@ use App\Service\LexofficeService; use App\Service\TenantContext; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; use Symfony\Contracts\Translation\TranslatorInterface; @@ -66,4 +67,37 @@ class LexofficeController extends AbstractController return $this->json($contact); } + + #[Route('/api/lexoffice/invoices', name: 'api_lexoffice_create_invoice', methods: ['POST'])] + public function createInvoice(Request $request): 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); + } + + $data = json_decode($request->getContent(), true); + $contactId = $data['contactId'] ?? ''; + $dateFrom = $data['dateFrom'] ?? null; + $dateTo = $data['dateTo'] ?? null; + + if ($contactId === '') { + return $this->json(['error' => $this->translator->trans('app.lexoffice.error_not_linked')], 400); + } + + try { + $result = $this->lexofficeService->createInvoiceDraft($account->getLexofficeApiKey(), $contactId, $dateFrom, $dateTo); + } catch (\Throwable) { + return $this->json(['error' => $this->translator->trans('app.lexoffice.error_api')], 502); + } + + return $this->json([ + 'id' => $result['id'] ?? null, + 'resourceUri' => $result['resourceUri'] ?? null, + ], 201); + } } diff --git a/httpdocs/src/Controller/ReportController.php b/httpdocs/src/Controller/ReportController.php index 3011937..8dba4ed 100644 --- a/httpdocs/src/Controller/ReportController.php +++ b/httpdocs/src/Controller/ReportController.php @@ -25,6 +25,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; class ReportController extends AbstractController { private const VALID_LIMITS = [50, 100, 250, 500]; + private const VALID_SORT_COLUMNS = ['date', 'client', 'project', 'service', 'user', 'note', 'duration', 'revenue']; public function __construct( private readonly EntityManagerInterface $tenantEm, @@ -92,12 +93,37 @@ class ReportController extends AbstractController } } + $sort = $request->query->get('sort', 'date'); + if (!in_array($sort, self::VALID_SORT_COLUMNS, true)) { + $sort = 'date'; + } + $dir = strtoupper($request->query->get('dir', 'DESC')) === 'ASC' ? 'ASC' : 'DESC'; + // Queries — immer gefilterte Methoden (leere Filter = kein WHERE) - $entries = $this->timeEntryRepo->findForReportFiltered($filters, $limit); + $entries = $this->timeEntryRepo->findForReportFiltered($filters, $limit, $sort, $dir); $totalCount = $this->timeEntryRepo->countFiltered($filters); $totalMinutes = $this->timeEntryRepo->sumDurationFiltered($filters); $totalRevenue = $this->timeEntryRepo->sumRevenueFiltered($filters); + // Lexoffice-Invoice-Button: nur sichtbar wenn genau 1 Kunde mit Lexoffice-Kontakt + $lexofficeInvoice = null; + if (!$isTracker && $account?->hasLexofficeApiKey()) { + $distinctClientIds = $this->timeEntryRepo->findDistinctClientIdsFiltered($filters); + if (count($distinctClientIds) === 1) { + $client = $this->clientRepo->find($distinctClientIds[0]); + if ($client?->isLexofficeClient()) { + $dateRange = $this->timeEntryRepo->findDateRangeFiltered($filters); + $lexofficeInvoice = [ + 'clientId' => $client->getId(), + 'clientName' => $client->getName(), + 'contactId' => $client->getLexofficeContactId(), + 'dateFrom' => $dateRange['min'], + 'dateTo' => $dateRange['max'], + ]; + } + } + } + return $this->render('report/times.html.twig', [ 'entries' => $entries, 'userMap' => $userMap, @@ -116,9 +142,12 @@ class ReportController extends AbstractController 'clients' => $this->clientRepo->findAllActiveOrderedByName(), 'filterRaw' => $filterRaw, 'filterActive' => $filterActive, + 'sort' => $sort, + 'dir' => $dir, 'filterDateFrom' => $filterDateFrom, 'filterDateTo' => $filterDateTo, 'trackingInterval' => $this->tenantContext->getAccount()?->getTrackingInterval() ?? 1, + 'lexofficeInvoice' => $lexofficeInvoice, ]); } diff --git a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php index f7b1a01..47d2a0c 100644 --- a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php +++ b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php @@ -169,11 +169,30 @@ class TimeEntryRepository extends ServiceEntityRepository return $qb; } - public function findForReportFiltered(array $filters, int $limit): array + private const SORT_COLUMNS = [ + 'date' => 't.date', + 'client' => 'c.name', + 'project' => 'p.name', + 'service' => 's.name', + 'user' => 't.userId', + 'note' => 't.note', + 'duration' => 't.duration', + ]; + + public function findForReportFiltered(array $filters, int $limit, string $sort = 'date', string $dir = 'DESC'): array { - return $this->buildFilteredQuery($filters) - ->addSelect('p', 'c', 's') - ->orderBy('t.date', 'DESC') + $qb = $this->buildFilteredQuery($filters) + ->addSelect('p', 'c', 's'); + + if ($sort === 'revenue') { + $qb->addSelect('(COALESCE(p.hourlyRate, c.hourlyRate, s.hourlyRate) * t.duration) AS HIDDEN revenueSort') + ->orderBy('revenueSort', $dir); + } else { + $column = self::SORT_COLUMNS[$sort] ?? self::SORT_COLUMNS['date']; + $qb->orderBy($column, $dir); + } + + return $qb ->addOrderBy('t.createdAt', 'DESC') ->setMaxResults($limit) ->getQuery() @@ -208,6 +227,32 @@ class TimeEntryRepository extends ServiceEntityRepository return (int) $result; } + /** + * @return int[] + */ + public function findDistinctClientIdsFiltered(array $filters): array + { + $rows = $this->buildFilteredQuery($filters) + ->select('DISTINCT IDENTITY(p.client) AS clientId') + ->getQuery() + ->getScalarResult(); + + return array_map(fn(array $row) => (int) $row['clientId'], $rows); + } + + /** + * @return array{min: ?string, max: ?string} + */ + public function findDateRangeFiltered(array $filters): array + { + $row = $this->buildFilteredQuery($filters) + ->select('MIN(t.date) AS minDate, MAX(t.date) AS maxDate') + ->getQuery() + ->getSingleResult(); + + return ['min' => $row['minDate'] ?? null, 'max' => $row['maxDate'] ?? null]; + } + public function sumRevenueFiltered(array $filters): float { $result = $this->buildFilteredQuery($filters) diff --git a/httpdocs/src/Service/LexofficeService.php b/httpdocs/src/Service/LexofficeService.php index 926cd29..ae854c5 100644 --- a/httpdocs/src/Service/LexofficeService.php +++ b/httpdocs/src/Service/LexofficeService.php @@ -62,6 +62,63 @@ class LexofficeService return $name !== '' ? ['id' => $contact['id'], 'name' => $name] : null; } + public function createInvoiceDraft(string $apiKey, string $contactId, ?string $dateFrom = null, ?string $dateTo = null): array + { + $now = (new \DateTimeImmutable())->format('Y-m-d\T00:00:00.000P'); + $shippingStart = $dateFrom + ? (new \DateTimeImmutable($dateFrom))->format('Y-m-d\T00:00:00.000P') + : $now; + $shippingEnd = $dateTo + ? (new \DateTimeImmutable($dateTo))->format('Y-m-d\T00:00:00.000P') + : $shippingStart; + + $body = [ + 'voucherDate' => $now, + 'address' => ['contactId' => $contactId], + 'lineItems' => [ + [ + 'type' => 'custom', + 'name' => 'Leistung', + 'quantity' => 1, + 'unitName' => 'Stk', + 'unitPrice' => [ + 'currency' => 'EUR', + 'netAmount' => 0, + 'taxRatePercentage' => 19, + ], + ], + ], + 'totalPrice' => ['currency' => 'EUR'], + 'taxConditions' => ['taxType' => 'net'], + 'shippingConditions' => [ + 'shippingType' => 'serviceperiod', + 'shippingDate' => $shippingStart, + 'shippingEndDate' => $shippingEnd, + ], + ]; + + $response = $this->httpClient->request('POST', self::BASE_URL . '/invoices', [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $apiKey, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ], + 'json' => $body, + ]); + + $statusCode = $response->getStatusCode(); + + if ($statusCode === 429) { + throw new \RuntimeException('Lexware API rate limit exceeded'); + } + + if ($statusCode < 200 || $statusCode >= 300) { + throw new \RuntimeException('Lexware API error: ' . $statusCode); + } + + return $response->toArray(false); + } + private function requestWithRetry(string $method, string $url, string $apiKey, array $query = []): array { $maxRetries = 3; diff --git a/httpdocs/templates/_atoms/icon-invoice.html.twig b/httpdocs/templates/_atoms/icon-invoice.html.twig new file mode 100644 index 0000000..cc07979 --- /dev/null +++ b/httpdocs/templates/_atoms/icon-invoice.html.twig @@ -0,0 +1,5 @@ + diff --git a/httpdocs/templates/report/times.html.twig b/httpdocs/templates/report/times.html.twig index d81a311..df70c66 100644 --- a/httpdocs/templates/report/times.html.twig +++ b/httpdocs/templates/report/times.html.twig @@ -58,6 +58,10 @@ selectPh: {{ 'app.entry.select_placeholder'|trans|json_encode|raw }}, btnLock: {{ 'app.report.btn_lock'|trans|json_encode|raw }}, btnUnlock: {{ 'app.report.btn_unlock'|trans|json_encode|raw }}, + invoiceCreating: {{ 'app.report.invoice_creating'|trans|json_encode|raw }}, + invoiceSuccess: {{ 'app.report.invoice_success'|trans|json_encode|raw }}, + invoiceError: {{ 'app.report.invoice_error'|trans|json_encode|raw }}, + invoiceOpen: {{ 'app.report.invoice_open'|trans|json_encode|raw }}, } }; @@ -104,6 +108,20 @@
+ {% endmacro %} +