| @@ -15,7 +15,7 @@ Technisch ähnlich wie **mite.de** – wir orientieren uns stark am UI/UX von mi | |||||
| ## Tech Stack | ## Tech Stack | ||||
| - **Backend**: Symfony 7, PHP 8.2, Doctrine ORM, MariaDB | - **Backend**: Symfony 7, PHP 8.2, Doctrine ORM, MariaDB | ||||
| - **Frontend**: Twig, SCSS (Webpack Encore), Vanilla JS (keine jQuery, kein Framework) | - **Frontend**: Twig, SCSS (Webpack Encore), Vanilla JS (keine jQuery, kein Framework) | ||||
| - **SCSS-Struktur**: Atoms → Components → Sections (BEM-ähnlich) | |||||
| - **SCSS-Struktur**: Atoms → Components → Sections → Themes (BEM-ähnlich) | |||||
| - **Dev**: DDEV (Port 8456 HTTPS), PHPMyAdmin installiert | - **Dev**: DDEV (Port 8456 HTTPS), PHPMyAdmin installiert | ||||
| - **Kein** Symfony Forms – eigene HTML-Formulare mit fetch()-API | - **Kein** Symfony Forms – eigene HTML-Formulare mit fetch()-API | ||||
| @@ -48,11 +48,13 @@ Technisch ähnlich wie **mite.de** – wir orientieren uns stark am UI/UX von mi | |||||
| #### `User` | #### `User` | ||||
| - `id`, `email` (unique), `firstName`, `lastName`, `password` (nullable), `note` | - `id`, `email` (unique), `firstName`, `lastName`, `password` (nullable), `note` | ||||
| - `theme` (VARCHAR 20, default `'standard'`) – Darstellungs-Theme des Users | |||||
| - Implementiert `UserInterface`, `PasswordAuthenticatedUserInterface` | - Implementiert `UserInterface`, `PasswordAuthenticatedUserInterface` | ||||
| - `getFullName()` → `"Flo Eisenmenger"` | - `getFullName()` → `"Flo Eisenmenger"` | ||||
| #### `Account` | #### `Account` | ||||
| - `id`, `name`, `slug` (unique, → Subdomain), `trackingInterval` (smallint, default 1) | - `id`, `name`, `slug` (unique, → Subdomain), `trackingInterval` (smallint, default 1) | ||||
| - `primaryColor` (VARCHAR 7, nullable) – Hauptfarbe des Standard-Themes (Hex, z.B. `#3a7bbf`). Nur Superadmin kann sie setzen. Wird für alle User des Accounts angewendet. | |||||
| - `createdAt`, `superAdminUser` (ManyToOne → User, nullable) | - `createdAt`, `superAdminUser` (ManyToOne → User, nullable) | ||||
| - `accountUsers` (OneToMany → AccountUser) | - `accountUsers` (OneToMany → AccountUser) | ||||
| - `getTenantDbName()` → `"db_" . str_replace('-', '_', slug)` | - `getTenantDbName()` → `"db_" . str_replace('-', '_', slug)` | ||||
| @@ -106,6 +108,8 @@ Technisch ähnlich wie **mite.de** – wir orientieren uns stark am UI/UX von mi | |||||
| - `GET /login` → `app_login` | - `GET /login` → `app_login` | ||||
| - `GET /logout` → `app_logout` | - `GET /logout` → `app_logout` | ||||
| - `GET /invite/{token}` → `app_invite` (Passwort setzen nach Einladung) | - `GET /invite/{token}` → `app_invite` (Passwort setzen nach Einladung) | ||||
| - `GET /password-reset` → Passwort-Reset anfordern | |||||
| - `GET /password-reset/{token}` → Neues Passwort setzen | |||||
| ### Timetracking | ### Timetracking | ||||
| - `GET /` → Redirect (je nach Login-Status) | - `GET /` → Redirect (je nach Login-Status) | ||||
| @@ -139,9 +143,9 @@ Technisch ähnlich wie **mite.de** – wir orientieren uns stark am UI/UX von mi | |||||
| ### Account-Einstellungen | ### Account-Einstellungen | ||||
| - `GET /account` → `account_index` (Tab: account / user, je nach Rolle) | - `GET /account` → `account_index` (Tab: account / user, je nach Rolle) | ||||
| - `PATCH /api/account` → Name + trackingInterval (nur Admin) | |||||
| - `PATCH /api/account` → Name + trackingInterval + primaryColor (nur Admin; primaryColor nur vom Superadmin befüllt) | |||||
| - `PATCH /api/account/superadmin` → Kontoinhaber übertragen (nur aktueller Superadmin) | - `PATCH /api/account/superadmin` → Kontoinhaber übertragen (nur aktueller Superadmin) | ||||
| - `PATCH /api/account/user` → Eigene Profildaten / Passwort ändern | |||||
| - `PATCH /api/account/user` → Eigene Profildaten / Passwort / Theme ändern | |||||
| --- | --- | ||||
| @@ -156,26 +160,62 @@ Technisch ähnlich wie **mite.de** – wir orientieren uns stark am UI/UX von mi | |||||
| - `AccountRoleHelper` – `isAdmin()`, `isMember()`, `isTracker()` für den aktuellen User/Account | - `AccountRoleHelper` – `isAdmin()`, `isMember()`, `isTracker()` für den aktuellen User/Account | ||||
| - `RegistrationService` – `startRegistration()`, `confirm(token)` – erstellt Account + DB + User | - `RegistrationService` – `startRegistration()`, `confirm(token)` – erstellt Account + DB + User | ||||
| - `SlugGenerator` – generiert/prüft Slugs aus Firmennamen | - `SlugGenerator` – generiert/prüft Slugs aus Firmennamen | ||||
| - `AppExtension` / `AppExtensionRuntime` – Twig-Funktionen: `deMonths()`, `deMonthsShort()`, `deWeekdays()`, `deWeekdaysShort()` | |||||
| - `BrandColorService` – leitet aus einem Hex-Farbwert (`primaryColor`) ein komplettes 6-Farben-Palette-Array via HSL-Offsets ab; wird in `AppExtension` und `AccountController` genutzt | |||||
| - `AppExtension` – Twig-Funktionen: `deMonths()`, `deMonthsShort()`, `deWeekdays()`, `deWeekdaysShort()`, `isCurrentUserAdmin()`, `isCurrentUserMemberOrAdmin()`, `getCurrentUserRole()`, **`brandPalette()`** (gibt das berechnete Farbpaletten-Array zurück, oder `null` wenn Standardfarbe) | |||||
| - `AppExtensionRuntime` – Runtime-Teil der Twig-Extension | |||||
| - `AccessDeniedHandler` – leitet bei 403 auf Login um | - `AccessDeniedHandler` – leitet bei 403 auf Login um | ||||
| --- | --- | ||||
| ## Theme-System | |||||
| ### User-Themes | |||||
| Jeder User kann sein persönliches Theme wählen (gespeichert in `User.theme`): | |||||
| - **`standard`** – Volle Navigation, alle Felder sichtbar (Default) | |||||
| - **`minimal`** – Ablenkungsfreie Ansicht: keine Top-Nav, Hamburger-Menü, Wochenansicht einklappbar, borderlose Eingabefelder, Entry-Liste per Klick aufklappbar | |||||
| `body[data-theme="minimal"]` wird in `base.html.twig` gesetzt. | |||||
| ### Brand-Farbe (Account-Level) | |||||
| Der Superadmin kann in den Account-Einstellungen eine **Hauptfarbe** (Hex, z.B. `#3a7bbf`) hinterlegen. Diese gilt für alle User des Accounts im Standard-Theme. | |||||
| **Funktionsweise:** | |||||
| 1. `Account.primaryColor` wird in der DB gespeichert | |||||
| 2. `BrandColorService::compute($hex)` leitet daraus 6 Farbvarianten ab (HSL-Offsets: primary +9L, primaryDark -3L, primaryLight +20L/-5S, headerFrom +13L/-4S, headerTo = Basisfarbe, bg +40L/-30S) | |||||
| 3. `AppExtension::brandPalette()` gibt die Palette zurück (oder `null` bei Standardfarbe `#3a7bbf`) | |||||
| 4. `base.html.twig` injiziert ein `<style>:root { --color-xxx: ... }</style>` Block wenn eine Custom-Farbe gesetzt ist | |||||
| 5. SCSS nutzt `var(--color-xxx)` für alle sichtbaren Farbverwendungen (Gradienten, Texte, Borders, Backgrounds). Subtile `rgba($color-primary, 0.02–0.1)` Tints bleiben compilierte SCSS-Werte (praktisch unsichtbar) | |||||
| **CSS Custom Properties (`:root` Defaults in `_variables.scss`):** | |||||
| - `--color-primary: #4a90d9` | |||||
| - `--color-primary-dark: #3178b8` | |||||
| - `--color-primary-light: #6aaee8` | |||||
| - `--color-header-from: #5b9fd6` | |||||
| - `--color-header-to: #3a7bbf` | |||||
| - `--color-bg: #dce9f5` | |||||
| - `--color-primary-rgb: 74, 144, 217` | |||||
| --- | |||||
| ## Template-Struktur | ## Template-Struktur | ||||
| ``` | ``` | ||||
| templates/ | templates/ | ||||
| ├── base.html.twig | |||||
| ├── base.html.twig ← Brand-Farbe CSS-Injection hier | |||||
| ├── _sections/ | |||||
| │ ├── nav.html.twig ← Top-Nav + Hamburger-Nav | |||||
| │ ├── tt-header.html.twig ← Minimal-Bar + Wochennavigation (collapsible) | |||||
| │ └── _atoms/ ← Icons, duration-help, etc. | |||||
| ├── home/ | ├── home/ | ||||
| │ └── index.html.twig ← Landing Page (Hauptdomain ohne Subdomain) | |||||
| │ └── index.html.twig ← Landing Page (Hauptdomain ohne Subdomain) | |||||
| ├── security/ | ├── security/ | ||||
| │ └── login.html.twig | │ └── login.html.twig | ||||
| ├── registration/ | ├── registration/ | ||||
| │ ├── register.html.twig | │ ├── register.html.twig | ||||
| │ ├── confirmed.html.twig ← Nach E-Mail-Bestätigung | |||||
| │ ├── confirmed.html.twig ← Nach E-Mail-Bestätigung | |||||
| │ └── confirm_error.html.twig | │ └── confirm_error.html.twig | ||||
| ├── invite/ | ├── invite/ | ||||
| │ ├── set_password.html.twig ← Passwort setzen nach Einladung | |||||
| │ ├── set_password.html.twig ← Passwort setzen nach Einladung | |||||
| │ └── error.html.twig | │ └── error.html.twig | ||||
| ├── email/ | ├── email/ | ||||
| │ ├── team_invite.html.twig | │ ├── team_invite.html.twig | ||||
| @@ -183,7 +223,7 @@ templates/ | |||||
| │ ├── registration_confirm.html.twig | │ ├── registration_confirm.html.twig | ||||
| │ └── registration_notify.html.twig | │ └── registration_notify.html.twig | ||||
| ├── timetracking/ | ├── timetracking/ | ||||
| │ ├── week.html.twig ← Hauptseite Zeiterfassung | |||||
| │ ├── week.html.twig ← Hauptseite Zeiterfassung | |||||
| │ └── _entry_row.html.twig | │ └── _entry_row.html.twig | ||||
| ├── client/ | ├── client/ | ||||
| │ └── index.html.twig | │ └── index.html.twig | ||||
| @@ -192,11 +232,11 @@ templates/ | |||||
| ├── service/ | ├── service/ | ||||
| │ └── index.html.twig | │ └── index.html.twig | ||||
| ├── team/ | ├── team/ | ||||
| │ └── index.html.twig ← Team-Verwaltung (nur Admins) | |||||
| │ └── index.html.twig ← Team-Verwaltung (nur Admins) | |||||
| ├── account/ | ├── account/ | ||||
| │ └── index.html.twig ← Account- + Profil-Einstellungen (Tabs) | |||||
| │ └── index.html.twig ← Account- + Profil-Einstellungen (Tabs) | |||||
| └── report/ | └── report/ | ||||
| └── times.html.twig ← Zeiteinträge-Report | |||||
| └── times.html.twig ← Zeiteinträge-Report | |||||
| ``` | ``` | ||||
| --- | --- | ||||
| @@ -207,7 +247,7 @@ templates/ | |||||
| assets/styles/ | assets/styles/ | ||||
| ├── main.scss ← Entry Point | ├── main.scss ← Entry Point | ||||
| ├── atoms/ | ├── atoms/ | ||||
| │ ├── _variables.scss | |||||
| │ ├── _variables.scss ← SCSS-Vars + :root CSS Custom Properties | |||||
| │ ├── _typography.scss | │ ├── _typography.scss | ||||
| │ ├── _buttons.scss ← .btn, .btn-primary, .btn-secondary | │ ├── _buttons.scss ← .btn, .btn-primary, .btn-secondary | ||||
| │ └── _inputs.scss ← .input, .select, .textarea | │ └── _inputs.scss ← .input, .select, .textarea | ||||
| @@ -225,9 +265,12 @@ assets/styles/ | |||||
| │ ├── _team.scss | │ ├── _team.scss | ||||
| │ ├── _account.scss | │ ├── _account.scss | ||||
| │ └── _report.scss | │ └── _report.scss | ||||
| └── sections/ | |||||
| ├── _timetracking.scss ← .tt-page, .tt-header, .tt-content | |||||
| └── _home.scss ← Landing Page | |||||
| ├── sections/ | |||||
| │ ├── _timetracking.scss ← .tt-page, .tt-header, .tt-content | |||||
| │ ├── _home.scss ← Landing Page | |||||
| │ └── _report.scss | |||||
| └── themes/ | |||||
| └── _minimal.scss ← body[data-theme="minimal"] Overrides | |||||
| ``` | ``` | ||||
| --- | --- | ||||
| @@ -239,13 +282,19 @@ assets/ | |||||
| ├── app.js ← Entry: importiert main.scss, calendar.js, entries.js | ├── app.js ← Entry: importiert main.scss, calendar.js, entries.js | ||||
| └── scripts/ | └── scripts/ | ||||
| ├── calendar.js ← WeekCalendar (Wochennavigation, Monatsansicht) | ├── calendar.js ← WeekCalendar (Wochennavigation, Monatsansicht) | ||||
| │ Positioniert Monatskalender relativ zum Cal-Icon | |||||
| ├── entries.js ← EntryManager (CRUD, fetch, localStorage) | ├── entries.js ← EntryManager (CRUD, fetch, localStorage) | ||||
| │ + initMinimalMode() (WeekToggle, NoteToggle, EntriesToggle) | |||||
| │ + window._updateWeekToggle(kw) für KW-Sync bei Navigation | |||||
| ├── duration.js ← parseDuration(), roundToQuarter(), formatMinutes(), | ├── duration.js ← parseDuration(), roundToQuarter(), formatMinutes(), | ||||
| │ validateDuration(), initDurationBlurHandler() | │ validateDuration(), initDurationBlurHandler() | ||||
| ├── nav.js ← Hamburger-Navigation (Minimal-Theme) | |||||
| ├── crud.js ← Entry: generisches CRUD (Kunden/Projekte/Leistungen) | ├── crud.js ← Entry: generisches CRUD (Kunden/Projekte/Leistungen) | ||||
| ├── registration.js ← Entry: Registrierungs-Flow, Live-Slug-Vorschau | ├── registration.js ← Entry: Registrierungs-Flow, Live-Slug-Vorschau | ||||
| ├── team.js ← Entry: Team-Verwaltung | ├── team.js ← Entry: Team-Verwaltung | ||||
| ├── account.js ← Entry: Account- + Profil-Einstellungen | ├── account.js ← Entry: Account- + Profil-Einstellungen | ||||
| │ Farbfeld: Picker ↔ Hex-Input synchron, | |||||
| │ Theme-Picker, Passwort-Toggle | |||||
| └── report.js ← Entry: Report-Seite, Edit + Invoiced-Toggle | └── report.js ← Entry: Report-Seite, Edit + Invoiced-Toggle | ||||
| ``` | ``` | ||||
| @@ -296,13 +345,25 @@ assets/ | |||||
| --- | --- | ||||
| ## Migrations (Central-DB) | |||||
| | Version | Inhalt | | |||||
| |---------------------|---------------------------------------------| | |||||
| | Version20260523* | Initiales Schema (User, Account, AccountUser, Token) | | |||||
| | Version20260524* | Passwort-Reset, Invite-Flow | | |||||
| | Version20260526120000 | `user.theme` VARCHAR(20) DEFAULT 'standard' | | |||||
| | Version20260526150000 | `account.primary_color` VARCHAR(7) NULL | | |||||
| Migration ausführen: `ddev exec php bin/console doctrine:migrations:migrate --em=central --no-interaction` | |||||
| --- | |||||
| ## Was noch fehlt / TODO | ## Was noch fehlt / TODO | ||||
| - [ ] Filter auf Report-Seite (Datumsbereich, Projekt, Service, User) | - [ ] Filter auf Report-Seite (Datumsbereich, Projekt, Service, User) | ||||
| - [ ] Export (CSV / PDF) | - [ ] Export (CSV / PDF) | ||||
| - [ ] Timer-Funktion (Live-Zeiterfassung) | - [ ] Timer-Funktion (Live-Zeiterfassung) | ||||
| - [ ] Wochenübersicht mit Summen pro Tag (im Wochenkalender) | - [ ] Wochenübersicht mit Summen pro Tag (im Wochenkalender) | ||||
| - [ ] E-Mail-Konfiguration für Produktivbetrieb (aktuell DDEV Mailpit) | - [ ] E-Mail-Konfiguration für Produktivbetrieb (aktuell DDEV Mailpit) | ||||
| - [ ] Passwort-Reset-Flow | |||||
| --- | --- | ||||
| @@ -324,4 +385,4 @@ bash 2-update-tenant-db.sh | |||||
| - PHPMyAdmin: `https://testtimetracking.ddev.site:8037` | - PHPMyAdmin: `https://testtimetracking.ddev.site:8037` | ||||
| - MariaDB: User `db`, Passwort `db`, Central-DB `db` | - MariaDB: User `db`, Passwort `db`, Central-DB `db` | ||||
| - `.env`: `DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=10.11.2-MariaDB&charset=utf8mb4"` | - `.env`: `DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=10.11.2-MariaDB&charset=utf8mb4"` | ||||
| - `APP_DOMAIN=testtimetracking.ddev.site:8456` (für Subdomain-Erkennung und E-Mail-Links) | |||||
| - `APP_DOMAIN=testtimetracking.ddev.site:8456` (für Subdomain-Erkennung und E-Mail-Links) | |||||
| @@ -7,5 +7,6 @@ | |||||
| // any CSS you import will output into a single css file (app.css in this case) | // any CSS you import will output into a single css file (app.css in this case) | ||||
| import './styles/main.scss'; | import './styles/main.scss'; | ||||
| import './scripts/nav.js'; | |||||
| import './scripts/calendar.js'; | import './scripts/calendar.js'; | ||||
| import './scripts/entries.js'; | import './scripts/entries.js'; | ||||
| @@ -21,16 +21,42 @@ document.addEventListener('DOMContentLoaded', () => { | |||||
| return json; | return json; | ||||
| } | } | ||||
| // ── Farbfeld: Picker ↔ Hex-Input synchron ──────────────────────────────── | |||||
| const colorPicker = document.getElementById('account-color-picker'); | |||||
| const colorHex = document.getElementById('account-color'); | |||||
| if (colorPicker && colorHex) { | |||||
| colorPicker.addEventListener('input', () => { | |||||
| colorHex.value = colorPicker.value; | |||||
| }); | |||||
| colorHex.addEventListener('input', () => { | |||||
| if (/^#[0-9a-fA-F]{6}$/.test(colorHex.value)) { | |||||
| colorPicker.value = colorHex.value; | |||||
| } | |||||
| }); | |||||
| } | |||||
| // ── Account-Formular ────────────────────────────────────────────────────── | // ── Account-Formular ────────────────────────────────────────────────────── | ||||
| const btnAccountSave = document.getElementById('btn-account-save'); | const btnAccountSave = document.getElementById('btn-account-save'); | ||||
| if (btnAccountSave) { | if (btnAccountSave) { | ||||
| btnAccountSave.addEventListener('click', async () => { | btnAccountSave.addEventListener('click', async () => { | ||||
| const payload = { | |||||
| name: document.getElementById('account-name').value.trim(), | |||||
| trackingInterval: parseInt(document.getElementById('account-interval').value, 10), | |||||
| }; | |||||
| if (colorHex) { | |||||
| const hex = colorHex.value.trim(); | |||||
| if (hex && !/^#[0-9a-fA-F]{6}$/.test(hex)) { | |||||
| showToast('Ungültiger Hex-Wert. Beispiel: #3a7bbf', true); | |||||
| return; | |||||
| } | |||||
| payload.primaryColor = hex || ''; | |||||
| } | |||||
| try { | try { | ||||
| await patchJson('/api/account', { | |||||
| name: document.getElementById('account-name').value.trim(), | |||||
| trackingInterval: parseInt(document.getElementById('account-interval').value, 10), | |||||
| }); | |||||
| showToast('Gespeichert.'); | |||||
| await patchJson('/api/account', payload); | |||||
| showToast('Gespeichert. Seite wird neu geladen…'); | |||||
| setTimeout(() => window.location.reload(), 1200); | |||||
| } catch (e) { | } catch (e) { | ||||
| showToast(e.message, true); | showToast(e.message, true); | ||||
| } | } | ||||
| @@ -76,6 +102,27 @@ document.addEventListener('DOMContentLoaded', () => { | |||||
| }); | }); | ||||
| } | } | ||||
| // ── Theme-Picker ────────────────────────────────────────────────────────── | |||||
| const themePicker = document.getElementById('theme-picker'); | |||||
| if (themePicker) { | |||||
| themePicker.querySelectorAll('input[name="theme"]').forEach(radio => { | |||||
| radio.addEventListener('change', async () => { | |||||
| const theme = radio.value; | |||||
| try { | |||||
| await patchJson('/api/account/user', { theme }); | |||||
| // Optionen visuell aktualisieren | |||||
| themePicker.querySelectorAll('.theme-option').forEach(opt => { | |||||
| opt.classList.toggle('theme-option--active', opt.dataset.theme === theme); | |||||
| }); | |||||
| document.body.dataset.theme = theme; | |||||
| showToast('Darstellung geändert.'); | |||||
| } catch (e) { | |||||
| showToast(e.message, true); | |||||
| } | |||||
| }); | |||||
| }); | |||||
| } | |||||
| // ── Benutzer-Formular ───────────────────────────────────────────────────── | // ── Benutzer-Formular ───────────────────────────────────────────────────── | ||||
| const btnUserSave = document.getElementById('btn-user-save'); | const btnUserSave = document.getElementById('btn-user-save'); | ||||
| if (btnUserSave) { | if (btnUserSave) { | ||||
| @@ -136,6 +136,7 @@ class WeekCalendar { | |||||
| } | } | ||||
| if (kwEl) kwEl.textContent = `${t('weekLabel')} ${this.getWeekNumber(this.activeDate)}`; | if (kwEl) kwEl.textContent = `${t('weekLabel')} ${this.getWeekNumber(this.activeDate)}`; | ||||
| window._updateWeekToggle?.(this.getWeekNumber(this.activeDate)); | |||||
| } | } | ||||
| // ── Monats-Ansicht ──────────────────────────────────────────────────────── | // ── Monats-Ansicht ──────────────────────────────────────────────────────── | ||||
| @@ -147,6 +148,12 @@ class WeekCalendar { | |||||
| this.monthDate = new Date(this.activeDate); | this.monthDate = new Date(this.activeDate); | ||||
| this.monthEl = document.createElement('div'); | this.monthEl = document.createElement('div'); | ||||
| this.monthEl.className = 'month-calendar month-calendar--hidden'; | this.monthEl.className = 'month-calendar month-calendar--hidden'; | ||||
| // Align calendar's right edge with the calendar icon button's right edge | |||||
| const calRect = this.calBtn.getBoundingClientRect(); | |||||
| const headerRect = this.header.getBoundingClientRect(); | |||||
| this.monthEl.style.right = `${Math.max(0, headerRect.right - calRect.right)}px`; | |||||
| this.header.appendChild(this.monthEl); | this.header.appendChild(this.monthEl); | ||||
| this.renderMonthGrid(); | this.renderMonthGrid(); | ||||
| requestAnimationFrame(() => requestAnimationFrame(() => { | requestAnimationFrame(() => requestAnimationFrame(() => { | ||||
| @@ -208,6 +208,8 @@ class EntryManager { | |||||
| if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; } | if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; } | ||||
| if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return; | if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return; | ||||
| if (getDailyTotalMinutes() + rawMinutes > 1440) { alert(t('errorDailyLimitExceeded')); return; } | |||||
| try { | try { | ||||
| const res = await fetch('/api/entries', { | const res = await fetch('/api/entries', { | ||||
| method: 'POST', | method: 'POST', | ||||
| @@ -321,6 +323,9 @@ class EntryManager { | |||||
| if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; } | if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; } | ||||
| if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return; | if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return; | ||||
| const currentEntryMinutes = parseInt(row.dataset.duration) || 0; | |||||
| if (getDailyTotalMinutes() - currentEntryMinutes + rawMinutes > 1440) { alert(t('errorDailyLimitExceeded')); return; } | |||||
| try { | try { | ||||
| const res = await fetch(`/api/entries/${id}`, { | const res = await fetch(`/api/entries/${id}`, { | ||||
| method: 'PATCH', | method: 'PATCH', | ||||
| @@ -461,6 +466,14 @@ class EntryManager { | |||||
| } | } | ||||
| } | } | ||||
| function getDailyTotalMinutes() { | |||||
| let total = 0; | |||||
| document.querySelectorAll('#entry-items .entry-row').forEach(row => { | |||||
| total += parseInt(row.dataset.duration) || 0; | |||||
| }); | |||||
| return total; | |||||
| } | |||||
| function saveLastProject(projectId) { | function saveLastProject(projectId) { | ||||
| if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId); | if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId); | ||||
| } | } | ||||
| @@ -477,8 +490,89 @@ function getLastService() { | |||||
| return localStorage.getItem(LAST_SERVICE_KEY); | return localStorage.getItem(LAST_SERVICE_KEY); | ||||
| } | } | ||||
| // ── Minimal-Modus-Initialisierung ───────────────────────────────────────────── | |||||
| const NOTE_KEY = 'tt_minimal_note_open'; | |||||
| function initMinimalMode() { | |||||
| if (document.body.dataset.theme !== 'minimal') return; | |||||
| initWeekToggle(); | |||||
| initNoteToggle(); | |||||
| initEntriesToggle(); | |||||
| } | |||||
| function initWeekToggle() { | |||||
| const btn = document.getElementById('btn-week-toggle'); | |||||
| const collapsible = document.getElementById('tt-header-collapsible'); | |||||
| if (!btn || !collapsible) return; | |||||
| btn.setAttribute('aria-expanded', 'false'); | |||||
| collapsible.classList.remove('is-open'); | |||||
| let kw = btn.textContent.trim().match(/\d+/)?.[0] ?? ''; | |||||
| btn.textContent = kw ? `KW ${kw} ▾` : '▾'; | |||||
| btn.addEventListener('click', () => { | |||||
| const open = collapsible.classList.toggle('is-open'); | |||||
| btn.setAttribute('aria-expanded', String(open)); | |||||
| btn.textContent = kw ? `KW ${kw} ${open ? '▴' : '▾'}` : (open ? '▴' : '▾'); | |||||
| }); | |||||
| window._updateWeekToggle = (newKw) => { | |||||
| kw = String(newKw); | |||||
| const open = collapsible.classList.contains('is-open'); | |||||
| btn.textContent = `KW ${kw} ${open ? '▴' : '▾'}`; | |||||
| }; | |||||
| } | |||||
| function initNoteToggle() { | |||||
| const btn = document.getElementById('btn-note-toggle'); | |||||
| const label = document.querySelector('.entry-form__label--note'); | |||||
| const field = document.querySelector('.entry-form__field--note'); | |||||
| if (!btn) return; | |||||
| const open = localStorage.getItem(NOTE_KEY) === '1'; | |||||
| setNoteVisible(open, btn, label, field); | |||||
| btn.addEventListener('click', () => { | |||||
| const nowOpen = label?.classList.toggle('is-visible'); | |||||
| field?.classList.toggle('is-visible'); | |||||
| btn.classList.toggle('is-open', !!nowOpen); | |||||
| btn.textContent = nowOpen ? '× Bemerkung ausblenden' : '+ Bemerkung hinzufügen'; | |||||
| localStorage.setItem(NOTE_KEY, nowOpen ? '1' : '0'); | |||||
| }); | |||||
| } | |||||
| function setNoteVisible(open, btn, label, field) { | |||||
| if (open) { | |||||
| label?.classList.add('is-visible'); | |||||
| field?.classList.add('is-visible'); | |||||
| btn.classList.add('is-open'); | |||||
| btn.textContent = '× Bemerkung ausblenden'; | |||||
| } else { | |||||
| btn.textContent = '+ Bemerkung hinzufügen'; | |||||
| } | |||||
| } | |||||
| function initEntriesToggle() { | |||||
| const summaryBtn = document.getElementById('btn-entries-toggle'); | |||||
| const entryList = document.getElementById('entry-list'); | |||||
| if (!summaryBtn || !entryList) return; | |||||
| // Immer eingeklappt beim Laden | |||||
| entryList.classList.add('is-collapsed'); | |||||
| summaryBtn.setAttribute('aria-expanded', 'false'); | |||||
| summaryBtn.addEventListener('click', () => { | |||||
| const collapsed = entryList.classList.toggle('is-collapsed'); | |||||
| summaryBtn.setAttribute('aria-expanded', String(!collapsed)); | |||||
| }); | |||||
| } | |||||
| window.entryManager = null; | window.entryManager = null; | ||||
| document.addEventListener('DOMContentLoaded', () => { | document.addEventListener('DOMContentLoaded', () => { | ||||
| initDurationBlurHandler(); | initDurationBlurHandler(); | ||||
| initMinimalMode(); | |||||
| window.entryManager = new EntryManager(); | window.entryManager = new EntryManager(); | ||||
| }); | }); | ||||
| @@ -0,0 +1,19 @@ | |||||
| // Hamburger-Nav (läuft auf allen Seiten via app.js) | |||||
| document.addEventListener('DOMContentLoaded', () => { | |||||
| const toggle = document.getElementById('hamburger-toggle'); | |||||
| const panel = document.getElementById('hamburger-panel'); | |||||
| if (!toggle || !panel) return; | |||||
| toggle.addEventListener('click', () => { | |||||
| const open = toggle.getAttribute('aria-expanded') === 'true'; | |||||
| toggle.setAttribute('aria-expanded', String(!open)); | |||||
| panel.hidden = open; | |||||
| }); | |||||
| document.addEventListener('click', e => { | |||||
| if (!toggle.contains(e.target) && !panel.contains(e.target)) { | |||||
| toggle.setAttribute('aria-expanded', 'false'); | |||||
| panel.hidden = true; | |||||
| } | |||||
| }); | |||||
| }); | |||||
| @@ -122,7 +122,11 @@ async function saveEdit(row) { | |||||
| }), | }), | ||||
| }); | }); | ||||
| if (!res.ok) { alert(t('errorSave')); return; } | |||||
| if (!res.ok) { | |||||
| const err = await res.json().catch(() => ({})); | |||||
| alert(err.error ?? t('errorSave')); | |||||
| return; | |||||
| } | |||||
| window.location.reload(); | window.location.reload(); | ||||
| @@ -423,6 +427,9 @@ class ReportFilter { | |||||
| row.querySelectorAll('.filter-select').forEach(sel => { | row.querySelectorAll('.filter-select').forEach(sel => { | ||||
| if (sel.value) params.append(`filter[${key}][]`, sel.value); | if (sel.value) params.append(`filter[${key}][]`, sel.value); | ||||
| }); | }); | ||||
| if (row.querySelector('.filter-neg-checkbox')?.checked) { | |||||
| params.set(`filter[${key}_neg]`, '1'); | |||||
| } | |||||
| } else if (key === 'period') { | } else if (key === 'period') { | ||||
| const val = this.periodSel?.value; | const val = this.periodSel?.value; | ||||
| @@ -445,6 +452,9 @@ class ReportFilter { | |||||
| params.set('filter[date_to]', `${toYear}-${toMonth}-${toDay}`); | params.set('filter[date_to]', `${toYear}-${toMonth}-${toDay}`); | ||||
| } | } | ||||
| } | } | ||||
| if (row.querySelector('.filter-neg-checkbox')?.checked) { | |||||
| params.set('filter[period_neg]', '1'); | |||||
| } | |||||
| } else if (key === 'note') { | } else if (key === 'note') { | ||||
| const val = row.querySelector('.filter-note-input')?.value?.trim(); | const val = row.querySelector('.filter-note-input')?.value?.trim(); | ||||
| @@ -26,7 +26,7 @@ | |||||
| } | } | ||||
| &:focus-visible { | &:focus-visible { | ||||
| outline: 2px solid $color-primary; | |||||
| outline: 2px solid var(--color-primary); | |||||
| outline-offset: 3px; | outline-offset: 3px; | ||||
| } | } | ||||
| } | } | ||||
| @@ -26,7 +26,7 @@ | |||||
| &:focus { | &:focus { | ||||
| outline: none; | outline: none; | ||||
| border-color: $color-primary; | |||||
| border-color: var(--color-primary); | |||||
| box-shadow: $shadow-focus; | box-shadow: $shadow-focus; | ||||
| } | } | ||||
| } | } | ||||
| @@ -48,7 +48,7 @@ | |||||
| cursor: pointer; | cursor: pointer; | ||||
| &:hover { | &:hover { | ||||
| border-color: $color-primary-light; | |||||
| border-color: var(--color-primary-light); | |||||
| } | } | ||||
| } | } | ||||
| @@ -83,7 +83,7 @@ | |||||
| transition: border-color $transition-fast, color $transition-fast; | transition: border-color $transition-fast, color $transition-fast; | ||||
| &:hover { | &:hover { | ||||
| border-color: $color-primary; | |||||
| color: $color-primary; | |||||
| border-color: var(--color-primary); | |||||
| color: var(--color-primary); | |||||
| } | } | ||||
| } | } | ||||
| @@ -1,4 +1,5 @@ | |||||
| // ─── Color Palette ─────────────────────────────────────────────────────────── | // ─── Color Palette ─────────────────────────────────────────────────────────── | ||||
| // Compile-time values (used in rgba() functions; keep as hex) | |||||
| $color-primary: #4a90d9; | $color-primary: #4a90d9; | ||||
| $color-primary-dark: #3178b8; | $color-primary-dark: #3178b8; | ||||
| $color-primary-light: #6aaee8; | $color-primary-light: #6aaee8; | ||||
| @@ -11,6 +12,17 @@ $color-accent-light: #f5bc3a; | |||||
| $color-white: #ffffff; | $color-white: #ffffff; | ||||
| $color-bg: #dce9f5; | $color-bg: #dce9f5; | ||||
| // ─── CSS Custom Properties (runtime-overridable via brand color) ────────────── | |||||
| :root { | |||||
| --color-primary: #{$color-primary}; | |||||
| --color-primary-dark: #{$color-primary-dark}; | |||||
| --color-primary-light: #{$color-primary-light}; | |||||
| --color-header-from: #{$color-header-from}; | |||||
| --color-header-to: #{$color-header-to}; | |||||
| --color-bg: #{$color-bg}; | |||||
| --color-primary-rgb: 74, 144, 217; | |||||
| } | |||||
| $color-card: #f0f0f0; | $color-card: #f0f0f0; | ||||
| $color-card-white: #ffffff; | $color-card-white: #ffffff; | ||||
| @@ -3,14 +3,14 @@ | |||||
| // ─── Page ───────────────────────────────────────────────────────────────────── | // ─── Page ───────────────────────────────────────────────────────────────────── | ||||
| .account-page { | .account-page { | ||||
| min-height: 100vh; | min-height: 100vh; | ||||
| background: $color-bg; | |||||
| background: var(--color-bg); | |||||
| display: flex; | display: flex; | ||||
| flex-direction: column; | flex-direction: column; | ||||
| } | } | ||||
| // ─── Header ────────────────────────────────────────────────────────────────── | // ─── Header ────────────────────────────────────────────────────────────────── | ||||
| .account-header { | .account-header { | ||||
| background: linear-gradient(135deg, $color-header-from 0%, $color-header-to 100%); | |||||
| background: linear-gradient(135deg, var(--color-header-from) 0%, var(--color-header-to) 100%); | |||||
| padding: $space-6; | padding: $space-6; | ||||
| display: flex; | display: flex; | ||||
| align-items: center; | align-items: center; | ||||
| @@ -114,12 +114,36 @@ | |||||
| .account-form__link { | .account-form__link { | ||||
| font-size: $font-size-sm; | font-size: $font-size-sm; | ||||
| color: $color-primary; | |||||
| color: var(--color-primary); | |||||
| text-decoration: none; | text-decoration: none; | ||||
| &:hover { text-decoration: underline; } | &:hover { text-decoration: underline; } | ||||
| } | } | ||||
| // ─── Farbfeld ───────────────────────────────────────────────────────────────── | |||||
| .account-color-field { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| gap: $space-3; | |||||
| } | |||||
| .account-color-field__swatch { | |||||
| width: 40px; | |||||
| height: 40px; | |||||
| border: 1px solid $color-input-border; | |||||
| border-radius: $radius-sm; | |||||
| padding: 2px; | |||||
| cursor: pointer; | |||||
| background: none; | |||||
| flex-shrink: 0; | |||||
| } | |||||
| .account-color-field__hex { | |||||
| width: 110px; | |||||
| font-family: monospace; | |||||
| letter-spacing: 0.04em; | |||||
| } | |||||
| // ─── Passwort-Sektion (toggle) ──────────────────────────────────────────────── | // ─── Passwort-Sektion (toggle) ──────────────────────────────────────────────── | ||||
| .account-form__pw-section { | .account-form__pw-section { | ||||
| display: contents; // bleibt im Grid-Fluss | display: contents; // bleibt im Grid-Fluss | ||||
| @@ -101,7 +101,7 @@ | |||||
| svg { width: 14px; height: 14px; pointer-events: none; } | svg { width: 14px; height: 14px; pointer-events: none; } | ||||
| &--edit:hover { background: rgba($color-primary, 0.1); color: $color-primary; } | |||||
| &--edit:hover { background: rgba($color-primary, 0.1); color: var(--color-primary); } | |||||
| &--delete:hover{ background: rgba($color-error, 0.1); color: $color-error; } | &--delete:hover{ background: rgba($color-error, 0.1); color: $color-error; } | ||||
| @media (hover: none) { opacity: 1; } | @media (hover: none) { opacity: 1; } | ||||
| @@ -153,7 +153,7 @@ | |||||
| &:hover { color: $color-text-dark; } | &:hover { color: $color-text-dark; } | ||||
| &--active { | &--active { | ||||
| background: $color-primary; | |||||
| background: var(--color-primary); | |||||
| color: $color-white; | color: $color-white; | ||||
| } | } | ||||
| } | } | ||||
| @@ -203,6 +203,6 @@ | |||||
| height: 16px; | height: 16px; | ||||
| cursor: pointer; | cursor: pointer; | ||||
| flex-shrink: 0; | flex-shrink: 0; | ||||
| accent-color: $color-primary; | |||||
| accent-color: var(--color-primary); | |||||
| } | } | ||||
| } | } | ||||
| @@ -147,7 +147,7 @@ | |||||
| svg { width: 14px; height: 14px; pointer-events: none; } | svg { width: 14px; height: 14px; pointer-events: none; } | ||||
| &--edit:hover { background: rgba($color-primary, 0.1); color: $color-primary; } | |||||
| m &--edit:hover { background: rgba($color-primary, 0.1); color: var(--color-primary); } | |||||
| &--delete:hover{ background: rgba($color-error, 0.1); color: $color-error; } | &--delete:hover{ background: rgba($color-error, 0.1); color: $color-error; } | ||||
| // immer sichtbar auf Touch-Geräten | // immer sichtbar auf Touch-Geräten | ||||
| @@ -3,7 +3,7 @@ | |||||
| // ─── Login Page ─────────────────────────────────────────────────────────────── | // ─── Login Page ─────────────────────────────────────────────────────────────── | ||||
| .login-body { | .login-body { | ||||
| min-height: 100vh; | min-height: 100vh; | ||||
| background: $color-bg; | |||||
| background: var(--color-bg); | |||||
| display: flex; | display: flex; | ||||
| align-items: center; | align-items: center; | ||||
| justify-content: center; | justify-content: center; | ||||
| @@ -71,7 +71,7 @@ | |||||
| white-space: nowrap; | white-space: nowrap; | ||||
| &:hover { | &:hover { | ||||
| color: $color-primary; | |||||
| color: var(--color-primary); | |||||
| text-decoration: underline; | text-decoration: underline; | ||||
| } | } | ||||
| } | } | ||||
| @@ -88,7 +88,7 @@ | |||||
| text-decoration: none; | text-decoration: none; | ||||
| &:hover { | &:hover { | ||||
| color: $color-primary; | |||||
| color: var(--color-primary); | |||||
| text-decoration: underline; | text-decoration: underline; | ||||
| } | } | ||||
| } | } | ||||
| @@ -128,7 +128,7 @@ | |||||
| width: 16px; | width: 16px; | ||||
| height: 16px; | height: 16px; | ||||
| cursor: pointer; | cursor: pointer; | ||||
| accent-color: $color-primary; | |||||
| accent-color: var(--color-primary); | |||||
| } | } | ||||
| } | } | ||||
| @@ -39,7 +39,7 @@ | |||||
| &--active { | &--active { | ||||
| color: $color-white; | color: $color-white; | ||||
| border-bottom-color: $color-primary-light; | |||||
| border-bottom-color: var(--color-primary-light); | |||||
| } | } | ||||
| &--disabled { | &--disabled { | ||||
| @@ -6,7 +6,7 @@ | |||||
| top: calc(100% + 8px); | top: calc(100% + 8px); | ||||
| right: 0; | right: 0; | ||||
| width: 380px; | width: 380px; | ||||
| background: linear-gradient(160deg, $color-primary-light, $color-primary-dark); | |||||
| background: linear-gradient(160deg, var(--color-primary-light), var(--color-primary-dark)); | |||||
| border-radius: $radius-xl; | border-radius: $radius-xl; | ||||
| padding: $space-4; | padding: $space-4; | ||||
| box-shadow: $shadow-calendar; | box-shadow: $shadow-calendar; | ||||
| @@ -2,7 +2,7 @@ | |||||
| .register-body { | .register-body { | ||||
| min-height: 100vh; | min-height: 100vh; | ||||
| background: $color-bg; | |||||
| background: var(--color-bg); | |||||
| display: flex; | display: flex; | ||||
| align-items: flex-start; | align-items: flex-start; | ||||
| justify-content: center; | justify-content: center; | ||||
| @@ -29,7 +29,7 @@ | |||||
| a { | a { | ||||
| font-size: $font-size-base; | font-size: $font-size-base; | ||||
| font-weight: $font-weight-bold; | font-weight: $font-weight-bold; | ||||
| color: $color-primary; | |||||
| color: var(--color-primary); | |||||
| text-decoration: none; | text-decoration: none; | ||||
| letter-spacing: 0.02em; | letter-spacing: 0.02em; | ||||
| } | } | ||||
| @@ -140,7 +140,7 @@ | |||||
| color: $color-text-muted; | color: $color-text-muted; | ||||
| a { | a { | ||||
| color: $color-primary; | |||||
| color: var(--color-primary); | |||||
| text-decoration: none; | text-decoration: none; | ||||
| &:hover { text-decoration: underline; } | &:hover { text-decoration: underline; } | ||||
| @@ -144,7 +144,7 @@ | |||||
| cursor: pointer; | cursor: pointer; | ||||
| input[type='radio'] { | input[type='radio'] { | ||||
| accent-color: $color-primary; | |||||
| accent-color: var(--color-primary); | |||||
| width: 15px; | width: 15px; | ||||
| height: 15px; | height: 15px; | ||||
| flex-shrink: 0; | flex-shrink: 0; | ||||
| @@ -23,6 +23,9 @@ | |||||
| @use 'sections/home'; | @use 'sections/home'; | ||||
| @use 'sections/report'; | @use 'sections/report'; | ||||
| // ─── Themes ─────────────────────────────────────────────────────────────────── | |||||
| @use 'themes/minimal'; | |||||
| // ─── Reset / Base ───────────────────────────────────────────────────────────── | // ─── Reset / Base ───────────────────────────────────────────────────────────── | ||||
| *, | *, | ||||
| *::before, | *::before, | ||||
| @@ -37,7 +40,7 @@ html { | |||||
| } | } | ||||
| body { | body { | ||||
| background: $color-bg; | |||||
| background: var(--color-bg); | |||||
| } | } | ||||
| @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap'); | @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap'); | ||||
| @@ -2,14 +2,14 @@ | |||||
| .home-body { | .home-body { | ||||
| min-height: 100vh; | min-height: 100vh; | ||||
| background: $color-bg; | |||||
| background: var(--color-bg); | |||||
| display: flex; | display: flex; | ||||
| flex-direction: column; | flex-direction: column; | ||||
| } | } | ||||
| // ─── Header ────────────────────────────────────────────────────────────────── | // ─── Header ────────────────────────────────────────────────────────────────── | ||||
| .home-header { | .home-header { | ||||
| background: linear-gradient(135deg, $color-header-from, $color-header-to); | |||||
| background: linear-gradient(135deg, var(--color-header-from), var(--color-header-to)); | |||||
| padding: $space-4 $space-8; | padding: $space-4 $space-8; | ||||
| } | } | ||||
| @@ -3,14 +3,14 @@ | |||||
| // ─── Page ───────────────────────────────────────────────────────────────────── | // ─── Page ───────────────────────────────────────────────────────────────────── | ||||
| .report-page { | .report-page { | ||||
| min-height: 100vh; | min-height: 100vh; | ||||
| background: $color-bg; | |||||
| background: var(--color-bg); | |||||
| display: flex; | display: flex; | ||||
| flex-direction: column; | flex-direction: column; | ||||
| } | } | ||||
| // ─── Header ────────────────────────────────────────────────────────────────── | // ─── Header ────────────────────────────────────────────────────────────────── | ||||
| .report-header { | .report-header { | ||||
| background: linear-gradient(135deg, $color-header-from 0%, $color-header-to 100%); | |||||
| background: linear-gradient(135deg, var(--color-header-from) 0%, var(--color-header-to) 100%); | |||||
| padding: $space-4 $space-6; | padding: $space-4 $space-6; | ||||
| display: flex; | display: flex; | ||||
| align-items: center; | align-items: center; | ||||
| @@ -99,7 +99,7 @@ | |||||
| gap: $space-2; | gap: $space-2; | ||||
| font-size: $font-size-sm; | font-size: $font-size-sm; | ||||
| font-weight: $font-weight-medium; | font-weight: $font-weight-medium; | ||||
| color: $color-primary; | |||||
| color: var(--color-primary); | |||||
| cursor: pointer; | cursor: pointer; | ||||
| text-decoration: none; | text-decoration: none; | ||||
| @@ -375,12 +375,12 @@ | |||||
| gap: $space-2; | gap: $space-2; | ||||
| a { | a { | ||||
| color: $color-primary; | |||||
| color: var(--color-primary); | |||||
| text-decoration: underline; | text-decoration: underline; | ||||
| cursor: pointer; | cursor: pointer; | ||||
| &:hover { | &:hover { | ||||
| color: $color-primary-dark; | |||||
| color: var(--color-primary-dark); | |||||
| } | } | ||||
| } | } | ||||
| @@ -506,7 +506,7 @@ button.report-toolbar__action { | |||||
| height: 14px; | height: 14px; | ||||
| cursor: pointer; | cursor: pointer; | ||||
| flex-shrink: 0; | flex-shrink: 0; | ||||
| accent-color: $color-primary; | |||||
| accent-color: var(--color-primary); | |||||
| } | } | ||||
| // ─── Body: Controls + Meta nebeneinander ──────────────────────────────────── | // ─── Body: Controls + Meta nebeneinander ──────────────────────────────────── | ||||
| @@ -579,8 +579,8 @@ button.report-toolbar__action { | |||||
| transition: border-color $transition-fast, color $transition-fast; | transition: border-color $transition-fast, color $transition-fast; | ||||
| &:hover { | &:hover { | ||||
| border-color: $color-primary; | |||||
| color: $color-primary; | |||||
| border-color: var(--color-primary); | |||||
| color: var(--color-primary); | |||||
| } | } | ||||
| } | } | ||||
| @@ -656,7 +656,7 @@ button.report-toolbar__action { | |||||
| color: $color-text-base; | color: $color-text-base; | ||||
| cursor: pointer; | cursor: pointer; | ||||
| user-select: none; | user-select: none; | ||||
| accent-color: $color-primary; | |||||
| accent-color: var(--color-primary); | |||||
| } | } | ||||
| // ─── Filter-Footer ──────────────────────────────────────────────────────────── | // ─── Filter-Footer ──────────────────────────────────────────────────────────── | ||||
| @@ -3,14 +3,14 @@ | |||||
| // ─── Page Wrapper ───────────────────────────────────────────────────────────── | // ─── Page Wrapper ───────────────────────────────────────────────────────────── | ||||
| .tt-page { | .tt-page { | ||||
| min-height: 100vh; | min-height: 100vh; | ||||
| background: $color-bg; | |||||
| background: var(--color-bg); | |||||
| display: flex; | display: flex; | ||||
| flex-direction: column; | flex-direction: column; | ||||
| } | } | ||||
| // ─── Header Section ────────────────────────────────────────────────────────── | // ─── Header Section ────────────────────────────────────────────────────────── | ||||
| .tt-header { | .tt-header { | ||||
| background: linear-gradient(135deg, $color-header-from 0%, $color-header-to 100%); | |||||
| background: linear-gradient(135deg, var(--color-header-from) 0%, var(--color-header-to) 100%); | |||||
| padding: $space-4 $space-6; | padding: $space-4 $space-6; | ||||
| display: flex; | display: flex; | ||||
| align-items: center; | align-items: center; | ||||
| @@ -0,0 +1,382 @@ | |||||
| @use '../atoms/variables' as *; | |||||
| // ─── Minimal Theme ───────────────────────────────────────────────────────────── | |||||
| // Gilt nur wenn body[data-theme="minimal"] gesetzt ist. | |||||
| // Standard-Theme bleibt vollständig unverändert. | |||||
| body[data-theme="minimal"] { | |||||
| background: #fff; | |||||
| // ── Normale Top-Nav ausblenden ────────────────────────────────────────────── | |||||
| .main-nav { display: none; } | |||||
| // ── Hamburger-Nav einblenden ──────────────────────────────────────────────── | |||||
| .hamburger-nav { | |||||
| display: flex; | |||||
| justify-content: flex-end; | |||||
| padding: $space-3 $space-3 0; | |||||
| } | |||||
| // ── Page-Background weiß ─────────────────────────────────────────────────── | |||||
| .tt-page { background: #fff; } | |||||
| // ── TT-Header: kein Gradient, cleaner Rahmen ─────────────────────────────── | |||||
| .tt-header { | |||||
| background: #fff; | |||||
| box-shadow: 0 1px 0 $color-border; | |||||
| padding: $space-3 $space-5; | |||||
| min-height: auto; | |||||
| flex-direction: column; | |||||
| align-items: stretch; | |||||
| gap: 0; | |||||
| } | |||||
| // Standard-Meta und Collapsible umschalten | |||||
| .tt-header__meta { display: none; } | |||||
| .tt-header__minimal-bar { display: flex; } | |||||
| // Collapsible: standardmäßig versteckt (JS steuert aria-expanded) | |||||
| .tt-header__collapsible { | |||||
| overflow: hidden; | |||||
| transition: max-height $transition-slow; | |||||
| max-height: 0; | |||||
| &.is-open { max-height: 120px; } | |||||
| } | |||||
| // Week-Nav im Minimal-Stil (dunkler Hintergrund funktioniert nicht auf weiß) | |||||
| .week-nav { | |||||
| background: var(--color-bg); | |||||
| border-radius: $radius-md; | |||||
| margin-top: $space-2; | |||||
| } | |||||
| .week-nav__arrow { color: $color-text-dark; &:hover { background: $color-border; } } | |||||
| .week-nav__day-name { color: $color-text-base; } | |||||
| .week-nav__day-date { color: $color-text-muted; } | |||||
| .week-nav__day--active { background: $color-text-dark; .week-nav__day-name, .week-nav__day-date { color: $color-white; } } | |||||
| .week-nav__day:hover:not(.week-nav__day--active) { background: rgba($color-text-dark, 0.07); } | |||||
| .week-nav__cal { background: rgba($color-text-dark, 0.08); color: $color-text-dark; &:hover { background: rgba($color-text-dark, 0.15); } } | |||||
| // ── Greeting versteckt ───────────────────────────────────────────────────── | |||||
| .greeting { display: none; } | |||||
| // ── Content zentriert und schmaler ──────────────────────────────────────── | |||||
| .tt-content { | |||||
| max-width: 520px; | |||||
| padding: $space-6 $space-4; | |||||
| gap: $space-6; | |||||
| } | |||||
| // ── Entry Form: cleaner, größere Inputs ──────────────────────────────────── | |||||
| .entry-form { | |||||
| background: #fff; | |||||
| border: none; | |||||
| box-shadow: none; | |||||
| border-radius: 0; | |||||
| padding: $space-4 0; | |||||
| } | |||||
| .entry-form__grid { | |||||
| grid-template-columns: 100px 1fr; | |||||
| gap: $space-5 $space-4; | |||||
| } | |||||
| .input, | |||||
| .select, | |||||
| .textarea { | |||||
| font-size: $font-size-md; | |||||
| } | |||||
| // Höhere Inputs (außer Textarea) | |||||
| .input, | |||||
| .select { | |||||
| height: 44px; | |||||
| padding: 0 $space-3; | |||||
| } | |||||
| // Borderless inputs inside the entry form | |||||
| .entry-form .input, | |||||
| .entry-form .select { | |||||
| border: none; | |||||
| box-shadow: none; | |||||
| background-color: $color-bg; | |||||
| &:focus { box-shadow: none; } | |||||
| } | |||||
| // Bemerkung-Zeile: standardmäßig versteckt | |||||
| .entry-form__label--note, | |||||
| .entry-form__field--note { | |||||
| display: none; | |||||
| &.is-visible { display: flex; } | |||||
| } | |||||
| .entry-form__label--note.is-visible { display: block; } | |||||
| // Bemerkung-Toggle-Row: standardmäßig sichtbar (im Standard ausgeblendet) | |||||
| .entry-form__note-toggle-row { display: flex; } | |||||
| // ── Entry-List-Summary ───────────────────────────────────────────────────── | |||||
| .entry-list__summary { display: block; } | |||||
| // Entry List standardmäßig versteckt (JS steuert) | |||||
| .entry-list.is-collapsed { | |||||
| display: none; | |||||
| } | |||||
| .entry-list { | |||||
| box-shadow: none; | |||||
| border: 1px solid $color-border; | |||||
| border-radius: $radius-md; | |||||
| } | |||||
| // ── Unterseiten (Report, Clients, etc.): nur Navigation ─────────────────── | |||||
| // Subpages übernehmen den Hamburger und das weiße Layout automatisch | |||||
| // durch .main-nav { display: none } und .hamburger-nav { display: block } | |||||
| // Report-Seite weißer Hintergrund | |||||
| .report-page, | |||||
| .crud-page, | |||||
| .account-page, | |||||
| .team-page { | |||||
| background: #fff; | |||||
| } | |||||
| } | |||||
| // ─── Hamburger-Nav (immer im DOM, per CSS im Standard versteckt) ────────────── | |||||
| .hamburger-nav { | |||||
| display: none; // Standard: versteckt | |||||
| position: relative; | |||||
| } | |||||
| .hamburger-nav__toggle { | |||||
| width: 40px; | |||||
| height: 40px; | |||||
| border: 1px solid $color-border; | |||||
| border-radius: $radius-md; | |||||
| background: #fff; | |||||
| cursor: pointer; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| box-shadow: $shadow-card; | |||||
| transition: background $transition-fast; | |||||
| &:hover { background: $color-bg; } | |||||
| } | |||||
| // Hamburger-Icon aus drei Balken | |||||
| .hamburger-nav__icon, | |||||
| .hamburger-nav__icon::before, | |||||
| .hamburger-nav__icon::after { | |||||
| display: block; | |||||
| width: 18px; | |||||
| height: 2px; | |||||
| background: $color-text-dark; | |||||
| border-radius: 2px; | |||||
| transition: transform $transition-base, opacity $transition-fast; | |||||
| } | |||||
| .hamburger-nav__icon { | |||||
| position: relative; | |||||
| &::before, | |||||
| &::after { | |||||
| content: ''; | |||||
| position: absolute; | |||||
| left: 0; | |||||
| } | |||||
| &::before { top: -5px; } | |||||
| &::after { top: 5px; } | |||||
| } | |||||
| // X-Icon wenn geöffnet | |||||
| .hamburger-nav__toggle[aria-expanded="true"] { | |||||
| .hamburger-nav__icon { | |||||
| background: transparent; | |||||
| &::before { transform: translateY(5px) rotate(45deg); } | |||||
| &::after { transform: translateY(-5px) rotate(-45deg); } | |||||
| } | |||||
| } | |||||
| .hamburger-nav__panel { | |||||
| position: absolute; | |||||
| top: calc(100% + #{$space-2}); | |||||
| right: 0; | |||||
| min-width: 200px; | |||||
| background: #fff; | |||||
| border: 1px solid $color-border; | |||||
| border-radius: $radius-md; | |||||
| box-shadow: 0 8px 24px rgba(0,0,0,0.12); | |||||
| padding: $space-2 0; | |||||
| z-index: 301; | |||||
| } | |||||
| .hamburger-nav__item { | |||||
| display: block; | |||||
| padding: $space-3 $space-5; | |||||
| font-size: $font-size-base; | |||||
| font-weight: $font-weight-medium; | |||||
| color: $color-text-base; | |||||
| text-decoration: none; | |||||
| transition: background $transition-fast, color $transition-fast; | |||||
| &:hover { background: $color-bg; color: $color-text-dark; } | |||||
| &--active { | |||||
| color: var(--color-primary); | |||||
| font-weight: $font-weight-bold; | |||||
| } | |||||
| } | |||||
| .hamburger-nav__divider { | |||||
| height: 1px; | |||||
| background: $color-border; | |||||
| margin: $space-2 0; | |||||
| } | |||||
| // ─── TT-Header Minimal-Bar (immer im DOM, im Standard ausgeblendet) ─────────── | |||||
| .tt-header__minimal-bar { | |||||
| display: none; // Standard: versteckt, Minimal: sichtbar (override oben) | |||||
| align-items: center; | |||||
| justify-content: space-between; | |||||
| padding: $space-1 0; | |||||
| } | |||||
| .tt-header__minimal-date { | |||||
| font-size: $font-size-md; | |||||
| font-weight: $font-weight-bold; | |||||
| color: $color-text-dark; | |||||
| } | |||||
| .tt-header__week-toggle { | |||||
| background: none; | |||||
| border: 1px solid $color-border; | |||||
| border-radius: $radius-pill; | |||||
| padding: $space-1 $space-3; | |||||
| font-size: $font-size-sm; | |||||
| font-weight: $font-weight-medium; | |||||
| color: $color-text-muted; | |||||
| cursor: pointer; | |||||
| transition: border-color $transition-fast, color $transition-fast; | |||||
| &:hover { border-color: $color-text-muted; color: $color-text-dark; } | |||||
| &[aria-expanded="true"] { color: $color-text-dark; border-color: $color-text-dark; } | |||||
| } | |||||
| // ─── Entry-List-Summary (immer im DOM, im Standard ausgeblendet) ────────────── | |||||
| .entry-list__summary { | |||||
| display: none; // Standard: versteckt | |||||
| } | |||||
| .entry-list__summary-btn { | |||||
| width: 100%; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| gap: $space-2; | |||||
| padding: $space-3 $space-4; | |||||
| background: none; | |||||
| border: 1px solid $color-border; | |||||
| border-radius: $radius-md; | |||||
| font-size: $font-size-base; | |||||
| font-weight: $font-weight-medium; | |||||
| color: $color-text-base; | |||||
| cursor: pointer; | |||||
| transition: background $transition-fast; | |||||
| &:hover { background: $color-bg; } | |||||
| } | |||||
| .entry-list__summary-count { font-weight: $font-weight-bold; color: $color-text-dark; } | |||||
| .entry-list__summary-sep { color: $color-text-light; } | |||||
| .entry-list__summary-total { color: $color-text-muted; } | |||||
| .entry-list__summary-arrow { | |||||
| margin-left: auto; | |||||
| color: $color-text-muted; | |||||
| font-size: $font-size-xs; | |||||
| transition: transform $transition-fast; | |||||
| .entry-list__summary-btn[aria-expanded="true"] & { | |||||
| transform: rotate(180deg); | |||||
| } | |||||
| } | |||||
| // ─── Note-Toggle-Button (immer im DOM, im Standard ausgeblendet) ────────────── | |||||
| .entry-form__note-toggle-row { | |||||
| display: none; // Standard: versteckt, Minimal: flex (override oben) | |||||
| grid-column: 2; | |||||
| } | |||||
| .entry-form__note-toggle { | |||||
| background: none; | |||||
| border: none; | |||||
| padding: 0; | |||||
| font-size: $font-size-sm; | |||||
| color: $color-primary; | |||||
| cursor: pointer; | |||||
| font-weight: $font-weight-medium; | |||||
| transition: color $transition-fast; | |||||
| &:hover { color: var(--color-primary-dark); } | |||||
| &.is-open { color: $color-text-muted; } | |||||
| } | |||||
| // ─── Theme-Picker auf der Account-Seite ────────────────────────────────────── | |||||
| .account-form__divider-row { | |||||
| grid-column: 1 / -1; | |||||
| padding: $space-2 0 $space-4; | |||||
| } | |||||
| .account-form__divider { | |||||
| border: none; | |||||
| border-top: 1px solid $color-border; | |||||
| margin: 0; | |||||
| } | |||||
| .theme-picker { | |||||
| display: flex; | |||||
| gap: $space-3; | |||||
| flex-wrap: wrap; | |||||
| } | |||||
| .theme-option { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| gap: $space-1; | |||||
| padding: $space-4; | |||||
| border: 2px solid $color-border; | |||||
| border-radius: $radius-md; | |||||
| cursor: pointer; | |||||
| min-width: 160px; | |||||
| transition: border-color $transition-fast, background $transition-fast; | |||||
| user-select: none; | |||||
| input[type="radio"] { display: none; } | |||||
| &:hover { border-color: var(--color-primary-light); background: rgba($color-primary, 0.02); } | |||||
| &--active { | |||||
| border-color: var(--color-primary); | |||||
| background: rgba($color-primary, 0.04); | |||||
| } | |||||
| } | |||||
| .theme-option__label { | |||||
| font-size: $font-size-base; | |||||
| font-weight: $font-weight-bold; | |||||
| color: $color-text-dark; | |||||
| } | |||||
| .theme-option__desc { | |||||
| font-size: $font-size-sm; | |||||
| color: $color-text-muted; | |||||
| line-height: 1.4; | |||||
| } | |||||
| @@ -0,0 +1,26 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace DoctrineMigrations; | |||||
| use Doctrine\DBAL\Schema\Schema; | |||||
| use Doctrine\Migrations\AbstractMigration; | |||||
| final class Version20260526120000 extends AbstractMigration | |||||
| { | |||||
| public function getDescription(): string | |||||
| { | |||||
| return 'Add theme preference to user'; | |||||
| } | |||||
| public function up(Schema $schema): void | |||||
| { | |||||
| $this->addSql("ALTER TABLE `user` ADD theme VARCHAR(20) NOT NULL DEFAULT 'standard'"); | |||||
| } | |||||
| public function down(Schema $schema): void | |||||
| { | |||||
| $this->addSql('ALTER TABLE `user` DROP COLUMN theme'); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,26 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace DoctrineMigrations; | |||||
| use Doctrine\DBAL\Schema\Schema; | |||||
| use Doctrine\Migrations\AbstractMigration; | |||||
| final class Version20260526150000 extends AbstractMigration | |||||
| { | |||||
| public function getDescription(): string | |||||
| { | |||||
| return 'Add primary_color to account'; | |||||
| } | |||||
| public function up(Schema $schema): void | |||||
| { | |||||
| $this->addSql("ALTER TABLE `account` ADD primary_color VARCHAR(7) NULL DEFAULT NULL"); | |||||
| } | |||||
| public function down(Schema $schema): void | |||||
| { | |||||
| $this->addSql('ALTER TABLE `account` DROP COLUMN primary_color'); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,33 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace DoctrineMigrations; | |||||
| use Doctrine\DBAL\Schema\Schema; | |||||
| use Doctrine\Migrations\AbstractMigration; | |||||
| /** | |||||
| * Auto-generated Migration: Please modify to your needs! | |||||
| */ | |||||
| final class Version20260526154632 extends AbstractMigration | |||||
| { | |||||
| public function getDescription(): string | |||||
| { | |||||
| return ''; | |||||
| } | |||||
| public function up(Schema $schema): void | |||||
| { | |||||
| // this up() migration is auto-generated, please modify it to your needs | |||||
| $this->addSql('ALTER TABLE account ADD primary_color VARCHAR(7) DEFAULT NULL'); | |||||
| $this->addSql('ALTER TABLE user CHANGE theme theme VARCHAR(20) NOT NULL'); | |||||
| } | |||||
| public function down(Schema $schema): void | |||||
| { | |||||
| // this down() migration is auto-generated, please modify it to your needs | |||||
| $this->addSql('ALTER TABLE account DROP primary_color'); | |||||
| $this->addSql('ALTER TABLE `user` CHANGE theme theme VARCHAR(20) DEFAULT \'standard\' NOT NULL'); | |||||
| } | |||||
| } | |||||
| @@ -5,6 +5,7 @@ namespace App\Controller; | |||||
| use App\Entity\Central\User; | use App\Entity\Central\User; | ||||
| use App\Repository\Central\AccountUserRepository; | use App\Repository\Central\AccountUserRepository; | ||||
| use App\Repository\Central\UserRepository; | use App\Repository\Central\UserRepository; | ||||
| use App\Service\BrandColorService; | |||||
| use App\Service\TenantContext; | use App\Service\TenantContext; | ||||
| use Doctrine\ORM\EntityManagerInterface; | use Doctrine\ORM\EntityManagerInterface; | ||||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | ||||
| @@ -22,6 +23,7 @@ class AccountController extends AbstractController | |||||
| private readonly AccountUserRepository $accountUserRepo, | private readonly AccountUserRepository $accountUserRepo, | ||||
| private readonly UserRepository $userRepo, | private readonly UserRepository $userRepo, | ||||
| private readonly UserPasswordHasherInterface $passwordHasher, | private readonly UserPasswordHasherInterface $passwordHasher, | ||||
| private readonly BrandColorService $brandColorService, | |||||
| ) {} | ) {} | ||||
| #[Route('/account', name: 'account_index')] | #[Route('/account', name: 'account_index')] | ||||
| @@ -49,6 +51,7 @@ class AccountController extends AbstractController | |||||
| 'account' => $account, | 'account' => $account, | ||||
| 'user' => $user, | 'user' => $user, | ||||
| 'isAdmin' => $isAdmin, | 'isAdmin' => $isAdmin, | ||||
| 'isSuperAdmin' => $account->isSuperAdmin($user), | |||||
| 'tab' => $tab, | 'tab' => $tab, | ||||
| 'adminUsers' => $adminUsers, | 'adminUsers' => $adminUsers, | ||||
| 'superAdminUserId' => $account->getSuperAdminUser()?->getId(), | 'superAdminUserId' => $account->getSuperAdminUser()?->getId(), | ||||
| @@ -85,6 +88,14 @@ class AccountController extends AbstractController | |||||
| } | } | ||||
| } | } | ||||
| if (array_key_exists('primaryColor', $data)) { | |||||
| $hex = $data['primaryColor'] === '' ? null : trim($data['primaryColor']); | |||||
| if ($hex !== null && !$this->brandColorService->isValid($hex)) { | |||||
| return $this->json(['error' => 'Ungültiger Hex-Farbwert.'], 422); | |||||
| } | |||||
| $account->setPrimaryColor($hex); | |||||
| } | |||||
| $this->em->flush(); | $this->em->flush(); | ||||
| return $this->json(['ok' => true, 'name' => $account->getName()]); | return $this->json(['ok' => true, 'name' => $account->getName()]); | ||||
| @@ -155,6 +166,10 @@ class AccountController extends AbstractController | |||||
| } | } | ||||
| } | } | ||||
| if (isset($data['theme']) && in_array($data['theme'], ['standard', 'minimal'], true)) { | |||||
| $user->setTheme($data['theme']); | |||||
| } | |||||
| if (!empty($data['newPassword'])) { | if (!empty($data['newPassword'])) { | ||||
| if (empty($data['currentPassword'])) { | if (empty($data['currentPassword'])) { | ||||
| return $this->json(['error' => 'Aktuelles Passwort ist erforderlich.'], 400); | return $this->json(['error' => 'Aktuelles Passwort ist erforderlich.'], 400); | ||||
| @@ -117,12 +117,18 @@ class TimeTrackingController extends AbstractController | |||||
| $service = $this->serviceRepo->find($data['serviceId']); | $service = $this->serviceRepo->find($data['serviceId']); | ||||
| } | } | ||||
| $newDuration = $this->parseDuration($data['duration'] ?? '0'); | |||||
| $currentTotal = $this->timeEntryRepo->sumDurationByDateAndUserId($date, $user->getId()); | |||||
| if ($currentTotal + $newDuration > 1440) { | |||||
| return $this->json(['error' => 'Du kannst nicht mehr als 24 Stunden pro Tag loggen.'], 422); | |||||
| } | |||||
| $entry = new TimeEntry(); | $entry = new TimeEntry(); | ||||
| $entry->setUserId($user->getId()); | $entry->setUserId($user->getId()); | ||||
| $entry->setProject($project); | $entry->setProject($project); | ||||
| $entry->setService($service); | $entry->setService($service); | ||||
| $entry->setDate($date); | $entry->setDate($date); | ||||
| $entry->setDuration($this->parseDuration($data['duration'] ?? '0')); | |||||
| $entry->setDuration($newDuration); | |||||
| $entry->setNote(!empty($data['note']) ? $data['note'] : null); | $entry->setNote(!empty($data['note']) ? $data['note'] : null); | ||||
| $this->tenantEm->persist($entry); | $this->tenantEm->persist($entry); | ||||
| @@ -164,9 +170,15 @@ class TimeTrackingController extends AbstractController | |||||
| $service = $this->serviceRepo->find($data['serviceId']); | $service = $this->serviceRepo->find($data['serviceId']); | ||||
| } | } | ||||
| $newDuration = $this->parseDuration($data['duration'] ?? '0'); | |||||
| $currentTotal = $this->timeEntryRepo->sumDurationByDateAndUserId($entry->getDate(), $entry->getUserId()); | |||||
| if ($currentTotal - $entry->getDuration() + $newDuration > 1440) { | |||||
| return $this->json(['error' => 'Du kannst nicht mehr als 24 Stunden pro Tag loggen.'], 422); | |||||
| } | |||||
| $entry->setProject($project); | $entry->setProject($project); | ||||
| $entry->setService($service); | $entry->setService($service); | ||||
| $entry->setDuration($this->parseDuration($data['duration'] ?? '0')); | |||||
| $entry->setDuration($newDuration); | |||||
| $entry->setNote(!empty($data['note']) ? $data['note'] : null); | $entry->setNote(!empty($data['note']) ? $data['note'] : null); | ||||
| $this->tenantEm->flush(); | $this->tenantEm->flush(); | ||||
| @@ -26,6 +26,9 @@ class Account | |||||
| #[ORM\Column(type: 'smallint', options: ['default' => 1])] | #[ORM\Column(type: 'smallint', options: ['default' => 1])] | ||||
| private int $trackingInterval = 1; | private int $trackingInterval = 1; | ||||
| #[ORM\Column(length: 7, nullable: true)] | |||||
| private ?string $primaryColor = null; | |||||
| #[ORM\Column] | #[ORM\Column] | ||||
| private \DateTimeImmutable $createdAt; | private \DateTimeImmutable $createdAt; | ||||
| @@ -54,6 +57,9 @@ class Account | |||||
| public function getTrackingInterval(): int { return $this->trackingInterval; } | public function getTrackingInterval(): int { return $this->trackingInterval; } | ||||
| public function setTrackingInterval(int $v): static { $this->trackingInterval = $v; return $this; } | public function setTrackingInterval(int $v): static { $this->trackingInterval = $v; return $this; } | ||||
| public function getPrimaryColor(): ?string { return $this->primaryColor; } | |||||
| public function setPrimaryColor(?string $c): static { $this->primaryColor = $c; return $this; } | |||||
| public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } | public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } | ||||
| public function getSuperAdminUser(): ?User { return $this->superAdminUser; } | public function getSuperAdminUser(): ?User { return $this->superAdminUser; } | ||||
| @@ -32,6 +32,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface | |||||
| #[ORM\Column(type: Types::TEXT, nullable: true)] | #[ORM\Column(type: Types::TEXT, nullable: true)] | ||||
| private ?string $note = null; | private ?string $note = null; | ||||
| #[ORM\Column(length: 20)] | |||||
| private string $theme = 'standard'; | |||||
| public function getUserIdentifier(): string { return $this->email; } | public function getUserIdentifier(): string { return $this->email; } | ||||
| public function getRoles(): array { return ['ROLE_USER']; } | public function getRoles(): array { return ['ROLE_USER']; } | ||||
| public function eraseCredentials(): void {} | public function eraseCredentials(): void {} | ||||
| @@ -55,5 +58,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface | |||||
| public function getNote(): ?string { return $this->note; } | public function getNote(): ?string { return $this->note; } | ||||
| public function setNote(?string $note): static { $this->note = $note; return $this; } | public function setNote(?string $note): static { $this->note = $note; return $this; } | ||||
| public function getTheme(): string { return $this->theme; } | |||||
| public function setTheme(string $theme): static { $this->theme = $theme; return $this; } | |||||
| public function __toString(): string { return $this->getFullName(); } | public function __toString(): string { return $this->getFullName(); } | ||||
| } | } | ||||
| @@ -0,0 +1,120 @@ | |||||
| <?php | |||||
| namespace App\Service; | |||||
| /** | |||||
| * Derives a full primary-color palette from a single hex value. | |||||
| * | |||||
| * The caller supplies the "header-to" color (darkest gradient stop, e.g. #3a7bbf). | |||||
| * All other primary variants are derived via HSL offsets matching the default palette. | |||||
| */ | |||||
| class BrandColorService | |||||
| { | |||||
| private const DEFAULT_HEX = '#3a7bbf'; | |||||
| /** Returns null when the hex equals the built-in default (no override needed). */ | |||||
| public function paletteOrNull(?string $hex): ?array | |||||
| { | |||||
| if ($hex === null || !$this->isValid($hex)) { | |||||
| return null; | |||||
| } | |||||
| if (strtolower($hex) === strtolower(self::DEFAULT_HEX)) { | |||||
| return null; | |||||
| } | |||||
| return $this->compute($hex); | |||||
| } | |||||
| public function compute(string $hex): array | |||||
| { | |||||
| [$h, $s, $l] = $this->hexToHsl($hex); | |||||
| [$r, $g, $b] = $this->hexToRgb($hex); | |||||
| return [ | |||||
| 'headerTo' => $hex, | |||||
| 'headerFrom' => $this->hslToHex($h, max(0, $s - 4), $this->clamp($l + 13)), | |||||
| 'primary' => $this->hslToHex($h, $s, $this->clamp($l + 9)), | |||||
| 'primaryDark' => $this->hslToHex($h, $s, $this->clamp($l - 3)), | |||||
| 'primaryLight' => $this->hslToHex($h, max(0, $s - 5), $this->clamp($l + 20)), | |||||
| 'bg' => $this->hslToHex($h, max(0, $s - 30), $this->clamp($l + 40)), | |||||
| 'rgb' => "$r, $g, $b", | |||||
| ]; | |||||
| } | |||||
| public function isValid(string $hex): bool | |||||
| { | |||||
| return (bool) preg_match('/^#[0-9a-fA-F]{6}$/', $hex); | |||||
| } | |||||
| // ── Internal helpers ────────────────────────────────────────────────────── | |||||
| private function hexToRgb(string $hex): array | |||||
| { | |||||
| $hex = ltrim($hex, '#'); | |||||
| return [ | |||||
| hexdec(substr($hex, 0, 2)), | |||||
| hexdec(substr($hex, 2, 2)), | |||||
| hexdec(substr($hex, 4, 2)), | |||||
| ]; | |||||
| } | |||||
| /** Returns [h°, s%, l%]. */ | |||||
| private function hexToHsl(string $hex): array | |||||
| { | |||||
| [$r, $g, $b] = $this->hexToRgb($hex); | |||||
| $r /= 255; $g /= 255; $b /= 255; | |||||
| $max = max($r, $g, $b); | |||||
| $min = min($r, $g, $b); | |||||
| $l = ($max + $min) / 2; | |||||
| if ($max === $min) { | |||||
| return [0, 0, round($l * 100, 2)]; | |||||
| } | |||||
| $d = $max - $min; | |||||
| $s = $l > 0.5 ? $d / (2 - $max - $min) : $d / ($max + $min); | |||||
| $h = match ($max) { | |||||
| $r => (($g - $b) / $d + ($g < $b ? 6 : 0)) / 6, | |||||
| $g => (($b - $r) / $d + 2) / 6, | |||||
| default => (($r - $g) / $d + 4) / 6, | |||||
| }; | |||||
| return [round($h * 360, 2), round($s * 100, 2), round($l * 100, 2)]; | |||||
| } | |||||
| private function hslToHex(float $h, float $s, float $l): string | |||||
| { | |||||
| $h /= 360; $s /= 100; $l /= 100; | |||||
| if ($s == 0) { | |||||
| $v = (int) round($l * 255); | |||||
| return sprintf('#%02x%02x%02x', $v, $v, $v); | |||||
| } | |||||
| $q = $l < 0.5 ? $l * (1 + $s) : $l + $s - $l * $s; | |||||
| $p = 2 * $l - $q; | |||||
| return sprintf( | |||||
| '#%02x%02x%02x', | |||||
| (int) round($this->hue2rgb($p, $q, $h + 1 / 3) * 255), | |||||
| (int) round($this->hue2rgb($p, $q, $h) * 255), | |||||
| (int) round($this->hue2rgb($p, $q, $h - 1 / 3) * 255), | |||||
| ); | |||||
| } | |||||
| private function hue2rgb(float $p, float $q, float $t): float | |||||
| { | |||||
| if ($t < 0) $t += 1; | |||||
| if ($t > 1) $t -= 1; | |||||
| if ($t < 1 / 6) return $p + ($q - $p) * 6 * $t; | |||||
| if ($t < 1 / 2) return $q; | |||||
| if ($t < 2 / 3) return $p + ($q - $p) * (2 / 3 - $t) * 6; | |||||
| return $p; | |||||
| } | |||||
| private function clamp(float $v): float | |||||
| { | |||||
| return max(0, min(100, $v)); | |||||
| } | |||||
| } | |||||
| @@ -3,6 +3,7 @@ | |||||
| namespace App\Twig; | namespace App\Twig; | ||||
| use App\Service\AccountRoleHelper; | use App\Service\AccountRoleHelper; | ||||
| use App\Service\BrandColorService; | |||||
| use App\Service\TenantContext; | use App\Service\TenantContext; | ||||
| use Twig\Extension\AbstractExtension; | use Twig\Extension\AbstractExtension; | ||||
| use Twig\TwigFilter; | use Twig\TwigFilter; | ||||
| @@ -13,6 +14,7 @@ class AppExtension extends AbstractExtension | |||||
| public function __construct( | public function __construct( | ||||
| private readonly AccountRoleHelper $roleHelper, | private readonly AccountRoleHelper $roleHelper, | ||||
| private readonly TenantContext $tenantContext, | private readonly TenantContext $tenantContext, | ||||
| private readonly BrandColorService $brandColorService, | |||||
| ) {} | ) {} | ||||
| public function getFilters(): array | public function getFilters(): array | ||||
| @@ -32,9 +34,17 @@ class AppExtension extends AbstractExtension | |||||
| new TwigFunction('isCurrentUserAdmin', [$this, 'isCurrentUserAdmin']), | new TwigFunction('isCurrentUserAdmin', [$this, 'isCurrentUserAdmin']), | ||||
| new TwigFunction('isCurrentUserMemberOrAdmin', [$this, 'isCurrentUserMemberOrAdmin']), | new TwigFunction('isCurrentUserMemberOrAdmin', [$this, 'isCurrentUserMemberOrAdmin']), | ||||
| new TwigFunction('getCurrentUserRole', [$this, 'getCurrentUserRole']), | new TwigFunction('getCurrentUserRole', [$this, 'getCurrentUserRole']), | ||||
| new TwigFunction('brandPalette', [$this, 'brandPalette']), | |||||
| ]; | ]; | ||||
| } | } | ||||
| /** Returns the derived color palette, or null when the account uses the built-in default. */ | |||||
| public function brandPalette(): ?array | |||||
| { | |||||
| $hex = $this->tenantContext->getAccount()?->getPrimaryColor(); | |||||
| return $this->brandColorService->paletteOrNull($hex); | |||||
| } | |||||
| public function isCurrentUserAdmin(): bool { return $this->roleHelper->isAdmin(); } | public function isCurrentUserAdmin(): bool { return $this->roleHelper->isAdmin(); } | ||||
| public function isCurrentUserMemberOrAdmin(): bool { return $this->roleHelper->isMemberOrAdmin(); } | public function isCurrentUserMemberOrAdmin(): bool { return $this->roleHelper->isMemberOrAdmin(); } | ||||
| public function getCurrentUserRole(): string { return $this->roleHelper->getCurrentAccountUser()?->getRole() ?? ''; } | public function getCurrentUserRole(): string { return $this->roleHelper->getCurrentAccountUser()?->getRole() ?? ''; } | ||||
| @@ -42,3 +42,48 @@ | |||||
| </a> | </a> | ||||
| </div> | </div> | ||||
| </nav> | </nav> | ||||
| {# Hamburger-Navigation — nur im Minimal-Theme sichtbar (via CSS) #} | |||||
| <div class="hamburger-nav" id="hamburger-nav"> | |||||
| <button class="hamburger-nav__toggle" id="hamburger-toggle" aria-label="Menü öffnen" aria-expanded="false"> | |||||
| <span class="hamburger-nav__icon"></span> | |||||
| </button> | |||||
| <div class="hamburger-nav__panel" id="hamburger-panel" hidden> | |||||
| <a href="{{ path('timetracking_week') }}" | |||||
| class="hamburger-nav__item{% if currentRoute starts with 'timetracking' %} hamburger-nav__item--active{% endif %}"> | |||||
| {{ 'app.nav.time_tracking'|trans }} | |||||
| </a> | |||||
| <a href="{{ path('report_times') }}" | |||||
| class="hamburger-nav__item{% if currentRoute starts with 'report' %} hamburger-nav__item--active{% endif %}"> | |||||
| {{ 'app.nav.reports'|trans }} | |||||
| </a> | |||||
| {% if isCurrentUserMemberOrAdmin() %} | |||||
| <a href="{{ path('client_index') }}" | |||||
| class="hamburger-nav__item{% if currentRoute starts with 'client' %} hamburger-nav__item--active{% endif %}"> | |||||
| {{ 'app.nav.clients'|trans }} | |||||
| </a> | |||||
| <a href="{{ path('project_index') }}" | |||||
| class="hamburger-nav__item{% if currentRoute starts with 'project' %} hamburger-nav__item--active{% endif %}"> | |||||
| {{ 'app.nav.projects'|trans }} | |||||
| </a> | |||||
| <a href="{{ path('service_index') }}" | |||||
| class="hamburger-nav__item{% if currentRoute starts with 'service' %} hamburger-nav__item--active{% endif %}"> | |||||
| {{ 'app.nav.services'|trans }} | |||||
| </a> | |||||
| {% endif %} | |||||
| {% if isCurrentUserAdmin() %} | |||||
| <a href="{{ path('team_index') }}" | |||||
| class="hamburger-nav__item{% if currentRoute starts with 'team' %} hamburger-nav__item--active{% endif %}"> | |||||
| {{ 'app.nav.team'|trans }} | |||||
| </a> | |||||
| {% endif %} | |||||
| <div class="hamburger-nav__divider"></div> | |||||
| <a href="{{ path('account_index', {tab: 'user'}) }}" | |||||
| class="hamburger-nav__item{% if currentRoute starts with 'account' %} hamburger-nav__item--active{% endif %}"> | |||||
| {{ 'app.nav.account'|trans }} | |||||
| </a> | |||||
| <a href="{{ path('app_logout') }}" class="hamburger-nav__item"> | |||||
| {{ 'app.nav.logout'|trans }} | |||||
| </a> | |||||
| </div> | |||||
| </div> | |||||
| @@ -4,6 +4,27 @@ | |||||
| prevWeekUrl, nextWeekUrl #} | prevWeekUrl, nextWeekUrl #} | ||||
| <header class="tt-header"> | <header class="tt-header"> | ||||
| {# Minimal-Modus: kompakter Header mit Toggle #} | |||||
| <div class="tt-header__minimal-bar"> | |||||
| <div class="tt-header__minimal-date"> | |||||
| {% set activStr = currentDate|date('Y-m-d') %} | |||||
| {% set monthName = months[currentDate|date('n') - 1] %} | |||||
| {% set weekdayIdx = currentDate|date('N') - 1 %} | |||||
| {% if activStr == todayStr %} | |||||
| {{ 'app.date.today'|trans }}, {{ currentDate|date('j') }}. {{ monthName }} | |||||
| {% elseif activStr == tomorrowStr %} | |||||
| {{ 'app.date.tomorrow'|trans }}, {{ currentDate|date('j') }}. {{ monthName }} | |||||
| {% elseif activStr == yesterdayStr %} | |||||
| {{ 'app.date.yesterday'|trans }}, {{ currentDate|date('j') }}. {{ monthName }} | |||||
| {% else %} | |||||
| {{ weekdays[weekdayIdx] }}, {{ currentDate|date('j') }}. {{ monthName }} | |||||
| {% endif %} | |||||
| </div> | |||||
| <button type="button" class="tt-header__week-toggle" id="btn-week-toggle" aria-expanded="false" title="Wochenansicht"> | |||||
| KW {{ currentWeekNumber }} ▾ | |||||
| </button> | |||||
| </div> | |||||
| <div class="tt-header__meta"> | <div class="tt-header__meta"> | ||||
| <div class="tt-header__date"> | <div class="tt-header__date"> | ||||
| {% set activStr = currentDate|date('Y-m-d') %} | {% set activStr = currentDate|date('Y-m-d') %} | ||||
| @@ -22,6 +43,7 @@ | |||||
| <div class="tt-header__kw">{{ 'app.date.week_label'|trans }} {{ currentWeekNumber }}</div> | <div class="tt-header__kw">{{ 'app.date.week_label'|trans }} {{ currentWeekNumber }}</div> | ||||
| </div> | </div> | ||||
| <div class="tt-header__collapsible" id="tt-header-collapsible"> | |||||
| <nav class="week-nav" | <nav class="week-nav" | ||||
| aria-label="{{ 'app.date.week_label'|trans }}" | aria-label="{{ 'app.date.week_label'|trans }}" | ||||
| data-active-date="{{ currentDate|date('Y-m-d') }}"> | data-active-date="{{ currentDate|date('Y-m-d') }}"> | ||||
| @@ -56,4 +78,5 @@ | |||||
| </a> | </a> | ||||
| </nav> | </nav> | ||||
| </div>{# /.tt-header__collapsible #} | |||||
| </header> | </header> | ||||
| @@ -10,7 +10,8 @@ | |||||
| <script> | <script> | ||||
| window.ACCOUNT = { | window.ACCOUNT = { | ||||
| tab: '{{ tab }}', | tab: '{{ tab }}', | ||||
| isSuperAdmin: {{ superAdminUserId is not null and superAdminUserId == user.id ? 'true' : 'false' }}, | |||||
| isSuperAdmin: {{ isSuperAdmin ? 'true' : 'false' }}, | |||||
| theme: '{{ user.theme|default('standard') }}', | |||||
| }; | }; | ||||
| </script> | </script> | ||||
| @@ -64,6 +65,22 @@ | |||||
| <span class="account-form__hint">Auf welche Einheit werden erfasste Zeiten aufgerundet.</span> | <span class="account-form__hint">Auf welche Einheit werden erfasste Zeiten aufgerundet.</span> | ||||
| </div> | </div> | ||||
| {% if isSuperAdmin %} | |||||
| <label class="account-form__label" for="account-color">Hauptfarbe</label> | |||||
| <div class="account-form__field"> | |||||
| <div class="account-color-field"> | |||||
| <input type="color" id="account-color-picker" | |||||
| value="{{ account.primaryColor ?? '#3a7bbf' }}" | |||||
| class="account-color-field__swatch" /> | |||||
| <input type="text" id="account-color" | |||||
| value="{{ account.primaryColor ?? '#3a7bbf' }}" | |||||
| class="input account-color-field__hex" | |||||
| maxlength="7" placeholder="#3a7bbf" autocomplete="off" /> | |||||
| </div> | |||||
| <span class="account-form__hint">Hex-Farbe für das Standard-Theme aller Benutzer. Standard: #3a7bbf</span> | |||||
| </div> | |||||
| {% endif %} | |||||
| <div class="account-form__actions"> | <div class="account-form__actions"> | ||||
| <button type="button" class="btn btn-primary" id="btn-account-save">Sichern</button> | <button type="button" class="btn btn-primary" id="btn-account-save">Sichern</button> | ||||
| <a href="{{ path('account_index', {tab: 'account'}) }}" class="btn btn-secondary">Abbrechen</a> | <a href="{{ path('account_index', {tab: 'account'}) }}" class="btn btn-secondary">Abbrechen</a> | ||||
| @@ -125,6 +142,31 @@ | |||||
| </div> | </div> | ||||
| {# ── Darstellung ───────────────────────────────────────────────────── #} | |||||
| <div class="account-form__grid account-form__grid--appearance" id="appearance-form"> | |||||
| <div class="account-form__divider-row"> | |||||
| <hr class="account-form__divider"> | |||||
| </div> | |||||
| <label class="account-form__label">Darstellung</label> | |||||
| <div class="account-form__field"> | |||||
| <div class="theme-picker" id="theme-picker"> | |||||
| <label class="theme-option{% if user.theme|default('standard') == 'standard' %} theme-option--active{% endif %}" data-theme="standard"> | |||||
| <input type="radio" name="theme" value="standard"{% if user.theme|default('standard') == 'standard' %} checked{% endif %}> | |||||
| <span class="theme-option__label">Standard</span> | |||||
| <span class="theme-option__desc">Volle Navigation, alle Felder sichtbar</span> | |||||
| </label> | |||||
| <label class="theme-option{% if user.theme|default('standard') == 'minimal' %} theme-option--active{% endif %}" data-theme="minimal"> | |||||
| <input type="radio" name="theme" value="minimal"{% if user.theme|default('standard') == 'minimal' %} checked{% endif %}> | |||||
| <span class="theme-option__label">Minimal</span> | |||||
| <span class="theme-option__desc">Ablenkungsfreie Ansicht, Hamburger-Menü</span> | |||||
| </label> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| {% endif %} | {% endif %} | ||||
| </div> | </div> | ||||
| @@ -8,6 +8,21 @@ | |||||
| {{ encore_entry_link_tags('app') }} | {{ encore_entry_link_tags('app') }} | ||||
| {% endblock %} | {% endblock %} | ||||
| {% set _bp = brandPalette() %} | |||||
| {% if _bp %} | |||||
| <style> | |||||
| :root { | |||||
| --color-primary: {{ _bp.primary }}; | |||||
| --color-primary-dark: {{ _bp.primaryDark }}; | |||||
| --color-primary-light: {{ _bp.primaryLight }}; | |||||
| --color-header-from: {{ _bp.headerFrom }}; | |||||
| --color-header-to: {{ _bp.headerTo }}; | |||||
| --color-bg: {{ _bp.bg }}; | |||||
| --color-primary-rgb: {{ _bp.rgb }}; | |||||
| } | |||||
| </style> | |||||
| {% endif %} | |||||
| {% block javascripts %} | {% block javascripts %} | ||||
| {{ encore_entry_script_tags('app') }} | {{ encore_entry_script_tags('app') }} | ||||
| {% endblock %} | {% endblock %} | ||||
| @@ -19,7 +34,7 @@ | |||||
| <script src="https://cdn.jsdelivr.net/npm/frankenphp-hot-reload/+esm" type="module"></script> | <script src="https://cdn.jsdelivr.net/npm/frankenphp-hot-reload/+esm" type="module"></script> | ||||
| {% endif %} | {% endif %} | ||||
| </head> | </head> | ||||
| <body> | |||||
| <body data-theme="{{ app.user is not null ? app.user.theme|default('standard') : 'standard' }}"> | |||||
| {% include '_sections/nav.html.twig' %} | {% include '_sections/nav.html.twig' %} | ||||
| {% block body %}{% endblock %} | {% block body %}{% endblock %} | ||||
| </body> | </body> | ||||
| @@ -73,6 +73,7 @@ window.TT = { | |||||
| durationHint: {{ 'app.entry.duration_hint'|trans|json_encode|raw }}, | durationHint: {{ 'app.entry.duration_hint'|trans|json_encode|raw }}, | ||||
| errorZeroDuration: {{ 'app.entry.error_zero_duration'|trans|json_encode|raw }}, | errorZeroDuration: {{ 'app.entry.error_zero_duration'|trans|json_encode|raw }}, | ||||
| errorDurationTooLong: {{ 'app.entry.error_duration_too_long'|trans|json_encode|raw }}, | errorDurationTooLong: {{ 'app.entry.error_duration_too_long'|trans|json_encode|raw }}, | ||||
| errorDailyLimitExceeded: {{ 'app.entry.error_daily_limit_exceeded'|trans|json_encode|raw }}, | |||||
| warnDurationLong: {{ 'app.entry.warn_duration_long'|trans|json_encode|raw }}, | warnDurationLong: {{ 'app.entry.warn_duration_long'|trans|json_encode|raw }}, | ||||
| invoicedTitle: {{ 'app.entry.invoiced_title'|trans|json_encode|raw }}, | invoicedTitle: {{ 'app.entry.invoiced_title'|trans|json_encode|raw }}, | ||||
| }, | }, | ||||
| @@ -98,8 +99,8 @@ window.TT = { | |||||
| {% include '_atoms/duration-help.html.twig' %} | {% include '_atoms/duration-help.html.twig' %} | ||||
| </div> | </div> | ||||
| <label class="entry-form__label">{{ 'app.entry.label_project_service'|trans }}</label> | |||||
| <div class="entry-form__field entry-form__field--selects"> | |||||
| <label class="entry-form__label">{{ 'app.entry.label_project'|trans }}</label> | |||||
| <div class="entry-form__field"> | |||||
| <select id="create-project" class="select"> | <select id="create-project" class="select"> | ||||
| <option value="">{{ 'app.entry.select_placeholder'|trans }}</option> | <option value="">{{ 'app.entry.select_placeholder'|trans }}</option> | ||||
| {% set currentClient = null %} | {% set currentClient = null %} | ||||
| @@ -113,6 +114,10 @@ window.TT = { | |||||
| {% endfor %} | {% endfor %} | ||||
| {% if currentClient is not null %}</optgroup>{% endif %} | {% if currentClient is not null %}</optgroup>{% endif %} | ||||
| </select> | </select> | ||||
| </div> | |||||
| <label class="entry-form__label">{{ 'app.entry.label_service'|trans }}</label> | |||||
| <div class="entry-form__field"> | |||||
| <select id="create-service" class="select"> | <select id="create-service" class="select"> | ||||
| <option value="">{{ 'app.entry.select_placeholder'|trans }}</option> | <option value="">{{ 'app.entry.select_placeholder'|trans }}</option> | ||||
| {% set currentGroup = null %} | {% set currentGroup = null %} | ||||
| @@ -129,12 +134,19 @@ window.TT = { | |||||
| </select> | </select> | ||||
| </div> | </div> | ||||
| <label class="entry-form__label">{{ 'app.entry.label_note'|trans }}</label> | |||||
| <div class="entry-form__field"> | |||||
| <label class="entry-form__label entry-form__label--note">{{ 'app.entry.label_note'|trans }}</label> | |||||
| <div class="entry-form__field entry-form__field--note"> | |||||
| <textarea id="create-note" class="textarea" rows="3" | <textarea id="create-note" class="textarea" rows="3" | ||||
| placeholder="{{ 'app.entry.placeholder_note'|trans }}"></textarea> | placeholder="{{ 'app.entry.placeholder_note'|trans }}"></textarea> | ||||
| </div> | </div> | ||||
| {# Minimal-Modus: Bemerkung-Toggle (nur via CSS/JS sichtbar) #} | |||||
| <div class="entry-form__note-toggle-row"> | |||||
| <button type="button" class="entry-form__note-toggle" id="btn-note-toggle"> | |||||
| + Bemerkung hinzufügen | |||||
| </button> | |||||
| </div> | |||||
| <div class="entry-form__actions"> | <div class="entry-form__actions"> | ||||
| <button type="button" class="btn btn-primary" id="btn-create"> | <button type="button" class="btn btn-primary" id="btn-create"> | ||||
| {{ 'app.entry.btn_create'|trans }} | {{ 'app.entry.btn_create'|trans }} | ||||
| @@ -144,6 +156,18 @@ window.TT = { | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| {# Minimal-Modus: Summary-Zeile zum Aufklappen #} | |||||
| {% if timeEntries is not empty %} | |||||
| <div class="entry-list__summary" id="entry-list-summary"> | |||||
| <button type="button" class="entry-list__summary-btn" id="btn-entries-toggle"> | |||||
| <span class="entry-list__summary-count">{{ timeEntries|length }} {{ timeEntries|length == 1 ? 'Eintrag' : 'Einträge' }}</span> | |||||
| <span class="entry-list__summary-sep">·</span> | |||||
| <span class="entry-list__summary-total">{{ totalDuration }}</span> | |||||
| <span class="entry-list__summary-arrow">▾</span> | |||||
| </button> | |||||
| </div> | |||||
| {% endif %} | |||||
| <div class="entry-list" id="entry-list" data-date="{{ currentDate|date('Y-m-d') }}"> | <div class="entry-list" id="entry-list" data-date="{{ currentDate|date('Y-m-d') }}"> | ||||
| {% if timeEntries is empty %} | {% if timeEntries is empty %} | ||||
| <div class="empty-state" id="empty-state"> | <div class="empty-state" id="empty-state"> | ||||
| @@ -26,6 +26,8 @@ app: | |||||
| entry: | entry: | ||||
| label_duration: "Dauer" | label_duration: "Dauer" | ||||
| label_project_service: "Projekt / Leistung" | label_project_service: "Projekt / Leistung" | ||||
| label_project: "Projekt" | |||||
| label_service: "Leistung" | |||||
| label_note: "Bemerkung" | label_note: "Bemerkung" | ||||
| placeholder_note: "Optionale Beschreibung …" | placeholder_note: "Optionale Beschreibung …" | ||||
| placeholder_duration_hint: "Format: 1:30 oder 1.5" | placeholder_duration_hint: "Format: 1:30 oder 1.5" | ||||
| @@ -44,6 +46,7 @@ app: | |||||
| duration_hint: "1:30 für 1 Std 30 Min · 8 12 für 8 bis 12 Uhr · 1,75 für 1 Std 45 Min · 0:00 zum Stoppen" | duration_hint: "1:30 für 1 Std 30 Min · 8 12 für 8 bis 12 Uhr · 1,75 für 1 Std 45 Min · 0:00 zum Stoppen" | ||||
| error_zero_duration: "Bitte eine Dauer größer als 0:00 eingeben." | error_zero_duration: "Bitte eine Dauer größer als 0:00 eingeben." | ||||
| error_duration_too_long: "Eine Dauer von mehr als 24 Stunden ist nicht möglich." | error_duration_too_long: "Eine Dauer von mehr als 24 Stunden ist nicht möglich." | ||||
| error_daily_limit_exceeded: "Du kannst nicht mehr als 24 Stunden pro Tag loggen." | |||||
| warn_duration_long: "Die Dauer ist länger als 8 Stunden. Wirklich speichern?" | warn_duration_long: "Die Dauer ist länger als 8 Stunden. Wirklich speichern?" | ||||
| service: | service: | ||||