Quellcode durchsuchen

farbwahl, minimal theme

master
FlorianEisenmenger vor 1 Woche
Ursprung
Commit
e759dd4fae
38 geänderte Dateien mit 1134 neuen und 68 gelöschten Zeilen
  1. +79
    -18
      httpdocs/PROJEKT_KONTEXT.md
  2. +1
    -0
      httpdocs/assets/app.js
  3. +52
    -5
      httpdocs/assets/scripts/account.js
  4. +7
    -0
      httpdocs/assets/scripts/calendar.js
  5. +94
    -0
      httpdocs/assets/scripts/entries.js
  6. +19
    -0
      httpdocs/assets/scripts/nav.js
  7. +11
    -1
      httpdocs/assets/scripts/report.js
  8. +1
    -1
      httpdocs/assets/styles/atoms/_buttons.scss
  9. +4
    -4
      httpdocs/assets/styles/atoms/_inputs.scss
  10. +12
    -0
      httpdocs/assets/styles/atoms/_variables.scss
  11. +27
    -3
      httpdocs/assets/styles/components/_account.scss
  12. +3
    -3
      httpdocs/assets/styles/components/_crud.scss
  13. +1
    -1
      httpdocs/assets/styles/components/_entry-list.scss
  14. +4
    -4
      httpdocs/assets/styles/components/_login.scss
  15. +1
    -1
      httpdocs/assets/styles/components/_main-nav.scss
  16. +1
    -1
      httpdocs/assets/styles/components/_month-calendar.scss
  17. +3
    -3
      httpdocs/assets/styles/components/_register.scss
  18. +1
    -1
      httpdocs/assets/styles/components/_team.scss
  19. +4
    -1
      httpdocs/assets/styles/main.scss
  20. +2
    -2
      httpdocs/assets/styles/sections/_home.scss
  21. +9
    -9
      httpdocs/assets/styles/sections/_report.scss
  22. +2
    -2
      httpdocs/assets/styles/sections/_timetracking.scss
  23. +382
    -0
      httpdocs/assets/styles/themes/_minimal.scss
  24. +26
    -0
      httpdocs/migrations/central/Version20260526120000.php
  25. +26
    -0
      httpdocs/migrations/central/Version20260526150000.php
  26. +33
    -0
      httpdocs/migrations/central/Version20260526154632.php
  27. +15
    -0
      httpdocs/src/Controller/AccountController.php
  28. +14
    -2
      httpdocs/src/Controller/TimeTrackingController.php
  29. +6
    -0
      httpdocs/src/Entity/Central/Account.php
  30. +6
    -0
      httpdocs/src/Entity/Central/User.php
  31. +120
    -0
      httpdocs/src/Service/BrandColorService.php
  32. +10
    -0
      httpdocs/src/Twig/AppExtension.php
  33. +45
    -0
      httpdocs/templates/_sections/nav.html.twig
  34. +23
    -0
      httpdocs/templates/_sections/tt-header.html.twig
  35. +43
    -1
      httpdocs/templates/account/index.html.twig
  36. +16
    -1
      httpdocs/templates/base.html.twig
  37. +28
    -4
      httpdocs/templates/timetracking/week.html.twig
  38. +3
    -0
      httpdocs/translations/messages.de.yaml

+ 79
- 18
httpdocs/PROJEKT_KONTEXT.md Datei anzeigen

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

+ 1
- 0
httpdocs/assets/app.js Datei anzeigen

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

+ 52
- 5
httpdocs/assets/scripts/account.js Datei anzeigen

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


+ 7
- 0
httpdocs/assets/scripts/calendar.js Datei anzeigen

@@ -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(() => {


+ 94
- 0
httpdocs/assets/scripts/entries.js Datei anzeigen

@@ -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();
});

+ 19
- 0
httpdocs/assets/scripts/nav.js Datei anzeigen

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

+ 11
- 1
httpdocs/assets/scripts/report.js Datei anzeigen

@@ -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();


+ 1
- 1
httpdocs/assets/styles/atoms/_buttons.scss Datei anzeigen

@@ -26,7 +26,7 @@
}

&:focus-visible {
outline: 2px solid $color-primary;
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
}


+ 4
- 4
httpdocs/assets/styles/atoms/_inputs.scss Datei anzeigen

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

+ 12
- 0
httpdocs/assets/styles/atoms/_variables.scss Datei anzeigen

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



+ 27
- 3
httpdocs/assets/styles/components/_account.scss Datei anzeigen

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


+ 3
- 3
httpdocs/assets/styles/components/_crud.scss Datei anzeigen

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

+ 1
- 1
httpdocs/assets/styles/components/_entry-list.scss Datei anzeigen

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


+ 4
- 4
httpdocs/assets/styles/components/_login.scss Datei anzeigen

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



+ 1
- 1
httpdocs/assets/styles/components/_main-nav.scss Datei anzeigen

@@ -39,7 +39,7 @@

&--active {
color: $color-white;
border-bottom-color: $color-primary-light;
border-bottom-color: var(--color-primary-light);
}

&--disabled {


+ 1
- 1
httpdocs/assets/styles/components/_month-calendar.scss Datei anzeigen

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


+ 3
- 3
httpdocs/assets/styles/components/_register.scss Datei anzeigen

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


+ 1
- 1
httpdocs/assets/styles/components/_team.scss Datei anzeigen

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


+ 4
- 1
httpdocs/assets/styles/main.scss Datei anzeigen

@@ -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
- 2
httpdocs/assets/styles/sections/_home.scss Datei anzeigen

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



+ 9
- 9
httpdocs/assets/styles/sections/_report.scss Datei anzeigen

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


+ 2
- 2
httpdocs/assets/styles/sections/_timetracking.scss Datei anzeigen

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


+ 382
- 0
httpdocs/assets/styles/themes/_minimal.scss Datei anzeigen

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

+ 26
- 0
httpdocs/migrations/central/Version20260526120000.php Datei anzeigen

@@ -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');
}
}

+ 26
- 0
httpdocs/migrations/central/Version20260526150000.php Datei anzeigen

@@ -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');
}
}

+ 33
- 0
httpdocs/migrations/central/Version20260526154632.php Datei anzeigen

@@ -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');
}
}

+ 15
- 0
httpdocs/src/Controller/AccountController.php Datei anzeigen

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


+ 14
- 2
httpdocs/src/Controller/TimeTrackingController.php Datei anzeigen

@@ -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();


+ 6
- 0
httpdocs/src/Entity/Central/Account.php Datei anzeigen

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


+ 6
- 0
httpdocs/src/Entity/Central/User.php Datei anzeigen

@@ -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(); }
}

+ 120
- 0
httpdocs/src/Service/BrandColorService.php Datei anzeigen

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

+ 10
- 0
httpdocs/src/Twig/AppExtension.php Datei anzeigen

@@ -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() ?? ''; }


+ 45
- 0
httpdocs/templates/_sections/nav.html.twig Datei anzeigen

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

+ 23
- 0
httpdocs/templates/_sections/tt-header.html.twig Datei anzeigen

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

+ 43
- 1
httpdocs/templates/account/index.html.twig Datei anzeigen

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


+ 16
- 1
httpdocs/templates/base.html.twig Datei anzeigen

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


+ 28
- 4
httpdocs/templates/timetracking/week.html.twig Datei anzeigen

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


+ 3
- 0
httpdocs/translations/messages.de.yaml Datei anzeigen

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


Laden…
Abbrechen
Speichern