| @@ -15,7 +15,7 @@ Technisch ähnlich wie **mite.de** – wir orientieren uns stark am UI/UX von mi | |||
| ## Tech Stack | |||
| - **Backend**: Symfony 7, PHP 8.2, Doctrine ORM, MariaDB | |||
| - **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 | |||
| - **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` | |||
| - `id`, `email` (unique), `firstName`, `lastName`, `password` (nullable), `note` | |||
| - `theme` (VARCHAR 20, default `'standard'`) – Darstellungs-Theme des Users | |||
| - Implementiert `UserInterface`, `PasswordAuthenticatedUserInterface` | |||
| - `getFullName()` → `"Flo Eisenmenger"` | |||
| #### `Account` | |||
| - `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) | |||
| - `accountUsers` (OneToMany → AccountUser) | |||
| - `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 /logout` → `app_logout` | |||
| - `GET /invite/{token}` → `app_invite` (Passwort setzen nach Einladung) | |||
| - `GET /password-reset` → Passwort-Reset anfordern | |||
| - `GET /password-reset/{token}` → Neues Passwort setzen | |||
| ### Timetracking | |||
| - `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 | |||
| - `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/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 | |||
| - `RegistrationService` – `startRegistration()`, `confirm(token)` – erstellt Account + DB + User | |||
| - `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 | |||
| --- | |||
| ## 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 | |||
| ``` | |||
| 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/ | |||
| │ └── index.html.twig ← Landing Page (Hauptdomain ohne Subdomain) | |||
| │ └── index.html.twig ← Landing Page (Hauptdomain ohne Subdomain) | |||
| ├── security/ | |||
| │ └── login.html.twig | |||
| ├── registration/ | |||
| │ ├── register.html.twig | |||
| │ ├── confirmed.html.twig ← Nach E-Mail-Bestätigung | |||
| │ ├── confirmed.html.twig ← Nach E-Mail-Bestätigung | |||
| │ └── confirm_error.html.twig | |||
| ├── invite/ | |||
| │ ├── set_password.html.twig ← Passwort setzen nach Einladung | |||
| │ ├── set_password.html.twig ← Passwort setzen nach Einladung | |||
| │ └── error.html.twig | |||
| ├── email/ | |||
| │ ├── team_invite.html.twig | |||
| @@ -183,7 +223,7 @@ templates/ | |||
| │ ├── registration_confirm.html.twig | |||
| │ └── registration_notify.html.twig | |||
| ├── timetracking/ | |||
| │ ├── week.html.twig ← Hauptseite Zeiterfassung | |||
| │ ├── week.html.twig ← Hauptseite Zeiterfassung | |||
| │ └── _entry_row.html.twig | |||
| ├── client/ | |||
| │ └── index.html.twig | |||
| @@ -192,11 +232,11 @@ templates/ | |||
| ├── service/ | |||
| │ └── index.html.twig | |||
| ├── team/ | |||
| │ └── index.html.twig ← Team-Verwaltung (nur Admins) | |||
| │ └── index.html.twig ← Team-Verwaltung (nur Admins) | |||
| ├── account/ | |||
| │ └── index.html.twig ← Account- + Profil-Einstellungen (Tabs) | |||
| │ └── index.html.twig ← Account- + Profil-Einstellungen (Tabs) | |||
| └── report/ | |||
| └── times.html.twig ← Zeiteinträge-Report | |||
| └── times.html.twig ← Zeiteinträge-Report | |||
| ``` | |||
| --- | |||
| @@ -207,7 +247,7 @@ templates/ | |||
| assets/styles/ | |||
| ├── main.scss ← Entry Point | |||
| ├── atoms/ | |||
| │ ├── _variables.scss | |||
| │ ├── _variables.scss ← SCSS-Vars + :root CSS Custom Properties | |||
| │ ├── _typography.scss | |||
| │ ├── _buttons.scss ← .btn, .btn-primary, .btn-secondary | |||
| │ └── _inputs.scss ← .input, .select, .textarea | |||
| @@ -225,9 +265,12 @@ assets/styles/ | |||
| │ ├── _team.scss | |||
| │ ├── _account.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 | |||
| └── scripts/ | |||
| ├── calendar.js ← WeekCalendar (Wochennavigation, Monatsansicht) | |||
| │ Positioniert Monatskalender relativ zum Cal-Icon | |||
| ├── entries.js ← EntryManager (CRUD, fetch, localStorage) | |||
| │ + initMinimalMode() (WeekToggle, NoteToggle, EntriesToggle) | |||
| │ + window._updateWeekToggle(kw) für KW-Sync bei Navigation | |||
| ├── duration.js ← parseDuration(), roundToQuarter(), formatMinutes(), | |||
| │ validateDuration(), initDurationBlurHandler() | |||
| ├── nav.js ← Hamburger-Navigation (Minimal-Theme) | |||
| ├── crud.js ← Entry: generisches CRUD (Kunden/Projekte/Leistungen) | |||
| ├── registration.js ← Entry: Registrierungs-Flow, Live-Slug-Vorschau | |||
| ├── team.js ← Entry: Team-Verwaltung | |||
| ├── account.js ← Entry: Account- + Profil-Einstellungen | |||
| │ Farbfeld: Picker ↔ Hex-Input synchron, | |||
| │ Theme-Picker, Passwort-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 | |||
| - [ ] Filter auf Report-Seite (Datumsbereich, Projekt, Service, User) | |||
| - [ ] Export (CSV / PDF) | |||
| - [ ] Timer-Funktion (Live-Zeiterfassung) | |||
| - [ ] Wochenübersicht mit Summen pro Tag (im Wochenkalender) | |||
| - [ ] 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` | |||
| - MariaDB: User `db`, Passwort `db`, Central-DB `db` | |||
| - `.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) | |||
| import './styles/main.scss'; | |||
| import './scripts/nav.js'; | |||
| import './scripts/calendar.js'; | |||
| import './scripts/entries.js'; | |||
| @@ -21,16 +21,42 @@ document.addEventListener('DOMContentLoaded', () => { | |||
| 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 ────────────────────────────────────────────────────── | |||
| const btnAccountSave = document.getElementById('btn-account-save'); | |||
| if (btnAccountSave) { | |||
| 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 { | |||
| 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) { | |||
| 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 ───────────────────────────────────────────────────── | |||
| const btnUserSave = document.getElementById('btn-user-save'); | |||
| if (btnUserSave) { | |||
| @@ -136,6 +136,7 @@ class WeekCalendar { | |||
| } | |||
| if (kwEl) kwEl.textContent = `${t('weekLabel')} ${this.getWeekNumber(this.activeDate)}`; | |||
| window._updateWeekToggle?.(this.getWeekNumber(this.activeDate)); | |||
| } | |||
| // ── Monats-Ansicht ──────────────────────────────────────────────────────── | |||
| @@ -147,6 +148,12 @@ class WeekCalendar { | |||
| this.monthDate = new Date(this.activeDate); | |||
| this.monthEl = document.createElement('div'); | |||
| 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.renderMonthGrid(); | |||
| requestAnimationFrame(() => requestAnimationFrame(() => { | |||
| @@ -208,6 +208,8 @@ class EntryManager { | |||
| if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; } | |||
| if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return; | |||
| if (getDailyTotalMinutes() + rawMinutes > 1440) { alert(t('errorDailyLimitExceeded')); return; } | |||
| try { | |||
| const res = await fetch('/api/entries', { | |||
| method: 'POST', | |||
| @@ -321,6 +323,9 @@ class EntryManager { | |||
| if (validation.status === 'error') { alert(t('errorDurationTooLong')); 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 { | |||
| const res = await fetch(`/api/entries/${id}`, { | |||
| 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) { | |||
| if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId); | |||
| } | |||
| @@ -477,8 +490,89 @@ function getLastService() { | |||
| 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; | |||
| document.addEventListener('DOMContentLoaded', () => { | |||
| initDurationBlurHandler(); | |||
| initMinimalMode(); | |||
| 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(); | |||
| @@ -423,6 +427,9 @@ class ReportFilter { | |||
| row.querySelectorAll('.filter-select').forEach(sel => { | |||
| 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') { | |||
| const val = this.periodSel?.value; | |||
| @@ -445,6 +452,9 @@ class ReportFilter { | |||
| 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') { | |||
| const val = row.querySelector('.filter-note-input')?.value?.trim(); | |||
| @@ -26,7 +26,7 @@ | |||
| } | |||
| &:focus-visible { | |||
| outline: 2px solid $color-primary; | |||
| outline: 2px solid var(--color-primary); | |||
| outline-offset: 3px; | |||
| } | |||
| } | |||
| @@ -26,7 +26,7 @@ | |||
| &:focus { | |||
| outline: none; | |||
| border-color: $color-primary; | |||
| border-color: var(--color-primary); | |||
| box-shadow: $shadow-focus; | |||
| } | |||
| } | |||
| @@ -48,7 +48,7 @@ | |||
| cursor: pointer; | |||
| &:hover { | |||
| border-color: $color-primary-light; | |||
| border-color: var(--color-primary-light); | |||
| } | |||
| } | |||
| @@ -83,7 +83,7 @@ | |||
| transition: border-color $transition-fast, color $transition-fast; | |||
| &:hover { | |||
| border-color: $color-primary; | |||
| color: $color-primary; | |||
| border-color: var(--color-primary); | |||
| color: var(--color-primary); | |||
| } | |||
| } | |||
| @@ -1,4 +1,5 @@ | |||
| // ─── Color Palette ─────────────────────────────────────────────────────────── | |||
| // Compile-time values (used in rgba() functions; keep as hex) | |||
| $color-primary: #4a90d9; | |||
| $color-primary-dark: #3178b8; | |||
| $color-primary-light: #6aaee8; | |||
| @@ -11,6 +12,17 @@ $color-accent-light: #f5bc3a; | |||
| $color-white: #ffffff; | |||
| $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-white: #ffffff; | |||
| @@ -3,14 +3,14 @@ | |||
| // ─── Page ───────────────────────────────────────────────────────────────────── | |||
| .account-page { | |||
| min-height: 100vh; | |||
| background: $color-bg; | |||
| background: var(--color-bg); | |||
| display: flex; | |||
| flex-direction: column; | |||
| } | |||
| // ─── 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; | |||
| display: flex; | |||
| align-items: center; | |||
| @@ -114,12 +114,36 @@ | |||
| .account-form__link { | |||
| font-size: $font-size-sm; | |||
| color: $color-primary; | |||
| color: var(--color-primary); | |||
| text-decoration: none; | |||
| &: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) ──────────────────────────────────────────────── | |||
| .account-form__pw-section { | |||
| display: contents; // bleibt im Grid-Fluss | |||
| @@ -101,7 +101,7 @@ | |||
| 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; } | |||
| @media (hover: none) { opacity: 1; } | |||
| @@ -153,7 +153,7 @@ | |||
| &:hover { color: $color-text-dark; } | |||
| &--active { | |||
| background: $color-primary; | |||
| background: var(--color-primary); | |||
| color: $color-white; | |||
| } | |||
| } | |||
| @@ -203,6 +203,6 @@ | |||
| height: 16px; | |||
| cursor: pointer; | |||
| flex-shrink: 0; | |||
| accent-color: $color-primary; | |||
| accent-color: var(--color-primary); | |||
| } | |||
| } | |||
| @@ -147,7 +147,7 @@ | |||
| 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; } | |||
| // immer sichtbar auf Touch-Geräten | |||
| @@ -3,7 +3,7 @@ | |||
| // ─── Login Page ─────────────────────────────────────────────────────────────── | |||
| .login-body { | |||
| min-height: 100vh; | |||
| background: $color-bg; | |||
| background: var(--color-bg); | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| @@ -71,7 +71,7 @@ | |||
| white-space: nowrap; | |||
| &:hover { | |||
| color: $color-primary; | |||
| color: var(--color-primary); | |||
| text-decoration: underline; | |||
| } | |||
| } | |||
| @@ -88,7 +88,7 @@ | |||
| text-decoration: none; | |||
| &:hover { | |||
| color: $color-primary; | |||
| color: var(--color-primary); | |||
| text-decoration: underline; | |||
| } | |||
| } | |||
| @@ -128,7 +128,7 @@ | |||
| width: 16px; | |||
| height: 16px; | |||
| cursor: pointer; | |||
| accent-color: $color-primary; | |||
| accent-color: var(--color-primary); | |||
| } | |||
| } | |||
| @@ -39,7 +39,7 @@ | |||
| &--active { | |||
| color: $color-white; | |||
| border-bottom-color: $color-primary-light; | |||
| border-bottom-color: var(--color-primary-light); | |||
| } | |||
| &--disabled { | |||
| @@ -6,7 +6,7 @@ | |||
| top: calc(100% + 8px); | |||
| right: 0; | |||
| 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; | |||
| padding: $space-4; | |||
| box-shadow: $shadow-calendar; | |||
| @@ -2,7 +2,7 @@ | |||
| .register-body { | |||
| min-height: 100vh; | |||
| background: $color-bg; | |||
| background: var(--color-bg); | |||
| display: flex; | |||
| align-items: flex-start; | |||
| justify-content: center; | |||
| @@ -29,7 +29,7 @@ | |||
| a { | |||
| font-size: $font-size-base; | |||
| font-weight: $font-weight-bold; | |||
| color: $color-primary; | |||
| color: var(--color-primary); | |||
| text-decoration: none; | |||
| letter-spacing: 0.02em; | |||
| } | |||
| @@ -140,7 +140,7 @@ | |||
| color: $color-text-muted; | |||
| a { | |||
| color: $color-primary; | |||
| color: var(--color-primary); | |||
| text-decoration: none; | |||
| &:hover { text-decoration: underline; } | |||
| @@ -144,7 +144,7 @@ | |||
| cursor: pointer; | |||
| input[type='radio'] { | |||
| accent-color: $color-primary; | |||
| accent-color: var(--color-primary); | |||
| width: 15px; | |||
| height: 15px; | |||
| flex-shrink: 0; | |||
| @@ -23,6 +23,9 @@ | |||
| @use 'sections/home'; | |||
| @use 'sections/report'; | |||
| // ─── Themes ─────────────────────────────────────────────────────────────────── | |||
| @use 'themes/minimal'; | |||
| // ─── Reset / Base ───────────────────────────────────────────────────────────── | |||
| *, | |||
| *::before, | |||
| @@ -37,7 +40,7 @@ html { | |||
| } | |||
| body { | |||
| background: $color-bg; | |||
| background: var(--color-bg); | |||
| } | |||
| @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap'); | |||
| @@ -2,14 +2,14 @@ | |||
| .home-body { | |||
| min-height: 100vh; | |||
| background: $color-bg; | |||
| background: var(--color-bg); | |||
| display: flex; | |||
| flex-direction: column; | |||
| } | |||
| // ─── 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; | |||
| } | |||
| @@ -3,14 +3,14 @@ | |||
| // ─── Page ───────────────────────────────────────────────────────────────────── | |||
| .report-page { | |||
| min-height: 100vh; | |||
| background: $color-bg; | |||
| background: var(--color-bg); | |||
| display: flex; | |||
| flex-direction: column; | |||
| } | |||
| // ─── 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; | |||
| display: flex; | |||
| align-items: center; | |||
| @@ -99,7 +99,7 @@ | |||
| gap: $space-2; | |||
| font-size: $font-size-sm; | |||
| font-weight: $font-weight-medium; | |||
| color: $color-primary; | |||
| color: var(--color-primary); | |||
| cursor: pointer; | |||
| text-decoration: none; | |||
| @@ -375,12 +375,12 @@ | |||
| gap: $space-2; | |||
| a { | |||
| color: $color-primary; | |||
| color: var(--color-primary); | |||
| text-decoration: underline; | |||
| cursor: pointer; | |||
| &:hover { | |||
| color: $color-primary-dark; | |||
| color: var(--color-primary-dark); | |||
| } | |||
| } | |||
| @@ -506,7 +506,7 @@ button.report-toolbar__action { | |||
| height: 14px; | |||
| cursor: pointer; | |||
| flex-shrink: 0; | |||
| accent-color: $color-primary; | |||
| accent-color: var(--color-primary); | |||
| } | |||
| // ─── Body: Controls + Meta nebeneinander ──────────────────────────────────── | |||
| @@ -579,8 +579,8 @@ button.report-toolbar__action { | |||
| transition: border-color $transition-fast, color $transition-fast; | |||
| &: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; | |||
| cursor: pointer; | |||
| user-select: none; | |||
| accent-color: $color-primary; | |||
| accent-color: var(--color-primary); | |||
| } | |||
| // ─── Filter-Footer ──────────────────────────────────────────────────────────── | |||
| @@ -3,14 +3,14 @@ | |||
| // ─── Page Wrapper ───────────────────────────────────────────────────────────── | |||
| .tt-page { | |||
| min-height: 100vh; | |||
| background: $color-bg; | |||
| background: var(--color-bg); | |||
| display: flex; | |||
| flex-direction: column; | |||
| } | |||
| // ─── Header Section ────────────────────────────────────────────────────────── | |||
| .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; | |||
| display: flex; | |||
| 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\Repository\Central\AccountUserRepository; | |||
| use App\Repository\Central\UserRepository; | |||
| use App\Service\BrandColorService; | |||
| use App\Service\TenantContext; | |||
| use Doctrine\ORM\EntityManagerInterface; | |||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | |||
| @@ -22,6 +23,7 @@ class AccountController extends AbstractController | |||
| private readonly AccountUserRepository $accountUserRepo, | |||
| private readonly UserRepository $userRepo, | |||
| private readonly UserPasswordHasherInterface $passwordHasher, | |||
| private readonly BrandColorService $brandColorService, | |||
| ) {} | |||
| #[Route('/account', name: 'account_index')] | |||
| @@ -49,6 +51,7 @@ class AccountController extends AbstractController | |||
| 'account' => $account, | |||
| 'user' => $user, | |||
| 'isAdmin' => $isAdmin, | |||
| 'isSuperAdmin' => $account->isSuperAdmin($user), | |||
| 'tab' => $tab, | |||
| 'adminUsers' => $adminUsers, | |||
| '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(); | |||
| 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['currentPassword'])) { | |||
| return $this->json(['error' => 'Aktuelles Passwort ist erforderlich.'], 400); | |||
| @@ -117,12 +117,18 @@ class TimeTrackingController extends AbstractController | |||
| $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->setUserId($user->getId()); | |||
| $entry->setProject($project); | |||
| $entry->setService($service); | |||
| $entry->setDate($date); | |||
| $entry->setDuration($this->parseDuration($data['duration'] ?? '0')); | |||
| $entry->setDuration($newDuration); | |||
| $entry->setNote(!empty($data['note']) ? $data['note'] : null); | |||
| $this->tenantEm->persist($entry); | |||
| @@ -164,9 +170,15 @@ class TimeTrackingController extends AbstractController | |||
| $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->setService($service); | |||
| $entry->setDuration($this->parseDuration($data['duration'] ?? '0')); | |||
| $entry->setDuration($newDuration); | |||
| $entry->setNote(!empty($data['note']) ? $data['note'] : null); | |||
| $this->tenantEm->flush(); | |||
| @@ -26,6 +26,9 @@ class Account | |||
| #[ORM\Column(type: 'smallint', options: ['default' => 1])] | |||
| private int $trackingInterval = 1; | |||
| #[ORM\Column(length: 7, nullable: true)] | |||
| private ?string $primaryColor = null; | |||
| #[ORM\Column] | |||
| private \DateTimeImmutable $createdAt; | |||
| @@ -54,6 +57,9 @@ class Account | |||
| public function getTrackingInterval(): int { return $this->trackingInterval; } | |||
| 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 getSuperAdminUser(): ?User { return $this->superAdminUser; } | |||
| @@ -32,6 +32,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface | |||
| #[ORM\Column(type: Types::TEXT, nullable: true)] | |||
| private ?string $note = null; | |||
| #[ORM\Column(length: 20)] | |||
| private string $theme = 'standard'; | |||
| public function getUserIdentifier(): string { return $this->email; } | |||
| public function getRoles(): array { return ['ROLE_USER']; } | |||
| public function eraseCredentials(): void {} | |||
| @@ -55,5 +58,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface | |||
| public function getNote(): ?string { return $this->note; } | |||
| 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(); } | |||
| } | |||
| @@ -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; | |||
| use App\Service\AccountRoleHelper; | |||
| use App\Service\BrandColorService; | |||
| use App\Service\TenantContext; | |||
| use Twig\Extension\AbstractExtension; | |||
| use Twig\TwigFilter; | |||
| @@ -13,6 +14,7 @@ class AppExtension extends AbstractExtension | |||
| public function __construct( | |||
| private readonly AccountRoleHelper $roleHelper, | |||
| private readonly TenantContext $tenantContext, | |||
| private readonly BrandColorService $brandColorService, | |||
| ) {} | |||
| public function getFilters(): array | |||
| @@ -32,9 +34,17 @@ class AppExtension extends AbstractExtension | |||
| new TwigFunction('isCurrentUserAdmin', [$this, 'isCurrentUserAdmin']), | |||
| new TwigFunction('isCurrentUserMemberOrAdmin', [$this, 'isCurrentUserMemberOrAdmin']), | |||
| 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 isCurrentUserMemberOrAdmin(): bool { return $this->roleHelper->isMemberOrAdmin(); } | |||
| public function getCurrentUserRole(): string { return $this->roleHelper->getCurrentAccountUser()?->getRole() ?? ''; } | |||
| @@ -42,3 +42,48 @@ | |||
| </a> | |||
| </div> | |||
| </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 #} | |||
| <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__date"> | |||
| {% set activStr = currentDate|date('Y-m-d') %} | |||
| @@ -22,6 +43,7 @@ | |||
| <div class="tt-header__kw">{{ 'app.date.week_label'|trans }} {{ currentWeekNumber }}</div> | |||
| </div> | |||
| <div class="tt-header__collapsible" id="tt-header-collapsible"> | |||
| <nav class="week-nav" | |||
| aria-label="{{ 'app.date.week_label'|trans }}" | |||
| data-active-date="{{ currentDate|date('Y-m-d') }}"> | |||
| @@ -56,4 +78,5 @@ | |||
| </a> | |||
| </nav> | |||
| </div>{# /.tt-header__collapsible #} | |||
| </header> | |||
| @@ -10,7 +10,8 @@ | |||
| <script> | |||
| window.ACCOUNT = { | |||
| tab: '{{ tab }}', | |||
| isSuperAdmin: {{ superAdminUserId is not null and superAdminUserId == user.id ? 'true' : 'false' }}, | |||
| isSuperAdmin: {{ isSuperAdmin ? 'true' : 'false' }}, | |||
| theme: '{{ user.theme|default('standard') }}', | |||
| }; | |||
| </script> | |||
| @@ -64,6 +65,22 @@ | |||
| <span class="account-form__hint">Auf welche Einheit werden erfasste Zeiten aufgerundet.</span> | |||
| </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"> | |||
| <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> | |||
| @@ -125,6 +142,31 @@ | |||
| </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 %} | |||
| </div> | |||
| @@ -8,6 +8,21 @@ | |||
| {{ encore_entry_link_tags('app') }} | |||
| {% 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 %} | |||
| {{ encore_entry_script_tags('app') }} | |||
| {% endblock %} | |||
| @@ -19,7 +34,7 @@ | |||
| <script src="https://cdn.jsdelivr.net/npm/frankenphp-hot-reload/+esm" type="module"></script> | |||
| {% endif %} | |||
| </head> | |||
| <body> | |||
| <body data-theme="{{ app.user is not null ? app.user.theme|default('standard') : 'standard' }}"> | |||
| {% include '_sections/nav.html.twig' %} | |||
| {% block body %}{% endblock %} | |||
| </body> | |||
| @@ -73,6 +73,7 @@ window.TT = { | |||
| durationHint: {{ 'app.entry.duration_hint'|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 }}, | |||
| errorDailyLimitExceeded: {{ 'app.entry.error_daily_limit_exceeded'|trans|json_encode|raw }}, | |||
| warnDurationLong: {{ 'app.entry.warn_duration_long'|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' %} | |||
| </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"> | |||
| <option value="">{{ 'app.entry.select_placeholder'|trans }}</option> | |||
| {% set currentClient = null %} | |||
| @@ -113,6 +114,10 @@ window.TT = { | |||
| {% endfor %} | |||
| {% if currentClient is not null %}</optgroup>{% endif %} | |||
| </select> | |||
| </div> | |||
| <label class="entry-form__label">{{ 'app.entry.label_service'|trans }}</label> | |||
| <div class="entry-form__field"> | |||
| <select id="create-service" class="select"> | |||
| <option value="">{{ 'app.entry.select_placeholder'|trans }}</option> | |||
| {% set currentGroup = null %} | |||
| @@ -129,12 +134,19 @@ window.TT = { | |||
| </select> | |||
| </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" | |||
| placeholder="{{ 'app.entry.placeholder_note'|trans }}"></textarea> | |||
| </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"> | |||
| <button type="button" class="btn btn-primary" id="btn-create"> | |||
| {{ 'app.entry.btn_create'|trans }} | |||
| @@ -144,6 +156,18 @@ window.TT = { | |||
| </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') }}"> | |||
| {% if timeEntries is empty %} | |||
| <div class="empty-state" id="empty-state"> | |||
| @@ -26,6 +26,8 @@ app: | |||
| entry: | |||
| label_duration: "Dauer" | |||
| label_project_service: "Projekt / Leistung" | |||
| label_project: "Projekt" | |||
| label_service: "Leistung" | |||
| label_note: "Bemerkung" | |||
| placeholder_note: "Optionale Beschreibung …" | |||
| 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" | |||
| 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_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?" | |||
| service: | |||