From 1351f07044c2180a80f5dc40f03ff7a255b15eb0 Mon Sep 17 00:00:00 2001 From: FlorianEisenmenger Date: Sun, 24 May 2026 23:06:10 +0200 Subject: [PATCH] Report Filter WIP --- httpdocs/assets/scripts/report.js | 228 ++++++++++++++++ httpdocs/assets/styles/sections/_report.scss | 248 +++++++++++++++++ httpdocs/src/Controller/ReportController.php | 191 +++++++++++-- .../Repository/Tenant/TimeEntryRepository.php | 87 ++++++ .../templates/report/_filter-panel.html.twig | 253 ++++++++++++++++++ httpdocs/templates/report/times.html.twig | 17 +- httpdocs/translations/messages.de.yaml | 24 ++ 7 files changed, 1025 insertions(+), 23 deletions(-) create mode 100644 httpdocs/templates/report/_filter-panel.html.twig diff --git a/httpdocs/assets/scripts/report.js b/httpdocs/assets/scripts/report.js index e575f2d..5cb9d81 100644 --- a/httpdocs/assets/scripts/report.js +++ b/httpdocs/assets/scripts/report.js @@ -197,3 +197,231 @@ document.addEventListener('DOMContentLoaded', () => { } }); }); + +// ── ReportFilter ────────────────────────────────────────────────────────────── + +class ReportFilter { + constructor() { + this.panel = document.getElementById('report-filter'); + this.toggleBtn = document.getElementById('btn-filter-toggle'); + this.applyBtn = document.getElementById('btn-filter-apply'); + this.hideBtn = document.getElementById('btn-filter-hide'); + this.periodSel = document.querySelector('.filter-period-select'); + this.customDates = document.querySelector('.filter-custom-dates'); + } + + init() { + if (!this.panel) return; + + // Toolbar-Toggle + this.toggleBtn?.addEventListener('click', () => this.togglePanel()); + + // Ausblenden-Button + this.hideBtn?.addEventListener('click', () => this.hidePanel()); + + // Filtern-Button + this.applyBtn?.addEventListener('click', () => this.applyFilters()); + + // Checkbox-Änderungen + this.panel.querySelectorAll('.filter-row__checkbox').forEach(cb => { + cb.addEventListener('change', () => { + const row = cb.closest('.filter-row'); + this.syncRowState(row, cb.checked); + }); + }); + + // Klick auf ausgegrautem Control → Checkbox aktivieren + this.panel.querySelectorAll('.filter-select, .filter-note-input').forEach(el => { + el.addEventListener('mousedown', () => this.activateRowByControl(el)); + }); + + // Zeitraum-Select → Custom-Felder zeigen/verstecken + this.periodSel?.addEventListener('change', () => { + const row = this.periodSel.closest('.filter-row'); + this.activateRowByControl(this.periodSel); + this.toggleCustomDates(this.periodSel.value === 'custom'); + }); + + // Plus-Buttons + this.panel.querySelectorAll('.filter-row__add').forEach(btn => { + btn.addEventListener('click', () => this.addControl(btn)); + }); + + // Remove-Buttons (via Delegation, da sie dynamisch entstehen) + this.panel.addEventListener('click', e => { + const removeBtn = e.target.closest('.filter-row__remove'); + if (removeBtn) this.removeControl(removeBtn); + }); + + // Initialer Zustand + this.panel.querySelectorAll('.filter-row').forEach(row => { + const cb = row.querySelector('.filter-row__checkbox'); + this.syncRowState(row, cb?.checked ?? false); + }); + } + + // ── Panel toggeln ───────────────────────────────────────────────────────── + + togglePanel() { + const isHidden = this.panel.hasAttribute('hidden'); + if (isHidden) { + this.panel.removeAttribute('hidden'); + this.toggleBtn?.classList.add('report-toolbar__action--active'); + } else { + this.hidePanel(); + } + } + + hidePanel() { + this.panel.setAttribute('hidden', ''); + this.toggleBtn?.classList.remove('report-toolbar__action--active'); + } + + // ── Row-Zustand (aktiv / inaktiv) ───────────────────────────────────────── + + syncRowState(row, active) { + row.classList.toggle('filter-row--inactive', !active); + } + + activateRowByControl(el) { + const row = el.closest('.filter-row'); + if (!row) return; + const cb = row.querySelector('.filter-row__checkbox'); + if (cb && !cb.checked) { + cb.checked = true; + this.syncRowState(row, true); + } + } + + // ── Zeitraum: Custom-Felder ──────────────────────────────────────────────── + + toggleCustomDates(show) { + if (!this.customDates) return; + if (show) { + this.customDates.removeAttribute('hidden'); + } else { + this.customDates.setAttribute('hidden', ''); + } + } + + // ── Plus: weiteres Control hinzufügen ───────────────────────────────────── + + addControl(btn) { + const targetId = btn.dataset.target; + const filterKey = btn.dataset.filterKey; + const container = document.getElementById(targetId); + if (!container) return; + + // Erste Gruppe als Template klonen + const template = container.querySelector('.filter-row__control-group'); + if (!template) return; + + const clone = template.cloneNode(true); + + // Select zurücksetzen + const clonedSelect = clone.querySelector('.filter-select'); + if (clonedSelect) clonedSelect.value = ''; + + // Remove-Button hinzufügen (falls noch keiner da) + if (!clone.querySelector('.filter-row__remove')) { + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'filter-row__remove'; + removeBtn.textContent = '×'; + clone.appendChild(removeBtn); + } + + // Neu: Klick auf den geklonten Select aktiviert ebenfalls die Row + clone.querySelector('.filter-select')?.addEventListener('mousedown', () => { + this.activateRowByControl(clone.querySelector('.filter-select')); + }); + + container.appendChild(clone); + + // Row aktivieren + const row = btn.closest('.filter-row'); + const cb = row?.querySelector('.filter-row__checkbox'); + if (cb && !cb.checked) { + cb.checked = true; + this.syncRowState(row, true); + } + + clonedSelect?.focus(); + } + + // ── Minus: Control entfernen ────────────────────────────────────────────── + + removeControl(removeBtn) { + const group = removeBtn.closest('.filter-row__control-group'); + const container = group?.parentElement; + group?.remove(); + + // Wenn keine Controls mehr übrig → Checkbox deaktivieren + if (container && !container.querySelector('.filter-row__control-group')) { + const row = container.closest('.filter-row'); + const cb = row?.querySelector('.filter-row__checkbox'); + if (cb) { + cb.checked = false; + this.syncRowState(row, false); + } + } + } + + // ── Filter anwenden → URL bauen und navigieren ──────────────────────────── + + applyFilters() { + const params = new URLSearchParams(); + params.set('limit', String(window.Report?.limit ?? 50)); + + this.panel.querySelectorAll('.filter-row').forEach(row => { + const cb = row.querySelector('.filter-row__checkbox'); + if (!cb?.checked) return; + + const key = row.dataset.filterKey; + + if (['clients', 'projects', 'services', 'users'].includes(key)) { + row.querySelectorAll('.filter-select').forEach(sel => { + if (sel.value) params.append(`filter[${key}][]`, sel.value); + }); + + } else if (key === 'period') { + const val = this.periodSel?.value; + if (!val) return; + params.set('filter[period]', val); + + if (val === 'custom' && this.customDates) { + const get = field => this.customDates.querySelector(`[data-date-field="${field}"]`)?.value ?? ''; + const fromDay = get('from-day').padStart(2, '0'); + const fromMonth = get('from-month').padStart(2, '0'); + const fromYear = get('from-year'); + const toDay = get('to-day').padStart(2, '0'); + const toMonth = get('to-month').padStart(2, '0'); + const toYear = get('to-year'); + + if (fromYear && fromMonth && fromDay) { + params.set('filter[date_from]', `${fromYear}-${fromMonth}-${fromDay}`); + } + if (toYear && toMonth && toDay) { + params.set('filter[date_to]', `${toYear}-${toMonth}-${toDay}`); + } + } + + } else if (key === 'note') { + const val = row.querySelector('.filter-note-input')?.value?.trim(); + if (val) params.set('filter[note]', val); + + } else if (key === 'invoiced') { + const checked = row.querySelector('.filter-invoiced-radio:checked'); + if (checked) params.set('filter[invoiced]', checked.value); + } + }); + + window.location.href = `/reports/times?${params}`; + } +} + +// ── Init ────────────────────────────────────────────────────────────────────── + +document.addEventListener('DOMContentLoaded', () => { + new ReportFilter().init(); +}); diff --git a/httpdocs/assets/styles/sections/_report.scss b/httpdocs/assets/styles/sections/_report.scss index f17a6fa..462c230 100644 --- a/httpdocs/assets/styles/sections/_report.scss +++ b/httpdocs/assets/styles/sections/_report.scss @@ -403,3 +403,251 @@ .report-pagination__lock-spacer { // Platzhalter für die Aktions-Spalte – hält die Ausrichtung } + +// ─── Toolbar-Button (klickbar) ──────────────────────────────────────────────── +button.report-toolbar__action { + background: none; + border: none; + cursor: pointer; + font-family: $font-family-base; + padding: $space-1 $space-3; + margin: -$space-1 -$space-3; + border-radius: $radius-pill; + transition: background $transition-fast, color $transition-fast; + + &--active { + background: $color-text-dark; + color: $color-white; + + svg path, + svg circle { + stroke: $color-white; + } + } +} + +// ─── Filter-Panel ───────────────────────────────────────────────────────────── +.report-filter { + background: $color-card; + border-bottom: 1px solid $color-border; + padding: $space-5 $space-5 0; +} + +.report-filter__body { + display: flex; + gap: $space-10; +} + +.report-filter__col { + flex: 1; + min-width: 0; +} + +.report-filter__heading { + font-size: $font-size-xs; + font-weight: $font-weight-bold; + color: $color-text-muted; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: $space-3; +} + +// ─── Filter-Row ─────────────────────────────────────────────────────────────── +.filter-row { + display: grid; + grid-template-columns: 160px 1fr 28px; + align-items: flex-start; + gap: $space-3; + padding: $space-2 0; + border-bottom: 1px solid rgba($color-border, 0.6); + + &:last-child { + border-bottom: none; + } + + // Ausgegraut wenn inaktiv – aber klickbar! + &--inactive { + .filter-select, + .filter-note-input, + .filter-period-select { + opacity: 0.5; + color: $color-text-muted; + } + + .filter-row__add { + opacity: 0.4; + } + + .filter-radio { + opacity: 0.5; + } + } +} + +.filter-row__label { + display: flex; + align-items: center; + gap: $space-2; + cursor: pointer; + font-size: $font-size-sm; + color: $color-text-base; + padding-top: 7px; // optisch mit den Selects ausrichten + user-select: none; +} + +.filter-row__checkbox { + width: 14px; + height: 14px; + cursor: pointer; + flex-shrink: 0; + accent-color: $color-primary; +} + +.filter-row__controls { + display: flex; + flex-direction: column; + gap: $space-2; + min-width: 0; +} + +.filter-row__control-group { + display: flex; + align-items: center; + gap: $space-2; + + .filter-select, + .filter-note-input { + width: 300px; + max-width: 100%; + } + + &--period { + flex-direction: column; + align-items: flex-start; + gap: $space-2; + } + + &--radio { + padding-top: 7px; + gap: $space-4; + } +} + +// ─── Plus- und Minus-Button ─────────────────────────────────────────────────── +.filter-row__add { + width: 22px; + height: 22px; + margin-top: 7px; + border: 1px solid $color-input-border; + background: $color-white; + border-radius: $radius-sm; + cursor: pointer; + font-size: $font-size-md; + line-height: 1; + color: $color-text-muted; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: border-color $transition-fast, color $transition-fast; + + &:hover { + border-color: $color-primary; + color: $color-primary; + } +} + +.filter-row__remove { + width: 20px; + height: 20px; + border: none; + background: none; + cursor: pointer; + font-size: $font-size-md; + line-height: 1; + color: $color-text-light; + display: flex; + align-items: center; + justify-content: center; + border-radius: $radius-sm; + flex-shrink: 0; + transition: color $transition-fast, background $transition-fast; + + &:hover { + color: $color-error; + background: rgba($color-error, 0.08); + } +} + +// ─── Zeitraum: Custom-Datumsfelder ─────────────────────────────────────────── +.filter-custom-dates { + display: flex; + flex-direction: column; + gap: $space-2; + margin-top: $space-2; +} + +.filter-date-group { + display: flex; + align-items: center; + gap: $space-2; +} + +.filter-date-label { + font-size: $font-size-sm; + color: $color-text-muted; + width: 26px; + flex-shrink: 0; +} + +.filter-date-select { + width: auto; + display: inline-block; + + &--sm { + padding-right: $space-6; + min-width: 60px; + } + + &--month { + padding-right: $space-6; + min-width: 100px; + } +} + +// ─── Radio-Filter (Abgeschlossen?) ──────────────────────────────────────────── +.filter-radio { + display: inline-flex; + align-items: center; + gap: $space-1; + font-size: $font-size-sm; + color: $color-text-base; + cursor: pointer; + user-select: none; + accent-color: $color-primary; +} + +// ─── Filter-Footer ──────────────────────────────────────────────────────────── +.report-filter__footer { + display: flex; + align-items: center; + gap: $space-4; + padding: $space-4 0; +} + +.filter-footer__link { + background: none; + border: none; + padding: 0; + cursor: pointer; + font-family: $font-family-base; + font-size: $font-size-sm; + color: $color-text-muted; + text-decoration: underline; + text-underline-offset: 2px; + transition: color $transition-fast; + + &:hover { + color: $color-text-base; + } +} diff --git a/httpdocs/src/Controller/ReportController.php b/httpdocs/src/Controller/ReportController.php index e40e861..132340a 100644 --- a/httpdocs/src/Controller/ReportController.php +++ b/httpdocs/src/Controller/ReportController.php @@ -3,6 +3,7 @@ namespace App\Controller; use App\Repository\Central\AccountUserRepository; +use App\Repository\Tenant\ClientRepository; use App\Repository\Tenant\TimeEntryRepository; use App\Repository\Tenant\ProjectRepository; use App\Repository\Tenant\ServiceRepository; @@ -30,6 +31,7 @@ class ReportController extends AbstractController private readonly Security $security, private readonly ProjectRepository $projectRepo, private readonly ServiceRepository $serviceRepo, + private readonly ClientRepository $clientRepo, ) {} #[Route('/reports/times', name: 'report_times')] @@ -54,35 +56,182 @@ class ReportController extends AbstractController $userMap[$au->getUser()->getId()] = $au->getUser()->getFullName(); } + // User-Liste für Filter-Dropdown (für Twig/JS) + $userList = []; + foreach ($userMap as $uid => $uname) { + $userList[] = ['id' => $uid, 'name' => $uname]; + } + + // Filter aus GET-Parametern lesen + $filterRaw = $request->query->all('filter'); + $filters = $this->parseFilters($filterRaw); + + // Tracker: immer auf eigenen User beschränken if ($isTracker) { - $entries = $this->timeEntryRepo->findForReportByUserId($currentUserId, $limit); - $totalCount = $this->timeEntryRepo->countByUserId($currentUserId); - $totalMinutes = $this->timeEntryRepo->sumDurationByUserId($currentUserId); - $totalRevenue = $this->timeEntryRepo->sumRevenueByUserId($currentUserId); - } else { - $entries = $this->timeEntryRepo->findForReport($limit); - $totalCount = $this->timeEntryRepo->countAll(); - $totalMinutes = $this->timeEntryRepo->sumDurationAll(); - $totalRevenue = $this->timeEntryRepo->sumRevenueAll(); + $filters['userIds'] = [$currentUserId]; + } + + // Ob der Benutzer explizit Filter gesetzt hat (für "Alle anzeigen") + $filterActive = !empty($request->query->all('filter')); + + // Custom-Datumsfelder für Twig vorparsen + $filterDateFrom = null; + $filterDateTo = null; + if (($filterRaw['period'] ?? '') === 'custom') { + if (!empty($filterRaw['date_from'])) { + try { + $filterDateFrom = new \DateTimeImmutable($filterRaw['date_from']); + } catch (\Exception) { + } + } + if (!empty($filterRaw['date_to'])) { + try { + $filterDateTo = new \DateTimeImmutable($filterRaw['date_to']); + } catch (\Exception) { + } + } } + // Queries — immer gefilterte Methoden (leere Filter = kein WHERE) + $entries = $this->timeEntryRepo->findForReportFiltered($filters, $limit); + $totalCount = $this->timeEntryRepo->countFiltered($filters); + $totalMinutes = $this->timeEntryRepo->sumDurationFiltered($filters); + $totalRevenue = $this->timeEntryRepo->sumRevenueFiltered($filters); + return $this->render('report/times.html.twig', [ - 'entries' => $entries, - 'userMap' => $userMap, - 'totalCount' => $totalCount, - 'totalDuration' => $this->formatMinutes($totalMinutes), - 'totalRevenue' => $totalRevenue, - 'limit' => $limit, - 'validLimits' => self::VALID_LIMITS, - 'accountName' => $account?->getName() ?? '', - 'currentUserId' => $currentUserId, - 'isAdmin' => $isAdmin, - 'projects' => $this->projectRepo->findAllWithClient(), - 'services' => $this->serviceRepo->findAllOrderedByBillable(), + 'entries' => $entries, + 'userMap' => $userMap, + 'userList' => $userList, + 'totalCount' => $totalCount, + 'totalDuration' => $this->formatMinutes($totalMinutes), + 'totalRevenue' => $totalRevenue, + 'limit' => $limit, + 'validLimits' => self::VALID_LIMITS, + 'accountName' => $account?->getName() ?? '', + 'currentUserId' => $currentUserId, + 'isAdmin' => $isAdmin, + 'isTracker' => $isTracker, + 'projects' => $this->projectRepo->findAllWithClient(), + 'services' => $this->serviceRepo->findAllOrderedByBillable(), + 'clients' => $this->clientRepo->findAllActiveOrderedByName(), + 'filterRaw' => $filterRaw, + 'filterActive' => $filterActive, + 'filterDateFrom' => $filterDateFrom, + 'filterDateTo' => $filterDateTo, 'trackingInterval' => $this->tenantContext->getAccount()?->getTrackingInterval() ?? 1, ]); } + // ── Filter-Parsing ──────────────────────────────────────────────────────── + + private function parseFilters(array $f): array + { + $filters = []; + + $clientIds = array_values(array_filter(array_map('intval', (array) ($f['clients'] ?? [])))); + if ($clientIds) { + $filters['clientIds'] = $clientIds; + } + + $projectIds = array_values(array_filter(array_map('intval', (array) ($f['projects'] ?? [])))); + if ($projectIds) { + $filters['projectIds'] = $projectIds; + } + + $serviceIds = array_values(array_filter(array_map('intval', (array) ($f['services'] ?? [])))); + if ($serviceIds) { + $filters['serviceIds'] = $serviceIds; + } + + $userIds = array_values(array_filter(array_map('intval', (array) ($f['users'] ?? [])))); + if ($userIds) { + $filters['userIds'] = $userIds; + } + + [$dateFrom, $dateTo] = $this->parsePeriod($f); + if ($dateFrom !== null) { + $filters['dateFrom'] = $dateFrom; + } + if ($dateTo !== null) { + $filters['dateTo'] = $dateTo; + } + + $note = trim($f['note'] ?? ''); + if ($note !== '') { + $filters['note'] = $note; + } + + if (isset($f['invoiced']) && in_array($f['invoiced'], ['yes', 'no'], true)) { + $filters['invoiced'] = $f['invoiced'] === 'yes'; + } + + return $filters; + } + + private function parsePeriod(array $f): array + { + $period = $f['period'] ?? ''; + $now = new \DateTimeImmutable(); + + return match ($period) { + 'today' => [ + $now->setTime(0, 0, 0), + $now->setTime(23, 59, 59), + ], + 'yesterday' => [ + $now->modify('-1 day')->setTime(0, 0, 0), + $now->modify('-1 day')->setTime(23, 59, 59), + ], + 'current_week' => [ + new \DateTimeImmutable('monday this week'), + new \DateTimeImmutable('sunday this week'), + ], + 'prev_week' => [ + new \DateTimeImmutable('monday last week'), + new \DateTimeImmutable('sunday last week'), + ], + 'current_month' => [ + new \DateTimeImmutable($now->format('Y-m-01')), + new \DateTimeImmutable($now->format('Y-m-t')), + ], + 'prev_month' => [ + new \DateTimeImmutable($now->modify('first day of last month')->format('Y-m-d')), + new \DateTimeImmutable($now->modify('last day of last month')->format('Y-m-d')), + ], + 'current_year' => [ + new \DateTimeImmutable($now->format('Y') . '-01-01'), + new \DateTimeImmutable($now->format('Y') . '-12-31'), + ], + 'prev_year' => [ + new \DateTimeImmutable(($now->format('Y') - 1) . '-01-01'), + new \DateTimeImmutable(($now->format('Y') - 1) . '-12-31'), + ], + 'custom' => $this->parseCustomDates($f), + default => [null, null], + }; + } + + private function parseCustomDates(array $f): array + { + $from = null; + $to = null; + + if (!empty($f['date_from'])) { + try { + $from = new \DateTimeImmutable($f['date_from']); + } catch (\Exception) { + } + } + if (!empty($f['date_to'])) { + try { + $to = new \DateTimeImmutable($f['date_to']); + } catch (\Exception) { + } + } + + return [$from, $to]; + } + // ── API: Abgerechnet-Status toggeln ─────────────────────────────────────── #[Route('/api/entries/{id}/invoiced', name: 'api_entry_invoiced_toggle', methods: ['PATCH'])] diff --git a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php index 046b340..f8ef6ce 100644 --- a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php +++ b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php @@ -186,4 +186,91 @@ class TimeEntryRepository extends ServiceEntityRepository ->getQuery() ->getSingleScalarResult(); } + + // ── Report mit Filtern ──────────────────────────────────────────────────── + + private function buildFilteredQuery(array $filters): \Doctrine\ORM\QueryBuilder + { + $qb = $this->createQueryBuilder('t') + ->join('t.project', 'p') + ->join('p.client', 'c') + ->leftJoin('t.service', 's'); + + if (!empty($filters['clientIds'])) { + $qb->andWhere('c.id IN (:clientIds)') + ->setParameter('clientIds', $filters['clientIds']); + } + if (!empty($filters['projectIds'])) { + $qb->andWhere('p.id IN (:projectIds)') + ->setParameter('projectIds', $filters['projectIds']); + } + if (!empty($filters['serviceIds'])) { + $qb->andWhere('s.id IN (:serviceIds)') + ->setParameter('serviceIds', $filters['serviceIds']); + } + if (!empty($filters['userIds'])) { + $qb->andWhere('t.userId IN (:userIds)') + ->setParameter('userIds', $filters['userIds']); + } + if (!empty($filters['dateFrom'])) { + $qb->andWhere('t.date >= :dateFrom') + ->setParameter('dateFrom', $filters['dateFrom']->format('Y-m-d')); + } + if (!empty($filters['dateTo'])) { + $qb->andWhere('t.date <= :dateTo') + ->setParameter('dateTo', $filters['dateTo']->format('Y-m-d')); + } + if (!empty($filters['note'])) { + $qb->andWhere('t.note LIKE :note') + ->setParameter('note', '%' . $filters['note'] . '%'); + } + if (isset($filters['invoiced'])) { + $qb->andWhere('t.invoiced = :invoiced') + ->setParameter('invoiced', $filters['invoiced']); + } + + return $qb; + } + + public function findForReportFiltered(array $filters, int $limit): array + { + return $this->buildFilteredQuery($filters) + ->addSelect('p', 'c', 's') + ->orderBy('t.date', 'DESC') + ->addOrderBy('t.createdAt', 'DESC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } + + public function countFiltered(array $filters): int + { + return (int) $this->buildFilteredQuery($filters) + ->select('COUNT(t.id)') + ->getQuery() + ->getSingleScalarResult(); + } + + public function sumDurationFiltered(array $filters): int + { + $result = $this->buildFilteredQuery($filters) + ->select('SUM(t.duration)') + ->getQuery() + ->getSingleScalarResult(); + + return (int) $result; + } + + public function sumRevenueFiltered(array $filters): float + { + $result = $this->buildFilteredQuery($filters) + ->select('SUM(c.hourlyRate * t.duration / 60)') + ->andWhere('c.hourlyRate IS NOT NULL') + ->andWhere('(s IS NULL OR s.billable = :billable_rev)') + ->setParameter('billable_rev', true) + ->getQuery() + ->getSingleScalarResult(); + + return (float) ($result ?? 0.0); + } } diff --git a/httpdocs/templates/report/_filter-panel.html.twig b/httpdocs/templates/report/_filter-panel.html.twig new file mode 100644 index 0000000..2e99c93 --- /dev/null +++ b/httpdocs/templates/report/_filter-panel.html.twig @@ -0,0 +1,253 @@ +{# templates/report/_filter-panel.html.twig #} +{# Variablen: clients, projects, services, userList, filterRaw, filterActive, + filterDateFrom, filterDateTo, isTracker, limit #} + +{% set fr = filterRaw %} +{% set frPeriod = fr.period is defined ? fr.period : '' %} +{% set frInvoiced = fr.invoiced is defined ? fr.invoiced : '' %} +{% set months = deMonths() %} +{% set yearNow = 'now'|date('Y') %} +{% set dayNow = 'now'|date('j') %} +{% set monthNow = 'now'|date('n') %} + +{% set hasClients = fr.clients is defined and fr.clients is not empty %} +{% set hasProjects = fr.projects is defined and fr.projects is not empty %} +{% set hasServices = fr.services is defined and fr.services is not empty %} +{% set hasUsers = fr.users is defined and fr.users is not empty %} +{% set hasPeriod = fr.period is defined and fr.period is not empty %} +{% set hasNote = fr.note is defined and fr.note is not empty %} +{% set hasInvoiced = fr.invoiced is defined and fr.invoiced is not empty %} +{% set isCustom = hasPeriod and frPeriod == 'custom' %} + +{% set fromDay = filterDateFrom ? filterDateFrom|date('j') : dayNow %} +{% set fromMonth = filterDateFrom ? filterDateFrom|date('n') : monthNow %} +{% set fromYear = filterDateFrom ? filterDateFrom|date('Y') : yearNow %} +{% set toDay = filterDateTo ? filterDateTo|date('j') : dayNow %} +{% set toMonth = filterDateTo ? filterDateTo|date('n') : monthNow %} +{% set toYear = filterDateTo ? filterDateTo|date('Y') : yearNow %} + +
+ +
+
+ +

{{ 'app.report.filter_by'|trans }}

+ + {# ── Kunde ────────────────────────────────────────────────────── #} +
+ +
+ {% set selClients = hasClients ? fr.clients : [''] %} + {% for i, val in selClients %} +
+ + {% if i > 0 %} + + {% endif %} +
+ {% endfor %} +
+ +
+ + {# ── Projekt ──────────────────────────────────────────────────── #} +
+ +
+ {% set selProjects = hasProjects ? fr.projects : [''] %} + {% for i, val in selProjects %} +
+ + {% if i > 0 %} + + {% endif %} +
+ {% endfor %} +
+ +
+ + {# ── Leistung ─────────────────────────────────────────────────── #} +
+ +
+ {% set selServices = hasServices ? fr.services : [''] %} + {% for i, val in selServices %} +
+ + {% if i > 0 %} + + {% endif %} +
+ {% endfor %} +
+ +
+ + {# ── Benutzer (nur für Admins / Members, nicht für Tracker) ───── #} + {% if not isTracker %} +
+ +
+ {% set selUsers = hasUsers ? fr.users : [''] %} + {% for i, val in selUsers %} +
+ + {% if i > 0 %} + + {% endif %} +
+ {% endfor %} +
+ +
+ {% endif %} + + {# ── Zeitraum ─────────────────────────────────────────────────── #} +
+ +
+
+ + +
+
+ {{ 'app.report.period_from'|trans }} + + + +
+
+ {{ 'app.report.period_to'|trans }} + + + +
+
+ +
+
+
+ + {# ── Bemerkung ────────────────────────────────────────────────── #} +
+ +
+
+ +
+
+
+ + {# ── Abgeschlossen? ───────────────────────────────────────────── #} +
+ +
+
+ + +
+
+
+ +
{# /.report-filter__col #} +
{# /.report-filter__body #} + + + +
{# /.report-filter #} diff --git a/httpdocs/templates/report/times.html.twig b/httpdocs/templates/report/times.html.twig index c19f64a..9878097 100644 --- a/httpdocs/templates/report/times.html.twig +++ b/httpdocs/templates/report/times.html.twig @@ -17,6 +17,13 @@ trackingInterval: {{ trackingInterval }}, currentUserId: {{ currentUserId }}, isAdmin: {{ isAdmin ? 'true' : 'false' }}, + isTracker: {{ isTracker ? 'true' : 'false' }}, + limit: {{ limit }}, + clients: [ + {% for client in clients %} + { id: {{ client.id }}, name: {{ client.name|json_encode|raw }} }{% if not loop.last %},{% endif %} + {% endfor %} + ], projects: [ {% for project in projects %} { @@ -33,6 +40,7 @@ billable: {{ service.billable ? 'true' : 'false' }} }{% if not loop.last %},{% endif %} {% endfor %} ], + users: {{ userList|json_encode|raw }}, i18n: { btnSave: {{ 'app.entry.btn_save'|trans|json_encode|raw }}, btnCancel: {{ 'app.entry.btn_cancel'|trans|json_encode|raw }}, @@ -85,13 +93,15 @@ {# ── Toolbar ──────────────────────────────────────────────────────── #}
- + @@ -101,6 +111,9 @@
+ {# ── Filter-Panel ─────────────────────────────────────────────────── #} + {% include 'report/_filter-panel.html.twig' %} + {# ── Tabellen-Header ───────────────────────────────────────────────── #}
diff --git a/httpdocs/translations/messages.de.yaml b/httpdocs/translations/messages.de.yaml index 16e8bb7..bcf96d5 100644 --- a/httpdocs/translations/messages.de.yaml +++ b/httpdocs/translations/messages.de.yaml @@ -84,6 +84,30 @@ app: btn_lock: "Als abgerechnet markieren" btn_unlock: "Abrechnung aufheben" invoiced_title: "Abgerechnet – Bearbeiten nicht möglich" + filter_by: "Filtern nach" + btn_filter: "Filtern" + btn_hide: "Ausblenden" + btn_show_all: "Alle anzeigen" + filter_client: "Kunde" + filter_project: "Projekt" + filter_service: "Leistung" + filter_user: "Benutzer" + filter_period: "Zeitraum" + filter_note: "Bemerkung" + filter_invoiced: "Abgeschlossen?" + period_today: "Heute" + period_yesterday: "Gestern" + period_current_week: "Aktuelle Woche" + period_prev_week: "Vorherige Woche" + period_current_month: "Aktueller Monat" + period_prev_month: "Vorheriger Monat" + period_current_year: "Aktuelles Jahr" + period_prev_year: "Vorheriges Jahr" + period_custom: "Von … bis" + period_from: "von" + period_to: "bis" + invoiced_yes: "Ja" + invoiced_no: "Nein" forgot_password: page_title: "Passwort vergessen – spawntree"