| @@ -0,0 +1,18 @@ | |||
| { | |||
| "permissions": { | |||
| "allow": [ | |||
| "Bash(xargs cat)", | |||
| "Bash(ddev exec *)", | |||
| "Bash(npx encore *)", | |||
| "Bash(python3 *)", | |||
| "Bash(php -r \"print_r\\(yaml_parse_file\\('/Users/floeis/Workspace/timetracking/httpdocs/translations/messages.de.yaml'\\) ? 'YAML valid' : 'YAML invalid'\\);\")", | |||
| "Bash(ruby *)", | |||
| "Bash(php -l src/Controller/TimeTrackingController.php)", | |||
| "Bash(php -l src/Controller/ReportController.php)", | |||
| "Bash(php -l src/Controller/TeamController.php)", | |||
| "Bash(php -l src/Controller/AccountController.php)", | |||
| "Bash(php -l src/Controller/ClientController.php)", | |||
| "Bash(php *)" | |||
| ] | |||
| } | |||
| } | |||
| @@ -0,0 +1,139 @@ | |||
| # CLAUDE.md – spawntree Timetracker | |||
| ## Projekt-Überblick | |||
| Internes Timetracking-Tool (Orientierung an mite.de), zunächst Single-User, geplant als SaaS. | |||
| Multi-Tenant-Architektur: jeder Account bekommt eine Subdomain und eigene Tenant-DB. | |||
| ## Tech Stack | |||
| - **Backend**: Symfony 7.4, PHP 8.2+ (DDEV nutzt 8.4), Doctrine ORM, MariaDB 10.11 | |||
| - **Frontend**: Twig, SCSS (Webpack Encore), Vanilla JS (kein Framework, kein jQuery) | |||
| - **Export**: PhpSpreadsheet (Excel), Dompdf (PDF), natives PHP (CSV) | |||
| - **Dev-Umgebung**: DDEV, Projektname `timetracking`, HTTPS-Port 8459 | |||
| - **Keine** Symfony Forms – eigene HTML-Formulare mit `fetch()`-API | |||
| ## Verzeichnisstruktur | |||
| Der Symfony-Code liegt unter `httpdocs/`. Das ist das Projekt-Root für Symfony. | |||
| ``` | |||
| httpdocs/ | |||
| ├── src/ | |||
| │ ├── Controller/ # Symfony Controller (Routes via PHP Attributes) | |||
| │ ├── Entity/ | |||
| │ │ ├── Central/ # User, Account, AccountUser, Tokens (Central-DB) | |||
| │ │ └── Tenant/ # Client, Project, Service, TimeEntry (Tenant-DB) | |||
| │ ├── Repository/ | |||
| │ │ ├── Central/ | |||
| │ │ └── Tenant/ | |||
| │ ├── Service/ # TenantContext, RegistrationService, etc. | |||
| │ ├── Doctrine/ # TenantConnectionMiddleware | |||
| │ ├── EventSubscriber/ # TenantRequestSubscriber, ArchivedUserSubscriber | |||
| │ ├── Security/ # ArchivedUserChecker, AccessDeniedHandler | |||
| │ └── Twig/ # AppExtension + Runtime | |||
| ├── config/ | |||
| │ ├── packages/ | |||
| │ │ ├── doctrine.yaml # Zwei Connections: central + tenant | |||
| │ │ └── security.yaml # Firewall, Access Control, form_login | |||
| │ ├── services.yaml # DI: Tenant-EM explizit an Controller gebunden | |||
| │ └── routes.yaml | |||
| ├── templates/ # Twig-Templates (Atomic Design: atoms/components/sections) | |||
| ├── assets/ | |||
| │ ├── app.js # Webpack-Entry für Timetracking | |||
| │ ├── styles/ # SCSS (main.scss als Entry) | |||
| │ └── scripts/ # JS-Module (calendar, entries, crud, team, account, report) | |||
| ├── migrations/ | |||
| │ ├── central/ # Doctrine-Migrations für Central-DB | |||
| │ └── tenant/ # Doctrine-Migrations für Tenant-DB | |||
| ├── translations/ # messages.de.yaml | |||
| └── public/ # Webroot (index.php, build/) | |||
| ``` | |||
| ## Multi-Tenant-Architektur | |||
| - **Central-DB** (`db`): User, Account, AccountUser, Tokens | |||
| - **Tenant-DB** (`db_{slug}`): Client, Project, Service, TimeEntry | |||
| - `TenantRequestSubscriber` (Prio 20) liest Subdomain → setzt `TenantContext` | |||
| - `TenantConnectionMiddleware` schaltet die DB-Connection auf `db_{slug}` um | |||
| - Zwei Doctrine Entity Manager: `central` und `tenant` | |||
| ## Wichtige Befehle | |||
| ```bash | |||
| # Dev-Umgebung starten | |||
| ddev start | |||
| # Datenbank Reset + Seed (innerhalb DDEV) | |||
| ddev exec bash 1-reset-and-seed.sh | |||
| ddev exec bash 2-update-tenant-db.sh | |||
| # Migrations | |||
| ddev exec php bin/console doctrine:migrations:migrate --em=central --no-interaction | |||
| ddev exec php bin/console doctrine:migrations:migrate --em=tenant --no-interaction | |||
| # Frontend Build | |||
| ddev exec npm run dev # Development | |||
| ddev exec npm run watch # Watch-Mode | |||
| ddev exec npm run build # Production | |||
| # Cache leeren | |||
| ddev exec php bin/console cache:clear | |||
| # Deploy | |||
| bash httpdocs/deploy.sh | |||
| ``` | |||
| ## Webpack Encore Entries | |||
| | Entry | Datei | Seite | | |||
| |----------------|----------------------------------|--------------------------| | |||
| | `app` | `assets/app.js` | Timetracking-Woche | | |||
| | `crud` | `assets/scripts/crud.js` | Kunden/Projekte/Services | | |||
| | `registration` | `assets/scripts/registration.js` | Registrierung | | |||
| | `team` | `assets/scripts/team.js` | Team-Verwaltung | | |||
| | `account` | `assets/scripts/account.js` | Account-Einstellungen | | |||
| | `report` | `assets/scripts/report.js` | Report-Seite | | |||
| ## Konventionen | |||
| - **Durations**: Integer (Minuten) in der DB, Eingabeformate: `1:30`, `8 12` (von-bis), `1,75` (Dezimal) | |||
| - **Rounding**: Konfigurierbar per `Account.trackingInterval` (1/15/30/60 Min) | |||
| - **API-Pattern**: `/api/...` Routen, JSON Request/Response, kein CSRF auf API-Endpunkten | |||
| - **Rollen**: `admin` (alles), `member` (eigene + fremde Einträge sehen), `tracker` (nur eigene) | |||
| - **Translations**: `messages.de.yaml`, JS-Strings via `window.TT.i18n` / `window.Report.i18n`. Auch Backend-Services (z.B. `ReportExportService`) nutzen `TranslatorInterface` — keine hardcoded Strings. | |||
| - **SCSS**: BEM-ähnlich, Atoms → Components → Sections → Themes | |||
| - **CSS Custom Properties**: Brand-Farben via `:root`-Variablen (`--color-primary`, etc.) | |||
| ## Rollen-System | |||
| | Rolle | Rechte | | |||
| |-----------|------------------------------------------------------------------| | |||
| | `admin` | Alles: Team, alle Einträge, Account-Settings | | |||
| | `member` | Eigene Einträge + alle fremden sehen (kein Team-Zugriff) | | |||
| | `tracker` | Nur eigene Einträge | | |||
| Superadmin = Account-Ersteller, kann Kontoinhaber übertragen und `primaryColor` setzen. | |||
| ## Services-Injection (services.yaml) | |||
| Controller die Tenant-Entities nutzen brauchen den `tenant_entity_manager` explizit: | |||
| - `$tenantEm: '@doctrine.orm.tenant_entity_manager'` (TimeTrackingController, ReportController) | |||
| - `$em: '@doctrine.orm.tenant_entity_manager'` (ClientController, ProjectController, ServiceController) | |||
| ## Report-Exporte | |||
| - **Excel** (`/reports/export/excel`): PhpSpreadsheet, Autofilter, Frozen Header, Zebra-Stripes, Summenzeile | |||
| - **CSV** (`/reports/export/csv`): Semikolon-Trennzeichen, UTF-8 BOM, deutsche Zahlenformatierung | |||
| - **PDF** (`/reports/export/pdf`): Dompdf, A4 Querformat, professioneller Header + Footer | |||
| Alle drei nutzen die gleichen Filter-Parameter wie die Report-Seite, exportieren ohne Limit. `ReportExportService` bereitet Daten zentral in `prepareData()` auf, formatspezifische Methoden erzeugen die Ausgabe. Tracker sehen nur eigene Einträge. | |||
| ## TenantConnectionMiddleware | |||
| Registriert via Service-Tag in `services.yaml` (nicht via `doctrine.yaml` — DoctrineBundle 3.x unterstützt `middlewares`-Config-Key nicht): | |||
| ```yaml | |||
| App\Doctrine\TenantConnectionMiddleware: | |||
| tags: | |||
| - { name: doctrine.middleware, connection: tenant } | |||
| ``` | |||
| @@ -16,7 +16,7 @@ | |||
| ###> symfony/framework-bundle ### | |||
| APP_ENV=dev | |||
| APP_SECRET= | |||
| APP_SECRET=f19f2bcb34a48e20e66302a0e88408a9 | |||
| APP_SHARE_DIR=var/share | |||
| ###< symfony/framework-bundle ### | |||
| @@ -37,7 +37,7 @@ DEFAULT_URI=http://localhost | |||
| ###< doctrine/doctrine-bundle ### | |||
| DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=10.11.2-MariaDB&charset=utf8mb4" | |||
| APP_DOMAIN=testtimetracking.ddev.site | |||
| APP_DOMAIN= | |||
| # ── Mailer ──────────────────────────────────────────────────────────────────── | |||
| # Lokal (DDEV Mailpit): smtp://127.0.0.1:1025 | |||
| @@ -45,4 +45,4 @@ APP_DOMAIN=testtimetracking.ddev.site | |||
| MAILER_DSN=smtp://127.0.0.1:1025 | |||
| # Benachrichtigung bei Neuanmeldung | |||
| REGISTRATION_NOTIFY_EMAIL=re@spawntree.de | |||
| REGISTRATION_NOTIFY_EMAIL= | |||
| @@ -1,4 +0,0 @@ | |||
| ###> symfony/framework-bundle ### | |||
| APP_SECRET=f19f2bcb34a48e20e66302a0e88408a9 | |||
| ###< symfony/framework-bundle ### | |||
| @@ -16,7 +16,7 @@ Technisch ähnlich wie **mite.de** – wir orientieren uns stark am UI/UX von mi | |||
| - **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 → Themes (BEM-ähnlich) | |||
| - **Dev**: DDEV (Port 8456 HTTPS), PHPMyAdmin installiert | |||
| - **Dev**: DDEV (Port 8459 HTTPS), PHPMyAdmin installiert | |||
| - **Kein** Symfony Forms – eigene HTML-Formulare mit fetch()-API | |||
| --- | |||
| @@ -24,7 +24,7 @@ Technisch ähnlich wie **mite.de** – wir orientieren uns stark am UI/UX von mi | |||
| ## Multi-Mandanten-Architektur | |||
| ### Konzept | |||
| - Jeder Account (Firma) bekommt eine eigene **Subdomain**: `spawntree.testtimetracking.ddev.site` | |||
| - Jeder Account (Firma) bekommt eine eigene **Subdomain**: `spawntree.timetracking.ddev.site` | |||
| - Jeder Account bekommt eine **eigene Tenant-Datenbank**: `db_spawntree` | |||
| - Die **Central-DB** (`db`) enthält accountübergreifende Daten: User, Account, AccountUser, Token | |||
| - Die **Tenant-DB** enthält accountspezifische Daten: Client, Project, Service, TimeEntry | |||
| @@ -131,6 +131,9 @@ Technisch ähnlich wie **mite.de** – wir orientieren uns stark am UI/UX von mi | |||
| ### Reports | |||
| - `GET /reports/times` → `report_times` | |||
| - `GET /reports/export/excel` → `report_export_excel` (Excel-Download mit aktuellen Filtern) | |||
| - `GET /reports/export/csv` → `report_export_csv` (CSV-Download mit aktuellen Filtern) | |||
| - `GET /reports/export/pdf` → `report_export_pdf` (PDF-Download mit aktuellen Filtern) | |||
| ### Team (nur Admins) | |||
| - `GET /team` → `team_index` | |||
| @@ -163,6 +166,7 @@ Technisch ähnlich wie **mite.de** – wir orientieren uns stark am UI/UX von mi | |||
| - `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 | |||
| - `ReportExportService` – generiert Excel (PhpSpreadsheet), CSV und PDF (Dompdf) Exporte; nutzt Translations für alle Labels | |||
| - `AccessDeniedHandler` – leitet bei 403 auf Login um | |||
| --- | |||
| @@ -360,7 +364,7 @@ Migration ausführen: `ddev exec php bin/console doctrine:migrations:migrate --e | |||
| ## Was noch fehlt / TODO | |||
| - [ ] Filter auf Report-Seite (Datumsbereich, Projekt, Service, User) | |||
| - [ ] Export (CSV / PDF) | |||
| - [x] Export (Excel / CSV / PDF) – `ReportExportService`, Icons in Toolbar | |||
| - [ ] Timer-Funktion (Live-Zeiterfassung) | |||
| - [ ] Wochenübersicht mit Summen pro Tag (im Wochenkalender) | |||
| - [ ] E-Mail-Konfiguration für Produktivbetrieb (aktuell DDEV Mailpit) | |||
| @@ -379,10 +383,10 @@ bash 2-update-tenant-db.sh | |||
| --- | |||
| ## DDEV-Konfiguration | |||
| - Projekt: `testtimetracking` | |||
| - Hauptdomain: `https://testtimetracking.ddev.site:8456` | |||
| - Tenant-Subdomain Beispiel: `https://spawntree.testtimetracking.ddev.site:8456` | |||
| - PHPMyAdmin: `https://testtimetracking.ddev.site:8037` | |||
| - Projekt: `timetracking` | |||
| - Hauptdomain: `https://timetracking.ddev.site:8459` | |||
| - Tenant-Subdomain Beispiel: `https://spawntree.timetracking.ddev.site:8459` | |||
| - PHPMyAdmin: `https://timetracking.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=timetracking.ddev.site:8459` (für Subdomain-Erkennung und E-Mail-Links) | |||
| @@ -1,177 +1,193 @@ | |||
| // account.js | |||
| document.addEventListener('DOMContentLoaded', () => { | |||
| // assets/scripts/account.js | |||
| const toast = document.getElementById('account-toast'); | |||
| import { esc, createTranslator } from './utils.js'; | |||
| function showToast(msg, isError = false) { | |||
| toast.textContent = msg; | |||
| toast.classList.toggle('account-toast--error', isError); | |||
| toast.classList.add('account-toast--visible'); | |||
| setTimeout(() => toast.classList.remove('account-toast--visible'), 3000); | |||
| } | |||
| const TOAST_DURATION = 3000; | |||
| async function patchJson(url, data) { | |||
| const res = await fetch(url, { | |||
| method: 'PATCH', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify(data), | |||
| }); | |||
| const json = await res.json(); | |||
| if (!res.ok) throw new Error(json.error ?? 'Fehler'); | |||
| return json; | |||
| } | |||
| // ── Farbfeld: Picker ↔ Hex-Input synchron + Live-Kontrast ──────────────── | |||
| const colorPicker = document.getElementById('account-color-picker'); | |||
| const colorHex = document.getElementById('account-color'); | |||
| function applyHeaderContrast(hex) { | |||
| const r = parseInt(hex.slice(1, 3), 16); | |||
| const g = parseInt(hex.slice(3, 5), 16); | |||
| const b = parseInt(hex.slice(5, 7), 16); | |||
| const brightness = (r * 299 + g * 587 + b * 114) / 1000; | |||
| const isLight = brightness > 128; | |||
| const root = document.documentElement; | |||
| root.style.setProperty('--header-text', isLight ? '#1a2a3a' : '#ffffff'); | |||
| root.style.setProperty('--header-text-muted', isLight ? 'rgba(26, 42, 58, 0.65)' : 'rgba(255, 255, 255, 0.75)'); | |||
| root.style.setProperty('--header-overlay', isLight ? 'rgba(0, 0, 0, 0.08)' : 'rgba(255, 255, 255, 0.18)'); | |||
| } | |||
| if (colorPicker && colorHex) { | |||
| colorPicker.addEventListener('input', () => { | |||
| colorHex.value = colorPicker.value; | |||
| applyHeaderContrast(colorPicker.value); | |||
| }); | |||
| colorHex.addEventListener('input', () => { | |||
| if (/^#[0-9a-fA-F]{6}$/.test(colorHex.value)) { | |||
| colorPicker.value = colorHex.value; | |||
| applyHeaderContrast(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', payload); | |||
| showToast('Gespeichert. Seite wird neu geladen…'); | |||
| setTimeout(() => window.location.reload(), 1200); | |||
| } catch (e) { | |||
| showToast(e.message, true); | |||
| } | |||
| }); | |||
| } | |||
| // ── Besitzer des Accounts ───────────────────────────────────────────────── | |||
| const superadminSelect = document.getElementById('superadmin-select'); | |||
| if (superadminSelect && !superadminSelect.disabled) { | |||
| superadminSelect.addEventListener('change', async () => { | |||
| const selectedName = superadminSelect.options[superadminSelect.selectedIndex].text; | |||
| if (!confirm(`${selectedName} zum neuen Kontoinhaber machen?`)) { | |||
| // Auswahl zurücksetzen | |||
| superadminSelect.value = superadminSelect.dataset.original; | |||
| return; | |||
| } | |||
| try { | |||
| await patchJson('/api/account/superadmin', { | |||
| userId: parseInt(superadminSelect.value, 10), | |||
| }); | |||
| showToast('Kontoinhaber geändert. Seite wird neu geladen…'); | |||
| setTimeout(() => window.location.reload(), 1500); | |||
| } catch (e) { | |||
| showToast(e.message, true); | |||
| superadminSelect.value = superadminSelect.dataset.original; | |||
| } | |||
| }); | |||
| const t = createTranslator('ACCOUNT'); | |||
| // Original-Wert merken für Rollback | |||
| superadminSelect.dataset.original = superadminSelect.value; | |||
| } | |||
| // ── Passwort-Toggle ─────────────────────────────────────────────────────── | |||
| const btnPwToggle = document.getElementById('btn-pw-toggle'); | |||
| const pwSection = document.getElementById('pw-section'); | |||
| if (btnPwToggle && pwSection) { | |||
| btnPwToggle.addEventListener('click', (e) => { | |||
| e.preventDefault(); | |||
| const open = !pwSection.hidden; | |||
| pwSection.hidden = open; | |||
| btnPwToggle.textContent = open ? 'ändern' : 'abbrechen'; | |||
| }); | |||
| } | |||
| // ── 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) { | |||
| btnUserSave.addEventListener('click', async () => { | |||
| const data = { | |||
| firstName: document.getElementById('user-firstname').value.trim(), | |||
| lastName: document.getElementById('user-lastname').value.trim(), | |||
| email: document.getElementById('user-email').value.trim(), | |||
| }; | |||
| if (pwSection && !pwSection.hidden) { | |||
| const pwNew = document.getElementById('user-pw-new').value; | |||
| const pwRepeat = document.getElementById('user-pw-repeat').value; | |||
| if (pwNew !== pwRepeat) { | |||
| showToast('Die Passwörter stimmen nicht überein.', true); | |||
| return; | |||
| } | |||
| data.currentPassword = document.getElementById('user-pw-current').value; | |||
| data.newPassword = pwNew; | |||
| } | |||
| try { | |||
| await patchJson('/api/account/user', data); | |||
| showToast('Gespeichert.'); | |||
| if (pwSection) { | |||
| pwSection.hidden = true; | |||
| document.getElementById('btn-pw-toggle').textContent = 'ändern'; | |||
| ['user-pw-current', 'user-pw-new', 'user-pw-repeat'].forEach(id => { | |||
| document.getElementById(id).value = ''; | |||
| }); | |||
| } | |||
| } catch (e) { | |||
| showToast(e.message, true); | |||
| } | |||
| document.addEventListener('DOMContentLoaded', () => { | |||
| const toast = document.getElementById('account-toast'); | |||
| function showToast(msg, isError = false) { | |||
| toast.textContent = msg; | |||
| toast.classList.toggle('account-toast--error', isError); | |||
| toast.classList.add('account-toast--visible'); | |||
| setTimeout(() => toast.classList.remove('account-toast--visible'), TOAST_DURATION); | |||
| } | |||
| async function patchJson(url, data) { | |||
| const res = await fetch(url, { | |||
| method: 'PATCH', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify(data), | |||
| }); | |||
| const json = await res.json(); | |||
| if (!res.ok) throw new Error(json.error ?? t('errorGeneric')); | |||
| return json; | |||
| } | |||
| // ── Farbfeld: Picker <-> Hex-Input synchron + Live-Kontrast ─────────────── | |||
| const colorPicker = document.getElementById('account-color-picker'); | |||
| const colorHex = document.getElementById('account-color'); | |||
| function applyHeaderContrast(hex) { | |||
| const r = parseInt(hex.slice(1, 3), 16); | |||
| const g = parseInt(hex.slice(3, 5), 16); | |||
| const b = parseInt(hex.slice(5, 7), 16); | |||
| const brightness = (r * 299 + g * 587 + b * 114) / 1000; | |||
| const isLight = brightness > 128; | |||
| const root = document.documentElement; | |||
| root.style.setProperty('--header-text', isLight ? '#1a2a3a' : '#ffffff'); | |||
| root.style.setProperty('--header-text-muted', isLight ? 'rgba(26, 42, 58, 0.65)' : 'rgba(255, 255, 255, 0.75)'); | |||
| root.style.setProperty('--header-overlay', isLight ? 'rgba(0, 0, 0, 0.08)' : 'rgba(255, 255, 255, 0.18)'); | |||
| } | |||
| if (colorPicker && colorHex) { | |||
| colorPicker.addEventListener('input', () => { | |||
| colorHex.value = colorPicker.value; | |||
| applyHeaderContrast(colorPicker.value); | |||
| }); | |||
| colorHex.addEventListener('input', () => { | |||
| if (/^#[0-9a-fA-F]{6}$/.test(colorHex.value)) { | |||
| colorPicker.value = colorHex.value; | |||
| applyHeaderContrast(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(t('invalidHex'), true); | |||
| return; | |||
| } | |||
| payload.primaryColor = hex || ''; | |||
| } | |||
| btnAccountSave.disabled = true; | |||
| try { | |||
| await patchJson('/api/account', payload); | |||
| showToast(t('savedReloading')); | |||
| setTimeout(() => window.location.reload(), 1200); | |||
| } catch (e) { | |||
| showToast(e.message, true); | |||
| } finally { | |||
| btnAccountSave.disabled = false; | |||
| } | |||
| }); | |||
| } | |||
| // ── Besitzer des Accounts ───────────────────────────────────────────────── | |||
| const superadminSelect = document.getElementById('superadmin-select'); | |||
| if (superadminSelect && !superadminSelect.disabled) { | |||
| superadminSelect.dataset.original = superadminSelect.value; | |||
| superadminSelect.addEventListener('change', async () => { | |||
| const selectedName = superadminSelect.options[superadminSelect.selectedIndex].text; | |||
| if (!confirm(t('ownerConfirm').replace('%name%', selectedName))) { | |||
| superadminSelect.value = superadminSelect.dataset.original; | |||
| return; | |||
| } | |||
| try { | |||
| await patchJson('/api/account/superadmin', { | |||
| userId: parseInt(superadminSelect.value, 10), | |||
| }); | |||
| } | |||
| showToast(t('ownerChanged')); | |||
| setTimeout(() => window.location.reload(), 1500); | |||
| } catch (e) { | |||
| showToast(e.message, true); | |||
| superadminSelect.value = superadminSelect.dataset.original; | |||
| } | |||
| }); | |||
| } | |||
| // ── Passwort-Toggle ─────────────────────────────────────────────────────── | |||
| const btnPwToggle = document.getElementById('btn-pw-toggle'); | |||
| const pwSection = document.getElementById('pw-section'); | |||
| if (btnPwToggle && pwSection) { | |||
| btnPwToggle.addEventListener('click', (e) => { | |||
| e.preventDefault(); | |||
| const open = !pwSection.hidden; | |||
| pwSection.hidden = open; | |||
| btnPwToggle.textContent = open ? t('changeLabel') : t('cancelLabel'); | |||
| }); | |||
| } | |||
| // ── 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 }); | |||
| themePicker.querySelectorAll('.theme-option').forEach(opt => { | |||
| opt.classList.toggle('theme-option--active', opt.dataset.theme === theme); | |||
| }); | |||
| document.body.dataset.theme = theme; | |||
| showToast(t('themeChanged')); | |||
| } catch (e) { | |||
| showToast(e.message, true); | |||
| } | |||
| }); | |||
| }); | |||
| } | |||
| // ── Benutzer-Formular ───────────────────────────────────────────────────── | |||
| const btnUserSave = document.getElementById('btn-user-save'); | |||
| if (btnUserSave) { | |||
| btnUserSave.addEventListener('click', async () => { | |||
| const data = { | |||
| firstName: document.getElementById('user-firstname').value.trim(), | |||
| lastName: document.getElementById('user-lastname').value.trim(), | |||
| email: document.getElementById('user-email').value.trim(), | |||
| }; | |||
| if (pwSection && !pwSection.hidden) { | |||
| const pwNew = document.getElementById('user-pw-new').value; | |||
| const pwRepeat = document.getElementById('user-pw-repeat').value; | |||
| if (pwNew !== pwRepeat) { | |||
| showToast(t('passwordMismatch'), true); | |||
| return; | |||
| } | |||
| data.currentPassword = document.getElementById('user-pw-current').value; | |||
| data.newPassword = pwNew; | |||
| } | |||
| btnUserSave.disabled = true; | |||
| try { | |||
| await patchJson('/api/account/user', data); | |||
| showToast(t('saved')); | |||
| if (pwSection) { | |||
| pwSection.hidden = true; | |||
| document.getElementById('btn-pw-toggle').textContent = t('changeLabel'); | |||
| ['user-pw-current', 'user-pw-new', 'user-pw-repeat'].forEach(id => { | |||
| document.getElementById(id).value = ''; | |||
| }); | |||
| } | |||
| } catch (e) { | |||
| showToast(e.message, true); | |||
| } finally { | |||
| btnUserSave.disabled = false; | |||
| } | |||
| }); | |||
| } | |||
| }); | |||
| @@ -1,9 +1,8 @@ | |||
| // assets/scripts/calendar.js | |||
| // Strings aus window.TT.i18n – keine hardcodierten deutschen Texte mehr | |||
| function t(key) { | |||
| return window.TT?.i18n?.[key] ?? key; | |||
| } | |||
| import { esc, createTranslator, FADE_MS } from './utils.js'; | |||
| const t = createTranslator('TT'); | |||
| class WeekCalendar { | |||
| constructor() { | |||
| @@ -19,9 +18,9 @@ class WeekCalendar { | |||
| this.today = new Date(); | |||
| this.today.setHours(0, 0, 0, 0); | |||
| this.monthOpen = false; | |||
| this.monthDate = new Date(this.activeDate); | |||
| this.monthEl = null; | |||
| this.monthOpen = false; | |||
| this.monthDate = new Date(this.activeDate); | |||
| this.monthEl = null; | |||
| if (!this.nav) return; | |||
| this.init(); | |||
| @@ -69,7 +68,7 @@ class WeekCalendar { | |||
| window.history.pushState({}, '', `/week/${this.formatDate(this.getMonday(this.activeDate))}`); | |||
| window.entryManager?.loadEntriesForDate(this.formatDate(this.activeDate)); | |||
| }, 180); | |||
| }, FADE_MS); | |||
| } | |||
| renderWeekDays() { | |||
| @@ -77,12 +76,11 @@ class WeekCalendar { | |||
| this.daysContainer.innerHTML = ''; | |||
| for (let i = 0; i < 7; i++) { | |||
| const d = new Date(monday); | |||
| const d = new Date(monday); | |||
| d.setDate(d.getDate() + i); | |||
| const isActive = this.isSameDay(d, this.activeDate); | |||
| const isToday = this.isSameDay(d, this.today); | |||
| // Führungsnull: padStart(2, '0') | |||
| const dayNum = String(d.getDate()).padStart(2, '0'); | |||
| const monthShort = this.monthsShort[d.getMonth()] ?? ''; | |||
| @@ -93,8 +91,8 @@ class WeekCalendar { | |||
| + (isToday ? ' week-nav__day--today' : ''); | |||
| a.dataset.date = this.formatDate(d); | |||
| a.innerHTML = ` | |||
| <span class="week-nav__day-name">${this.weekdaysShort[i] ?? ''}</span> | |||
| <span class="week-nav__day-date">${dayNum}. ${monthShort}</span> | |||
| <span class="week-nav__day-name">${esc(this.weekdaysShort[i] ?? '')}</span> | |||
| <span class="week-nav__day-date">${dayNum}. ${esc(monthShort)}</span> | |||
| `; | |||
| this.daysContainer.appendChild(a); | |||
| } | |||
| @@ -120,9 +118,8 @@ class WeekCalendar { | |||
| const tomorrow = new Date(this.today); tomorrow.setDate(this.today.getDate() + 1); | |||
| const yesterday = new Date(this.today); yesterday.setDate(this.today.getDate() - 1); | |||
| // JS getDay(): 0=So, 1=Mo...6=Sa → weekdays[0]=Montag, also index = getDay()-1, So=6 | |||
| const jsDay = d.getDay(); | |||
| const isoIdx = jsDay === 0 ? 6 : jsDay - 1; | |||
| const jsDay = d.getDay(); | |||
| const isoIdx = jsDay === 0 ? 6 : jsDay - 1; | |||
| const weekday = this.weekdays[isoIdx] ?? ''; | |||
| let prefix; | |||
| @@ -149,7 +146,6 @@ class WeekCalendar { | |||
| 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`; | |||
| @@ -167,7 +163,9 @@ class WeekCalendar { | |||
| if (!this.monthEl) return; | |||
| this.monthEl.classList.remove('month-calendar--visible'); | |||
| this.monthEl.classList.add('month-calendar--hidden'); | |||
| setTimeout(() => { this.monthEl?.remove(); this.monthEl = null; }, 280); | |||
| const el = this.monthEl; | |||
| setTimeout(() => el.remove(), FADE_MS + 100); | |||
| this.monthEl = null; | |||
| this.monthOpen = false; | |||
| this.calBtn.classList.remove('week-nav__cal--active'); | |||
| } | |||
| @@ -189,7 +187,7 @@ class WeekCalendar { | |||
| <button class="month-calendar__arrow month-nav-prev" title="${t('prevMonth')}"> | |||
| <svg viewBox="0 0 8 14" fill="none"><path d="M7 1L1 7L7 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg> | |||
| </button> | |||
| <span class="month-calendar__title">${this.months[month] ?? ''} ${year}</span> | |||
| <span class="month-calendar__title">${esc(this.months[month] ?? '')} ${year}</span> | |||
| <button class="month-calendar__arrow month-nav-next" title="${t('nextMonth')}"> | |||
| <svg viewBox="0 0 8 14" fill="none"><path d="M1 1L7 7L1 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg> | |||
| </button> | |||
| @@ -206,7 +204,7 @@ class WeekCalendar { | |||
| </div> | |||
| <div class="month-calendar__grid"> | |||
| <div class="month-calendar__weekdays"> | |||
| ${this.weekdaysShort.map(d => `<span>${d}</span>`).join('')} | |||
| ${this.weekdaysShort.map(d => `<span>${esc(d)}</span>`).join('')} | |||
| </div> | |||
| <div class="month-calendar__days">`; | |||
| @@ -1,29 +1,31 @@ | |||
| // assets/scripts/crud.js | |||
| // Generisches CRUD-Handler für Kunden, Projekte, Leistungen | |||
| import { esc, createTranslator, ANIMATION_MS, removeWithAnimation, animateIn } from './utils.js'; | |||
| const api = window.CRUD?.apiBase ?? ''; | |||
| // ── Hilfsfunktionen ─────────────────────────────────────────────────────────── | |||
| const t = createTranslator('CRUD'); | |||
| // ── Hilfsfunktionen ────────────────────────────────────────────────────────── | |||
| function buildClientOptions(selectedId = null) { | |||
| const clients = window.CRUD?.clients ?? []; | |||
| let html = '<option value="">Bitte wählen</option>'; | |||
| let html = `<option value="">${t('selectPh')}</option>`; | |||
| clients.forEach(c => { | |||
| const sel = String(c.id) === String(selectedId) ? ' selected' : ''; | |||
| html += `<option value="${c.id}"${sel}>${c.name}</option>`; | |||
| html += `<option value="${c.id}"${sel}>${esc(c.name)}</option>`; | |||
| }); | |||
| return html; | |||
| } | |||
| function rowPrefix() { | |||
| // Ermittelt den Entitätstyp aus der URL | |||
| if (location.pathname.includes('/clients')) return 'client'; | |||
| if (location.pathname.includes('/projects')) return 'project'; | |||
| if (location.pathname.includes('/services')) return 'service'; | |||
| return 'row'; | |||
| } | |||
| // ── Create-Formular ─────────────────────────────────────────────────────────── | |||
| // ── Create-Formular ────────────────────────────────────────────────────────── | |||
| function initCreateForm() { | |||
| const btnNew = document.getElementById('btn-new'); | |||
| @@ -47,8 +49,7 @@ function initCreateForm() { | |||
| } | |||
| function resetCreateForm() { | |||
| const fields = ['create-name', 'create-note']; | |||
| fields.forEach(id => { | |||
| ['create-name', 'create-note'].forEach(id => { | |||
| const el = document.getElementById(id); | |||
| if (el) el.value = ''; | |||
| }); | |||
| @@ -62,10 +63,12 @@ function resetCreateForm() { | |||
| async function createEntity() { | |||
| const name = document.getElementById('create-name')?.value?.trim(); | |||
| if (!name) { alert('Bitte einen Namen eingeben.'); return; } | |||
| if (!name) { alert(t('errorNoName')); return; } | |||
| const btn = document.getElementById('btn-create-save'); | |||
| const body = buildCreateBody(); | |||
| if (btn) btn.disabled = true; | |||
| try { | |||
| const res = await fetch(api, { | |||
| method: 'POST', | |||
| @@ -75,7 +78,7 @@ async function createEntity() { | |||
| if (!res.ok) { | |||
| const err = await res.json().catch(() => ({})); | |||
| alert(err.error ?? 'Fehler beim Speichern.'); | |||
| alert(err.error ?? t('errorSave')); | |||
| return; | |||
| } | |||
| @@ -83,10 +86,10 @@ async function createEntity() { | |||
| appendRowToList(data); | |||
| document.getElementById('crud-create')?.classList.remove('crud-create--visible'); | |||
| resetCreateForm(); | |||
| } catch (err) { | |||
| console.error(err); | |||
| alert('Fehler beim Speichern.'); | |||
| } catch { | |||
| alert(t('errorSave')); | |||
| } finally { | |||
| if (btn) btn.disabled = false; | |||
| } | |||
| } | |||
| @@ -96,22 +99,19 @@ function buildCreateBody() { | |||
| note: document.getElementById('create-note')?.value || null, | |||
| }; | |||
| // Kunden-spezifisch | |||
| const rate = document.getElementById('create-rate'); | |||
| if (rate) body.hourlyRate = rate.value || null; | |||
| // Projekt-spezifisch | |||
| const client = document.getElementById('create-client'); | |||
| if (client) body.clientId = parseInt(client.value) || null; | |||
| if (client) body.clientId = parseInt(client.value, 10) || null; | |||
| // Leistungs-spezifisch | |||
| const billable = document.getElementById('create-billable'); | |||
| if (billable) body.billable = billable.checked; | |||
| return body; | |||
| } | |||
| // ── Liste: Event Delegation ──────────────────────────────────────────────────── | |||
| // ── Liste: Event Delegation ────────────────────────────────────────────────── | |||
| function initList() { | |||
| const list = document.getElementById('crud-list'); | |||
| @@ -121,21 +121,20 @@ function initList() { | |||
| const actionEl = e.target.closest('[data-action]'); | |||
| if (!actionEl) return; | |||
| const action = actionEl.dataset.action; | |||
| const row = e.target.closest('.crud-row'); | |||
| const row = e.target.closest('.crud-row'); | |||
| if (!row) return; | |||
| switch (action) { | |||
| case 'edit': openEdit(row); break; | |||
| case 'delete': deleteRow(row); break; | |||
| case 'save': saveEdit(row); break; | |||
| case 'cancel': closeEdit(row); break; | |||
| case 'unarchive': unarchiveRow(row); break; | |||
| switch (actionEl.dataset.action) { | |||
| case 'edit': openEdit(row); break; | |||
| case 'delete': deleteRow(row); break; | |||
| case 'save': saveEdit(row); break; | |||
| case 'cancel': closeEdit(row); break; | |||
| case 'unarchive': unarchiveRow(row); break; | |||
| } | |||
| }); | |||
| } | |||
| // ── Inline Edit ─────────────────────────────────────────────────────────────── | |||
| // ── Inline Edit ────────────────────────────────────────────────────────────── | |||
| function openEdit(row) { | |||
| row.querySelector('.crud-row__display').hidden = true; | |||
| @@ -149,29 +148,31 @@ function closeEdit(row) { | |||
| } | |||
| async function saveEdit(row) { | |||
| const id = row.dataset.id; | |||
| const name = row.querySelector('.edit-name')?.value?.trim(); | |||
| const saveBtn = row.querySelector('[data-action="save"]'); | |||
| if (saveBtn?.disabled) return; | |||
| if (!name) { alert('Bitte einen Namen eingeben.'); return; } | |||
| const name = row.querySelector('.edit-name')?.value?.trim(); | |||
| if (!name) { alert(t('errorNoName')); return; } | |||
| const body = buildEditBody(row); | |||
| if (saveBtn) saveBtn.disabled = true; | |||
| try { | |||
| const res = await fetch(`${api}/${id}`, { | |||
| const res = await fetch(`${api}/${row.dataset.id}`, { | |||
| method: 'PATCH', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify(body), | |||
| }); | |||
| if (!res.ok) { alert('Fehler beim Speichern.'); return; } | |||
| if (!res.ok) { alert(t('errorSave')); return; } | |||
| const data = await res.json(); | |||
| updateRowDisplay(row, data); | |||
| closeEdit(row); | |||
| } catch (err) { | |||
| console.error(err); | |||
| alert('Fehler beim Speichern.'); | |||
| } catch { | |||
| alert(t('errorSave')); | |||
| } finally { | |||
| if (saveBtn) saveBtn.disabled = false; | |||
| } | |||
| } | |||
| @@ -181,15 +182,12 @@ function buildEditBody(row) { | |||
| note: row.querySelector('.edit-note')?.value || null, | |||
| }; | |||
| // Kunden | |||
| const rate = row.querySelector('.edit-rate'); | |||
| if (rate) body.hourlyRate = rate.value || null; | |||
| // Projekt | |||
| const client = row.querySelector('.edit-client'); | |||
| if (client) body.clientId = parseInt(client.value) || null; | |||
| if (client) body.clientId = parseInt(client.value, 10) || null; | |||
| // Leistung | |||
| const billable = row.querySelector('.edit-billable'); | |||
| if (billable) body.billable = billable.checked; | |||
| @@ -201,19 +199,14 @@ function updateRowDisplay(row, data) { | |||
| const metaEl = row.querySelector('.crud-row__meta'); | |||
| if (nameEl) nameEl.textContent = data.name; | |||
| // Kunden: Meta-Text unverändert (Projektanzahl ändert sich nicht) | |||
| // Projekte: Client-Name aktualisieren | |||
| if (data.clientName && metaEl) metaEl.textContent = data.clientName; | |||
| // data-Attribute aktualisieren | |||
| row.dataset.name = data.name; | |||
| if (data.hourlyRate !== undefined) row.dataset.rate = data.hourlyRate ?? ''; | |||
| if (data.hourlyRate !== undefined) row.dataset.rate = data.hourlyRate ?? ''; | |||
| if (data.clientId !== undefined) row.dataset.clientId = data.clientId; | |||
| if (data.billable !== undefined) row.dataset.billable = data.billable ? '1' : '0'; | |||
| if (data.note !== undefined) row.dataset.note = data.note ?? ''; | |||
| if (data.note !== undefined) row.dataset.note = data.note ?? ''; | |||
| // Edit-Felder aktualisieren | |||
| const editName = row.querySelector('.edit-name'); | |||
| if (editName) editName.value = data.name; | |||
| @@ -227,58 +220,54 @@ function updateRowDisplay(row, data) { | |||
| if (editBillable) editBillable.checked = !!data.billable; | |||
| } | |||
| // ── Delete ──────────────────────────────────────────────────────────────────── | |||
| // ── Delete ─────────────────────────────────────────────────────────────────── | |||
| async function deleteRow(row) { | |||
| if (!confirm('Wirklich löschen?')) return; | |||
| if (!confirm(t('confirmDelete'))) return; | |||
| try { | |||
| const res = await fetch(`${api}/${row.dataset.id}`, { method: 'DELETE' }); | |||
| if (res.status === 409) { | |||
| if (confirm('Dieser Eintrag hat abhängige Zeiteinträge und kann nicht gelöscht werden.\nStattdessen archivieren?')) { | |||
| if (confirm(t('confirmArchive'))) { | |||
| await archiveRow(row); | |||
| } | |||
| return; | |||
| } | |||
| if (!res.ok) { alert('Fehler beim Löschen.'); return; } | |||
| row.classList.add('crud-row--removing'); | |||
| setTimeout(() => row.remove(), 280); | |||
| if (!res.ok) { alert(t('errorDelete')); return; } | |||
| removeWithAnimation(row, 'crud-row--removing'); | |||
| } catch { | |||
| alert('Fehler beim Löschen.'); | |||
| alert(t('errorDelete')); | |||
| } | |||
| } | |||
| async function archiveRow(row) { | |||
| try { | |||
| const res = await fetch(`${api}/${row.dataset.id}/archive`, { method: 'PATCH' }); | |||
| if (!res.ok) { alert('Fehler beim Archivieren.'); return; } | |||
| if (!res.ok) { alert(t('errorArchive')); return; } | |||
| row.dataset.archived = '1'; | |||
| row.classList.add('crud-row--archived'); | |||
| updateRowArchivedState(row, true); | |||
| filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active'); | |||
| } catch { | |||
| alert('Fehler beim Archivieren.'); | |||
| alert(t('errorArchive')); | |||
| } | |||
| } | |||
| async function unarchiveRow(row) { | |||
| try { | |||
| const res = await fetch(`${api}/${row.dataset.id}/unarchive`, { method: 'PATCH' }); | |||
| if (!res.ok) { alert('Fehler beim Wiederherstellen.'); return; } | |||
| if (!res.ok) { alert(t('errorRestore')); return; } | |||
| row.dataset.archived = '0'; | |||
| row.classList.remove('crud-row--archived'); | |||
| updateRowArchivedState(row, false); | |||
| filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active'); | |||
| } catch { | |||
| alert('Fehler beim Wiederherstellen.'); | |||
| alert(t('errorRestore')); | |||
| } | |||
| } | |||
| @@ -288,16 +277,16 @@ function updateRowArchivedState(row, archived) { | |||
| if (archived) { | |||
| actions.innerHTML = ` | |||
| <button class="crud-row__btn crud-row__btn--restore" data-action="unarchive" title="Wiederherstellen"> | |||
| <button class="crud-row__btn crud-row__btn--restore" data-action="unarchive" title="${t('btnRestore')}"> | |||
| <svg viewBox="0 0 16 16" fill="none"><path d="M2 8a6 6 0 1 1 1.5 3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M2 13V9h4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg> | |||
| </button>`; | |||
| row.querySelector('.crud-row__edit')?.remove(); | |||
| } else { | |||
| actions.innerHTML = ` | |||
| <button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="Bearbeiten"> | |||
| <button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="${t('btnEdit')}"> | |||
| <svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg> | |||
| </button> | |||
| <button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="Löschen"> | |||
| <button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="${t('btnDelete')}"> | |||
| <svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg> | |||
| </button>`; | |||
| } | |||
| @@ -306,8 +295,8 @@ function updateRowArchivedState(row, archived) { | |||
| function filterByTab(tab) { | |||
| document.querySelectorAll('#crud-list .crud-row').forEach(row => { | |||
| row.hidden = tab === 'active' | |||
| ? row.dataset.archived === '1' | |||
| : row.dataset.archived === '0'; | |||
| ? row.dataset.archived === '1' | |||
| : row.dataset.archived === '0'; | |||
| }); | |||
| } | |||
| @@ -329,7 +318,7 @@ function initTabs() { | |||
| }); | |||
| } | |||
| // ── Neue Zeile einfügen ─────────────────────────────────────────────────────── | |||
| // ── Neue Zeile einfügen ────────────────────────────────────────────────────── | |||
| function appendRowToList(data) { | |||
| const list = document.getElementById('crud-list'); | |||
| @@ -337,9 +326,8 @@ function appendRowToList(data) { | |||
| const html = buildRowHTML(data); | |||
| // Services haben Gruppen → in die richtige Gruppe einfügen | |||
| if (data.billable !== undefined) { | |||
| const groupLabel = data.billable ? 'Verrechenbar' : 'Nicht-verrechenbar'; | |||
| const groupLabel = data.billable ? t('groupBillable') : t('groupNotBillable'); | |||
| let targetGroup = null; | |||
| list.querySelectorAll('.crud-list__group').forEach(g => { | |||
| @@ -351,17 +339,14 @@ function appendRowToList(data) { | |||
| if (targetGroup) { | |||
| targetGroup.insertAdjacentHTML('beforeend', html); | |||
| } else { | |||
| // Gruppe existiert noch nicht → neu anlegen | |||
| const groupHtml = `<div class="crud-list__group"><div class="crud-list__group-label">${groupLabel}</div>${html}</div>`; | |||
| const groupHtml = `<div class="crud-list__group"><div class="crud-list__group-label">${esc(groupLabel)}</div>${html}</div>`; | |||
| if (!data.billable) { | |||
| // Nicht-verrechenbar immer ans Ende | |||
| list.insertAdjacentHTML('beforeend', groupHtml); | |||
| } else { | |||
| // Verrechenbar vor die erste existierende Gruppe | |||
| const firstGroup = list.querySelector('.crud-list__group'); | |||
| firstGroup | |||
| ? firstGroup.insertAdjacentHTML('beforebegin', groupHtml) | |||
| : list.insertAdjacentHTML('beforeend', groupHtml); | |||
| ? firstGroup.insertAdjacentHTML('beforebegin', groupHtml) | |||
| : list.insertAdjacentHTML('beforeend', groupHtml); | |||
| } | |||
| } | |||
| } else { | |||
| @@ -370,11 +355,7 @@ function appendRowToList(data) { | |||
| const prefix = rowPrefix(); | |||
| const el = document.getElementById(`${prefix}-${data.id}`); | |||
| if (el) { | |||
| requestAnimationFrame(() => requestAnimationFrame(() => { | |||
| el.classList.remove('crud-row--new'); | |||
| })); | |||
| } | |||
| if (el) animateIn(el, 'crud-row--new'); | |||
| } | |||
| function buildRowHTML(data) { | |||
| @@ -382,48 +363,45 @@ function buildRowHTML(data) { | |||
| let metaHtml = ''; | |||
| let editFields = ''; | |||
| // Kunden | |||
| if (data.projectCount !== undefined) { | |||
| const c = data.projectCount; | |||
| metaHtml = `<span class="crud-row__meta">${c} ${c === 1 ? 'Projekt' : 'Projekte'}</span>`; | |||
| metaHtml = `<span class="crud-row__meta">${c} ${c === 1 ? t('projectSingular') : t('projectPlural')}</span>`; | |||
| editFields = ` | |||
| <label class="entry-form__label">Name</label> | |||
| <div class="entry-form__field"><input type="text" class="input edit-name" value="${data.name}" /></div> | |||
| <label class="entry-form__label">Stundensatz</label> | |||
| <label class="entry-form__label">${t('labelName')}</label> | |||
| <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div> | |||
| <label class="entry-form__label">${t('labelRate')}</label> | |||
| <div class="entry-form__field" style="gap:8px"> | |||
| <input type="number" class="input edit-rate" style="width:100px" value="${data.hourlyRate ?? ''}" step="0.01" min="0" /> | |||
| <input type="number" class="input edit-rate" style="width:100px" value="${esc(data.hourlyRate ?? '')}" step="0.01" min="0" /> | |||
| <span style="color:#7a8a9a;font-size:0.875rem">€</span> | |||
| </div> | |||
| <label class="entry-form__label">Bemerkung</label> | |||
| <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${data.note ?? ''}</textarea></div>`; | |||
| <label class="entry-form__label">${t('labelNote')}</label> | |||
| <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`; | |||
| } | |||
| // Projekte | |||
| if (data.clientName !== undefined && data.projectCount === undefined) { | |||
| metaHtml = `<span class="crud-row__meta">${data.clientName}</span>`; | |||
| metaHtml = `<span class="crud-row__meta">${esc(data.clientName)}</span>`; | |||
| editFields = ` | |||
| <label class="entry-form__label">Name</label> | |||
| <div class="entry-form__field"><input type="text" class="input edit-name" value="${data.name}" /></div> | |||
| <label class="entry-form__label">Kunde</label> | |||
| <label class="entry-form__label">${t('labelName')}</label> | |||
| <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div> | |||
| <label class="entry-form__label">${t('labelClient')}</label> | |||
| <div class="entry-form__field"><select class="select edit-client">${buildClientOptions(data.clientId)}</select></div> | |||
| <label class="entry-form__label">Bemerkung</label> | |||
| <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${data.note ?? ''}</textarea></div>`; | |||
| <label class="entry-form__label">${t('labelNote')}</label> | |||
| <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`; | |||
| } | |||
| // Leistungen | |||
| if (data.billable !== undefined) { | |||
| editFields = ` | |||
| <label class="entry-form__label">Name</label> | |||
| <div class="entry-form__field"><input type="text" class="input edit-name" value="${data.name}" /></div> | |||
| <label class="entry-form__label">Verrechenbar</label> | |||
| <label class="entry-form__label">${t('labelName')}</label> | |||
| <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div> | |||
| <label class="entry-form__label">${t('labelBillable')}</label> | |||
| <div class="entry-form__field"> | |||
| <label style="display:flex;align-items:center;gap:8px;cursor:pointer"> | |||
| <input type="checkbox" class="edit-billable" ${data.billable ? 'checked' : ''} /> | |||
| <span style="font-size:0.875rem">Ja, diese Leistung ist verrechenbar</span> | |||
| <span style="font-size:0.875rem">${t('billableLabel')}</span> | |||
| </label> | |||
| </div> | |||
| <label class="entry-form__label">Bemerkung</label> | |||
| <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${data.note ?? ''}</textarea></div>`; | |||
| <label class="entry-form__label">${t('labelNote')}</label> | |||
| <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`; | |||
| } | |||
| return ` | |||
| @@ -431,22 +409,22 @@ function buildRowHTML(data) { | |||
| id="${prefix}-${data.id}" | |||
| data-id="${data.id}" | |||
| data-archived="0" | |||
| data-name="${data.name}" | |||
| ${data.hourlyRate !== undefined ? `data-rate="${data.hourlyRate ?? ''}"` : ''} | |||
| data-name="${esc(data.name)}" | |||
| ${data.hourlyRate !== undefined ? `data-rate="${esc(data.hourlyRate ?? '')}"` : ''} | |||
| ${data.clientId !== undefined ? `data-client-id="${data.clientId}"` : ''} | |||
| ${data.billable !== undefined ? `data-billable="${data.billable ? '1' : '0'}"` : ''} | |||
| data-note="${data.note ?? ''}"> | |||
| data-note="${esc(data.note ?? '')}"> | |||
| <div class="crud-row__display"> | |||
| <div class="crud-row__info"> | |||
| <span class="crud-row__name">${data.name}</span> | |||
| <span class="crud-row__name">${esc(data.name)}</span> | |||
| ${metaHtml} | |||
| </div> | |||
| <div class="crud-row__actions"> | |||
| <button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="Bearbeiten"> | |||
| <button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="${t('btnEdit')}"> | |||
| <svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg> | |||
| </button> | |||
| <button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="Löschen"> | |||
| <button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="${t('btnDelete')}"> | |||
| <svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg> | |||
| </button> | |||
| </div> | |||
| @@ -456,15 +434,15 @@ function buildRowHTML(data) { | |||
| <div class="entry-form__grid entry-form__grid--inline"> | |||
| ${editFields} | |||
| <div class="entry-form__actions"> | |||
| <button type="button" class="btn btn-primary" data-action="save">Sichern</button> | |||
| <button type="button" class="btn btn-secondary" data-action="cancel">Abbrechen</button> | |||
| <button type="button" class="btn btn-primary" data-action="save">${t('btnSave')}</button> | |||
| <button type="button" class="btn btn-secondary" data-action="cancel">${t('btnCancel')}</button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div>`; | |||
| } | |||
| // ── Init ────────────────────────────────────────────────────────────────────── | |||
| // ── Init ───────────────────────────────────────────────────────────────────── | |||
| document.addEventListener('DOMContentLoaded', () => { | |||
| initCreateForm(); | |||
| @@ -1,96 +1,75 @@ | |||
| // assets/scripts/duration.js | |||
| // Zentrale Logik für Zeiteingabe – wird von entries.js importiert | |||
| // ── Konfiguration ───────────────────────────────────────────────────────────── | |||
| // Auf false setzen um Viertelstunden-Runden zu deaktivieren | |||
| export const DURATION_CONFIG = { | |||
| roundToQuarter: true, | |||
| }; | |||
| // ── Parser ──────────────────────────────────────────────────────────────────── | |||
| /** | |||
| * Parst Zeiteingaben in Minuten. | |||
| * | |||
| * Unterstützte Formate: | |||
| * "1:30" → 90 (Stunden:Minuten) | |||
| * "8 12" → 240 (von 8 bis 12 Uhr) | |||
| * "1,75" → 105 (Dezimalstunden mit Komma) | |||
| * "1.75" → 105 (Dezimalstunden mit Punkt) | |||
| * "2" → 120 (nur Stunden als ganze Zahl) | |||
| * "0:00" → 0 (Stopp/Reset) | |||
| */ | |||
| export function parseDuration(input) { | |||
| input = String(input).trim(); | |||
| if (!input || input === '0' || input === '0:00') return 0; | |||
| // "8 12" → von 8 bis 12 Uhr | |||
| // "8 12" -> von 8 bis 12 Uhr | |||
| if (/^\d+\s+\d+$/.test(input)) { | |||
| const parts = input.split(/\s+/).map(Number); | |||
| const minutes = (parts[1] - parts[0]) * 60; | |||
| return Math.max(0, minutes); | |||
| return Math.max(0, (parts[1] - parts[0]) * 60); | |||
| } | |||
| // "1:30" → Stunden:Minuten | |||
| // "1:30" -> Stunden:Minuten | |||
| if (input.includes(':')) { | |||
| const [h, m] = input.split(':').map(s => parseInt(s) || 0); | |||
| const [h, m] = input.split(':').map(s => parseInt(s, 10) || 0); | |||
| return h * 60 + m; | |||
| } | |||
| // "1,75" oder "1.75" → Dezimalstunden | |||
| // "1,75" oder "1.75" -> Dezimalstunden | |||
| if (input.includes(',') || input.includes('.')) { | |||
| const hours = parseFloat(input.replace(',', '.')); | |||
| return isNaN(hours) ? 0 : Math.round(hours * 60); | |||
| } | |||
| // "2" → 2 Stunden | |||
| const hours = parseInt(input); | |||
| // "2" -> 2 Stunden | |||
| const hours = parseInt(input, 10); | |||
| return isNaN(hours) ? 0 : hours * 60; | |||
| } | |||
| // ── Rounding ────────────────────────────────────────────────────────────────── | |||
| /** | |||
| * Rundet Minuten auf die nächste Viertelstunde auf. | |||
| * 0 bleibt 0 (Stopp). | |||
| */ | |||
| export function roundToQuarter(minutes) { | |||
| if (!DURATION_CONFIG.roundToQuarter) return minutes; | |||
| if (minutes === 0) return 0; | |||
| const interval = window.TT?.trackingInterval ?? 15; | |||
| const interval = window.TT?.trackingInterval ?? window.Report?.trackingInterval ?? 15; | |||
| return Math.ceil(minutes / interval) * interval; | |||
| } | |||
| // ── Formatter ───────────────────────────────────────────────────────────────── | |||
| export function formatMinutes(minutes) { | |||
| const h = Math.floor(minutes / 60); | |||
| const m = minutes % 60; | |||
| return `${h}:${String(m).padStart(2, '0')}`; | |||
| } | |||
| // ── Blur-Handler (global, per Event Delegation) ─────────────────────────────── | |||
| // Reagiert auf blur an allen Dauer-Inputs, egal ob server-gerendert oder JS-erstellt | |||
| export function validateDuration(minutes) { | |||
| if (minutes > 1440) return { status: 'error' }; | |||
| if (minutes > 480) return { status: 'warn' }; | |||
| return { status: 'ok' }; | |||
| } | |||
| export function parseAndValidate(raw) { | |||
| const minutes = roundToQuarter(parseDuration(raw)); | |||
| const formatted = formatMinutes(minutes); | |||
| if (minutes === 0) return { minutes, formatted, error: 'errorZeroDuration' }; | |||
| const v = validateDuration(minutes); | |||
| if (v.status === 'error') return { minutes, formatted, error: 'errorDurationTooLong' }; | |||
| if (v.status === 'warn') return { minutes, formatted, warn: 'warnDurationLong' }; | |||
| return { minutes, formatted }; | |||
| } | |||
| export function initDurationBlurHandler() { | |||
| document.addEventListener('blur', e => { | |||
| if (!(e.target instanceof Element)) return; | |||
| if (!e.target.matches('#create-duration, .edit-duration')) return; | |||
| const raw = e.target.value; | |||
| const minutes = roundToQuarter(parseDuration(raw)); | |||
| const minutes = roundToQuarter(parseDuration(e.target.value)); | |||
| e.target.value = formatMinutes(minutes); | |||
| }, true); // capture=true, weil blur nicht bubbled | |||
| } | |||
| /** | |||
| * Validiert eine Dauer in Minuten. | |||
| * > 1440 (24h) → error | |||
| * > 480 (8h) → warn | |||
| */ | |||
| export function validateDuration(minutes) { | |||
| if (minutes > 1440) return { status: 'error' }; | |||
| if (minutes > 480) return { status: 'warn' }; | |||
| return { status: 'ok' }; | |||
| }, true); | |||
| } | |||
| @@ -1,14 +1,17 @@ | |||
| // assets/scripts/entries.js | |||
| import { parseDuration, roundToQuarter, formatMinutes, initDurationBlurHandler, validateDuration } from './duration.js'; | |||
| import { parseAndValidate, initDurationBlurHandler } from './duration.js'; | |||
| import { esc, createTranslator, ANIMATION_MS, FADE_MS, MINUTES_PER_DAY, removeWithAnimation, animateIn } from './utils.js'; | |||
| const LAST_PROJECT_KEY = 'tt_last_project_id'; | |||
| const LAST_SERVICE_KEY = 'tt_last_service_id'; | |||
| const NOTE_KEY = 'tt_minimal_note_open'; | |||
| const LOCK_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="3" y="7.5" width="10" height="7" rx="1.5" stroke="currentColor" stroke-width="1.3"/><path d="M5.5 7.5V5.5a2.5 2.5 0 015 0v2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`; | |||
| function t(key) { | |||
| return window.TT?.i18n?.[key] ?? key; | |||
| } | |||
| const t = createTranslator('TT'); | |||
| // ── Select-Builder ─────────────────────────────────────────────────────────── | |||
| function buildProjectOptions(selectedId = null) { | |||
| const groups = {}; | |||
| @@ -19,10 +22,10 @@ function buildProjectOptions(selectedId = null) { | |||
| let html = `<option value="">${t('selectPh')}</option>`; | |||
| for (const [client, projects] of Object.entries(groups)) { | |||
| html += `<optgroup label="${client}">`; | |||
| html += `<optgroup label="${esc(client)}">`; | |||
| projects.forEach(p => { | |||
| const sel = String(p.id) === String(selectedId) ? ' selected' : ''; | |||
| html += `<option value="${p.id}"${sel}>${p.name}</option>`; | |||
| html += `<option value="${p.id}"${sel}>${esc(p.name)}</option>`; | |||
| }); | |||
| html += '</optgroup>'; | |||
| } | |||
| @@ -35,36 +38,33 @@ function buildServiceOptions(selectedId = null) { | |||
| let html = `<option value="">${t('selectPh')}</option>`; | |||
| if (billable.length) { | |||
| html += `<optgroup label="${t('billable')}">`; | |||
| billable.forEach(s => { | |||
| const addGroup = (label, list) => { | |||
| if (!list.length) return; | |||
| html += `<optgroup label="${esc(label)}">`; | |||
| list.forEach(s => { | |||
| const sel = String(s.id) === String(selectedId) ? ' selected' : ''; | |||
| html += `<option value="${s.id}"${sel}>${s.name}</option>`; | |||
| html += `<option value="${s.id}"${sel}>${esc(s.name)}</option>`; | |||
| }); | |||
| html += '</optgroup>'; | |||
| } | |||
| }; | |||
| if (notBillable.length) { | |||
| html += `<optgroup label="${t('notBillable')}">`; | |||
| notBillable.forEach(s => { | |||
| const sel = String(s.id) === String(selectedId) ? ' selected' : ''; | |||
| html += `<option value="${s.id}"${sel}>${s.name}</option>`; | |||
| }); | |||
| html += '</optgroup>'; | |||
| } | |||
| addGroup(t('billable'), billable); | |||
| addGroup(t('notBillable'), notBillable); | |||
| return html; | |||
| } | |||
| // ── Row HTML ───────────────────────────────────────────────────────────────── | |||
| function buildEntryRowHTML(entry, animate = false) { | |||
| const servicePart = entry.serviceName ? ` / ${entry.serviceName}` : ''; | |||
| const notePart = entry.note ? `<div class="entry-row__note">${entry.note}</div>` : ''; | |||
| const servicePart = entry.serviceName ? ` / ${esc(entry.serviceName)}` : ''; | |||
| const notePart = entry.note ? `<div class="entry-row__note">${esc(entry.note)}</div>` : ''; | |||
| const invoiced = !!entry.invoiced; | |||
| const actionsHtml = invoiced | |||
| ? `<span class="entry-row__badge">${entry.durationFormatted}</span> | |||
| ? `<span class="entry-row__badge">${esc(entry.durationFormatted)}</span> | |||
| <span class="entry-row__lock-indicator" title="${t('invoicedTitle')}">${LOCK_SVG}</span>` | |||
| : `<span class="entry-row__badge">${entry.durationFormatted}</span> | |||
| : `<span class="entry-row__badge">${esc(entry.durationFormatted)}</span> | |||
| <button class="entry-row__btn entry-row__btn--edit" title="${t('btnEdit')}" data-action="edit"> | |||
| <svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg> | |||
| </button> | |||
| @@ -78,7 +78,7 @@ function buildEntryRowHTML(entry, animate = false) { | |||
| <label class="entry-form__label">${t('labelDuration')}</label> | |||
| <div class="entry-form__field"> | |||
| <input type="text" class="input input--sm edit-duration" | |||
| value="${entry.durationFormatted}" autocomplete="off" /> | |||
| value="${esc(entry.durationFormatted)}" autocomplete="off" /> | |||
| <div class="duration-help"> | |||
| <span class="duration-help__icon">?</span> | |||
| <span class="duration-help__hint">${t('durationHint')}</span> | |||
| @@ -91,7 +91,7 @@ function buildEntryRowHTML(entry, animate = false) { | |||
| </div> | |||
| <label class="entry-form__label">${t('labelNote')}</label> | |||
| <div class="entry-form__field"> | |||
| <textarea class="textarea edit-note" rows="3">${entry.note ?? ''}</textarea> | |||
| <textarea class="textarea edit-note" rows="3">${esc(entry.note ?? '')}</textarea> | |||
| </div> | |||
| <div class="entry-form__actions"> | |||
| <button type="button" class="btn btn-primary" data-action="save">${t('btnSave')}</button> | |||
| @@ -107,12 +107,12 @@ function buildEntryRowHTML(entry, animate = false) { | |||
| data-duration="${entry.duration}" | |||
| data-project-id="${entry.projectId}" | |||
| data-service-id="${entry.serviceId ?? ''}" | |||
| data-note="${(entry.note ?? '').replace(/"/g, '"')}" | |||
| data-note="${esc(entry.note ?? '')}" | |||
| data-invoiced="${invoiced ? 'true' : 'false'}"> | |||
| <div class="entry-row__display"> | |||
| <div class="entry-row__info"> | |||
| <div class="entry-row__title">${entry.clientName} / ${entry.projectName}${servicePart}</div> | |||
| <div class="entry-row__title">${esc(entry.clientName)} / ${esc(entry.projectName)}${servicePart}</div> | |||
| ${notePart} | |||
| </div> | |||
| <div class="entry-row__actions"> | |||
| @@ -123,6 +123,23 @@ function buildEntryRowHTML(entry, animate = false) { | |||
| </div>`; | |||
| } | |||
| // ── Helpers ────────────────────────────────────────────────────────────────── | |||
| function getDailyTotalMinutes() { | |||
| let total = 0; | |||
| document.querySelectorAll('#entry-items .entry-row').forEach(row => { | |||
| total += parseInt(row.dataset.duration, 10) || 0; | |||
| }); | |||
| return total; | |||
| } | |||
| function saveLastProject(id) { if (id) localStorage.setItem(LAST_PROJECT_KEY, id); } | |||
| function getLastProject() { return localStorage.getItem(LAST_PROJECT_KEY); } | |||
| function saveLastService(id) { if (id) localStorage.setItem(LAST_SERVICE_KEY, id); } | |||
| function getLastService() { return localStorage.getItem(LAST_SERVICE_KEY); } | |||
| // ── EntryManager ───────────────────────────────────────────────────────────── | |||
| class EntryManager { | |||
| constructor() { | |||
| this.list = document.getElementById('entry-list'); | |||
| @@ -133,13 +150,8 @@ class EntryManager { | |||
| const cp = document.getElementById('create-project'); | |||
| const cs = document.getElementById('create-service'); | |||
| document.getElementById('create-service')?.addEventListener('change', e => { | |||
| saveLastService(e.target.value); | |||
| }); | |||
| document.getElementById('create-project')?.addEventListener('change', e => { | |||
| saveLastProject(e.target.value); | |||
| }); | |||
| cs?.addEventListener('change', e => saveLastService(e.target.value)); | |||
| cp?.addEventListener('change', e => saveLastProject(e.target.value)); | |||
| if (cp) { | |||
| const lastProject = getLastProject(); | |||
| @@ -182,13 +194,13 @@ class EntryManager { | |||
| return; | |||
| } | |||
| // Klick auf Anzeige-Bereich (kein Button) → Edit öffnen | |||
| if (e.target.closest('.entry-row__display') && row.dataset.invoiced !== 'true') { | |||
| this.openEdit(row); | |||
| } | |||
| } | |||
| async createEntry() { | |||
| const btn = document.getElementById('btn-create'); | |||
| const durationRaw = document.getElementById('create-duration')?.value ?? '0:00'; | |||
| const projectId = document.getElementById('create-project')?.value; | |||
| const serviceId = document.getElementById('create-service')?.value; | |||
| @@ -196,36 +208,27 @@ class EntryManager { | |||
| if (!projectId) { alert(t('errorNoProject')); return; } | |||
| const duration = formatMinutes(roundToQuarter(parseDuration(durationRaw))); | |||
| if (duration === '0:00') { | |||
| alert(t('errorZeroDuration')); | |||
| return; | |||
| } | |||
| const rawMinutes = roundToQuarter(parseDuration(durationRaw)); | |||
| const validation = validateDuration(rawMinutes); | |||
| if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; } | |||
| if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return; | |||
| if (getDailyTotalMinutes() + rawMinutes > 1440) { alert(t('errorDailyLimitExceeded')); return; } | |||
| const dur = parseAndValidate(durationRaw); | |||
| if (dur.error) { alert(t(dur.error)); return; } | |||
| if (dur.warn && !confirm(t(dur.warn))) return; | |||
| if (getDailyTotalMinutes() + dur.minutes > MINUTES_PER_DAY) { alert(t('errorDailyLimitExceeded')); return; } | |||
| if (btn) btn.disabled = true; | |||
| try { | |||
| const res = await fetch('/api/entries', { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify({ | |||
| date: window.TT.activeDate, | |||
| duration, | |||
| projectId: parseInt(projectId), | |||
| serviceId: serviceId ? parseInt(serviceId) : null, | |||
| duration: dur.formatted, | |||
| projectId: parseInt(projectId, 10), | |||
| serviceId: serviceId ? parseInt(serviceId, 10) : null, | |||
| note: note || null, | |||
| }), | |||
| }); | |||
| if (!res.ok) { | |||
| const err = await res.json().catch(() => ({})); | |||
| console.error('API Fehler:', res.status, err); | |||
| alert(t('errorSave') + (err.error ? `\n${err.error}` : '')); | |||
| return; | |||
| } | |||
| @@ -234,10 +237,10 @@ class EntryManager { | |||
| this.addEntryToDOM(data.entry); | |||
| this.updateTotal(data.totalDuration); | |||
| this.resetCreateForm(); | |||
| } catch (err) { | |||
| console.error('Netzwerkfehler:', err); | |||
| } catch { | |||
| alert(t('errorSave')); | |||
| } finally { | |||
| if (btn) btn.disabled = false; | |||
| } | |||
| } | |||
| @@ -255,9 +258,7 @@ class EntryManager { | |||
| items.insertAdjacentHTML('beforeend', buildEntryRowHTML(entry, true)); | |||
| const el = document.getElementById(`entry-${entry.id}`); | |||
| requestAnimationFrame(() => requestAnimationFrame(() => { | |||
| el?.classList.remove('entry-row--new'); | |||
| })); | |||
| if (el) animateIn(el, 'entry-row--new'); | |||
| } | |||
| resetCreateForm() { | |||
| @@ -272,9 +273,7 @@ class EntryManager { | |||
| } | |||
| openEdit(row) { | |||
| // Safety-Guard: invoiced-Einträge können nicht geöffnet werden | |||
| if (row.dataset.invoiced === 'true') return; | |||
| // Kein Edit-Formular vorhanden → nicht öffnen | |||
| const editSection = row.querySelector('.entry-row__edit'); | |||
| if (!editSection) return; | |||
| @@ -303,6 +302,9 @@ class EntryManager { | |||
| } | |||
| async saveEdit(row) { | |||
| const saveBtn = row.querySelector('[data-action="save"]'); | |||
| if (saveBtn?.disabled) return; | |||
| const id = row.dataset.id; | |||
| const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00'; | |||
| const projectId = row.querySelector('.edit-project')?.value; | |||
| @@ -311,35 +313,30 @@ class EntryManager { | |||
| if (!projectId) { alert(t('errorNoProject')); return; } | |||
| const duration = formatMinutes(roundToQuarter(parseDuration(durationRaw))); | |||
| const dur = parseAndValidate(durationRaw); | |||
| if (dur.error) { alert(t(dur.error)); return; } | |||
| if (dur.warn && !confirm(t(dur.warn))) return; | |||
| if (duration === '0:00') { | |||
| alert(t('errorZeroDuration')); | |||
| const currentMinutes = parseInt(row.dataset.duration, 10) || 0; | |||
| if (getDailyTotalMinutes() - currentMinutes + dur.minutes > MINUTES_PER_DAY) { | |||
| alert(t('errorDailyLimitExceeded')); | |||
| return; | |||
| } | |||
| const rawMinutes = roundToQuarter(parseDuration(durationRaw)); | |||
| const validation = validateDuration(rawMinutes); | |||
| 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; } | |||
| if (saveBtn) saveBtn.disabled = true; | |||
| try { | |||
| const res = await fetch(`/api/entries/${id}`, { | |||
| method: 'PATCH', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify({ | |||
| duration, | |||
| projectId: parseInt(projectId), | |||
| serviceId: serviceId ? parseInt(serviceId) : null, | |||
| duration: dur.formatted, | |||
| projectId: parseInt(projectId, 10), | |||
| serviceId: serviceId ? parseInt(serviceId, 10) : null, | |||
| note: note || null, | |||
| }), | |||
| }); | |||
| if (!res.ok) { | |||
| console.error('PATCH fehlgeschlagen:', res.status); | |||
| alert(t('errorSave')); | |||
| return; | |||
| } | |||
| @@ -348,10 +345,10 @@ class EntryManager { | |||
| this.updateRowDisplay(row, data.entry); | |||
| this.updateTotal(data.totalDuration); | |||
| this.closeEdit(row); | |||
| } catch (err) { | |||
| console.error('saveEdit Fehler:', err); | |||
| } catch { | |||
| alert(t('errorSave')); | |||
| } finally { | |||
| if (saveBtn) saveBtn.disabled = false; | |||
| } | |||
| } | |||
| @@ -388,14 +385,14 @@ class EntryManager { | |||
| if (!res.ok) { alert(t('errorDelete')); return; } | |||
| const data = await res.json(); | |||
| row.classList.add('entry-row--removing'); | |||
| removeWithAnimation(row, 'entry-row--removing'); | |||
| setTimeout(() => { | |||
| row.remove(); | |||
| this.updateTotal(data.totalDuration); | |||
| this.checkIfEmpty(); | |||
| }, 280); | |||
| } catch { alert(t('errorDelete')); } | |||
| }, ANIMATION_MS); | |||
| } catch { | |||
| alert(t('errorDelete')); | |||
| } | |||
| } | |||
| async loadEntriesForDate(dateStr) { | |||
| @@ -403,9 +400,9 @@ class EntryManager { | |||
| try { | |||
| this.list.classList.add('entry-list--fading'); | |||
| await new Promise(r => setTimeout(r, 180)); | |||
| await new Promise(r => setTimeout(r, FADE_MS)); | |||
| const res = await fetch(`/api/entries?date=${dateStr}`); | |||
| const res = await fetch(`/api/entries?date=${dateStr}`); | |||
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |||
| const data = await res.json(); | |||
| @@ -428,7 +425,7 @@ class EntryManager { | |||
| let html = '<div class="entry-list__items" id="entry-items">'; | |||
| entries.forEach(e => { html += buildEntryRowHTML(e, false); }); | |||
| html += `</div><div class="entry-list__footer" id="entry-footer"> | |||
| <span class="entry-list__total">${totalDuration}</span></div>`; | |||
| <span class="entry-list__total">${esc(totalDuration)}</span></div>`; | |||
| this.list.innerHTML = html; | |||
| this.emptyState = null; | |||
| @@ -449,7 +446,7 @@ class EntryManager { | |||
| footer.id = 'entry-footer'; | |||
| this.list.appendChild(footer); | |||
| } | |||
| footer.innerHTML = `<span class="entry-list__total">${totalDuration}</span>`; | |||
| footer.innerHTML = `<span class="entry-list__total">${esc(totalDuration)}</span>`; | |||
| } | |||
| hideEmptyState() { this.emptyState?.remove(); this.emptyState = null; } | |||
| @@ -466,33 +463,7 @@ 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); | |||
| } | |||
| function getLastProject() { | |||
| return localStorage.getItem(LAST_PROJECT_KEY); | |||
| } | |||
| function saveLastService(serviceId) { | |||
| if (serviceId) localStorage.setItem(LAST_SERVICE_KEY, serviceId); | |||
| } | |||
| function getLastService() { | |||
| return localStorage.getItem(LAST_SERVICE_KEY); | |||
| } | |||
| // ── Minimal-Modus-Initialisierung ───────────────────────────────────────────── | |||
| const NOTE_KEY = 'tt_minimal_note_open'; | |||
| // ── Minimal-Modus ──────────────────────────────────────────────────────────── | |||
| function initMinimalMode() { | |||
| if (document.body.dataset.theme !== 'minimal') return; | |||
| @@ -527,9 +498,9 @@ function initWeekToggle() { | |||
| } | |||
| 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'); | |||
| 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'; | |||
| @@ -539,7 +510,7 @@ function initNoteToggle() { | |||
| 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'; | |||
| btn.textContent = nowOpen ? t('noteHide') : t('noteShow'); | |||
| localStorage.setItem(NOTE_KEY, nowOpen ? '1' : '0'); | |||
| }); | |||
| } | |||
| @@ -549,18 +520,17 @@ function setNoteVisible(open, btn, label, field) { | |||
| label?.classList.add('is-visible'); | |||
| field?.classList.add('is-visible'); | |||
| btn.classList.add('is-open'); | |||
| btn.textContent = '× Bemerkung ausblenden'; | |||
| btn.textContent = t('noteHide'); | |||
| } else { | |||
| btn.textContent = '+ Bemerkung hinzufügen'; | |||
| btn.textContent = t('noteShow'); | |||
| } | |||
| } | |||
| function initEntriesToggle() { | |||
| const summaryBtn = document.getElementById('btn-entries-toggle'); | |||
| const entryList = document.getElementById('entry-list'); | |||
| 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'); | |||
| @@ -1,87 +1,88 @@ | |||
| // assets/scripts/registration.js | |||
| import { esc, createTranslator } from './utils.js'; | |||
| const t = createTranslator('Register'); | |||
| document.addEventListener('DOMContentLoaded', () => { | |||
| const form = document.getElementById('register-form'); | |||
| const companyInput = document.getElementById('companyName'); | |||
| const slugPreview = document.getElementById('slug-preview'); | |||
| const submitBtn = document.getElementById('submit-btn'); | |||
| const errorBox = document.getElementById('register-errors'); | |||
| const appDomain = window.REGISTER_APP_DOMAIN ?? ''; | |||
| const form = document.getElementById('register-form'); | |||
| const companyInput = document.getElementById('companyName'); | |||
| const slugPreview = document.getElementById('slug-preview'); | |||
| const submitBtn = document.getElementById('submit-btn'); | |||
| const errorBox = document.getElementById('register-errors'); | |||
| const appDomain = window.Register?.appDomain ?? ''; | |||
| // ── Slug-Vorschau ───────────────────────────────────────────────────────── | |||
| let debounceTimer = null; | |||
| companyInput?.addEventListener('input', () => { | |||
| clearTimeout(debounceTimer); | |||
| debounceTimer = setTimeout(async () => { | |||
| const value = companyInput.value.trim(); | |||
| if (!value) { slugPreview.textContent = ''; return; } | |||
| // ── Slug-Vorschau ───────────────────────────────────────────────────────── | |||
| let debounce = null; | |||
| companyInput?.addEventListener('input', () => { | |||
| clearTimeout(debounce); | |||
| debounce = setTimeout(async () => { | |||
| const value = companyInput.value.trim(); | |||
| if (!value) { slugPreview.textContent = ''; return; } | |||
| try { | |||
| const res = await fetch('/api/register/preview-slug', { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify({ companyName: value }), | |||
| }); | |||
| const data = await res.json(); | |||
| slugPreview.textContent = data.slug ? data.slug + '.' + appDomain : '–'; | |||
| } catch { | |||
| slugPreview.textContent = ''; | |||
| } | |||
| }, 350); | |||
| }); | |||
| try { | |||
| const res = await fetch('/api/register/preview-slug', { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify({ companyName: value }), | |||
| }); | |||
| const data = await res.json(); | |||
| slugPreview.textContent = data.slug ? data.slug + '.' + appDomain : '–'; | |||
| } catch { | |||
| slugPreview.textContent = ''; | |||
| } | |||
| }, 350); | |||
| }); | |||
| // ── Formular absenden ───────────────────────────────────────────────────── | |||
| // ── Formular absenden ───────────────────────────────────────────────────── | |||
| form?.addEventListener('submit', async (e) => { | |||
| e.preventDefault(); | |||
| errorBox.innerHTML = ''; | |||
| submitBtn.disabled = true; | |||
| submitBtn.textContent = 'Wird gesendet …'; | |||
| form?.addEventListener('submit', async (e) => { | |||
| e.preventDefault(); | |||
| errorBox.innerHTML = ''; | |||
| submitBtn.disabled = true; | |||
| submitBtn.textContent = t('sending'); | |||
| const payload = { | |||
| companyName: document.getElementById('companyName').value, | |||
| email: document.getElementById('email').value, | |||
| firstName: document.getElementById('firstName').value, | |||
| lastName: document.getElementById('lastName').value, | |||
| password: document.getElementById('password').value, | |||
| passwordRepeat: document.getElementById('passwordRepeat').value, | |||
| }; | |||
| const payload = { | |||
| companyName: document.getElementById('companyName').value, | |||
| email: document.getElementById('email').value, | |||
| firstName: document.getElementById('firstName').value, | |||
| lastName: document.getElementById('lastName').value, | |||
| password: document.getElementById('password').value, | |||
| passwordRepeat: document.getElementById('passwordRepeat').value, | |||
| }; | |||
| try { | |||
| const res = await fetch('/api/register', { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify(payload), | |||
| }); | |||
| const data = await res.json(); | |||
| try { | |||
| const res = await fetch('/api/register', { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify(payload), | |||
| }); | |||
| const data = await res.json(); | |||
| if (res.ok) { | |||
| document.querySelector('.register-page').innerHTML = ` | |||
| <div class="register-success"> | |||
| <div class="register-success__icon">✓</div> | |||
| <h2 class="register-success__title">Fast geschafft!</h2> | |||
| <p class="register-success__text"> | |||
| Wir haben eine Bestätigungs-E-Mail an | |||
| <strong>${payload.email}</strong> geschickt. | |||
| </p> | |||
| <p class="register-success__hint"> | |||
| Bitte klicke auf den Link in der E-Mail um dein Konto zu aktivieren. | |||
| Der Link ist 24 Stunden gültig. | |||
| </p> | |||
| </div> | |||
| `; | |||
| } else { | |||
| (data.errors ?? ['Unbekannter Fehler.']).forEach(msg => { | |||
| const p = document.createElement('p'); | |||
| p.textContent = msg; | |||
| errorBox.appendChild(p); | |||
| }); | |||
| submitBtn.disabled = false; | |||
| submitBtn.textContent = 'Konto erstellen'; | |||
| } | |||
| } catch { | |||
| errorBox.innerHTML = '<p>Verbindungsfehler. Bitte versuche es erneut.</p>'; | |||
| submitBtn.disabled = false; | |||
| submitBtn.textContent = 'Konto erstellen'; | |||
| } | |||
| }); | |||
| }); | |||
| if (res.ok) { | |||
| const text = t('successText').replace('%email%', `<strong>${esc(payload.email)}</strong>`); | |||
| document.querySelector('.register-page').innerHTML = ` | |||
| <div class="register-success"> | |||
| <div class="register-success__icon">✓</div> | |||
| <h2 class="register-success__title">${t('successTitle')}</h2> | |||
| <p class="register-success__text">${text}</p> | |||
| <p class="register-success__hint">${t('successHint')}</p> | |||
| </div> | |||
| `; | |||
| } else { | |||
| (data.errors ?? [t('errorUnknown')]).forEach(msg => { | |||
| const p = document.createElement('p'); | |||
| p.textContent = msg; | |||
| errorBox.appendChild(p); | |||
| }); | |||
| submitBtn.disabled = false; | |||
| submitBtn.textContent = t('btnSubmit'); | |||
| } | |||
| } catch { | |||
| errorBox.innerHTML = `<p>${esc(t('errorConnection'))}</p>`; | |||
| submitBtn.disabled = false; | |||
| submitBtn.textContent = t('btnSubmit'); | |||
| } | |||
| }); | |||
| }); | |||
| @@ -1,477 +1,437 @@ | |||
| // assets/scripts/report.js | |||
| import { | |||
| parseDuration, | |||
| roundToQuarter, | |||
| formatMinutes, | |||
| validateDuration, | |||
| initDurationBlurHandler, | |||
| } from './duration.js'; | |||
| // ── Hilfsfunktionen ─────────────────────────────────────────────────────────── | |||
| function t(key) { | |||
| return window.Report?.i18n?.[key] ?? key; | |||
| } | |||
| import { parseAndValidate, initDurationBlurHandler } from './duration.js'; | |||
| import { esc, createTranslator } from './utils.js'; | |||
| const t = createTranslator('Report'); | |||
| // ── Hilfsfunktionen ────────────────────────────────────────────────────────── | |||
| function populateProjectSelect(select, selectedId) { | |||
| const projects = window.Report?.projects ?? []; | |||
| select.innerHTML = ''; | |||
| projects.forEach(p => { | |||
| const opt = document.createElement('option'); | |||
| opt.value = p.id; | |||
| opt.textContent = `${p.clientName} / ${p.name}`; | |||
| if (p.id === selectedId) opt.selected = true; | |||
| select.appendChild(opt); | |||
| }); | |||
| const projects = window.Report?.projects ?? []; | |||
| select.innerHTML = ''; | |||
| projects.forEach(p => { | |||
| const opt = document.createElement('option'); | |||
| opt.value = p.id; | |||
| opt.textContent = `${p.clientName} / ${p.name}`; | |||
| if (p.id === selectedId) opt.selected = true; | |||
| select.appendChild(opt); | |||
| }); | |||
| } | |||
| function populateServiceSelect(select, selectedId) { | |||
| const services = window.Report?.services ?? []; | |||
| const billable = services.filter(s => s.billable); | |||
| const notBillable = services.filter(s => !s.billable); | |||
| select.innerHTML = `<option value="">${t('selectPh')}</option>`; | |||
| function addGroup(label, list) { | |||
| if (!list.length) return; | |||
| const group = document.createElement('optgroup'); | |||
| group.label = label; | |||
| list.forEach(s => { | |||
| const opt = document.createElement('option'); | |||
| opt.value = s.id; | |||
| opt.textContent = s.name; | |||
| if (s.id === selectedId) opt.selected = true; | |||
| group.appendChild(opt); | |||
| }); | |||
| select.appendChild(group); | |||
| } | |||
| const services = window.Report?.services ?? []; | |||
| const billable = services.filter(s => s.billable); | |||
| const notBillable = services.filter(s => !s.billable); | |||
| select.innerHTML = `<option value="">${t('selectPh')}</option>`; | |||
| function addGroup(label, list) { | |||
| if (!list.length) return; | |||
| const group = document.createElement('optgroup'); | |||
| group.label = label; | |||
| list.forEach(s => { | |||
| const opt = document.createElement('option'); | |||
| opt.value = s.id; | |||
| opt.textContent = s.name; | |||
| if (s.id === selectedId) opt.selected = true; | |||
| group.appendChild(opt); | |||
| }); | |||
| select.appendChild(group); | |||
| } | |||
| addGroup(t('billable'), billable); | |||
| addGroup(t('notBillable'), notBillable); | |||
| addGroup(t('billable'), billable); | |||
| addGroup(t('notBillable'), notBillable); | |||
| } | |||
| // ── Edit öffnen ─────────────────────────────────────────────────────────────── | |||
| // ── Edit öffnen ────────────────────────────────────────────────────────────── | |||
| function openEdit(row) { | |||
| document.querySelectorAll('.report-table__row--editing').forEach(r => { | |||
| if (r !== row) closeEdit(r); | |||
| }); | |||
| document.querySelectorAll('.report-table__row--editing').forEach(r => { | |||
| if (r !== row) closeEdit(r); | |||
| }); | |||
| const editForm = row.querySelector('.report-row__edit'); | |||
| if (!editForm) return; | |||
| const editForm = row.querySelector('.report-row__edit'); | |||
| if (!editForm) return; | |||
| // Selects klonen um akkumulierte Listener zu vermeiden | |||
| const oldProjectSel = row.querySelector('.edit-project'); | |||
| const oldServiceSel = row.querySelector('.edit-service'); | |||
| const projectSel = oldProjectSel.cloneNode(false); | |||
| const serviceSel = oldServiceSel.cloneNode(false); | |||
| oldProjectSel.replaceWith(projectSel); | |||
| oldServiceSel.replaceWith(serviceSel); | |||
| const oldProjectSel = row.querySelector('.edit-project'); | |||
| const oldServiceSel = row.querySelector('.edit-service'); | |||
| const projectSel = oldProjectSel.cloneNode(false); | |||
| const serviceSel = oldServiceSel.cloneNode(false); | |||
| oldProjectSel.replaceWith(projectSel); | |||
| oldServiceSel.replaceWith(serviceSel); | |||
| const projectId = parseInt(row.dataset.projectId) || null; | |||
| const serviceId = parseInt(row.dataset.serviceId) || null; | |||
| const projectId = parseInt(row.dataset.projectId, 10) || null; | |||
| const serviceId = parseInt(row.dataset.serviceId, 10) || null; | |||
| populateProjectSelect(projectSel, projectId); | |||
| populateServiceSelect(serviceSel, serviceId); | |||
| populateProjectSelect(projectSel, projectId); | |||
| populateServiceSelect(serviceSel, serviceId); | |||
| projectSel.addEventListener('change', () => { | |||
| populateServiceSelect(row.querySelector('.edit-service'), null); | |||
| }); | |||
| projectSel.addEventListener('change', () => { | |||
| populateServiceSelect(row.querySelector('.edit-service'), null); | |||
| }); | |||
| editForm.hidden = false; | |||
| row.classList.add('report-table__row--editing'); | |||
| row.querySelector('.edit-duration')?.focus(); | |||
| editForm.hidden = false; | |||
| row.classList.add('report-table__row--editing'); | |||
| row.querySelector('.edit-duration')?.focus(); | |||
| } | |||
| function closeEdit(row) { | |||
| const editForm = row.querySelector('.report-row__edit'); | |||
| if (!editForm) return; | |||
| editForm.hidden = true; | |||
| row.classList.remove('report-table__row--editing'); | |||
| const editForm = row.querySelector('.report-row__edit'); | |||
| if (!editForm) return; | |||
| editForm.hidden = true; | |||
| row.classList.remove('report-table__row--editing'); | |||
| } | |||
| // ── Speichern ───────────────────────────────────────────────────────────────── | |||
| // ── Speichern ──────────────────────────────────────────────────────────────── | |||
| async function saveEdit(row) { | |||
| const id = row.dataset.entryId; | |||
| const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00'; | |||
| const projectId = row.querySelector('.edit-project')?.value; | |||
| const serviceId = row.querySelector('.edit-service')?.value; | |||
| const note = row.querySelector('.edit-note')?.value ?? ''; | |||
| if (!projectId) { alert(t('errorNoProject')); return; } | |||
| const rawMinutes = roundToQuarter(parseDuration(durationRaw)); | |||
| if (rawMinutes === 0) { alert(t('errorZeroDuration')); return; } | |||
| const validation = validateDuration(rawMinutes); | |||
| if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; } | |||
| if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return; | |||
| try { | |||
| const res = await fetch(`/api/entries/${id}`, { | |||
| method: 'PATCH', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify({ | |||
| duration: formatMinutes(rawMinutes), | |||
| projectId: parseInt(projectId), | |||
| serviceId: serviceId ? parseInt(serviceId) : null, | |||
| note: note || null, | |||
| }), | |||
| }); | |||
| if (!res.ok) { | |||
| const err = await res.json().catch(() => ({})); | |||
| alert(err.error ?? t('errorSave')); | |||
| return; | |||
| } | |||
| window.location.reload(); | |||
| const saveBtn = row.querySelector('[data-action="save"]'); | |||
| if (saveBtn?.disabled) return; | |||
| const id = row.dataset.entryId; | |||
| const projectId = row.querySelector('.edit-project')?.value; | |||
| const serviceId = row.querySelector('.edit-service')?.value; | |||
| const note = row.querySelector('.edit-note')?.value ?? ''; | |||
| if (!projectId) { alert(t('errorNoProject')); return; } | |||
| const dur = parseAndValidate(row.querySelector('.edit-duration')?.value ?? '0:00'); | |||
| if (dur.error) { alert(t(dur.error)); return; } | |||
| if (dur.warn && !confirm(t(dur.warn))) return; | |||
| if (saveBtn) saveBtn.disabled = true; | |||
| try { | |||
| const res = await fetch(`/api/entries/${id}`, { | |||
| method: 'PATCH', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify({ | |||
| duration: dur.formatted, | |||
| projectId: parseInt(projectId, 10), | |||
| serviceId: serviceId ? parseInt(serviceId, 10) : null, | |||
| note: note || null, | |||
| }), | |||
| }); | |||
| } catch { | |||
| alert(t('errorSave')); | |||
| if (!res.ok) { | |||
| const err = await res.json().catch(() => ({})); | |||
| alert(err.error ?? t('errorSave')); | |||
| return; | |||
| } | |||
| window.location.reload(); | |||
| } catch { | |||
| alert(t('errorSave')); | |||
| } finally { | |||
| if (saveBtn) saveBtn.disabled = false; | |||
| } | |||
| } | |||
| // ── Löschen ─────────────────────────────────────────────────────────────────── | |||
| // ── Löschen ────────────────────────────────────────────────────────────────── | |||
| async function deleteEntry(row) { | |||
| if (!confirm(t('confirmDelete'))) return; | |||
| const id = row.dataset.entryId; | |||
| try { | |||
| const res = await fetch(`/api/entries/${id}`, { method: 'DELETE' }); | |||
| if (!res.ok) { alert(t('errorDelete')); return; } | |||
| window.location.reload(); | |||
| } catch { | |||
| alert(t('errorDelete')); | |||
| } | |||
| if (!confirm(t('confirmDelete'))) return; | |||
| try { | |||
| const res = await fetch(`/api/entries/${row.dataset.entryId}`, { method: 'DELETE' }); | |||
| if (!res.ok) { alert(t('errorDelete')); return; } | |||
| window.location.reload(); | |||
| } catch { | |||
| alert(t('errorDelete')); | |||
| } | |||
| } | |||
| // ── Abgerechnet toggeln ─────────────────────────────────────────────────────── | |||
| // ── Abgerechnet toggeln ────────────────────────────────────────────────────── | |||
| async function toggleInvoiced(row) { | |||
| const id = row.dataset.entryId; | |||
| const btn = row.querySelector('[data-action="toggle-invoiced"]'); | |||
| const id = row.dataset.entryId; | |||
| const btn = row.querySelector('[data-action="toggle-invoiced"]'); | |||
| try { | |||
| const res = await fetch(`/api/entries/${id}/invoiced`, { method: 'PATCH' }); | |||
| if (!res.ok) return; | |||
| try { | |||
| const res = await fetch(`/api/entries/${id}/invoiced`, { method: 'PATCH' }); | |||
| if (!res.ok) return; | |||
| const data = await res.json(); | |||
| const invoiced = data.invoiced; | |||
| const data = await res.json(); | |||
| const invoiced = data.invoiced; | |||
| row.dataset.invoiced = invoiced ? 'true' : 'false'; | |||
| row.classList.toggle('report-table__row--invoiced', invoiced); | |||
| row.dataset.invoiced = invoiced ? 'true' : 'false'; | |||
| row.classList.toggle('report-table__row--invoiced', invoiced); | |||
| if (btn) { | |||
| btn.classList.toggle('report-lock--invoiced', invoiced); | |||
| btn.title = invoiced ? t('btnUnlock') : t('btnLock'); | |||
| } | |||
| } catch (err) { | |||
| console.error('Fehler beim Toggeln des Abrechnungsstatus:', err); | |||
| if (btn) { | |||
| btn.classList.toggle('report-lock--invoiced', invoiced); | |||
| btn.title = invoiced ? t('btnUnlock') : t('btnLock'); | |||
| } | |||
| } catch (err) { | |||
| console.error('toggleInvoiced error:', err); | |||
| } | |||
| } | |||
| // ── Event-Delegation ────────────────────────────────────────────────────────── | |||
| // ── Event-Delegation ───────────────────────────────────────────────────────── | |||
| document.addEventListener('DOMContentLoaded', () => { | |||
| initDurationBlurHandler(); | |||
| const table = document.querySelector('.report-table'); | |||
| if (!table) return; | |||
| initDurationBlurHandler(); | |||
| const table = document.querySelector('.report-table'); | |||
| if (table) { | |||
| table.addEventListener('click', e => { | |||
| const btn = e.target.closest('[data-action]'); | |||
| if (!btn) return; | |||
| const row = btn.closest('.report-table__row'); | |||
| if (!row) return; | |||
| switch (btn.dataset.action) { | |||
| case 'edit': openEdit(row); break; | |||
| case 'cancel': closeEdit(row); break; | |||
| case 'save': saveEdit(row); break; | |||
| case 'delete': deleteEntry(row); break; | |||
| case 'toggle-invoiced': toggleInvoiced(row); break; | |||
| } | |||
| const btn = e.target.closest('[data-action]'); | |||
| if (!btn) return; | |||
| const row = btn.closest('.report-table__row'); | |||
| if (!row) return; | |||
| switch (btn.dataset.action) { | |||
| case 'edit': openEdit(row); break; | |||
| case 'cancel': closeEdit(row); break; | |||
| case 'save': saveEdit(row); break; | |||
| case 'delete': deleteEntry(row); break; | |||
| case 'toggle-invoiced': toggleInvoiced(row); break; | |||
| } | |||
| }); | |||
| } | |||
| new ReportFilter().init(); | |||
| initExportButtons(); | |||
| initPrintButton(); | |||
| }); | |||
| // ── ReportFilter ────────────────────────────────────────────────────────────── | |||
| // ── ReportFilter ───────────────────────────────────────────────────────────── | |||
| class ReportFilter { | |||
| constructor() { | |||
| this.panel = document.getElementById('report-filter'); | |||
| this.toggleBtn = document.getElementById('btn-filter-toggle'); | |||
| this.applyBtn = document.getElementById('btn-filter-apply'); | |||
| this.hideBtn = document.getElementById('btn-filter-hide'); | |||
| this.periodSel = document.querySelector('.filter-period-select'); | |||
| this.customDates = document.querySelector('.filter-custom-dates'); | |||
| } | |||
| init() { | |||
| if (!this.panel) return; | |||
| constructor() { | |||
| this.panel = document.getElementById('report-filter'); | |||
| this.toggleBtn = document.getElementById('btn-filter-toggle'); | |||
| this.applyBtn = document.getElementById('btn-filter-apply'); | |||
| this.hideBtn = document.getElementById('btn-filter-hide'); | |||
| this.periodSel = document.querySelector('.filter-period-select'); | |||
| this.customDates = document.querySelector('.filter-custom-dates'); | |||
| } | |||
| init() { | |||
| if (!this.panel) return; | |||
| this.toggleBtn?.addEventListener('click', () => this.togglePanel()); | |||
| this.hideBtn?.addEventListener('click', () => this.hidePanel()); | |||
| this.applyBtn?.addEventListener('click', () => this.applyFilters()); | |||
| this.panel.querySelectorAll('.filter-row__checkbox').forEach(cb => { | |||
| cb.addEventListener('change', () => { | |||
| this.syncRowState(cb.closest('.filter-row'), cb.checked); | |||
| }); | |||
| }); | |||
| // Toolbar-Toggle | |||
| this.toggleBtn?.addEventListener('click', () => this.togglePanel()); | |||
| this.panel.querySelectorAll('.filter-select, .filter-note-input').forEach(el => { | |||
| el.addEventListener('mousedown', () => this.activateRowByControl(el)); | |||
| }); | |||
| // Ausblenden-Button | |||
| this.hideBtn?.addEventListener('click', () => this.hidePanel()); | |||
| this.periodSel?.addEventListener('change', () => { | |||
| this.activateRowByControl(this.periodSel); | |||
| this.toggleCustomDates(this.periodSel.value === 'custom'); | |||
| }); | |||
| // Filtern-Button | |||
| this.applyBtn?.addEventListener('click', () => this.applyFilters()); | |||
| this.panel.querySelectorAll('.filter-row__add').forEach(btn => { | |||
| btn.addEventListener('click', () => this.addControl(btn)); | |||
| }); | |||
| // Checkbox-Änderungen | |||
| this.panel.querySelectorAll('.filter-row__checkbox').forEach(cb => { | |||
| cb.addEventListener('change', () => { | |||
| const row = cb.closest('.filter-row'); | |||
| this.syncRowState(row, cb.checked); | |||
| }); | |||
| }); | |||
| this.panel.addEventListener('click', e => { | |||
| const removeBtn = e.target.closest('.filter-row__remove'); | |||
| if (removeBtn) this.removeControl(removeBtn); | |||
| }); | |||
| // Klick auf ausgegrautem Control → Checkbox aktivieren | |||
| this.panel.querySelectorAll('.filter-select, .filter-note-input').forEach(el => { | |||
| el.addEventListener('mousedown', () => this.activateRowByControl(el)); | |||
| }); | |||
| this.panel.addEventListener('change', e => { | |||
| const sel = e.target.closest('.filter-select'); | |||
| if (!sel) return; | |||
| const container = sel.closest('.filter-row__controls'); | |||
| if (container) this.refreshGroupSelects(container); | |||
| }); | |||
| // Zeitraum-Select → Custom-Felder zeigen/verstecken | |||
| this.periodSel?.addEventListener('change', () => { | |||
| const row = this.periodSel.closest('.filter-row'); | |||
| this.activateRowByControl(this.periodSel); | |||
| this.toggleCustomDates(this.periodSel.value === 'custom'); | |||
| }); | |||
| this.panel.querySelectorAll('.filter-row').forEach(row => { | |||
| const cb = row.querySelector('.filter-row__checkbox'); | |||
| this.syncRowState(row, cb?.checked ?? false); | |||
| }); | |||
| // Plus-Buttons | |||
| this.panel.querySelectorAll('.filter-row__add').forEach(btn => { | |||
| btn.addEventListener('click', () => this.addControl(btn)); | |||
| }); | |||
| this.panel.querySelectorAll('.filter-row__controls').forEach(container => { | |||
| this.refreshGroupSelects(container); | |||
| }); | |||
| } | |||
| togglePanel() { | |||
| const isHidden = this.panel.hasAttribute('hidden'); | |||
| if (isHidden) { | |||
| this.panel.removeAttribute('hidden'); | |||
| this.toggleBtn?.classList.add('report-toolbar__action--active'); | |||
| } else { | |||
| this.hidePanel(); | |||
| } | |||
| } | |||
| hidePanel() { | |||
| this.panel.setAttribute('hidden', ''); | |||
| this.toggleBtn?.classList.remove('report-toolbar__action--active'); | |||
| } | |||
| syncRowState(row, active) { | |||
| row.classList.toggle('filter-row--inactive', !active); | |||
| } | |||
| activateRowByControl(el) { | |||
| const row = el.closest('.filter-row'); | |||
| if (!row) return; | |||
| const cb = row.querySelector('.filter-row__checkbox'); | |||
| if (cb && !cb.checked) { | |||
| cb.checked = true; | |||
| this.syncRowState(row, true); | |||
| } | |||
| } | |||
| // Remove-Buttons (via Delegation, da sie dynamisch entstehen) | |||
| this.panel.addEventListener('click', e => { | |||
| const removeBtn = e.target.closest('.filter-row__remove'); | |||
| if (removeBtn) this.removeControl(removeBtn); | |||
| }); | |||
| toggleCustomDates(show) { | |||
| if (!this.customDates) return; | |||
| this.customDates.toggleAttribute('hidden', !show); | |||
| } | |||
| // Select-Änderung → Optionen in der Gruppe aktualisieren | |||
| this.panel.addEventListener('change', e => { | |||
| const sel = e.target.closest('.filter-select'); | |||
| if (!sel) return; | |||
| const container = sel.closest('.filter-row__controls'); | |||
| if (container) this.refreshGroupSelects(container); | |||
| }); | |||
| addControl(btn) { | |||
| const targetId = btn.dataset.target; | |||
| const container = document.getElementById(targetId); | |||
| if (!container) return; | |||
| // Initialer Zustand | |||
| this.panel.querySelectorAll('.filter-row').forEach(row => { | |||
| const cb = row.querySelector('.filter-row__checkbox'); | |||
| this.syncRowState(row, cb?.checked ?? false); | |||
| }); | |||
| const template = container.querySelector('.filter-row__control-group'); | |||
| if (!template) return; | |||
| // Bereits geladene Mehrfach-Selects deduplizieren (nach Seiten-Reload mit Filtern) | |||
| this.panel.querySelectorAll('.filter-row__controls').forEach(container => { | |||
| this.refreshGroupSelects(container); | |||
| }); | |||
| } | |||
| const clone = template.cloneNode(true); | |||
| // ── Panel toggeln ───────────────────────────────────────────────────────── | |||
| const clonedSelect = clone.querySelector('.filter-select'); | |||
| if (clonedSelect) clonedSelect.value = ''; | |||
| togglePanel() { | |||
| const isHidden = this.panel.hasAttribute('hidden'); | |||
| if (isHidden) { | |||
| this.panel.removeAttribute('hidden'); | |||
| this.toggleBtn?.classList.add('report-toolbar__action--active'); | |||
| } else { | |||
| this.hidePanel(); | |||
| } | |||
| if (!clone.querySelector('.filter-row__remove')) { | |||
| const removeBtn = document.createElement('button'); | |||
| removeBtn.type = 'button'; | |||
| removeBtn.className = 'filter-row__remove'; | |||
| removeBtn.textContent = '×'; | |||
| clone.appendChild(removeBtn); | |||
| } | |||
| hidePanel() { | |||
| this.panel.setAttribute('hidden', ''); | |||
| this.toggleBtn?.classList.remove('report-toolbar__action--active'); | |||
| } | |||
| clone.querySelector('.filter-select')?.addEventListener('mousedown', () => { | |||
| this.activateRowByControl(clone.querySelector('.filter-select')); | |||
| }); | |||
| // ── Row-Zustand (aktiv / inaktiv) ───────────────────────────────────────── | |||
| container.appendChild(clone); | |||
| this.refreshGroupSelects(container); | |||
| syncRowState(row, active) { | |||
| row.classList.toggle('filter-row--inactive', !active); | |||
| const row = btn.closest('.filter-row'); | |||
| const cb = row?.querySelector('.filter-row__checkbox'); | |||
| if (cb && !cb.checked) { | |||
| cb.checked = true; | |||
| this.syncRowState(row, true); | |||
| } | |||
| activateRowByControl(el) { | |||
| const row = el.closest('.filter-row'); | |||
| if (!row) return; | |||
| const cb = row.querySelector('.filter-row__checkbox'); | |||
| if (cb && !cb.checked) { | |||
| cb.checked = true; | |||
| this.syncRowState(row, true); | |||
| } | |||
| clonedSelect?.focus(); | |||
| } | |||
| removeControl(removeBtn) { | |||
| const group = removeBtn.closest('.filter-row__control-group'); | |||
| const container = group?.parentElement; | |||
| group?.remove(); | |||
| if (container && !container.querySelector('.filter-row__control-group')) { | |||
| const row = container.closest('.filter-row'); | |||
| const cb = row?.querySelector('.filter-row__checkbox'); | |||
| if (cb) { | |||
| cb.checked = false; | |||
| this.syncRowState(row, false); | |||
| } | |||
| } | |||
| // ── Zeitraum: Custom-Felder ──────────────────────────────────────────────── | |||
| toggleCustomDates(show) { | |||
| if (!this.customDates) return; | |||
| if (show) { | |||
| this.customDates.removeAttribute('hidden'); | |||
| } else { | |||
| this.customDates.setAttribute('hidden', ''); | |||
| } | |||
| } | |||
| if (container) this.refreshGroupSelects(container); | |||
| } | |||
| // ── Plus: weiteres Control hinzufügen ───────────────────────────────────── | |||
| refreshGroupSelects(container) { | |||
| const selects = [...container.querySelectorAll('.filter-select')]; | |||
| if (selects.length < 2) return; | |||
| addControl(btn) { | |||
| const targetId = btn.dataset.target; | |||
| const filterKey = btn.dataset.filterKey; | |||
| const container = document.getElementById(targetId); | |||
| if (!container) return; | |||
| const selectedValues = new Set( | |||
| selects.map(s => s.value).filter(v => v !== '') | |||
| ); | |||
| // Erste Gruppe als Template klonen | |||
| const template = container.querySelector('.filter-row__control-group'); | |||
| if (!template) return; | |||
| selects.forEach(sel => { | |||
| const ownValue = sel.value; | |||
| sel.querySelectorAll('option').forEach(opt => { | |||
| if (!opt.value) return; | |||
| opt.hidden = selectedValues.has(opt.value) && opt.value !== ownValue; | |||
| }); | |||
| }); | |||
| } | |||
| const clone = template.cloneNode(true); | |||
| applyFilters() { | |||
| const params = new URLSearchParams(); | |||
| params.set('limit', String(window.Report?.limit ?? 50)); | |||
| // Select zurücksetzen | |||
| const clonedSelect = clone.querySelector('.filter-select'); | |||
| if (clonedSelect) clonedSelect.value = ''; | |||
| this.panel.querySelectorAll('.filter-row').forEach(row => { | |||
| const cb = row.querySelector('.filter-row__checkbox'); | |||
| if (!cb?.checked) return; | |||
| // Remove-Button hinzufügen (falls noch keiner da) | |||
| if (!clone.querySelector('.filter-row__remove')) { | |||
| const removeBtn = document.createElement('button'); | |||
| removeBtn.type = 'button'; | |||
| removeBtn.className = 'filter-row__remove'; | |||
| removeBtn.textContent = '×'; | |||
| clone.appendChild(removeBtn); | |||
| } | |||
| const key = row.dataset.filterKey; | |||
| // Neu: Klick auf den geklonten Select aktiviert ebenfalls die Row | |||
| clone.querySelector('.filter-select')?.addEventListener('mousedown', () => { | |||
| this.activateRowByControl(clone.querySelector('.filter-select')); | |||
| if (['clients', 'projects', 'services', 'users'].includes(key)) { | |||
| row.querySelectorAll('.filter-select').forEach(sel => { | |||
| if (sel.value) params.append(`filter[${key}][]`, sel.value); | |||
| }); | |||
| container.appendChild(clone); | |||
| // Optionen deduplizieren | |||
| this.refreshGroupSelects(container); | |||
| // Row aktivieren | |||
| const row = btn.closest('.filter-row'); | |||
| const cb = row?.querySelector('.filter-row__checkbox'); | |||
| if (cb && !cb.checked) { | |||
| cb.checked = true; | |||
| this.syncRowState(row, true); | |||
| if (row.querySelector('.filter-neg-checkbox')?.checked) { | |||
| params.set(`filter[${key}_neg]`, '1'); | |||
| } | |||
| clonedSelect?.focus(); | |||
| } | |||
| // ── Minus: Control entfernen ────────────────────────────────────────────── | |||
| removeControl(removeBtn) { | |||
| const group = removeBtn.closest('.filter-row__control-group'); | |||
| const container = group?.parentElement; | |||
| group?.remove(); | |||
| // Wenn keine Controls mehr übrig → Checkbox deaktivieren | |||
| if (container && !container.querySelector('.filter-row__control-group')) { | |||
| const row = container.closest('.filter-row'); | |||
| const cb = row?.querySelector('.filter-row__checkbox'); | |||
| if (cb) { | |||
| cb.checked = false; | |||
| this.syncRowState(row, false); | |||
| } | |||
| } else if (key === 'period') { | |||
| const val = this.periodSel?.value; | |||
| if (!val) return; | |||
| params.set('filter[period]', val); | |||
| if (val === 'custom' && this.customDates) { | |||
| const get = field => this.customDates.querySelector(`[data-date-field="${field}"]`)?.value ?? ''; | |||
| const fromDay = get('from-day').padStart(2, '0'); | |||
| const fromMonth = get('from-month').padStart(2, '0'); | |||
| const fromYear = get('from-year'); | |||
| const toDay = get('to-day').padStart(2, '0'); | |||
| const toMonth = get('to-month').padStart(2, '0'); | |||
| const toYear = get('to-year'); | |||
| if (fromYear && fromMonth && fromDay) { | |||
| params.set('filter[date_from]', `${fromYear}-${fromMonth}-${fromDay}`); | |||
| } | |||
| if (toYear && toMonth && toDay) { | |||
| params.set('filter[date_to]', `${toYear}-${toMonth}-${toDay}`); | |||
| } | |||
| } | |||
| if (row.querySelector('.filter-neg-checkbox')?.checked) { | |||
| params.set('filter[period_neg]', '1'); | |||
| } | |||
| // Verbleibende Selects aktualisieren | |||
| if (container) this.refreshGroupSelects(container); | |||
| } | |||
| // ── Optionen in Mehrfach-Selects deduplizieren ──────────────────────────── | |||
| refreshGroupSelects(container) { | |||
| const selects = [...container.querySelectorAll('.filter-select')]; | |||
| if (selects.length < 2) return; | |||
| } else if (key === 'note') { | |||
| const val = row.querySelector('.filter-note-input')?.value?.trim(); | |||
| if (val) params.set('filter[note]', val); | |||
| // Alle gewählten Values sammeln | |||
| const selectedValues = new Set( | |||
| selects.map(s => s.value).filter(v => v !== '') | |||
| ); | |||
| } else if (key === 'invoiced') { | |||
| const checked = row.querySelector('.filter-invoiced-radio:checked'); | |||
| if (checked) params.set('filter[invoiced]', checked.value); | |||
| } | |||
| }); | |||
| selects.forEach(sel => { | |||
| const ownValue = sel.value; | |||
| sel.querySelectorAll('option').forEach(opt => { | |||
| if (!opt.value) return; // "..." immer sichtbar lassen | |||
| // Verstecken wenn woanders gewählt, aber nicht beim eigenen Select | |||
| opt.hidden = selectedValues.has(opt.value) && opt.value !== ownValue; | |||
| }); | |||
| }); | |||
| } | |||
| window.location.href = `/reports/times?${params}`; | |||
| } | |||
| } | |||
| // ── Filter anwenden → URL bauen und navigieren ──────────────────────────── | |||
| applyFilters() { | |||
| const params = new URLSearchParams(); | |||
| params.set('limit', String(window.Report?.limit ?? 50)); | |||
| this.panel.querySelectorAll('.filter-row').forEach(row => { | |||
| const cb = row.querySelector('.filter-row__checkbox'); | |||
| if (!cb?.checked) return; | |||
| const key = row.dataset.filterKey; | |||
| if (['clients', 'projects', 'services', 'users'].includes(key)) { | |||
| 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; | |||
| if (!val) return; | |||
| params.set('filter[period]', val); | |||
| if (val === 'custom' && this.customDates) { | |||
| const get = field => this.customDates.querySelector(`[data-date-field="${field}"]`)?.value ?? ''; | |||
| const fromDay = get('from-day').padStart(2, '0'); | |||
| const fromMonth = get('from-month').padStart(2, '0'); | |||
| const fromYear = get('from-year'); | |||
| const toDay = get('to-day').padStart(2, '0'); | |||
| const toMonth = get('to-month').padStart(2, '0'); | |||
| const toYear = get('to-year'); | |||
| if (fromYear && fromMonth && fromDay) { | |||
| params.set('filter[date_from]', `${fromYear}-${fromMonth}-${fromDay}`); | |||
| } | |||
| if (toYear && toMonth && toDay) { | |||
| params.set('filter[date_to]', `${toYear}-${toMonth}-${toDay}`); | |||
| } | |||
| } | |||
| if (row.querySelector('.filter-neg-checkbox')?.checked) { | |||
| params.set('filter[period_neg]', '1'); | |||
| } | |||
| } else if (key === 'note') { | |||
| const val = row.querySelector('.filter-note-input')?.value?.trim(); | |||
| if (val) params.set('filter[note]', val); | |||
| } else if (key === 'invoiced') { | |||
| const checked = row.querySelector('.filter-invoiced-radio:checked'); | |||
| if (checked) params.set('filter[invoiced]', checked.value); | |||
| } | |||
| }); | |||
| // ── Export ──────────────────────────────────────────────────────────────────── | |||
| window.location.href = `/reports/times?${params}`; | |||
| } | |||
| function initExportButtons() { | |||
| ['excel', 'csv', 'pdf'].forEach(format => { | |||
| document.getElementById(`btn-export-${format}`)?.addEventListener('click', () => { | |||
| const params = new URLSearchParams(window.location.search); | |||
| params.delete('limit'); | |||
| window.location.href = `/reports/export/${format}?${params}`; | |||
| }); | |||
| }); | |||
| } | |||
| // ── Init ────────────────────────────────────────────────────────────────────── | |||
| document.addEventListener('DOMContentLoaded', () => { | |||
| new ReportFilter().init(); | |||
| }); | |||
| function initPrintButton() { | |||
| document.getElementById('btn-print')?.addEventListener('click', () => { | |||
| window.print(); | |||
| }); | |||
| } | |||
| @@ -1,223 +1,261 @@ | |||
| // team.js | |||
| // assets/scripts/team.js | |||
| import { esc, createTranslator, ANIMATION_MS, removeWithAnimation } from './utils.js'; | |||
| const t = createTranslator('Team'); | |||
| document.addEventListener('DOMContentLoaded', () => { | |||
| // ── Tabs ───────────────────────────────────────────────────────────────────── | |||
| document.querySelectorAll('.crud-tab').forEach(tab => { | |||
| tab.addEventListener('click', () => { | |||
| document.querySelectorAll('.crud-tab').forEach(t => | |||
| t.classList.toggle('crud-tab--active', t === tab) | |||
| ); | |||
| document.querySelectorAll('[data-tab-panel]').forEach(panel => { | |||
| panel.hidden = panel.dataset.tabPanel !== tab.dataset.tab; | |||
| }); | |||
| }); | |||
| }); | |||
| // ── Tabs ────────────────────────────────────────────────────────────────── | |||
| // ── Einlade-Modal ───────────────────────────────────────────────────────────── | |||
| const modal = document.getElementById('team-modal'); | |||
| const errorsBox = document.getElementById('team-modal-errors'); | |||
| const openModal = () => { modal.hidden = false; }; | |||
| const closeModal = () => { | |||
| modal.hidden = true; | |||
| errorsBox.hidden = true; | |||
| ['inv-firstName', 'inv-lastName', 'inv-email'].forEach(id => { | |||
| document.getElementById(id).value = ''; | |||
| }); | |||
| const defaultRole = modal.querySelector('input[name="inv-role"][value="member"]'); | |||
| if (defaultRole) defaultRole.checked = true; | |||
| }; | |||
| document.querySelectorAll('.crud-tab').forEach(tab => { | |||
| tab.addEventListener('click', () => { | |||
| document.querySelectorAll('.crud-tab').forEach(t => | |||
| t.classList.toggle('crud-tab--active', t === tab) | |||
| ); | |||
| document.querySelectorAll('[data-tab-panel]').forEach(panel => { | |||
| panel.hidden = panel.dataset.tabPanel !== tab.dataset.tab; | |||
| }); | |||
| }); | |||
| }); | |||
| document.getElementById('team-invite-btn').addEventListener('click', openModal); | |||
| document.getElementById('team-modal-close').addEventListener('click', closeModal); | |||
| document.getElementById('team-modal-cancel').addEventListener('click', closeModal); | |||
| modal.addEventListener('click', e => { if (e.target === modal) closeModal(); }); | |||
| document.getElementById('team-modal-submit').addEventListener('click', async () => { | |||
| const payload = { | |||
| firstName: document.getElementById('inv-firstName').value.trim(), | |||
| lastName: document.getElementById('inv-lastName').value.trim(), | |||
| email: document.getElementById('inv-email').value.trim(), | |||
| role: modal.querySelector('input[name="inv-role"]:checked')?.value ?? 'member', | |||
| }; | |||
| const res = await fetch('/api/team/invite', { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify(payload), | |||
| }); | |||
| const data = await res.json(); | |||
| // ── Einlade-Modal ───────────────────────────────────────────────────────── | |||
| if (!res.ok) { | |||
| errorsBox.hidden = false; | |||
| errorsBox.innerHTML = '<ul>' + (data.errors ?? [data.error]).map(e => `<li>${e}</li>`).join('') + '</ul>'; | |||
| return; | |||
| } | |||
| const modal = document.getElementById('team-modal'); | |||
| const errorsBox = document.getElementById('team-modal-errors'); | |||
| closeModal(); | |||
| window.location.reload(); | |||
| const openModal = () => { modal.hidden = false; }; | |||
| const closeModal = () => { | |||
| modal.hidden = true; | |||
| errorsBox.hidden = true; | |||
| ['inv-firstName', 'inv-lastName', 'inv-email'].forEach(id => { | |||
| document.getElementById(id).value = ''; | |||
| }); | |||
| const defaultRole = modal.querySelector('input[name="inv-role"][value="member"]'); | |||
| if (defaultRole) defaultRole.checked = true; | |||
| }; | |||
| document.getElementById('team-invite-btn').addEventListener('click', openModal); | |||
| document.getElementById('team-modal-close').addEventListener('click', closeModal); | |||
| document.getElementById('team-modal-cancel').addEventListener('click', closeModal); | |||
| modal.addEventListener('click', e => { if (e.target === modal) closeModal(); }); | |||
| const submitBtn = document.getElementById('team-modal-submit'); | |||
| submitBtn.addEventListener('click', async () => { | |||
| const payload = { | |||
| firstName: document.getElementById('inv-firstName').value.trim(), | |||
| lastName: document.getElementById('inv-lastName').value.trim(), | |||
| email: document.getElementById('inv-email').value.trim(), | |||
| role: modal.querySelector('input[name="inv-role"]:checked')?.value ?? 'member', | |||
| }; | |||
| // ── Listen-Delegation: aktive User + Einladungen ─────────────────────────── | |||
| const list = document.getElementById('team-list'); | |||
| if (list) { | |||
| list.addEventListener('click', e => { | |||
| const actionEl = e.target.closest('[data-action]'); | |||
| if (!actionEl) return; | |||
| const action = actionEl.dataset.action; | |||
| const row = e.target.closest('.crud-row'); | |||
| if (!row) return; | |||
| switch (action) { | |||
| case 'edit': openEdit(row); break; | |||
| case 'save': saveEdit(row); break; | |||
| case 'cancel': closeEdit(row); break; | |||
| case 'delete': deleteMember(row); break; | |||
| case 'delete-invite': deleteInvite(actionEl.dataset.id, row); break; | |||
| } | |||
| }); | |||
| submitBtn.disabled = true; | |||
| try { | |||
| const res = await fetch('/api/team/invite', { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify(payload), | |||
| }); | |||
| const data = await res.json(); | |||
| if (!res.ok) { | |||
| errorsBox.hidden = false; | |||
| const errors = data.errors ?? [data.error]; | |||
| errorsBox.innerHTML = '<ul>' + errors.map(e => `<li>${esc(e)}</li>`).join('') + '</ul>'; | |||
| return; | |||
| } | |||
| closeModal(); | |||
| window.location.reload(); | |||
| } catch { | |||
| errorsBox.hidden = false; | |||
| errorsBox.innerHTML = `<ul><li>${esc(t('errorSave'))}</li></ul>`; | |||
| } finally { | |||
| submitBtn.disabled = false; | |||
| } | |||
| }); | |||
| // ── Listen-Delegation: aktive User + Einladungen ────────────────────────── | |||
| const list = document.getElementById('team-list'); | |||
| if (list) { | |||
| list.addEventListener('click', e => { | |||
| const actionEl = e.target.closest('[data-action]'); | |||
| if (!actionEl) return; | |||
| const row = e.target.closest('.crud-row'); | |||
| if (!row) return; | |||
| switch (actionEl.dataset.action) { | |||
| case 'edit': openEdit(row); break; | |||
| case 'save': saveEdit(row); break; | |||
| case 'cancel': closeEdit(row); break; | |||
| case 'delete': deleteMember(row); break; | |||
| case 'delete-invite': deleteInvite(actionEl.dataset.id, row); break; | |||
| } | |||
| }); | |||
| } | |||
| // ── Listen-Delegation: archivierte User ─────────────────────────────────── | |||
| const archivedList = document.getElementById('team-list-archived'); | |||
| if (archivedList) { | |||
| archivedList.addEventListener('click', e => { | |||
| const actionEl = e.target.closest('[data-action]'); | |||
| if (!actionEl) return; | |||
| const row = e.target.closest('.crud-row'); | |||
| if (!row) return; | |||
| if (actionEl.dataset.action === 'unarchive') { | |||
| unarchiveMember(row); | |||
| } | |||
| }); | |||
| } | |||
| // ── Listen-Delegation: archivierte User ─────────────────────────────────── | |||
| // ── Inline Edit ─────────────────────────────────────────────────────────── | |||
| function openEdit(row) { | |||
| row.querySelector('.crud-row__display').hidden = true; | |||
| row.querySelector('.crud-row__edit').hidden = false; | |||
| row.querySelector('.edit-first-name')?.focus(); | |||
| } | |||
| const archivedList = document.getElementById('team-list-archived'); | |||
| if (archivedList) { | |||
| archivedList.addEventListener('click', e => { | |||
| const actionEl = e.target.closest('[data-action]'); | |||
| if (!actionEl) return; | |||
| const row = e.target.closest('.crud-row'); | |||
| if (!row) return; | |||
| function closeEdit(row) { | |||
| row.querySelector('.crud-row__display').hidden = false; | |||
| row.querySelector('.crud-row__edit').hidden = true; | |||
| if (actionEl.dataset.action === 'unarchive') { | |||
| unarchiveMember(row); | |||
| } | |||
| }); | |||
| } | |||
| // Felder auf ursprüngliche Werte zurücksetzen | |||
| row.querySelector('.edit-first-name').value = row.dataset.firstName ?? ''; | |||
| row.querySelector('.edit-last-name').value = row.dataset.lastName ?? ''; | |||
| row.querySelector('.edit-email').value = row.dataset.email ?? ''; | |||
| row.querySelector('.edit-note').value = row.dataset.note ?? ''; | |||
| // ── Inline Edit ─────────────────────────────────────────────────────────── | |||
| const currentRole = row.dataset.role; | |||
| row.querySelectorAll('.edit-role').forEach(radio => { | |||
| radio.checked = radio.value === currentRole; | |||
| }); | |||
| } | |||
| function openEdit(row) { | |||
| row.querySelector('.crud-row__display').hidden = true; | |||
| row.querySelector('.crud-row__edit').hidden = false; | |||
| row.querySelector('.edit-first-name')?.focus(); | |||
| } | |||
| async function saveEdit(row) { | |||
| const id = row.dataset.id; | |||
| const firstName = row.querySelector('.edit-first-name').value.trim(); | |||
| const lastName = row.querySelector('.edit-last-name').value.trim(); | |||
| const email = row.querySelector('.edit-email').value.trim(); | |||
| const note = row.querySelector('.edit-note').value || null; | |||
| const role = row.querySelector('.edit-role:checked')?.value ?? row.dataset.role; | |||
| const res = await fetch(`/api/team/${id}`, { | |||
| method: 'PATCH', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify({ firstName, lastName, email, note, role }), | |||
| }); | |||
| if (!res.ok) { | |||
| const data = await res.json(); | |||
| alert((data.errors ?? [data.error]).join('\n')); | |||
| return; | |||
| } | |||
| function closeEdit(row) { | |||
| row.querySelector('.crud-row__display').hidden = false; | |||
| row.querySelector('.crud-row__edit').hidden = true; | |||
| const data = await res.json(); | |||
| updateDisplay(row, data); | |||
| closeEdit(row); | |||
| } | |||
| row.querySelector('.edit-first-name').value = row.dataset.firstName ?? ''; | |||
| row.querySelector('.edit-last-name').value = row.dataset.lastName ?? ''; | |||
| row.querySelector('.edit-email').value = row.dataset.email ?? ''; | |||
| row.querySelector('.edit-note').value = row.dataset.note ?? ''; | |||
| function updateDisplay(row, data) { | |||
| row.querySelector('.crud-row__name').textContent = data.fullName; | |||
| row.querySelector('.crud-row__meta').textContent = `(${data.roleLabel})`; | |||
| row.dataset.firstName = data.firstName; | |||
| row.dataset.lastName = data.lastName; | |||
| row.dataset.email = data.email; | |||
| row.dataset.note = data.note ?? ''; | |||
| row.dataset.role = data.role; | |||
| // Edit-Felder aktualisieren | |||
| row.querySelector('.edit-first-name').value = data.firstName; | |||
| row.querySelector('.edit-last-name').value = data.lastName; | |||
| row.querySelector('.edit-email').value = data.email; | |||
| row.querySelector('.edit-note').value = data.note ?? ''; | |||
| row.querySelectorAll('.edit-role').forEach(radio => { | |||
| radio.checked = radio.value === data.role; | |||
| }); | |||
| const currentRole = row.dataset.role; | |||
| row.querySelectorAll('.edit-role').forEach(radio => { | |||
| radio.checked = radio.value === currentRole; | |||
| }); | |||
| } | |||
| async function saveEdit(row) { | |||
| const saveBtn = row.querySelector('[data-action="save"]'); | |||
| if (saveBtn?.disabled) return; | |||
| const id = row.dataset.id; | |||
| const firstName = row.querySelector('.edit-first-name').value.trim(); | |||
| const lastName = row.querySelector('.edit-last-name').value.trim(); | |||
| const email = row.querySelector('.edit-email').value.trim(); | |||
| const note = row.querySelector('.edit-note').value || null; | |||
| const role = row.querySelector('.edit-role:checked')?.value ?? row.dataset.role; | |||
| if (saveBtn) saveBtn.disabled = true; | |||
| try { | |||
| const res = await fetch(`/api/team/${id}`, { | |||
| method: 'PATCH', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify({ firstName, lastName, email, note, role }), | |||
| }); | |||
| if (!res.ok) { | |||
| const data = await res.json(); | |||
| alert((data.errors ?? [data.error]).join('\n')); | |||
| return; | |||
| } | |||
| const data = await res.json(); | |||
| updateDisplay(row, data); | |||
| closeEdit(row); | |||
| } catch { | |||
| alert(t('errorSave')); | |||
| } finally { | |||
| if (saveBtn) saveBtn.disabled = false; | |||
| } | |||
| } | |||
| function updateDisplay(row, data) { | |||
| row.querySelector('.crud-row__name').textContent = data.fullName; | |||
| row.querySelector('.crud-row__meta').textContent = `(${data.roleLabel})`; | |||
| row.dataset.firstName = data.firstName; | |||
| row.dataset.lastName = data.lastName; | |||
| row.dataset.email = data.email; | |||
| row.dataset.note = data.note ?? ''; | |||
| row.dataset.role = data.role; | |||
| row.querySelector('.edit-first-name').value = data.firstName; | |||
| row.querySelector('.edit-last-name').value = data.lastName; | |||
| row.querySelector('.edit-email').value = data.email; | |||
| row.querySelector('.edit-note').value = data.note ?? ''; | |||
| row.querySelectorAll('.edit-role').forEach(radio => { | |||
| radio.checked = radio.value === data.role; | |||
| }); | |||
| } | |||
| // ── Delete ──────────────────────────────────────────────────────────────── | |||
| async function deleteMember(row) { | |||
| if (!confirm('Wirklich entfernen?')) return; | |||
| // ── Delete ──────────────────────────────────────────────────────────────── | |||
| const id = row.dataset.id; | |||
| const res = await fetch(`/api/team/${id}`, { method: 'DELETE' }); | |||
| async function deleteMember(row) { | |||
| if (!confirm(t('confirmDelete'))) return; | |||
| if (res.status === 409) { | |||
| if (confirm('Dieser Benutzer hat Zeiteinträge und kann nicht gelöscht werden.\nStattdessen archivieren?')) { | |||
| await archiveMember(row); | |||
| } | |||
| return; | |||
| } | |||
| try { | |||
| const res = await fetch(`/api/team/${row.dataset.id}`, { method: 'DELETE' }); | |||
| if (!res.ok) { | |||
| const data = await res.json(); | |||
| alert(data.error ?? 'Fehler beim Löschen.'); | |||
| return; | |||
| if (res.status === 409) { | |||
| if (confirm(t('confirmArchive'))) { | |||
| await archiveMember(row); | |||
| } | |||
| row.classList.add('crud-row--removing'); | |||
| setTimeout(() => row.remove(), 280); | |||
| return; | |||
| } | |||
| if (!res.ok) { | |||
| const data = await res.json().catch(() => ({})); | |||
| alert(data.error ?? t('errorDelete')); | |||
| return; | |||
| } | |||
| removeWithAnimation(row, 'crud-row--removing'); | |||
| } catch { | |||
| alert(t('errorDelete')); | |||
| } | |||
| async function deleteInvite(id, row) { | |||
| if (!confirm('Einladung zurückziehen?')) return; | |||
| const res = await fetch(`/api/team/invite/${id}`, { method: 'DELETE' }); | |||
| if (!res.ok) { | |||
| const data = await res.json(); | |||
| alert(data.error ?? 'Fehler'); | |||
| return; | |||
| } | |||
| row.classList.add('crud-row--removing'); | |||
| setTimeout(() => row.remove(), 280); | |||
| } | |||
| async function deleteInvite(id, row) { | |||
| if (!confirm(t('confirmRevokeInvite'))) return; | |||
| try { | |||
| const res = await fetch(`/api/team/invite/${id}`, { method: 'DELETE' }); | |||
| if (!res.ok) { | |||
| const data = await res.json().catch(() => ({})); | |||
| alert(data.error ?? t('errorGeneric')); | |||
| return; | |||
| } | |||
| removeWithAnimation(row, 'crud-row--removing'); | |||
| } catch { | |||
| alert(t('errorGeneric')); | |||
| } | |||
| } | |||
| // ── Archive / Unarchive ─────────────────────────────────────────────────── | |||
| async function archiveMember(row) { | |||
| const id = row.dataset.id; | |||
| const res = await fetch(`/api/team/${id}/archive`, { method: 'PATCH' }); | |||
| // ── Archive / Unarchive ─────────────────────────────────────────────────── | |||
| if (!res.ok) { alert('Fehler beim Archivieren.'); return; } | |||
| async function archiveMember(row) { | |||
| try { | |||
| const res = await fetch(`/api/team/${row.dataset.id}/archive`, { method: 'PATCH' }); | |||
| if (!res.ok) { alert(t('errorArchive')); return; } | |||
| row.classList.add('crud-row--removing'); | |||
| setTimeout(() => window.location.reload(), 280); | |||
| removeWithAnimation(row, 'crud-row--removing'); | |||
| setTimeout(() => window.location.reload(), ANIMATION_MS); | |||
| } catch { | |||
| alert(t('errorArchive')); | |||
| } | |||
| } | |||
| async function unarchiveMember(row) { | |||
| const id = row.dataset.id; | |||
| const res = await fetch(`/api/team/${id}/unarchive`, { method: 'PATCH' }); | |||
| if (!res.ok) { alert('Fehler beim Wiederherstellen.'); return; } | |||
| async function unarchiveMember(row) { | |||
| try { | |||
| const res = await fetch(`/api/team/${row.dataset.id}/unarchive`, { method: 'PATCH' }); | |||
| if (!res.ok) { alert(t('errorRestore')); return; } | |||
| row.classList.add('crud-row--removing'); | |||
| setTimeout(() => window.location.reload(), 280); | |||
| removeWithAnimation(row, 'crud-row--removing'); | |||
| setTimeout(() => window.location.reload(), ANIMATION_MS); | |||
| } catch { | |||
| alert(t('errorRestore')); | |||
| } | |||
| }); | |||
| } | |||
| }); | |||
| @@ -0,0 +1,26 @@ | |||
| // assets/scripts/utils.js | |||
| const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; | |||
| export const ANIMATION_MS = 280; | |||
| export const FADE_MS = 180; | |||
| export const MINUTES_PER_DAY = 1440; | |||
| export function esc(str) { | |||
| return String(str ?? '').replace(/[&<>"']/g, c => ESCAPES[c]); | |||
| } | |||
| export function createTranslator(namespace, defaults = {}) { | |||
| return (key) => window[namespace]?.i18n?.[key] ?? defaults[key] ?? key; | |||
| } | |||
| export function removeWithAnimation(el, className) { | |||
| el.classList.add(className); | |||
| setTimeout(() => el.remove(), ANIMATION_MS); | |||
| } | |||
| export function animateIn(el, className) { | |||
| requestAnimationFrame(() => requestAnimationFrame(() => { | |||
| el.classList.remove(className); | |||
| })); | |||
| } | |||
| @@ -52,7 +52,7 @@ | |||
| } | |||
| } | |||
| // ─── Select Label Tag (wie "Dogument", "Verrechenbar") ─────────────────────── | |||
| // ─── Select Label Tag (z.B. "Verrechenbar") ───────────────────────────────── | |||
| .select-hint { | |||
| font-size: $font-size-xs; | |||
| color: $color-text-muted; | |||
| @@ -0,0 +1,53 @@ | |||
| @use 'variables' as *; | |||
| @mixin icon-btn($size: 28px, $shape: 50%) { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| width: $size; | |||
| height: $size; | |||
| border-radius: $shape; | |||
| background: transparent; | |||
| border: none; | |||
| cursor: pointer; | |||
| transition: opacity $transition-fast, background $transition-fast, color $transition-fast; | |||
| svg { pointer-events: none; } | |||
| } | |||
| @mixin card($bg: $color-card-white, $radius: $radius-lg) { | |||
| background: $bg; | |||
| border-radius: $radius; | |||
| box-shadow: $shadow-card; | |||
| } | |||
| @mixin page-shell { | |||
| min-height: 100vh; | |||
| background: var(--color-bg); | |||
| display: flex; | |||
| flex-direction: column; | |||
| } | |||
| @mixin section-header { | |||
| background: linear-gradient(135deg, var(--color-header-from) 0%, var(--color-header-to) 100%); | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: space-between; | |||
| gap: $space-6; | |||
| box-shadow: $shadow-header; | |||
| } | |||
| @mixin text-truncate { | |||
| white-space: nowrap; | |||
| overflow: hidden; | |||
| text-overflow: ellipsis; | |||
| } | |||
| @mixin form-label { | |||
| font-size: $font-size-sm; | |||
| color: $color-text-muted; | |||
| text-align: right; | |||
| padding-right: $space-2; | |||
| white-space: nowrap; | |||
| } | |||
| @@ -1,5 +1,4 @@ | |||
| // ─── Color Palette ─────────────────────────────────────────────────────────── | |||
| // Compile-time values (used in rgba() functions; keep as hex) | |||
| $color-primary: #4a90d9; | |||
| $color-primary-dark: #3178b8; | |||
| $color-primary-light: #6aaee8; | |||
| @@ -12,20 +11,6 @@ $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; | |||
| --header-text: #{$color-white}; | |||
| --header-text-muted: rgba(255, 255, 255, 0.75); | |||
| --header-overlay: rgba(255, 255, 255, 0.18); | |||
| } | |||
| $color-card: #f0f0f0; | |||
| $color-card-white: #ffffff; | |||
| @@ -40,21 +25,30 @@ $color-input-border: #b8c4d0; | |||
| $color-day-active-bg: #1a2a3a; | |||
| $color-day-active-text:#ffffff; | |||
| $color-day-hover: rgba(255,255,255,0.2); | |||
| $color-error: #c83232; | |||
| $color-success: #2d9e60; | |||
| $color-success-bg: #e6f5ee; | |||
| $color-activate: #3a9a3a; | |||
| $color-activate-light: #4ab44a; | |||
| $color-warning: #b86200; | |||
| $color-warning-light: #e8820a; | |||
| $color-overlay: rgba(0, 0, 0, 0.45); | |||
| // ─── 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; | |||
| --header-text: #{$color-white}; | |||
| --header-text-muted: rgba(255, 255, 255, 0.75); | |||
| --header-overlay: rgba(255, 255, 255, 0.18); | |||
| } | |||
| // ─── Typography ────────────────────────────────────────────────────────────── | |||
| $font-family-base: 'DM Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; | |||
| $font-size-xs: 0.7rem; | |||
| @@ -89,12 +83,12 @@ $radius-xl: 24px; | |||
| $radius-pill: 100px; | |||
| // ─── Shadows ───────────────────────────────────────────────────────────────── | |||
| $shadow-card: 0 2px 12px rgba(0, 60, 120, 0.08); | |||
| $shadow-header: 0 2px 16px rgba(0, 50, 120, 0.2); | |||
| $shadow-calendar:0 8px 32px rgba(0, 60, 120, 0.35); | |||
| $shadow-input: 0 1px 3px rgba(0, 40, 80, 0.06) inset; | |||
| $shadow-focus: 0 0 0 3px rgba(#4a90d9, 0.15); | |||
| $shadow-button: 0 2px 8px rgba(240, 165, 0, 0.35); | |||
| $shadow-card: 0 2px 12px rgba(0, 60, 120, 0.08); | |||
| $shadow-header: 0 2px 16px rgba(0, 50, 120, 0.2); | |||
| $shadow-calendar: 0 8px 32px rgba(0, 60, 120, 0.35); | |||
| $shadow-input: 0 1px 3px rgba(0, 40, 80, 0.06) inset; | |||
| $shadow-focus: 0 0 0 3px rgba($color-primary, 0.15); | |||
| $shadow-button: 0 2px 8px rgba($color-accent, 0.35); | |||
| // ─── Transitions ───────────────────────────────────────────────────────────── | |||
| $transition-fast: 0.15s ease; | |||
| @@ -102,5 +96,7 @@ $transition-base: 0.2s ease; | |||
| $transition-slow: 0.3s ease; | |||
| // ─── Layout ────────────────────────────────────────────────────────────────── | |||
| $header-height: 88px; | |||
| $header-height: 88px; | |||
| $content-max-width: 860px; | |||
| $icon-btn-size: 28px; | |||
| $icon-svg-size: 14px; | |||
| @@ -1,22 +1,15 @@ | |||
| @use '../atoms/variables' as *; | |||
| @use '../atoms/mixins' as *; | |||
| // ─── Page ───────────────────────────────────────────────────────────────────── | |||
| .account-page { | |||
| min-height: 100vh; | |||
| background: var(--color-bg); | |||
| display: flex; | |||
| flex-direction: column; | |||
| @include page-shell; | |||
| } | |||
| // ─── Header ────────────────────────────────────────────────────────────────── | |||
| .account-header { | |||
| background: linear-gradient(135deg, var(--color-header-from) 0%, var(--color-header-to) 100%); | |||
| @include section-header; | |||
| padding: $space-6; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: space-between; | |||
| gap: $space-6; | |||
| box-shadow: $shadow-header; | |||
| } | |||
| .account-header__title { | |||
| @@ -74,9 +67,7 @@ | |||
| // ─── Karte ─────────────────────────────────────────────────────────────────── | |||
| .account-card { | |||
| background: $color-card-white; | |||
| border-radius: $radius-lg; | |||
| box-shadow: $shadow-card; | |||
| @include card; | |||
| padding: $space-8; | |||
| } | |||
| @@ -89,9 +80,8 @@ | |||
| } | |||
| .account-form__label { | |||
| font-size: $font-size-sm; | |||
| @include form-label; | |||
| font-weight: $font-weight-medium; | |||
| color: $color-text-muted; | |||
| padding-top: 7px; | |||
| } | |||
| @@ -146,11 +136,7 @@ | |||
| // ─── Passwort-Sektion (toggle) ──────────────────────────────────────────────── | |||
| .account-form__pw-section { | |||
| display: contents; // bleibt im Grid-Fluss | |||
| &[hidden] { | |||
| display: none !important; | |||
| } | |||
| display: contents; | |||
| } | |||
| // ─── Actions ───────────────────────────────────────────────────────────────── | |||
| @@ -1,4 +1,5 @@ | |||
| @use '../atoms/variables' as *; | |||
| @use '../atoms/mixins' as *; | |||
| // ─── CRUD Seiten Layout ──────────────────────────────────────────────────────── | |||
| .crud-page { | |||
| @@ -22,9 +23,7 @@ | |||
| // ─── Liste ───────────────────────────────────────────────────────────────────── | |||
| .crud-list { | |||
| background: $color-card-white; | |||
| border-radius: $radius-lg; | |||
| box-shadow: $shadow-card; | |||
| @include card; | |||
| overflow: hidden; | |||
| } | |||
| @@ -86,20 +85,11 @@ | |||
| } | |||
| .crud-row__btn { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| width: 28px; | |||
| height: 28px; | |||
| border-radius: 50%; | |||
| background: transparent; | |||
| border: none; | |||
| cursor: pointer; | |||
| @include icon-btn; | |||
| opacity: 0; | |||
| transition: opacity $transition-fast, background $transition-fast, color $transition-fast; | |||
| color: $color-text-muted; | |||
| svg { width: 14px; height: 14px; pointer-events: none; } | |||
| svg { width: $icon-svg-size; height: $icon-svg-size; } | |||
| &--edit:hover { background: rgba(var(--color-primary-rgb), 0.1); color: var(--color-primary); } | |||
| &--delete:hover{ background: rgba($color-error, 0.1); color: $color-error; } | |||
| @@ -114,15 +104,11 @@ | |||
| border-top: 1px solid rgba($color-border, 0.5); | |||
| } | |||
| .crud-row__display[hidden] { display: none !important; } | |||
| // ─── Create-Formular oben ────────────────────────────────────────────────────── | |||
| .crud-create { | |||
| background: $color-card; | |||
| border-radius: $radius-lg; | |||
| @include card($color-card); | |||
| padding: $space-5 $space-6; | |||
| margin-bottom: $space-4; | |||
| box-shadow: $shadow-card; | |||
| display: none; | |||
| &--visible { display: block; } | |||
| @@ -131,11 +117,10 @@ | |||
| // ─── Tabs (Aktiv / Archiviert) ───────────────────────────────────────────────── | |||
| .crud-tabs { | |||
| display: inline-flex; | |||
| background: $color-card-white; | |||
| @include card; | |||
| border-radius: $radius-pill; | |||
| padding: 3px; | |||
| margin-bottom: $space-4; | |||
| box-shadow: $shadow-card; | |||
| } | |||
| .crud-tab { | |||
| @@ -1,11 +1,10 @@ | |||
| @use '../atoms/variables' as *; | |||
| @use '../atoms/mixins' as *; | |||
| // ─── Entry Form Card ───────────────────────────────────────────────────────── | |||
| .entry-form { | |||
| background: $color-card; | |||
| border-radius: $radius-lg; | |||
| @include card($color-card); | |||
| padding: $space-6 $space-8; | |||
| box-shadow: $shadow-card; | |||
| } | |||
| .entry-form__grid { | |||
| @@ -30,6 +29,19 @@ | |||
| gap: $space-2; | |||
| } | |||
| .entry-form__field--rate { | |||
| gap: $space-2; | |||
| } | |||
| .entry-form__unit { | |||
| color: $color-text-muted; | |||
| font-size: $font-size-sm; | |||
| } | |||
| .input--rate { | |||
| width: 100px; | |||
| } | |||
| .entry-form__field--selects { | |||
| display: flex; | |||
| gap: $space-3; | |||
| @@ -1,10 +1,9 @@ | |||
| @use '../atoms/variables' as *; | |||
| @use '../atoms/mixins' as *; | |||
| // ─── Entry List Container ────────────────────────────────────────────────── | |||
| .entry-list { | |||
| background: $color-card-white; | |||
| border-radius: $radius-lg; | |||
| box-shadow: $shadow-card; | |||
| @include card; | |||
| overflow: hidden; | |||
| transition: opacity 0.18s ease; | |||
| @@ -13,10 +12,8 @@ | |||
| // ─── Empty State ────────────────────────────────────────────────────────── | |||
| .empty-state { | |||
| background: $color-card-white; | |||
| border-radius: $radius-lg; | |||
| @include card; | |||
| padding: $space-6 $space-8; | |||
| box-shadow: $shadow-card; | |||
| } | |||
| .empty-state__title { | |||
| @@ -30,8 +27,7 @@ | |||
| .entry-list__footer { | |||
| display: flex; | |||
| justify-content: flex-end; | |||
| // 2 Buttons (28px) + 2× gap (8px) + eigener padding = Badge bündig | |||
| padding: $space-3 calc(#{$space-8} + 28px + 28px + #{$space-2} + #{$space-2}); | |||
| padding: $space-3 calc(#{$space-8} + #{$icon-btn-size} + #{$icon-btn-size} + #{$space-2} + #{$space-2}); | |||
| border-top: 1px solid $color-border; | |||
| } | |||
| @@ -51,13 +47,11 @@ | |||
| &:last-child { border-bottom: none; } | |||
| // Fade-in bei neuem Eintrag | |||
| &--new { | |||
| opacity: 0; | |||
| transform: translateY(-6px); | |||
| } | |||
| // Fade-out beim Löschen | |||
| &--removing { | |||
| opacity: 0; | |||
| transform: translateX(12px); | |||
| @@ -79,13 +73,7 @@ | |||
| &:hover { | |||
| background: rgba(var(--color-primary-rgb), 0.05); | |||
| .entry-row__btn { | |||
| opacity: 1; | |||
| } | |||
| } | |||
| &[hidden] { | |||
| display: none !important; | |||
| .entry-row__btn { opacity: 1; } | |||
| } | |||
| } | |||
| @@ -98,18 +86,14 @@ | |||
| font-size: $font-size-base; | |||
| font-weight: $font-weight-bold; | |||
| color: $color-text-dark; | |||
| white-space: nowrap; | |||
| overflow: hidden; | |||
| text-overflow: ellipsis; | |||
| @include text-truncate; | |||
| } | |||
| .entry-row__note { | |||
| font-size: $font-size-sm; | |||
| color: $color-text-muted; | |||
| margin-top: 2px; | |||
| white-space: nowrap; | |||
| overflow: hidden; | |||
| text-overflow: ellipsis; | |||
| @include text-truncate; | |||
| } | |||
| .entry-row__actions { | |||
| @@ -132,25 +116,15 @@ | |||
| } | |||
| .entry-row__btn { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| width: 28px; | |||
| height: 28px; | |||
| border-radius: 50%; | |||
| background: transparent; | |||
| border: none; | |||
| cursor: pointer; | |||
| @include icon-btn; | |||
| opacity: 0; | |||
| transition: opacity $transition-fast, background $transition-fast, color $transition-fast; | |||
| color: $color-text-muted; | |||
| svg { width: 14px; height: 14px; pointer-events: none; } | |||
| svg { width: $icon-svg-size; height: $icon-svg-size; } | |||
| &--edit:hover { background: rgba(var(--color-primary-rgb), 0.1); color: var(--color-primary); } | |||
| &--delete:hover{ background: rgba($color-error, 0.1); color: $color-error; } | |||
| // immer sichtbar auf Touch-Geräten | |||
| @media (hover: none) { opacity: 1; } | |||
| } | |||
| @@ -159,11 +133,11 @@ | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| width: calc(28px + #{$space-2} + 28px); | |||
| width: calc(#{$icon-btn-size} + #{$space-2} + #{$icon-btn-size}); | |||
| flex-shrink: 0; | |||
| color: $color-text-dark; | |||
| svg { width: 14px; height: 14px; pointer-events: none; } | |||
| svg { width: $icon-svg-size; height: $icon-svg-size; pointer-events: none; } | |||
| } | |||
| // ─── Abgerechneter Eintrag ──────────────────────────────────────────────── | |||
| @@ -181,7 +155,6 @@ | |||
| } | |||
| .entry-form__grid--inline { | |||
| // Gleiche Grid-Struktur wie das Haupt-Formular | |||
| display: grid; | |||
| grid-template-columns: 130px 1fr; | |||
| gap: $space-3 $space-6; | |||
| @@ -1,4 +1,5 @@ | |||
| @use '../atoms/variables' as *; | |||
| @use '../atoms/mixins' as *; | |||
| // ─── Login Page ─────────────────────────────────────────────────────────────── | |||
| .login-body { | |||
| @@ -11,12 +12,10 @@ | |||
| // ─── Card ───────────────────────────────────────────────────────────────────── | |||
| .login-card { | |||
| background: $color-card-white; | |||
| border-radius: $radius-xl; | |||
| @include card($color-card-white, $radius-xl); | |||
| padding: $space-10 $space-12; | |||
| width: 100%; | |||
| max-width: 540px; | |||
| box-shadow: $shadow-card; | |||
| } | |||
| .login-card__title { | |||
| @@ -47,10 +46,8 @@ | |||
| } | |||
| .login-form__label { | |||
| @include form-label; | |||
| font-size: $font-size-base; | |||
| color: $color-text-muted; | |||
| text-align: right; | |||
| padding-right: $space-2; | |||
| } | |||
| .login-form__field { | |||
| @@ -76,7 +73,7 @@ | |||
| } | |||
| } | |||
| // ─── Footer-Link (z. B. "Zurück zur Anmeldung") ─────────────────────────────── | |||
| // ─── Footer-Link ────────────────────────────────────────────────────────────── | |||
| .login-form__footer { | |||
| text-align: center; | |||
| margin-top: $space-6; | |||
| @@ -141,4 +138,4 @@ | |||
| .login-form__submit { | |||
| padding: $space-3 $space-10; | |||
| font-size: $font-size-md; | |||
| } | |||
| } | |||
| @@ -1,4 +1,5 @@ | |||
| @use '../atoms/variables' as *; | |||
| @use '../atoms/mixins' as *; | |||
| // ─── Monatskalender Container ───────────────────────────────────────────────── | |||
| .month-calendar { | |||
| @@ -44,17 +45,8 @@ | |||
| } | |||
| .month-calendar__arrow { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| width: 28px; | |||
| height: 28px; | |||
| border-radius: 50%; | |||
| background: transparent; | |||
| border: none; | |||
| @include icon-btn; | |||
| color: var(--header-text); | |||
| cursor: pointer; | |||
| transition: background $transition-fast; | |||
| &:hover { background: var(--header-overlay); } | |||
| @@ -62,7 +54,6 @@ | |||
| } | |||
| .month-calendar__close { | |||
| // erbt .week-nav__cal Styles – hier nur Positionierung | |||
| margin-left: 0; | |||
| } | |||
| @@ -112,13 +103,11 @@ | |||
| background: var(--header-overlay); | |||
| } | |||
| // Tage aus Vor-/Nachmonat | |||
| &--other { | |||
| color: var(--header-text-muted); | |||
| cursor: default; | |||
| } | |||
| // Heutiger Tag | |||
| &--today { | |||
| font-weight: $font-weight-bold; | |||
| background: $color-white; | |||
| @@ -129,7 +118,6 @@ | |||
| } | |||
| } | |||
| // Ausgewählter Tag | |||
| &--active:not(&--today) { | |||
| background: var(--header-overlay); | |||
| font-weight: $font-weight-bold; | |||
| @@ -1,4 +1,5 @@ | |||
| @use '../atoms/variables' as *; | |||
| @use '../atoms/mixins' as *; | |||
| .register-body { | |||
| min-height: 100vh; | |||
| @@ -16,10 +17,8 @@ | |||
| } | |||
| .register-card { | |||
| background: $color-card-white; | |||
| border-radius: $radius-xl; | |||
| @include card($color-card-white, $radius-xl); | |||
| padding: $space-10 $space-12; | |||
| box-shadow: $shadow-card; | |||
| } | |||
| .register-card__brand { | |||
| @@ -186,8 +185,12 @@ | |||
| margin-bottom: $space-3; | |||
| } | |||
| .register-success__btn { | |||
| margin-top: $space-6; | |||
| } | |||
| .register-success__hint { | |||
| font-size: $font-size-sm; | |||
| color: $color-text-muted; | |||
| line-height: $line-height-base; | |||
| } | |||
| } | |||
| @@ -1,4 +1,5 @@ | |||
| @use '../atoms/variables' as *; | |||
| @use '../atoms/mixins' as *; | |||
| // ─── Ausstehend-Badge ────────────────────────────────────────────────────────── | |||
| .team-badge { | |||
| @@ -24,14 +25,10 @@ | |||
| align-items: center; | |||
| justify-content: center; | |||
| z-index: 200; | |||
| &[hidden] { display: none !important; } | |||
| } | |||
| .modal-card { | |||
| background: $color-card-white; | |||
| border-radius: $radius-lg; | |||
| box-shadow: $shadow-card; | |||
| @include card; | |||
| width: 100%; | |||
| max-width: 460px; | |||
| padding: 0; | |||
| @@ -53,18 +50,11 @@ | |||
| } | |||
| .modal-card__close { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| width: 28px; | |||
| height: 28px; | |||
| background: transparent; | |||
| border: none; | |||
| cursor: pointer; | |||
| @include icon-btn; | |||
| color: $color-text-muted; | |||
| border-radius: 50%; | |||
| transition: background $transition-fast; | |||
| svg { width: 16px; height: 16px; } | |||
| &:hover { background: rgba($color-border, 0.5); } | |||
| } | |||
| @@ -111,8 +101,6 @@ | |||
| color: $color-error; | |||
| font-size: $font-size-sm; | |||
| &[hidden] { display: none !important; } | |||
| ul { margin: 0; padding-left: 1.2em; } | |||
| } | |||
| @@ -161,4 +149,4 @@ | |||
| margin-top: $space-1; | |||
| font-size: $font-size-xs; | |||
| color: $color-text-muted; | |||
| } | |||
| } | |||
| @@ -1,4 +1,5 @@ | |||
| @use '../atoms/variables' as *; | |||
| @use '../atoms/mixins' as *; | |||
| // ─── Wrapper ───────────────────────────────────────────────────────────────── | |||
| .week-nav { | |||
| @@ -14,19 +15,9 @@ | |||
| // ─── Pfeile ────────────────────────────────────────────────────────────────── | |||
| .week-nav__arrow { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| width: 28px; | |||
| height: 28px; | |||
| border-radius: 50%; | |||
| background: transparent; | |||
| border: none; | |||
| @include icon-btn; | |||
| color: var(--header-text); | |||
| cursor: pointer; | |||
| text-decoration: none; | |||
| flex-shrink: 0; | |||
| transition: background $transition-fast; | |||
| &:hover { background: var(--header-overlay); } | |||
| @@ -101,21 +92,12 @@ | |||
| // ─── Kalender-Icon ─────────────────────────────────────────────────────────── | |||
| .week-nav__cal { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| width: 34px; | |||
| height: 34px; | |||
| border-radius: $radius-md; | |||
| @include icon-btn(34px, $radius-md); | |||
| background: var(--header-overlay); | |||
| color: var(--header-text); | |||
| cursor: pointer; | |||
| border: none; | |||
| margin-left: $space-1; | |||
| flex-shrink: 0; | |||
| transition: background $transition-fast; | |||
| svg { width: 16px; height: 16px; pointer-events: none; } | |||
| svg { width: 16px; height: 16px; } | |||
| &:hover, | |||
| &--active { background: var(--header-overlay); } | |||
| @@ -1,5 +1,6 @@ | |||
| // ─── Atoms ──────────────────────────────────────────────────────────────────── | |||
| @use 'atoms/variables' as *; | |||
| @use 'atoms/mixins' as *; | |||
| @use 'atoms/typography'; | |||
| @use 'atoms/buttons'; | |||
| @use 'atoms/inputs'; | |||
| @@ -27,6 +28,8 @@ | |||
| @use 'themes/minimal'; | |||
| // ─── Reset / Base ───────────────────────────────────────────────────────────── | |||
| @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap'); | |||
| *, | |||
| *::before, | |||
| *::after { | |||
| @@ -43,4 +46,6 @@ body { | |||
| background: var(--color-bg); | |||
| } | |||
| @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap'); | |||
| [hidden] { | |||
| display: none !important; | |||
| } | |||
| @@ -1,10 +1,8 @@ | |||
| @use '../atoms/variables' as *; | |||
| @use '../atoms/mixins' as *; | |||
| .home-body { | |||
| min-height: 100vh; | |||
| background: var(--color-bg); | |||
| display: flex; | |||
| flex-direction: column; | |||
| @include page-shell; | |||
| } | |||
| // ─── Header ────────────────────────────────────────────────────────────────── | |||
| @@ -64,4 +62,4 @@ | |||
| .home-hero__cta { | |||
| font-size: $font-size-md; | |||
| padding: $space-4 $space-10; | |||
| } | |||
| } | |||
| @@ -1,22 +1,15 @@ | |||
| @use '../atoms/variables' as *; | |||
| @use '../atoms/mixins' as *; | |||
| // ─── Page ───────────────────────────────────────────────────────────────────── | |||
| .report-page { | |||
| min-height: 100vh; | |||
| background: var(--color-bg); | |||
| display: flex; | |||
| flex-direction: column; | |||
| @include page-shell; | |||
| } | |||
| // ─── Header ────────────────────────────────────────────────────────────────── | |||
| .report-header { | |||
| background: linear-gradient(135deg, var(--color-header-from) 0%, var(--color-header-to) 100%); | |||
| @include section-header; | |||
| padding: $space-4 $space-6; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: space-between; | |||
| gap: $space-6; | |||
| box-shadow: $shadow-header; | |||
| } | |||
| .report-header__title { | |||
| @@ -72,9 +65,7 @@ | |||
| // ─── Karte ─────────────────────────────────────────────────────────────────── | |||
| .report-card { | |||
| background: $color-card-white; | |||
| border-radius: $radius-lg; | |||
| box-shadow: $shadow-card; | |||
| @include card; | |||
| overflow: hidden; | |||
| } | |||
| @@ -93,6 +84,31 @@ | |||
| gap: $space-6; | |||
| } | |||
| .report-toolbar__right { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: $space-2; | |||
| } | |||
| .report-toolbar__export { | |||
| @include icon-btn(30px, $radius-sm); | |||
| color: $color-text-light; | |||
| svg { width: 18px; height: 18px; } | |||
| &:hover { | |||
| color: var(--color-primary); | |||
| background: rgba(var(--color-primary-rgb), 0.08); | |||
| } | |||
| } | |||
| .report-toolbar__separator { | |||
| width: 1px; | |||
| height: 18px; | |||
| background: $color-border; | |||
| margin: 0 $space-1; | |||
| } | |||
| .report-toolbar__action { | |||
| display: inline-flex; | |||
| align-items: center; | |||
| @@ -104,8 +120,8 @@ | |||
| text-decoration: none; | |||
| svg { | |||
| width: 14px; | |||
| height: 14px; | |||
| width: $icon-svg-size; | |||
| height: $icon-svg-size; | |||
| flex-shrink: 0; | |||
| } | |||
| @@ -133,7 +149,7 @@ | |||
| 1fr // Bemerkung | |||
| 80px // Stunden | |||
| 100px // Umsatz | |||
| 88px; // Aktionen (Edit + Delete + Schloss) | |||
| 88px; // Aktionen | |||
| align-items: center; | |||
| border-bottom: 1px solid $color-border; | |||
| padding: 0 $space-5; | |||
| @@ -142,7 +158,6 @@ | |||
| .report-table__head { | |||
| padding-top: $space-2; | |||
| padding-bottom: $space-2; | |||
| background: transparent; | |||
| .report-table__cell { | |||
| font-size: $font-size-xs; | |||
| @@ -161,34 +176,24 @@ | |||
| &:hover { | |||
| background: rgba(var(--color-primary-rgb), 0.05); | |||
| } | |||
| &:hover .report-action-btn { | |||
| opacity: 1; | |||
| .report-action-btn { opacity: 1; } | |||
| } | |||
| &--invoiced { | |||
| .report-table__cell--date { color: $color-text-light; } | |||
| .report-table__cell--client { color: $color-text-light; } | |||
| .report-table__cell--project { color: $color-text-light; } | |||
| .report-table__cell--service { color: $color-text-light; } | |||
| .report-table__cell--user { color: $color-text-light; } | |||
| .report-table__cell--note { color: $color-text-light; } | |||
| .report-table__cell--duration { color: $color-text-light; } | |||
| .report-table__cell--revenue { color: $color-text-light; } | |||
| &--invoiced .report-table__cell { | |||
| &--date, &--client, &--project, &--service, | |||
| &--user, &--note, &--duration, &--revenue { | |||
| color: $color-text-light; | |||
| } | |||
| } | |||
| &--editing { | |||
| background: rgba(var(--color-primary-rgb), 0.05); | |||
| .report-table__cell--actions { | |||
| visibility: hidden; | |||
| } | |||
| .report-table__cell--actions { visibility: hidden; } | |||
| } | |||
| &:last-child { | |||
| border-bottom: none; | |||
| } | |||
| &:last-child { border-bottom: none; } | |||
| } | |||
| .report-table__cell { | |||
| @@ -217,9 +222,7 @@ | |||
| &--note { | |||
| color: $color-text-muted; | |||
| font-size: $font-size-sm; | |||
| white-space: nowrap; | |||
| overflow: hidden; | |||
| text-overflow: ellipsis; | |||
| @include text-truncate; | |||
| } | |||
| } | |||
| @@ -245,23 +248,11 @@ | |||
| // ─── Aktions-Buttons (Edit / Delete) ───────────────────────────────────────── | |||
| .report-action-btn { | |||
| display: inline-flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| width: 26px; | |||
| height: 26px; | |||
| border: none; | |||
| background: none; | |||
| cursor: pointer; | |||
| color: $color-text-light; | |||
| border-radius: $radius-sm; | |||
| @include icon-btn(26px, $radius-sm); | |||
| opacity: 0; | |||
| transition: opacity $transition-fast, color $transition-fast, background $transition-fast; | |||
| color: $color-text-light; | |||
| svg { | |||
| width: 14px; | |||
| height: 14px; | |||
| } | |||
| svg { width: $icon-svg-size; height: $icon-svg-size; } | |||
| &:hover { | |||
| color: $color-text-muted; | |||
| @@ -276,22 +267,10 @@ | |||
| // ─── Schloss-Button ────────────────────────────────────────────────────────── | |||
| .report-lock { | |||
| display: inline-flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| width: 24px; | |||
| height: 24px; | |||
| border: none; | |||
| background: none; | |||
| cursor: pointer; | |||
| @include icon-btn(24px, $radius-sm); | |||
| color: $color-text-light; | |||
| border-radius: $radius-sm; | |||
| transition: color $transition-fast, background $transition-fast; | |||
| svg { | |||
| width: 14px; | |||
| height: 14px; | |||
| } | |||
| svg { width: $icon-svg-size; height: $icon-svg-size; } | |||
| &:hover { | |||
| color: $color-text-muted; | |||
| @@ -325,11 +304,7 @@ | |||
| } | |||
| .report-row__edit-label { | |||
| font-size: $font-size-sm; | |||
| color: $color-text-muted; | |||
| text-align: right; | |||
| padding-right: $space-2; | |||
| white-space: nowrap; | |||
| @include form-label; | |||
| } | |||
| .report-row__edit-field { | |||
| @@ -379,9 +354,7 @@ | |||
| text-decoration: underline; | |||
| cursor: pointer; | |||
| &:hover { | |||
| color: var(--color-primary-dark); | |||
| } | |||
| &:hover { color: var(--color-primary-dark); } | |||
| } | |||
| strong { | |||
| @@ -401,7 +374,7 @@ | |||
| } | |||
| .report-pagination__lock-spacer { | |||
| // Platzhalter für die Aktions-Spalte – hält die Ausrichtung | |||
| // Platzhalter – hält Spalten-Ausrichtung mit der Tabelle | |||
| } | |||
| // ─── Toolbar-Button (klickbar) ──────────────────────────────────────────────── | |||
| @@ -462,29 +435,28 @@ button.report-toolbar__action { | |||
| padding: $space-2 0; | |||
| border-bottom: 1px solid rgba($color-border, 0.6); | |||
| &:last-child { | |||
| border-bottom: none; | |||
| } | |||
| &:last-child { border-bottom: none; } | |||
| // Ausgegraut wenn inaktiv – aber klickbar! | |||
| &--inactive { | |||
| .filter-select, | |||
| .filter-note-input, | |||
| .filter-period-select { | |||
| .filter-period-select, | |||
| .filter-radio { | |||
| opacity: 0.5; | |||
| color: $color-text-muted; | |||
| } | |||
| .filter-row__add { | |||
| opacity: 0.4; | |||
| .filter-select, | |||
| .filter-note-input, | |||
| .filter-period-select { | |||
| color: $color-text-muted; | |||
| } | |||
| .filter-radio { | |||
| opacity: 0.5; | |||
| .filter-row__add, | |||
| .filter-neg { | |||
| opacity: 0.4; | |||
| } | |||
| .filter-neg { | |||
| opacity: 0.4; | |||
| pointer-events: none; | |||
| } | |||
| } | |||
| @@ -497,7 +469,7 @@ button.report-toolbar__action { | |||
| cursor: pointer; | |||
| font-size: $font-size-sm; | |||
| color: $color-text-base; | |||
| padding-top: 7px; // optisch mit den Selects ausrichten | |||
| padding-top: 7px; | |||
| user-select: none; | |||
| } | |||
| @@ -552,31 +524,23 @@ button.report-toolbar__action { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: $space-3; | |||
| padding-top: 7px; // vertikal mit Select ausrichten | |||
| padding-top: 7px; | |||
| flex-shrink: 0; | |||
| white-space: nowrap; | |||
| &--no-add { | |||
| padding-left: calc(22px + #{$space-3}); // Platz für fehlenden Add-Button | |||
| padding-left: calc(22px + #{$space-3}); | |||
| } | |||
| } | |||
| // ─── Plus- und Minus-Button ─────────────────────────────────────────────────── | |||
| .filter-row__add { | |||
| width: 22px; | |||
| height: 22px; | |||
| @include icon-btn(22px, $radius-sm); | |||
| border: 1px solid $color-input-border; | |||
| background: $color-white; | |||
| border-radius: $radius-sm; | |||
| cursor: pointer; | |||
| font-size: $font-size-md; | |||
| line-height: 1; | |||
| color: $color-text-muted; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| flex-shrink: 0; | |||
| transition: border-color $transition-fast, color $transition-fast; | |||
| &:hover { | |||
| border-color: var(--color-primary); | |||
| @@ -585,20 +549,10 @@ button.report-toolbar__action { | |||
| } | |||
| .filter-row__remove { | |||
| width: 20px; | |||
| height: 20px; | |||
| border: none; | |||
| background: none; | |||
| cursor: pointer; | |||
| @include icon-btn(20px, $radius-sm); | |||
| font-size: $font-size-md; | |||
| line-height: 1; | |||
| color: $color-text-light; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| border-radius: $radius-sm; | |||
| flex-shrink: 0; | |||
| transition: color $transition-fast, background $transition-fast; | |||
| &:hover { | |||
| color: $color-error; | |||
| @@ -612,11 +566,6 @@ button.report-toolbar__action { | |||
| flex-direction: column; | |||
| gap: $space-2; | |||
| margin-top: $space-2; | |||
| // [hidden] wird durch display:flex überschrieben – explizit gegensteuern | |||
| &[hidden] { | |||
| display: none; | |||
| } | |||
| } | |||
| .filter-date-group { | |||
| @@ -679,9 +628,7 @@ button.report-toolbar__action { | |||
| text-underline-offset: 2px; | |||
| transition: color $transition-fast; | |||
| &:hover { | |||
| color: $color-text-base; | |||
| } | |||
| &:hover { color: $color-text-base; } | |||
| } | |||
| // ─── Negativfilter-Checkbox ─────────────────────────────────────────────────── | |||
| @@ -1,26 +1,19 @@ | |||
| @use '../atoms/variables' as *; | |||
| @use '../atoms/mixins' as *; | |||
| // ─── Page Wrapper ───────────────────────────────────────────────────────────── | |||
| .tt-page { | |||
| min-height: 100vh; | |||
| background: var(--color-bg); | |||
| display: flex; | |||
| flex-direction: column; | |||
| @include page-shell; | |||
| } | |||
| // ─── Header Section ────────────────────────────────────────────────────────── | |||
| .tt-header { | |||
| background: linear-gradient(135deg, var(--color-header-from) 0%, var(--color-header-to) 100%); | |||
| @include section-header; | |||
| padding: $space-4 $space-6; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: space-between; | |||
| gap: $space-6; | |||
| min-height: $header-height; | |||
| position: sticky; | |||
| top: 0; | |||
| z-index: 100; | |||
| box-shadow: $shadow-header; | |||
| } | |||
| .tt-header__meta { | |||
| @@ -47,7 +40,7 @@ | |||
| max-width: $content-max-width; | |||
| width: 100%; | |||
| margin: 0 auto; | |||
| padding: $space-6 $space-6; | |||
| padding: $space-6; | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: $space-4; | |||
| @@ -1,11 +1,12 @@ | |||
| @use '../atoms/variables' as *; | |||
| @use '../atoms/mixins' 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; | |||
| background: $color-white; | |||
| // ── Normale Top-Nav ausblenden ────────────────────────────────────────────── | |||
| .main-nav { display: none; } | |||
| @@ -18,11 +19,11 @@ body[data-theme="minimal"] { | |||
| } | |||
| // ── Page-Background weiß ─────────────────────────────────────────────────── | |||
| .tt-page { background: #fff; } | |||
| .tt-page { background: $color-white; } | |||
| // ── TT-Header: kein Gradient, kein Schatten, cleaner Rahmen ─────────────── | |||
| .tt-header { | |||
| background: #fff; | |||
| background: $color-white; | |||
| box-shadow: none; | |||
| border-bottom: 1px solid $color-border; | |||
| padding: $space-3 $space-5; | |||
| @@ -85,7 +86,7 @@ body[data-theme="minimal"] { | |||
| // ── Entry Form: cleaner, größere Inputs ──────────────────────────────────── | |||
| .entry-form { | |||
| background: #fff; | |||
| background: $color-white; | |||
| border: none; | |||
| border-radius: 0; | |||
| padding: $space-4 0; | |||
| @@ -153,7 +154,7 @@ body[data-theme="minimal"] { | |||
| } | |||
| .entry-list { | |||
| background: #fff; | |||
| background: $color-white; | |||
| box-shadow: none; | |||
| border: 1px solid $color-border; | |||
| border-radius: $radius-md; | |||
| @@ -164,7 +165,7 @@ body[data-theme="minimal"] { | |||
| .crud-page, | |||
| .account-page, | |||
| .team-page { | |||
| background: #fff; | |||
| background: $color-white; | |||
| } | |||
| } | |||
| @@ -180,7 +181,7 @@ body[data-theme="minimal"] { | |||
| height: 52px; | |||
| border: none; | |||
| border-radius: $radius-lg; | |||
| background: #fff; | |||
| background: $color-white; | |||
| cursor: pointer; | |||
| display: flex; | |||
| align-items: center; | |||
| @@ -230,7 +231,7 @@ body[data-theme="minimal"] { | |||
| top: calc(100% + #{$space-2}); | |||
| right: 0; | |||
| min-width: 200px; | |||
| background: #fff; | |||
| background: $color-white; | |||
| border: 1px solid $color-border; | |||
| border-radius: $radius-md; | |||
| box-shadow: 0 8px 24px rgba(0,0,0,0.12); | |||
| @@ -303,7 +304,7 @@ body[data-theme="minimal"] { | |||
| align-items: center; | |||
| gap: $space-2; | |||
| padding: $space-3 $space-4; | |||
| background: #fff; | |||
| background: $color-white; | |||
| border: 1px solid $color-border; | |||
| border-radius: $radius-md; | |||
| font-size: $font-size-base; | |||
| @@ -12,6 +12,8 @@ | |||
| "doctrine/doctrine-bundle": "^3.2.2", | |||
| "doctrine/doctrine-migrations-bundle": "^4.0", | |||
| "doctrine/orm": "^3.6.6", | |||
| "dompdf/dompdf": "^3.1", | |||
| "phpoffice/phpspreadsheet": "^5.8", | |||
| "symfony/console": "7.4.*", | |||
| "symfony/dotenv": "7.4.*", | |||
| "symfony/flex": "^2.10", | |||
| @@ -4,8 +4,84 @@ | |||
| "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", | |||
| "This file is @generated automatically" | |||
| ], | |||
| "content-hash": "dae707f4e483331f467dcf211922216c", | |||
| "content-hash": "6a52005068f345beb15a732e99cbb73a", | |||
| "packages": [ | |||
| { | |||
| "name": "composer/pcre", | |||
| "version": "3.4.0", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/composer/pcre.git", | |||
| "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", | |||
| "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "php": "^7.4 || ^8.0" | |||
| }, | |||
| "conflict": { | |||
| "phpstan/phpstan": "<2.2.2" | |||
| }, | |||
| "require-dev": { | |||
| "phpstan/phpstan": "^2", | |||
| "phpstan/phpstan-deprecation-rules": "^2", | |||
| "phpstan/phpstan-strict-rules": "^2", | |||
| "phpunit/phpunit": "^9" | |||
| }, | |||
| "type": "library", | |||
| "extra": { | |||
| "phpstan": { | |||
| "includes": [ | |||
| "extension.neon" | |||
| ] | |||
| }, | |||
| "branch-alias": { | |||
| "dev-main": "3.x-dev" | |||
| } | |||
| }, | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Composer\\Pcre\\": "src" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Jordi Boggiano", | |||
| "email": "j.boggiano@seld.be", | |||
| "homepage": "http://seld.be" | |||
| } | |||
| ], | |||
| "description": "PCRE wrapping library that offers type-safe preg_* replacements.", | |||
| "keywords": [ | |||
| "PCRE", | |||
| "preg", | |||
| "regex", | |||
| "regular expression" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/composer/pcre/issues", | |||
| "source": "https://github.com/composer/pcre/tree/3.4.0" | |||
| }, | |||
| "funding": [ | |||
| { | |||
| "url": "https://packagist.com", | |||
| "type": "custom" | |||
| }, | |||
| { | |||
| "url": "https://github.com/composer", | |||
| "type": "github" | |||
| } | |||
| ], | |||
| "time": "2026-06-07T11:47:49+00:00" | |||
| }, | |||
| { | |||
| "name": "doctrine/collections", | |||
| "version": "2.6.0", | |||
| @@ -1120,6 +1196,161 @@ | |||
| }, | |||
| "time": "2026-02-08T16:21:46+00:00" | |||
| }, | |||
| { | |||
| "name": "dompdf/dompdf", | |||
| "version": "v3.1.5", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/dompdf/dompdf.git", | |||
| "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", | |||
| "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "dompdf/php-font-lib": "^1.0.0", | |||
| "dompdf/php-svg-lib": "^1.0.0", | |||
| "ext-dom": "*", | |||
| "ext-mbstring": "*", | |||
| "masterminds/html5": "^2.0", | |||
| "php": "^7.1 || ^8.0" | |||
| }, | |||
| "require-dev": { | |||
| "ext-gd": "*", | |||
| "ext-json": "*", | |||
| "ext-zip": "*", | |||
| "mockery/mockery": "^1.3", | |||
| "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", | |||
| "squizlabs/php_codesniffer": "^3.5", | |||
| "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" | |||
| }, | |||
| "suggest": { | |||
| "ext-gd": "Needed to process images", | |||
| "ext-gmagick": "Improves image processing performance", | |||
| "ext-imagick": "Improves image processing performance", | |||
| "ext-zlib": "Needed for pdf stream compression" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Dompdf\\": "src/" | |||
| }, | |||
| "classmap": [ | |||
| "lib/" | |||
| ] | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "LGPL-2.1" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "The Dompdf Community", | |||
| "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" | |||
| } | |||
| ], | |||
| "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", | |||
| "homepage": "https://github.com/dompdf/dompdf", | |||
| "support": { | |||
| "issues": "https://github.com/dompdf/dompdf/issues", | |||
| "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" | |||
| }, | |||
| "time": "2026-03-03T13:54:37+00:00" | |||
| }, | |||
| { | |||
| "name": "dompdf/php-font-lib", | |||
| "version": "1.0.2", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/dompdf/php-font-lib.git", | |||
| "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", | |||
| "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "ext-mbstring": "*", | |||
| "php": "^7.1 || ^8.0" | |||
| }, | |||
| "require-dev": { | |||
| "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "psr-4": { | |||
| "FontLib\\": "src/FontLib" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "LGPL-2.1-or-later" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "The FontLib Community", | |||
| "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" | |||
| } | |||
| ], | |||
| "description": "A library to read, parse, export and make subsets of different types of font files.", | |||
| "homepage": "https://github.com/dompdf/php-font-lib", | |||
| "support": { | |||
| "issues": "https://github.com/dompdf/php-font-lib/issues", | |||
| "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" | |||
| }, | |||
| "time": "2026-01-20T14:10:26+00:00" | |||
| }, | |||
| { | |||
| "name": "dompdf/php-svg-lib", | |||
| "version": "1.0.2", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/dompdf/php-svg-lib.git", | |||
| "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", | |||
| "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "ext-mbstring": "*", | |||
| "php": "^7.1 || ^8.0", | |||
| "sabberworm/php-css-parser": "^8.4 || ^9.0" | |||
| }, | |||
| "require-dev": { | |||
| "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Svg\\": "src/Svg" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "LGPL-3.0-or-later" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "The SvgLib Community", | |||
| "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" | |||
| } | |||
| ], | |||
| "description": "A library to read, parse and export to PDF SVG files.", | |||
| "homepage": "https://github.com/dompdf/php-svg-lib", | |||
| "support": { | |||
| "issues": "https://github.com/dompdf/php-svg-lib/issues", | |||
| "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" | |||
| }, | |||
| "time": "2026-01-02T16:01:13+00:00" | |||
| }, | |||
| { | |||
| "name": "egulias/email-validator", | |||
| "version": "4.0.4", | |||
| @@ -1187,6 +1418,258 @@ | |||
| ], | |||
| "time": "2025-03-06T22:45:56+00:00" | |||
| }, | |||
| { | |||
| "name": "maennchen/zipstream-php", | |||
| "version": "3.2.2", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/maennchen/ZipStream-PHP.git", | |||
| "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", | |||
| "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "ext-mbstring": "*", | |||
| "ext-zlib": "*", | |||
| "php-64bit": "^8.3" | |||
| }, | |||
| "require-dev": { | |||
| "brianium/paratest": "^7.7", | |||
| "ext-zip": "*", | |||
| "friendsofphp/php-cs-fixer": "^3.86", | |||
| "guzzlehttp/guzzle": "^7.5", | |||
| "mikey179/vfsstream": "^1.6", | |||
| "php-coveralls/php-coveralls": "^2.5", | |||
| "phpunit/phpunit": "^12.0", | |||
| "vimeo/psalm": "^6.0" | |||
| }, | |||
| "suggest": { | |||
| "guzzlehttp/psr7": "^2.4", | |||
| "psr/http-message": "^2.0" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "psr-4": { | |||
| "ZipStream\\": "src/" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Paul Duncan", | |||
| "email": "pabs@pablotron.org" | |||
| }, | |||
| { | |||
| "name": "Jonatan Männchen", | |||
| "email": "jonatan@maennchen.ch" | |||
| }, | |||
| { | |||
| "name": "Jesse Donat", | |||
| "email": "donatj@gmail.com" | |||
| }, | |||
| { | |||
| "name": "András Kolesár", | |||
| "email": "kolesar@kolesar.hu" | |||
| } | |||
| ], | |||
| "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", | |||
| "keywords": [ | |||
| "stream", | |||
| "zip" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/maennchen/ZipStream-PHP/issues", | |||
| "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2" | |||
| }, | |||
| "funding": [ | |||
| { | |||
| "url": "https://github.com/maennchen", | |||
| "type": "github" | |||
| } | |||
| ], | |||
| "time": "2026-04-11T18:38:28+00:00" | |||
| }, | |||
| { | |||
| "name": "markbaker/complex", | |||
| "version": "3.0.2", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/MarkBaker/PHPComplex.git", | |||
| "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", | |||
| "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "php": "^7.2 || ^8.0" | |||
| }, | |||
| "require-dev": { | |||
| "dealerdirect/phpcodesniffer-composer-installer": "dev-master", | |||
| "phpcompatibility/php-compatibility": "^9.3", | |||
| "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", | |||
| "squizlabs/php_codesniffer": "^3.7" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Complex\\": "classes/src/" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Mark Baker", | |||
| "email": "mark@lange.demon.co.uk" | |||
| } | |||
| ], | |||
| "description": "PHP Class for working with complex numbers", | |||
| "homepage": "https://github.com/MarkBaker/PHPComplex", | |||
| "keywords": [ | |||
| "complex", | |||
| "mathematics" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/MarkBaker/PHPComplex/issues", | |||
| "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" | |||
| }, | |||
| "time": "2022-12-06T16:21:08+00:00" | |||
| }, | |||
| { | |||
| "name": "markbaker/matrix", | |||
| "version": "3.0.1", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/MarkBaker/PHPMatrix.git", | |||
| "reference": "728434227fe21be27ff6d86621a1b13107a2562c" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", | |||
| "reference": "728434227fe21be27ff6d86621a1b13107a2562c", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "php": "^7.1 || ^8.0" | |||
| }, | |||
| "require-dev": { | |||
| "dealerdirect/phpcodesniffer-composer-installer": "dev-master", | |||
| "phpcompatibility/php-compatibility": "^9.3", | |||
| "phpdocumentor/phpdocumentor": "2.*", | |||
| "phploc/phploc": "^4.0", | |||
| "phpmd/phpmd": "2.*", | |||
| "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", | |||
| "sebastian/phpcpd": "^4.0", | |||
| "squizlabs/php_codesniffer": "^3.7" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Matrix\\": "classes/src/" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Mark Baker", | |||
| "email": "mark@demon-angel.eu" | |||
| } | |||
| ], | |||
| "description": "PHP Class for working with matrices", | |||
| "homepage": "https://github.com/MarkBaker/PHPMatrix", | |||
| "keywords": [ | |||
| "mathematics", | |||
| "matrix", | |||
| "vector" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/MarkBaker/PHPMatrix/issues", | |||
| "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" | |||
| }, | |||
| "time": "2022-12-02T22:17:43+00:00" | |||
| }, | |||
| { | |||
| "name": "masterminds/html5", | |||
| "version": "2.10.0", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/Masterminds/html5-php.git", | |||
| "reference": "fcf91eb64359852f00d921887b219479b4f21251" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", | |||
| "reference": "fcf91eb64359852f00d921887b219479b4f21251", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "ext-dom": "*", | |||
| "php": ">=5.3.0" | |||
| }, | |||
| "require-dev": { | |||
| "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" | |||
| }, | |||
| "type": "library", | |||
| "extra": { | |||
| "branch-alias": { | |||
| "dev-master": "2.7-dev" | |||
| } | |||
| }, | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Masterminds\\": "src" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Matt Butcher", | |||
| "email": "technosophos@gmail.com" | |||
| }, | |||
| { | |||
| "name": "Matt Farina", | |||
| "email": "matt@mattfarina.com" | |||
| }, | |||
| { | |||
| "name": "Asmir Mustafic", | |||
| "email": "goetas@gmail.com" | |||
| } | |||
| ], | |||
| "description": "An HTML5 parser and serializer.", | |||
| "homepage": "http://masterminds.github.io/html5-php", | |||
| "keywords": [ | |||
| "HTML5", | |||
| "dom", | |||
| "html", | |||
| "parser", | |||
| "querypath", | |||
| "serializer", | |||
| "xml" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/Masterminds/html5-php/issues", | |||
| "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" | |||
| }, | |||
| "time": "2025-07-25T09:04:22+00:00" | |||
| }, | |||
| { | |||
| "name": "monolog/monolog", | |||
| "version": "3.10.0", | |||
| @@ -1290,6 +1773,115 @@ | |||
| ], | |||
| "time": "2026-01-02T08:56:05+00:00" | |||
| }, | |||
| { | |||
| "name": "phpoffice/phpspreadsheet", | |||
| "version": "5.8.0", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", | |||
| "reference": "01964d92536edf1a3a874b9580a52824bebf6fbb" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/01964d92536edf1a3a874b9580a52824bebf6fbb", | |||
| "reference": "01964d92536edf1a3a874b9580a52824bebf6fbb", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "composer/pcre": "^1||^2||^3", | |||
| "ext-ctype": "*", | |||
| "ext-dom": "*", | |||
| "ext-fileinfo": "*", | |||
| "ext-filter": "*", | |||
| "ext-gd": "*", | |||
| "ext-iconv": "*", | |||
| "ext-libxml": "*", | |||
| "ext-mbstring": "*", | |||
| "ext-simplexml": "*", | |||
| "ext-xml": "*", | |||
| "ext-xmlreader": "*", | |||
| "ext-xmlwriter": "*", | |||
| "ext-zip": "*", | |||
| "ext-zlib": "*", | |||
| "maennchen/zipstream-php": "^2.1 || ^3.0", | |||
| "markbaker/complex": "^3.0", | |||
| "markbaker/matrix": "^3.0", | |||
| "php": "^8.1", | |||
| "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" | |||
| }, | |||
| "require-dev": { | |||
| "dealerdirect/phpcodesniffer-composer-installer": "dev-main", | |||
| "dompdf/dompdf": "^2.0 || ^3.0", | |||
| "ext-intl": "*", | |||
| "friendsofphp/php-cs-fixer": "^3.2", | |||
| "mitoteam/jpgraph": "^10.5", | |||
| "mpdf/mpdf": "^8.1.1", | |||
| "phpcompatibility/php-compatibility": "^9.3", | |||
| "phpstan/phpstan": "^1.1 || ^2.0", | |||
| "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", | |||
| "phpstan/phpstan-phpunit": "^1.0 || ^2.0", | |||
| "phpunit/phpunit": "^10.5", | |||
| "squizlabs/php_codesniffer": "^3.7", | |||
| "tecnickcom/tcpdf": "^6.5" | |||
| }, | |||
| "suggest": { | |||
| "dompdf/dompdf": "Option for rendering PDF with PDF Writer", | |||
| "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()", | |||
| "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", | |||
| "mpdf/mpdf": "Option for rendering PDF with PDF Writer", | |||
| "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "psr-4": { | |||
| "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Maarten Balliauw", | |||
| "homepage": "https://blog.maartenballiauw.be" | |||
| }, | |||
| { | |||
| "name": "Mark Baker", | |||
| "homepage": "https://markbakeruk.net" | |||
| }, | |||
| { | |||
| "name": "Franck Lefevre", | |||
| "homepage": "https://rootslabs.net" | |||
| }, | |||
| { | |||
| "name": "Erik Tilt" | |||
| }, | |||
| { | |||
| "name": "Adrien Crivelli" | |||
| }, | |||
| { | |||
| "name": "Owen Leibman" | |||
| } | |||
| ], | |||
| "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", | |||
| "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", | |||
| "keywords": [ | |||
| "OpenXML", | |||
| "excel", | |||
| "gnumeric", | |||
| "ods", | |||
| "php", | |||
| "spreadsheet", | |||
| "xls", | |||
| "xlsx" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", | |||
| "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.8.0" | |||
| }, | |||
| "time": "2026-06-07T03:51:10+00:00" | |||
| }, | |||
| { | |||
| "name": "psr/cache", | |||
| "version": "3.0.0", | |||
| @@ -1540,6 +2132,137 @@ | |||
| }, | |||
| "time": "2024-09-11T13:17:53+00:00" | |||
| }, | |||
| { | |||
| "name": "psr/simple-cache", | |||
| "version": "3.0.0", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/php-fig/simple-cache.git", | |||
| "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", | |||
| "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "php": ">=8.0.0" | |||
| }, | |||
| "type": "library", | |||
| "extra": { | |||
| "branch-alias": { | |||
| "dev-master": "3.0.x-dev" | |||
| } | |||
| }, | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Psr\\SimpleCache\\": "src/" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "PHP-FIG", | |||
| "homepage": "https://www.php-fig.org/" | |||
| } | |||
| ], | |||
| "description": "Common interfaces for simple caching", | |||
| "keywords": [ | |||
| "cache", | |||
| "caching", | |||
| "psr", | |||
| "psr-16", | |||
| "simple-cache" | |||
| ], | |||
| "support": { | |||
| "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" | |||
| }, | |||
| "time": "2021-10-29T13:26:27+00:00" | |||
| }, | |||
| { | |||
| "name": "sabberworm/php-css-parser", | |||
| "version": "v9.3.0", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", | |||
| "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", | |||
| "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "ext-iconv": "*", | |||
| "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", | |||
| "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" | |||
| }, | |||
| "require-dev": { | |||
| "php-parallel-lint/php-parallel-lint": "1.4.0", | |||
| "phpstan/extension-installer": "1.4.3", | |||
| "phpstan/phpstan": "1.12.32 || 2.1.32", | |||
| "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8", | |||
| "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7", | |||
| "phpunit/phpunit": "8.5.52", | |||
| "rawr/phpunit-data-provider": "3.3.1", | |||
| "rector/rector": "1.2.10 || 2.2.8", | |||
| "rector/type-perfect": "1.0.0 || 2.1.0", | |||
| "squizlabs/php_codesniffer": "4.0.1", | |||
| "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1" | |||
| }, | |||
| "suggest": { | |||
| "ext-mbstring": "for parsing UTF-8 CSS" | |||
| }, | |||
| "type": "library", | |||
| "extra": { | |||
| "branch-alias": { | |||
| "dev-main": "9.4.x-dev" | |||
| } | |||
| }, | |||
| "autoload": { | |||
| "files": [ | |||
| "src/Rule/Rule.php", | |||
| "src/RuleSet/RuleContainer.php" | |||
| ], | |||
| "psr-4": { | |||
| "Sabberworm\\CSS\\": "src/" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Raphael Schweikert" | |||
| }, | |||
| { | |||
| "name": "Oliver Klee", | |||
| "email": "github@oliverklee.de" | |||
| }, | |||
| { | |||
| "name": "Jake Hotson", | |||
| "email": "jake.github@qzdesign.co.uk" | |||
| } | |||
| ], | |||
| "description": "Parser for CSS Files written in PHP", | |||
| "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", | |||
| "keywords": [ | |||
| "css", | |||
| "parser", | |||
| "stylesheet" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", | |||
| "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0" | |||
| }, | |||
| "time": "2026-03-03T17:31:43+00:00" | |||
| }, | |||
| { | |||
| "name": "symfony/asset", | |||
| "version": "v7.4.8", | |||
| @@ -6314,6 +7037,149 @@ | |||
| ], | |||
| "time": "2026-05-20T07:20:23+00:00" | |||
| }, | |||
| { | |||
| "name": "thecodingmachine/safe", | |||
| "version": "v3.4.0", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/thecodingmachine/safe.git", | |||
| "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", | |||
| "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "php": "^8.1" | |||
| }, | |||
| "require-dev": { | |||
| "php-parallel-lint/php-parallel-lint": "^1.4", | |||
| "phpstan/phpstan": "^2", | |||
| "phpunit/phpunit": "^10", | |||
| "squizlabs/php_codesniffer": "^3.2" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "files": [ | |||
| "lib/special_cases.php", | |||
| "generated/apache.php", | |||
| "generated/apcu.php", | |||
| "generated/array.php", | |||
| "generated/bzip2.php", | |||
| "generated/calendar.php", | |||
| "generated/classobj.php", | |||
| "generated/com.php", | |||
| "generated/cubrid.php", | |||
| "generated/curl.php", | |||
| "generated/datetime.php", | |||
| "generated/dir.php", | |||
| "generated/eio.php", | |||
| "generated/errorfunc.php", | |||
| "generated/exec.php", | |||
| "generated/fileinfo.php", | |||
| "generated/filesystem.php", | |||
| "generated/filter.php", | |||
| "generated/fpm.php", | |||
| "generated/ftp.php", | |||
| "generated/funchand.php", | |||
| "generated/gettext.php", | |||
| "generated/gmp.php", | |||
| "generated/gnupg.php", | |||
| "generated/hash.php", | |||
| "generated/ibase.php", | |||
| "generated/ibmDb2.php", | |||
| "generated/iconv.php", | |||
| "generated/image.php", | |||
| "generated/imap.php", | |||
| "generated/info.php", | |||
| "generated/inotify.php", | |||
| "generated/json.php", | |||
| "generated/ldap.php", | |||
| "generated/libxml.php", | |||
| "generated/lzf.php", | |||
| "generated/mailparse.php", | |||
| "generated/mbstring.php", | |||
| "generated/misc.php", | |||
| "generated/mysql.php", | |||
| "generated/mysqli.php", | |||
| "generated/network.php", | |||
| "generated/oci8.php", | |||
| "generated/opcache.php", | |||
| "generated/openssl.php", | |||
| "generated/outcontrol.php", | |||
| "generated/pcntl.php", | |||
| "generated/pcre.php", | |||
| "generated/pgsql.php", | |||
| "generated/posix.php", | |||
| "generated/ps.php", | |||
| "generated/pspell.php", | |||
| "generated/readline.php", | |||
| "generated/rnp.php", | |||
| "generated/rpminfo.php", | |||
| "generated/rrd.php", | |||
| "generated/sem.php", | |||
| "generated/session.php", | |||
| "generated/shmop.php", | |||
| "generated/sockets.php", | |||
| "generated/sodium.php", | |||
| "generated/solr.php", | |||
| "generated/spl.php", | |||
| "generated/sqlsrv.php", | |||
| "generated/ssdeep.php", | |||
| "generated/ssh2.php", | |||
| "generated/stream.php", | |||
| "generated/strings.php", | |||
| "generated/swoole.php", | |||
| "generated/uodbc.php", | |||
| "generated/uopz.php", | |||
| "generated/url.php", | |||
| "generated/var.php", | |||
| "generated/xdiff.php", | |||
| "generated/xml.php", | |||
| "generated/xmlrpc.php", | |||
| "generated/yaml.php", | |||
| "generated/yaz.php", | |||
| "generated/zip.php", | |||
| "generated/zlib.php" | |||
| ], | |||
| "classmap": [ | |||
| "lib/DateTime.php", | |||
| "lib/DateTimeImmutable.php", | |||
| "lib/Exceptions/", | |||
| "generated/Exceptions/" | |||
| ] | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "description": "PHP core functions that throw exceptions instead of returning FALSE on error", | |||
| "support": { | |||
| "issues": "https://github.com/thecodingmachine/safe/issues", | |||
| "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" | |||
| }, | |||
| "funding": [ | |||
| { | |||
| "url": "https://github.com/OskarStark", | |||
| "type": "github" | |||
| }, | |||
| { | |||
| "url": "https://github.com/shish", | |||
| "type": "github" | |||
| }, | |||
| { | |||
| "url": "https://github.com/silasjoisten", | |||
| "type": "github" | |||
| }, | |||
| { | |||
| "url": "https://github.com/staabm", | |||
| "type": "github" | |||
| } | |||
| ], | |||
| "time": "2026-02-04T18:08:13+00:00" | |||
| }, | |||
| { | |||
| "name": "twig/twig", | |||
| "version": "v3.26.0", | |||
| @@ -10,8 +10,7 @@ doctrine: | |||
| # ersetzt dbname zur Laufzeit mit 'db_{slug}' | |||
| url: '%env(resolve:DATABASE_URL)%' | |||
| profiling_collect_backtrace: '%kernel.debug%' | |||
| # middlewares: | |||
| # - App\Doctrine\TenantConnectionMiddleware | |||
| # Middleware wird via Service-Tag registriert (services.yaml) | |||
| orm: | |||
| default_entity_manager: central | |||
| @@ -65,5 +65,9 @@ services: | |||
| arguments: | |||
| $appDomain: '%app.domain%' | |||
| App\Doctrine\TenantConnectionMiddleware: | |||
| tags: | |||
| - { name: doctrine.middleware, connection: tenant } | |||
| App\Controller\InviteController: | |||
| arguments: ~ | |||
| @@ -14,6 +14,7 @@ use Symfony\Component\HttpFoundation\Request; | |||
| use Symfony\Component\HttpFoundation\Response; | |||
| use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; | |||
| use Symfony\Component\Routing\Attribute\Route; | |||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||
| class AccountController extends AbstractController | |||
| { | |||
| @@ -24,6 +25,7 @@ class AccountController extends AbstractController | |||
| private readonly UserRepository $userRepo, | |||
| private readonly UserPasswordHasherInterface $passwordHasher, | |||
| private readonly BrandColorService $brandColorService, | |||
| private readonly TranslatorInterface $translator, | |||
| ) {} | |||
| #[Route('/account', name: 'account_index')] | |||
| @@ -56,10 +58,10 @@ class AccountController extends AbstractController | |||
| 'adminUsers' => $adminUsers, | |||
| 'superAdminUserId' => $account->getSuperAdminUser()?->getId(), | |||
| 'intervalOptions' => [ | |||
| 1 => 'Minuten', | |||
| 15 => 'Viertelstunde', | |||
| 30 => 'Halbe Stunde', | |||
| 60 => 'Stunde', | |||
| 1 => $this->translator->trans('app.account.interval_minutes'), | |||
| 15 => $this->translator->trans('app.account.interval_quarter'), | |||
| 30 => $this->translator->trans('app.account.interval_half'), | |||
| 60 => $this->translator->trans('app.account.interval_hour'), | |||
| ], | |||
| ]); | |||
| } | |||
| @@ -72,7 +74,7 @@ class AccountController extends AbstractController | |||
| $accountUser = $this->accountUserRepo->findOneBy(['account' => $account, 'user' => $user]); | |||
| if (!$accountUser?->isAdmin()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $data = json_decode($request->getContent(), true) ?? []; | |||
| @@ -91,7 +93,7 @@ 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); | |||
| return $this->json(['error' => $this->translator->trans('app.error.invalid_hex')], 422); | |||
| } | |||
| $account->setPrimaryColor($hex); | |||
| } | |||
| @@ -110,29 +112,28 @@ class AccountController extends AbstractController | |||
| $accountUser = $this->accountUserRepo->findOneBy(['account' => $account, 'user' => $currentUser]); | |||
| if (!$accountUser?->isAdmin()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| // Nur der aktuelle Superadmin darf den Besitzer übertragen | |||
| if ($account->getSuperAdminUser()?->getId() !== $currentUser->getId()) { | |||
| return $this->json(['error' => 'Nur der aktuelle Kontoinhaber kann diese Funktion nutzen.'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.account.superadmin_only')], 403); | |||
| } | |||
| $data = json_decode($request->getContent(), true) ?? []; | |||
| $userId = (int) ($data['userId'] ?? 0); | |||
| if ($userId === $currentUser->getId()) { | |||
| return $this->json(['error' => 'Du bist bereits Kontoinhaber.'], 400); | |||
| return $this->json(['error' => $this->translator->trans('app.account.already_owner')], 400); | |||
| } | |||
| $newOwner = $this->userRepo->find($userId); | |||
| if ($newOwner === null) { | |||
| return $this->json(['error' => 'Benutzer nicht gefunden.'], 404); | |||
| return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| } | |||
| $newAccountUser = $this->accountUserRepo->findOneBy(['account' => $account, 'user' => $newOwner]); | |||
| if ($newAccountUser === null || !$newAccountUser->isAdmin() || $newAccountUser->isArchived()) { | |||
| return $this->json(['error' => 'Der Benutzer muss aktiver Administrator sein.'], 400); | |||
| return $this->json(['error' => $this->translator->trans('app.account.new_owner_must_be_admin')], 400); | |||
| } | |||
| $account->setSuperAdminUser($newOwner); | |||
| @@ -160,7 +161,7 @@ class AccountController extends AbstractController | |||
| if ($newEmail !== $user->getEmail()) { | |||
| $existing = $this->userRepo->findOneBy(['email' => $newEmail]); | |||
| if ($existing !== null && $existing->getId() !== $user->getId()) { | |||
| return $this->json(['error' => 'Diese E-Mail-Adresse wird bereits verwendet.'], 409); | |||
| return $this->json(['error' => $this->translator->trans('app.error.email_taken')], 409); | |||
| } | |||
| $user->setEmail($newEmail); | |||
| } | |||
| @@ -172,13 +173,13 @@ class AccountController extends AbstractController | |||
| if (!empty($data['newPassword'])) { | |||
| if (empty($data['currentPassword'])) { | |||
| return $this->json(['error' => 'Aktuelles Passwort ist erforderlich.'], 400); | |||
| return $this->json(['error' => $this->translator->trans('app.validation.password_current_required')], 400); | |||
| } | |||
| if (!$this->passwordHasher->isPasswordValid($user, $data['currentPassword'])) { | |||
| return $this->json(['error' => 'Das aktuelle Passwort ist falsch.'], 400); | |||
| return $this->json(['error' => $this->translator->trans('app.validation.password_current_wrong')], 400); | |||
| } | |||
| if (strlen($data['newPassword']) < 8) { | |||
| return $this->json(['error' => 'Das neue Passwort muss mindestens 8 Zeichen haben.'], 400); | |||
| return $this->json(['error' => $this->translator->trans('app.validation.password_new_min_length')], 400); | |||
| } | |||
| $user->setPassword($this->passwordHasher->hashPassword($user, $data['newPassword'])); | |||
| } | |||
| @@ -12,14 +12,16 @@ use Symfony\Component\HttpFoundation\JsonResponse; | |||
| use Symfony\Component\HttpFoundation\Request; | |||
| use Symfony\Component\HttpFoundation\Response; | |||
| use Symfony\Component\Routing\Attribute\Route; | |||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||
| class ClientController extends AbstractController | |||
| { | |||
| public function __construct( | |||
| private EntityManagerInterface $em, | |||
| private ClientRepository $clientRepo, | |||
| private TimeEntryRepository $timeEntryRepo, | |||
| private readonly AccountRoleHelper $roleHelper, | |||
| private readonly EntityManagerInterface $em, | |||
| private readonly ClientRepository $clientRepo, | |||
| private readonly TimeEntryRepository $timeEntryRepo, | |||
| private readonly AccountRoleHelper $roleHelper, | |||
| private readonly TranslatorInterface $translator, | |||
| ) {} | |||
| #[Route('/clients', name: 'client_index')] | |||
| @@ -37,12 +39,12 @@ class ClientController extends AbstractController | |||
| public function create(Request $request): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $data = json_decode($request->getContent(), true); | |||
| if (empty($data['name'])) { | |||
| return $this->json(['error' => 'Name ist erforderlich'], 400); | |||
| return $this->json(['error' => $this->translator->trans('app.error.name_required')], 400); | |||
| } | |||
| $client = new Client(); | |||
| @@ -60,15 +62,15 @@ class ClientController extends AbstractController | |||
| public function update(int $id, Request $request): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $client = $this->clientRepo->find($id); | |||
| if (!$client) return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| if (!$client) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| $data = json_decode($request->getContent(), true); | |||
| if (empty($data['name'])) { | |||
| return $this->json(['error' => 'Name ist erforderlich'], 400); | |||
| return $this->json(['error' => $this->translator->trans('app.error.name_required')], 400); | |||
| } | |||
| $client->setName(trim($data['name'])); | |||
| @@ -84,10 +86,10 @@ class ClientController extends AbstractController | |||
| public function delete(int $id): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $client = $this->clientRepo->find($id); | |||
| if (!$client) return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| if (!$client) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| if ($this->timeEntryRepo->countByClient($client) > 0) { | |||
| return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409); | |||
| @@ -103,10 +105,10 @@ class ClientController extends AbstractController | |||
| public function archive(int $id): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $client = $this->clientRepo->find($id); | |||
| if (!$client) return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| if (!$client) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| $client->setArchivedAt(new \DateTimeImmutable()); | |||
| $this->em->flush(); | |||
| @@ -118,10 +120,10 @@ class ClientController extends AbstractController | |||
| public function unarchive(int $id): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $client = $this->clientRepo->find($id); | |||
| if (!$client) return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| if (!$client) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| $client->setArchivedAt(null); | |||
| $this->em->flush(); | |||
| @@ -26,41 +26,46 @@ class InviteController extends AbstractController | |||
| private readonly Security $security, | |||
| ) {} | |||
| private function renderInviteError(string $errorKey): Response | |||
| { | |||
| return $this->render('invite/error.html.twig', ['error' => $errorKey]); | |||
| } | |||
| #[Route('/invite/{token}', name: 'app_invite')] | |||
| public function setPassword(string $token, Request $request): Response | |||
| { | |||
| $invite = $this->inviteTokenRepo->findOneBy(['token' => $token]); | |||
| if ($invite === null) { | |||
| return $this->render('invite/error.html.twig', [ | |||
| 'error' => 'Dieser Einladungslink ist ungültig.', | |||
| ]); | |||
| return $this->renderInviteError('link_invalid'); | |||
| } | |||
| if ($invite->isExpired()) { | |||
| return $this->render('invite/error.html.twig', [ | |||
| 'error' => 'Dieser Einladungslink ist abgelaufen (gültig 7 Tage).', | |||
| ]); | |||
| return $this->renderInviteError('link_expired'); | |||
| } | |||
| // Account-Kontext prüfen (Sicherheit: Link muss auf richtigem Subdomain geöffnet werden) | |||
| $account = $this->tenantContext->getAccount(); | |||
| if ($account === null || $account->getId() !== $invite->getAccount()?->getId()) { | |||
| return $this->render('invite/error.html.twig', [ | |||
| 'error' => 'Dieser Einladungslink gehört zu einem anderen Account.', | |||
| ]); | |||
| return $this->renderInviteError('link_wrong_account'); | |||
| } | |||
| $error = null; | |||
| if ($request->isMethod('POST')) { | |||
| if (!$this->isCsrfTokenValid('invite_password', $request->request->get('_csrf_token'))) { | |||
| return $this->render('invite/set_password.html.twig', [ | |||
| 'invite' => $invite, | |||
| 'error' => 'csrf', | |||
| ]); | |||
| } | |||
| $password = $request->request->get('password', ''); | |||
| $passwordRepeat = $request->request->get('passwordRepeat', ''); | |||
| if (strlen($password) < 8) { | |||
| $error = 'Das Passwort muss mindestens 8 Zeichen haben.'; | |||
| $error = 'too_short'; | |||
| } elseif ($password !== $passwordRepeat) { | |||
| $error = 'Die Passwörter stimmen nicht überein.'; | |||
| $error = 'mismatch'; | |||
| } else { | |||
| // User anlegen (oder existierenden finden, falls E-Mail schon vorhanden) | |||
| $user = $this->userRepo->findOneBy(['email' => $invite->getEmail()]); | |||
| @@ -18,6 +18,7 @@ use Symfony\Component\Mime\Address; | |||
| use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; | |||
| use Symfony\Component\Routing\Attribute\Route; | |||
| use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | |||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||
| class PasswordResetController extends AbstractController | |||
| { | |||
| @@ -30,6 +31,7 @@ class PasswordResetController extends AbstractController | |||
| private readonly UserPasswordHasherInterface $passwordHasher, | |||
| private readonly Security $security, | |||
| private readonly UrlGeneratorInterface $urlGenerator, | |||
| private readonly TranslatorInterface $translator, | |||
| ) {} | |||
| #[Route('/forgot-password', name: 'app_forgot_password', methods: ['GET', 'POST'])] | |||
| @@ -45,6 +47,15 @@ class PasswordResetController extends AbstractController | |||
| $error = null; | |||
| if ($request->isMethod('POST')) { | |||
| if (!$this->isCsrfTokenValid('forgot_password', $request->request->get('_csrf_token'))) { | |||
| $error = 'invalid_csrf'; | |||
| return $this->render('security/forgot_password.html.twig', [ | |||
| 'accountName' => $account->getName(), | |||
| 'sent' => false, | |||
| 'error' => $error, | |||
| ]); | |||
| } | |||
| $email = trim($request->request->get('email', '')); | |||
| if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { | |||
| @@ -115,6 +126,16 @@ class PasswordResetController extends AbstractController | |||
| $error = null; | |||
| if ($request->isMethod('POST')) { | |||
| if (!$this->isCsrfTokenValid('reset_password', $request->request->get('_csrf_token'))) { | |||
| $error = 'invalid_csrf'; | |||
| return $this->render('security/reset_password.html.twig', [ | |||
| 'accountName' => $resetToken->getAccount()->getName(), | |||
| 'invalid' => false, | |||
| 'expired' => false, | |||
| 'error' => $error, | |||
| ]); | |||
| } | |||
| $password = $request->request->get('password', ''); | |||
| $passwordRepeat = $request->request->get('passwordRepeat', ''); | |||
| @@ -163,7 +184,7 @@ class PasswordResetController extends AbstractController | |||
| $email = (new TemplatedEmail()) | |||
| ->to(new Address($user->getEmail(), $user->getFullName())) | |||
| ->subject('Passwort zurücksetzen – ' . $token->getAccount()->getName()) | |||
| ->subject($this->translator->trans('app.email.password_reset.subject', ['%account%' => $token->getAccount()->getName()])) | |||
| ->htmlTemplate('email/password_reset.html.twig') | |||
| ->context([ | |||
| 'token' => $token, | |||
| @@ -13,15 +13,17 @@ use Symfony\Component\HttpFoundation\JsonResponse; | |||
| use Symfony\Component\HttpFoundation\Request; | |||
| use Symfony\Component\HttpFoundation\Response; | |||
| use Symfony\Component\Routing\Attribute\Route; | |||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||
| class ProjectController extends AbstractController | |||
| { | |||
| public function __construct( | |||
| private EntityManagerInterface $em, | |||
| private ProjectRepository $projectRepo, | |||
| private ClientRepository $clientRepo, | |||
| private TimeEntryRepository $timeEntryRepo, | |||
| private readonly AccountRoleHelper $roleHelper, | |||
| private readonly EntityManagerInterface $em, | |||
| private readonly ProjectRepository $projectRepo, | |||
| private readonly ClientRepository $clientRepo, | |||
| private readonly TimeEntryRepository $timeEntryRepo, | |||
| private readonly AccountRoleHelper $roleHelper, | |||
| private readonly TranslatorInterface $translator, | |||
| ) {} | |||
| #[Route('/projects', name: 'project_index')] | |||
| @@ -40,13 +42,13 @@ class ProjectController extends AbstractController | |||
| public function create(Request $request): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $data = json_decode($request->getContent(), true); | |||
| $client = $this->clientRepo->find($data['clientId'] ?? 0); | |||
| if (empty($data['name'])) return $this->json(['error' => 'Name ist erforderlich'], 400); | |||
| if (!$client) return $this->json(['error' => 'Kunde nicht gefunden'], 400); | |||
| if (empty($data['name'])) return $this->json(['error' => $this->translator->trans('app.error.name_required')], 400); | |||
| if (!$client) return $this->json(['error' => $this->translator->trans('app.error.client_not_found')], 400); | |||
| $project = new Project(); | |||
| $project->setName(trim($data['name'])); | |||
| @@ -63,16 +65,16 @@ class ProjectController extends AbstractController | |||
| public function update(int $id, Request $request): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $project = $this->projectRepo->find($id); | |||
| if (!$project) return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| if (!$project) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| $data = json_decode($request->getContent(), true); | |||
| $client = $this->clientRepo->find($data['clientId'] ?? 0); | |||
| if (empty($data['name'])) return $this->json(['error' => 'Name ist erforderlich'], 400); | |||
| if (!$client) return $this->json(['error' => 'Kunde nicht gefunden'], 400); | |||
| if (empty($data['name'])) return $this->json(['error' => $this->translator->trans('app.error.name_required')], 400); | |||
| if (!$client) return $this->json(['error' => $this->translator->trans('app.error.client_not_found')], 400); | |||
| $project->setName(trim($data['name'])); | |||
| $project->setClient($client); | |||
| @@ -87,10 +89,10 @@ class ProjectController extends AbstractController | |||
| public function delete(int $id): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $project = $this->projectRepo->find($id); | |||
| if (!$project) return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| if (!$project) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| if ($this->timeEntryRepo->countByProject($project) > 0) { | |||
| return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409); | |||
| @@ -106,10 +108,10 @@ class ProjectController extends AbstractController | |||
| public function archive(int $id): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $project = $this->projectRepo->find($id); | |||
| if (!$project) return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| if (!$project) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| $project->setArchivedAt(new \DateTimeImmutable()); | |||
| $this->em->flush(); | |||
| @@ -121,10 +123,10 @@ class ProjectController extends AbstractController | |||
| public function unarchive(int $id): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $project = $this->projectRepo->find($id); | |||
| if (!$project) return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| if (!$project) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| $project->setArchivedAt(null); | |||
| $this->em->flush(); | |||
| @@ -10,12 +10,14 @@ use Symfony\Component\HttpFoundation\JsonResponse; | |||
| use Symfony\Component\HttpFoundation\Request; | |||
| use Symfony\Component\HttpFoundation\Response; | |||
| use Symfony\Component\Routing\Attribute\Route; | |||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||
| class RegistrationController extends AbstractController | |||
| { | |||
| public function __construct( | |||
| private readonly RegistrationService $registrationService, | |||
| private readonly SlugGenerator $slugGenerator, | |||
| private readonly TranslatorInterface $translator, | |||
| private readonly string $appDomain, | |||
| private readonly LoggerInterface $logger, | |||
| ) {} | |||
| @@ -62,12 +64,12 @@ class RegistrationController extends AbstractController | |||
| $passwordRepeat = $data['passwordRepeat'] ?? ''; | |||
| $errors = []; | |||
| if ($companyName === '') { $errors[] = 'Firmenname ist erforderlich.'; } | |||
| if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Keine gültige E-Mail-Adresse.'; } | |||
| if ($firstName === '') { $errors[] = 'Vorname ist erforderlich.'; } | |||
| if ($lastName === '') { $errors[] = 'Nachname ist erforderlich.'; } | |||
| if (strlen($password) < 8) { $errors[] = 'Passwort muss mindestens 8 Zeichen lang sein.'; } | |||
| if ($password !== $passwordRepeat) { $errors[] = 'Passwörter stimmen nicht überein.'; } | |||
| if ($companyName === '') { $errors[] = $this->translator->trans('app.validation.company_name_required'); } | |||
| if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = $this->translator->trans('app.validation.email_invalid'); } | |||
| if ($firstName === '') { $errors[] = $this->translator->trans('app.validation.first_name_required'); } | |||
| if ($lastName === '') { $errors[] = $this->translator->trans('app.validation.last_name_required'); } | |||
| if (strlen($password) < 8) { $errors[] = $this->translator->trans('app.validation.password_min_length'); } | |||
| if ($password !== $passwordRepeat) { $errors[] = $this->translator->trans('app.validation.password_mismatch'); } | |||
| if (!empty($errors)) { | |||
| return $this->json(['errors' => $errors], Response::HTTP_UNPROCESSABLE_ENTITY); | |||
| @@ -82,11 +84,8 @@ class RegistrationController extends AbstractController | |||
| return $this->json(['errors' => [$e->getMessage()]], Response::HTTP_UNPROCESSABLE_ENTITY); | |||
| } catch (\Throwable $e) { | |||
| $this->logger->error('Registration failed: ' . $e->getMessage(), ['exception' => $e]); | |||
| return $this->json(['errors' => ['Ein Fehler ist aufgetreten. Bitte versuche es erneut.']], Response::HTTP_INTERNAL_SERVER_ERROR); | |||
| return $this->json(['errors' => [$this->translator->trans('app.error.generic')]], Response::HTTP_INTERNAL_SERVER_ERROR); | |||
| } | |||
| // } catch (\Throwable $e) { | |||
| // return $this->json(['errors' => ['Ein Fehler ist aufgetreten. Bitte versuche es erneut.']], Response::HTTP_INTERNAL_SERVER_ERROR); | |||
| // } | |||
| } | |||
| #[Route('/verify/{token}', name: 'app_verify')] | |||
| @@ -7,16 +7,20 @@ use App\Repository\Tenant\ClientRepository; | |||
| use App\Repository\Tenant\TimeEntryRepository; | |||
| use App\Repository\Tenant\ProjectRepository; | |||
| use App\Repository\Tenant\ServiceRepository; | |||
| use App\Service\ReportExportService; | |||
| use App\Service\TenantContext; | |||
| use App\Entity\Central\User; | |||
| use App\Service\AccountRoleHelper; | |||
| use Doctrine\ORM\EntityManagerInterface; | |||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | |||
| use Symfony\Component\HttpFoundation\BinaryFileResponse; | |||
| use Symfony\Component\HttpFoundation\JsonResponse; | |||
| use Symfony\Component\HttpFoundation\Request; | |||
| use Symfony\Component\HttpFoundation\Response; | |||
| use Symfony\Component\HttpFoundation\ResponseHeaderBag; | |||
| use Symfony\Component\Routing\Attribute\Route; | |||
| use Symfony\Bundle\SecurityBundle\Security; | |||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||
| class ReportController extends AbstractController | |||
| { | |||
| @@ -32,6 +36,8 @@ class ReportController extends AbstractController | |||
| private readonly ProjectRepository $projectRepo, | |||
| private readonly ServiceRepository $serviceRepo, | |||
| private readonly ClientRepository $clientRepo, | |||
| private readonly ReportExportService $exportService, | |||
| private readonly TranslatorInterface $translator, | |||
| ) {} | |||
| #[Route('/reports/times', name: 'report_times')] | |||
| @@ -48,15 +54,11 @@ class ReportController extends AbstractController | |||
| $isAdmin = $this->roleHelper->isAdmin(); | |||
| $isTracker = $this->roleHelper->isTracker(); | |||
| // User-Map: userId → vollständiger Name | |||
| $account = $this->tenantContext->getAccount(); | |||
| $accountUsers = $this->accountUserRepo->findBy(['account' => $account]); | |||
| $userMap = []; | |||
| foreach ($accountUsers as $au) { | |||
| $userMap[$au->getUser()->getId()] = $au->getUser()->getFullName(); | |||
| } | |||
| $userMap = $this->buildUserMap(); | |||
| // User-Liste für Filter-Dropdown (für Twig/JS) | |||
| $account = $this->tenantContext->getAccount(); | |||
| $accountUsers = $this->accountUserRepo->findBy(['account' => $account]); | |||
| $userList = []; | |||
| foreach ($accountUsers as $au) { | |||
| $userList[] = [ | |||
| @@ -66,14 +68,8 @@ class ReportController extends AbstractController | |||
| ]; | |||
| } | |||
| // Filter aus GET-Parametern lesen | |||
| $filterRaw = $request->query->all('filter'); | |||
| $filters = $this->parseFilters($filterRaw); | |||
| // Tracker: immer auf eigenen User beschränken | |||
| if ($isTracker) { | |||
| $filters['userIds'] = [$currentUserId]; | |||
| } | |||
| $filters = $this->resolveFilters($request); | |||
| // Ob der Benutzer explizit Filter gesetzt hat (für "Alle anzeigen") | |||
| $filterActive = !empty($request->query->all('filter')); | |||
| @@ -126,6 +122,94 @@ class ReportController extends AbstractController | |||
| ]); | |||
| } | |||
| // ── Excel-Export ───────────────────────────────────────────────────────── | |||
| #[Route('/reports/export/excel', name: 'report_export_excel')] | |||
| public function exportExcel(Request $request): BinaryFileResponse | |||
| { | |||
| $filters = $this->resolveFilters($request); | |||
| $entries = $this->timeEntryRepo->findAllFiltered($filters); | |||
| $accountName = $this->tenantContext->getAccount()?->getName() ?? ''; | |||
| $userMap = $this->buildUserMap(); | |||
| $tmpFile = $this->exportService->generateExcel($entries, $userMap, $accountName); | |||
| $filename = 'Zeitreport_' . $accountName . '_' . date('Y-m-d') . '.xlsx'; | |||
| $response = new BinaryFileResponse($tmpFile); | |||
| $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename); | |||
| $response->deleteFileAfterSend(true); | |||
| return $response; | |||
| } | |||
| #[Route('/reports/export/csv', name: 'report_export_csv')] | |||
| public function exportCsv(Request $request): BinaryFileResponse | |||
| { | |||
| $filters = $this->resolveFilters($request); | |||
| $entries = $this->timeEntryRepo->findAllFiltered($filters); | |||
| $accountName = $this->tenantContext->getAccount()?->getName() ?? ''; | |||
| $userMap = $this->buildUserMap(); | |||
| $tmpFile = $this->exportService->generateCsv($entries, $userMap); | |||
| $filename = 'Zeitreport_' . $accountName . '_' . date('Y-m-d') . '.csv'; | |||
| $response = new BinaryFileResponse($tmpFile); | |||
| $response->headers->set('Content-Type', 'text/csv; charset=UTF-8'); | |||
| $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename); | |||
| $response->deleteFileAfterSend(true); | |||
| return $response; | |||
| } | |||
| #[Route('/reports/export/pdf', name: 'report_export_pdf')] | |||
| public function exportPdf(Request $request): BinaryFileResponse | |||
| { | |||
| $filters = $this->resolveFilters($request); | |||
| $entries = $this->timeEntryRepo->findAllFiltered($filters); | |||
| $accountName = $this->tenantContext->getAccount()?->getName() ?? ''; | |||
| $userMap = $this->buildUserMap(); | |||
| $tmpFile = $this->exportService->generatePdf($entries, $userMap, $accountName); | |||
| $filename = 'Zeitreport_' . $accountName . '_' . date('Y-m-d') . '.pdf'; | |||
| $response = new BinaryFileResponse($tmpFile); | |||
| $response->headers->set('Content-Type', 'application/pdf'); | |||
| $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename); | |||
| $response->deleteFileAfterSend(true); | |||
| return $response; | |||
| } | |||
| // ── Shared Helpers ─────────────────────────────────────────────────────── | |||
| private function resolveFilters(Request $request): array | |||
| { | |||
| $filterRaw = $request->query->all('filter'); | |||
| $filters = $this->parseFilters($filterRaw); | |||
| if ($this->roleHelper->isTracker()) { | |||
| /** @var User $currentUser */ | |||
| $currentUser = $this->security->getUser(); | |||
| $filters['userIds'] = [$currentUser->getId()]; | |||
| } | |||
| return $filters; | |||
| } | |||
| private function buildUserMap(): array | |||
| { | |||
| $account = $this->tenantContext->getAccount(); | |||
| $accountUsers = $this->accountUserRepo->findBy(['account' => $account]); | |||
| $userMap = []; | |||
| foreach ($accountUsers as $au) { | |||
| $userMap[$au->getUser()->getId()] = $au->getUser()->getFullName(); | |||
| } | |||
| return $userMap; | |||
| } | |||
| // ── Filter-Parsing ──────────────────────────────────────────────────────── | |||
| private function parseFilters(array $f): array | |||
| @@ -250,13 +334,13 @@ class ReportController extends AbstractController | |||
| { | |||
| $entry = $this->timeEntryRepo->find($id); | |||
| if (!$entry) { | |||
| return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| } | |||
| /** @var User $currentUser */ | |||
| $currentUser = $this->security->getUser(); | |||
| if (!$this->roleHelper->isAdmin() && $entry->getUserId() !== $currentUser->getId()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $entry->setInvoiced(!$entry->isInvoiced()); | |||
| @@ -12,14 +12,16 @@ use Symfony\Component\HttpFoundation\JsonResponse; | |||
| use Symfony\Component\HttpFoundation\Request; | |||
| use Symfony\Component\HttpFoundation\Response; | |||
| use Symfony\Component\Routing\Attribute\Route; | |||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||
| class ServiceController extends AbstractController | |||
| { | |||
| public function __construct( | |||
| private EntityManagerInterface $em, | |||
| private ServiceRepository $serviceRepo, | |||
| private TimeEntryRepository $timeEntryRepo, | |||
| private readonly AccountRoleHelper $roleHelper, | |||
| private readonly EntityManagerInterface $em, | |||
| private readonly ServiceRepository $serviceRepo, | |||
| private readonly TimeEntryRepository $timeEntryRepo, | |||
| private readonly AccountRoleHelper $roleHelper, | |||
| private readonly TranslatorInterface $translator, | |||
| ) {} | |||
| #[Route('/services', name: 'service_index')] | |||
| @@ -37,11 +39,11 @@ class ServiceController extends AbstractController | |||
| public function create(Request $request): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $data = json_decode($request->getContent(), true); | |||
| if (empty($data['name'])) return $this->json(['error' => 'Name ist erforderlich'], 400); | |||
| if (empty($data['name'])) return $this->json(['error' => $this->translator->trans('app.error.name_required')], 400); | |||
| $service = new Service(); | |||
| $service->setName(trim($data['name'])); | |||
| @@ -58,14 +60,14 @@ class ServiceController extends AbstractController | |||
| public function update(int $id, Request $request): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $service = $this->serviceRepo->find($id); | |||
| if (!$service) return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| if (!$service) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| $data = json_decode($request->getContent(), true); | |||
| if (empty($data['name'])) return $this->json(['error' => 'Name ist erforderlich'], 400); | |||
| if (empty($data['name'])) return $this->json(['error' => $this->translator->trans('app.error.name_required')], 400); | |||
| $service->setName(trim($data['name'])); | |||
| $service->setBillable((bool) ($data['billable'] ?? true)); | |||
| @@ -80,10 +82,10 @@ class ServiceController extends AbstractController | |||
| public function delete(int $id): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $service = $this->serviceRepo->find($id); | |||
| if (!$service) return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| if (!$service) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| if ($this->timeEntryRepo->countByService($service) > 0) { | |||
| return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409); | |||
| @@ -99,10 +101,10 @@ class ServiceController extends AbstractController | |||
| public function archive(int $id): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $service = $this->serviceRepo->find($id); | |||
| if (!$service) return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| if (!$service) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| $service->setArchivedAt(new \DateTimeImmutable()); | |||
| $this->em->flush(); | |||
| @@ -114,10 +116,10 @@ class ServiceController extends AbstractController | |||
| public function unarchive(int $id): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $service = $this->serviceRepo->find($id); | |||
| if (!$service) return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| if (!$service) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| $service->setArchivedAt(null); | |||
| $this->em->flush(); | |||
| @@ -21,6 +21,7 @@ use Symfony\Component\Mailer\MailerInterface; | |||
| use Symfony\Component\Mime\Address; | |||
| use Symfony\Component\Routing\Attribute\Route; | |||
| use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | |||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||
| class TeamController extends AbstractController | |||
| { | |||
| @@ -34,6 +35,7 @@ class TeamController extends AbstractController | |||
| private readonly AccountRoleHelper $roleHelper, | |||
| private readonly MailerInterface $mailer, | |||
| private readonly UrlGeneratorInterface $urlGenerator, | |||
| private readonly TranslatorInterface $translator, | |||
| private readonly string $appDomain, | |||
| ) {} | |||
| @@ -63,7 +65,7 @@ class TeamController extends AbstractController | |||
| public function invite(Request $request): JsonResponse | |||
| { | |||
| if (!$this->roleHelper->isAdmin()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $data = json_decode($request->getContent(), true) ?? []; | |||
| @@ -73,12 +75,12 @@ class TeamController extends AbstractController | |||
| $role = $data['role'] ?? AccountUser::ROLE_MEMBER; | |||
| $errors = []; | |||
| if ($email === '') { $errors[] = 'E-Mail ist erforderlich.'; } | |||
| if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Keine gültige E-Mail-Adresse.'; } | |||
| if ($firstName === '') { $errors[] = 'Vorname ist erforderlich.'; } | |||
| if ($lastName === '') { $errors[] = 'Nachname ist erforderlich.'; } | |||
| if ($email === '') { $errors[] = $this->translator->trans('app.validation.email_required'); } | |||
| if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = $this->translator->trans('app.validation.email_invalid'); } | |||
| if ($firstName === '') { $errors[] = $this->translator->trans('app.validation.first_name_required'); } | |||
| if ($lastName === '') { $errors[] = $this->translator->trans('app.validation.last_name_required'); } | |||
| if (!in_array($role, [AccountUser::ROLE_ADMIN, AccountUser::ROLE_MEMBER, AccountUser::ROLE_TRACKER], true)) { | |||
| $errors[] = 'Ungültige Rolle.'; | |||
| $errors[] = $this->translator->trans('app.error.invalid_role'); | |||
| } | |||
| if (!empty($errors)) { | |||
| @@ -94,7 +96,7 @@ class TeamController extends AbstractController | |||
| 'user' => $existingUser, | |||
| ]); | |||
| if ($alreadyMember !== null) { | |||
| return $this->json(['errors' => ['Diese Person ist bereits Mitglied dieses Accounts.']], 409); | |||
| return $this->json(['errors' => [$this->translator->trans('app.team.already_member')]], 409); | |||
| } | |||
| } | |||
| @@ -124,22 +126,22 @@ class TeamController extends AbstractController | |||
| public function archive(int $id): JsonResponse | |||
| { | |||
| if (!$this->roleHelper->isAdmin()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $account = $this->tenantContext->getAccount(); | |||
| $accountUser = $this->accountUserRepo->find($id); | |||
| if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) { | |||
| return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| } | |||
| if ($accountUser->getUser() === $this->getUser()) { | |||
| return $this->json(['error' => 'Du kannst dich nicht selbst archivieren.'], 400); | |||
| return $this->json(['error' => $this->translator->trans('app.team.cannot_archive_self')], 400); | |||
| } | |||
| if ($account->getSuperAdminUser()?->getId() === $accountUser->getUser()->getId()) { | |||
| return $this->json(['error' => 'Der Kontoinhaber kann nicht archiviert werden.'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.team.cannot_archive_owner')], 403); | |||
| } | |||
| $accountUser->setArchivedAt(new \DateTimeImmutable()); | |||
| @@ -152,14 +154,14 @@ class TeamController extends AbstractController | |||
| public function unarchive(int $id): JsonResponse | |||
| { | |||
| if (!$this->roleHelper->isAdmin()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $account = $this->tenantContext->getAccount(); | |||
| $accountUser = $this->accountUserRepo->find($id); | |||
| if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) { | |||
| return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| } | |||
| $accountUser->setArchivedAt(null); | |||
| @@ -172,30 +174,30 @@ class TeamController extends AbstractController | |||
| public function edit(int $id, Request $request): JsonResponse | |||
| { | |||
| if (!$this->roleHelper->isAdmin()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $account = $this->tenantContext->getAccount(); | |||
| $accountUser = $this->accountUserRepo->find($id); | |||
| if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) { | |||
| return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| } | |||
| $data = json_decode($request->getContent(), true) ?? []; | |||
| $firstName = trim($data['firstName'] ?? ''); | |||
| $lastName = trim($data['lastName'] ?? ''); | |||
| $email = trim($data['email'] ?? ''); | |||
| $note = $data['note'] !== '' ? ($data['note'] ?? null) : null; | |||
| $note = !empty($data['note']) ? $data['note'] : null; | |||
| $role = $data['role'] ?? null; | |||
| $errors = []; | |||
| if ($firstName === '') { $errors[] = 'Vorname ist erforderlich.'; } | |||
| if ($lastName === '') { $errors[] = 'Nachname ist erforderlich.'; } | |||
| if ($email === '') { $errors[] = 'E-Mail ist erforderlich.'; } | |||
| if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Keine gültige E-Mail-Adresse.'; } | |||
| if ($firstName === '') { $errors[] = $this->translator->trans('app.validation.first_name_required'); } | |||
| if ($lastName === '') { $errors[] = $this->translator->trans('app.validation.last_name_required'); } | |||
| if ($email === '') { $errors[] = $this->translator->trans('app.validation.email_required'); } | |||
| if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = $this->translator->trans('app.validation.email_invalid'); } | |||
| if ($role !== null && !in_array($role, [AccountUser::ROLE_ADMIN, AccountUser::ROLE_MEMBER, AccountUser::ROLE_TRACKER], true)) { | |||
| $errors[] = 'Ungültige Rolle.'; | |||
| $errors[] = $this->translator->trans('app.error.invalid_role'); | |||
| } | |||
| if (!empty($errors)) { | |||
| @@ -208,14 +210,13 @@ class TeamController extends AbstractController | |||
| if ($email !== $user->getEmail()) { | |||
| $existing = $this->userRepo->findOneBy(['email' => $email]); | |||
| if ($existing !== null) { | |||
| return $this->json(['errors' => ['Diese E-Mail-Adresse wird bereits verwendet.']], 409); | |||
| return $this->json(['errors' => [$this->translator->trans('app.error.email_taken')]], 409); | |||
| } | |||
| } | |||
| // Eigene Rolle: Admin darf sich nicht selbst degradieren | |||
| $isSelf = ($user === $this->getUser()); | |||
| if ($isSelf && $accountUser->isAdmin() && $role !== null && $role !== AccountUser::ROLE_ADMIN) { | |||
| return $this->json(['errors' => ['Du kannst deine eigene Administratoren-Rolle nicht ändern.']], 400); | |||
| return $this->json(['errors' => [$this->translator->trans('app.team.cannot_change_own_role')]], 400); | |||
| } | |||
| $user->setFirstName($firstName); | |||
| @@ -236,22 +237,22 @@ class TeamController extends AbstractController | |||
| public function delete(int $id): JsonResponse | |||
| { | |||
| if (!$this->roleHelper->isAdmin()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $account = $this->tenantContext->getAccount(); | |||
| $accountUser = $this->accountUserRepo->find($id); | |||
| if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) { | |||
| return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| } | |||
| if ($accountUser->getUser() === $this->getUser()) { | |||
| return $this->json(['error' => 'Du kannst dich nicht selbst entfernen.'], 400); | |||
| return $this->json(['error' => $this->translator->trans('app.team.cannot_remove_self')], 400); | |||
| } | |||
| if ($account->getSuperAdminUser()?->getId() === $accountUser->getUser()->getId()) { | |||
| return $this->json(['error' => 'Der Kontoinhaber kann nicht entfernt werden.'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.team.cannot_remove_owner')], 403); | |||
| } | |||
| $userId = $accountUser->getUser()->getId(); | |||
| @@ -269,14 +270,14 @@ class TeamController extends AbstractController | |||
| public function deleteInvite(int $id): JsonResponse | |||
| { | |||
| if (!$this->roleHelper->isAdmin()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $account = $this->tenantContext->getAccount(); | |||
| $invite = $this->inviteTokenRepo->find($id); | |||
| if ($invite === null || $invite->getAccount()?->getId() !== $account?->getId()) { | |||
| return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| } | |||
| $this->em->remove($invite); | |||
| @@ -295,7 +296,7 @@ class TeamController extends AbstractController | |||
| 'email' => $au->getUser()->getEmail(), | |||
| 'note' => $au->getUser()->getNote(), | |||
| 'role' => $au->getRole(), | |||
| 'roleLabel' => $au->getRoleLabel(), | |||
| 'roleLabel' => $this->translator->trans('app.role.' . $au->getRole()), | |||
| ]; | |||
| } | |||
| @@ -309,7 +310,7 @@ class TeamController extends AbstractController | |||
| $email = (new TemplatedEmail()) | |||
| ->to(new Address($invite->getEmail(), $invite->getFirstName() . ' ' . $invite->getLastName())) | |||
| ->subject('Einladung zu ' . $invite->getAccount()->getName()) | |||
| ->subject($this->translator->trans('app.email.invite.subject', ['%company%' => $invite->getAccount()->getName()])) | |||
| ->htmlTemplate('email/team_invite.html.twig') | |||
| ->context([ | |||
| 'invite' => $invite, | |||
| @@ -16,6 +16,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; | |||
| use Symfony\Component\HttpFoundation\Request; | |||
| use Symfony\Component\HttpFoundation\Response; | |||
| use Symfony\Component\Routing\Attribute\Route; | |||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||
| class TimeTrackingController extends AbstractController | |||
| { | |||
| @@ -27,6 +28,7 @@ class TimeTrackingController extends AbstractController | |||
| private readonly TenantContext $tenantContext, | |||
| private readonly AccountRoleHelper $roleHelper, | |||
| private readonly Security $security, | |||
| private readonly TranslatorInterface $translator, | |||
| ) {} | |||
| // ── Hauptseite ──────────────────────────────────────────────────────────── | |||
| @@ -106,7 +108,7 @@ class TimeTrackingController extends AbstractController | |||
| $project = $this->projectRepo->find($data['projectId'] ?? 0); | |||
| if (!$project) { | |||
| return $this->json(['error' => 'Projekt nicht gefunden'], 400); | |||
| return $this->json(['error' => $this->translator->trans('app.error.project_not_found')], 400); | |||
| } | |||
| $tz = new \DateTimeZone('Europe/Berlin'); | |||
| @@ -120,7 +122,7 @@ class TimeTrackingController extends AbstractController | |||
| $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); | |||
| return $this->json(['error' => $this->translator->trans('app.error.daily_limit')], 422); | |||
| } | |||
| $entry = new TimeEntry(); | |||
| @@ -149,20 +151,20 @@ class TimeTrackingController extends AbstractController | |||
| { | |||
| $entry = $this->timeEntryRepo->find($id); | |||
| if (!$entry) { | |||
| return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| } | |||
| /** @var User $currentUser */ | |||
| $currentUser = $this->security->getUser(); | |||
| if (!$this->roleHelper->isAdmin() && $entry->getUserId() !== $currentUser->getId()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $data = json_decode($request->getContent(), true); | |||
| $project = $this->projectRepo->find($data['projectId'] ?? 0); | |||
| if (!$project) { | |||
| return $this->json(['error' => 'Projekt nicht gefunden'], 400); | |||
| return $this->json(['error' => $this->translator->trans('app.error.project_not_found')], 400); | |||
| } | |||
| $service = null; | |||
| @@ -173,7 +175,7 @@ class TimeTrackingController extends AbstractController | |||
| $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); | |||
| return $this->json(['error' => $this->translator->trans('app.error.daily_limit')], 422); | |||
| } | |||
| $entry->setProject($project); | |||
| @@ -201,13 +203,13 @@ class TimeTrackingController extends AbstractController | |||
| { | |||
| $entry = $this->timeEntryRepo->find($id); | |||
| if (!$entry) { | |||
| return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||
| } | |||
| /** @var User $currentUser */ | |||
| $currentUser = $this->security->getUser(); | |||
| if (!$this->roleHelper->isAdmin() && $entry->getUserId() !== $currentUser->getId()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $date = $entry->getDate(); | |||
| @@ -261,13 +263,15 @@ class TimeTrackingController extends AbstractController | |||
| { | |||
| $hour = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('H'); | |||
| return match(true) { | |||
| $hour >= 5 && $hour < 11 => 'Guten Morgen', | |||
| $hour >= 11 && $hour < 14 => 'Mahlzeit', | |||
| $hour >= 14 && $hour < 18 => 'Guten Tag', | |||
| $hour >= 18 && $hour < 22 => 'Guten Abend', | |||
| default => 'Gute Nacht', | |||
| $key = match(true) { | |||
| $hour >= 5 && $hour < 11 => 'app.greeting.morning', | |||
| $hour >= 11 && $hour < 14 => 'app.greeting.noon', | |||
| $hour >= 14 && $hour < 18 => 'app.greeting.afternoon', | |||
| $hour >= 18 && $hour < 22 => 'app.greeting.evening', | |||
| default => 'app.greeting.night', | |||
| }; | |||
| return $this->translator->trans($key); | |||
| } | |||
| private function parseDuration(string $input): int | |||
| @@ -53,13 +53,8 @@ class AccountUser | |||
| public function isTracker(): bool { return $this->role === self::ROLE_TRACKER; } | |||
| public function isMemberOrAdmin(): bool { return $this->isAdmin() || $this->isMember(); } | |||
| public function getRoleLabel(): string | |||
| public function getRoleLabelKey(): string | |||
| { | |||
| return match ($this->role) { | |||
| self::ROLE_ADMIN => 'Administrator', | |||
| self::ROLE_MEMBER => 'Standard', | |||
| self::ROLE_TRACKER => 'Zeiterfasser', | |||
| default => $this->role, | |||
| }; | |||
| return 'app.role.' . $this->role; | |||
| } | |||
| } | |||
| @@ -11,6 +11,7 @@ use Symfony\Component\HttpKernel\Event\RequestEvent; | |||
| use Symfony\Component\HttpKernel\KernelEvents; | |||
| use Symfony\Component\Routing\RouterInterface; | |||
| use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; | |||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||
| class ArchivedUserSubscriber implements EventSubscriberInterface | |||
| { | |||
| @@ -19,9 +20,8 @@ class ArchivedUserSubscriber implements EventSubscriberInterface | |||
| private readonly AccountUserRepository $accountUserRepo, | |||
| private readonly TenantContext $tenantContext, | |||
| private readonly RouterInterface $router, | |||
| ) | |||
| { | |||
| } | |||
| private readonly TranslatorInterface $translator, | |||
| ) {} | |||
| public function onKernelRequest(RequestEvent $event): void | |||
| { | |||
| @@ -59,7 +59,7 @@ class ArchivedUserSubscriber implements EventSubscriberInterface | |||
| // API: 401, sonst Redirect zu Login | |||
| if (str_starts_with($request->getPathInfo(), '/api/')) { | |||
| $event->setResponse(new JsonResponse(['error' => 'Konto deaktiviert.'], 401)); | |||
| $event->setResponse(new JsonResponse(['error' => $this->translator->trans('app.account.deactivated_api')], 401)); | |||
| } else { | |||
| $event->setResponse(new RedirectResponse($this->router->generate('app_login'))); | |||
| } | |||
| @@ -46,104 +46,6 @@ class TimeEntryRepository extends ServiceEntityRepository | |||
| return (int) $result; | |||
| } | |||
| // ── Report ──────────────────────────────────────────────────────────────── | |||
| public function findForReport(int $limit = 50): array | |||
| { | |||
| return $this->createQueryBuilder('t') | |||
| ->join('t.project', 'p') | |||
| ->join('p.client', 'c') | |||
| ->leftJoin('t.service', 's') | |||
| ->addSelect('p', 'c', 's') | |||
| ->orderBy('t.date', 'DESC') | |||
| ->addOrderBy('t.createdAt', 'DESC') | |||
| ->setMaxResults($limit) | |||
| ->getQuery() | |||
| ->getResult(); | |||
| } | |||
| public function countAll(): int | |||
| { | |||
| return (int) $this->createQueryBuilder('t') | |||
| ->select('COUNT(t.id)') | |||
| ->getQuery() | |||
| ->getSingleScalarResult(); | |||
| } | |||
| public function sumDurationAll(): int | |||
| { | |||
| $result = $this->createQueryBuilder('t') | |||
| ->select('SUM(t.duration)') | |||
| ->getQuery() | |||
| ->getSingleScalarResult(); | |||
| return (int) $result; | |||
| } | |||
| public function sumRevenueAll(): float | |||
| { | |||
| $result = $this->createQueryBuilder('t') | |||
| ->select('SUM(c.hourlyRate * t.duration / 60)') | |||
| ->join('t.project', 'p') | |||
| ->join('p.client', 'c') | |||
| ->leftJoin('t.service', 's') | |||
| ->where('c.hourlyRate IS NOT NULL') | |||
| ->andWhere('(s IS NULL OR s.billable = :billable)') | |||
| ->setParameter('billable', true) | |||
| ->getQuery() | |||
| ->getSingleScalarResult(); | |||
| return (float) ($result ?? 0.0); | |||
| } | |||
| // ── Report: nach User gefiltert (für Tracker) ───────────────────────────── | |||
| public function findForReportByUserId(int $userId, int $limit = 50): array | |||
| { | |||
| return $this->createQueryBuilder('t') | |||
| ->join('t.project', 'p') | |||
| ->join('p.client', 'c') | |||
| ->leftJoin('t.service', 's') | |||
| ->addSelect('p', 'c', 's') | |||
| ->where('t.userId = :userId') | |||
| ->setParameter('userId', $userId) | |||
| ->orderBy('t.date', 'DESC') | |||
| ->addOrderBy('t.createdAt', 'DESC') | |||
| ->setMaxResults($limit) | |||
| ->getQuery() | |||
| ->getResult(); | |||
| } | |||
| public function sumDurationByUserId(int $userId): int | |||
| { | |||
| $result = $this->createQueryBuilder('t') | |||
| ->select('SUM(t.duration)') | |||
| ->where('t.userId = :userId') | |||
| ->setParameter('userId', $userId) | |||
| ->getQuery() | |||
| ->getSingleScalarResult(); | |||
| return (int) $result; | |||
| } | |||
| public function sumRevenueByUserId(int $userId): float | |||
| { | |||
| $result = $this->createQueryBuilder('t') | |||
| ->select('SUM(c.hourlyRate * t.duration / 60)') | |||
| ->join('t.project', 'p') | |||
| ->join('p.client', 'c') | |||
| ->leftJoin('t.service', 's') | |||
| ->where('t.userId = :userId') | |||
| ->andWhere('c.hourlyRate IS NOT NULL') | |||
| ->andWhere('(s IS NULL OR s.billable = :billable)') | |||
| ->setParameter('userId', $userId) | |||
| ->setParameter('billable', true) | |||
| ->getQuery() | |||
| ->getSingleScalarResult(); | |||
| return (float) ($result ?? 0.0); | |||
| } | |||
| // ── Zähler für abhängige Entitäten ──────────────────────────────────────── | |||
| public function countByProject(Project $project): int | |||
| @@ -263,6 +165,16 @@ class TimeEntryRepository extends ServiceEntityRepository | |||
| ->getResult(); | |||
| } | |||
| public function findAllFiltered(array $filters): array | |||
| { | |||
| return $this->buildFilteredQuery($filters) | |||
| ->addSelect('p', 'c', 's') | |||
| ->orderBy('t.date', 'DESC') | |||
| ->addOrderBy('t.createdAt', 'DESC') | |||
| ->getQuery() | |||
| ->getResult(); | |||
| } | |||
| public function countFiltered(array $filters): int | |||
| { | |||
| return (int) $this->buildFilteredQuery($filters) | |||
| @@ -7,15 +7,15 @@ use App\Service\TenantContext; | |||
| use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException; | |||
| use Symfony\Component\Security\Core\User\UserCheckerInterface; | |||
| use Symfony\Component\Security\Core\User\UserInterface; | |||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||
| class ArchivedUserChecker implements UserCheckerInterface | |||
| { | |||
| public function __construct( | |||
| private readonly AccountUserRepository $accountUserRepo, | |||
| private readonly TenantContext $tenantContext, | |||
| ) | |||
| { | |||
| } | |||
| private readonly TranslatorInterface $translator, | |||
| ) {} | |||
| public function checkPreAuth(UserInterface $user): void | |||
| { | |||
| @@ -34,7 +34,7 @@ class ArchivedUserChecker implements UserCheckerInterface | |||
| ]); | |||
| if ($accountUser !== null && $accountUser->isArchived()) { | |||
| throw new CustomUserMessageAccountStatusException('Dein Konto wurde deaktiviert.'); | |||
| throw new CustomUserMessageAccountStatusException($this->translator->trans('app.account.deactivated')); | |||
| } | |||
| } | |||
| } | |||
| @@ -12,6 +12,9 @@ use Symfony\Bundle\SecurityBundle\Security; | |||
| */ | |||
| class AccountRoleHelper | |||
| { | |||
| private ?AccountUser $cached = null; | |||
| private bool $resolved = false; | |||
| public function __construct( | |||
| private readonly Security $security, | |||
| private readonly TenantContext $tenantContext, | |||
| @@ -20,6 +23,10 @@ class AccountRoleHelper | |||
| public function getCurrentAccountUser(): ?AccountUser | |||
| { | |||
| if ($this->resolved) { | |||
| return $this->cached; | |||
| } | |||
| $user = $this->security->getUser(); | |||
| $account = $this->tenantContext->getAccount(); | |||
| @@ -27,10 +34,13 @@ class AccountRoleHelper | |||
| return null; | |||
| } | |||
| return $this->accountUserRepo->findOneBy([ | |||
| $this->cached = $this->accountUserRepo->findOneBy([ | |||
| 'account' => $account, | |||
| 'user' => $user, | |||
| ]); | |||
| $this->resolved = true; | |||
| return $this->cached; | |||
| } | |||
| public function isAdmin(): bool { return $this->getCurrentAccountUser()?->isAdmin() ?? false; } | |||
| @@ -13,6 +13,7 @@ use Symfony\Component\Mailer\MailerInterface; | |||
| use Symfony\Component\Mime\Address; | |||
| use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; | |||
| use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | |||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||
| class RegistrationService | |||
| { | |||
| @@ -24,6 +25,7 @@ class RegistrationService | |||
| private readonly UserPasswordHasherInterface $passwordHasher, | |||
| private readonly UrlGeneratorInterface $urlGenerator, | |||
| private readonly SlugGenerator $slugGenerator, | |||
| private readonly TranslatorInterface $translator, | |||
| private readonly string $appDomain, | |||
| private readonly string $notifyEmail, | |||
| ) {} | |||
| @@ -41,7 +43,7 @@ class RegistrationService | |||
| // E-Mail bereits vergeben? | |||
| $existingUser = $this->centralEm->getRepository(User::class)->findOneBy(['email' => $email]); | |||
| if ($existingUser !== null) { | |||
| throw new \DomainException('Diese E-Mail-Adresse wird bereits verwendet.'); | |||
| throw new \DomainException($this->translator->trans('app.registration.email_taken')); | |||
| } | |||
| // Pending Token für dieselbe E-Mail? (doppeltes Absenden verhindern) | |||
| @@ -83,13 +85,13 @@ class RegistrationService | |||
| $token = $this->centralEm->getRepository(RegistrationToken::class)->findOneBy(['token' => $tokenString]); | |||
| if ($token === null) { | |||
| throw new \InvalidArgumentException('Ungültiger Bestätigungslink.'); | |||
| throw new \InvalidArgumentException($this->translator->trans('app.registration.confirm_invalid')); | |||
| } | |||
| if ($token->isExpired()) { | |||
| $this->centralEm->remove($token); | |||
| $this->centralEm->flush(); | |||
| throw new \InvalidArgumentException('Dieser Link ist abgelaufen (gültig 24 Stunden). Bitte registriere dich erneut.'); | |||
| throw new \InvalidArgumentException($this->translator->trans('app.registration.confirm_expired')); | |||
| } | |||
| // Account anlegen | |||
| @@ -150,7 +152,7 @@ class RegistrationService | |||
| $email = (new TemplatedEmail()) | |||
| ->to(new Address($token->getEmail(), $token->getFirstName() . ' ' . $token->getLastName())) | |||
| ->subject('Bitte bestätige deine Registrierung – spawntree Timetracker') | |||
| ->subject($this->translator->trans('app.email.confirm.subject')) | |||
| ->htmlTemplate('email/registration_confirm.html.twig') | |||
| ->context([ | |||
| 'token' => $token, | |||
| @@ -166,7 +168,7 @@ class RegistrationService | |||
| $email = (new TemplatedEmail()) | |||
| ->to(new Address($user->getEmail(), $user->getFullName())) | |||
| ->subject('Willkommen beim spawntree Timetracker!') | |||
| ->subject($this->translator->trans('app.email.welcome.subject')) | |||
| ->htmlTemplate('email/registration_welcome.html.twig') | |||
| ->context([ | |||
| 'user' => $user, | |||
| @@ -181,7 +183,7 @@ class RegistrationService | |||
| { | |||
| $email = (new TemplatedEmail()) | |||
| ->to($this->notifyEmail) | |||
| ->subject('[Timetracker] Neue Registrierung: ' . $account->getName()) | |||
| ->subject($this->translator->trans('app.email.notify.subject', ['%name%' => $account->getName()])) | |||
| ->htmlTemplate('email/registration_notify.html.twig') | |||
| ->context([ | |||
| 'user' => $user, | |||
| @@ -0,0 +1,395 @@ | |||
| <?php | |||
| namespace App\Service; | |||
| use App\Entity\Tenant\TimeEntry; | |||
| use Dompdf\Dompdf; | |||
| use Dompdf\Options; | |||
| use PhpOffice\PhpSpreadsheet\Shared\Date; | |||
| use PhpOffice\PhpSpreadsheet\Spreadsheet; | |||
| use PhpOffice\PhpSpreadsheet\Style\Alignment; | |||
| use PhpOffice\PhpSpreadsheet\Style\Border; | |||
| use PhpOffice\PhpSpreadsheet\Style\Fill; | |||
| use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; | |||
| use PhpOffice\PhpSpreadsheet\Writer\Xlsx; | |||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||
| class ReportExportService | |||
| { | |||
| private const HEADER_KEYS = [ | |||
| 'app.report.export_col_date', | |||
| 'app.report.export_col_client', | |||
| 'app.report.export_col_project', | |||
| 'app.report.export_col_service', | |||
| 'app.report.export_col_user', | |||
| 'app.report.export_col_note', | |||
| 'app.report.export_col_hours', | |||
| 'app.report.export_col_revenue', | |||
| 'app.report.export_col_invoiced', | |||
| ]; | |||
| private const EXCEL_WIDTHS = [14, 22, 22, 20, 20, 36, 12, 14, 14]; | |||
| private const COLOR_HEADER_BG = 'E8EFF7'; | |||
| private const COLOR_HEADER_TEXT = '2D3748'; | |||
| private const COLOR_BORDER = 'D1D9E6'; | |||
| private const COLOR_STRIPE = 'F7F9FC'; | |||
| public function __construct( | |||
| private readonly TranslatorInterface $translator, | |||
| ) {} | |||
| private function t(string $id, array $params = []): string | |||
| { | |||
| return $this->translator->trans($id, $params); | |||
| } | |||
| private function headers(): array | |||
| { | |||
| return array_map(fn(string $key) => $this->t($key), self::HEADER_KEYS); | |||
| } | |||
| // ── Data Preparation ───────────────────────────────────────────────────── | |||
| /** | |||
| * @param TimeEntry[] $entries | |||
| * @param array<int, string> $userMap | |||
| * @return array{rows: list<array>, totalHours: float, totalRevenue: float} | |||
| */ | |||
| private function prepareData(array $entries, array $userMap): array | |||
| { | |||
| $rows = []; | |||
| $totalMinutes = 0; | |||
| $totalRevenue = 0.0; | |||
| foreach ($entries as $entry) { | |||
| $service = $entry->getService(); | |||
| $client = $entry->getProject()?->getClient(); | |||
| $billable = $service === null || $service->isBillable(); | |||
| $rate = $client?->getHourlyRate(); | |||
| $hours = $entry->getDuration() / 60; | |||
| $revenue = ($billable && $rate !== null) ? $rate * $hours : null; | |||
| $totalMinutes += $entry->getDuration(); | |||
| if ($revenue !== null) { | |||
| $totalRevenue += $revenue; | |||
| } | |||
| $rows[] = [ | |||
| 'date' => $entry->getDate(), | |||
| 'client' => $client?->getName() ?? '', | |||
| 'project' => $entry->getProject()?->getName() ?? '', | |||
| 'service' => $service?->getName() ?? '', | |||
| 'user' => $userMap[$entry->getUserId()] ?? $this->t('app.report.user_fallback', ['%id%' => $entry->getUserId()]), | |||
| 'note' => $entry->getNote() ?? '', | |||
| 'hours' => $hours, | |||
| 'revenue' => $revenue, | |||
| 'invoiced' => $entry->isInvoiced(), | |||
| ]; | |||
| } | |||
| return [ | |||
| 'rows' => $rows, | |||
| 'totalHours' => $totalMinutes / 60, | |||
| 'totalRevenue' => $totalRevenue, | |||
| ]; | |||
| } | |||
| // ── Excel ──────────────────────────────────────────────────────────────── | |||
| /** | |||
| * @param TimeEntry[] $entries | |||
| * @param array<int, string> $userMap | |||
| */ | |||
| public function generateExcel(array $entries, array $userMap, string $accountName): string | |||
| { | |||
| $data = $this->prepareData($entries, $userMap); | |||
| $spreadsheet = new Spreadsheet(); | |||
| $spreadsheet->getProperties() | |||
| ->setTitle($this->t('app.report.export_title', ['%account%' => $accountName])) | |||
| ->setCreator($accountName); | |||
| $sheet = $spreadsheet->getActiveSheet(); | |||
| $sheet->setTitle($this->t('app.report.export_col_hours')); | |||
| $this->excelWriteHeader($sheet); | |||
| $lastRow = $this->excelWriteData($sheet, $data['rows']); | |||
| $this->excelWriteSummary($sheet, $data, $lastRow + 1); | |||
| $this->excelApplyStyles($sheet, $lastRow); | |||
| $tmpFile = tempnam(sys_get_temp_dir(), 'report_') . '.xlsx'; | |||
| (new Xlsx($spreadsheet))->save($tmpFile); | |||
| $spreadsheet->disconnectWorksheets(); | |||
| return $tmpFile; | |||
| } | |||
| private function excelWriteHeader(Worksheet $sheet): void | |||
| { | |||
| $headers = $this->headers(); | |||
| $cols = range('A', 'I'); | |||
| foreach ($cols as $i => $col) { | |||
| $sheet->setCellValue($col . '1', $headers[$i]); | |||
| $sheet->getColumnDimension($col)->setWidth(self::EXCEL_WIDTHS[$i]); | |||
| } | |||
| $style = $sheet->getStyle('A1:I1'); | |||
| $style->getFont()->setBold(true)->setSize(10)->getColor()->setRGB(self::COLOR_HEADER_TEXT); | |||
| $style->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setRGB(self::COLOR_HEADER_BG); | |||
| $style->getAlignment()->setVertical(Alignment::VERTICAL_CENTER); | |||
| $style->getBorders()->getBottom()->setBorderStyle(Border::BORDER_THIN)->getColor()->setRGB(self::COLOR_BORDER); | |||
| $sheet->getRowDimension(1)->setRowHeight(28); | |||
| } | |||
| private function excelWriteData(Worksheet $sheet, array $rows): int | |||
| { | |||
| $yes = $this->t('app.report.export_yes'); | |||
| $no = $this->t('app.report.export_no'); | |||
| $row = 2; | |||
| foreach ($rows as $r) { | |||
| $sheet->setCellValue("A{$row}", Date::dateTimeToExcel($r['date'])); | |||
| $sheet->setCellValue("B{$row}", $r['client']); | |||
| $sheet->setCellValue("C{$row}", $r['project']); | |||
| $sheet->setCellValue("D{$row}", $r['service']); | |||
| $sheet->setCellValue("E{$row}", $r['user']); | |||
| $sheet->setCellValue("F{$row}", $r['note']); | |||
| $sheet->setCellValue("G{$row}", $r['hours']); | |||
| if ($r['revenue'] !== null) { | |||
| $sheet->setCellValue("H{$row}", $r['revenue']); | |||
| } | |||
| $sheet->setCellValue("I{$row}", $r['invoiced'] ? $yes : $no); | |||
| if ($row % 2 === 1) { | |||
| $sheet->getStyle("A{$row}:I{$row}") | |||
| ->getFill()->setFillType(Fill::FILL_SOLID) | |||
| ->getStartColor()->setRGB(self::COLOR_STRIPE); | |||
| } | |||
| $sheet->getRowDimension($row)->setRowHeight(22); | |||
| $row++; | |||
| } | |||
| return $row - 1; | |||
| } | |||
| private function excelWriteSummary(Worksheet $sheet, array $data, int $summaryRow): void | |||
| { | |||
| if (empty($data['rows'])) { | |||
| return; | |||
| } | |||
| $sheet->setCellValue("F{$summaryRow}", $this->t('app.report.export_sum')); | |||
| $sheet->setCellValue("G{$summaryRow}", $data['totalHours']); | |||
| $sheet->setCellValue("H{$summaryRow}", $data['totalRevenue']); | |||
| $style = $sheet->getStyle("A{$summaryRow}:I{$summaryRow}"); | |||
| $style->getFont()->setBold(true)->setSize(10); | |||
| $style->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setRGB(self::COLOR_HEADER_BG); | |||
| $style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN)->getColor()->setRGB(self::COLOR_BORDER); | |||
| $sheet->getStyle("F{$summaryRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT); | |||
| $sheet->getRowDimension($summaryRow)->setRowHeight(28); | |||
| } | |||
| private function excelApplyStyles(Worksheet $sheet, int $lastDataRow): void | |||
| { | |||
| if ($lastDataRow < 2) { | |||
| return; | |||
| } | |||
| $dataRange = "A2:I{$lastDataRow}"; | |||
| $sheet->getStyle($dataRange)->getFont()->setSize(10); | |||
| $sheet->getStyle($dataRange)->getAlignment()->setVertical(Alignment::VERTICAL_CENTER); | |||
| $sheet->getStyle($dataRange)->getBorders()->getBottom() | |||
| ->setBorderStyle(Border::BORDER_HAIR)->getColor()->setRGB(self::COLOR_BORDER); | |||
| $sheet->getStyle("A2:A{$lastDataRow}")->getNumberFormat()->setFormatCode('DD.MM.YYYY'); | |||
| $sheet->getStyle("G2:G{$lastDataRow}")->getNumberFormat()->setFormatCode('#,##0.00'); | |||
| $sheet->getStyle("G2:G{$lastDataRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT); | |||
| $summaryRow = $lastDataRow + 1; | |||
| $revenueRange = "H2:H{$summaryRow}"; | |||
| $sheet->getStyle($revenueRange)->getNumberFormat()->setFormatCode('#,##0.00\ "€"'); | |||
| $sheet->getStyle($revenueRange)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT); | |||
| $sheet->getStyle("G{$summaryRow}")->getNumberFormat()->setFormatCode('#,##0.00'); | |||
| $sheet->getStyle("G{$summaryRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT); | |||
| $sheet->getStyle("I2:I{$lastDataRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); | |||
| $sheet->setAutoFilter("A1:I{$lastDataRow}"); | |||
| $sheet->freezePane('A2'); | |||
| } | |||
| // ── CSV ────────────────────────────────────────────────────────────────── | |||
| /** | |||
| * @param TimeEntry[] $entries | |||
| * @param array<int, string> $userMap | |||
| */ | |||
| public function generateCsv(array $entries, array $userMap): string | |||
| { | |||
| $data = $this->prepareData($entries, $userMap); | |||
| $yes = $this->t('app.report.export_yes'); | |||
| $no = $this->t('app.report.export_no'); | |||
| $tmpFile = tempnam(sys_get_temp_dir(), 'report_') . '.csv'; | |||
| $handle = fopen($tmpFile, 'w'); | |||
| fwrite($handle, "\xEF\xBB\xBF"); | |||
| fputcsv($handle, $this->headers(), ';'); | |||
| foreach ($data['rows'] as $r) { | |||
| fputcsv($handle, [ | |||
| $r['date']->format('d.m.Y'), | |||
| $r['client'], | |||
| $r['project'], | |||
| $r['service'], | |||
| $r['user'], | |||
| $r['note'], | |||
| number_format($r['hours'], 2, ',', ''), | |||
| $r['revenue'] !== null ? number_format($r['revenue'], 2, ',', '') : '', | |||
| $r['invoiced'] ? $yes : $no, | |||
| ], ';'); | |||
| } | |||
| if (!empty($data['rows'])) { | |||
| fputcsv($handle, [ | |||
| '', '', '', '', '', $this->t('app.report.export_sum'), | |||
| number_format($data['totalHours'], 2, ',', ''), | |||
| number_format($data['totalRevenue'], 2, ',', ''), | |||
| '', | |||
| ], ';'); | |||
| } | |||
| fclose($handle); | |||
| return $tmpFile; | |||
| } | |||
| // ── PDF ────────────────────────────────────────────────────────────────── | |||
| /** | |||
| * @param TimeEntry[] $entries | |||
| * @param array<int, string> $userMap | |||
| */ | |||
| public function generatePdf(array $entries, array $userMap, string $accountName): string | |||
| { | |||
| $data = $this->prepareData($entries, $userMap); | |||
| $html = $this->buildPdfHtml($data, $accountName); | |||
| $options = new Options(); | |||
| $options->setDefaultFont('Helvetica'); | |||
| $options->setIsRemoteEnabled(false); | |||
| $dompdf = new Dompdf($options); | |||
| $dompdf->loadHtml($html); | |||
| $dompdf->setPaper('A4', 'landscape'); | |||
| $dompdf->render(); | |||
| $tmpFile = tempnam(sys_get_temp_dir(), 'report_') . '.pdf'; | |||
| file_put_contents($tmpFile, $dompdf->output()); | |||
| return $tmpFile; | |||
| } | |||
| private function buildPdfHtml(array $data, string $accountName): string | |||
| { | |||
| $date = date('d.m.Y'); | |||
| $title = $this->t('app.report.export_title', ['%account%' => $accountName]); | |||
| $created = $this->t('app.report.export_created_at', ['%date%' => $date]); | |||
| $count = count($data['rows']); | |||
| $countLabel = $count === 1 | |||
| ? $this->t('app.report.export_entry_count_one') | |||
| : $this->t('app.report.export_entry_count', ['%count%' => $count]); | |||
| $headers = $this->headers(); | |||
| $invoicedTh = $this->t('app.report.export_col_invoiced_short'); | |||
| $sumLabel = $this->t('app.report.export_sum'); | |||
| $yes = $this->t('app.report.export_yes'); | |||
| $no = $this->t('app.report.export_no'); | |||
| $thHtml = '<th>' . htmlspecialchars($headers[0]) . '</th>' | |||
| . '<th>' . htmlspecialchars($headers[1]) . '</th>' | |||
| . '<th>' . htmlspecialchars($headers[2]) . '</th>' | |||
| . '<th>' . htmlspecialchars($headers[3]) . '</th>' | |||
| . '<th>' . htmlspecialchars($headers[4]) . '</th>' | |||
| . '<th>' . htmlspecialchars($headers[5]) . '</th>' | |||
| . '<th class="num">' . htmlspecialchars($headers[6]) . '</th>' | |||
| . '<th class="num">' . htmlspecialchars($headers[7]) . '</th>' | |||
| . '<th class="center">' . htmlspecialchars($invoicedTh) . '</th>'; | |||
| $rowsHtml = ''; | |||
| foreach ($data['rows'] as $i => $r) { | |||
| $stripe = $i % 2 === 1 ? ' style="background:#f7f9fc"' : ''; | |||
| $revenue = $r['revenue'] !== null ? number_format($r['revenue'], 2, ',', '.') . ' €' : ''; | |||
| $hours = number_format($r['hours'], 2, ',', '.'); | |||
| $rowsHtml .= "<tr{$stripe}>" | |||
| . '<td>' . htmlspecialchars($r['date']->format('d.m.Y')) . '</td>' | |||
| . '<td>' . htmlspecialchars($r['client']) . '</td>' | |||
| . '<td>' . htmlspecialchars($r['project']) . '</td>' | |||
| . '<td>' . htmlspecialchars($r['service']) . '</td>' | |||
| . '<td>' . htmlspecialchars($r['user']) . '</td>' | |||
| . '<td class="note">' . htmlspecialchars($r['note']) . '</td>' | |||
| . '<td class="num">' . $hours . '</td>' | |||
| . '<td class="num">' . $revenue . '</td>' | |||
| . '<td class="center">' . ($r['invoiced'] ? $yes : $no) . '</td>' | |||
| . '</tr>'; | |||
| } | |||
| $totalHours = number_format($data['totalHours'], 2, ',', '.'); | |||
| $totalRevenue = number_format($data['totalRevenue'], 2, ',', '.') . ' €'; | |||
| $sumLabel = htmlspecialchars($sumLabel); | |||
| return <<<HTML | |||
| <!DOCTYPE html> | |||
| <html lang="de"> | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <style> | |||
| @page { margin: 0; } | |||
| * { margin: 0; padding: 0; box-sizing: border-box; } | |||
| body { font-family: Helvetica, Arial, sans-serif; font-size: 9px; color: #2d3748; padding: 18mm 16mm; } | |||
| .header { padding: 0 0 12px; border-bottom: 2px solid #3a7bbf; margin-bottom: 12px; } | |||
| .header h1 { font-size: 16px; color: #3a7bbf; font-weight: 700; } | |||
| .header .meta { font-size: 9px; color: #718096; margin-top: 3px; } | |||
| table { width: 100%; border-collapse: collapse; } | |||
| th { background: #e8eff7; color: #2d3748; font-size: 8px; font-weight: 700; | |||
| text-transform: uppercase; letter-spacing: 0.04em; text-align: left; | |||
| padding: 6px 8px; border-bottom: 1px solid #d1d9e6; } | |||
| td { padding: 5px 8px; border-bottom: 1px solid #e8ecf1; font-size: 9px; vertical-align: top; } | |||
| .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } | |||
| .center { text-align: center; } | |||
| .note { color: #718096; max-width: 200px; overflow: hidden; text-overflow: ellipsis; } | |||
| .summary td { font-weight: 700; background: #e8eff7; border-top: 2px solid #d1d9e6; border-bottom: none; padding: 7px 8px; } | |||
| .footer { margin-top: 16px; font-size: 8px; color: #a0aec0; text-align: right; } | |||
| </style> | |||
| </head> | |||
| <body> | |||
| <div class="header"> | |||
| <h1>{$title}</h1> | |||
| <div class="meta">{$created} · {$countLabel}</div> | |||
| </div> | |||
| <table> | |||
| <thead><tr>{$thHtml}</tr></thead> | |||
| <tbody> | |||
| {$rowsHtml} | |||
| <tr class="summary"> | |||
| <td colspan="6" class="num">{$sumLabel}</td> | |||
| <td class="num">{$totalHours}</td> | |||
| <td class="num">{$totalRevenue}</td> | |||
| <td></td> | |||
| </tr> | |||
| </tbody> | |||
| </table> | |||
| <div class="footer">{$accountName} · {$created}</div> | |||
| </body> | |||
| </html> | |||
| HTML; | |||
| } | |||
| } | |||
| @@ -1,18 +0,0 @@ | |||
| <?php | |||
| namespace App\Twig\Runtime; | |||
| use Twig\Extension\RuntimeExtensionInterface; | |||
| class AppExtensionRuntime implements RuntimeExtensionInterface | |||
| { | |||
| public function __construct() | |||
| { | |||
| // Inject dependencies if needed | |||
| } | |||
| public function doSomething($value) | |||
| { | |||
| // ... | |||
| } | |||
| } | |||
| @@ -0,0 +1,5 @@ | |||
| <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> | |||
| <path d="M4.5 1.5h5l3 3v9a1 1 0 01-1 1h-7a1 1 0 01-1-1v-11a1 1 0 011-1z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> | |||
| <path d="M9.5 1.5v3h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> | |||
| <path d="M5 9h6M5 11h6M8 7v6" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/> | |||
| </svg> | |||
| @@ -0,0 +1,5 @@ | |||
| <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> | |||
| <path d="M4.5 1.5h5l3 3v9a1 1 0 01-1 1h-7a1 1 0 01-1-1v-11a1 1 0 011-1z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> | |||
| <path d="M9.5 1.5v3h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> | |||
| <path d="M6 7.5l4 5M10 7.5l-4 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/> | |||
| </svg> | |||
| @@ -0,0 +1,5 @@ | |||
| <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> | |||
| <path d="M4.5 1.5h5l3 3v9a1 1 0 01-1 1h-7a1 1 0 01-1-1v-11a1 1 0 011-1z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> | |||
| <path d="M9.5 1.5v3h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> | |||
| <path d="M5 8h2.5a1 1 0 010 2H5V8zm0 0v4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> | |||
| </svg> | |||
| @@ -0,0 +1,6 @@ | |||
| <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> | |||
| <path d="M4 5V1.5h8V5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> | |||
| <rect x="2" y="5" width="12" height="6" rx="1" stroke="currentColor" stroke-width="1.2"/> | |||
| <path d="M4 9.5h8v5H4z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> | |||
| <circle cx="10.5" cy="7.5" r=".75" fill="currentColor"/> | |||
| </svg> | |||
| @@ -10,5 +10,5 @@ | |||
| <div class="register-success__icon{% if modifier is defined and modifier %} register-success__icon{{ modifier }}{% endif %}">{{ icon }}</div> | |||
| <h1 class="register-success__title">{{ title }}</h1> | |||
| <p class="register-success__text">{{ text|raw }}</p> | |||
| <a href="{{ btn_href }}" class="btn btn-primary" style="margin-top: 1.5rem;">{{ btn_label }}</a> | |||
| <a href="{{ btn_href }}" class="btn btn-primary register-success__btn">{{ btn_label }}</a> | |||
| </div> | |||
| @@ -0,0 +1,15 @@ | |||
| {# templates/_macros/helpers.html.twig #} | |||
| {% macro smart_date(currentDate, todayStr, tomorrowStr, yesterdayStr, months, weekdays) %} | |||
| {%- set activStr = currentDate|date('Y-m-d') -%} | |||
| {%- set monthName = months[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[currentDate|date('N') - 1] }}, {{ currentDate|date('j') }}. {{ monthName }} | |||
| {%- endif -%} | |||
| {% endmacro %} | |||
| @@ -45,7 +45,7 @@ | |||
| {# 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"> | |||
| <button class="hamburger-nav__toggle" id="hamburger-toggle" aria-label="{{ 'app.nav.menu_open'|trans }}" aria-expanded="false"> | |||
| <span class="hamburger-nav__icon"></span> | |||
| </button> | |||
| <div class="hamburger-nav__panel" id="hamburger-panel" hidden> | |||
| @@ -3,42 +3,22 @@ | |||
| months, monthsShort, weekdays, weekDays, currentWeekNumber, | |||
| prevWeekUrl, nextWeekUrl #} | |||
| {% from '_macros/helpers.html.twig' import smart_date %} | |||
| <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 %} | |||
| {{ smart_date(currentDate, todayStr, tomorrowStr, yesterdayStr, months, weekdays) }} | |||
| </div> | |||
| <button type="button" class="tt-header__week-toggle" id="btn-week-toggle" aria-expanded="false" title="Wochenansicht"> | |||
| KW {{ currentWeekNumber }} ▾ | |||
| <button type="button" class="tt-header__week-toggle" id="btn-week-toggle" aria-expanded="false" title="{{ 'app.nav.week_view'|trans }}"> | |||
| {{ 'app.date.week_short'|trans }} {{ currentWeekNumber }} ▾ | |||
| </button> | |||
| </div> | |||
| <div class="tt-header__meta"> | |||
| <div class="tt-header__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 %} | |||
| {{ smart_date(currentDate, todayStr, tomorrowStr, yesterdayStr, months, weekdays) }} | |||
| </div> | |||
| <div class="tt-header__kw">{{ 'app.date.week_label'|trans }} {{ currentWeekNumber }}</div> | |||
| </div> | |||
| @@ -2,7 +2,7 @@ | |||
| {% extends 'base.html.twig' %} | |||
| {% block title %} | |||
| {% if tab == 'account' %}Account{% else %}Mein Benutzer{% endif %} | |||
| {% if tab == 'account' %}{{ 'app.account.page_title_account'|trans }}{% else %}{{ 'app.account.page_title_user'|trans }}{% endif %} | |||
| {% endblock %} | |||
| {% block body %} | |||
| @@ -12,6 +12,18 @@ | |||
| tab: '{{ tab }}', | |||
| isSuperAdmin: {{ isSuperAdmin ? 'true' : 'false' }}, | |||
| theme: '{{ user.theme|default('standard') }}', | |||
| i18n: { | |||
| invalidHex: {{ 'app.account.invalid_hex'|trans|json_encode|raw }}, | |||
| saved: {{ 'app.account.saved'|trans|json_encode|raw }}, | |||
| savedReloading: {{ 'app.account.saved_reloading'|trans|json_encode|raw }}, | |||
| ownerChanged: {{ 'app.account.owner_changed'|trans|json_encode|raw }}, | |||
| ownerConfirm: {{ 'app.account.owner_confirm'|trans|json_encode|raw }}, | |||
| themeChanged: {{ 'app.account.theme_changed'|trans|json_encode|raw }}, | |||
| passwordMismatch: {{ 'app.account.password_mismatch'|trans|json_encode|raw }}, | |||
| changeLabel: {{ 'app.account.change_label'|trans|json_encode|raw }}, | |||
| cancelLabel: {{ 'app.account.cancel_label'|trans|json_encode|raw }}, | |||
| errorGeneric: {{ 'app.account.error_generic'|trans|json_encode|raw }}, | |||
| }, | |||
| }; | |||
| </script> | |||
| @@ -19,18 +31,18 @@ | |||
| <div class="account-header"> | |||
| <h1 class="account-header__title"> | |||
| {% if tab == 'account' %}Account{% else %}Mein Benutzer{% endif %} | |||
| {% if tab == 'account' %}{{ 'app.account.page_title_account'|trans }}{% else %}{{ 'app.account.page_title_user'|trans }}{% endif %} | |||
| </h1> | |||
| {% if isAdmin %} | |||
| <nav class="account-tabs"> | |||
| <a href="{{ path('account_index', {tab: 'account'}) }}" | |||
| class="account-tab{% if tab == 'account' %} account-tab--active{% endif %}"> | |||
| Account | |||
| {{ 'app.account.tab_account'|trans }} | |||
| </a> | |||
| <a href="{{ path('account_index', {tab: 'user'}) }}" | |||
| class="account-tab{% if tab == 'user' %} account-tab--active{% endif %}"> | |||
| Mein Benutzer | |||
| {{ 'app.account.tab_user'|trans }} | |||
| </a> | |||
| </nav> | |||
| {% endif %} | |||
| @@ -44,16 +56,16 @@ | |||
| <div class="account-form__grid" id="account-form"> | |||
| <label class="account-form__label" for="account-name">Firmenname</label> | |||
| <label class="account-form__label" for="account-name">{{ 'app.account.label_company_name'|trans }}</label> | |||
| <div class="account-form__field"> | |||
| <input type="text" id="account-name" class="input" | |||
| value="{{ account.name|e('html_attr') }}" /> | |||
| <span class="account-form__hint"> | |||
| Subdomain: <strong>{{ account.slug }}.{{ app.request.host|split('.')|slice(1)|join('.') }}</strong> — ändert sich nicht. | |||
| {{ 'app.account.hint_subdomain'|trans({'%subdomain%': account.slug ~ '.' ~ (app.request.host|split('.')|slice(1)|join('.'))}) }} | |||
| </span> | |||
| </div> | |||
| <label class="account-form__label" for="account-interval">Zeitintervall</label> | |||
| <label class="account-form__label" for="account-interval">{{ 'app.account.label_interval'|trans }}</label> | |||
| <div class="account-form__field"> | |||
| <select id="account-interval" class="select"> | |||
| {% for value, label in intervalOptions %} | |||
| @@ -62,11 +74,11 @@ | |||
| </option> | |||
| {% endfor %} | |||
| </select> | |||
| <span class="account-form__hint">Auf welche Einheit werden erfasste Zeiten aufgerundet.</span> | |||
| <span class="account-form__hint">{{ 'app.account.hint_interval'|trans }}</span> | |||
| </div> | |||
| {% if isSuperAdmin %} | |||
| <label class="account-form__label" for="account-color">Hauptfarbe</label> | |||
| <label class="account-form__label" for="account-color">{{ 'app.account.label_color'|trans }}</label> | |||
| <div class="account-form__field"> | |||
| <div class="account-color-field"> | |||
| <input type="color" id="account-color-picker" | |||
| @@ -77,13 +89,13 @@ | |||
| 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> | |||
| <span class="account-form__hint">{{ 'app.account.hint_color'|trans }}</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> | |||
| <button type="button" class="btn btn-primary" id="btn-account-save">{{ 'app.entry.btn_save'|trans }}</button> | |||
| <a href="{{ path('account_index', {tab: 'account'}) }}" class="btn btn-secondary">{{ 'app.entry.btn_cancel'|trans }}</a> | |||
| </div> | |||
| </div> | |||
| @@ -93,42 +105,42 @@ | |||
| <div class="account-form__grid" id="user-form"> | |||
| <label class="account-form__label" for="user-firstname">Vorname</label> | |||
| <label class="account-form__label" for="user-firstname">{{ 'app.account.label_first_name'|trans }}</label> | |||
| <div class="account-form__field"> | |||
| <input type="text" id="user-firstname" class="input" | |||
| value="{{ user.firstName|e('html_attr') }}" /> | |||
| </div> | |||
| <label class="account-form__label" for="user-lastname">Nachname</label> | |||
| <label class="account-form__label" for="user-lastname">{{ 'app.account.label_last_name'|trans }}</label> | |||
| <div class="account-form__field"> | |||
| <input type="text" id="user-lastname" class="input" | |||
| value="{{ user.lastName|e('html_attr') }}" /> | |||
| </div> | |||
| <label class="account-form__label" for="user-email">E-Mail</label> | |||
| <label class="account-form__label" for="user-email">{{ 'app.account.label_email'|trans }}</label> | |||
| <div class="account-form__field"> | |||
| <input type="email" id="user-email" class="input" | |||
| value="{{ user.email|e('html_attr') }}" /> | |||
| </div> | |||
| <label class="account-form__label">Passwort</label> | |||
| <label class="account-form__label">{{ 'app.account.label_password'|trans }}</label> | |||
| <div class="account-form__field"> | |||
| <a href="#" class="account-form__link" id="btn-pw-toggle">ändern</a> | |||
| <a href="#" class="account-form__link" id="btn-pw-toggle">{{ 'app.account.change_label'|trans }}</a> | |||
| </div> | |||
| <div class="account-form__pw-section" id="pw-section" hidden> | |||
| <label class="account-form__label" for="user-pw-current">Aktuelles Passwort</label> | |||
| <label class="account-form__label" for="user-pw-current">{{ 'app.account.label_current_password'|trans }}</label> | |||
| <div class="account-form__field"> | |||
| <input type="password" id="user-pw-current" class="input" autocomplete="current-password" /> | |||
| </div> | |||
| <label class="account-form__label" for="user-pw-new">Neues Passwort</label> | |||
| <label class="account-form__label" for="user-pw-new">{{ 'app.account.label_new_password'|trans }}</label> | |||
| <div class="account-form__field"> | |||
| <input type="password" id="user-pw-new" class="input" autocomplete="new-password" minlength="8" /> | |||
| </div> | |||
| <label class="account-form__label" for="user-pw-repeat">Wiederholen</label> | |||
| <label class="account-form__label" for="user-pw-repeat">{{ 'app.account.label_password_repeat'|trans }}</label> | |||
| <div class="account-form__field"> | |||
| <input type="password" id="user-pw-repeat" class="input" autocomplete="new-password" /> | |||
| </div> | |||
| @@ -136,8 +148,8 @@ | |||
| </div> | |||
| <div class="account-form__actions"> | |||
| <button type="button" class="btn btn-primary" id="btn-user-save">Sichern</button> | |||
| <a href="{{ path('account_index', {tab: 'user'}) }}" class="btn btn-secondary">Abbrechen</a> | |||
| <button type="button" class="btn btn-primary" id="btn-user-save">{{ 'app.entry.btn_save'|trans }}</button> | |||
| <a href="{{ path('account_index', {tab: 'user'}) }}" class="btn btn-secondary">{{ 'app.entry.btn_cancel'|trans }}</a> | |||
| </div> | |||
| </div> | |||
| @@ -149,18 +161,18 @@ | |||
| <hr class="account-form__divider"> | |||
| </div> | |||
| <label class="account-form__label">Darstellung</label> | |||
| <label class="account-form__label">{{ 'app.account.label_appearance'|trans }}</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> | |||
| <span class="theme-option__label">{{ 'app.account.theme_standard'|trans }}</span> | |||
| <span class="theme-option__desc">{{ 'app.account.theme_standard_desc'|trans }}</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> | |||
| <span class="theme-option__label">{{ 'app.account.theme_minimal'|trans }}</span> | |||
| <span class="theme-option__desc">{{ 'app.account.theme_minimal_desc'|trans }}</span> | |||
| </label> | |||
| </div> | |||
| </div> | |||
| @@ -176,7 +188,7 @@ | |||
| <div class="account-card account-card--owner"> | |||
| <div class="account-form__grid"> | |||
| <label class="account-form__label" for="superadmin-select">Besitzer des Accounts</label> | |||
| <label class="account-form__label" for="superadmin-select">{{ 'app.account.label_owner'|trans }}</label> | |||
| <div class="account-form__field"> | |||
| <select id="superadmin-select" class="select" | |||
| {% if superAdminUserId != user.id %}disabled{% endif %}> | |||
| @@ -187,8 +199,7 @@ | |||
| {% endfor %} | |||
| </select> | |||
| <p class="account-form__hint account-form__hint--owner"> | |||
| Der Besitzer des Accounts ist für die Verwaltung der Zahlungsdaten zuständig. | |||
| Nur er kann den Account kündigen. | |||
| {{ 'app.account.hint_owner'|trans }} | |||
| </p> | |||
| </div> | |||
| @@ -1,7 +1,7 @@ | |||
| {# templates/client/index.html.twig #} | |||
| {% extends 'base.html.twig' %} | |||
| {% block title %}Kunden{% endblock %} | |||
| {% block title %}{{ 'app.client.page_title'|trans }}{% endblock %} | |||
| {% block body %} | |||
| @@ -9,46 +9,71 @@ | |||
| window.CRUD = { | |||
| apiBase: '/api/clients', | |||
| clients: null, | |||
| i18n: { | |||
| confirmDelete: {{ 'app.crud.confirm_delete'|trans|json_encode|raw }}, | |||
| confirmArchive: {{ 'app.crud.confirm_archive'|trans|json_encode|raw }}, | |||
| errorSave: {{ 'app.crud.error_save'|trans|json_encode|raw }}, | |||
| errorDelete: {{ 'app.crud.error_delete'|trans|json_encode|raw }}, | |||
| errorArchive: {{ 'app.crud.error_archive'|trans|json_encode|raw }}, | |||
| errorRestore: {{ 'app.crud.error_restore'|trans|json_encode|raw }}, | |||
| errorNoName: {{ 'app.crud.error_no_name'|trans|json_encode|raw }}, | |||
| selectPh: {{ 'app.crud.select_ph'|trans|json_encode|raw }}, | |||
| labelName: {{ 'app.crud.label_name'|trans|json_encode|raw }}, | |||
| labelRate: {{ 'app.crud.label_rate'|trans|json_encode|raw }}, | |||
| labelNote: {{ 'app.crud.label_note'|trans|json_encode|raw }}, | |||
| labelClient: {{ 'app.crud.label_client'|trans|json_encode|raw }}, | |||
| labelBillable: {{ 'app.service.label_billable'|trans|json_encode|raw }}, | |||
| billableLabel: {{ 'app.service.billable_checkbox'|trans|json_encode|raw }}, | |||
| btnSave: {{ 'app.entry.btn_save'|trans|json_encode|raw }}, | |||
| btnCancel: {{ 'app.entry.btn_cancel'|trans|json_encode|raw }}, | |||
| btnEdit: {{ 'app.entry.btn_edit'|trans|json_encode|raw }}, | |||
| btnDelete: {{ 'app.entry.btn_delete'|trans|json_encode|raw }}, | |||
| btnRestore: {{ 'app.crud.btn_restore'|trans|json_encode|raw }}, | |||
| groupBillable: {{ 'app.service.billable'|trans|json_encode|raw }}, | |||
| groupNotBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }}, | |||
| projectSingular: {{ 'app.crud.project_singular'|trans|json_encode|raw }}, | |||
| projectPlural: {{ 'app.crud.project_plural'|trans|json_encode|raw }}, | |||
| }, | |||
| }; | |||
| </script> | |||
| <div class="crud-page"> | |||
| <div class="crud-page__header"> | |||
| <h1 class="crud-page__title">Kunden</h1> | |||
| <button class="btn btn-primary" id="btn-new">Neuer Kunde</button> | |||
| <h1 class="crud-page__title">{{ 'app.client.page_title'|trans }}</h1> | |||
| <button class="btn btn-primary" id="btn-new">{{ 'app.client.btn_new'|trans }}</button> | |||
| </div> | |||
| <div class="crud-create" id="crud-create"> | |||
| <div class="entry-form__grid"> | |||
| <label class="entry-form__label">Name</label> | |||
| <label class="entry-form__label">{{ 'app.crud.label_name'|trans }}</label> | |||
| <div class="entry-form__field"> | |||
| <input type="text" id="create-name" class="input" placeholder="Kundenname" /> | |||
| <input type="text" id="create-name" class="input" placeholder="{{ 'app.client.placeholder_name'|trans }}" /> | |||
| </div> | |||
| <label class="entry-form__label">Stundensatz</label> | |||
| <div class="entry-form__field" style="gap: 8px"> | |||
| <input type="number" id="create-rate" class="input" style="width:100px" placeholder="0,00" step="0.01" min="0" /> | |||
| <span style="color: var(--color-text-muted, #7a8a9a); font-size: 0.875rem">€</span> | |||
| <label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label> | |||
| <div class="entry-form__field entry-form__field--rate"> | |||
| <input type="number" id="create-rate" class="input input--rate" placeholder="0,00" step="0.01" min="0" /> | |||
| <span class="entry-form__unit">€</span> | |||
| </div> | |||
| <label class="entry-form__label">Bemerkung</label> | |||
| <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | |||
| <div class="entry-form__field"> | |||
| <textarea id="create-note" class="textarea" rows="2"></textarea> | |||
| </div> | |||
| <div class="entry-form__actions"> | |||
| <button type="button" class="btn btn-primary" id="btn-create-save">Erstellen</button> | |||
| <button type="button" class="btn btn-secondary" id="btn-create-cancel">Abbrechen</button> | |||
| <button type="button" class="btn btn-primary" id="btn-create-save">{{ 'app.entry.btn_create'|trans }}</button> | |||
| <button type="button" class="btn btn-secondary" id="btn-create-cancel">{{ 'app.entry.btn_cancel'|trans }}</button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="crud-tabs"> | |||
| <button class="crud-tab crud-tab--active" data-tab="active">Aktiv</button> | |||
| <button class="crud-tab" data-tab="archived">Archiviert</button> | |||
| <button class="crud-tab crud-tab--active" data-tab="active">{{ 'app.crud.tab_active'|trans }}</button> | |||
| <button class="crud-tab" data-tab="archived">{{ 'app.crud.tab_archived'|trans }}</button> | |||
| </div> | |||
| <div class="crud-list" id="crud-list"> | |||
| @@ -66,19 +91,19 @@ window.CRUD = { | |||
| <span class="crud-row__name">{{ client.name }}</span> | |||
| <span class="crud-row__meta"> | |||
| {% set count = client.projects|length %} | |||
| {{ count }} {{ count == 1 ? 'Projekt' : 'Projekte' }} | |||
| {{ count }} {{ count == 1 ? 'app.crud.project_singular'|trans : 'app.crud.project_plural'|trans }} | |||
| </span> | |||
| </div> | |||
| <div class="crud-row__actions"> | |||
| {% if client.isArchived() %} | |||
| <button class="crud-row__btn crud-row__btn--restore" data-action="unarchive" title="Wiederherstellen"> | |||
| <button class="crud-row__btn crud-row__btn--restore" data-action="unarchive" title="{{ 'app.crud.btn_restore'|trans }}"> | |||
| {% include '_atoms/icon-restore.html.twig' %} | |||
| </button> | |||
| {% else %} | |||
| <button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="Bearbeiten"> | |||
| <button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="{{ 'app.entry.btn_edit'|trans }}"> | |||
| {% include '_atoms/icon-edit.html.twig' %} | |||
| </button> | |||
| <button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="Löschen"> | |||
| <button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="{{ 'app.entry.btn_delete'|trans }}"> | |||
| {% include '_atoms/icon-delete.html.twig' %} | |||
| </button> | |||
| {% endif %} | |||
| @@ -89,26 +114,26 @@ window.CRUD = { | |||
| <div class="crud-row__edit" hidden> | |||
| <div class="entry-form__grid entry-form__grid--inline"> | |||
| <label class="entry-form__label">Name</label> | |||
| <label class="entry-form__label">{{ 'app.crud.label_name'|trans }}</label> | |||
| <div class="entry-form__field"> | |||
| <input type="text" class="input edit-name" value="{{ client.name }}" /> | |||
| </div> | |||
| <label class="entry-form__label">Stundensatz</label> | |||
| <div class="entry-form__field" style="gap: 8px"> | |||
| <input type="number" class="input edit-rate" style="width:100px" | |||
| <label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label> | |||
| <div class="entry-form__field entry-form__field--rate"> | |||
| <input type="number" class="input input--rate edit-rate" | |||
| value="{{ client.hourlyRate|default('') }}" step="0.01" min="0" /> | |||
| <span style="color: var(--color-text-muted, #7a8a9a); font-size: 0.875rem">€</span> | |||
| <span class="entry-form__unit">€</span> | |||
| </div> | |||
| <label class="entry-form__label">Bemerkung</label> | |||
| <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | |||
| <div class="entry-form__field"> | |||
| <textarea class="textarea edit-note" rows="2">{{ client.note|default('') }}</textarea> | |||
| </div> | |||
| <div class="entry-form__actions"> | |||
| <button type="button" class="btn btn-primary" data-action="save">Sichern</button> | |||
| <button type="button" class="btn btn-secondary" data-action="cancel">Abbrechen</button> | |||
| <button type="button" class="btn btn-primary" data-action="save">{{ 'app.entry.btn_save'|trans }}</button> | |||
| <button type="button" class="btn btn-secondary" data-action="cancel">{{ 'app.entry.btn_cancel'|trans }}</button> | |||
| </div> | |||
| </div> | |||
| @@ -117,8 +142,8 @@ window.CRUD = { | |||
| </div> | |||
| {% else %} | |||
| <div class="empty-state" style="padding: 2rem"> | |||
| <p class="empty-state__title">Noch keine Kunden angelegt.</p> | |||
| <div class="empty-state"> | |||
| <p class="empty-state__title">{{ 'app.client.empty'|trans }}</p> | |||
| </div> | |||
| {% endfor %} | |||
| </div> | |||
| @@ -4,7 +4,7 @@ | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| <title>spawntree Timetracker</title> | |||
| <title>{{ 'app.home.title'|trans }}</title> | |||
| {{ encore_entry_link_tags('app') }} | |||
| </head> | |||
| <body class="home-body"> | |||
| @@ -12,15 +12,15 @@ | |||
| <header class="home-header"> | |||
| <div class="home-header__inner"> | |||
| <div class="home-header__brand">spawntree <span>Timetracker</span></div> | |||
| <a href="{{ path('app_register') }}" class="btn btn-primary">Kostenlos starten</a> | |||
| <a href="{{ path('app_register') }}" class="btn btn-primary">{{ 'app.home.btn_start'|trans }}</a> | |||
| </div> | |||
| </header> | |||
| <main class="home-hero"> | |||
| <div class="home-hero__inner"> | |||
| <h1 class="home-hero__title">Zeiterfassung,<br>die nicht nervt.</h1> | |||
| <p class="home-hero__sub">Einfach, schnell, ohne Overhead. Dein Team, deine Projekte, deine Zeit.</p> | |||
| <a href="{{ path('app_register') }}" class="btn btn-primary home-hero__cta">Jetzt registrieren →</a> | |||
| <h1 class="home-hero__title">{{ 'app.home.hero_title'|trans }}</h1> | |||
| <p class="home-hero__sub">{{ 'app.home.hero_sub'|trans }}</p> | |||
| <a href="{{ path('app_register') }}" class="btn btn-primary home-hero__cta">{{ 'app.home.btn_register'|trans }}</a> | |||
| </div> | |||
| </main> | |||
| @@ -10,7 +10,7 @@ | |||
| <body class="login-body"> | |||
| <div class="login-card"> | |||
| <div class="login-card__title">{{ 'app.invite_error.title'|trans }}</div> | |||
| <div class="login-card__error">{{ error }}</div> | |||
| <div class="login-card__error">{{ ('app.invite_error.' ~ error)|trans }}</div> | |||
| </div> | |||
| </body> | |||
| </html> | |||
| @@ -14,11 +14,16 @@ | |||
| <div class="login-card__title">{{ invite.account.name }}</div> | |||
| <p class="login-card__sub">{{ 'app.set_password.subtitle'|trans({'%name%': invite.firstName}) }}</p> | |||
| {% if error %} | |||
| <div class="login-card__error">{{ error }}</div> | |||
| {% if error == 'csrf' %} | |||
| <div class="login-card__error">{{ 'app.csrf_error'|trans }}</div> | |||
| {% elseif error == 'too_short' %} | |||
| <div class="login-card__error">{{ 'app.set_password.error_too_short'|trans }}</div> | |||
| {% elseif error == 'mismatch' %} | |||
| <div class="login-card__error">{{ 'app.set_password.error_mismatch'|trans }}</div> | |||
| {% endif %} | |||
| <form class="login-form" method="post"> | |||
| <input type="hidden" name="_csrf_token" value="{{ csrf_token('invite_password') }}" /> | |||
| <div class="login-form__grid"> | |||
| @@ -1,57 +1,82 @@ | |||
| {# templates/project/index.html.twig #} | |||
| {% extends 'base.html.twig' %} | |||
| {% block title %}Projekte{% endblock %} | |||
| {% block title %}{{ 'app.project.page_title'|trans }}{% endblock %} | |||
| {% block body %} | |||
| <script> | |||
| window.CRUD = { | |||
| apiBase: '/api/projects', | |||
| clients: {{ clients|map(c => { id: c.id, name: c.name })|json_encode|raw }}, | |||
| i18n: { | |||
| confirmDelete: {{ 'app.crud.confirm_delete'|trans|json_encode|raw }}, | |||
| confirmArchive: {{ 'app.crud.confirm_archive'|trans|json_encode|raw }}, | |||
| errorSave: {{ 'app.crud.error_save'|trans|json_encode|raw }}, | |||
| errorDelete: {{ 'app.crud.error_delete'|trans|json_encode|raw }}, | |||
| errorArchive: {{ 'app.crud.error_archive'|trans|json_encode|raw }}, | |||
| errorRestore: {{ 'app.crud.error_restore'|trans|json_encode|raw }}, | |||
| errorNoName: {{ 'app.crud.error_no_name'|trans|json_encode|raw }}, | |||
| selectPh: {{ 'app.crud.select_ph'|trans|json_encode|raw }}, | |||
| labelName: {{ 'app.crud.label_name'|trans|json_encode|raw }}, | |||
| labelRate: {{ 'app.crud.label_rate'|trans|json_encode|raw }}, | |||
| labelNote: {{ 'app.crud.label_note'|trans|json_encode|raw }}, | |||
| labelClient: {{ 'app.crud.label_client'|trans|json_encode|raw }}, | |||
| labelBillable: {{ 'app.service.label_billable'|trans|json_encode|raw }}, | |||
| billableLabel: {{ 'app.service.billable_checkbox'|trans|json_encode|raw }}, | |||
| btnSave: {{ 'app.entry.btn_save'|trans|json_encode|raw }}, | |||
| btnCancel: {{ 'app.entry.btn_cancel'|trans|json_encode|raw }}, | |||
| btnEdit: {{ 'app.entry.btn_edit'|trans|json_encode|raw }}, | |||
| btnDelete: {{ 'app.entry.btn_delete'|trans|json_encode|raw }}, | |||
| btnRestore: {{ 'app.crud.btn_restore'|trans|json_encode|raw }}, | |||
| groupBillable: {{ 'app.service.billable'|trans|json_encode|raw }}, | |||
| groupNotBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }}, | |||
| projectSingular: {{ 'app.crud.project_singular'|trans|json_encode|raw }}, | |||
| projectPlural: {{ 'app.crud.project_plural'|trans|json_encode|raw }}, | |||
| }, | |||
| }; | |||
| </script> | |||
| <div class="crud-page"> | |||
| <div class="crud-page__header"> | |||
| <h1 class="crud-page__title">Projekte</h1> | |||
| <button class="btn btn-primary" id="btn-new">Neues Projekt</button> | |||
| <h1 class="crud-page__title">{{ 'app.project.page_title'|trans }}</h1> | |||
| <button class="btn btn-primary" id="btn-new">{{ 'app.project.btn_new'|trans }}</button> | |||
| </div> | |||
| <div class="crud-create" id="crud-create"> | |||
| <div class="entry-form__grid"> | |||
| <label class="entry-form__label">Name</label> | |||
| <label class="entry-form__label">{{ 'app.crud.label_name'|trans }}</label> | |||
| <div class="entry-form__field"> | |||
| <input type="text" id="create-name" class="input" placeholder="Projektname" /> | |||
| <input type="text" id="create-name" class="input" placeholder="{{ 'app.project.placeholder_name'|trans }}" /> | |||
| </div> | |||
| <label class="entry-form__label">Kunde</label> | |||
| <label class="entry-form__label">{{ 'app.crud.label_client'|trans }}</label> | |||
| <div class="entry-form__field"> | |||
| <select id="create-client" class="select"> | |||
| <option value="">Bitte wählen</option> | |||
| <option value="">{{ 'app.crud.select_ph'|trans }}</option> | |||
| {% for client in clients %} | |||
| <option value="{{ client.id }}">{{ client.name }}</option> | |||
| {% endfor %} | |||
| </select> | |||
| </div> | |||
| <label class="entry-form__label">Bemerkung</label> | |||
| <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | |||
| <div class="entry-form__field"> | |||
| <textarea id="create-note" class="textarea" rows="2"></textarea> | |||
| </div> | |||
| <div class="entry-form__actions"> | |||
| <button type="button" class="btn btn-primary" id="btn-create-save">Erstellen</button> | |||
| <button type="button" class="btn btn-secondary" id="btn-create-cancel">Abbrechen</button> | |||
| <button type="button" class="btn btn-primary" id="btn-create-save">{{ 'app.entry.btn_create'|trans }}</button> | |||
| <button type="button" class="btn btn-secondary" id="btn-create-cancel">{{ 'app.entry.btn_cancel'|trans }}</button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="crud-tabs"> | |||
| <button class="crud-tab crud-tab--active" data-tab="active">Aktiv</button> | |||
| <button class="crud-tab" data-tab="archived">Archiviert</button> | |||
| <button class="crud-tab crud-tab--active" data-tab="active">{{ 'app.crud.tab_active'|trans }}</button> | |||
| <button class="crud-tab" data-tab="archived">{{ 'app.crud.tab_archived'|trans }}</button> | |||
| </div> | |||
| <div class="crud-list" id="crud-list"> | |||
| @@ -72,15 +97,15 @@ window.CRUD = { | |||
| </div> | |||
| <div class="crud-row__actions"> | |||
| {% if project.isArchived() %} | |||
| <button class="crud-row__btn crud-row__btn--restore" data-action="unarchive" title="Wiederherstellen"> | |||
| <svg viewBox="0 0 16 16" fill="none"><path d="M2 8a6 6 0 1 1 1.5 3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M2 13V9h4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg> | |||
| <button class="crud-row__btn crud-row__btn--restore" data-action="unarchive" title="{{ 'app.crud.btn_restore'|trans }}"> | |||
| {% include '_atoms/icon-restore.html.twig' %} | |||
| </button> | |||
| {% else %} | |||
| <button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="Bearbeiten"> | |||
| <svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg> | |||
| <button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="{{ 'app.entry.btn_edit'|trans }}"> | |||
| {% include '_atoms/icon-edit.html.twig' %} | |||
| </button> | |||
| <button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="Löschen"> | |||
| <svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg> | |||
| <button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="{{ 'app.entry.btn_delete'|trans }}"> | |||
| {% include '_atoms/icon-delete.html.twig' %} | |||
| </button> | |||
| {% endif %} | |||
| </div> | |||
| @@ -90,12 +115,12 @@ window.CRUD = { | |||
| <div class="crud-row__edit" hidden> | |||
| <div class="entry-form__grid entry-form__grid--inline"> | |||
| <label class="entry-form__label">Name</label> | |||
| <label class="entry-form__label">{{ 'app.crud.label_name'|trans }}</label> | |||
| <div class="entry-form__field"> | |||
| <input type="text" class="input edit-name" value="{{ project.name }}" /> | |||
| </div> | |||
| <label class="entry-form__label">Kunde</label> | |||
| <label class="entry-form__label">{{ 'app.crud.label_client'|trans }}</label> | |||
| <div class="entry-form__field"> | |||
| <select class="select edit-client"> | |||
| {% for client in clients %} | |||
| @@ -107,14 +132,14 @@ window.CRUD = { | |||
| </select> | |||
| </div> | |||
| <label class="entry-form__label">Bemerkung</label> | |||
| <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | |||
| <div class="entry-form__field"> | |||
| <textarea class="textarea edit-note" rows="2">{{ project.note|default('') }}</textarea> | |||
| </div> | |||
| <div class="entry-form__actions"> | |||
| <button type="button" class="btn btn-primary" data-action="save">Sichern</button> | |||
| <button type="button" class="btn btn-secondary" data-action="cancel">Abbrechen</button> | |||
| <button type="button" class="btn btn-primary" data-action="save">{{ 'app.entry.btn_save'|trans }}</button> | |||
| <button type="button" class="btn btn-secondary" data-action="cancel">{{ 'app.entry.btn_cancel'|trans }}</button> | |||
| </div> | |||
| </div> | |||
| @@ -124,7 +149,7 @@ window.CRUD = { | |||
| </div> | |||
| {% else %} | |||
| <div class="empty-state"> | |||
| <p class="empty-state__title">Noch keine Projekte angelegt.</p> | |||
| <p class="empty-state__title">{{ 'app.project.empty'|trans }}</p> | |||
| </div> | |||
| {% endfor %} | |||
| @@ -90,7 +90,20 @@ | |||
| {% endblock %} | |||
| {% endembed %} | |||
| <script>window.REGISTER_APP_DOMAIN = '{{ appDomain }}';</script> | |||
| <script> | |||
| window.Register = { | |||
| appDomain: '{{ appDomain }}', | |||
| i18n: { | |||
| btnSubmit: {{ 'app.register.btn_submit'|trans|json_encode|raw }}, | |||
| sending: {{ 'app.register.sending'|trans|json_encode|raw }}, | |||
| errorUnknown: {{ 'app.register.error_unknown'|trans|json_encode|raw }}, | |||
| errorConnection: {{ 'app.register.error_connection'|trans|json_encode|raw }}, | |||
| successTitle: {{ 'app.register.success_title'|trans|json_encode|raw }}, | |||
| successText: {{ 'app.register.success_text'|trans|json_encode|raw }}, | |||
| successHint: {{ 'app.register.success_hint'|trans|json_encode|raw }}, | |||
| } | |||
| }; | |||
| </script> | |||
| {{ encore_entry_script_tags('registration') }} | |||
| </body> | |||
| @@ -124,16 +124,16 @@ | |||
| <select class="select filter-select" data-filter-key="services"> | |||
| <option value="">...</option> | |||
| {% if activeServices|length > 0 %} | |||
| <optgroup label="Aktiv"> | |||
| <optgroup label="{{ 'app.crud.tab_active'|trans }}"> | |||
| {% for service in activeServices %} | |||
| <option value="{{ service.id }}"{% if val == service.id %} selected{% endif %}>{{ service.name }}{% if not service.billable %} (nicht-verrechenbar){% endif %}</option> | |||
| <option value="{{ service.id }}"{% if val == service.id %} selected{% endif %}>{{ service.name }}{% if not service.billable %} ({{ 'app.service.not_billable'|trans }}){% endif %}</option> | |||
| {% endfor %} | |||
| </optgroup> | |||
| {% endif %} | |||
| {% if archivedServices|length > 0 %} | |||
| <optgroup label="Archiviert"> | |||
| <optgroup label="{{ 'app.crud.tab_archived'|trans }}"> | |||
| {% for service in archivedServices %} | |||
| <option value="{{ service.id }}"{% if val == service.id %} selected{% endif %}>{{ service.name }}{% if not service.billable %} (nicht-verrechenbar){% endif %}</option> | |||
| <option value="{{ service.id }}"{% if val == service.id %} selected{% endif %}>{{ service.name }}{% if not service.billable %} ({{ 'app.service.not_billable'|trans }}){% endif %}</option> | |||
| {% endfor %} | |||
| </optgroup> | |||
| {% endif %} | |||
| @@ -172,14 +172,14 @@ | |||
| <select class="select filter-select" data-filter-key="users"> | |||
| <option value="">...</option> | |||
| {% if activeUsers|length > 0 %} | |||
| <optgroup label="Aktiv"> | |||
| <optgroup label="{{ 'app.crud.tab_active'|trans }}"> | |||
| {% for user in activeUsers %} | |||
| <option value="{{ user.id }}"{% if val == user.id %} selected{% endif %}>{{ user.name }}</option> | |||
| {% endfor %} | |||
| </optgroup> | |||
| {% endif %} | |||
| {% if archivedUsers|length > 0 %} | |||
| <optgroup label="Archiviert"> | |||
| <optgroup label="{{ 'app.crud.tab_archived'|trans }}"> | |||
| {% for user in archivedUsers %} | |||
| <option value="{{ user.id }}"{% if val == user.id %} selected{% endif %}>{{ user.name }}</option> | |||
| {% endfor %} | |||
| @@ -103,6 +103,35 @@ | |||
| {{ 'app.report.toolbar_filter'|trans }} | |||
| </button> | |||
| </div> | |||
| <div class="report-toolbar__right"> | |||
| <button class="report-toolbar__export" | |||
| id="btn-export-excel" | |||
| type="button" | |||
| title="{{ 'app.report.export_excel'|trans }}"> | |||
| {% include '_atoms/icon-excel.html.twig' %} | |||
| </button> | |||
| <button class="report-toolbar__export" | |||
| id="btn-export-csv" | |||
| type="button" | |||
| title="{{ 'app.report.export_csv'|trans }}"> | |||
| {% include '_atoms/icon-csv.html.twig' %} | |||
| </button> | |||
| <button class="report-toolbar__export" | |||
| id="btn-export-pdf" | |||
| type="button" | |||
| title="{{ 'app.report.export_pdf'|trans }}"> | |||
| {% include '_atoms/icon-pdf.html.twig' %} | |||
| </button> | |||
| <span class="report-toolbar__separator"></span> | |||
| <button class="report-toolbar__export" | |||
| id="btn-print" | |||
| type="button" | |||
| title="{{ 'app.report.print'|trans }}"> | |||
| {% include '_atoms/icon-print.html.twig' %} | |||
| </button> | |||
| </div> | |||
| </div> | |||
| {# ── Filter-Panel ─────────────────────────────────────────────────── #} | |||
| @@ -165,7 +194,7 @@ | |||
| </div> | |||
| <div class="report-table__cell report-table__cell--user"> | |||
| {{ userMap[entry.userId] ?? ('User #' ~ entry.userId) }} | |||
| {{ userMap[entry.userId] ?? 'app.report.user_fallback'|trans({'%id%': entry.userId}) }} | |||
| </div> | |||
| <div class="report-table__cell report-table__cell--note"> | |||
| @@ -22,13 +22,16 @@ | |||
| {% else %} | |||
| {% if error == 'invalid_email' %} | |||
| {% if error == 'invalid_csrf' %} | |||
| <div class="login-card__error">{{ 'app.csrf_error'|trans }}</div> | |||
| {% elseif error == 'invalid_email' %} | |||
| <div class="login-card__error">{{ 'app.forgot_password.error_invalid_email'|trans }}</div> | |||
| {% endif %} | |||
| <p class="login-card__sub">{{ 'app.forgot_password.subtitle'|trans }}</p> | |||
| <form class="login-form" method="post"> | |||
| <input type="hidden" name="_csrf_token" value="{{ csrf_token('forgot_password') }}" /> | |||
| <div class="login-form__grid"> | |||
| <label class="login-form__label" for="email">{{ 'app.login.label_email'|trans }}</label> | |||
| @@ -29,13 +29,16 @@ | |||
| {% else %} | |||
| {% if error == 'too_short' %} | |||
| {% if error == 'invalid_csrf' %} | |||
| <div class="login-card__error">{{ 'app.csrf_error'|trans }}</div> | |||
| {% elseif error == 'too_short' %} | |||
| <div class="login-card__error">{{ 'app.reset_password.error_too_short'|trans }}</div> | |||
| {% elseif error == 'mismatch' %} | |||
| <div class="login-card__error">{{ 'app.reset_password.error_mismatch'|trans }}</div> | |||
| {% endif %} | |||
| <form class="login-form" method="post"> | |||
| <input type="hidden" name="_csrf_token" value="{{ csrf_token('reset_password') }}" /> | |||
| <div class="login-form__grid"> | |||
| @@ -5,7 +5,34 @@ | |||
| {% block body %} | |||
| <script> | |||
| window.CRUD = { apiBase: '/api/services' }; | |||
| window.CRUD = { | |||
| apiBase: '/api/services', | |||
| i18n: { | |||
| confirmDelete: {{ 'app.crud.confirm_delete'|trans|json_encode|raw }}, | |||
| confirmArchive: {{ 'app.crud.confirm_archive'|trans|json_encode|raw }}, | |||
| errorSave: {{ 'app.crud.error_save'|trans|json_encode|raw }}, | |||
| errorDelete: {{ 'app.crud.error_delete'|trans|json_encode|raw }}, | |||
| errorArchive: {{ 'app.crud.error_archive'|trans|json_encode|raw }}, | |||
| errorRestore: {{ 'app.crud.error_restore'|trans|json_encode|raw }}, | |||
| errorNoName: {{ 'app.crud.error_no_name'|trans|json_encode|raw }}, | |||
| selectPh: {{ 'app.crud.select_ph'|trans|json_encode|raw }}, | |||
| labelName: {{ 'app.crud.label_name'|trans|json_encode|raw }}, | |||
| labelRate: {{ 'app.crud.label_rate'|trans|json_encode|raw }}, | |||
| labelNote: {{ 'app.crud.label_note'|trans|json_encode|raw }}, | |||
| labelClient: {{ 'app.crud.label_client'|trans|json_encode|raw }}, | |||
| labelBillable: {{ 'app.service.label_billable'|trans|json_encode|raw }}, | |||
| billableLabel: {{ 'app.service.billable_checkbox'|trans|json_encode|raw }}, | |||
| btnSave: {{ 'app.entry.btn_save'|trans|json_encode|raw }}, | |||
| btnCancel: {{ 'app.entry.btn_cancel'|trans|json_encode|raw }}, | |||
| btnEdit: {{ 'app.entry.btn_edit'|trans|json_encode|raw }}, | |||
| btnDelete: {{ 'app.entry.btn_delete'|trans|json_encode|raw }}, | |||
| btnRestore: {{ 'app.crud.btn_restore'|trans|json_encode|raw }}, | |||
| groupBillable: {{ 'app.service.billable'|trans|json_encode|raw }}, | |||
| groupNotBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }}, | |||
| projectSingular: {{ 'app.crud.project_singular'|trans|json_encode|raw }}, | |||
| projectPlural: {{ 'app.crud.project_plural'|trans|json_encode|raw }}, | |||
| }, | |||
| }; | |||
| </script> | |||
| <div class="crud-page"> | |||
| @@ -1,22 +1,36 @@ | |||
| {# templates/team/index.html.twig #} | |||
| {% extends 'base.html.twig' %} | |||
| {% block title %}Team{% endblock %} | |||
| {% block title %}{{ 'app.team.page_title'|trans }}{% endblock %} | |||
| {% block body %} | |||
| <script> | |||
| window.Team = { | |||
| i18n: { | |||
| confirmDelete: {{ 'app.team.confirm_delete'|trans|json_encode|raw }}, | |||
| confirmArchive: {{ 'app.team.confirm_archive'|trans|json_encode|raw }}, | |||
| confirmRevokeInvite:{{ 'app.team.confirm_revoke_invite'|trans|json_encode|raw }}, | |||
| errorSave: {{ 'app.team.error_save'|trans|json_encode|raw }}, | |||
| errorDelete: {{ 'app.team.error_delete'|trans|json_encode|raw }}, | |||
| errorArchive: {{ 'app.team.error_archive'|trans|json_encode|raw }}, | |||
| errorRestore: {{ 'app.team.error_restore'|trans|json_encode|raw }}, | |||
| errorGeneric: {{ 'app.team.error_generic'|trans|json_encode|raw }}, | |||
| } | |||
| }; | |||
| </script> | |||
| <div class="crud-page"> | |||
| <div class="crud-page__header"> | |||
| <h1 class="crud-page__title">Team</h1> | |||
| <button class="btn btn-primary" id="team-invite-btn">Neuer Benutzer</button> | |||
| <h1 class="crud-page__title">{{ 'app.team.page_title'|trans }}</h1> | |||
| <button class="btn btn-primary" id="team-invite-btn">{{ 'app.team.btn_new'|trans }}</button> | |||
| </div> | |||
| <div class="crud-tabs"> | |||
| <button class="crud-tab crud-tab--active" data-tab="active"> | |||
| Aktiv ({{ activeUsers|length + pendingInvites|length }}) | |||
| {{ 'app.crud.tab_active'|trans }} ({{ activeUsers|length + pendingInvites|length }}) | |||
| </button> | |||
| <button class="crud-tab" data-tab="archived"> | |||
| Archiviert ({{ archivedUsers|length }}) | |||
| {{ 'app.crud.tab_archived'|trans }} ({{ archivedUsers|length }}) | |||
| </button> | |||
| </div> | |||
| @@ -37,22 +51,22 @@ | |||
| <div class="crud-row__display"> | |||
| <div class="crud-row__info"> | |||
| <span class="crud-row__name">{{ au.user.fullName }}</span> | |||
| <span class="crud-row__meta">({{ au.roleLabel }})</span> | |||
| <span class="crud-row__meta">({{ au.roleLabelKey|trans }})</span> | |||
| {% if au.user.password is null %} | |||
| <span class="team-badge team-badge--pending">Einladung ausstehend</span> | |||
| <span class="team-badge team-badge--pending">{{ 'app.team.invite_pending'|trans }}</span> | |||
| {% endif %} | |||
| </div> | |||
| <div class="crud-row__actions"> | |||
| <button class="crud-row__btn crud-row__btn--edit" | |||
| data-action="edit" | |||
| title="Bearbeiten"> | |||
| <svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg> | |||
| title="{{ 'app.entry.btn_edit'|trans }}"> | |||
| {% include '_atoms/icon-edit.html.twig' %} | |||
| </button> | |||
| {% if au.user.id != currentUserId %} | |||
| <button class="crud-row__btn crud-row__btn--delete" | |||
| data-action="delete" | |||
| title="Entfernen"> | |||
| <svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg> | |||
| title="{{ 'app.team.btn_remove'|trans }}"> | |||
| {% include '_atoms/icon-delete.html.twig' %} | |||
| </button> | |||
| {% endif %} | |||
| </div> | |||
| @@ -61,57 +75,57 @@ | |||
| <div class="crud-row__edit" hidden> | |||
| <div class="entry-form__grid entry-form__grid--inline"> | |||
| <label class="entry-form__label">Vorname</label> | |||
| <label class="entry-form__label">{{ 'app.team.label_first_name'|trans }}</label> | |||
| <div class="entry-form__field"> | |||
| <input type="text" class="input edit-first-name" | |||
| value="{{ au.user.firstName }}" /> | |||
| </div> | |||
| <label class="entry-form__label">Nachname</label> | |||
| <label class="entry-form__label">{{ 'app.team.label_last_name'|trans }}</label> | |||
| <div class="entry-form__field"> | |||
| <input type="text" class="input edit-last-name" | |||
| value="{{ au.user.lastName }}" /> | |||
| </div> | |||
| <label class="entry-form__label">E-Mail</label> | |||
| <label class="entry-form__label">{{ 'app.team.label_email'|trans }}</label> | |||
| <div class="entry-form__field"> | |||
| <input type="email" class="input edit-email" | |||
| value="{{ au.user.email }}" /> | |||
| </div> | |||
| <label class="entry-form__label">Bemerkung</label> | |||
| <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | |||
| <div class="entry-form__field"> | |||
| <textarea class="textarea edit-note" rows="2">{{ au.user.note|default('') }}</textarea> | |||
| </div> | |||
| <label class="entry-form__label">Rolle</label> | |||
| <label class="entry-form__label">{{ 'app.team.label_role'|trans }}</label> | |||
| <div class="entry-form__field"> | |||
| <div class="team-role-selector{% if au.user.id == currentUserId and au.isAdmin() %} team-role-selector--disabled{% endif %}"> | |||
| {% set roleDisabled = (au.user.id == currentUserId and au.isAdmin()) ? 'disabled' : '' %} | |||
| <label class="team-role-option"> | |||
| <input type="radio" class="edit-role" name="role-{{ au.id }}" | |||
| value="tracker" {{ au.role == 'tracker' ? 'checked' : '' }} {{ roleDisabled }} /> | |||
| <span class="team-role-option__label">Zeiterfasser</span> | |||
| <span class="team-role-option__label">{{ 'app.team.role_tracker'|trans }}</span> | |||
| </label> | |||
| <label class="team-role-option"> | |||
| <input type="radio" class="edit-role" name="role-{{ au.id }}" | |||
| value="member" {{ au.role == 'member' ? 'checked' : '' }} {{ roleDisabled }} /> | |||
| <span class="team-role-option__label">Standard-Nutzer</span> | |||
| <span class="team-role-option__label">{{ 'app.team.role_member'|trans }}</span> | |||
| </label> | |||
| <label class="team-role-option"> | |||
| <input type="radio" class="edit-role" name="role-{{ au.id }}" | |||
| value="admin" {{ au.role == 'admin' ? 'checked' : '' }} {{ roleDisabled }} /> | |||
| <span class="team-role-option__label">Administrator</span> | |||
| <span class="team-role-option__label">{{ 'app.team.role_admin'|trans }}</span> | |||
| </label> | |||
| </div> | |||
| {% if au.user.id == currentUserId and au.isAdmin() %} | |||
| <p class="team-role-hint">Eigene Administrator-Rolle kann nicht geändert werden.</p> | |||
| <p class="team-role-hint">{{ 'app.team.role_change_disabled'|trans }}</p> | |||
| {% endif %} | |||
| </div> | |||
| <div class="entry-form__actions"> | |||
| <button type="button" class="btn btn-primary" data-action="save">Sichern</button> | |||
| <button type="button" class="btn btn-secondary" data-action="cancel">Abbrechen</button> | |||
| <button type="button" class="btn btn-primary" data-action="save">{{ 'app.entry.btn_save'|trans }}</button> | |||
| <button type="button" class="btn btn-secondary" data-action="cancel">{{ 'app.entry.btn_cancel'|trans }}</button> | |||
| </div> | |||
| </div> | |||
| @@ -127,14 +141,14 @@ | |||
| <div class="crud-row__info"> | |||
| <span class="crud-row__name">{{ invite.firstName }} {{ invite.lastName }}</span> | |||
| <span class="crud-row__meta">({{ invite.email }})</span> | |||
| <span class="team-badge team-badge--pending">Einladung ausstehend</span> | |||
| <span class="team-badge team-badge--pending">{{ 'app.team.invite_pending'|trans }}</span> | |||
| </div> | |||
| <div class="crud-row__actions"> | |||
| <button class="crud-row__btn crud-row__btn--delete" | |||
| data-action="delete-invite" | |||
| data-id="{{ invite.id }}" | |||
| title="Einladung zurückziehen"> | |||
| <svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg> | |||
| title="{{ 'app.team.btn_revoke_invite'|trans }}"> | |||
| {% include '_atoms/icon-delete.html.twig' %} | |||
| </button> | |||
| </div> | |||
| </div> | |||
| @@ -142,7 +156,7 @@ | |||
| {% endfor %} | |||
| {% if activeUsers is empty and pendingInvites is empty %} | |||
| <div class="crud-list__empty">Noch keine aktiven Teammitglieder.</div> | |||
| <div class="crud-list__empty">{{ 'app.team.empty_active'|trans }}</div> | |||
| {% endif %} | |||
| </div> | |||
| @@ -155,19 +169,19 @@ | |||
| <div class="crud-row__display"> | |||
| <div class="crud-row__info"> | |||
| <span class="crud-row__name">{{ au.user.fullName }}</span> | |||
| <span class="crud-row__meta">({{ au.roleLabel }})</span> | |||
| <span class="crud-row__meta">({{ au.roleLabelKey|trans }})</span> | |||
| </div> | |||
| <div class="crud-row__actions"> | |||
| <button class="crud-row__btn crud-row__btn--restore" | |||
| data-action="unarchive" | |||
| title="Wiederherstellen"> | |||
| <svg viewBox="0 0 16 16" fill="none"><path d="M2 8a6 6 0 1 1 1.5 3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M2 13V9h4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg> | |||
| title="{{ 'app.crud.btn_restore'|trans }}"> | |||
| {% include '_atoms/icon-restore.html.twig' %} | |||
| </button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| {% else %} | |||
| <div class="crud-list__empty">Keine archivierten Teammitglieder.</div> | |||
| <div class="crud-list__empty">{{ 'app.team.empty_archived'|trans }}</div> | |||
| {% endfor %} | |||
| </div> | |||
| @@ -178,7 +192,7 @@ | |||
| <div class="modal-overlay" id="team-modal" hidden> | |||
| <div class="modal-card"> | |||
| <div class="modal-card__header"> | |||
| <h2 class="modal-card__title">Neuen Benutzer einladen</h2> | |||
| <h2 class="modal-card__title">{{ 'app.team.modal_title'|trans }}</h2> | |||
| <button class="modal-card__close" id="team-modal-close" type="button"> | |||
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |||
| <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> | |||
| @@ -191,40 +205,40 @@ | |||
| <div class="modal-card__body"> | |||
| <div class="form-row"> | |||
| <div class="form-field"> | |||
| <label class="form-field__label" for="inv-firstName">Vorname</label> | |||
| <label class="form-field__label" for="inv-firstName">{{ 'app.team.label_first_name'|trans }}</label> | |||
| <input class="input" type="text" id="inv-firstName" autocomplete="off" /> | |||
| </div> | |||
| <div class="form-field"> | |||
| <label class="form-field__label" for="inv-lastName">Nachname</label> | |||
| <label class="form-field__label" for="inv-lastName">{{ 'app.team.label_last_name'|trans }}</label> | |||
| <input class="input" type="text" id="inv-lastName" autocomplete="off" /> | |||
| </div> | |||
| </div> | |||
| <div class="form-field"> | |||
| <label class="form-field__label" for="inv-email">E-Mail</label> | |||
| <label class="form-field__label" for="inv-email">{{ 'app.team.label_email'|trans }}</label> | |||
| <input class="input" type="email" id="inv-email" autocomplete="off" /> | |||
| </div> | |||
| <div class="form-field"> | |||
| <label class="form-field__label">Rolle</label> | |||
| <label class="form-field__label">{{ 'app.team.label_role'|trans }}</label> | |||
| <div class="team-role-selector"> | |||
| <label class="team-role-option"> | |||
| <input type="radio" name="inv-role" value="tracker" /> | |||
| <span class="team-role-option__label">Zeiterfasser</span> | |||
| <span class="team-role-option__label">{{ 'app.team.role_tracker'|trans }}</span> | |||
| </label> | |||
| <label class="team-role-option"> | |||
| <input type="radio" name="inv-role" value="member" checked /> | |||
| <span class="team-role-option__label">Standard-Nutzer</span> | |||
| <span class="team-role-option__label">{{ 'app.team.role_member'|trans }}</span> | |||
| </label> | |||
| <label class="team-role-option"> | |||
| <input type="radio" name="inv-role" value="admin" /> | |||
| <span class="team-role-option__label">Administrator</span> | |||
| <span class="team-role-option__label">{{ 'app.team.role_admin'|trans }}</span> | |||
| </label> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="modal-card__footer"> | |||
| <button class="btn btn-secondary" id="team-modal-cancel" type="button">Abbrechen</button> | |||
| <button class="btn btn-cta" id="team-modal-submit" type="button">Einladung senden</button> | |||
| <button class="btn btn-secondary" id="team-modal-cancel" type="button">{{ 'app.entry.btn_cancel'|trans }}</button> | |||
| <button class="btn btn-cta" id="team-modal-submit" type="button">{{ 'app.team.btn_invite'|trans }}</button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| @@ -234,4 +248,4 @@ | |||
| {% block javascripts %} | |||
| {{ parent() }} | |||
| {{ encore_entry_script_tags('team') }} | |||
| {% endblock %} | |||
| {% endblock %} | |||
| @@ -1,27 +1,15 @@ | |||
| {# templates/timetracking/week.html.twig #} | |||
| {% extends 'base.html.twig' %} | |||
| {# | |||
| Datums-Arrays kommen aus AppExtension (single source of truth). | |||
| Skalare Strings kommen aus messages.de.yaml via |trans. | |||
| #} | |||
| {% from '_macros/helpers.html.twig' import smart_date %} | |||
| {% set months = deMonths() %} | |||
| {% set monthsShort = deMonthsShort() %} | |||
| {% set weekdays = deWeekdays() %} | |||
| {% set weekdaysShort= deWeekdaysShort() %} | |||
| {% block title %} | |||
| {% set monthName = months[currentDate|date('n') - 1] %} | |||
| {% set activStr = currentDate|date('Y-m-d') %} | |||
| {% 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[currentDate|date('N') - 1] }}, {{ currentDate|date('j') }}. {{ monthName }} | |||
| {% endif %} | |||
| {{ smart_date(currentDate, todayStr, tomorrowStr, yesterdayStr, months, weekdays) }} | |||
| {% endblock %} | |||
| {% block body %} | |||
| @@ -76,6 +64,8 @@ window.TT = { | |||
| 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 }}, | |||
| noteShow: {{ 'app.entry.note_show'|trans|json_encode|raw }}, | |||
| noteHide: {{ 'app.entry.note_hide'|trans|json_encode|raw }}, | |||
| }, | |||
| }; | |||
| </script> | |||
| @@ -143,7 +133,7 @@ window.TT = { | |||
| {# 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 | |||
| {{ 'app.entry.note_show'|trans }} | |||
| </button> | |||
| </div> | |||
| @@ -160,7 +150,7 @@ window.TT = { | |||
| {% 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-count">{{ timeEntries|length }} {{ timeEntries|length == 1 ? 'app.entry.count_one'|trans : 'app.entry.count_other'|trans }}</span> | |||
| <span class="entry-list__summary-sep">·</span> | |||
| <span class="entry-list__summary-total">{{ totalDuration }}</span> | |||
| <span class="entry-list__summary-arrow">▾</span> | |||
| @@ -1,13 +1,54 @@ | |||
| # translations/messages.de.yaml | |||
| app: | |||
| csrf_error: "Ungültiger Sicherheitstoken. Bitte lade die Seite neu." | |||
| error: | |||
| not_found: "Nicht gefunden" | |||
| access_denied: "Zugriff verweigert" | |||
| project_not_found: "Projekt nicht gefunden" | |||
| client_not_found: "Kunde nicht gefunden" | |||
| name_required: "Name ist erforderlich" | |||
| daily_limit: "Du kannst nicht mehr als 24 Stunden pro Tag loggen." | |||
| invalid_role: "Ungültige Rolle." | |||
| invalid_hex: "Ungültiger Hex-Farbwert." | |||
| generic: "Ein Fehler ist aufgetreten. Bitte versuche es erneut." | |||
| email_taken: "Diese E-Mail-Adresse wird bereits verwendet." | |||
| validation: | |||
| email_required: "E-Mail ist erforderlich." | |||
| email_invalid: "Keine gültige E-Mail-Adresse." | |||
| first_name_required: "Vorname ist erforderlich." | |||
| last_name_required: "Nachname ist erforderlich." | |||
| company_name_required: "Firmenname ist erforderlich." | |||
| password_min_length: "Passwort muss mindestens 8 Zeichen lang sein." | |||
| password_mismatch: "Passwörter stimmen nicht überein." | |||
| password_current_required: "Aktuelles Passwort ist erforderlich." | |||
| password_current_wrong: "Das aktuelle Passwort ist falsch." | |||
| password_new_min_length: "Das neue Passwort muss mindestens 8 Zeichen haben." | |||
| greeting: | |||
| morning: "Guten Morgen" | |||
| noon: "Mahlzeit" | |||
| afternoon: "Guten Tag" | |||
| evening: "Guten Abend" | |||
| night: "Gute Nacht" | |||
| role: | |||
| admin: "Administrator" | |||
| member: "Standard" | |||
| tracker: "Zeiterfasser" | |||
| date: | |||
| today: "Heute" | |||
| tomorrow: "Morgen" | |||
| yesterday: "Gestern" | |||
| week_label: "Kalenderwoche" | |||
| week_short: "KW" | |||
| nav: | |||
| menu_open: "Menü öffnen" | |||
| week_view: "Wochenansicht" | |||
| prev_week: "Vorherige Woche" | |||
| next_week: "Nächste Woche" | |||
| month_view: "Monatsansicht öffnen/schließen" | |||
| @@ -48,6 +89,11 @@ app: | |||
| 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?" | |||
| note_show: "+ Bemerkung hinzufügen" | |||
| note_hide: "× Bemerkung ausblenden" | |||
| invoiced_title: "Abgerechnet – Bearbeiten nicht möglich" | |||
| count_one: "Eintrag" | |||
| count_other: "Einträge" | |||
| service: | |||
| billable: "Verrechenbar" | |||
| @@ -60,11 +106,35 @@ app: | |||
| empty: "Noch keine Leistungen angelegt." | |||
| crud: | |||
| label_name: "Name" | |||
| label_note: "Bemerkung" | |||
| tab_active: "Aktiv" | |||
| tab_archived: "Archiviert" | |||
| btn_restore: "Wiederherstellen" | |||
| label_name: "Name" | |||
| label_note: "Bemerkung" | |||
| label_rate: "Stundensatz" | |||
| label_client: "Kunde" | |||
| tab_active: "Aktiv" | |||
| tab_archived: "Archiviert" | |||
| btn_restore: "Wiederherstellen" | |||
| select_ph: "Bitte wählen" | |||
| confirm_delete: "Wirklich löschen?" | |||
| confirm_archive: "Dieser Eintrag hat abhängige Zeiteinträge und kann nicht gelöscht werden.\nStattdessen archivieren?" | |||
| error_save: "Fehler beim Speichern." | |||
| error_delete: "Fehler beim Löschen." | |||
| error_archive: "Fehler beim Archivieren." | |||
| error_restore: "Fehler beim Wiederherstellen." | |||
| error_no_name: "Bitte einen Namen eingeben." | |||
| project_singular: "Projekt" | |||
| project_plural: "Projekte" | |||
| client: | |||
| page_title: "Kunden" | |||
| btn_new: "Neuer Kunde" | |||
| placeholder_name: "Kundenname" | |||
| empty: "Noch keine Kunden angelegt." | |||
| project: | |||
| page_title: "Projekte" | |||
| btn_new: "Neues Projekt" | |||
| placeholder_name: "Projektname" | |||
| empty: "Noch keine Projekte angelegt." | |||
| report: | |||
| page_title: "Reports: Zeiten" | |||
| @@ -72,6 +142,27 @@ app: | |||
| tab_times: "Zeiten" | |||
| tab_projects: "Projekte" | |||
| toolbar_filter: "Filtern/Gruppieren" | |||
| export_excel: "Als Excel exportieren" | |||
| export_csv: "Als CSV exportieren" | |||
| export_pdf: "Als PDF exportieren" | |||
| print: "Drucken" | |||
| export_col_date: "Datum" | |||
| export_col_client: "Kunde" | |||
| export_col_project: "Projekt" | |||
| export_col_service: "Leistung" | |||
| export_col_user: "Benutzer" | |||
| export_col_note: "Bemerkung" | |||
| export_col_hours: "Stunden" | |||
| export_col_revenue: "Umsatz" | |||
| export_col_invoiced: "Abgerechnet" | |||
| export_col_invoiced_short: "Abger." | |||
| export_sum: "Summe" | |||
| export_yes: "Ja" | |||
| export_no: "Nein" | |||
| export_title: "Zeitreport – %account%" | |||
| export_created_at: "Erstellt am %date%" | |||
| export_entry_count: "%count% Einträge" | |||
| export_entry_count_one: "1 Eintrag" | |||
| toolbar_edit: "Einträge bearbeiten" | |||
| col_date: "Datum" | |||
| col_client: "Kunde" | |||
| @@ -112,6 +203,85 @@ app: | |||
| invoiced_yes: "Ja" | |||
| invoiced_no: "Nein" | |||
| filter_neg: "Negativfilter" | |||
| user_fallback: "Benutzer #%id%" | |||
| team: | |||
| page_title: "Team" | |||
| btn_new: "Neuer Benutzer" | |||
| btn_invite: "Einladung senden" | |||
| btn_remove: "Entfernen" | |||
| btn_revoke_invite: "Einladung zurückziehen" | |||
| invite_pending: "Einladung ausstehend" | |||
| modal_title: "Neuen Benutzer einladen" | |||
| label_first_name: "Vorname" | |||
| label_last_name: "Nachname" | |||
| label_email: "E-Mail" | |||
| label_role: "Rolle" | |||
| role_tracker: "Zeiterfasser" | |||
| role_member: "Standard-Nutzer" | |||
| role_admin: "Administrator" | |||
| role_change_disabled: "Eigene Administrator-Rolle kann nicht geändert werden." | |||
| empty_active: "Noch keine aktiven Teammitglieder." | |||
| empty_archived: "Keine archivierten Teammitglieder." | |||
| confirm_delete: "Wirklich entfernen?" | |||
| confirm_archive: "Dieser Benutzer hat Zeiteinträge und kann nicht gelöscht werden.\nStattdessen archivieren?" | |||
| confirm_revoke_invite: "Einladung zurückziehen?" | |||
| error_save: "Fehler beim Speichern." | |||
| error_delete: "Fehler beim Löschen." | |||
| error_archive: "Fehler beim Archivieren." | |||
| error_restore: "Fehler beim Wiederherstellen." | |||
| error_generic: "Fehler" | |||
| already_member: "Diese Person ist bereits Mitglied dieses Accounts." | |||
| cannot_archive_self: "Du kannst dich nicht selbst archivieren." | |||
| cannot_archive_owner: "Der Kontoinhaber kann nicht archiviert werden." | |||
| cannot_remove_self: "Du kannst dich nicht selbst entfernen." | |||
| cannot_remove_owner: "Der Kontoinhaber kann nicht entfernt werden." | |||
| cannot_change_own_role: "Du kannst deine eigene Administratoren-Rolle nicht ändern." | |||
| account: | |||
| page_title_account: "Account" | |||
| page_title_user: "Mein Benutzer" | |||
| tab_account: "Account" | |||
| tab_user: "Mein Benutzer" | |||
| label_company_name: "Firmenname" | |||
| hint_subdomain: "Subdomain: %subdomain% — ändert sich nicht." | |||
| label_interval: "Zeitintervall" | |||
| hint_interval: "Auf welche Einheit werden erfasste Zeiten aufgerundet." | |||
| label_color: "Hauptfarbe" | |||
| hint_color: "Hex-Farbe für das Standard-Theme aller Benutzer. Standard: #3a7bbf" | |||
| label_first_name: "Vorname" | |||
| label_last_name: "Nachname" | |||
| label_email: "E-Mail" | |||
| label_password: "Passwort" | |||
| label_current_password: "Aktuelles Passwort" | |||
| label_new_password: "Neues Passwort" | |||
| label_password_repeat: "Wiederholen" | |||
| label_appearance: "Darstellung" | |||
| theme_standard: "Standard" | |||
| theme_standard_desc: "Volle Navigation, alle Felder sichtbar" | |||
| theme_minimal: "Minimal" | |||
| theme_minimal_desc: "Ablenkungsfreie Ansicht, Hamburger-Menü" | |||
| label_owner: "Besitzer des Accounts" | |||
| hint_owner: "Der Besitzer des Accounts ist für die Verwaltung der Zahlungsdaten zuständig. Nur er kann den Account kündigen." | |||
| invalid_hex: "Ungültiger Hex-Wert. Beispiel: #3a7bbf" | |||
| saved: "Gespeichert." | |||
| saved_reloading: "Gespeichert. Seite wird neu geladen…" | |||
| owner_changed: "Kontoinhaber geändert. Seite wird neu geladen…" | |||
| owner_confirm: "%name% zum neuen Kontoinhaber machen?" | |||
| theme_changed: "Darstellung geändert." | |||
| password_mismatch: "Die Passwörter stimmen nicht überein." | |||
| change_label: "ändern" | |||
| cancel_label: "abbrechen" | |||
| error_generic: "Fehler" | |||
| superadmin_only: "Nur der aktuelle Kontoinhaber kann diese Funktion nutzen." | |||
| already_owner: "Du bist bereits Kontoinhaber." | |||
| new_owner_must_be_admin: "Der Benutzer muss aktiver Administrator sein." | |||
| deactivated: "Dein Konto wurde deaktiviert." | |||
| deactivated_api: "Konto deaktiviert." | |||
| interval_minutes: "Minuten" | |||
| interval_quarter: "Viertelstunde" | |||
| interval_half: "Halbe Stunde" | |||
| interval_hour: "Stunde" | |||
| forgot_password: | |||
| page_title: "Passwort vergessen – spawntree" | |||
| @@ -154,6 +324,12 @@ app: | |||
| label_password: "Passwort" | |||
| label_password_repeat: "Wiederholen" | |||
| btn_submit: "Konto erstellen" | |||
| sending: "Wird gesendet …" | |||
| error_unknown: "Unbekannter Fehler." | |||
| error_connection: "Verbindungsfehler. Bitte versuche es erneut." | |||
| success_title: "Fast geschafft!" | |||
| success_text: "Wir haben eine Bestätigungs-E-Mail an %email% geschickt." | |||
| success_hint: "Bitte klicke auf den Link in der E-Mail um dein Konto zu aktivieren. Der Link ist 24 Stunden gültig." | |||
| already_registered: "Bereits registriert?" | |||
| link_login: "Zur Anmeldung" | |||
| @@ -172,6 +348,9 @@ app: | |||
| invite_error: | |||
| page_title: "Fehler – Einladungslink" | |||
| title: "Ungültiger Link" | |||
| link_invalid: "Dieser Einladungslink ist ungültig." | |||
| link_expired: "Dieser Einladungslink ist abgelaufen (gültig 7 Tage)." | |||
| link_wrong_account: "Dieser Einladungslink gehört zu einem anderen Account." | |||
| set_password: | |||
| page_title: "Passwort festlegen – %name%" | |||
| @@ -179,9 +358,12 @@ app: | |||
| label_password: "Passwort" | |||
| label_password_repeat: "Wiederholen" | |||
| btn_submit: "Passwort speichern & anmelden" | |||
| error_too_short: "Das Passwort muss mindestens 8 Zeichen haben." | |||
| error_mismatch: "Die Passwörter stimmen nicht überein." | |||
| email: | |||
| confirm: | |||
| subject: "Bitte bestätige deine Registrierung – spawntree Timetracker" | |||
| greeting: "Hallo %name%," | |||
| body: "bitte bestätige deine Registrierung für %company% mit einem Klick auf den Button." | |||
| btn: "E-Mail bestätigen" | |||
| @@ -189,6 +371,7 @@ app: | |||
| ignore: "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren." | |||
| notify: | |||
| subject: "[Timetracker] Neue Registrierung: %name%" | |||
| title: "Neue Registrierung im Timetracker" | |||
| col_company: "Firma" | |||
| col_slug: "Slug" | |||
| @@ -197,6 +380,7 @@ app: | |||
| col_date: "Datum" | |||
| password_reset: | |||
| subject: "Passwort zurücksetzen – %account%" | |||
| greeting: "Hallo %name%," | |||
| body: "du hast ein Zurücksetzen deines Passworts angefordert. Klicke auf den Button, um ein neues Passwort festzulegen." | |||
| btn: "Passwort zurücksetzen" | |||
| @@ -204,12 +388,14 @@ app: | |||
| ignore: "Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren." | |||
| welcome: | |||
| subject: "Willkommen beim spawntree Timetracker!" | |||
| greeting: "Hallo %name%," | |||
| body: "dein Konto für %company% ist jetzt aktiv. Los geht's!" | |||
| btn: "Zum Timetracker →" | |||
| url_label: "Deine URL:" | |||
| invite: | |||
| subject: "Einladung zu %company%" | |||
| title: "Du wurdest zu %company% eingeladen" | |||
| greeting: "Hallo %name%," | |||
| role_added_pre: "du wurdest als" | |||
| @@ -220,3 +406,15 @@ app: | |||
| cta: "Klicke auf den Button, um dein Passwort festzulegen und loszulegen. Der Link ist 7 Tage gültig." | |||
| btn: "Passwort festlegen →" | |||
| ignore: "Wenn du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren." | |||
| registration: | |||
| email_taken: "Diese E-Mail-Adresse wird bereits verwendet." | |||
| confirm_invalid: "Ungültiger Bestätigungslink." | |||
| confirm_expired: "Dieser Link ist abgelaufen (gültig 24 Stunden). Bitte registriere dich erneut." | |||
| home: | |||
| title: "spawntree Timetracker" | |||
| btn_start: "Kostenlos starten" | |||
| hero_title: "Zeiterfassung, die nicht nervt." | |||
| hero_sub: "Einfach, schnell, ohne Overhead. Dein Team, deine Projekte, deine Zeit." | |||
| btn_register: "Jetzt registrieren →" | |||