diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b067396..f36f964 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,7 +16,11 @@ "Bash(echo \"exit: $?\")", "WebSearch", "WebFetch(domain:developers.lexoffice.io)", - "WebFetch(domain:developers.lexware.io)" + "WebFetch(domain:developers.lexware.io)", + "Skill(run)", + "Bash(ddev describe *)", + "Bash(curl *)", + "Bash(ddev mysql *)" ] } } diff --git a/httpdocs/assets/scripts/report.js b/httpdocs/assets/scripts/report.js index 0c74a41..95b7054 100644 --- a/httpdocs/assets/scripts/report.js +++ b/httpdocs/assets/scripts/report.js @@ -523,30 +523,135 @@ function initPrintButton() { }); } -// ── Lexoffice Invoice ──────────────────────────────────────────────────────── +// ── Lexoffice Invoice Modal ────────────────────────────────────────────────── function initLexofficeInvoiceButton() { const btn = document.getElementById('btn-lexoffice-invoice'); if (!btn) return; - const originalTitle = btn.title; + const modal = document.getElementById('invoice-modal'); + const closeBtn = document.getElementById('invoice-modal-close'); + const createBtn = document.getElementById('invoice-modal-create'); + const preview = document.getElementById('invoice-preview'); + if (!modal || !preview) return; + + const contactId = btn.dataset.contactId; + const clientName = btn.dataset.clientName; + const dateFrom = btn.dataset.dateFrom; + const dateTo = btn.dataset.dateTo; + + let currentItems = []; + + function getFilterParams() { + const params = new URLSearchParams(window.location.search); + params.delete('limit'); + params.delete('sort'); + params.delete('dir'); + return params; + } + + function formatNumber(n) { + return n.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + function renderPreview(items) { + currentItems = items; + createBtn.disabled = items.length === 0; - btn.addEventListener('click', async () => { - if (btn.disabled) return; + if (items.length === 0) { + preview.innerHTML = `
' + t('errorLoad') + '
'; + } +} + +function getSelectedUserId() { + const el = document.getElementById('stats-user-select'); + return el ? el.value : ''; +} + +document.addEventListener('DOMContentLoaded', () => { + const rangeSelect = document.getElementById('stats-range-select'); + const metricSelect = document.getElementById('stats-metric-select'); + const userSelect = document.getElementById('stats-user-select'); + if (!rangeSelect || !metricSelect) return; + + loadAndRender(rangeSelect.value, metricSelect.value, getSelectedUserId()); + + rangeSelect.addEventListener('change', () => { + loadAndRender(rangeSelect.value, metricSelect.value, getSelectedUserId()); + }); + + if (userSelect) { + userSelect.addEventListener('change', () => { + loadAndRender(rangeSelect.value, metricSelect.value, getSelectedUserId()); + }); + } + + metricSelect.addEventListener('change', () => { + if (cachedData) { + renderChart(cachedData, metricSelect.value); + } + }); +}); diff --git a/httpdocs/assets/styles/main.scss b/httpdocs/assets/styles/main.scss index e26f620..9fd7d36 100644 --- a/httpdocs/assets/styles/main.scss +++ b/httpdocs/assets/styles/main.scss @@ -24,6 +24,7 @@ @use 'sections/timetracking'; @use 'sections/home'; @use 'sections/report'; +@use 'sections/statistics'; // ─── Themes ─────────────────────────────────────────────────────────────────── @use 'themes/minimal'; diff --git a/httpdocs/assets/styles/sections/_report.scss b/httpdocs/assets/styles/sections/_report.scss index c5b18c1..36f7655 100644 --- a/httpdocs/assets/styles/sections/_report.scss +++ b/httpdocs/assets/styles/sections/_report.scss @@ -34,29 +34,6 @@ } } -// ─── Account-Name Anzeige ──────────────────────────────────────────────────── -.report-account-name { - display: inline-flex; - align-items: center; - gap: $space-2; - padding: $space-2 $space-4; - font-size: $font-size-sm; - font-weight: $font-weight-medium; - color: var(--header-text); - background: var(--header-overlay); - border: 1px solid var(--header-overlay); - border-radius: $radius-pill; - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); - white-space: nowrap; - - &__icon { - width: 16px; - height: 16px; - flex-shrink: 0; - } -} - // ─── Disabled Tab ──────────────────────────────────────────────────────────── .account-tab--disabled { opacity: 0.45; @@ -745,3 +722,154 @@ button.report-toolbar__action { font-weight: $font-weight-medium; } } + +// ─── Invoice Modal ─────────────────────────────────────────────────────────── +.invoice-modal { + max-width: 620px; + max-height: 90vh; + display: flex; + flex-direction: column; +} + +.invoice-modal .modal-card__body { + overflow-y: auto; + min-height: 0; +} + +.invoice-modal__group { + display: flex; + flex-direction: column; + gap: $space-2; +} + +.invoice-modal__group-label { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $color-text-dark; +} + +.invoice-modal__radios { + display: flex; + gap: $space-5; + flex-wrap: wrap; +} + +.invoice-modal__radio { + display: inline-flex; + align-items: center; + gap: $space-2; + font-size: $font-size-sm; + color: $color-text-base; + cursor: pointer; + user-select: none; + + input[type='radio'] { + accent-color: var(--color-primary); + width: 15px; + height: 15px; + cursor: pointer; + } +} + +.invoice-modal__invoiced-check { + display: inline-flex; + align-items: center; + gap: $space-2; + font-size: $font-size-sm; + color: $color-text-base; + cursor: pointer; + user-select: none; + margin-right: auto; + + input[type='checkbox'] { + accent-color: var(--color-primary); + width: 15px; + height: 15px; + cursor: pointer; + } +} + +.invoice-modal__preview { + min-height: 80px; +} + +.invoice-modal__loading, +.invoice-modal__empty { + padding: $space-6; + text-align: center; + color: $color-text-muted; + font-size: $font-size-sm; +} + +// ─── Invoice Preview Table ─────────────────────────────────────────────────── +.invoice-preview__table { + border: 1px solid $color-border; + border-radius: $radius-md; + overflow: hidden; +} + +.invoice-preview__head, +.invoice-preview__row, +.invoice-preview__foot { + display: grid; + grid-template-columns: 1fr 80px 70px 100px 100px; + align-items: center; + padding: $space-2 $space-4; + gap: $space-2; +} + +.invoice-preview__head { + background: rgba($color-border, 0.3); + border-bottom: 1px solid $color-border; + + .invoice-preview__cell { + font-size: $font-size-xs; + font-weight: $font-weight-bold; + color: $color-text-muted; + text-transform: uppercase; + letter-spacing: 0.03em; + } +} + +.invoice-preview__row { + border-bottom: 1px solid rgba($color-border, 0.6); + + &:last-child { border-bottom: none; } +} + +.invoice-preview__foot { + border-top: 1px solid $color-border; + background: rgba($color-border, 0.15); +} + +.invoice-preview__cell { + font-size: $font-size-sm; + color: $color-text-base; + + &--name { + @include text-truncate; + } + + &--num { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + &--unit { + color: $color-text-muted; + } + + &--total { + font-weight: $font-weight-bold; + color: $color-text-dark; + } +} + +.invoice-preview__desc { + margin-top: $space-1; + font-size: $font-size-xs; + color: $color-text-muted; + white-space: pre-line; + line-height: 1.5; +} diff --git a/httpdocs/assets/styles/sections/_statistics.scss b/httpdocs/assets/styles/sections/_statistics.scss new file mode 100644 index 0000000..84dd8a2 --- /dev/null +++ b/httpdocs/assets/styles/sections/_statistics.scss @@ -0,0 +1,142 @@ +@use '../atoms/variables' as *; +@use '../atoms/mixins' as *; + +// ─── Statistiken-Bubble (Report-Header) ───────────────────────────────────── +.report-stats-bubble { + display: inline-flex; + align-items: center; + gap: $space-2; + padding: $space-2 $space-4; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: var(--header-text-muted); + background: var(--header-overlay); + border: 1px solid var(--header-overlay); + border-radius: $radius-pill; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + white-space: nowrap; + text-decoration: none; + transition: background $transition-fast, color $transition-fast; + cursor: pointer; + + &:hover { + color: var(--header-text); + background: rgba(255, 255, 255, 0.28); + } + + &--active { + color: var(--header-text); + background: rgba(255, 255, 255, 0.28); + border-color: rgba(255, 255, 255, 0.35); + } + + &__icon { + width: 16px; + height: 16px; + flex-shrink: 0; + } +} + +// ─── Toolbar ──────────────────────────────────────────────────────────────── +.statistics-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: $space-4 $space-5; + border-bottom: 1px solid $color-border; + + @include tablet { + flex-wrap: wrap; + gap: $space-3; + } +} + +.statistics-toolbar__title { + font-size: $font-size-md; + font-weight: $font-weight-bold; + color: $color-text-dark; +} + +.statistics-toolbar__controls { + display: flex; + align-items: center; + gap: $space-3; +} + +.statistics-range-select { + appearance: none; + background: $color-input-bg url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%237a8a9a'/%3E%3C/svg%3E") no-repeat right 12px center; + border: 1px solid $color-input-border; + border-radius: $radius-md; + padding: $space-2 $space-8 $space-2 $space-3; + font-size: $font-size-sm; + font-family: $font-family-base; + color: $color-text-base; + cursor: pointer; + transition: border-color $transition-fast; + + &:hover { + border-color: var(--color-primary); + } + + &:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: $shadow-focus; + } +} + +// ─── Chart ────────────────────────────────────────────────────────────────── +.statistics-chart-wrap { + position: relative; + height: 400px; + padding: $space-6 $space-5; + + @include tablet { + height: 300px; + padding: $space-4 $space-3; + } +} + +.statistics-error { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: $color-text-muted; + font-size: $font-size-sm; +} + +// ─── Legende ──────────────────────────────────────────────────────────────── +.statistics-legend { + display: flex; + align-items: center; + justify-content: center; + gap: $space-6; + padding: $space-3 $space-5 $space-5; +} + +.statistics-legend__item { + display: inline-flex; + align-items: center; + gap: $space-2; + font-size: $font-size-sm; + color: $color-text-muted; + + &::before { + content: ''; + display: inline-block; + width: 12px; + height: 12px; + border-radius: $radius-sm; + } + + &--billable::before { + background: var(--color-primary); + } + + &--non-billable::before { + background: $color-text-light; + } +} diff --git a/httpdocs/package-lock.json b/httpdocs/package-lock.json index e54582b..4c92674 100644 --- a/httpdocs/package-lock.json +++ b/httpdocs/package-lock.json @@ -5,6 +5,9 @@ "packages": { "": { "license": "UNLICENSED", + "dependencies": { + "chart.js": "^4.5.1" + }, "devDependencies": { "@babel/core": "^7.17.0", "@babel/preset-env": "^7.16.0", @@ -1692,6 +1695,12 @@ "webpack": "^5.0.0" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -2800,6 +2809,18 @@ "node": ">=8" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", diff --git a/httpdocs/package.json b/httpdocs/package.json index bb6f1c7..0bccb04 100644 --- a/httpdocs/package.json +++ b/httpdocs/package.json @@ -17,5 +17,8 @@ "dev": "encore dev", "watch": "encore dev --watch", "build": "encore production --progress" + }, + "dependencies": { + "chart.js": "^4.5.1" } } diff --git a/httpdocs/src/Controller/LexofficeController.php b/httpdocs/src/Controller/LexofficeController.php index d68fdbc..7862336 100644 --- a/httpdocs/src/Controller/LexofficeController.php +++ b/httpdocs/src/Controller/LexofficeController.php @@ -84,13 +84,14 @@ class LexofficeController extends AbstractController $contactId = $data['contactId'] ?? ''; $dateFrom = $data['dateFrom'] ?? null; $dateTo = $data['dateTo'] ?? null; + $lineItems = $data['lineItems'] ?? 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); + $result = $this->lexofficeService->createInvoiceDraft($account->getLexofficeApiKey(), $contactId, $dateFrom, $dateTo, $lineItems); } catch (\Throwable) { return $this->json(['error' => $this->translator->trans('app.lexoffice.error_api')], 502); } diff --git a/httpdocs/src/Controller/ReportController.php b/httpdocs/src/Controller/ReportController.php index 48a0a0c..1164f15 100644 --- a/httpdocs/src/Controller/ReportController.php +++ b/httpdocs/src/Controller/ReportController.php @@ -106,13 +106,15 @@ class ReportController extends AbstractController $totalRevenue = $this->timeEntryRepo->sumRevenueFiltered($filters); // Lexoffice-Invoice-Button: nur sichtbar wenn genau 1 Kunde mit Lexoffice-Kontakt + // und es nicht-abgerechnete Einträge gibt $lexofficeInvoice = null; if (!$isTracker && $account?->hasLexofficeApiKey()) { - $distinctClientIds = $this->timeEntryRepo->findDistinctClientIdsFiltered($filters); + $invoiceFilters = array_merge($filters, ['invoiced' => false]); + $distinctClientIds = $this->timeEntryRepo->findDistinctClientIdsFiltered($invoiceFilters); if (count($distinctClientIds) === 1) { $client = $this->clientRepo->find($distinctClientIds[0]); if ($client?->isLexofficeClient()) { - $dateRange = $this->timeEntryRepo->findDateRangeFiltered($filters); + $dateRange = $this->timeEntryRepo->findDateRangeFiltered($invoiceFilters); $lexofficeInvoice = [ 'clientId' => $client->getId(), 'clientName' => $client->getName(), @@ -151,6 +153,63 @@ class ReportController extends AbstractController ]); } + // ── Statistiken ───────────────────────────────────────────────────────── + + #[Route('/reports/statistics', name: 'report_statistics')] + public function statistics(): Response + { + /** @var User $currentUser */ + $currentUser = $this->security->getUser(); + $account = $this->tenantContext->getAccount(); + $isTracker = $this->roleHelper->isTracker(); + + $userList = []; + if (!$isTracker) { + $accountUsers = $this->accountUserRepo->findBy(['account' => $account]); + foreach ($accountUsers as $au) { + if (!$au->isArchived()) { + $userList[] = [ + 'id' => $au->getUser()->getId(), + 'name' => $au->getUser()->getFullName(), + ]; + } + } + } + + return $this->render('report/statistics.html.twig', [ + 'accountName' => $account?->getName() ?? '', + 'currentUserId' => $currentUser->getId(), + 'isTracker' => $isTracker, + 'userList' => $userList, + ]); + } + + #[Route('/api/statistics', name: 'api_statistics', methods: ['GET'])] + public function statisticsData(Request $request): JsonResponse + { + $range = $request->query->get('range', '12months'); + if (!in_array($range, ['12months', '6months', '4weeks'], true)) { + $range = '12months'; + } + + /** @var User $currentUser */ + $currentUser = $this->security->getUser(); + + $userId = null; + if ($this->roleHelper->isTracker()) { + $userId = $currentUser->getId(); + } else { + $requestedUserId = $request->query->get('userId'); + if ($requestedUserId !== null && $requestedUserId !== '') { + $userId = (int) $requestedUserId; + } + } + + $data = $this->timeEntryRepo->getStatisticsData($range, $userId); + + return $this->json($data); + } + // ── Excel-Export ───────────────────────────────────────────────────────── #[Route('/reports/export/excel', name: 'report_export_excel')] @@ -362,6 +421,88 @@ class ReportController extends AbstractController return [$from, $to]; } + // ── API: Rechnungs-Vorschau (gruppierte Zeiten) ──────────────────────────── + + #[Route('/api/lexoffice/invoice-preview', name: 'api_lexoffice_invoice_preview', methods: ['GET'])] + public function invoicePreview(Request $request): JsonResponse + { + if ($this->roleHelper->isTracker()) { + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); + } + + $groupBy = $request->query->get('groupBy', 'service'); + if (!in_array($groupBy, ['service', 'project', 'label'], true)) { + $groupBy = 'service'; + } + + $filters = $this->resolveFilters($request); + $filters['invoiced'] = false; + $rows = $this->timeEntryRepo->getGroupedForInvoice($filters, $groupBy); + + $grouped = []; + foreach ($rows as $row) { + $key = $row['itemName'] ?? ''; + $totalMinutes = (int) $row['totalMinutes']; + $totalRevenue = (float) ($row['totalRevenue'] ?? 0); + $hours = $totalMinutes / 60; + $rate = $hours > 0 ? round($totalRevenue / $hours, 2) : 0.0; + + $subName = match ($groupBy) { + 'service' => $row['subName'] ?? '', + 'project' => $row['subName'] ?? '', + 'label' => trim(($row['projectName'] ?? '') . (($row['serviceName'] ?? '') !== '' && ($row['serviceName'] ?? '') !== null ? ' / ' . $row['serviceName'] : '')), + default => '', + }; + + $grouped[$key][] = [ + 'subName' => $subName, + 'clientName' => $row['clientName'] ?? null, + 'totalMinutes' => $totalMinutes, + 'rate' => $rate, + ]; + } + + $items = []; + foreach ($grouped as $groupKey => $subRows) { + $displayName = $groupKey; + if ($displayName === '' || $displayName === null) { + $displayName = match ($groupBy) { + 'service' => $this->translator->trans('app.report.invoice_no_service'), + 'label' => $this->translator->trans('app.report.invoice_no_label'), + default => '–', + }; + } + + $byRate = []; + foreach ($subRows as $sub) { + $rateKey = number_format($sub['rate'], 2, '.', ''); + if (!isset($byRate[$rateKey])) { + $byRate[$rateKey] = ['totalMinutes' => 0, 'rate' => $sub['rate'], 'subNames' => []]; + } + $byRate[$rateKey]['totalMinutes'] += $sub['totalMinutes']; + if ($sub['subName'] !== '' && $sub['subName'] !== null) { + $byRate[$rateKey]['subNames'][] = $sub['subName']; + } + } + + foreach ($byRate as $rateGroup) { + $uniqueSubs = array_unique($rateGroup['subNames']); + $description = count($uniqueSubs) > 0 + ? implode("\n", array_map(fn(string $s) => '- ' . $s, $uniqueSubs)) + : null; + + $items[] = [ + 'name' => $displayName, + 'description' => $description, + 'hours' => round($rateGroup['totalMinutes'] / 60, 2), + 'rate' => $rateGroup['rate'], + ]; + } + } + + return $this->json($items); + } + // ── API: Abgerechnet-Status toggeln ─────────────────────────────────────── #[Route('/api/entries/{id}/invoiced', name: 'api_entry_invoiced_toggle', methods: ['PATCH'])] @@ -384,6 +525,31 @@ class ReportController extends AbstractController return $this->json(['invoiced' => $entry->isInvoiced()]); } + // ── API: Gefilterte Einträge als abgerechnet markieren ────────────────────── + + #[Route('/api/entries/mark-invoiced', name: 'api_entries_mark_invoiced', methods: ['POST'])] + public function markFilteredInvoiced(Request $request): JsonResponse + { + if ($this->roleHelper->isTracker()) { + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); + } + + $filters = $this->resolveFilters($request); + $entries = $this->timeEntryRepo->findAllFiltered($filters); + + $count = 0; + foreach ($entries as $entry) { + if (!$entry->isInvoiced()) { + $entry->setInvoiced(true); + $count++; + } + } + + $this->tenantEm->flush(); + + return $this->json(['marked' => $count]); + } + // ── Hilfsfunktion ───────────────────────────────────────────────────────── private function formatMinutes(int $minutes): string diff --git a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php index 369554f..1569eeb 100644 --- a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php +++ b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php @@ -297,6 +297,143 @@ class TimeEntryRepository extends ServiceEntityRepository return array_column($rows, 'label'); } + public function getGroupedForInvoice(array $filters, string $groupBy): array + { + $qb = $this->buildFilteredQuery($filters); + $rateExpr = 'COALESCE(p.hourlyRate, c.hourlyRate, s.hourlyRate)'; + + switch ($groupBy) { + case 'service': + $qb->select("s.name AS itemName, p.name AS subName, SUM(t.duration) AS totalMinutes, SUM($rateExpr * t.duration / 60) AS totalRevenue") + ->groupBy('s.id, s.name, p.id, p.name') + ->orderBy('s.name', 'ASC') + ->addOrderBy('p.name', 'ASC'); + break; + case 'project': + $qb->select("p.name AS itemName, c.name AS clientName, s.name AS subName, SUM(t.duration) AS totalMinutes, SUM($rateExpr * t.duration / 60) AS totalRevenue") + ->groupBy('p.id, p.name, c.name, s.id, s.name') + ->orderBy('p.name', 'ASC') + ->addOrderBy('s.name', 'ASC'); + break; + case 'label': + $qb->select("t.label AS itemName, p.name AS projectName, s.name AS serviceName, SUM(t.duration) AS totalMinutes, SUM($rateExpr * t.duration / 60) AS totalRevenue") + ->groupBy('t.label, p.id, p.name, s.id, s.name') + ->orderBy('t.label', 'ASC') + ->addOrderBy('p.name', 'ASC'); + break; + default: + return []; + } + + return $qb->getQuery()->getScalarResult(); + } + + public function getStatisticsData(string $range, ?int $userId = null): array + { + $now = new \DateTimeImmutable('today'); + + [$dateFrom, $groupBy] = match ($range) { + '6months' => [$now->modify('-6 months +1 day'), 'week'], + '4weeks' => [$now->modify('-27 days'), 'day'], + default => [$now->modify('-12 months +1 day'), 'month'], + }; + + $qb = $this->createQueryBuilder('t') + ->select('t.date, t.duration, s.billable, p.hourlyRate AS projectRate, c.hourlyRate AS clientRate, s.hourlyRate AS serviceRate') + ->join('t.project', 'p') + ->join('p.client', 'c') + ->leftJoin('t.service', 's') + ->where('t.date >= :dateFrom') + ->andWhere('t.date <= :dateTo') + ->setParameter('dateFrom', $dateFrom->format('Y-m-d')) + ->setParameter('dateTo', $now->format('Y-m-d')) + ->orderBy('t.date', 'ASC'); + + if ($userId !== null) { + $qb->andWhere('t.userId = :userId') + ->setParameter('userId', $userId); + } + + $rows = $qb->getQuery()->getResult(); + + $buckets = []; + foreach ($rows as $row) { + /** @var \DateTimeImmutable $date */ + $date = $row['date']; + $minutes = (int) $row['duration']; + $billable = $row['billable'] ?? true; + $rate = (float) ($row['projectRate'] ?? $row['clientRate'] ?? $row['serviceRate'] ?? 0); + $revenue = $rate * $minutes / 60; + + $key = match ($groupBy) { + 'week' => $date->format('o-W'), + 'day' => $date->format('Y-m-d'), + default => $date->format('Y-m'), + }; + + if (!isset($buckets[$key])) { + $buckets[$key] = ['billable' => 0, 'nonBillable' => 0, 'billableRevenue' => 0.0, 'nonBillableRevenue' => 0.0]; + } + if ($billable) { + $buckets[$key]['billable'] += $minutes; + $buckets[$key]['billableRevenue'] += $revenue; + } else { + $buckets[$key]['nonBillable'] += $minutes; + $buckets[$key]['nonBillableRevenue'] += $revenue; + } + } + + $allKeys = $this->generatePeriodKeys($dateFrom, $now, $groupBy); + $labels = []; + $billableArr = []; + $nonBillArr = []; + $billableRevArr = []; + $nonBillRevArr = []; + + foreach ($allKeys as $key) { + $labels[] = $key; + $billableArr[] = round(($buckets[$key]['billable'] ?? 0) / 60, 2); + $nonBillArr[] = round(($buckets[$key]['nonBillable'] ?? 0) / 60, 2); + $billableRevArr[] = round($buckets[$key]['billableRevenue'] ?? 0, 2); + $nonBillRevArr[] = round($buckets[$key]['nonBillableRevenue'] ?? 0, 2); + } + + return [ + 'labels' => $labels, + 'billable' => $billableArr, + 'nonBillable' => $nonBillArr, + 'billableRevenue' => $billableRevArr, + 'nonBillableRevenue' => $nonBillRevArr, + 'groupBy' => $groupBy, + ]; + } + + private function generatePeriodKeys(\DateTimeImmutable $from, \DateTimeImmutable $to, string $groupBy): array + { + $keys = []; + $cursor = $from; + + while ($cursor <= $to) { + $key = match ($groupBy) { + 'week' => $cursor->format('o-W'), + 'day' => $cursor->format('Y-m-d'), + default => $cursor->format('Y-m'), + }; + + if (!in_array($key, $keys, true)) { + $keys[] = $key; + } + + $cursor = match ($groupBy) { + 'week' => $cursor->modify('+7 days'), + 'day' => $cursor->modify('+1 day'), + default => $cursor->modify('first day of next month'), + }; + } + + return $keys; + } + 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 ae854c5..166d76b 100644 --- a/httpdocs/src/Service/LexofficeService.php +++ b/httpdocs/src/Service/LexofficeService.php @@ -62,7 +62,10 @@ class LexofficeService return $name !== '' ? ['id' => $contact['id'], 'name' => $name] : null; } - public function createInvoiceDraft(string $apiKey, string $contactId, ?string $dateFrom = null, ?string $dateTo = null): array + /** + * @param array