| @@ -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(); | |||||
| }); | |||||
| @@ -403,3 +403,251 @@ | |||||
| .report-pagination__lock-spacer { | .report-pagination__lock-spacer { | ||||
| // Platzhalter für die Aktions-Spalte – hält die Ausrichtung | // 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; | |||||
| } | |||||
| } | |||||
| @@ -3,6 +3,7 @@ | |||||
| namespace App\Controller; | namespace App\Controller; | ||||
| use App\Repository\Central\AccountUserRepository; | use App\Repository\Central\AccountUserRepository; | ||||
| use App\Repository\Tenant\ClientRepository; | |||||
| use App\Repository\Tenant\TimeEntryRepository; | use App\Repository\Tenant\TimeEntryRepository; | ||||
| use App\Repository\Tenant\ProjectRepository; | use App\Repository\Tenant\ProjectRepository; | ||||
| use App\Repository\Tenant\ServiceRepository; | use App\Repository\Tenant\ServiceRepository; | ||||
| @@ -30,6 +31,7 @@ class ReportController extends AbstractController | |||||
| private readonly Security $security, | private readonly Security $security, | ||||
| private readonly ProjectRepository $projectRepo, | private readonly ProjectRepository $projectRepo, | ||||
| private readonly ServiceRepository $serviceRepo, | private readonly ServiceRepository $serviceRepo, | ||||
| private readonly ClientRepository $clientRepo, | |||||
| ) {} | ) {} | ||||
| #[Route('/reports/times', name: 'report_times')] | #[Route('/reports/times', name: 'report_times')] | ||||
| @@ -54,35 +56,182 @@ class ReportController extends AbstractController | |||||
| $userMap[$au->getUser()->getId()] = $au->getUser()->getFullName(); | $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) { | 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', [ | 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, | '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 ─────────────────────────────────────── | // ── API: Abgerechnet-Status toggeln ─────────────────────────────────────── | ||||
| #[Route('/api/entries/{id}/invoiced', name: 'api_entry_invoiced_toggle', methods: ['PATCH'])] | #[Route('/api/entries/{id}/invoiced', name: 'api_entry_invoiced_toggle', methods: ['PATCH'])] | ||||
| @@ -186,4 +186,91 @@ class TimeEntryRepository extends ServiceEntityRepository | |||||
| ->getQuery() | ->getQuery() | ||||
| ->getSingleScalarResult(); | ->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); | |||||
| } | |||||
| } | } | ||||
| @@ -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 %} | |||||
| <div class="report-filter" id="report-filter"{% if not filterActive %} hidden{% endif %}> | |||||
| <div class="report-filter__body"> | |||||
| <div class="report-filter__col"> | |||||
| <h3 class="report-filter__heading">{{ 'app.report.filter_by'|trans }}</h3> | |||||
| {# ── Kunde ────────────────────────────────────────────────────── #} | |||||
| <div class="filter-row{% if not hasClients %} filter-row--inactive{% endif %}" | |||||
| data-filter-key="clients"> | |||||
| <label class="filter-row__label"> | |||||
| <input type="checkbox" class="filter-row__checkbox"{% if hasClients %} checked{% endif %}> | |||||
| <span>{{ 'app.report.filter_client'|trans }}</span> | |||||
| </label> | |||||
| <div class="filter-row__controls" id="filter-controls-clients"> | |||||
| {% set selClients = hasClients ? fr.clients : [''] %} | |||||
| {% for i, val in selClients %} | |||||
| <div class="filter-row__control-group"> | |||||
| <select class="select filter-select" data-filter-key="clients"> | |||||
| <option value="">...</option> | |||||
| {% for client in clients %} | |||||
| <option value="{{ client.id }}"{% if val == client.id %} selected{% endif %}>{{ client.name }}</option> | |||||
| {% endfor %} | |||||
| </select> | |||||
| {% if i > 0 %} | |||||
| <button type="button" class="filter-row__remove">×</button> | |||||
| {% endif %} | |||||
| </div> | |||||
| {% endfor %} | |||||
| </div> | |||||
| <button type="button" class="filter-row__add" data-target="filter-controls-clients" data-filter-key="clients">+</button> | |||||
| </div> | |||||
| {# ── Projekt ──────────────────────────────────────────────────── #} | |||||
| <div class="filter-row{% if not hasProjects %} filter-row--inactive{% endif %}" | |||||
| data-filter-key="projects"> | |||||
| <label class="filter-row__label"> | |||||
| <input type="checkbox" class="filter-row__checkbox"{% if hasProjects %} checked{% endif %}> | |||||
| <span>{{ 'app.report.filter_project'|trans }}</span> | |||||
| </label> | |||||
| <div class="filter-row__controls" id="filter-controls-projects"> | |||||
| {% set selProjects = hasProjects ? fr.projects : [''] %} | |||||
| {% for i, val in selProjects %} | |||||
| <div class="filter-row__control-group"> | |||||
| <select class="select filter-select" data-filter-key="projects"> | |||||
| <option value="">...</option> | |||||
| {% for project in projects %} | |||||
| <option value="{{ project.id }}"{% if val == project.id %} selected{% endif %}>{{ project.client.name }} / {{ project.name }}</option> | |||||
| {% endfor %} | |||||
| </select> | |||||
| {% if i > 0 %} | |||||
| <button type="button" class="filter-row__remove">×</button> | |||||
| {% endif %} | |||||
| </div> | |||||
| {% endfor %} | |||||
| </div> | |||||
| <button type="button" class="filter-row__add" data-target="filter-controls-projects" data-filter-key="projects">+</button> | |||||
| </div> | |||||
| {# ── Leistung ─────────────────────────────────────────────────── #} | |||||
| <div class="filter-row{% if not hasServices %} filter-row--inactive{% endif %}" | |||||
| data-filter-key="services"> | |||||
| <label class="filter-row__label"> | |||||
| <input type="checkbox" class="filter-row__checkbox"{% if hasServices %} checked{% endif %}> | |||||
| <span>{{ 'app.report.filter_service'|trans }}</span> | |||||
| </label> | |||||
| <div class="filter-row__controls" id="filter-controls-services"> | |||||
| {% set selServices = hasServices ? fr.services : [''] %} | |||||
| {% for i, val in selServices %} | |||||
| <div class="filter-row__control-group"> | |||||
| <select class="select filter-select" data-filter-key="services"> | |||||
| <option value="">...</option> | |||||
| {% for service in services %} | |||||
| <option value="{{ service.id }}"{% if val == service.id %} selected{% endif %}>{{ service.name }}</option> | |||||
| {% endfor %} | |||||
| </select> | |||||
| {% if i > 0 %} | |||||
| <button type="button" class="filter-row__remove">×</button> | |||||
| {% endif %} | |||||
| </div> | |||||
| {% endfor %} | |||||
| </div> | |||||
| <button type="button" class="filter-row__add" data-target="filter-controls-services" data-filter-key="services">+</button> | |||||
| </div> | |||||
| {# ── Benutzer (nur für Admins / Members, nicht für Tracker) ───── #} | |||||
| {% if not isTracker %} | |||||
| <div class="filter-row{% if not hasUsers %} filter-row--inactive{% endif %}" | |||||
| data-filter-key="users"> | |||||
| <label class="filter-row__label"> | |||||
| <input type="checkbox" class="filter-row__checkbox"{% if hasUsers %} checked{% endif %}> | |||||
| <span>{{ 'app.report.filter_user'|trans }}</span> | |||||
| </label> | |||||
| <div class="filter-row__controls" id="filter-controls-users"> | |||||
| {% set selUsers = hasUsers ? fr.users : [''] %} | |||||
| {% for i, val in selUsers %} | |||||
| <div class="filter-row__control-group"> | |||||
| <select class="select filter-select" data-filter-key="users"> | |||||
| <option value="">...</option> | |||||
| {% for user in userList %} | |||||
| <option value="{{ user.id }}"{% if val == user.id %} selected{% endif %}>{{ user.name }}</option> | |||||
| {% endfor %} | |||||
| </select> | |||||
| {% if i > 0 %} | |||||
| <button type="button" class="filter-row__remove">×</button> | |||||
| {% endif %} | |||||
| </div> | |||||
| {% endfor %} | |||||
| </div> | |||||
| <button type="button" class="filter-row__add" data-target="filter-controls-users" data-filter-key="users">+</button> | |||||
| </div> | |||||
| {% endif %} | |||||
| {# ── Zeitraum ─────────────────────────────────────────────────── #} | |||||
| <div class="filter-row{% if not hasPeriod %} filter-row--inactive{% endif %}" | |||||
| data-filter-key="period"> | |||||
| <label class="filter-row__label"> | |||||
| <input type="checkbox" class="filter-row__checkbox"{% if hasPeriod %} checked{% endif %}> | |||||
| <span>{{ 'app.report.filter_period'|trans }}</span> | |||||
| </label> | |||||
| <div class="filter-row__controls"> | |||||
| <div class="filter-row__control-group filter-row__control-group--period"> | |||||
| <select class="select filter-select filter-period-select" data-filter-key="period"> | |||||
| <option value="">...</option> | |||||
| <option value="today" {% if frPeriod == 'today' %}selected{% endif %}>{{ 'app.report.period_today'|trans }}</option> | |||||
| <option value="yesterday" {% if frPeriod == 'yesterday' %}selected{% endif %}>{{ 'app.report.period_yesterday'|trans }}</option> | |||||
| <option value="current_week" {% if frPeriod == 'current_week' %}selected{% endif %}>{{ 'app.report.period_current_week'|trans }}</option> | |||||
| <option value="prev_week" {% if frPeriod == 'prev_week' %}selected{% endif %}>{{ 'app.report.period_prev_week'|trans }}</option> | |||||
| <option value="current_month" {% if frPeriod == 'current_month' %}selected{% endif %}>{{ 'app.report.period_current_month'|trans }}</option> | |||||
| <option value="prev_month" {% if frPeriod == 'prev_month' %}selected{% endif %}>{{ 'app.report.period_prev_month'|trans }}</option> | |||||
| <option value="current_year" {% if frPeriod == 'current_year' %}selected{% endif %}>{{ 'app.report.period_current_year'|trans }}</option> | |||||
| <option value="prev_year" {% if frPeriod == 'prev_year' %}selected{% endif %}>{{ 'app.report.period_prev_year'|trans }}</option> | |||||
| <option value="custom" {% if frPeriod == 'custom' %}selected{% endif %}>{{ 'app.report.period_custom'|trans }}</option> | |||||
| </select> | |||||
| <div class="filter-custom-dates"{% if not isCustom %} hidden{% endif %}> | |||||
| <div class="filter-date-group"> | |||||
| <span class="filter-date-label">{{ 'app.report.period_from'|trans }}</span> | |||||
| <select class="select filter-date-select filter-date-select--sm" data-date-field="from-day"> | |||||
| {% for d in 1..31 %} | |||||
| <option value="{{ d }}"{% if fromDay == d %} selected{% endif %}>{{ d }}</option> | |||||
| {% endfor %} | |||||
| </select> | |||||
| <select class="select filter-date-select filter-date-select--month" data-date-field="from-month"> | |||||
| {% for i in 0..11 %} | |||||
| <option value="{{ i + 1 }}"{% if fromMonth == (i + 1) %} selected{% endif %}>{{ months[i] }}</option> | |||||
| {% endfor %} | |||||
| </select> | |||||
| <select class="select filter-date-select filter-date-select--sm" data-date-field="from-year"> | |||||
| {% for y in range(yearNow - 5, yearNow) %} | |||||
| <option value="{{ y }}"{% if fromYear == y %} selected{% endif %}>{{ y }}</option> | |||||
| {% endfor %} | |||||
| </select> | |||||
| </div> | |||||
| <div class="filter-date-group"> | |||||
| <span class="filter-date-label">{{ 'app.report.period_to'|trans }}</span> | |||||
| <select class="select filter-date-select filter-date-select--sm" data-date-field="to-day"> | |||||
| {% for d in 1..31 %} | |||||
| <option value="{{ d }}"{% if toDay == d %} selected{% endif %}>{{ d }}</option> | |||||
| {% endfor %} | |||||
| </select> | |||||
| <select class="select filter-date-select filter-date-select--month" data-date-field="to-month"> | |||||
| {% for i in 0..11 %} | |||||
| <option value="{{ i + 1 }}"{% if toMonth == (i + 1) %} selected{% endif %}>{{ months[i] }}</option> | |||||
| {% endfor %} | |||||
| </select> | |||||
| <select class="select filter-date-select filter-date-select--sm" data-date-field="to-year"> | |||||
| {% for y in range(yearNow - 5, yearNow) %} | |||||
| <option value="{{ y }}"{% if toYear == y %} selected{% endif %}>{{ y }}</option> | |||||
| {% endfor %} | |||||
| </select> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| {# ── Bemerkung ────────────────────────────────────────────────── #} | |||||
| <div class="filter-row{% if not hasNote %} filter-row--inactive{% endif %}" | |||||
| data-filter-key="note"> | |||||
| <label class="filter-row__label"> | |||||
| <input type="checkbox" class="filter-row__checkbox"{% if hasNote %} checked{% endif %}> | |||||
| <span>{{ 'app.report.filter_note'|trans }}</span> | |||||
| </label> | |||||
| <div class="filter-row__controls"> | |||||
| <div class="filter-row__control-group"> | |||||
| <input type="text" class="input filter-note-input" value="{{ fr.note ?? '' }}" placeholder="..."> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| {# ── Abgeschlossen? ───────────────────────────────────────────── #} | |||||
| <div class="filter-row{% if not hasInvoiced %} filter-row--inactive{% endif %}" | |||||
| data-filter-key="invoiced"> | |||||
| <label class="filter-row__label"> | |||||
| <input type="checkbox" class="filter-row__checkbox"{% if hasInvoiced %} checked{% endif %}> | |||||
| <span>{{ 'app.report.filter_invoiced'|trans }}</span> | |||||
| </label> | |||||
| <div class="filter-row__controls"> | |||||
| <div class="filter-row__control-group filter-row__control-group--radio"> | |||||
| <label class="filter-radio"> | |||||
| <input type="radio" class="filter-invoiced-radio" value="yes"{% if frInvoiced != 'no' %} checked{% endif %}> | |||||
| {{ 'app.report.invoiced_yes'|trans }} | |||||
| </label> | |||||
| <label class="filter-radio"> | |||||
| <input type="radio" class="filter-invoiced-radio" value="no"{% if frInvoiced == 'no' %} checked{% endif %}> | |||||
| {{ 'app.report.invoiced_no'|trans }} | |||||
| </label> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div>{# /.report-filter__col #} | |||||
| </div>{# /.report-filter__body #} | |||||
| <div class="report-filter__footer"> | |||||
| <button type="button" class="btn btn-primary" id="btn-filter-apply">{{ 'app.report.btn_filter'|trans }}</button> | |||||
| <button type="button" class="filter-footer__link" id="btn-filter-hide">{{ 'app.report.btn_hide'|trans }}</button> | |||||
| {% if filterActive %} | |||||
| <a href="{{ path('report_times', {limit: limit}) }}" class="filter-footer__link">{{ 'app.report.btn_show_all'|trans }}</a> | |||||
| {% endif %} | |||||
| </div> | |||||
| </div>{# /.report-filter #} | |||||
| @@ -17,6 +17,13 @@ | |||||
| trackingInterval: {{ trackingInterval }}, | trackingInterval: {{ trackingInterval }}, | ||||
| currentUserId: {{ currentUserId }}, | currentUserId: {{ currentUserId }}, | ||||
| isAdmin: {{ isAdmin ? 'true' : 'false' }}, | 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: [ | projects: [ | ||||
| {% for project in projects %} | {% for project in projects %} | ||||
| { | { | ||||
| @@ -33,6 +40,7 @@ | |||||
| billable: {{ service.billable ? 'true' : 'false' }} }{% if not loop.last %},{% endif %} | billable: {{ service.billable ? 'true' : 'false' }} }{% if not loop.last %},{% endif %} | ||||
| {% endfor %} | {% endfor %} | ||||
| ], | ], | ||||
| users: {{ userList|json_encode|raw }}, | |||||
| i18n: { | i18n: { | ||||
| btnSave: {{ 'app.entry.btn_save'|trans|json_encode|raw }}, | btnSave: {{ 'app.entry.btn_save'|trans|json_encode|raw }}, | ||||
| btnCancel: {{ 'app.entry.btn_cancel'|trans|json_encode|raw }}, | btnCancel: {{ 'app.entry.btn_cancel'|trans|json_encode|raw }}, | ||||
| @@ -85,13 +93,15 @@ | |||||
| {# ── Toolbar ──────────────────────────────────────────────────────── #} | {# ── Toolbar ──────────────────────────────────────────────────────── #} | ||||
| <div class="report-toolbar"> | <div class="report-toolbar"> | ||||
| <div class="report-toolbar__left"> | <div class="report-toolbar__left"> | ||||
| <span class="report-toolbar__action report-toolbar__action--disabled"> | |||||
| <button class="report-toolbar__action{% if filterActive %} report-toolbar__action--active{% endif %}" | |||||
| id="btn-filter-toggle" | |||||
| type="button"> | |||||
| <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> | <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> | ||||
| <circle cx="6.5" cy="6.5" r="4" stroke="currentColor" stroke-width="1.3"/> | <circle cx="6.5" cy="6.5" r="4" stroke="currentColor" stroke-width="1.3"/> | ||||
| <path d="M11 11l2.5 2.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/> | <path d="M11 11l2.5 2.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/> | ||||
| </svg> | </svg> | ||||
| {{ 'app.report.toolbar_filter'|trans }} | {{ 'app.report.toolbar_filter'|trans }} | ||||
| </span> | |||||
| </button> | |||||
| <span class="report-toolbar__action report-toolbar__action--disabled"> | <span class="report-toolbar__action report-toolbar__action--disabled"> | ||||
| <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> | <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> | ||||
| <path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/> | <path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/> | ||||
| @@ -101,6 +111,9 @@ | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| {# ── Filter-Panel ─────────────────────────────────────────────────── #} | |||||
| {% include 'report/_filter-panel.html.twig' %} | |||||
| {# ── Tabellen-Header ───────────────────────────────────────────────── #} | {# ── Tabellen-Header ───────────────────────────────────────────────── #} | ||||
| <div class="report-table"> | <div class="report-table"> | ||||
| <div class="report-table__head"> | <div class="report-table__head"> | ||||
| @@ -84,6 +84,30 @@ app: | |||||
| btn_lock: "Als abgerechnet markieren" | btn_lock: "Als abgerechnet markieren" | ||||
| btn_unlock: "Abrechnung aufheben" | btn_unlock: "Abrechnung aufheben" | ||||
| invoiced_title: "Abgerechnet – Bearbeiten nicht möglich" | 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: | forgot_password: | ||||
| page_title: "Passwort vergessen – spawntree" | page_title: "Passwort vergessen – spawntree" | ||||