| @@ -12,7 +12,8 @@ | |||||
| "Bash(php -l src/Controller/TeamController.php)", | "Bash(php -l src/Controller/TeamController.php)", | ||||
| "Bash(php -l src/Controller/AccountController.php)", | "Bash(php -l src/Controller/AccountController.php)", | ||||
| "Bash(php -l src/Controller/ClientController.php)", | "Bash(php -l src/Controller/ClientController.php)", | ||||
| "Bash(php *)" | |||||
| "Bash(php *)", | |||||
| "Bash(echo \"exit: $?\")" | |||||
| ] | ] | ||||
| } | } | ||||
| } | } | ||||
| @@ -42,7 +42,7 @@ httpdocs/ | |||||
| ├── assets/ | ├── assets/ | ||||
| │ ├── app.js # Webpack-Entry für Timetracking | │ ├── app.js # Webpack-Entry für Timetracking | ||||
| │ ├── styles/ # SCSS (main.scss als Entry) | │ ├── 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/ | ├── migrations/ | ||||
| │ ├── central/ # Doctrine-Migrations für Central-DB | │ ├── central/ # Doctrine-Migrations für Central-DB | ||||
| │ └── tenant/ # Doctrine-Migrations für Tenant-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) | - **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) | - **Rounding**: Konfigurierbar per `Account.trackingInterval` (1/15/30/60 Min) | ||||
| - **API-Pattern**: `/api/...` Routen, JSON Request/Response, kein CSRF auf API-Endpunkten | - **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) | - **Rollen**: `admin` (alles), `member` (eigene + fremde Einträge sehen), `tracker` (nur eigene) | ||||
| - **CSS Custom Properties**: Brand-Farben via `:root`-Variablen (`--color-primary`, etc.) | - **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}) }}` | - **Twig**: `{{ 'app.section.key'|trans }}` bzw. `{{ 'app.key'|trans({'%placeholder%': value}) }}` | ||||
| - **PHP Controller/Services**: `TranslatorInterface` injizieren, `$this->translator->trans('app.key')` nutzen | - **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`) | - **Schlüsselstruktur**: `app.{section}.{key}` (z.B. `app.team.btn_new`, `app.error.access_denied`, `app.validation.email_required`) | ||||
| ### Kein Inline-CSS | ### Kein Inline-CSS | ||||
| @@ -134,6 +135,12 @@ Gemeinsame Hilfsfunktionen in `assets/scripts/utils.js`: | |||||
| - `removeWithAnimation(el, className)` / `animateIn(el, className)` — Animierte DOM-Operationen | - `removeWithAnimation(el, className)` / `animateIn(el, className)` — Animierte DOM-Operationen | ||||
| - Konstanten: `ANIMATION_MS`, `FADE_MS`, `MINUTES_PER_DAY` | - 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 | ### Globale `[hidden]`-Regel | ||||
| `main.scss` enthält `[hidden] { display: none !important; }`. Kein `&[hidden] { display: none !important; }` in einzelnen Komponenten nötig. | `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. | 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 | ## TenantConnectionMiddleware | ||||
| Registriert via Service-Tag in `services.yaml` (nicht via `doctrine.yaml` — DoctrineBundle 3.x unterstützt `middlewares`-Config-Key nicht): | 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 { | class StopwatchManager { | ||||
| constructor() { | 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.running = false; | ||||
| this.entryId = null; | this.entryId = null; | ||||
| @@ -205,94 +206,128 @@ class StopwatchManager { | |||||
| this.originalTitle = document.title; | this.originalTitle = document.title; | ||||
| this.cachedOptions = null; | this.cachedOptions = null; | ||||
| this.busy = false; | this.busy = false; | ||||
| this.activePopoverCtx = null; | |||||
| if (!this.toggle) return; | |||||
| if (!this.contexts.length) return; | |||||
| this.init(); | 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) => { | 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(); | this.loadFromLocalStorage(); | ||||
| await this.loadStatus(); | await this.loadStatus(); | ||||
| } | } | ||||
| // ── Toggle ────────────────────────────────────────────────────────────────── | // ── Toggle ────────────────────────────────────────────────────────────────── | ||||
| handleToggleClick() { | |||||
| handleToggleClick(ctx) { | |||||
| if (this.running) { | if (this.running) { | ||||
| this.stop(); | this.stop(); | ||||
| } else { | } 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; | return; | ||||
| } | } | ||||
| if (this.activePopoverCtx && this.activePopoverCtx !== ctx) { | |||||
| this.closePopover(this.activePopoverCtx); | |||||
| } | |||||
| await this.loadOptions(); | 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 ────────────────────────────────────────────────────────── | // ── Select-Builder ────────────────────────────────────────────────────────── | ||||
| populateSelects() { | |||||
| populateSelects(ctx) { | |||||
| if (!this.cachedOptions) return; | if (!this.cachedOptions) return; | ||||
| const lastProject = localStorage.getItem(LAST_PROJECT_KEY); | const lastProject = localStorage.getItem(LAST_PROJECT_KEY); | ||||
| const lastService = localStorage.getItem(LAST_SERVICE_KEY); | const lastService = localStorage.getItem(LAST_SERVICE_KEY); | ||||
| if (this.projectSelect) { | |||||
| if (ctx.projectSelect) { | |||||
| const groups = {}; | const groups = {}; | ||||
| (this.cachedOptions.projects ?? []).forEach(p => { | (this.cachedOptions.projects ?? []).forEach(p => { | ||||
| if (!groups[p.clientName]) groups[p.clientName] = []; | if (!groups[p.clientName]) groups[p.clientName] = []; | ||||
| groups[p.clientName].push(p); | groups[p.clientName].push(p); | ||||
| }); | }); | ||||
| this.projectSelect.setGroups( | |||||
| ctx.projectSelect.setGroups( | |||||
| Object.entries(groups).map(([label, items]) => ({ label, items })) | 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 billable = (this.cachedOptions.services ?? []).filter(s => s.billable); | ||||
| const notBillable = (this.cachedOptions.services ?? []).filter(s => !s.billable); | const notBillable = (this.cachedOptions.services ?? []).filter(s => !s.billable); | ||||
| const groups = []; | const groups = []; | ||||
| if (billable.length) groups.push({ label: t('billable'), items: billable }); | if (billable.length) groups.push({ label: t('billable'), items: billable }); | ||||
| if (notBillable.length) groups.push({ label: t('notBillable'), items: notBillable }); | 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 */ } | } catch { /* silent */ } | ||||
| } | } | ||||
| async startNew() { | |||||
| async startNew(ctx) { | |||||
| if (this.busy) return; | if (this.busy) return; | ||||
| const projectId = this.projectSelect?.getValue(); | |||||
| const projectId = ctx.projectSelect?.getValue(); | |||||
| if (!projectId) { | if (!projectId) { | ||||
| this.projectSelect?.focus(); | |||||
| ctx.projectSelect?.focus(); | |||||
| return; | return; | ||||
| } | } | ||||
| this.busy = true; | this.busy = true; | ||||
| this.startBtn.disabled = true; | |||||
| if (ctx.startBtn) ctx.startBtn.disabled = true; | |||||
| try { | try { | ||||
| const serviceId = this.serviceSelect?.getValue(); | |||||
| const serviceId = ctx.serviceSelect?.getValue(); | |||||
| const { ok, status, data } = await apiCall('/api/timer/start', { | const { ok, status, data } = await apiCall('/api/timer/start', { | ||||
| method: 'POST', | method: 'POST', | ||||
| @@ -361,7 +396,7 @@ class StopwatchManager { | |||||
| body: JSON.stringify({ | body: JSON.stringify({ | ||||
| projectId: parseInt(projectId, 10), | projectId: parseInt(projectId, 10), | ||||
| serviceId: serviceId ? parseInt(serviceId, 10) : null, | 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(); | await this.forceStop(); | ||||
| this.busy = false; | this.busy = false; | ||||
| return this.startNew(); | |||||
| return this.startNew(ctx); | |||||
| } | } | ||||
| if (!ok) { | if (!ok) { | ||||
| @@ -390,7 +425,7 @@ class StopwatchManager { | |||||
| this.saveToLocalStorage(); | this.saveToLocalStorage(); | ||||
| this.applyRunningState(); | this.applyRunningState(); | ||||
| this.closePopover(); | |||||
| this.closePopover(ctx); | |||||
| if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId); | if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId); | ||||
| if (serviceId) localStorage.setItem(LAST_SERVICE_KEY, serviceId); | if (serviceId) localStorage.setItem(LAST_SERVICE_KEY, serviceId); | ||||
| @@ -405,7 +440,7 @@ class StopwatchManager { | |||||
| alert(t('errorStart') + `\n${ex.message}`); | alert(t('errorStart') + `\n${ex.message}`); | ||||
| } finally { | } finally { | ||||
| this.busy = false; | 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; | this.tickInterval = null; | ||||
| } | } | ||||
| document.title = this.originalTitle; | 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')}`; | const short = `${h}:${String(m).padStart(2, '0')}`; | ||||
| document.title = `${short} - ${this.originalTitle}`; | 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) { | if (this.entryId) { | ||||
| @@ -558,17 +597,19 @@ class StopwatchManager { | |||||
| // ── Visual State ──────────────────────────────────────────────────────────── | // ── Visual State ──────────────────────────────────────────────────────────── | ||||
| applyRunningState() { | 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.startTicking(); | ||||
| this.markActiveEntryRow(); | this.markActiveEntryRow(); | ||||
| } | } | ||||
| applyStoppedState() { | 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.stopTicking(); | ||||
| this.entryId = null; | this.entryId = null; | ||||
| this.startedAt = null; | this.startedAt = null; | ||||
| @@ -47,4 +47,23 @@ | |||||
| pointer-events: none; | pointer-events: none; | ||||
| cursor: default; | 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); | @include icon-btn(auto, $radius-pill); | ||||
| color: rgba($color-white, 0.65); | color: rgba($color-white, 0.65); | ||||
| margin: 0 $space-1; | margin: 0 $space-1; | ||||
| position: relative; | |||||
| gap: $space-1; | gap: $space-1; | ||||
| padding: 0 $space-1; | padding: 0 $space-1; | ||||
| height: 32px; | height: 32px; | ||||
| min-width: 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); } | &:hover { color: $color-white; background: var(--header-overlay); } | ||||
| &--running { | &--running { | ||||
| color: $color-success; | color: $color-success; | ||||
| padding: 0 $space-2 0 $space-1; | |||||
| gap: $space-2; | |||||
| &:hover { color: $color-success; } | &: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 { | &::before { | ||||
| content: ''; | content: ''; | ||||
| position: absolute; | position: absolute; | ||||
| top: 1px; | |||||
| left: 1px; | |||||
| width: 28px; | |||||
| height: 28px; | |||||
| inset: 0; | |||||
| border-radius: 50%; | border-radius: 50%; | ||||
| border: 2px solid transparent; | border: 2px solid transparent; | ||||
| border-top-color: $color-success; | border-top-color: $color-success; | ||||
| @@ -46,10 +54,7 @@ | |||||
| &::after { | &::after { | ||||
| content: ''; | content: ''; | ||||
| position: absolute; | position: absolute; | ||||
| top: 1px; | |||||
| left: 1px; | |||||
| width: 28px; | |||||
| height: 28px; | |||||
| inset: 0; | |||||
| border-radius: 50%; | border-radius: 50%; | ||||
| border: 2px solid rgba($color-success, 0.2); | border: 2px solid rgba($color-success, 0.2); | ||||
| } | } | ||||
| @@ -259,25 +264,77 @@ | |||||
| } | } | ||||
| // ─── Hamburger-Nav Stopwatch ───────────────────────────────────────────────── | // ─── Hamburger-Nav Stopwatch ───────────────────────────────────────────────── | ||||
| .hamburger-nav__stopwatch-wrap { | |||||
| position: relative; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| } | |||||
| .hamburger-nav__stopwatch { | .hamburger-nav__stopwatch { | ||||
| display: flex; | display: flex; | ||||
| align-items: center; | align-items: center; | ||||
| gap: $space-2; | gap: $space-2; | ||||
| padding: $space-2 $space-4; | |||||
| color: $color-text-base; | |||||
| background: none; | background: none; | ||||
| border: none; | border: none; | ||||
| cursor: pointer; | 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 { | &--running { | ||||
| color: $color-success; | 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 einblenden ──────────────────────────────────────────────── | ||||
| .hamburger-nav { | .hamburger-nav { | ||||
| display: flex; | display: flex; | ||||
| justify-content: flex-end; | |||||
| align-items: center; | |||||
| justify-content: space-between; | |||||
| padding: $space-4 $space-4 $space-5; | 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" | <button class="main-nav__stopwatch" id="stopwatch-toggle" | ||||
| title="{{ 'app.stopwatch.title'|trans }}" | title="{{ 'app.stopwatch.title'|trans }}" | ||||
| aria-expanded="false"> | 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> | <span class="main-nav__stopwatch-time" id="stopwatch-header-time" hidden></span> | ||||
| </button> | </button> | ||||
| <div class="stopwatch-popover" id="stopwatch-popover" hidden> | <div class="stopwatch-popover" id="stopwatch-popover" hidden> | ||||
| @@ -53,22 +53,47 @@ | |||||
| {% endif %} | {% endif %} | ||||
| {% if isCurrentUserAdmin() %} | {% if isCurrentUserAdmin() %} | ||||
| <a href="{{ path('team_index') }}" | <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 }} | {{ 'app.nav.team'|trans }} | ||||
| </a> | </a> | ||||
| {% endif %} | {% endif %} | ||||
| <a href="{{ path('account_index') }}" | <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 }} | {{ 'app.nav.account'|trans }} | ||||
| </a> | </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> | </a> | ||||
| </div> | </div> | ||||
| </nav> | </nav> | ||||
| {# Hamburger-Navigation — nur im Minimal-Theme sichtbar (via CSS) #} | {# Hamburger-Navigation — nur im Minimal-Theme sichtbar (via CSS) #} | ||||
| <div class="hamburger-nav" id="hamburger-nav"> | <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"> | <button class="hamburger-nav__toggle" id="hamburger-toggle" aria-label="{{ 'app.nav.menu_open'|trans }}" aria-expanded="false"> | ||||
| <span class="hamburger-nav__icon"></span> | <span class="hamburger-nav__icon"></span> | ||||
| </button> | </button> | ||||
| @@ -77,12 +102,6 @@ | |||||
| class="hamburger-nav__item{% if currentRoute starts with 'timetracking' %} hamburger-nav__item--active{% endif %}"> | class="hamburger-nav__item{% if currentRoute starts with 'timetracking' %} hamburger-nav__item--active{% endif %}"> | ||||
| {{ 'app.nav.time_tracking'|trans }} | {{ 'app.nav.time_tracking'|trans }} | ||||
| </a> | </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') }}" | <a href="{{ path('report_times') }}" | ||||
| class="hamburger-nav__item{% if currentRoute starts with 'report' %} hamburger-nav__item--active{% endif %}"> | class="hamburger-nav__item{% if currentRoute starts with 'report' %} hamburger-nav__item--active{% endif %}"> | ||||
| {{ 'app.nav.reports'|trans }} | {{ 'app.nav.reports'|trans }} | ||||