From b477d4415e48af2660f7c886b3f3c89e4074f40d Mon Sep 17 00:00:00 2001 From: FlorianEisenmenger Date: Wed, 17 Jun 2026 14:34:48 +0200 Subject: [PATCH] fixes --- .claude/settings.local.json | 3 +- CLAUDE.md | 42 +++- httpdocs/assets/scripts/stopwatch.js | 187 +++++++++++------- .../assets/styles/components/_main-nav.scss | 19 ++ .../assets/styles/components/_stopwatch.scss | 97 +++++++-- httpdocs/assets/styles/themes/_minimal.scss | 3 +- .../templates/_atoms/icon-logout.html.twig | 5 + httpdocs/templates/_atoms/icon-team.html.twig | 9 + httpdocs/templates/_atoms/icon-user.html.twig | 5 + httpdocs/templates/_sections/nav.html.twig | 41 ++-- 10 files changed, 303 insertions(+), 108 deletions(-) create mode 100644 httpdocs/templates/_atoms/icon-logout.html.twig create mode 100644 httpdocs/templates/_atoms/icon-team.html.twig create mode 100644 httpdocs/templates/_atoms/icon-user.html.twig diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 419d660..fb024fe 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,8 @@ "Bash(php -l src/Controller/TeamController.php)", "Bash(php -l src/Controller/AccountController.php)", "Bash(php -l src/Controller/ClientController.php)", - "Bash(php *)" + "Bash(php *)", + "Bash(echo \"exit: $?\")" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 525afda..332499f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ httpdocs/ ├── assets/ │ ├── app.js # Webpack-Entry für Timetracking │ ├── styles/ # SCSS (main.scss als Entry) -│ └── scripts/ # JS-Module (calendar, entries, crud, team, account, report) +│ └── scripts/ # JS-Module (calendar, entries, crud, stopwatch, team, account, report) ├── migrations/ │ ├── central/ # Doctrine-Migrations für Central-DB │ └── tenant/ # Doctrine-Migrations für Tenant-DB @@ -100,6 +100,7 @@ bash httpdocs/deploy.sh - **Durations**: Integer (Minuten) in der DB, Eingabeformate: `1:30`, `8 12` (von-bis), `1,75` (Dezimal) - **Rounding**: Konfigurierbar per `Account.trackingInterval` (1/15/30/60 Min) - **API-Pattern**: `/api/...` Routen, JSON Request/Response, kein CSRF auf API-Endpunkten +- **Timer/Stoppuhr**: `TimeEntry.timerStartedAt` (nullable `DateTimeImmutable`) markiert laufende Timer. Pro User nur ein aktiver Timer gleichzeitig. Elapsed wird beim Stoppen auf `trackingInterval` gerundet und zu `duration` addiert. - **Rollen**: `admin` (alles), `member` (eigene + fremde Einträge sehen), `tracker` (nur eigene) - **CSS Custom Properties**: Brand-Farben via `:root`-Variablen (`--color-primary`, etc.) @@ -109,7 +110,7 @@ Alle UI-Texte leben zentral in `translations/messages.de.yaml`. Nirgends dürfen - **Twig**: `{{ 'app.section.key'|trans }}` bzw. `{{ 'app.key'|trans({'%placeholder%': value}) }}` - **PHP Controller/Services**: `TranslatorInterface` injizieren, `$this->translator->trans('app.key')` nutzen -- **JS**: Strings werden im Twig-Template als `window.XX.i18n`-Objekt übergeben (z.B. `window.CRUD.i18n`, `window.ACCOUNT.i18n`). JS-Module nutzen `createTranslator('XX')` aus `utils.js` zum Zugriff: `const t = createTranslator('CRUD'); t('confirmDelete')` +- **JS**: Strings werden im Twig-Template als `window.XX.i18n`-Objekt übergeben (z.B. `window.CRUD.i18n`, `window.ACCOUNT.i18n`, `window.STOPWATCH.i18n`). JS-Module nutzen `createTranslator('XX')` aus `utils.js` zum Zugriff: `const t = createTranslator('CRUD'); t('confirmDelete')` - **Schlüsselstruktur**: `app.{section}.{key}` (z.B. `app.team.btn_new`, `app.error.access_denied`, `app.validation.email_required`) ### Kein Inline-CSS @@ -134,6 +135,12 @@ Gemeinsame Hilfsfunktionen in `assets/scripts/utils.js`: - `removeWithAnimation(el, className)` / `animateIn(el, className)` — Animierte DOM-Operationen - Konstanten: `ANIMATION_MS`, `FADE_MS`, `MINUTES_PER_DAY` +### JS: stopwatch.js + +Stoppuhr-Modul mit zwei Klassen: +- `SearchableSelect` — Wiederverwendbares durchsuchbares Dropdown mit Gruppen, Keyboard-Navigation und Filter +- `StopwatchManager` — Timer-Steuerung: Start/Stop/Resume, Tick-Display (Sekunden-Auflösung), DOM-Integration mit `window.entryManager`, Tab-Title-Update + ### Globale `[hidden]`-Regel `main.scss` enthält `[hidden] { display: none !important; }`. Kein `&[hidden] { display: none !important; }` in einzelnen Komponenten nötig. @@ -170,6 +177,37 @@ Controller die Tenant-Entities nutzen brauchen den `tenant_entity_manager` expli Alle drei nutzen die gleichen Filter-Parameter wie die Report-Seite, exportieren ohne Limit. `ReportExportService` bereitet Daten zentral in `prepareData()` auf, formatspezifische Methoden erzeugen die Ausgabe. Tracker sehen nur eigene Einträge. +## Stoppuhr / Timer + +Live-Timer zum Tracken von Zeiteinträgen. UI in der Navigation (Desktop + Hamburger-Menü), zusätzlich Play-Button an jeder Entry-Row zum Fortsetzen. + +### Architektur + +- **Backend**: Timer-API im `TimeTrackingController` (`/api/timer/*`) +- **Frontend**: `assets/scripts/stopwatch.js` (importiert in `app.js`), enthält `SearchableSelect`-Klasse (durchsuchbare Dropdowns) und `StopwatchManager` +- **Styles**: `assets/styles/components/_stopwatch.scss` +- **Template**: Popover in `_sections/nav.html.twig`, Play-Button in `timetracking/_entry_row.html.twig` +- **Icon**: `_atoms/icon-stopwatch.html.twig` + +### Timer-API Endpunkte + +| Route | Method | Beschreibung | +|---------------------------|--------|--------------------------------------------| +| `/api/timer/status` | GET | Prüft ob ein Timer läuft | +| `/api/timer/options` | GET | Projekte + Services für Select-Dropdowns | +| `/api/timer/start` | POST | Neuen Timer starten (erstellt TimeEntry) | +| `/api/timer/start/{id}` | POST | Bestehenden Eintrag fortsetzen (Resume) | +| `/api/timer/stop` | POST | Laufenden Timer stoppen + Duration addieren| + +### Timer-Logik + +- `TimeEntry.timerStartedAt` wird beim Start gesetzt, beim Stopp auf `null` +- Beim Stopp: elapsed = `now - timerStartedAt`, gerundet auf `Account.trackingInterval`, addiert zu `duration` +- Maximal 1440 Min/Tag (Overflow-Schutz) +- Conflict (409): wenn bereits ein Timer läuft, User wird gefragt ob der alte gestoppt werden soll +- LocalStorage (`tt_timer_state`): persistiert Timer-State über Page-Reloads, wird mit Server-State abgeglichen +- LocalStorage (`tt_last_project_id`, `tt_last_service_id`): merkt letzte Auswahl für Quick-Start + ## TenantConnectionMiddleware Registriert via Service-Tag in `services.yaml` (nicht via `doctrine.yaml` — DoctrineBundle 3.x unterstützt `middlewares`-Config-Key nicht): diff --git a/httpdocs/assets/scripts/stopwatch.js b/httpdocs/assets/scripts/stopwatch.js index fe2bf25..27e2e83 100644 --- a/httpdocs/assets/scripts/stopwatch.js +++ b/httpdocs/assets/scripts/stopwatch.js @@ -181,20 +181,21 @@ class SearchableSelect { class StopwatchManager { constructor() { - this.toggle = document.getElementById('stopwatch-toggle'); - this.popover = document.getElementById('stopwatch-popover'); - this.display = document.getElementById('stopwatch-display'); - this.startBtn = document.getElementById('stopwatch-start'); - this.noteField = document.getElementById('stopwatch-note'); - this.hamburgerBtn = document.getElementById('hamburger-stopwatch'); - this.headerTime = document.getElementById('stopwatch-header-time'); - - this.projectSelect = null; - this.serviceSelect = null; - const projEl = document.getElementById('stopwatch-project'); - const svcEl = document.getElementById('stopwatch-service'); - if (projEl) this.projectSelect = new SearchableSelect(projEl); - if (svcEl) this.serviceSelect = new SearchableSelect(svcEl); + this.contexts = []; + + const desktopCtx = this.buildContext( + 'stopwatch-toggle', 'stopwatch-popover', 'stopwatch-display', + 'stopwatch-start', 'stopwatch-note', 'stopwatch-header-time', + 'stopwatch-project', 'stopwatch-service' + ); + if (desktopCtx) this.contexts.push(desktopCtx); + + const hamburgerCtx = this.buildContext( + 'hamburger-stopwatch', 'hamburger-stopwatch-popover', 'hamburger-stopwatch-display', + 'hamburger-sw-start', 'hamburger-sw-note', 'hamburger-stopwatch-time', + 'hamburger-sw-project', 'hamburger-sw-service' + ); + if (hamburgerCtx) this.contexts.push(hamburgerCtx); this.running = false; this.entryId = null; @@ -205,94 +206,128 @@ class StopwatchManager { this.originalTitle = document.title; this.cachedOptions = null; this.busy = false; + this.activePopoverCtx = null; - if (!this.toggle) return; + if (!this.contexts.length) return; this.init(); } - async init() { - this.toggle.addEventListener('click', (e) => { - e.stopPropagation(); - this.handleToggleClick(); - }); + buildContext(toggleId, popoverId, displayId, startBtnId, noteId, timeId, projectId, serviceId) { + const toggle = document.getElementById(toggleId); + if (!toggle) return null; - this.startBtn?.addEventListener('click', () => this.startNew()); + const ctx = { + toggle, + popover: document.getElementById(popoverId), + display: document.getElementById(displayId), + startBtn: document.getElementById(startBtnId), + noteField: document.getElementById(noteId), + headerTime: document.getElementById(timeId), + projectSelect: null, + serviceSelect: null, + runningClass: toggleId === 'hamburger-stopwatch' + ? 'hamburger-nav__stopwatch--running' + : 'main-nav__stopwatch--running', + }; + + const projEl = document.getElementById(projectId); + const svcEl = document.getElementById(serviceId); + if (projEl) ctx.projectSelect = new SearchableSelect(projEl); + if (svcEl) ctx.serviceSelect = new SearchableSelect(svcEl); + + return ctx; + } + + async init() { + for (const ctx of this.contexts) { + ctx.toggle.addEventListener('click', (e) => { + e.stopPropagation(); + this.handleToggleClick(ctx); + }); + ctx.startBtn?.addEventListener('click', () => this.startNew(ctx)); + } document.addEventListener('click', (e) => { - if (this.popover && !this.popover.hidden - && !this.popover.contains(e.target) - && !this.toggle.contains(e.target)) { - this.closePopover(); + if (!this.activePopoverCtx) return; + const ctx = this.activePopoverCtx; + if (ctx.popover && !ctx.popover.hidden + && !ctx.popover.contains(e.target) + && !ctx.toggle.contains(e.target)) { + this.closePopover(ctx); } }); - this.hamburgerBtn?.addEventListener('click', () => this.handleToggleClick()); - this.loadFromLocalStorage(); await this.loadStatus(); } // ── Toggle ────────────────────────────────────────────────────────────────── - handleToggleClick() { + handleToggleClick(ctx) { if (this.running) { this.stop(); } else { - this.togglePopover(); + this.togglePopover(ctx); } } - async togglePopover() { - if (!this.popover) return; + async togglePopover(ctx) { + if (!ctx.popover) return; - if (!this.popover.hidden) { - this.closePopover(); + if (!ctx.popover.hidden) { + this.closePopover(ctx); return; } + if (this.activePopoverCtx && this.activePopoverCtx !== ctx) { + this.closePopover(this.activePopoverCtx); + } + await this.loadOptions(); - this.populateSelects(); - this.popover.hidden = false; - this.toggle.setAttribute('aria-expanded', 'true'); - this.projectSelect?.focus(); + this.populateSelects(ctx); + ctx.popover.hidden = false; + ctx.toggle.setAttribute('aria-expanded', 'true'); + this.activePopoverCtx = ctx; + ctx.projectSelect?.focus(); } - closePopover() { - if (!this.popover) return; - this.popover.hidden = true; - this.toggle.setAttribute('aria-expanded', 'false'); - this.projectSelect?.close(); - this.serviceSelect?.close(); + closePopover(ctx) { + if (!ctx?.popover) return; + ctx.popover.hidden = true; + ctx.toggle.setAttribute('aria-expanded', 'false'); + ctx.projectSelect?.close(); + ctx.serviceSelect?.close(); + if (this.activePopoverCtx === ctx) this.activePopoverCtx = null; } // ── Select-Builder ────────────────────────────────────────────────────────── - populateSelects() { + populateSelects(ctx) { if (!this.cachedOptions) return; const lastProject = localStorage.getItem(LAST_PROJECT_KEY); const lastService = localStorage.getItem(LAST_SERVICE_KEY); - if (this.projectSelect) { + if (ctx.projectSelect) { const groups = {}; (this.cachedOptions.projects ?? []).forEach(p => { if (!groups[p.clientName]) groups[p.clientName] = []; groups[p.clientName].push(p); }); - this.projectSelect.setGroups( + ctx.projectSelect.setGroups( Object.entries(groups).map(([label, items]) => ({ label, items })) ); - if (lastProject) this.projectSelect.setValue(lastProject); + if (lastProject) ctx.projectSelect.setValue(lastProject); } - if (this.serviceSelect) { + if (ctx.serviceSelect) { const billable = (this.cachedOptions.services ?? []).filter(s => s.billable); const notBillable = (this.cachedOptions.services ?? []).filter(s => !s.billable); const groups = []; if (billable.length) groups.push({ label: t('billable'), items: billable }); if (notBillable.length) groups.push({ label: t('notBillable'), items: notBillable }); - this.serviceSelect.setGroups(groups); - if (lastService) this.serviceSelect.setValue(lastService); + ctx.serviceSelect.setGroups(groups); + if (lastService) ctx.serviceSelect.setValue(lastService); } } @@ -340,20 +375,20 @@ class StopwatchManager { } catch { /* silent */ } } - async startNew() { + async startNew(ctx) { if (this.busy) return; - const projectId = this.projectSelect?.getValue(); + const projectId = ctx.projectSelect?.getValue(); if (!projectId) { - this.projectSelect?.focus(); + ctx.projectSelect?.focus(); return; } this.busy = true; - this.startBtn.disabled = true; + if (ctx.startBtn) ctx.startBtn.disabled = true; try { - const serviceId = this.serviceSelect?.getValue(); + const serviceId = ctx.serviceSelect?.getValue(); const { ok, status, data } = await apiCall('/api/timer/start', { method: 'POST', @@ -361,7 +396,7 @@ class StopwatchManager { body: JSON.stringify({ projectId: parseInt(projectId, 10), serviceId: serviceId ? parseInt(serviceId, 10) : null, - note: this.noteField?.value || null, + note: ctx.noteField?.value || null, }), }); @@ -374,7 +409,7 @@ class StopwatchManager { await this.forceStop(); this.busy = false; - return this.startNew(); + return this.startNew(ctx); } if (!ok) { @@ -390,7 +425,7 @@ class StopwatchManager { this.saveToLocalStorage(); this.applyRunningState(); - this.closePopover(); + this.closePopover(ctx); if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId); if (serviceId) localStorage.setItem(LAST_SERVICE_KEY, serviceId); @@ -405,7 +440,7 @@ class StopwatchManager { alert(t('errorStart') + `\n${ex.message}`); } finally { this.busy = false; - if (this.startBtn) this.startBtn.disabled = false; + if (ctx.startBtn) ctx.startBtn.disabled = false; } } @@ -519,10 +554,12 @@ class StopwatchManager { this.tickInterval = null; } document.title = this.originalTitle; - if (this.display) this.display.textContent = '0:00'; - if (this.headerTime) { - this.headerTime.textContent = ''; - this.headerTime.hidden = true; + for (const ctx of this.contexts) { + if (ctx.display) ctx.display.textContent = '0:00'; + if (ctx.headerTime) { + ctx.headerTime.textContent = ''; + ctx.headerTime.hidden = true; + } } } @@ -542,11 +579,13 @@ class StopwatchManager { const short = `${h}:${String(m).padStart(2, '0')}`; document.title = `${short} - ${this.originalTitle}`; - if (this.display) this.display.textContent = long; - if (this.headerTime) { - this.headerTime.textContent = short; - this.headerTime.hidden = false; + for (const ctx of this.contexts) { + if (ctx.display) ctx.display.textContent = long; + if (ctx.headerTime) { + ctx.headerTime.textContent = short; + ctx.headerTime.hidden = false; + } } if (this.entryId) { @@ -558,17 +597,19 @@ class StopwatchManager { // ── Visual State ──────────────────────────────────────────────────────────── applyRunningState() { - this.toggle?.classList.add('main-nav__stopwatch--running'); - this.hamburgerBtn?.classList.add('hamburger-nav__stopwatch--running'); - if (this.toggle && this.entryLabel) this.toggle.title = this.entryLabel; + for (const ctx of this.contexts) { + ctx.toggle.classList.add(ctx.runningClass); + if (this.entryLabel) ctx.toggle.title = this.entryLabel; + } this.startTicking(); this.markActiveEntryRow(); } applyStoppedState() { - this.toggle?.classList.remove('main-nav__stopwatch--running'); - this.hamburgerBtn?.classList.remove('hamburger-nav__stopwatch--running'); - if (this.toggle) this.toggle.title = t('title'); + for (const ctx of this.contexts) { + ctx.toggle.classList.remove(ctx.runningClass); + ctx.toggle.title = t('title'); + } this.stopTicking(); this.entryId = null; this.startedAt = null; diff --git a/httpdocs/assets/styles/components/_main-nav.scss b/httpdocs/assets/styles/components/_main-nav.scss index c46dbfa..6887756 100644 --- a/httpdocs/assets/styles/components/_main-nav.scss +++ b/httpdocs/assets/styles/components/_main-nav.scss @@ -47,4 +47,23 @@ pointer-events: none; cursor: default; } + + &--icon { + gap: $space-1; + + svg { width: 16px; height: 16px; flex-shrink: 0; } + } +} + +.main-nav__logout { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 $space-3; + color: rgba($color-white, 0.45); + transition: color $transition-fast; + + svg { width: 18px; height: 18px; } + + &:hover { color: $color-white; } } diff --git a/httpdocs/assets/styles/components/_stopwatch.scss b/httpdocs/assets/styles/components/_stopwatch.scss index 0f81ad8..26a8535 100644 --- a/httpdocs/assets/styles/components/_stopwatch.scss +++ b/httpdocs/assets/styles/components/_stopwatch.scss @@ -13,29 +13,37 @@ @include icon-btn(auto, $radius-pill); color: rgba($color-white, 0.65); margin: 0 $space-1; - position: relative; gap: $space-1; padding: 0 $space-1; height: 32px; min-width: 32px; - svg { width: 16px; height: 16px; position: relative; z-index: 1; flex-shrink: 0; } + svg { width: 16px; height: 16px; } &:hover { color: $color-white; background: var(--header-overlay); } &--running { color: $color-success; - padding: 0 $space-2 0 $space-1; + gap: $space-2; &:hover { color: $color-success; } + } +} + +.main-nav__stopwatch-icon { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + flex-shrink: 0; + .main-nav__stopwatch--running & { &::before { content: ''; position: absolute; - top: 1px; - left: 1px; - width: 28px; - height: 28px; + inset: 0; border-radius: 50%; border: 2px solid transparent; border-top-color: $color-success; @@ -46,10 +54,7 @@ &::after { content: ''; position: absolute; - top: 1px; - left: 1px; - width: 28px; - height: 28px; + inset: 0; border-radius: 50%; border: 2px solid rgba($color-success, 0.2); } @@ -259,25 +264,77 @@ } // ─── Hamburger-Nav Stopwatch ───────────────────────────────────────────────── +.hamburger-nav__stopwatch-wrap { + position: relative; + display: flex; + align-items: center; +} + .hamburger-nav__stopwatch { display: flex; align-items: center; gap: $space-2; - padding: $space-2 $space-4; - color: $color-text-base; background: none; border: none; cursor: pointer; - font-size: $font-size-sm; - width: 100%; - text-align: left; - - svg { width: 14px; height: 14px; flex-shrink: 0; } + padding: $space-2; + color: $color-text-muted; + border-radius: $radius-lg; + transition: color $transition-fast, background $transition-fast; - &:hover { background: rgba(0, 0, 0, 0.04); } + &:hover { color: $color-text-dark; background: var(--color-bg); } &--running { color: $color-success; - font-weight: $font-weight-medium; + + &:hover { color: $color-success; } } } + +.hamburger-nav__stopwatch-icon { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + flex-shrink: 0; + + svg { width: 18px; height: 18px; } + + .hamburger-nav__stopwatch--running & { + &::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + border: 2px solid transparent; + border-top-color: $color-success; + border-right-color: $color-success; + animation: stopwatch-spin 1s linear infinite; + } + + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + border: 2px solid rgba($color-success, 0.2); + } + } +} + +.hamburger-nav__stopwatch-time { + font-size: $font-size-sm; + font-weight: $font-weight-bold; + font-variant-numeric: tabular-nums; + color: $color-success; + white-space: nowrap; +} + +.stopwatch-popover--hamburger { + left: 0; + transform: none; + + &::before { left: 20px; transform: rotate(45deg); } +} diff --git a/httpdocs/assets/styles/themes/_minimal.scss b/httpdocs/assets/styles/themes/_minimal.scss index 782251a..26cf182 100644 --- a/httpdocs/assets/styles/themes/_minimal.scss +++ b/httpdocs/assets/styles/themes/_minimal.scss @@ -14,7 +14,8 @@ body[data-theme="minimal"] { // ── Hamburger-Nav einblenden ──────────────────────────────────────────────── .hamburger-nav { display: flex; - justify-content: flex-end; + align-items: center; + justify-content: space-between; padding: $space-4 $space-4 $space-5; } diff --git a/httpdocs/templates/_atoms/icon-logout.html.twig b/httpdocs/templates/_atoms/icon-logout.html.twig new file mode 100644 index 0000000..0c554b5 --- /dev/null +++ b/httpdocs/templates/_atoms/icon-logout.html.twig @@ -0,0 +1,5 @@ +{# templates/_atoms/icon-logout.html.twig #} + + + + diff --git a/httpdocs/templates/_atoms/icon-team.html.twig b/httpdocs/templates/_atoms/icon-team.html.twig new file mode 100644 index 0000000..27c2184 --- /dev/null +++ b/httpdocs/templates/_atoms/icon-team.html.twig @@ -0,0 +1,9 @@ +{# templates/_atoms/icon-team.html.twig #} + + + + + + + + diff --git a/httpdocs/templates/_atoms/icon-user.html.twig b/httpdocs/templates/_atoms/icon-user.html.twig new file mode 100644 index 0000000..0b9839d --- /dev/null +++ b/httpdocs/templates/_atoms/icon-user.html.twig @@ -0,0 +1,5 @@ +{# templates/_atoms/icon-user.html.twig #} + + + + diff --git a/httpdocs/templates/_sections/nav.html.twig b/httpdocs/templates/_sections/nav.html.twig index 261acce..c3d9ea1 100644 --- a/httpdocs/templates/_sections/nav.html.twig +++ b/httpdocs/templates/_sections/nav.html.twig @@ -12,7 +12,7 @@ {# Hamburger-Navigation — nur im Minimal-Theme sichtbar (via CSS) #}
+ {% if app.user %} +
+ + +
+ {% endif %} @@ -77,12 +102,6 @@ class="hamburger-nav__item{% if currentRoute starts with 'timetracking' %} hamburger-nav__item--active{% endif %}"> {{ 'app.nav.time_tracking'|trans }} - {% if app.user %} - - {% endif %} {{ 'app.nav.reports'|trans }}