FlorianEisenmenger 23時間前
コミット
b477d4415e
10個のファイルの変更303行の追加108行の削除
  1. +2
    -1
      .claude/settings.local.json
  2. +40
    -2
      CLAUDE.md
  3. +114
    -73
      httpdocs/assets/scripts/stopwatch.js
  4. +19
    -0
      httpdocs/assets/styles/components/_main-nav.scss
  5. +77
    -20
      httpdocs/assets/styles/components/_stopwatch.scss
  6. +2
    -1
      httpdocs/assets/styles/themes/_minimal.scss
  7. +5
    -0
      httpdocs/templates/_atoms/icon-logout.html.twig
  8. +9
    -0
      httpdocs/templates/_atoms/icon-team.html.twig
  9. +5
    -0
      httpdocs/templates/_atoms/icon-user.html.twig
  10. +30
    -11
      httpdocs/templates/_sections/nav.html.twig

+ 2
- 1
.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: $?\")"
]
}
}

+ 40
- 2
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):


+ 114
- 73
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;


+ 19
- 0
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; }
}

+ 77
- 20
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); }
}

+ 2
- 1
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;
}



+ 5
- 0
httpdocs/templates/_atoms/icon-logout.html.twig ファイルの表示

@@ -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>

+ 9
- 0
httpdocs/templates/_atoms/icon-team.html.twig ファイルの表示

@@ -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>

+ 5
- 0
httpdocs/templates/_atoms/icon-user.html.twig ファイルの表示

@@ -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>

+ 30
- 11
httpdocs/templates/_sections/nav.html.twig ファイルの表示

@@ -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 }}


読み込み中…
キャンセル
保存