| @@ -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; | |||
| } | |||
| }); | |||
| } | |||
| @@ -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; | |||
| @@ -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); | |||
| } | |||
| } | |||
| @@ -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, | |||
| ]); | |||
| } | |||
| @@ -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) | |||
| @@ -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; | |||
| @@ -0,0 +1,5 @@ | |||
| <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> | |||
| <path d="M4.5 1.5h5l3 3v9a1 1 0 01-1 1h-7a1 1 0 01-1-1v-11a1 1 0 011-1z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> | |||
| <path d="M9.5 1.5v3h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> | |||
| <path d="M5.5 7h5M5.5 9h5M5.5 11h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/> | |||
| </svg> | |||
| @@ -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 }}, | |||
| } | |||
| }; | |||
| </script> | |||
| @@ -104,6 +108,20 @@ | |||
| </button> | |||
| </div> | |||
| <div class="report-toolbar__right"> | |||
| {% if lexofficeInvoice %} | |||
| <button class="report-toolbar__export" | |||
| id="btn-lexoffice-invoice" | |||
| type="button" | |||
| title="{{ 'app.report.create_invoice'|trans }}" | |||
| data-contact-id="{{ lexofficeInvoice.contactId }}" | |||
| data-client-name="{{ lexofficeInvoice.clientName }}" | |||
| data-date-from="{{ lexofficeInvoice.dateFrom }}" | |||
| data-date-to="{{ lexofficeInvoice.dateTo }}"> | |||
| {% include '_atoms/icon-invoice.html.twig' %} | |||
| </button> | |||
| <span class="report-toolbar__separator"></span> | |||
| {% endif %} | |||
| <button class="report-toolbar__export" | |||
| id="btn-export-excel" | |||
| type="button" | |||
| @@ -138,25 +156,26 @@ | |||
| {% include 'report/_filter-panel.html.twig' %} | |||
| {# ── Tabellen-Header ───────────────────────────────────────────────── #} | |||
| {% macro sort_header(col, label, sort, dir, extra) %} | |||
| {% set active = sort == col %} | |||
| {% set nextDir = (active and dir == 'ASC') ? 'DESC' : 'ASC' %} | |||
| <div class="report-table__cell report-table__cell--{{ col }} report-table__cell--sortable{% if active %} report-table__cell--sorted{% endif %}" | |||
| data-sort="{{ col }}" data-dir="{{ nextDir }}"> | |||
| <span class="report-table__cell-label">{{ label }}{% if active %}<span class="report-table__sort-icon">{{ dir == 'ASC' ? '▴' : '▾' }}</span>{% endif %}</span> | |||
| {% if extra %}<span class="report-table__summary">{{ extra }}</span>{% endif %} | |||
| </div> | |||
| {% endmacro %} | |||
| <div class="report-table"> | |||
| <div class="report-table__head"> | |||
| <div class="report-table__cell report-table__cell--date"> | |||
| {{ 'app.report.col_date'|trans }} | |||
| <span class="report-table__sort-icon">▾</span> | |||
| </div> | |||
| <div class="report-table__cell report-table__cell--client">{{ 'app.report.col_client'|trans }}</div> | |||
| <div class="report-table__cell report-table__cell--project">{{ 'app.report.col_project'|trans }}</div> | |||
| <div class="report-table__cell report-table__cell--service">{{ 'app.report.col_service'|trans }}</div> | |||
| <div class="report-table__cell report-table__cell--user">{{ 'app.report.col_user'|trans }}</div> | |||
| <div class="report-table__cell report-table__cell--note">{{ 'app.report.col_note'|trans }}</div> | |||
| <div class="report-table__cell report-table__cell--duration"> | |||
| {{ 'app.report.col_hours'|trans }} | |||
| <span class="report-table__summary">{{ totalDuration }}</span> | |||
| </div> | |||
| <div class="report-table__cell report-table__cell--revenue"> | |||
| {{ 'app.report.col_revenue'|trans }} | |||
| <span class="report-table__summary">{{ totalRevenue|number_format(2, ',', '.') }} €</span> | |||
| </div> | |||
| {{ _self.sort_header('date', 'app.report.col_date'|trans, sort, dir, '') }} | |||
| {{ _self.sort_header('client', 'app.report.col_client'|trans, sort, dir, '') }} | |||
| {{ _self.sort_header('project', 'app.report.col_project'|trans, sort, dir, '') }} | |||
| {{ _self.sort_header('service', 'app.report.col_service'|trans, sort, dir, '') }} | |||
| {{ _self.sort_header('user', 'app.report.col_user'|trans, sort, dir, '') }} | |||
| {{ _self.sort_header('note', 'app.report.col_note'|trans, sort, dir, '') }} | |||
| {{ _self.sort_header('duration', 'app.report.col_hours'|trans, sort, dir, totalDuration) }} | |||
| {{ _self.sort_header('revenue', 'app.report.col_revenue'|trans, sort, dir, totalRevenue|number_format(2, ',', '.') ~ ' €') }} | |||
| <div class="report-table__cell report-table__cell--actions"></div> | |||
| </div> | |||
| @@ -160,6 +160,11 @@ app: | |||
| export_csv: "Als CSV exportieren" | |||
| export_pdf: "Als PDF exportieren" | |||
| print: "Drucken" | |||
| create_invoice: "Rechnungsentwurf in Lexware Office erstellen" | |||
| invoice_creating: "Rechnungsentwurf wird erstellt…" | |||
| invoice_success: 'Rechnungsentwurf für „%client%" wurde in Lexware Office erstellt.' | |||
| invoice_error: "Fehler beim Erstellen des Rechnungsentwurfs." | |||
| invoice_open: "In Lexware Office öffnen" | |||
| export_col_date: "Datum" | |||
| export_col_client: "Kunde" | |||
| export_col_project: "Projekt" | |||