diff --git a/httpdocs/PROJEKT_KONTEXT.md b/httpdocs/PROJEKT_KONTEXT.md index 8db0225..d84fb74 100644 --- a/httpdocs/PROJEKT_KONTEXT.md +++ b/httpdocs/PROJEKT_KONTEXT.md @@ -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 `` 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) \ No newline at end of file diff --git a/httpdocs/assets/app.js b/httpdocs/assets/app.js index 6ab77a0..585b02b 100644 --- a/httpdocs/assets/app.js +++ b/httpdocs/assets/app.js @@ -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'; diff --git a/httpdocs/assets/scripts/account.js b/httpdocs/assets/scripts/account.js index 154c72a..034dc39 100644 --- a/httpdocs/assets/scripts/account.js +++ b/httpdocs/assets/scripts/account.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) { diff --git a/httpdocs/assets/scripts/calendar.js b/httpdocs/assets/scripts/calendar.js index 4d58918..66bd9b2 100644 --- a/httpdocs/assets/scripts/calendar.js +++ b/httpdocs/assets/scripts/calendar.js @@ -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(() => { diff --git a/httpdocs/assets/scripts/entries.js b/httpdocs/assets/scripts/entries.js index 194b3d6..70ff18e 100644 --- a/httpdocs/assets/scripts/entries.js +++ b/httpdocs/assets/scripts/entries.js @@ -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(); }); diff --git a/httpdocs/assets/scripts/nav.js b/httpdocs/assets/scripts/nav.js new file mode 100644 index 0000000..98825c3 --- /dev/null +++ b/httpdocs/assets/scripts/nav.js @@ -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; + } + }); +}); \ No newline at end of file diff --git a/httpdocs/assets/scripts/report.js b/httpdocs/assets/scripts/report.js index 2fd3fca..a46d966 100644 --- a/httpdocs/assets/scripts/report.js +++ b/httpdocs/assets/scripts/report.js @@ -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(); diff --git a/httpdocs/assets/styles/atoms/_buttons.scss b/httpdocs/assets/styles/atoms/_buttons.scss index 4f98a7e..e3021ef 100644 --- a/httpdocs/assets/styles/atoms/_buttons.scss +++ b/httpdocs/assets/styles/atoms/_buttons.scss @@ -26,7 +26,7 @@ } &:focus-visible { - outline: 2px solid $color-primary; + outline: 2px solid var(--color-primary); outline-offset: 3px; } } diff --git a/httpdocs/assets/styles/atoms/_inputs.scss b/httpdocs/assets/styles/atoms/_inputs.scss index 19a4db7..2cdfd58 100644 --- a/httpdocs/assets/styles/atoms/_inputs.scss +++ b/httpdocs/assets/styles/atoms/_inputs.scss @@ -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); } } diff --git a/httpdocs/assets/styles/atoms/_variables.scss b/httpdocs/assets/styles/atoms/_variables.scss index 0cbc091..86a225c 100644 --- a/httpdocs/assets/styles/atoms/_variables.scss +++ b/httpdocs/assets/styles/atoms/_variables.scss @@ -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; diff --git a/httpdocs/assets/styles/components/_account.scss b/httpdocs/assets/styles/components/_account.scss index 4f2ea94..7f8b0f3 100644 --- a/httpdocs/assets/styles/components/_account.scss +++ b/httpdocs/assets/styles/components/_account.scss @@ -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 diff --git a/httpdocs/assets/styles/components/_crud.scss b/httpdocs/assets/styles/components/_crud.scss index b517f43..d8ec263 100644 --- a/httpdocs/assets/styles/components/_crud.scss +++ b/httpdocs/assets/styles/components/_crud.scss @@ -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); } } diff --git a/httpdocs/assets/styles/components/_entry-list.scss b/httpdocs/assets/styles/components/_entry-list.scss index c26b983..58c34a3 100644 --- a/httpdocs/assets/styles/components/_entry-list.scss +++ b/httpdocs/assets/styles/components/_entry-list.scss @@ -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 diff --git a/httpdocs/assets/styles/components/_login.scss b/httpdocs/assets/styles/components/_login.scss index 85536ba..d7bd0ea 100644 --- a/httpdocs/assets/styles/components/_login.scss +++ b/httpdocs/assets/styles/components/_login.scss @@ -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); } } diff --git a/httpdocs/assets/styles/components/_main-nav.scss b/httpdocs/assets/styles/components/_main-nav.scss index 52a14a6..c46dbfa 100644 --- a/httpdocs/assets/styles/components/_main-nav.scss +++ b/httpdocs/assets/styles/components/_main-nav.scss @@ -39,7 +39,7 @@ &--active { color: $color-white; - border-bottom-color: $color-primary-light; + border-bottom-color: var(--color-primary-light); } &--disabled { diff --git a/httpdocs/assets/styles/components/_month-calendar.scss b/httpdocs/assets/styles/components/_month-calendar.scss index c310ed3..2c26c2a 100644 --- a/httpdocs/assets/styles/components/_month-calendar.scss +++ b/httpdocs/assets/styles/components/_month-calendar.scss @@ -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; diff --git a/httpdocs/assets/styles/components/_register.scss b/httpdocs/assets/styles/components/_register.scss index 59de1af..7f06ec2 100644 --- a/httpdocs/assets/styles/components/_register.scss +++ b/httpdocs/assets/styles/components/_register.scss @@ -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; } diff --git a/httpdocs/assets/styles/components/_team.scss b/httpdocs/assets/styles/components/_team.scss index f279b43..2564744 100644 --- a/httpdocs/assets/styles/components/_team.scss +++ b/httpdocs/assets/styles/components/_team.scss @@ -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; diff --git a/httpdocs/assets/styles/main.scss b/httpdocs/assets/styles/main.scss index f3cae61..b0f6e43 100644 --- a/httpdocs/assets/styles/main.scss +++ b/httpdocs/assets/styles/main.scss @@ -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'); diff --git a/httpdocs/assets/styles/sections/_home.scss b/httpdocs/assets/styles/sections/_home.scss index 9e6a11b..94a4cbe 100644 --- a/httpdocs/assets/styles/sections/_home.scss +++ b/httpdocs/assets/styles/sections/_home.scss @@ -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; } diff --git a/httpdocs/assets/styles/sections/_report.scss b/httpdocs/assets/styles/sections/_report.scss index 2428e8a..cd6918a 100644 --- a/httpdocs/assets/styles/sections/_report.scss +++ b/httpdocs/assets/styles/sections/_report.scss @@ -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 ──────────────────────────────────────────────────────────── diff --git a/httpdocs/assets/styles/sections/_timetracking.scss b/httpdocs/assets/styles/sections/_timetracking.scss index dc987b8..38c9ba0 100644 --- a/httpdocs/assets/styles/sections/_timetracking.scss +++ b/httpdocs/assets/styles/sections/_timetracking.scss @@ -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; diff --git a/httpdocs/assets/styles/themes/_minimal.scss b/httpdocs/assets/styles/themes/_minimal.scss new file mode 100644 index 0000000..87454da --- /dev/null +++ b/httpdocs/assets/styles/themes/_minimal.scss @@ -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; +} \ No newline at end of file diff --git a/httpdocs/migrations/central/Version20260526120000.php b/httpdocs/migrations/central/Version20260526120000.php new file mode 100644 index 0000000..2f43f59 --- /dev/null +++ b/httpdocs/migrations/central/Version20260526120000.php @@ -0,0 +1,26 @@ +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'); + } +} \ No newline at end of file diff --git a/httpdocs/migrations/central/Version20260526150000.php b/httpdocs/migrations/central/Version20260526150000.php new file mode 100644 index 0000000..59dfe33 --- /dev/null +++ b/httpdocs/migrations/central/Version20260526150000.php @@ -0,0 +1,26 @@ +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'); + } +} \ No newline at end of file diff --git a/httpdocs/migrations/central/Version20260526154632.php b/httpdocs/migrations/central/Version20260526154632.php new file mode 100644 index 0000000..2bc4d89 --- /dev/null +++ b/httpdocs/migrations/central/Version20260526154632.php @@ -0,0 +1,33 @@ +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'); + } +} diff --git a/httpdocs/src/Controller/AccountController.php b/httpdocs/src/Controller/AccountController.php index 1055c66..29ab3c3 100644 --- a/httpdocs/src/Controller/AccountController.php +++ b/httpdocs/src/Controller/AccountController.php @@ -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); diff --git a/httpdocs/src/Controller/TimeTrackingController.php b/httpdocs/src/Controller/TimeTrackingController.php index 002ab52..4acf15a 100644 --- a/httpdocs/src/Controller/TimeTrackingController.php +++ b/httpdocs/src/Controller/TimeTrackingController.php @@ -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(); diff --git a/httpdocs/src/Entity/Central/Account.php b/httpdocs/src/Entity/Central/Account.php index 9878fea..1ce1f76 100644 --- a/httpdocs/src/Entity/Central/Account.php +++ b/httpdocs/src/Entity/Central/Account.php @@ -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; } diff --git a/httpdocs/src/Entity/Central/User.php b/httpdocs/src/Entity/Central/User.php index fbd0db1..b4cabbd 100644 --- a/httpdocs/src/Entity/Central/User.php +++ b/httpdocs/src/Entity/Central/User.php @@ -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(); } } \ No newline at end of file diff --git a/httpdocs/src/Service/BrandColorService.php b/httpdocs/src/Service/BrandColorService.php new file mode 100644 index 0000000..e40604d --- /dev/null +++ b/httpdocs/src/Service/BrandColorService.php @@ -0,0 +1,120 @@ +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)); + } +} \ No newline at end of file diff --git a/httpdocs/src/Twig/AppExtension.php b/httpdocs/src/Twig/AppExtension.php index e6b40e4..7041c0b 100644 --- a/httpdocs/src/Twig/AppExtension.php +++ b/httpdocs/src/Twig/AppExtension.php @@ -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() ?? ''; } diff --git a/httpdocs/templates/_sections/nav.html.twig b/httpdocs/templates/_sections/nav.html.twig index 2946c7d..9a21ee4 100644 --- a/httpdocs/templates/_sections/nav.html.twig +++ b/httpdocs/templates/_sections/nav.html.twig @@ -42,3 +42,48 @@ + +{# Hamburger-Navigation — nur im Minimal-Theme sichtbar (via CSS) #} +
+ + +
\ No newline at end of file diff --git a/httpdocs/templates/_sections/tt-header.html.twig b/httpdocs/templates/_sections/tt-header.html.twig index 8d2d31c..607f22d 100644 --- a/httpdocs/templates/_sections/tt-header.html.twig +++ b/httpdocs/templates/_sections/tt-header.html.twig @@ -4,6 +4,27 @@ prevWeekUrl, nextWeekUrl #}
+ {# Minimal-Modus: kompakter Header mit Toggle #} +
+
+ {% 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 %} +
+ +
+
{% set activStr = currentDate|date('Y-m-d') %} @@ -22,6 +43,7 @@
{{ 'app.date.week_label'|trans }} {{ currentWeekNumber }}
+
+
{# /.tt-header__collapsible #}
diff --git a/httpdocs/templates/account/index.html.twig b/httpdocs/templates/account/index.html.twig index 9bf20b3..0d73c00 100644 --- a/httpdocs/templates/account/index.html.twig +++ b/httpdocs/templates/account/index.html.twig @@ -10,7 +10,8 @@ @@ -64,6 +65,22 @@ Auf welche Einheit werden erfasste Zeiten aufgerundet. + {% if isSuperAdmin %} + +
+
+ + +
+ Hex-Farbe für das Standard-Theme aller Benutzer. Standard: #3a7bbf +
+ {% endif %} +
Abbrechen @@ -125,6 +142,31 @@
+ {# ── Darstellung ───────────────────────────────────────────────────── #} +
+ +
+ +
+ + +
+
+ + +
+
+ +
+ {% endif %} diff --git a/httpdocs/templates/base.html.twig b/httpdocs/templates/base.html.twig index 1007914..1a86268 100644 --- a/httpdocs/templates/base.html.twig +++ b/httpdocs/templates/base.html.twig @@ -8,6 +8,21 @@ {{ encore_entry_link_tags('app') }} {% endblock %} + {% set _bp = brandPalette() %} + {% if _bp %} + + {% endif %} + {% block javascripts %} {{ encore_entry_script_tags('app') }} {% endblock %} @@ -19,7 +34,7 @@ {% endif %} - + {% include '_sections/nav.html.twig' %} {% block body %}{% endblock %} diff --git a/httpdocs/templates/timetracking/week.html.twig b/httpdocs/templates/timetracking/week.html.twig index cd17d45..8b8a62d 100644 --- a/httpdocs/templates/timetracking/week.html.twig +++ b/httpdocs/templates/timetracking/week.html.twig @@ -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' %} - -
+ +
+
+ + +
- -
+ +
+ {# Minimal-Modus: Bemerkung-Toggle (nur via CSS/JS sichtbar) #} +
+ +
+
+ {# Minimal-Modus: Summary-Zeile zum Aufklappen #} + {% if timeEntries is not empty %} +
+ +
+ {% endif %} +
{% if timeEntries is empty %}
diff --git a/httpdocs/translations/messages.de.yaml b/httpdocs/translations/messages.de.yaml index d406193..c44bc1a 100644 --- a/httpdocs/translations/messages.de.yaml +++ b/httpdocs/translations/messages.de.yaml @@ -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: