| @@ -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 { | |||
| // 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; | |||
| 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'])] | |||
| @@ -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); | |||
| } | |||
| } | |||
| @@ -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 }}, | |||
| 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 ──────────────────────────────────────────────────────── #} | |||
| <div class="report-toolbar"> | |||
| <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"> | |||
| <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"/> | |||
| </svg> | |||
| {{ 'app.report.toolbar_filter'|trans }} | |||
| </span> | |||
| </button> | |||
| <span class="report-toolbar__action report-toolbar__action--disabled"> | |||
| <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"/> | |||
| @@ -101,6 +111,9 @@ | |||
| </div> | |||
| </div> | |||
| {# ── Filter-Panel ─────────────────────────────────────────────────── #} | |||
| {% include 'report/_filter-panel.html.twig' %} | |||
| {# ── Tabellen-Header ───────────────────────────────────────────────── #} | |||
| <div class="report-table"> | |||
| <div class="report-table__head"> | |||
| @@ -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" | |||