| @@ -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: $?\")" | |||
| ] | |||
| } | |||
| } | |||
| @@ -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): | |||
| @@ -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; | |||
| @@ -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; } | |||
| } | |||
| @@ -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); } | |||
| } | |||
| @@ -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; | |||
| } | |||
| @@ -0,0 +1,5 @@ | |||
| {# templates/_atoms/icon-logout.html.twig #} | |||
| <svg viewBox="0 0 16 16" fill="none"> | |||
| <path d="M6 2H3.5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1H6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/> | |||
| <path d="M10.5 11l3-3-3-3M5.5 8h8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/> | |||
| </svg> | |||
| @@ -0,0 +1,9 @@ | |||
| {# templates/_atoms/icon-team.html.twig #} | |||
| <svg viewBox="0 0 16 16" fill="none"> | |||
| <circle cx="8" cy="4" r="2" stroke="currentColor" stroke-width="1.3"/> | |||
| <path d="M4.5 13c0-2.2 1.6-3.5 3.5-3.5s3.5 1.3 3.5 3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/> | |||
| <circle cx="3" cy="5.5" r="1.5" stroke="currentColor" stroke-width="1.1"/> | |||
| <path d="M1 12c0-1.7 1-2.7 2-2.7" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/> | |||
| <circle cx="13" cy="5.5" r="1.5" stroke="currentColor" stroke-width="1.1"/> | |||
| <path d="M15 12c0-1.7-1-2.7-2-2.7" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/> | |||
| </svg> | |||
| @@ -0,0 +1,5 @@ | |||
| {# templates/_atoms/icon-user.html.twig #} | |||
| <svg viewBox="0 0 16 16" fill="none"> | |||
| <circle cx="8" cy="5" r="2.5" stroke="currentColor" stroke-width="1.3"/> | |||
| <path d="M3.5 14c0-2.8 2-4.5 4.5-4.5s4.5 1.7 4.5 4.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/> | |||
| </svg> | |||
| @@ -12,7 +12,7 @@ | |||
| <button class="main-nav__stopwatch" id="stopwatch-toggle" | |||
| title="{{ 'app.stopwatch.title'|trans }}" | |||
| aria-expanded="false"> | |||
| {% include '_atoms/icon-stopwatch.html.twig' %} | |||
| <span class="main-nav__stopwatch-icon">{% include '_atoms/icon-stopwatch.html.twig' %}</span> | |||
| <span class="main-nav__stopwatch-time" id="stopwatch-header-time" hidden></span> | |||
| </button> | |||
| <div class="stopwatch-popover" id="stopwatch-popover" hidden> | |||
| @@ -53,22 +53,47 @@ | |||
| {% endif %} | |||
| {% if isCurrentUserAdmin() %} | |||
| <a href="{{ path('team_index') }}" | |||
| class="main-nav__item{% if currentRoute starts with 'team' %} main-nav__item--active{% endif %}"> | |||
| class="main-nav__item main-nav__item--icon{% if currentRoute starts with 'team' %} main-nav__item--active{% endif %}"> | |||
| {% include '_atoms/icon-team.html.twig' %} | |||
| {{ 'app.nav.team'|trans }} | |||
| </a> | |||
| {% endif %} | |||
| <a href="{{ path('account_index') }}" | |||
| class="main-nav__item{% if currentRoute starts with 'account' %} main-nav__item--active{% endif %}"> | |||
| class="main-nav__item main-nav__item--icon{% if currentRoute starts with 'account' %} main-nav__item--active{% endif %}"> | |||
| {% include '_atoms/icon-user.html.twig' %} | |||
| {{ 'app.nav.account'|trans }} | |||
| </a> | |||
| <a href="{{ path('app_logout') }}" class="main-nav__item"> | |||
| {{ 'app.nav.logout'|trans }} | |||
| <a href="{{ path('app_logout') }}" class="main-nav__logout" title="{{ 'app.nav.logout'|trans }}"> | |||
| {% include '_atoms/icon-logout.html.twig' %} | |||
| </a> | |||
| </div> | |||
| </nav> | |||
| {# Hamburger-Navigation — nur im Minimal-Theme sichtbar (via CSS) #} | |||
| <div class="hamburger-nav" id="hamburger-nav"> | |||
| {% if app.user %} | |||
| <div class="hamburger-nav__stopwatch-wrap"> | |||
| <button class="hamburger-nav__stopwatch" id="hamburger-stopwatch" | |||
| title="{{ 'app.stopwatch.title'|trans }}"> | |||
| <span class="hamburger-nav__stopwatch-icon">{% include '_atoms/icon-stopwatch.html.twig' %}</span> | |||
| <span class="hamburger-nav__stopwatch-time" id="hamburger-stopwatch-time" hidden></span> | |||
| </button> | |||
| <div class="stopwatch-popover stopwatch-popover--hamburger" id="hamburger-stopwatch-popover" hidden> | |||
| <div class="stopwatch-popover__timer" id="hamburger-stopwatch-display">0:00</div> | |||
| <div class="stopwatch-popover__form"> | |||
| <div id="hamburger-sw-project" class="searchable-select" data-placeholder="{{ 'app.stopwatch.select_project'|trans }}"></div> | |||
| <div id="hamburger-sw-service" class="searchable-select" data-placeholder="{{ 'app.stopwatch.select_service'|trans }}"></div> | |||
| <textarea id="hamburger-sw-note" class="textarea" rows="2" | |||
| placeholder="{{ 'app.entry.placeholder_note'|trans }}"></textarea> | |||
| <div class="stopwatch-popover__actions"> | |||
| <button type="button" class="btn btn-primary" id="hamburger-sw-start"> | |||
| {{ 'app.stopwatch.btn_start'|trans }} | |||
| </button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| {% endif %} | |||
| <button class="hamburger-nav__toggle" id="hamburger-toggle" aria-label="{{ 'app.nav.menu_open'|trans }}" aria-expanded="false"> | |||
| <span class="hamburger-nav__icon"></span> | |||
| </button> | |||
| @@ -77,12 +102,6 @@ | |||
| class="hamburger-nav__item{% if currentRoute starts with 'timetracking' %} hamburger-nav__item--active{% endif %}"> | |||
| {{ 'app.nav.time_tracking'|trans }} | |||
| </a> | |||
| {% if app.user %} | |||
| <button class="hamburger-nav__stopwatch" id="hamburger-stopwatch"> | |||
| {% include '_atoms/icon-stopwatch.html.twig' %} | |||
| <span>{{ 'app.stopwatch.title'|trans }}</span> | |||
| </button> | |||
| {% endif %} | |||
| <a href="{{ path('report_times') }}" | |||
| class="hamburger-nav__item{% if currentRoute starts with 'report' %} hamburger-nav__item--active{% endif %}"> | |||
| {{ 'app.nav.reports'|trans }} | |||