| @@ -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 ### | ###> symfony/framework-bundle ### | ||||
| APP_ENV=dev | APP_ENV=dev | ||||
| APP_SECRET= | |||||
| APP_SECRET=f19f2bcb34a48e20e66302a0e88408a9 | |||||
| APP_SHARE_DIR=var/share | APP_SHARE_DIR=var/share | ||||
| ###< symfony/framework-bundle ### | ###< symfony/framework-bundle ### | ||||
| @@ -37,7 +37,7 @@ DEFAULT_URI=http://localhost | |||||
| ###< doctrine/doctrine-bundle ### | ###< doctrine/doctrine-bundle ### | ||||
| DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=10.11.2-MariaDB&charset=utf8mb4" | DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=10.11.2-MariaDB&charset=utf8mb4" | ||||
| APP_DOMAIN=testtimetracking.ddev.site | |||||
| APP_DOMAIN= | |||||
| # ── Mailer ──────────────────────────────────────────────────────────────────── | # ── Mailer ──────────────────────────────────────────────────────────────────── | ||||
| # Lokal (DDEV Mailpit): smtp://127.0.0.1:1025 | # 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 | MAILER_DSN=smtp://127.0.0.1:1025 | ||||
| # Benachrichtigung bei Neuanmeldung | # 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 | - **Backend**: Symfony 7, PHP 8.2, Doctrine ORM, MariaDB | ||||
| - **Frontend**: Twig, SCSS (Webpack Encore), Vanilla JS (keine jQuery, kein Framework) | - **Frontend**: Twig, SCSS (Webpack Encore), Vanilla JS (keine jQuery, kein Framework) | ||||
| - **SCSS-Struktur**: Atoms → Components → Sections → Themes (BEM-ähnlich) | - **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 | - **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 | ## Multi-Mandanten-Architektur | ||||
| ### Konzept | ### 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` | - Jeder Account bekommt eine **eigene Tenant-Datenbank**: `db_spawntree` | ||||
| - Die **Central-DB** (`db`) enthält accountübergreifende Daten: User, Account, AccountUser, Token | - Die **Central-DB** (`db`) enthält accountübergreifende Daten: User, Account, AccountUser, Token | ||||
| - Die **Tenant-DB** enthält accountspezifische Daten: Client, Project, Service, TimeEntry | - 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 | ### Reports | ||||
| - `GET /reports/times` → `report_times` | - `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) | ### Team (nur Admins) | ||||
| - `GET /team` → `team_index` | - `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 | - `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) | - `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 | - `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 | - `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 | ## Was noch fehlt / TODO | ||||
| - [ ] Filter auf Report-Seite (Datumsbereich, Projekt, Service, User) | - [ ] Filter auf Report-Seite (Datumsbereich, Projekt, Service, User) | ||||
| - [ ] Export (CSV / PDF) | |||||
| - [x] Export (Excel / CSV / PDF) – `ReportExportService`, Icons in Toolbar | |||||
| - [ ] Timer-Funktion (Live-Zeiterfassung) | - [ ] Timer-Funktion (Live-Zeiterfassung) | ||||
| - [ ] Wochenübersicht mit Summen pro Tag (im Wochenkalender) | - [ ] Wochenübersicht mit Summen pro Tag (im Wochenkalender) | ||||
| - [ ] E-Mail-Konfiguration für Produktivbetrieb (aktuell DDEV Mailpit) | - [ ] E-Mail-Konfiguration für Produktivbetrieb (aktuell DDEV Mailpit) | ||||
| @@ -379,10 +383,10 @@ bash 2-update-tenant-db.sh | |||||
| --- | --- | ||||
| ## DDEV-Konfiguration | ## 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` | - MariaDB: User `db`, Passwort `db`, Central-DB `db` | ||||
| - `.env`: `DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=10.11.2-MariaDB&charset=utf8mb4"` | - `.env`: `DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=10.11.2-MariaDB&charset=utf8mb4"` | ||||
| - `APP_DOMAIN=testtimetracking.ddev.site:8456` (für Subdomain-Erkennung und E-Mail-Links) | |||||
| - `APP_DOMAIN=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 | // 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 { | class WeekCalendar { | ||||
| constructor() { | constructor() { | ||||
| @@ -19,9 +18,9 @@ class WeekCalendar { | |||||
| this.today = new Date(); | this.today = new Date(); | ||||
| this.today.setHours(0, 0, 0, 0); | 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; | if (!this.nav) return; | ||||
| this.init(); | this.init(); | ||||
| @@ -69,7 +68,7 @@ class WeekCalendar { | |||||
| window.history.pushState({}, '', `/week/${this.formatDate(this.getMonday(this.activeDate))}`); | window.history.pushState({}, '', `/week/${this.formatDate(this.getMonday(this.activeDate))}`); | ||||
| window.entryManager?.loadEntriesForDate(this.formatDate(this.activeDate)); | window.entryManager?.loadEntriesForDate(this.formatDate(this.activeDate)); | ||||
| }, 180); | |||||
| }, FADE_MS); | |||||
| } | } | ||||
| renderWeekDays() { | renderWeekDays() { | ||||
| @@ -77,12 +76,11 @@ class WeekCalendar { | |||||
| this.daysContainer.innerHTML = ''; | this.daysContainer.innerHTML = ''; | ||||
| for (let i = 0; i < 7; i++) { | for (let i = 0; i < 7; i++) { | ||||
| const d = new Date(monday); | |||||
| const d = new Date(monday); | |||||
| d.setDate(d.getDate() + i); | d.setDate(d.getDate() + i); | ||||
| const isActive = this.isSameDay(d, this.activeDate); | const isActive = this.isSameDay(d, this.activeDate); | ||||
| const isToday = this.isSameDay(d, this.today); | const isToday = this.isSameDay(d, this.today); | ||||
| // Führungsnull: padStart(2, '0') | |||||
| const dayNum = String(d.getDate()).padStart(2, '0'); | const dayNum = String(d.getDate()).padStart(2, '0'); | ||||
| const monthShort = this.monthsShort[d.getMonth()] ?? ''; | const monthShort = this.monthsShort[d.getMonth()] ?? ''; | ||||
| @@ -93,8 +91,8 @@ class WeekCalendar { | |||||
| + (isToday ? ' week-nav__day--today' : ''); | + (isToday ? ' week-nav__day--today' : ''); | ||||
| a.dataset.date = this.formatDate(d); | a.dataset.date = this.formatDate(d); | ||||
| a.innerHTML = ` | 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); | this.daysContainer.appendChild(a); | ||||
| } | } | ||||
| @@ -120,9 +118,8 @@ class WeekCalendar { | |||||
| const tomorrow = new Date(this.today); tomorrow.setDate(this.today.getDate() + 1); | const tomorrow = new Date(this.today); tomorrow.setDate(this.today.getDate() + 1); | ||||
| const yesterday = new Date(this.today); yesterday.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] ?? ''; | const weekday = this.weekdays[isoIdx] ?? ''; | ||||
| let prefix; | let prefix; | ||||
| @@ -149,7 +146,6 @@ class WeekCalendar { | |||||
| this.monthEl = document.createElement('div'); | this.monthEl = document.createElement('div'); | ||||
| this.monthEl.className = 'month-calendar month-calendar--hidden'; | this.monthEl.className = 'month-calendar month-calendar--hidden'; | ||||
| // Align calendar's right edge with the calendar icon button's right edge | |||||
| const calRect = this.calBtn.getBoundingClientRect(); | const calRect = this.calBtn.getBoundingClientRect(); | ||||
| const headerRect = this.header.getBoundingClientRect(); | const headerRect = this.header.getBoundingClientRect(); | ||||
| this.monthEl.style.right = `${Math.max(0, headerRect.right - calRect.right)}px`; | this.monthEl.style.right = `${Math.max(0, headerRect.right - calRect.right)}px`; | ||||
| @@ -167,7 +163,9 @@ class WeekCalendar { | |||||
| if (!this.monthEl) return; | if (!this.monthEl) return; | ||||
| this.monthEl.classList.remove('month-calendar--visible'); | this.monthEl.classList.remove('month-calendar--visible'); | ||||
| this.monthEl.classList.add('month-calendar--hidden'); | 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.monthOpen = false; | ||||
| this.calBtn.classList.remove('week-nav__cal--active'); | 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')}"> | <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> | <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> | </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')}"> | <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> | <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> | </button> | ||||
| @@ -206,7 +204,7 @@ class WeekCalendar { | |||||
| </div> | </div> | ||||
| <div class="month-calendar__grid"> | <div class="month-calendar__grid"> | ||||
| <div class="month-calendar__weekdays"> | <div class="month-calendar__weekdays"> | ||||
| ${this.weekdaysShort.map(d => `<span>${d}</span>`).join('')} | |||||
| ${this.weekdaysShort.map(d => `<span>${esc(d)}</span>`).join('')} | |||||
| </div> | </div> | ||||
| <div class="month-calendar__days">`; | <div class="month-calendar__days">`; | ||||
| @@ -1,29 +1,31 @@ | |||||
| // assets/scripts/crud.js | // 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 ?? ''; | const api = window.CRUD?.apiBase ?? ''; | ||||
| // ── Hilfsfunktionen ─────────────────────────────────────────────────────────── | |||||
| const t = createTranslator('CRUD'); | |||||
| // ── Hilfsfunktionen ────────────────────────────────────────────────────────── | |||||
| function buildClientOptions(selectedId = null) { | function buildClientOptions(selectedId = null) { | ||||
| const clients = window.CRUD?.clients ?? []; | const clients = window.CRUD?.clients ?? []; | ||||
| let html = '<option value="">Bitte wählen</option>'; | |||||
| let html = `<option value="">${t('selectPh')}</option>`; | |||||
| clients.forEach(c => { | clients.forEach(c => { | ||||
| const sel = String(c.id) === String(selectedId) ? ' selected' : ''; | 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; | return html; | ||||
| } | } | ||||
| function rowPrefix() { | function rowPrefix() { | ||||
| // Ermittelt den Entitätstyp aus der URL | |||||
| if (location.pathname.includes('/clients')) return 'client'; | if (location.pathname.includes('/clients')) return 'client'; | ||||
| if (location.pathname.includes('/projects')) return 'project'; | if (location.pathname.includes('/projects')) return 'project'; | ||||
| if (location.pathname.includes('/services')) return 'service'; | if (location.pathname.includes('/services')) return 'service'; | ||||
| return 'row'; | return 'row'; | ||||
| } | } | ||||
| // ── Create-Formular ─────────────────────────────────────────────────────────── | |||||
| // ── Create-Formular ────────────────────────────────────────────────────────── | |||||
| function initCreateForm() { | function initCreateForm() { | ||||
| const btnNew = document.getElementById('btn-new'); | const btnNew = document.getElementById('btn-new'); | ||||
| @@ -47,8 +49,7 @@ function initCreateForm() { | |||||
| } | } | ||||
| function resetCreateForm() { | function resetCreateForm() { | ||||
| const fields = ['create-name', 'create-note']; | |||||
| fields.forEach(id => { | |||||
| ['create-name', 'create-note'].forEach(id => { | |||||
| const el = document.getElementById(id); | const el = document.getElementById(id); | ||||
| if (el) el.value = ''; | if (el) el.value = ''; | ||||
| }); | }); | ||||
| @@ -62,10 +63,12 @@ function resetCreateForm() { | |||||
| async function createEntity() { | async function createEntity() { | ||||
| const name = document.getElementById('create-name')?.value?.trim(); | 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(); | const body = buildCreateBody(); | ||||
| if (btn) btn.disabled = true; | |||||
| try { | try { | ||||
| const res = await fetch(api, { | const res = await fetch(api, { | ||||
| method: 'POST', | method: 'POST', | ||||
| @@ -75,7 +78,7 @@ async function createEntity() { | |||||
| if (!res.ok) { | if (!res.ok) { | ||||
| const err = await res.json().catch(() => ({})); | const err = await res.json().catch(() => ({})); | ||||
| alert(err.error ?? 'Fehler beim Speichern.'); | |||||
| alert(err.error ?? t('errorSave')); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -83,10 +86,10 @@ async function createEntity() { | |||||
| appendRowToList(data); | appendRowToList(data); | ||||
| document.getElementById('crud-create')?.classList.remove('crud-create--visible'); | document.getElementById('crud-create')?.classList.remove('crud-create--visible'); | ||||
| resetCreateForm(); | 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, | note: document.getElementById('create-note')?.value || null, | ||||
| }; | }; | ||||
| // Kunden-spezifisch | |||||
| const rate = document.getElementById('create-rate'); | const rate = document.getElementById('create-rate'); | ||||
| if (rate) body.hourlyRate = rate.value || null; | if (rate) body.hourlyRate = rate.value || null; | ||||
| // Projekt-spezifisch | |||||
| const client = document.getElementById('create-client'); | 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'); | const billable = document.getElementById('create-billable'); | ||||
| if (billable) body.billable = billable.checked; | if (billable) body.billable = billable.checked; | ||||
| return body; | return body; | ||||
| } | } | ||||
| // ── Liste: Event Delegation ──────────────────────────────────────────────────── | |||||
| // ── Liste: Event Delegation ────────────────────────────────────────────────── | |||||
| function initList() { | function initList() { | ||||
| const list = document.getElementById('crud-list'); | const list = document.getElementById('crud-list'); | ||||
| @@ -121,21 +121,20 @@ function initList() { | |||||
| const actionEl = e.target.closest('[data-action]'); | const actionEl = e.target.closest('[data-action]'); | ||||
| if (!actionEl) return; | 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; | 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) { | function openEdit(row) { | ||||
| row.querySelector('.crud-row__display').hidden = true; | row.querySelector('.crud-row__display').hidden = true; | ||||
| @@ -149,29 +148,31 @@ function closeEdit(row) { | |||||
| } | } | ||||
| async function saveEdit(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); | const body = buildEditBody(row); | ||||
| if (saveBtn) saveBtn.disabled = true; | |||||
| try { | try { | ||||
| const res = await fetch(`${api}/${id}`, { | |||||
| const res = await fetch(`${api}/${row.dataset.id}`, { | |||||
| method: 'PATCH', | method: 'PATCH', | ||||
| headers: { 'Content-Type': 'application/json' }, | headers: { 'Content-Type': 'application/json' }, | ||||
| body: JSON.stringify(body), | body: JSON.stringify(body), | ||||
| }); | }); | ||||
| if (!res.ok) { alert('Fehler beim Speichern.'); return; } | |||||
| if (!res.ok) { alert(t('errorSave')); return; } | |||||
| const data = await res.json(); | const data = await res.json(); | ||||
| updateRowDisplay(row, data); | updateRowDisplay(row, data); | ||||
| closeEdit(row); | 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, | note: row.querySelector('.edit-note')?.value || null, | ||||
| }; | }; | ||||
| // Kunden | |||||
| const rate = row.querySelector('.edit-rate'); | const rate = row.querySelector('.edit-rate'); | ||||
| if (rate) body.hourlyRate = rate.value || null; | if (rate) body.hourlyRate = rate.value || null; | ||||
| // Projekt | |||||
| const client = row.querySelector('.edit-client'); | 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'); | const billable = row.querySelector('.edit-billable'); | ||||
| if (billable) body.billable = billable.checked; | if (billable) body.billable = billable.checked; | ||||
| @@ -201,19 +199,14 @@ function updateRowDisplay(row, data) { | |||||
| const metaEl = row.querySelector('.crud-row__meta'); | const metaEl = row.querySelector('.crud-row__meta'); | ||||
| if (nameEl) nameEl.textContent = data.name; | 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; | if (data.clientName && metaEl) metaEl.textContent = data.clientName; | ||||
| // data-Attribute aktualisieren | |||||
| row.dataset.name = data.name; | 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.clientId !== undefined) row.dataset.clientId = data.clientId; | ||||
| if (data.billable !== undefined) row.dataset.billable = data.billable ? '1' : '0'; | 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'); | const editName = row.querySelector('.edit-name'); | ||||
| if (editName) editName.value = data.name; | if (editName) editName.value = data.name; | ||||
| @@ -227,58 +220,54 @@ function updateRowDisplay(row, data) { | |||||
| if (editBillable) editBillable.checked = !!data.billable; | if (editBillable) editBillable.checked = !!data.billable; | ||||
| } | } | ||||
| // ── Delete ──────────────────────────────────────────────────────────────────── | |||||
| // ── Delete ─────────────────────────────────────────────────────────────────── | |||||
| async function deleteRow(row) { | async function deleteRow(row) { | ||||
| if (!confirm('Wirklich löschen?')) return; | |||||
| if (!confirm(t('confirmDelete'))) return; | |||||
| try { | try { | ||||
| const res = await fetch(`${api}/${row.dataset.id}`, { method: 'DELETE' }); | const res = await fetch(`${api}/${row.dataset.id}`, { method: 'DELETE' }); | ||||
| if (res.status === 409) { | 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); | await archiveRow(row); | ||||
| } | } | ||||
| return; | 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 { | } catch { | ||||
| alert('Fehler beim Löschen.'); | |||||
| alert(t('errorDelete')); | |||||
| } | } | ||||
| } | } | ||||
| async function archiveRow(row) { | async function archiveRow(row) { | ||||
| try { | try { | ||||
| const res = await fetch(`${api}/${row.dataset.id}/archive`, { method: 'PATCH' }); | 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.dataset.archived = '1'; | ||||
| row.classList.add('crud-row--archived'); | row.classList.add('crud-row--archived'); | ||||
| updateRowArchivedState(row, true); | updateRowArchivedState(row, true); | ||||
| filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active'); | filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active'); | ||||
| } catch { | } catch { | ||||
| alert('Fehler beim Archivieren.'); | |||||
| alert(t('errorArchive')); | |||||
| } | } | ||||
| } | } | ||||
| async function unarchiveRow(row) { | async function unarchiveRow(row) { | ||||
| try { | try { | ||||
| const res = await fetch(`${api}/${row.dataset.id}/unarchive`, { method: 'PATCH' }); | 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.dataset.archived = '0'; | ||||
| row.classList.remove('crud-row--archived'); | row.classList.remove('crud-row--archived'); | ||||
| updateRowArchivedState(row, false); | updateRowArchivedState(row, false); | ||||
| filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active'); | filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active'); | ||||
| } catch { | } catch { | ||||
| alert('Fehler beim Wiederherstellen.'); | |||||
| alert(t('errorRestore')); | |||||
| } | } | ||||
| } | } | ||||
| @@ -288,16 +277,16 @@ function updateRowArchivedState(row, archived) { | |||||
| if (archived) { | if (archived) { | ||||
| actions.innerHTML = ` | 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> | <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>`; | </button>`; | ||||
| row.querySelector('.crud-row__edit')?.remove(); | row.querySelector('.crud-row__edit')?.remove(); | ||||
| } else { | } else { | ||||
| actions.innerHTML = ` | 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> | <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> | ||||
| <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> | <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>`; | </button>`; | ||||
| } | } | ||||
| @@ -306,8 +295,8 @@ function updateRowArchivedState(row, archived) { | |||||
| function filterByTab(tab) { | function filterByTab(tab) { | ||||
| document.querySelectorAll('#crud-list .crud-row').forEach(row => { | document.querySelectorAll('#crud-list .crud-row').forEach(row => { | ||||
| row.hidden = tab === 'active' | 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) { | function appendRowToList(data) { | ||||
| const list = document.getElementById('crud-list'); | const list = document.getElementById('crud-list'); | ||||
| @@ -337,9 +326,8 @@ function appendRowToList(data) { | |||||
| const html = buildRowHTML(data); | const html = buildRowHTML(data); | ||||
| // Services haben Gruppen → in die richtige Gruppe einfügen | |||||
| if (data.billable !== undefined) { | if (data.billable !== undefined) { | ||||
| const groupLabel = data.billable ? 'Verrechenbar' : 'Nicht-verrechenbar'; | |||||
| const groupLabel = data.billable ? t('groupBillable') : t('groupNotBillable'); | |||||
| let targetGroup = null; | let targetGroup = null; | ||||
| list.querySelectorAll('.crud-list__group').forEach(g => { | list.querySelectorAll('.crud-list__group').forEach(g => { | ||||
| @@ -351,17 +339,14 @@ function appendRowToList(data) { | |||||
| if (targetGroup) { | if (targetGroup) { | ||||
| targetGroup.insertAdjacentHTML('beforeend', html); | targetGroup.insertAdjacentHTML('beforeend', html); | ||||
| } else { | } 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) { | if (!data.billable) { | ||||
| // Nicht-verrechenbar immer ans Ende | |||||
| list.insertAdjacentHTML('beforeend', groupHtml); | list.insertAdjacentHTML('beforeend', groupHtml); | ||||
| } else { | } else { | ||||
| // Verrechenbar vor die erste existierende Gruppe | |||||
| const firstGroup = list.querySelector('.crud-list__group'); | const firstGroup = list.querySelector('.crud-list__group'); | ||||
| firstGroup | firstGroup | ||||
| ? firstGroup.insertAdjacentHTML('beforebegin', groupHtml) | |||||
| : list.insertAdjacentHTML('beforeend', groupHtml); | |||||
| ? firstGroup.insertAdjacentHTML('beforebegin', groupHtml) | |||||
| : list.insertAdjacentHTML('beforeend', groupHtml); | |||||
| } | } | ||||
| } | } | ||||
| } else { | } else { | ||||
| @@ -370,11 +355,7 @@ function appendRowToList(data) { | |||||
| const prefix = rowPrefix(); | const prefix = rowPrefix(); | ||||
| const el = document.getElementById(`${prefix}-${data.id}`); | 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) { | function buildRowHTML(data) { | ||||
| @@ -382,48 +363,45 @@ function buildRowHTML(data) { | |||||
| let metaHtml = ''; | let metaHtml = ''; | ||||
| let editFields = ''; | let editFields = ''; | ||||
| // Kunden | |||||
| if (data.projectCount !== undefined) { | if (data.projectCount !== undefined) { | ||||
| const c = data.projectCount; | 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 = ` | 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"> | <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> | <span style="color:#7a8a9a;font-size:0.875rem">€</span> | ||||
| </div> | </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) { | 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 = ` | 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> | <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) { | if (data.billable !== undefined) { | ||||
| editFields = ` | 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"> | <div class="entry-form__field"> | ||||
| <label style="display:flex;align-items:center;gap:8px;cursor:pointer"> | <label style="display:flex;align-items:center;gap:8px;cursor:pointer"> | ||||
| <input type="checkbox" class="edit-billable" ${data.billable ? 'checked' : ''} /> | <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> | </label> | ||||
| </div> | </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 ` | return ` | ||||
| @@ -431,22 +409,22 @@ function buildRowHTML(data) { | |||||
| id="${prefix}-${data.id}" | id="${prefix}-${data.id}" | ||||
| data-id="${data.id}" | data-id="${data.id}" | ||||
| data-archived="0" | 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.clientId !== undefined ? `data-client-id="${data.clientId}"` : ''} | ||||
| ${data.billable !== undefined ? `data-billable="${data.billable ? '1' : '0'}"` : ''} | ${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__display"> | ||||
| <div class="crud-row__info"> | <div class="crud-row__info"> | ||||
| <span class="crud-row__name">${data.name}</span> | |||||
| <span class="crud-row__name">${esc(data.name)}</span> | |||||
| ${metaHtml} | ${metaHtml} | ||||
| </div> | </div> | ||||
| <div class="crud-row__actions"> | <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> | <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> | ||||
| <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> | <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> | </button> | ||||
| </div> | </div> | ||||
| @@ -456,15 +434,15 @@ function buildRowHTML(data) { | |||||
| <div class="entry-form__grid entry-form__grid--inline"> | <div class="entry-form__grid entry-form__grid--inline"> | ||||
| ${editFields} | ${editFields} | ||||
| <div class="entry-form__actions"> | <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> | ||||
| </div> | </div> | ||||
| </div>`; | </div>`; | ||||
| } | } | ||||
| // ── Init ────────────────────────────────────────────────────────────────────── | |||||
| // ── Init ───────────────────────────────────────────────────────────────────── | |||||
| document.addEventListener('DOMContentLoaded', () => { | document.addEventListener('DOMContentLoaded', () => { | ||||
| initCreateForm(); | initCreateForm(); | ||||
| @@ -1,96 +1,75 @@ | |||||
| // assets/scripts/duration.js | // 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 = { | export const DURATION_CONFIG = { | ||||
| roundToQuarter: true, | 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) { | export function parseDuration(input) { | ||||
| input = String(input).trim(); | input = String(input).trim(); | ||||
| if (!input || input === '0' || input === '0:00') return 0; | 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)) { | if (/^\d+\s+\d+$/.test(input)) { | ||||
| const parts = input.split(/\s+/).map(Number); | 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(':')) { | 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; | return h * 60 + m; | ||||
| } | } | ||||
| // "1,75" oder "1.75" → Dezimalstunden | |||||
| // "1,75" oder "1.75" -> Dezimalstunden | |||||
| if (input.includes(',') || input.includes('.')) { | if (input.includes(',') || input.includes('.')) { | ||||
| const hours = parseFloat(input.replace(',', '.')); | const hours = parseFloat(input.replace(',', '.')); | ||||
| return isNaN(hours) ? 0 : Math.round(hours * 60); | 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; | return isNaN(hours) ? 0 : hours * 60; | ||||
| } | } | ||||
| // ── Rounding ────────────────────────────────────────────────────────────────── | |||||
| /** | |||||
| * Rundet Minuten auf die nächste Viertelstunde auf. | |||||
| * 0 bleibt 0 (Stopp). | |||||
| */ | |||||
| export function roundToQuarter(minutes) { | export function roundToQuarter(minutes) { | ||||
| if (!DURATION_CONFIG.roundToQuarter) return minutes; | if (!DURATION_CONFIG.roundToQuarter) return minutes; | ||||
| if (minutes === 0) return 0; | 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; | return Math.ceil(minutes / interval) * interval; | ||||
| } | } | ||||
| // ── Formatter ───────────────────────────────────────────────────────────────── | |||||
| export function formatMinutes(minutes) { | export function formatMinutes(minutes) { | ||||
| const h = Math.floor(minutes / 60); | const h = Math.floor(minutes / 60); | ||||
| const m = minutes % 60; | const m = minutes % 60; | ||||
| return `${h}:${String(m).padStart(2, '0')}`; | 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() { | export function initDurationBlurHandler() { | ||||
| document.addEventListener('blur', e => { | document.addEventListener('blur', e => { | ||||
| if (!(e.target instanceof Element)) return; | if (!(e.target instanceof Element)) return; | ||||
| if (!e.target.matches('#create-duration, .edit-duration')) 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); | 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 | // 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_PROJECT_KEY = 'tt_last_project_id'; | ||||
| const LAST_SERVICE_KEY = 'tt_last_service_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>`; | 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) { | function buildProjectOptions(selectedId = null) { | ||||
| const groups = {}; | const groups = {}; | ||||
| @@ -19,10 +22,10 @@ function buildProjectOptions(selectedId = null) { | |||||
| let html = `<option value="">${t('selectPh')}</option>`; | let html = `<option value="">${t('selectPh')}</option>`; | ||||
| for (const [client, projects] of Object.entries(groups)) { | for (const [client, projects] of Object.entries(groups)) { | ||||
| html += `<optgroup label="${client}">`; | |||||
| html += `<optgroup label="${esc(client)}">`; | |||||
| projects.forEach(p => { | projects.forEach(p => { | ||||
| const sel = String(p.id) === String(selectedId) ? ' selected' : ''; | 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>'; | html += '</optgroup>'; | ||||
| } | } | ||||
| @@ -35,36 +38,33 @@ function buildServiceOptions(selectedId = null) { | |||||
| let html = `<option value="">${t('selectPh')}</option>`; | 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' : ''; | 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>'; | 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; | return html; | ||||
| } | } | ||||
| // ── Row HTML ───────────────────────────────────────────────────────────────── | |||||
| function buildEntryRowHTML(entry, animate = false) { | 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 invoiced = !!entry.invoiced; | ||||
| const actionsHtml = 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__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"> | <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> | <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> | ||||
| @@ -78,7 +78,7 @@ function buildEntryRowHTML(entry, animate = false) { | |||||
| <label class="entry-form__label">${t('labelDuration')}</label> | <label class="entry-form__label">${t('labelDuration')}</label> | ||||
| <div class="entry-form__field"> | <div class="entry-form__field"> | ||||
| <input type="text" class="input input--sm edit-duration" | <input type="text" class="input input--sm edit-duration" | ||||
| value="${entry.durationFormatted}" autocomplete="off" /> | |||||
| value="${esc(entry.durationFormatted)}" autocomplete="off" /> | |||||
| <div class="duration-help"> | <div class="duration-help"> | ||||
| <span class="duration-help__icon">?</span> | <span class="duration-help__icon">?</span> | ||||
| <span class="duration-help__hint">${t('durationHint')}</span> | <span class="duration-help__hint">${t('durationHint')}</span> | ||||
| @@ -91,7 +91,7 @@ function buildEntryRowHTML(entry, animate = false) { | |||||
| </div> | </div> | ||||
| <label class="entry-form__label">${t('labelNote')}</label> | <label class="entry-form__label">${t('labelNote')}</label> | ||||
| <div class="entry-form__field"> | <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> | ||||
| <div class="entry-form__actions"> | <div class="entry-form__actions"> | ||||
| <button type="button" class="btn btn-primary" data-action="save">${t('btnSave')}</button> | <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-duration="${entry.duration}" | ||||
| data-project-id="${entry.projectId}" | data-project-id="${entry.projectId}" | ||||
| data-service-id="${entry.serviceId ?? ''}" | data-service-id="${entry.serviceId ?? ''}" | ||||
| data-note="${(entry.note ?? '').replace(/"/g, '"')}" | |||||
| data-note="${esc(entry.note ?? '')}" | |||||
| data-invoiced="${invoiced ? 'true' : 'false'}"> | data-invoiced="${invoiced ? 'true' : 'false'}"> | ||||
| <div class="entry-row__display"> | <div class="entry-row__display"> | ||||
| <div class="entry-row__info"> | <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} | ${notePart} | ||||
| </div> | </div> | ||||
| <div class="entry-row__actions"> | <div class="entry-row__actions"> | ||||
| @@ -123,6 +123,23 @@ function buildEntryRowHTML(entry, animate = false) { | |||||
| </div>`; | </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 { | class EntryManager { | ||||
| constructor() { | constructor() { | ||||
| this.list = document.getElementById('entry-list'); | this.list = document.getElementById('entry-list'); | ||||
| @@ -133,13 +150,8 @@ class EntryManager { | |||||
| const cp = document.getElementById('create-project'); | const cp = document.getElementById('create-project'); | ||||
| const cs = document.getElementById('create-service'); | 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) { | if (cp) { | ||||
| const lastProject = getLastProject(); | const lastProject = getLastProject(); | ||||
| @@ -182,13 +194,13 @@ class EntryManager { | |||||
| return; | return; | ||||
| } | } | ||||
| // Klick auf Anzeige-Bereich (kein Button) → Edit öffnen | |||||
| if (e.target.closest('.entry-row__display') && row.dataset.invoiced !== 'true') { | if (e.target.closest('.entry-row__display') && row.dataset.invoiced !== 'true') { | ||||
| this.openEdit(row); | this.openEdit(row); | ||||
| } | } | ||||
| } | } | ||||
| async createEntry() { | async createEntry() { | ||||
| const btn = document.getElementById('btn-create'); | |||||
| const durationRaw = document.getElementById('create-duration')?.value ?? '0:00'; | const durationRaw = document.getElementById('create-duration')?.value ?? '0:00'; | ||||
| const projectId = document.getElementById('create-project')?.value; | const projectId = document.getElementById('create-project')?.value; | ||||
| const serviceId = document.getElementById('create-service')?.value; | const serviceId = document.getElementById('create-service')?.value; | ||||
| @@ -196,36 +208,27 @@ class EntryManager { | |||||
| if (!projectId) { alert(t('errorNoProject')); return; } | 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 { | try { | ||||
| const res = await fetch('/api/entries', { | const res = await fetch('/api/entries', { | ||||
| method: 'POST', | method: 'POST', | ||||
| headers: { 'Content-Type': 'application/json' }, | headers: { 'Content-Type': 'application/json' }, | ||||
| body: JSON.stringify({ | body: JSON.stringify({ | ||||
| date: window.TT.activeDate, | 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, | note: note || null, | ||||
| }), | }), | ||||
| }); | }); | ||||
| if (!res.ok) { | if (!res.ok) { | ||||
| const err = await res.json().catch(() => ({})); | const err = await res.json().catch(() => ({})); | ||||
| console.error('API Fehler:', res.status, err); | |||||
| alert(t('errorSave') + (err.error ? `\n${err.error}` : '')); | alert(t('errorSave') + (err.error ? `\n${err.error}` : '')); | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -234,10 +237,10 @@ class EntryManager { | |||||
| this.addEntryToDOM(data.entry); | this.addEntryToDOM(data.entry); | ||||
| this.updateTotal(data.totalDuration); | this.updateTotal(data.totalDuration); | ||||
| this.resetCreateForm(); | this.resetCreateForm(); | ||||
| } catch (err) { | |||||
| console.error('Netzwerkfehler:', err); | |||||
| } catch { | |||||
| alert(t('errorSave')); | alert(t('errorSave')); | ||||
| } finally { | |||||
| if (btn) btn.disabled = false; | |||||
| } | } | ||||
| } | } | ||||
| @@ -255,9 +258,7 @@ class EntryManager { | |||||
| items.insertAdjacentHTML('beforeend', buildEntryRowHTML(entry, true)); | items.insertAdjacentHTML('beforeend', buildEntryRowHTML(entry, true)); | ||||
| const el = document.getElementById(`entry-${entry.id}`); | const el = document.getElementById(`entry-${entry.id}`); | ||||
| requestAnimationFrame(() => requestAnimationFrame(() => { | |||||
| el?.classList.remove('entry-row--new'); | |||||
| })); | |||||
| if (el) animateIn(el, 'entry-row--new'); | |||||
| } | } | ||||
| resetCreateForm() { | resetCreateForm() { | ||||
| @@ -272,9 +273,7 @@ class EntryManager { | |||||
| } | } | ||||
| openEdit(row) { | openEdit(row) { | ||||
| // Safety-Guard: invoiced-Einträge können nicht geöffnet werden | |||||
| if (row.dataset.invoiced === 'true') return; | if (row.dataset.invoiced === 'true') return; | ||||
| // Kein Edit-Formular vorhanden → nicht öffnen | |||||
| const editSection = row.querySelector('.entry-row__edit'); | const editSection = row.querySelector('.entry-row__edit'); | ||||
| if (!editSection) return; | if (!editSection) return; | ||||
| @@ -303,6 +302,9 @@ class EntryManager { | |||||
| } | } | ||||
| async saveEdit(row) { | async saveEdit(row) { | ||||
| const saveBtn = row.querySelector('[data-action="save"]'); | |||||
| if (saveBtn?.disabled) return; | |||||
| const id = row.dataset.id; | const id = row.dataset.id; | ||||
| const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00'; | const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00'; | ||||
| const projectId = row.querySelector('.edit-project')?.value; | const projectId = row.querySelector('.edit-project')?.value; | ||||
| @@ -311,35 +313,30 @@ class EntryManager { | |||||
| if (!projectId) { alert(t('errorNoProject')); return; } | 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; | 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 { | try { | ||||
| const res = await fetch(`/api/entries/${id}`, { | const res = await fetch(`/api/entries/${id}`, { | ||||
| method: 'PATCH', | method: 'PATCH', | ||||
| headers: { 'Content-Type': 'application/json' }, | headers: { 'Content-Type': 'application/json' }, | ||||
| body: JSON.stringify({ | 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, | note: note || null, | ||||
| }), | }), | ||||
| }); | }); | ||||
| if (!res.ok) { | if (!res.ok) { | ||||
| console.error('PATCH fehlgeschlagen:', res.status); | |||||
| alert(t('errorSave')); | alert(t('errorSave')); | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -348,10 +345,10 @@ class EntryManager { | |||||
| this.updateRowDisplay(row, data.entry); | this.updateRowDisplay(row, data.entry); | ||||
| this.updateTotal(data.totalDuration); | this.updateTotal(data.totalDuration); | ||||
| this.closeEdit(row); | this.closeEdit(row); | ||||
| } catch (err) { | |||||
| console.error('saveEdit Fehler:', err); | |||||
| } catch { | |||||
| alert(t('errorSave')); | alert(t('errorSave')); | ||||
| } finally { | |||||
| if (saveBtn) saveBtn.disabled = false; | |||||
| } | } | ||||
| } | } | ||||
| @@ -388,14 +385,14 @@ class EntryManager { | |||||
| if (!res.ok) { alert(t('errorDelete')); return; } | if (!res.ok) { alert(t('errorDelete')); return; } | ||||
| const data = await res.json(); | const data = await res.json(); | ||||
| row.classList.add('entry-row--removing'); | |||||
| removeWithAnimation(row, 'entry-row--removing'); | |||||
| setTimeout(() => { | setTimeout(() => { | ||||
| row.remove(); | |||||
| this.updateTotal(data.totalDuration); | this.updateTotal(data.totalDuration); | ||||
| this.checkIfEmpty(); | this.checkIfEmpty(); | ||||
| }, 280); | |||||
| } catch { alert(t('errorDelete')); } | |||||
| }, ANIMATION_MS); | |||||
| } catch { | |||||
| alert(t('errorDelete')); | |||||
| } | |||||
| } | } | ||||
| async loadEntriesForDate(dateStr) { | async loadEntriesForDate(dateStr) { | ||||
| @@ -403,9 +400,9 @@ class EntryManager { | |||||
| try { | try { | ||||
| this.list.classList.add('entry-list--fading'); | 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}`); | if (!res.ok) throw new Error(`HTTP ${res.status}`); | ||||
| const data = await res.json(); | const data = await res.json(); | ||||
| @@ -428,7 +425,7 @@ class EntryManager { | |||||
| let html = '<div class="entry-list__items" id="entry-items">'; | let html = '<div class="entry-list__items" id="entry-items">'; | ||||
| entries.forEach(e => { html += buildEntryRowHTML(e, false); }); | entries.forEach(e => { html += buildEntryRowHTML(e, false); }); | ||||
| html += `</div><div class="entry-list__footer" id="entry-footer"> | 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.list.innerHTML = html; | ||||
| this.emptyState = null; | this.emptyState = null; | ||||
| @@ -449,7 +446,7 @@ class EntryManager { | |||||
| footer.id = 'entry-footer'; | footer.id = 'entry-footer'; | ||||
| this.list.appendChild(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; } | 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() { | function initMinimalMode() { | ||||
| if (document.body.dataset.theme !== 'minimal') return; | if (document.body.dataset.theme !== 'minimal') return; | ||||
| @@ -527,9 +498,9 @@ function initWeekToggle() { | |||||
| } | } | ||||
| function initNoteToggle() { | 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; | if (!btn) return; | ||||
| const open = localStorage.getItem(NOTE_KEY) === '1'; | const open = localStorage.getItem(NOTE_KEY) === '1'; | ||||
| @@ -539,7 +510,7 @@ function initNoteToggle() { | |||||
| const nowOpen = label?.classList.toggle('is-visible'); | const nowOpen = label?.classList.toggle('is-visible'); | ||||
| field?.classList.toggle('is-visible'); | field?.classList.toggle('is-visible'); | ||||
| btn.classList.toggle('is-open', !!nowOpen); | 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'); | localStorage.setItem(NOTE_KEY, nowOpen ? '1' : '0'); | ||||
| }); | }); | ||||
| } | } | ||||
| @@ -549,18 +520,17 @@ function setNoteVisible(open, btn, label, field) { | |||||
| label?.classList.add('is-visible'); | label?.classList.add('is-visible'); | ||||
| field?.classList.add('is-visible'); | field?.classList.add('is-visible'); | ||||
| btn.classList.add('is-open'); | btn.classList.add('is-open'); | ||||
| btn.textContent = '× Bemerkung ausblenden'; | |||||
| btn.textContent = t('noteHide'); | |||||
| } else { | } else { | ||||
| btn.textContent = '+ Bemerkung hinzufügen'; | |||||
| btn.textContent = t('noteShow'); | |||||
| } | } | ||||
| } | } | ||||
| function initEntriesToggle() { | 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; | if (!summaryBtn || !entryList) return; | ||||
| // Immer eingeklappt beim Laden | |||||
| entryList.classList.add('is-collapsed'); | entryList.classList.add('is-collapsed'); | ||||
| summaryBtn.setAttribute('aria-expanded', 'false'); | summaryBtn.setAttribute('aria-expanded', 'false'); | ||||
| @@ -1,87 +1,88 @@ | |||||
| // assets/scripts/registration.js | // assets/scripts/registration.js | ||||
| import { esc, createTranslator } from './utils.js'; | |||||
| const t = createTranslator('Register'); | |||||
| document.addEventListener('DOMContentLoaded', () => { | 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 | // 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) { | 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) { | 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) { | 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) { | 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) { | 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) { | 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) { | 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', () => { | 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 => { | 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 { | 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', () => { | 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 { | .select-hint { | ||||
| font-size: $font-size-xs; | font-size: $font-size-xs; | ||||
| color: $color-text-muted; | 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 ─────────────────────────────────────────────────────────── | // ─── Color Palette ─────────────────────────────────────────────────────────── | ||||
| // Compile-time values (used in rgba() functions; keep as hex) | |||||
| $color-primary: #4a90d9; | $color-primary: #4a90d9; | ||||
| $color-primary-dark: #3178b8; | $color-primary-dark: #3178b8; | ||||
| $color-primary-light: #6aaee8; | $color-primary-light: #6aaee8; | ||||
| @@ -12,20 +11,6 @@ $color-accent-light: #f5bc3a; | |||||
| $color-white: #ffffff; | $color-white: #ffffff; | ||||
| $color-bg: #dce9f5; | $color-bg: #dce9f5; | ||||
| // ─── CSS Custom Properties (runtime-overridable via brand color) ────────────── | |||||
| :root { | |||||
| --color-primary: #{$color-primary}; | |||||
| --color-primary-dark: #{$color-primary-dark}; | |||||
| --color-primary-light: #{$color-primary-light}; | |||||
| --color-header-from: #{$color-header-from}; | |||||
| --color-header-to: #{$color-header-to}; | |||||
| --color-bg: #{$color-bg}; | |||||
| --color-primary-rgb: 74, 144, 217; | |||||
| --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: #f0f0f0; | ||||
| $color-card-white: #ffffff; | $color-card-white: #ffffff; | ||||
| @@ -40,21 +25,30 @@ $color-input-border: #b8c4d0; | |||||
| $color-day-active-bg: #1a2a3a; | $color-day-active-bg: #1a2a3a; | ||||
| $color-day-active-text:#ffffff; | $color-day-active-text:#ffffff; | ||||
| $color-day-hover: rgba(255,255,255,0.2); | |||||
| $color-error: #c83232; | $color-error: #c83232; | ||||
| $color-success: #2d9e60; | $color-success: #2d9e60; | ||||
| $color-success-bg: #e6f5ee; | $color-success-bg: #e6f5ee; | ||||
| $color-activate: #3a9a3a; | $color-activate: #3a9a3a; | ||||
| $color-activate-light: #4ab44a; | $color-activate-light: #4ab44a; | ||||
| $color-warning: #b86200; | $color-warning: #b86200; | ||||
| $color-warning-light: #e8820a; | $color-warning-light: #e8820a; | ||||
| $color-overlay: rgba(0, 0, 0, 0.45); | $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 ────────────────────────────────────────────────────────────── | // ─── Typography ────────────────────────────────────────────────────────────── | ||||
| $font-family-base: 'DM Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; | $font-family-base: 'DM Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; | ||||
| $font-size-xs: 0.7rem; | $font-size-xs: 0.7rem; | ||||
| @@ -89,12 +83,12 @@ $radius-xl: 24px; | |||||
| $radius-pill: 100px; | $radius-pill: 100px; | ||||
| // ─── Shadows ───────────────────────────────────────────────────────────────── | // ─── 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 ───────────────────────────────────────────────────────────── | // ─── Transitions ───────────────────────────────────────────────────────────── | ||||
| $transition-fast: 0.15s ease; | $transition-fast: 0.15s ease; | ||||
| @@ -102,5 +96,7 @@ $transition-base: 0.2s ease; | |||||
| $transition-slow: 0.3s ease; | $transition-slow: 0.3s ease; | ||||
| // ─── Layout ────────────────────────────────────────────────────────────────── | // ─── Layout ────────────────────────────────────────────────────────────────── | ||||
| $header-height: 88px; | |||||
| $header-height: 88px; | |||||
| $content-max-width: 860px; | $content-max-width: 860px; | ||||
| $icon-btn-size: 28px; | |||||
| $icon-svg-size: 14px; | |||||
| @@ -1,22 +1,15 @@ | |||||
| @use '../atoms/variables' as *; | @use '../atoms/variables' as *; | ||||
| @use '../atoms/mixins' as *; | |||||
| // ─── Page ───────────────────────────────────────────────────────────────────── | // ─── Page ───────────────────────────────────────────────────────────────────── | ||||
| .account-page { | .account-page { | ||||
| min-height: 100vh; | |||||
| background: var(--color-bg); | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| @include page-shell; | |||||
| } | } | ||||
| // ─── Header ────────────────────────────────────────────────────────────────── | // ─── Header ────────────────────────────────────────────────────────────────── | ||||
| .account-header { | .account-header { | ||||
| background: linear-gradient(135deg, var(--color-header-from) 0%, var(--color-header-to) 100%); | |||||
| @include section-header; | |||||
| padding: $space-6; | padding: $space-6; | ||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: space-between; | |||||
| gap: $space-6; | |||||
| box-shadow: $shadow-header; | |||||
| } | } | ||||
| .account-header__title { | .account-header__title { | ||||
| @@ -74,9 +67,7 @@ | |||||
| // ─── Karte ─────────────────────────────────────────────────────────────────── | // ─── Karte ─────────────────────────────────────────────────────────────────── | ||||
| .account-card { | .account-card { | ||||
| background: $color-card-white; | |||||
| border-radius: $radius-lg; | |||||
| box-shadow: $shadow-card; | |||||
| @include card; | |||||
| padding: $space-8; | padding: $space-8; | ||||
| } | } | ||||
| @@ -89,9 +80,8 @@ | |||||
| } | } | ||||
| .account-form__label { | .account-form__label { | ||||
| font-size: $font-size-sm; | |||||
| @include form-label; | |||||
| font-weight: $font-weight-medium; | font-weight: $font-weight-medium; | ||||
| color: $color-text-muted; | |||||
| padding-top: 7px; | padding-top: 7px; | ||||
| } | } | ||||
| @@ -146,11 +136,7 @@ | |||||
| // ─── Passwort-Sektion (toggle) ──────────────────────────────────────────────── | // ─── Passwort-Sektion (toggle) ──────────────────────────────────────────────── | ||||
| .account-form__pw-section { | .account-form__pw-section { | ||||
| display: contents; // bleibt im Grid-Fluss | |||||
| &[hidden] { | |||||
| display: none !important; | |||||
| } | |||||
| display: contents; | |||||
| } | } | ||||
| // ─── Actions ───────────────────────────────────────────────────────────────── | // ─── Actions ───────────────────────────────────────────────────────────────── | ||||
| @@ -1,4 +1,5 @@ | |||||
| @use '../atoms/variables' as *; | @use '../atoms/variables' as *; | ||||
| @use '../atoms/mixins' as *; | |||||
| // ─── CRUD Seiten Layout ──────────────────────────────────────────────────────── | // ─── CRUD Seiten Layout ──────────────────────────────────────────────────────── | ||||
| .crud-page { | .crud-page { | ||||
| @@ -22,9 +23,7 @@ | |||||
| // ─── Liste ───────────────────────────────────────────────────────────────────── | // ─── Liste ───────────────────────────────────────────────────────────────────── | ||||
| .crud-list { | .crud-list { | ||||
| background: $color-card-white; | |||||
| border-radius: $radius-lg; | |||||
| box-shadow: $shadow-card; | |||||
| @include card; | |||||
| overflow: hidden; | overflow: hidden; | ||||
| } | } | ||||
| @@ -86,20 +85,11 @@ | |||||
| } | } | ||||
| .crud-row__btn { | .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; | opacity: 0; | ||||
| transition: opacity $transition-fast, background $transition-fast, color $transition-fast; | |||||
| color: $color-text-muted; | 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); } | &--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; } | &--delete:hover{ background: rgba($color-error, 0.1); color: $color-error; } | ||||
| @@ -114,15 +104,11 @@ | |||||
| border-top: 1px solid rgba($color-border, 0.5); | border-top: 1px solid rgba($color-border, 0.5); | ||||
| } | } | ||||
| .crud-row__display[hidden] { display: none !important; } | |||||
| // ─── Create-Formular oben ────────────────────────────────────────────────────── | // ─── Create-Formular oben ────────────────────────────────────────────────────── | ||||
| .crud-create { | .crud-create { | ||||
| background: $color-card; | |||||
| border-radius: $radius-lg; | |||||
| @include card($color-card); | |||||
| padding: $space-5 $space-6; | padding: $space-5 $space-6; | ||||
| margin-bottom: $space-4; | margin-bottom: $space-4; | ||||
| box-shadow: $shadow-card; | |||||
| display: none; | display: none; | ||||
| &--visible { display: block; } | &--visible { display: block; } | ||||
| @@ -131,11 +117,10 @@ | |||||
| // ─── Tabs (Aktiv / Archiviert) ───────────────────────────────────────────────── | // ─── Tabs (Aktiv / Archiviert) ───────────────────────────────────────────────── | ||||
| .crud-tabs { | .crud-tabs { | ||||
| display: inline-flex; | display: inline-flex; | ||||
| background: $color-card-white; | |||||
| @include card; | |||||
| border-radius: $radius-pill; | border-radius: $radius-pill; | ||||
| padding: 3px; | padding: 3px; | ||||
| margin-bottom: $space-4; | margin-bottom: $space-4; | ||||
| box-shadow: $shadow-card; | |||||
| } | } | ||||
| .crud-tab { | .crud-tab { | ||||
| @@ -1,11 +1,10 @@ | |||||
| @use '../atoms/variables' as *; | @use '../atoms/variables' as *; | ||||
| @use '../atoms/mixins' as *; | |||||
| // ─── Entry Form Card ───────────────────────────────────────────────────────── | // ─── Entry Form Card ───────────────────────────────────────────────────────── | ||||
| .entry-form { | .entry-form { | ||||
| background: $color-card; | |||||
| border-radius: $radius-lg; | |||||
| @include card($color-card); | |||||
| padding: $space-6 $space-8; | padding: $space-6 $space-8; | ||||
| box-shadow: $shadow-card; | |||||
| } | } | ||||
| .entry-form__grid { | .entry-form__grid { | ||||
| @@ -30,6 +29,19 @@ | |||||
| gap: $space-2; | 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 { | .entry-form__field--selects { | ||||
| display: flex; | display: flex; | ||||
| gap: $space-3; | gap: $space-3; | ||||
| @@ -1,10 +1,9 @@ | |||||
| @use '../atoms/variables' as *; | @use '../atoms/variables' as *; | ||||
| @use '../atoms/mixins' as *; | |||||
| // ─── Entry List Container ────────────────────────────────────────────────── | // ─── Entry List Container ────────────────────────────────────────────────── | ||||
| .entry-list { | .entry-list { | ||||
| background: $color-card-white; | |||||
| border-radius: $radius-lg; | |||||
| box-shadow: $shadow-card; | |||||
| @include card; | |||||
| overflow: hidden; | overflow: hidden; | ||||
| transition: opacity 0.18s ease; | transition: opacity 0.18s ease; | ||||
| @@ -13,10 +12,8 @@ | |||||
| // ─── Empty State ────────────────────────────────────────────────────────── | // ─── Empty State ────────────────────────────────────────────────────────── | ||||
| .empty-state { | .empty-state { | ||||
| background: $color-card-white; | |||||
| border-radius: $radius-lg; | |||||
| @include card; | |||||
| padding: $space-6 $space-8; | padding: $space-6 $space-8; | ||||
| box-shadow: $shadow-card; | |||||
| } | } | ||||
| .empty-state__title { | .empty-state__title { | ||||
| @@ -30,8 +27,7 @@ | |||||
| .entry-list__footer { | .entry-list__footer { | ||||
| display: flex; | display: flex; | ||||
| justify-content: flex-end; | 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; | border-top: 1px solid $color-border; | ||||
| } | } | ||||
| @@ -51,13 +47,11 @@ | |||||
| &:last-child { border-bottom: none; } | &:last-child { border-bottom: none; } | ||||
| // Fade-in bei neuem Eintrag | |||||
| &--new { | &--new { | ||||
| opacity: 0; | opacity: 0; | ||||
| transform: translateY(-6px); | transform: translateY(-6px); | ||||
| } | } | ||||
| // Fade-out beim Löschen | |||||
| &--removing { | &--removing { | ||||
| opacity: 0; | opacity: 0; | ||||
| transform: translateX(12px); | transform: translateX(12px); | ||||
| @@ -79,13 +73,7 @@ | |||||
| &:hover { | &:hover { | ||||
| background: rgba(var(--color-primary-rgb), 0.05); | 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-size: $font-size-base; | ||||
| font-weight: $font-weight-bold; | font-weight: $font-weight-bold; | ||||
| color: $color-text-dark; | color: $color-text-dark; | ||||
| white-space: nowrap; | |||||
| overflow: hidden; | |||||
| text-overflow: ellipsis; | |||||
| @include text-truncate; | |||||
| } | } | ||||
| .entry-row__note { | .entry-row__note { | ||||
| font-size: $font-size-sm; | font-size: $font-size-sm; | ||||
| color: $color-text-muted; | color: $color-text-muted; | ||||
| margin-top: 2px; | margin-top: 2px; | ||||
| white-space: nowrap; | |||||
| overflow: hidden; | |||||
| text-overflow: ellipsis; | |||||
| @include text-truncate; | |||||
| } | } | ||||
| .entry-row__actions { | .entry-row__actions { | ||||
| @@ -132,25 +116,15 @@ | |||||
| } | } | ||||
| .entry-row__btn { | .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; | opacity: 0; | ||||
| transition: opacity $transition-fast, background $transition-fast, color $transition-fast; | |||||
| color: $color-text-muted; | 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); } | &--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; } | &--delete:hover{ background: rgba($color-error, 0.1); color: $color-error; } | ||||
| // immer sichtbar auf Touch-Geräten | |||||
| @media (hover: none) { opacity: 1; } | @media (hover: none) { opacity: 1; } | ||||
| } | } | ||||
| @@ -159,11 +133,11 @@ | |||||
| display: flex; | display: flex; | ||||
| align-items: center; | align-items: center; | ||||
| justify-content: center; | justify-content: center; | ||||
| width: calc(28px + #{$space-2} + 28px); | |||||
| width: calc(#{$icon-btn-size} + #{$space-2} + #{$icon-btn-size}); | |||||
| flex-shrink: 0; | flex-shrink: 0; | ||||
| color: $color-text-dark; | 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 ──────────────────────────────────────────────── | // ─── Abgerechneter Eintrag ──────────────────────────────────────────────── | ||||
| @@ -181,7 +155,6 @@ | |||||
| } | } | ||||
| .entry-form__grid--inline { | .entry-form__grid--inline { | ||||
| // Gleiche Grid-Struktur wie das Haupt-Formular | |||||
| display: grid; | display: grid; | ||||
| grid-template-columns: 130px 1fr; | grid-template-columns: 130px 1fr; | ||||
| gap: $space-3 $space-6; | gap: $space-3 $space-6; | ||||
| @@ -1,4 +1,5 @@ | |||||
| @use '../atoms/variables' as *; | @use '../atoms/variables' as *; | ||||
| @use '../atoms/mixins' as *; | |||||
| // ─── Login Page ─────────────────────────────────────────────────────────────── | // ─── Login Page ─────────────────────────────────────────────────────────────── | ||||
| .login-body { | .login-body { | ||||
| @@ -11,12 +12,10 @@ | |||||
| // ─── Card ───────────────────────────────────────────────────────────────────── | // ─── Card ───────────────────────────────────────────────────────────────────── | ||||
| .login-card { | .login-card { | ||||
| background: $color-card-white; | |||||
| border-radius: $radius-xl; | |||||
| @include card($color-card-white, $radius-xl); | |||||
| padding: $space-10 $space-12; | padding: $space-10 $space-12; | ||||
| width: 100%; | width: 100%; | ||||
| max-width: 540px; | max-width: 540px; | ||||
| box-shadow: $shadow-card; | |||||
| } | } | ||||
| .login-card__title { | .login-card__title { | ||||
| @@ -47,10 +46,8 @@ | |||||
| } | } | ||||
| .login-form__label { | .login-form__label { | ||||
| @include form-label; | |||||
| font-size: $font-size-base; | font-size: $font-size-base; | ||||
| color: $color-text-muted; | |||||
| text-align: right; | |||||
| padding-right: $space-2; | |||||
| } | } | ||||
| .login-form__field { | .login-form__field { | ||||
| @@ -76,7 +73,7 @@ | |||||
| } | } | ||||
| } | } | ||||
| // ─── Footer-Link (z. B. "Zurück zur Anmeldung") ─────────────────────────────── | |||||
| // ─── Footer-Link ────────────────────────────────────────────────────────────── | |||||
| .login-form__footer { | .login-form__footer { | ||||
| text-align: center; | text-align: center; | ||||
| margin-top: $space-6; | margin-top: $space-6; | ||||
| @@ -141,4 +138,4 @@ | |||||
| .login-form__submit { | .login-form__submit { | ||||
| padding: $space-3 $space-10; | padding: $space-3 $space-10; | ||||
| font-size: $font-size-md; | font-size: $font-size-md; | ||||
| } | |||||
| } | |||||
| @@ -1,4 +1,5 @@ | |||||
| @use '../atoms/variables' as *; | @use '../atoms/variables' as *; | ||||
| @use '../atoms/mixins' as *; | |||||
| // ─── Monatskalender Container ───────────────────────────────────────────────── | // ─── Monatskalender Container ───────────────────────────────────────────────── | ||||
| .month-calendar { | .month-calendar { | ||||
| @@ -44,17 +45,8 @@ | |||||
| } | } | ||||
| .month-calendar__arrow { | .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); | color: var(--header-text); | ||||
| cursor: pointer; | |||||
| transition: background $transition-fast; | |||||
| &:hover { background: var(--header-overlay); } | &:hover { background: var(--header-overlay); } | ||||
| @@ -62,7 +54,6 @@ | |||||
| } | } | ||||
| .month-calendar__close { | .month-calendar__close { | ||||
| // erbt .week-nav__cal Styles – hier nur Positionierung | |||||
| margin-left: 0; | margin-left: 0; | ||||
| } | } | ||||
| @@ -112,13 +103,11 @@ | |||||
| background: var(--header-overlay); | background: var(--header-overlay); | ||||
| } | } | ||||
| // Tage aus Vor-/Nachmonat | |||||
| &--other { | &--other { | ||||
| color: var(--header-text-muted); | color: var(--header-text-muted); | ||||
| cursor: default; | cursor: default; | ||||
| } | } | ||||
| // Heutiger Tag | |||||
| &--today { | &--today { | ||||
| font-weight: $font-weight-bold; | font-weight: $font-weight-bold; | ||||
| background: $color-white; | background: $color-white; | ||||
| @@ -129,7 +118,6 @@ | |||||
| } | } | ||||
| } | } | ||||
| // Ausgewählter Tag | |||||
| &--active:not(&--today) { | &--active:not(&--today) { | ||||
| background: var(--header-overlay); | background: var(--header-overlay); | ||||
| font-weight: $font-weight-bold; | font-weight: $font-weight-bold; | ||||
| @@ -1,4 +1,5 @@ | |||||
| @use '../atoms/variables' as *; | @use '../atoms/variables' as *; | ||||
| @use '../atoms/mixins' as *; | |||||
| .register-body { | .register-body { | ||||
| min-height: 100vh; | min-height: 100vh; | ||||
| @@ -16,10 +17,8 @@ | |||||
| } | } | ||||
| .register-card { | .register-card { | ||||
| background: $color-card-white; | |||||
| border-radius: $radius-xl; | |||||
| @include card($color-card-white, $radius-xl); | |||||
| padding: $space-10 $space-12; | padding: $space-10 $space-12; | ||||
| box-shadow: $shadow-card; | |||||
| } | } | ||||
| .register-card__brand { | .register-card__brand { | ||||
| @@ -186,8 +185,12 @@ | |||||
| margin-bottom: $space-3; | margin-bottom: $space-3; | ||||
| } | } | ||||
| .register-success__btn { | |||||
| margin-top: $space-6; | |||||
| } | |||||
| .register-success__hint { | .register-success__hint { | ||||
| font-size: $font-size-sm; | font-size: $font-size-sm; | ||||
| color: $color-text-muted; | color: $color-text-muted; | ||||
| line-height: $line-height-base; | line-height: $line-height-base; | ||||
| } | |||||
| } | |||||
| @@ -1,4 +1,5 @@ | |||||
| @use '../atoms/variables' as *; | @use '../atoms/variables' as *; | ||||
| @use '../atoms/mixins' as *; | |||||
| // ─── Ausstehend-Badge ────────────────────────────────────────────────────────── | // ─── Ausstehend-Badge ────────────────────────────────────────────────────────── | ||||
| .team-badge { | .team-badge { | ||||
| @@ -24,14 +25,10 @@ | |||||
| align-items: center; | align-items: center; | ||||
| justify-content: center; | justify-content: center; | ||||
| z-index: 200; | z-index: 200; | ||||
| &[hidden] { display: none !important; } | |||||
| } | } | ||||
| .modal-card { | .modal-card { | ||||
| background: $color-card-white; | |||||
| border-radius: $radius-lg; | |||||
| box-shadow: $shadow-card; | |||||
| @include card; | |||||
| width: 100%; | width: 100%; | ||||
| max-width: 460px; | max-width: 460px; | ||||
| padding: 0; | padding: 0; | ||||
| @@ -53,18 +50,11 @@ | |||||
| } | } | ||||
| .modal-card__close { | .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; | color: $color-text-muted; | ||||
| border-radius: 50%; | |||||
| transition: background $transition-fast; | |||||
| svg { width: 16px; height: 16px; } | svg { width: 16px; height: 16px; } | ||||
| &:hover { background: rgba($color-border, 0.5); } | &:hover { background: rgba($color-border, 0.5); } | ||||
| } | } | ||||
| @@ -111,8 +101,6 @@ | |||||
| color: $color-error; | color: $color-error; | ||||
| font-size: $font-size-sm; | font-size: $font-size-sm; | ||||
| &[hidden] { display: none !important; } | |||||
| ul { margin: 0; padding-left: 1.2em; } | ul { margin: 0; padding-left: 1.2em; } | ||||
| } | } | ||||
| @@ -161,4 +149,4 @@ | |||||
| margin-top: $space-1; | margin-top: $space-1; | ||||
| font-size: $font-size-xs; | font-size: $font-size-xs; | ||||
| color: $color-text-muted; | color: $color-text-muted; | ||||
| } | |||||
| } | |||||
| @@ -1,4 +1,5 @@ | |||||
| @use '../atoms/variables' as *; | @use '../atoms/variables' as *; | ||||
| @use '../atoms/mixins' as *; | |||||
| // ─── Wrapper ───────────────────────────────────────────────────────────────── | // ─── Wrapper ───────────────────────────────────────────────────────────────── | ||||
| .week-nav { | .week-nav { | ||||
| @@ -14,19 +15,9 @@ | |||||
| // ─── Pfeile ────────────────────────────────────────────────────────────────── | // ─── Pfeile ────────────────────────────────────────────────────────────────── | ||||
| .week-nav__arrow { | .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); | color: var(--header-text); | ||||
| cursor: pointer; | |||||
| text-decoration: none; | text-decoration: none; | ||||
| flex-shrink: 0; | |||||
| transition: background $transition-fast; | |||||
| &:hover { background: var(--header-overlay); } | &:hover { background: var(--header-overlay); } | ||||
| @@ -101,21 +92,12 @@ | |||||
| // ─── Kalender-Icon ─────────────────────────────────────────────────────────── | // ─── Kalender-Icon ─────────────────────────────────────────────────────────── | ||||
| .week-nav__cal { | .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); | background: var(--header-overlay); | ||||
| color: var(--header-text); | color: var(--header-text); | ||||
| cursor: pointer; | |||||
| border: none; | |||||
| margin-left: $space-1; | 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, | &:hover, | ||||
| &--active { background: var(--header-overlay); } | &--active { background: var(--header-overlay); } | ||||
| @@ -1,5 +1,6 @@ | |||||
| // ─── Atoms ──────────────────────────────────────────────────────────────────── | // ─── Atoms ──────────────────────────────────────────────────────────────────── | ||||
| @use 'atoms/variables' as *; | @use 'atoms/variables' as *; | ||||
| @use 'atoms/mixins' as *; | |||||
| @use 'atoms/typography'; | @use 'atoms/typography'; | ||||
| @use 'atoms/buttons'; | @use 'atoms/buttons'; | ||||
| @use 'atoms/inputs'; | @use 'atoms/inputs'; | ||||
| @@ -27,6 +28,8 @@ | |||||
| @use 'themes/minimal'; | @use 'themes/minimal'; | ||||
| // ─── Reset / Base ───────────────────────────────────────────────────────────── | // ─── Reset / Base ───────────────────────────────────────────────────────────── | ||||
| @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap'); | |||||
| *, | *, | ||||
| *::before, | *::before, | ||||
| *::after { | *::after { | ||||
| @@ -43,4 +46,6 @@ body { | |||||
| background: var(--color-bg); | 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/variables' as *; | ||||
| @use '../atoms/mixins' as *; | |||||
| .home-body { | .home-body { | ||||
| min-height: 100vh; | |||||
| background: var(--color-bg); | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| @include page-shell; | |||||
| } | } | ||||
| // ─── Header ────────────────────────────────────────────────────────────────── | // ─── Header ────────────────────────────────────────────────────────────────── | ||||
| @@ -64,4 +62,4 @@ | |||||
| .home-hero__cta { | .home-hero__cta { | ||||
| font-size: $font-size-md; | font-size: $font-size-md; | ||||
| padding: $space-4 $space-10; | padding: $space-4 $space-10; | ||||
| } | |||||
| } | |||||
| @@ -1,22 +1,15 @@ | |||||
| @use '../atoms/variables' as *; | @use '../atoms/variables' as *; | ||||
| @use '../atoms/mixins' as *; | |||||
| // ─── Page ───────────────────────────────────────────────────────────────────── | // ─── Page ───────────────────────────────────────────────────────────────────── | ||||
| .report-page { | .report-page { | ||||
| min-height: 100vh; | |||||
| background: var(--color-bg); | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| @include page-shell; | |||||
| } | } | ||||
| // ─── Header ────────────────────────────────────────────────────────────────── | // ─── Header ────────────────────────────────────────────────────────────────── | ||||
| .report-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; | padding: $space-4 $space-6; | ||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: space-between; | |||||
| gap: $space-6; | |||||
| box-shadow: $shadow-header; | |||||
| } | } | ||||
| .report-header__title { | .report-header__title { | ||||
| @@ -72,9 +65,7 @@ | |||||
| // ─── Karte ─────────────────────────────────────────────────────────────────── | // ─── Karte ─────────────────────────────────────────────────────────────────── | ||||
| .report-card { | .report-card { | ||||
| background: $color-card-white; | |||||
| border-radius: $radius-lg; | |||||
| box-shadow: $shadow-card; | |||||
| @include card; | |||||
| overflow: hidden; | overflow: hidden; | ||||
| } | } | ||||
| @@ -93,6 +84,31 @@ | |||||
| gap: $space-6; | 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 { | .report-toolbar__action { | ||||
| display: inline-flex; | display: inline-flex; | ||||
| align-items: center; | align-items: center; | ||||
| @@ -104,8 +120,8 @@ | |||||
| text-decoration: none; | text-decoration: none; | ||||
| svg { | svg { | ||||
| width: 14px; | |||||
| height: 14px; | |||||
| width: $icon-svg-size; | |||||
| height: $icon-svg-size; | |||||
| flex-shrink: 0; | flex-shrink: 0; | ||||
| } | } | ||||
| @@ -133,7 +149,7 @@ | |||||
| 1fr // Bemerkung | 1fr // Bemerkung | ||||
| 80px // Stunden | 80px // Stunden | ||||
| 100px // Umsatz | 100px // Umsatz | ||||
| 88px; // Aktionen (Edit + Delete + Schloss) | |||||
| 88px; // Aktionen | |||||
| align-items: center; | align-items: center; | ||||
| border-bottom: 1px solid $color-border; | border-bottom: 1px solid $color-border; | ||||
| padding: 0 $space-5; | padding: 0 $space-5; | ||||
| @@ -142,7 +158,6 @@ | |||||
| .report-table__head { | .report-table__head { | ||||
| padding-top: $space-2; | padding-top: $space-2; | ||||
| padding-bottom: $space-2; | padding-bottom: $space-2; | ||||
| background: transparent; | |||||
| .report-table__cell { | .report-table__cell { | ||||
| font-size: $font-size-xs; | font-size: $font-size-xs; | ||||
| @@ -161,34 +176,24 @@ | |||||
| &:hover { | &:hover { | ||||
| background: rgba(var(--color-primary-rgb), 0.05); | 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 { | &--editing { | ||||
| background: rgba(var(--color-primary-rgb), 0.05); | 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 { | .report-table__cell { | ||||
| @@ -217,9 +222,7 @@ | |||||
| &--note { | &--note { | ||||
| color: $color-text-muted; | color: $color-text-muted; | ||||
| font-size: $font-size-sm; | font-size: $font-size-sm; | ||||
| white-space: nowrap; | |||||
| overflow: hidden; | |||||
| text-overflow: ellipsis; | |||||
| @include text-truncate; | |||||
| } | } | ||||
| } | } | ||||
| @@ -245,23 +248,11 @@ | |||||
| // ─── Aktions-Buttons (Edit / Delete) ───────────────────────────────────────── | // ─── Aktions-Buttons (Edit / Delete) ───────────────────────────────────────── | ||||
| .report-action-btn { | .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; | 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 { | &:hover { | ||||
| color: $color-text-muted; | color: $color-text-muted; | ||||
| @@ -276,22 +267,10 @@ | |||||
| // ─── Schloss-Button ────────────────────────────────────────────────────────── | // ─── Schloss-Button ────────────────────────────────────────────────────────── | ||||
| .report-lock { | .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; | 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 { | &:hover { | ||||
| color: $color-text-muted; | color: $color-text-muted; | ||||
| @@ -325,11 +304,7 @@ | |||||
| } | } | ||||
| .report-row__edit-label { | .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 { | .report-row__edit-field { | ||||
| @@ -379,9 +354,7 @@ | |||||
| text-decoration: underline; | text-decoration: underline; | ||||
| cursor: pointer; | cursor: pointer; | ||||
| &:hover { | |||||
| color: var(--color-primary-dark); | |||||
| } | |||||
| &:hover { color: var(--color-primary-dark); } | |||||
| } | } | ||||
| strong { | strong { | ||||
| @@ -401,7 +374,7 @@ | |||||
| } | } | ||||
| .report-pagination__lock-spacer { | .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) ──────────────────────────────────────────────── | // ─── Toolbar-Button (klickbar) ──────────────────────────────────────────────── | ||||
| @@ -462,29 +435,28 @@ button.report-toolbar__action { | |||||
| padding: $space-2 0; | padding: $space-2 0; | ||||
| border-bottom: 1px solid rgba($color-border, 0.6); | 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 { | &--inactive { | ||||
| .filter-select, | .filter-select, | ||||
| .filter-note-input, | .filter-note-input, | ||||
| .filter-period-select { | |||||
| .filter-period-select, | |||||
| .filter-radio { | |||||
| opacity: 0.5; | 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 { | .filter-neg { | ||||
| opacity: 0.4; | |||||
| pointer-events: none; | pointer-events: none; | ||||
| } | } | ||||
| } | } | ||||
| @@ -497,7 +469,7 @@ button.report-toolbar__action { | |||||
| cursor: pointer; | cursor: pointer; | ||||
| font-size: $font-size-sm; | font-size: $font-size-sm; | ||||
| color: $color-text-base; | color: $color-text-base; | ||||
| padding-top: 7px; // optisch mit den Selects ausrichten | |||||
| padding-top: 7px; | |||||
| user-select: none; | user-select: none; | ||||
| } | } | ||||
| @@ -552,31 +524,23 @@ button.report-toolbar__action { | |||||
| display: flex; | display: flex; | ||||
| align-items: center; | align-items: center; | ||||
| gap: $space-3; | gap: $space-3; | ||||
| padding-top: 7px; // vertikal mit Select ausrichten | |||||
| padding-top: 7px; | |||||
| flex-shrink: 0; | flex-shrink: 0; | ||||
| white-space: nowrap; | white-space: nowrap; | ||||
| &--no-add { | &--no-add { | ||||
| padding-left: calc(22px + #{$space-3}); // Platz für fehlenden Add-Button | |||||
| padding-left: calc(22px + #{$space-3}); | |||||
| } | } | ||||
| } | } | ||||
| // ─── Plus- und Minus-Button ─────────────────────────────────────────────────── | // ─── Plus- und Minus-Button ─────────────────────────────────────────────────── | ||||
| .filter-row__add { | .filter-row__add { | ||||
| width: 22px; | |||||
| height: 22px; | |||||
| @include icon-btn(22px, $radius-sm); | |||||
| border: 1px solid $color-input-border; | border: 1px solid $color-input-border; | ||||
| background: $color-white; | background: $color-white; | ||||
| border-radius: $radius-sm; | |||||
| cursor: pointer; | |||||
| font-size: $font-size-md; | font-size: $font-size-md; | ||||
| line-height: 1; | line-height: 1; | ||||
| color: $color-text-muted; | color: $color-text-muted; | ||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| flex-shrink: 0; | |||||
| transition: border-color $transition-fast, color $transition-fast; | |||||
| &:hover { | &:hover { | ||||
| border-color: var(--color-primary); | border-color: var(--color-primary); | ||||
| @@ -585,20 +549,10 @@ button.report-toolbar__action { | |||||
| } | } | ||||
| .filter-row__remove { | .filter-row__remove { | ||||
| width: 20px; | |||||
| height: 20px; | |||||
| border: none; | |||||
| background: none; | |||||
| cursor: pointer; | |||||
| @include icon-btn(20px, $radius-sm); | |||||
| font-size: $font-size-md; | font-size: $font-size-md; | ||||
| line-height: 1; | line-height: 1; | ||||
| color: $color-text-light; | 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 { | &:hover { | ||||
| color: $color-error; | color: $color-error; | ||||
| @@ -612,11 +566,6 @@ button.report-toolbar__action { | |||||
| flex-direction: column; | flex-direction: column; | ||||
| gap: $space-2; | gap: $space-2; | ||||
| margin-top: $space-2; | margin-top: $space-2; | ||||
| // [hidden] wird durch display:flex überschrieben – explizit gegensteuern | |||||
| &[hidden] { | |||||
| display: none; | |||||
| } | |||||
| } | } | ||||
| .filter-date-group { | .filter-date-group { | ||||
| @@ -679,9 +628,7 @@ button.report-toolbar__action { | |||||
| text-underline-offset: 2px; | text-underline-offset: 2px; | ||||
| transition: color $transition-fast; | transition: color $transition-fast; | ||||
| &:hover { | |||||
| color: $color-text-base; | |||||
| } | |||||
| &:hover { color: $color-text-base; } | |||||
| } | } | ||||
| // ─── Negativfilter-Checkbox ─────────────────────────────────────────────────── | // ─── Negativfilter-Checkbox ─────────────────────────────────────────────────── | ||||
| @@ -1,26 +1,19 @@ | |||||
| @use '../atoms/variables' as *; | @use '../atoms/variables' as *; | ||||
| @use '../atoms/mixins' as *; | |||||
| // ─── Page Wrapper ───────────────────────────────────────────────────────────── | // ─── Page Wrapper ───────────────────────────────────────────────────────────── | ||||
| .tt-page { | .tt-page { | ||||
| min-height: 100vh; | |||||
| background: var(--color-bg); | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| @include page-shell; | |||||
| } | } | ||||
| // ─── Header Section ────────────────────────────────────────────────────────── | // ─── Header Section ────────────────────────────────────────────────────────── | ||||
| .tt-header { | .tt-header { | ||||
| background: linear-gradient(135deg, var(--color-header-from) 0%, var(--color-header-to) 100%); | |||||
| @include section-header; | |||||
| padding: $space-4 $space-6; | padding: $space-4 $space-6; | ||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: space-between; | |||||
| gap: $space-6; | |||||
| min-height: $header-height; | min-height: $header-height; | ||||
| position: sticky; | position: sticky; | ||||
| top: 0; | top: 0; | ||||
| z-index: 100; | z-index: 100; | ||||
| box-shadow: $shadow-header; | |||||
| } | } | ||||
| .tt-header__meta { | .tt-header__meta { | ||||
| @@ -47,7 +40,7 @@ | |||||
| max-width: $content-max-width; | max-width: $content-max-width; | ||||
| width: 100%; | width: 100%; | ||||
| margin: 0 auto; | margin: 0 auto; | ||||
| padding: $space-6 $space-6; | |||||
| padding: $space-6; | |||||
| display: flex; | display: flex; | ||||
| flex-direction: column; | flex-direction: column; | ||||
| gap: $space-4; | gap: $space-4; | ||||
| @@ -1,11 +1,12 @@ | |||||
| @use '../atoms/variables' as *; | @use '../atoms/variables' as *; | ||||
| @use '../atoms/mixins' as *; | |||||
| // ─── Minimal Theme ───────────────────────────────────────────────────────────── | // ─── Minimal Theme ───────────────────────────────────────────────────────────── | ||||
| // Gilt nur wenn body[data-theme="minimal"] gesetzt ist. | // Gilt nur wenn body[data-theme="minimal"] gesetzt ist. | ||||
| // Standard-Theme bleibt vollständig unverändert. | // Standard-Theme bleibt vollständig unverändert. | ||||
| body[data-theme="minimal"] { | body[data-theme="minimal"] { | ||||
| background: #fff; | |||||
| background: $color-white; | |||||
| // ── Normale Top-Nav ausblenden ────────────────────────────────────────────── | // ── Normale Top-Nav ausblenden ────────────────────────────────────────────── | ||||
| .main-nav { display: none; } | .main-nav { display: none; } | ||||
| @@ -18,11 +19,11 @@ body[data-theme="minimal"] { | |||||
| } | } | ||||
| // ── Page-Background weiß ─────────────────────────────────────────────────── | // ── Page-Background weiß ─────────────────────────────────────────────────── | ||||
| .tt-page { background: #fff; } | |||||
| .tt-page { background: $color-white; } | |||||
| // ── TT-Header: kein Gradient, kein Schatten, cleaner Rahmen ─────────────── | // ── TT-Header: kein Gradient, kein Schatten, cleaner Rahmen ─────────────── | ||||
| .tt-header { | .tt-header { | ||||
| background: #fff; | |||||
| background: $color-white; | |||||
| box-shadow: none; | box-shadow: none; | ||||
| border-bottom: 1px solid $color-border; | border-bottom: 1px solid $color-border; | ||||
| padding: $space-3 $space-5; | padding: $space-3 $space-5; | ||||
| @@ -85,7 +86,7 @@ body[data-theme="minimal"] { | |||||
| // ── Entry Form: cleaner, größere Inputs ──────────────────────────────────── | // ── Entry Form: cleaner, größere Inputs ──────────────────────────────────── | ||||
| .entry-form { | .entry-form { | ||||
| background: #fff; | |||||
| background: $color-white; | |||||
| border: none; | border: none; | ||||
| border-radius: 0; | border-radius: 0; | ||||
| padding: $space-4 0; | padding: $space-4 0; | ||||
| @@ -153,7 +154,7 @@ body[data-theme="minimal"] { | |||||
| } | } | ||||
| .entry-list { | .entry-list { | ||||
| background: #fff; | |||||
| background: $color-white; | |||||
| box-shadow: none; | box-shadow: none; | ||||
| border: 1px solid $color-border; | border: 1px solid $color-border; | ||||
| border-radius: $radius-md; | border-radius: $radius-md; | ||||
| @@ -164,7 +165,7 @@ body[data-theme="minimal"] { | |||||
| .crud-page, | .crud-page, | ||||
| .account-page, | .account-page, | ||||
| .team-page { | .team-page { | ||||
| background: #fff; | |||||
| background: $color-white; | |||||
| } | } | ||||
| } | } | ||||
| @@ -180,7 +181,7 @@ body[data-theme="minimal"] { | |||||
| height: 52px; | height: 52px; | ||||
| border: none; | border: none; | ||||
| border-radius: $radius-lg; | border-radius: $radius-lg; | ||||
| background: #fff; | |||||
| background: $color-white; | |||||
| cursor: pointer; | cursor: pointer; | ||||
| display: flex; | display: flex; | ||||
| align-items: center; | align-items: center; | ||||
| @@ -230,7 +231,7 @@ body[data-theme="minimal"] { | |||||
| top: calc(100% + #{$space-2}); | top: calc(100% + #{$space-2}); | ||||
| right: 0; | right: 0; | ||||
| min-width: 200px; | min-width: 200px; | ||||
| background: #fff; | |||||
| background: $color-white; | |||||
| border: 1px solid $color-border; | border: 1px solid $color-border; | ||||
| border-radius: $radius-md; | border-radius: $radius-md; | ||||
| box-shadow: 0 8px 24px rgba(0,0,0,0.12); | box-shadow: 0 8px 24px rgba(0,0,0,0.12); | ||||
| @@ -303,7 +304,7 @@ body[data-theme="minimal"] { | |||||
| align-items: center; | align-items: center; | ||||
| gap: $space-2; | gap: $space-2; | ||||
| padding: $space-3 $space-4; | padding: $space-3 $space-4; | ||||
| background: #fff; | |||||
| background: $color-white; | |||||
| border: 1px solid $color-border; | border: 1px solid $color-border; | ||||
| border-radius: $radius-md; | border-radius: $radius-md; | ||||
| font-size: $font-size-base; | font-size: $font-size-base; | ||||
| @@ -12,6 +12,8 @@ | |||||
| "doctrine/doctrine-bundle": "^3.2.2", | "doctrine/doctrine-bundle": "^3.2.2", | ||||
| "doctrine/doctrine-migrations-bundle": "^4.0", | "doctrine/doctrine-migrations-bundle": "^4.0", | ||||
| "doctrine/orm": "^3.6.6", | "doctrine/orm": "^3.6.6", | ||||
| "dompdf/dompdf": "^3.1", | |||||
| "phpoffice/phpspreadsheet": "^5.8", | |||||
| "symfony/console": "7.4.*", | "symfony/console": "7.4.*", | ||||
| "symfony/dotenv": "7.4.*", | "symfony/dotenv": "7.4.*", | ||||
| "symfony/flex": "^2.10", | "symfony/flex": "^2.10", | ||||
| @@ -4,8 +4,84 @@ | |||||
| "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", | ||||
| "This file is @generated automatically" | "This file is @generated automatically" | ||||
| ], | ], | ||||
| "content-hash": "dae707f4e483331f467dcf211922216c", | |||||
| "content-hash": "6a52005068f345beb15a732e99cbb73a", | |||||
| "packages": [ | "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", | "name": "doctrine/collections", | ||||
| "version": "2.6.0", | "version": "2.6.0", | ||||
| @@ -1120,6 +1196,161 @@ | |||||
| }, | }, | ||||
| "time": "2026-02-08T16:21:46+00:00" | "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", | "name": "egulias/email-validator", | ||||
| "version": "4.0.4", | "version": "4.0.4", | ||||
| @@ -1187,6 +1418,258 @@ | |||||
| ], | ], | ||||
| "time": "2025-03-06T22:45:56+00:00" | "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", | "name": "monolog/monolog", | ||||
| "version": "3.10.0", | "version": "3.10.0", | ||||
| @@ -1290,6 +1773,115 @@ | |||||
| ], | ], | ||||
| "time": "2026-01-02T08:56:05+00:00" | "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", | "name": "psr/cache", | ||||
| "version": "3.0.0", | "version": "3.0.0", | ||||
| @@ -1540,6 +2132,137 @@ | |||||
| }, | }, | ||||
| "time": "2024-09-11T13:17:53+00:00" | "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", | "name": "symfony/asset", | ||||
| "version": "v7.4.8", | "version": "v7.4.8", | ||||
| @@ -6314,6 +7037,149 @@ | |||||
| ], | ], | ||||
| "time": "2026-05-20T07:20:23+00:00" | "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", | "name": "twig/twig", | ||||
| "version": "v3.26.0", | "version": "v3.26.0", | ||||
| @@ -10,8 +10,7 @@ doctrine: | |||||
| # ersetzt dbname zur Laufzeit mit 'db_{slug}' | # ersetzt dbname zur Laufzeit mit 'db_{slug}' | ||||
| url: '%env(resolve:DATABASE_URL)%' | url: '%env(resolve:DATABASE_URL)%' | ||||
| profiling_collect_backtrace: '%kernel.debug%' | profiling_collect_backtrace: '%kernel.debug%' | ||||
| # middlewares: | |||||
| # - App\Doctrine\TenantConnectionMiddleware | |||||
| # Middleware wird via Service-Tag registriert (services.yaml) | |||||
| orm: | orm: | ||||
| default_entity_manager: central | default_entity_manager: central | ||||
| @@ -65,5 +65,9 @@ services: | |||||
| arguments: | arguments: | ||||
| $appDomain: '%app.domain%' | $appDomain: '%app.domain%' | ||||
| App\Doctrine\TenantConnectionMiddleware: | |||||
| tags: | |||||
| - { name: doctrine.middleware, connection: tenant } | |||||
| App\Controller\InviteController: | App\Controller\InviteController: | ||||
| arguments: ~ | arguments: ~ | ||||
| @@ -14,6 +14,7 @@ use Symfony\Component\HttpFoundation\Request; | |||||
| use Symfony\Component\HttpFoundation\Response; | use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; | use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; | ||||
| use Symfony\Component\Routing\Attribute\Route; | use Symfony\Component\Routing\Attribute\Route; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||||
| class AccountController extends AbstractController | class AccountController extends AbstractController | ||||
| { | { | ||||
| @@ -24,6 +25,7 @@ class AccountController extends AbstractController | |||||
| private readonly UserRepository $userRepo, | private readonly UserRepository $userRepo, | ||||
| private readonly UserPasswordHasherInterface $passwordHasher, | private readonly UserPasswordHasherInterface $passwordHasher, | ||||
| private readonly BrandColorService $brandColorService, | private readonly BrandColorService $brandColorService, | ||||
| private readonly TranslatorInterface $translator, | |||||
| ) {} | ) {} | ||||
| #[Route('/account', name: 'account_index')] | #[Route('/account', name: 'account_index')] | ||||
| @@ -56,10 +58,10 @@ class AccountController extends AbstractController | |||||
| 'adminUsers' => $adminUsers, | 'adminUsers' => $adminUsers, | ||||
| 'superAdminUserId' => $account->getSuperAdminUser()?->getId(), | 'superAdminUserId' => $account->getSuperAdminUser()?->getId(), | ||||
| 'intervalOptions' => [ | '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]); | $accountUser = $this->accountUserRepo->findOneBy(['account' => $account, 'user' => $user]); | ||||
| if (!$accountUser?->isAdmin()) { | 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) ?? []; | $data = json_decode($request->getContent(), true) ?? []; | ||||
| @@ -91,7 +93,7 @@ class AccountController extends AbstractController | |||||
| if (array_key_exists('primaryColor', $data)) { | if (array_key_exists('primaryColor', $data)) { | ||||
| $hex = $data['primaryColor'] === '' ? null : trim($data['primaryColor']); | $hex = $data['primaryColor'] === '' ? null : trim($data['primaryColor']); | ||||
| if ($hex !== null && !$this->brandColorService->isValid($hex)) { | 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); | $account->setPrimaryColor($hex); | ||||
| } | } | ||||
| @@ -110,29 +112,28 @@ class AccountController extends AbstractController | |||||
| $accountUser = $this->accountUserRepo->findOneBy(['account' => $account, 'user' => $currentUser]); | $accountUser = $this->accountUserRepo->findOneBy(['account' => $account, 'user' => $currentUser]); | ||||
| if (!$accountUser?->isAdmin()) { | 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()) { | 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) ?? []; | $data = json_decode($request->getContent(), true) ?? []; | ||||
| $userId = (int) ($data['userId'] ?? 0); | $userId = (int) ($data['userId'] ?? 0); | ||||
| if ($userId === $currentUser->getId()) { | 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); | $newOwner = $this->userRepo->find($userId); | ||||
| if ($newOwner === null) { | 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]); | $newAccountUser = $this->accountUserRepo->findOneBy(['account' => $account, 'user' => $newOwner]); | ||||
| if ($newAccountUser === null || !$newAccountUser->isAdmin() || $newAccountUser->isArchived()) { | 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); | $account->setSuperAdminUser($newOwner); | ||||
| @@ -160,7 +161,7 @@ class AccountController extends AbstractController | |||||
| if ($newEmail !== $user->getEmail()) { | if ($newEmail !== $user->getEmail()) { | ||||
| $existing = $this->userRepo->findOneBy(['email' => $newEmail]); | $existing = $this->userRepo->findOneBy(['email' => $newEmail]); | ||||
| if ($existing !== null && $existing->getId() !== $user->getId()) { | 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); | $user->setEmail($newEmail); | ||||
| } | } | ||||
| @@ -172,13 +173,13 @@ class AccountController extends AbstractController | |||||
| if (!empty($data['newPassword'])) { | if (!empty($data['newPassword'])) { | ||||
| if (empty($data['currentPassword'])) { | if (empty($data['currentPassword'])) { | ||||
| return $this->json(['error' => 'Aktuelles Passwort ist erforderlich.'], 400); | |||||
| return $this->json(['error' => $this->translator->trans('app.validation.password_current_required')], 400); | |||||
| } | } | ||||
| if (!$this->passwordHasher->isPasswordValid($user, $data['currentPassword'])) { | 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) { | 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'])); | $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\Request; | ||||
| use Symfony\Component\HttpFoundation\Response; | use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\Routing\Attribute\Route; | use Symfony\Component\Routing\Attribute\Route; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||||
| class ClientController extends AbstractController | class ClientController extends AbstractController | ||||
| { | { | ||||
| public function __construct( | 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')] | #[Route('/clients', name: 'client_index')] | ||||
| @@ -37,12 +39,12 @@ class ClientController extends AbstractController | |||||
| public function create(Request $request): JsonResponse | public function create(Request $request): JsonResponse | ||||
| { | { | ||||
| if ($this->roleHelper->isTracker()) { | 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); | $data = json_decode($request->getContent(), true); | ||||
| if (empty($data['name'])) { | 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(); | $client = new Client(); | ||||
| @@ -60,15 +62,15 @@ class ClientController extends AbstractController | |||||
| public function update(int $id, Request $request): JsonResponse | public function update(int $id, Request $request): JsonResponse | ||||
| { | { | ||||
| if ($this->roleHelper->isTracker()) { | 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); | $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); | $data = json_decode($request->getContent(), true); | ||||
| if (empty($data['name'])) { | 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'])); | $client->setName(trim($data['name'])); | ||||
| @@ -84,10 +86,10 @@ class ClientController extends AbstractController | |||||
| public function delete(int $id): JsonResponse | public function delete(int $id): JsonResponse | ||||
| { | { | ||||
| if ($this->roleHelper->isTracker()) { | 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); | $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) { | if ($this->timeEntryRepo->countByClient($client) > 0) { | ||||
| return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409); | return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409); | ||||
| @@ -103,10 +105,10 @@ class ClientController extends AbstractController | |||||
| public function archive(int $id): JsonResponse | public function archive(int $id): JsonResponse | ||||
| { | { | ||||
| if ($this->roleHelper->isTracker()) { | 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); | $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()); | $client->setArchivedAt(new \DateTimeImmutable()); | ||||
| $this->em->flush(); | $this->em->flush(); | ||||
| @@ -118,10 +120,10 @@ class ClientController extends AbstractController | |||||
| public function unarchive(int $id): JsonResponse | public function unarchive(int $id): JsonResponse | ||||
| { | { | ||||
| if ($this->roleHelper->isTracker()) { | 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); | $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); | $client->setArchivedAt(null); | ||||
| $this->em->flush(); | $this->em->flush(); | ||||
| @@ -26,41 +26,46 @@ class InviteController extends AbstractController | |||||
| private readonly Security $security, | 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')] | #[Route('/invite/{token}', name: 'app_invite')] | ||||
| public function setPassword(string $token, Request $request): Response | public function setPassword(string $token, Request $request): Response | ||||
| { | { | ||||
| $invite = $this->inviteTokenRepo->findOneBy(['token' => $token]); | $invite = $this->inviteTokenRepo->findOneBy(['token' => $token]); | ||||
| if ($invite === null) { | if ($invite === null) { | ||||
| return $this->render('invite/error.html.twig', [ | |||||
| 'error' => 'Dieser Einladungslink ist ungültig.', | |||||
| ]); | |||||
| return $this->renderInviteError('link_invalid'); | |||||
| } | } | ||||
| if ($invite->isExpired()) { | 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(); | $account = $this->tenantContext->getAccount(); | ||||
| if ($account === null || $account->getId() !== $invite->getAccount()?->getId()) { | 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; | $error = null; | ||||
| if ($request->isMethod('POST')) { | 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', ''); | $password = $request->request->get('password', ''); | ||||
| $passwordRepeat = $request->request->get('passwordRepeat', ''); | $passwordRepeat = $request->request->get('passwordRepeat', ''); | ||||
| if (strlen($password) < 8) { | if (strlen($password) < 8) { | ||||
| $error = 'Das Passwort muss mindestens 8 Zeichen haben.'; | |||||
| $error = 'too_short'; | |||||
| } elseif ($password !== $passwordRepeat) { | } elseif ($password !== $passwordRepeat) { | ||||
| $error = 'Die Passwörter stimmen nicht überein.'; | |||||
| $error = 'mismatch'; | |||||
| } else { | } else { | ||||
| // User anlegen (oder existierenden finden, falls E-Mail schon vorhanden) | // User anlegen (oder existierenden finden, falls E-Mail schon vorhanden) | ||||
| $user = $this->userRepo->findOneBy(['email' => $invite->getEmail()]); | $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\PasswordHasher\Hasher\UserPasswordHasherInterface; | ||||
| use Symfony\Component\Routing\Attribute\Route; | use Symfony\Component\Routing\Attribute\Route; | ||||
| use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||||
| class PasswordResetController extends AbstractController | class PasswordResetController extends AbstractController | ||||
| { | { | ||||
| @@ -30,6 +31,7 @@ class PasswordResetController extends AbstractController | |||||
| private readonly UserPasswordHasherInterface $passwordHasher, | private readonly UserPasswordHasherInterface $passwordHasher, | ||||
| private readonly Security $security, | private readonly Security $security, | ||||
| private readonly UrlGeneratorInterface $urlGenerator, | private readonly UrlGeneratorInterface $urlGenerator, | ||||
| private readonly TranslatorInterface $translator, | |||||
| ) {} | ) {} | ||||
| #[Route('/forgot-password', name: 'app_forgot_password', methods: ['GET', 'POST'])] | #[Route('/forgot-password', name: 'app_forgot_password', methods: ['GET', 'POST'])] | ||||
| @@ -45,6 +47,15 @@ class PasswordResetController extends AbstractController | |||||
| $error = null; | $error = null; | ||||
| if ($request->isMethod('POST')) { | 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', '')); | $email = trim($request->request->get('email', '')); | ||||
| if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { | if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { | ||||
| @@ -115,6 +126,16 @@ class PasswordResetController extends AbstractController | |||||
| $error = null; | $error = null; | ||||
| if ($request->isMethod('POST')) { | 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', ''); | $password = $request->request->get('password', ''); | ||||
| $passwordRepeat = $request->request->get('passwordRepeat', ''); | $passwordRepeat = $request->request->get('passwordRepeat', ''); | ||||
| @@ -163,7 +184,7 @@ class PasswordResetController extends AbstractController | |||||
| $email = (new TemplatedEmail()) | $email = (new TemplatedEmail()) | ||||
| ->to(new Address($user->getEmail(), $user->getFullName())) | ->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') | ->htmlTemplate('email/password_reset.html.twig') | ||||
| ->context([ | ->context([ | ||||
| 'token' => $token, | 'token' => $token, | ||||
| @@ -13,15 +13,17 @@ use Symfony\Component\HttpFoundation\JsonResponse; | |||||
| use Symfony\Component\HttpFoundation\Request; | use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpFoundation\Response; | use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\Routing\Attribute\Route; | use Symfony\Component\Routing\Attribute\Route; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||||
| class ProjectController extends AbstractController | class ProjectController extends AbstractController | ||||
| { | { | ||||
| public function __construct( | 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')] | #[Route('/projects', name: 'project_index')] | ||||
| @@ -40,13 +42,13 @@ class ProjectController extends AbstractController | |||||
| public function create(Request $request): JsonResponse | public function create(Request $request): JsonResponse | ||||
| { | { | ||||
| if ($this->roleHelper->isTracker()) { | 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); | $data = json_decode($request->getContent(), true); | ||||
| $client = $this->clientRepo->find($data['clientId'] ?? 0); | $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 = new Project(); | ||||
| $project->setName(trim($data['name'])); | $project->setName(trim($data['name'])); | ||||
| @@ -63,16 +65,16 @@ class ProjectController extends AbstractController | |||||
| public function update(int $id, Request $request): JsonResponse | public function update(int $id, Request $request): JsonResponse | ||||
| { | { | ||||
| if ($this->roleHelper->isTracker()) { | 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); | $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); | $data = json_decode($request->getContent(), true); | ||||
| $client = $this->clientRepo->find($data['clientId'] ?? 0); | $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->setName(trim($data['name'])); | ||||
| $project->setClient($client); | $project->setClient($client); | ||||
| @@ -87,10 +89,10 @@ class ProjectController extends AbstractController | |||||
| public function delete(int $id): JsonResponse | public function delete(int $id): JsonResponse | ||||
| { | { | ||||
| if ($this->roleHelper->isTracker()) { | 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); | $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) { | if ($this->timeEntryRepo->countByProject($project) > 0) { | ||||
| return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409); | return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409); | ||||
| @@ -106,10 +108,10 @@ class ProjectController extends AbstractController | |||||
| public function archive(int $id): JsonResponse | public function archive(int $id): JsonResponse | ||||
| { | { | ||||
| if ($this->roleHelper->isTracker()) { | 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); | $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()); | $project->setArchivedAt(new \DateTimeImmutable()); | ||||
| $this->em->flush(); | $this->em->flush(); | ||||
| @@ -121,10 +123,10 @@ class ProjectController extends AbstractController | |||||
| public function unarchive(int $id): JsonResponse | public function unarchive(int $id): JsonResponse | ||||
| { | { | ||||
| if ($this->roleHelper->isTracker()) { | 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); | $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); | $project->setArchivedAt(null); | ||||
| $this->em->flush(); | $this->em->flush(); | ||||
| @@ -10,12 +10,14 @@ use Symfony\Component\HttpFoundation\JsonResponse; | |||||
| use Symfony\Component\HttpFoundation\Request; | use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpFoundation\Response; | use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\Routing\Attribute\Route; | use Symfony\Component\Routing\Attribute\Route; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||||
| class RegistrationController extends AbstractController | class RegistrationController extends AbstractController | ||||
| { | { | ||||
| public function __construct( | public function __construct( | ||||
| private readonly RegistrationService $registrationService, | private readonly RegistrationService $registrationService, | ||||
| private readonly SlugGenerator $slugGenerator, | private readonly SlugGenerator $slugGenerator, | ||||
| private readonly TranslatorInterface $translator, | |||||
| private readonly string $appDomain, | private readonly string $appDomain, | ||||
| private readonly LoggerInterface $logger, | private readonly LoggerInterface $logger, | ||||
| ) {} | ) {} | ||||
| @@ -62,12 +64,12 @@ class RegistrationController extends AbstractController | |||||
| $passwordRepeat = $data['passwordRepeat'] ?? ''; | $passwordRepeat = $data['passwordRepeat'] ?? ''; | ||||
| $errors = []; | $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)) { | if (!empty($errors)) { | ||||
| return $this->json(['errors' => $errors], Response::HTTP_UNPROCESSABLE_ENTITY); | 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); | return $this->json(['errors' => [$e->getMessage()]], Response::HTTP_UNPROCESSABLE_ENTITY); | ||||
| } catch (\Throwable $e) { | } catch (\Throwable $e) { | ||||
| $this->logger->error('Registration failed: ' . $e->getMessage(), ['exception' => $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')] | #[Route('/verify/{token}', name: 'app_verify')] | ||||
| @@ -7,16 +7,20 @@ use App\Repository\Tenant\ClientRepository; | |||||
| use App\Repository\Tenant\TimeEntryRepository; | use App\Repository\Tenant\TimeEntryRepository; | ||||
| use App\Repository\Tenant\ProjectRepository; | use App\Repository\Tenant\ProjectRepository; | ||||
| use App\Repository\Tenant\ServiceRepository; | use App\Repository\Tenant\ServiceRepository; | ||||
| use App\Service\ReportExportService; | |||||
| use App\Service\TenantContext; | use App\Service\TenantContext; | ||||
| use App\Entity\Central\User; | use App\Entity\Central\User; | ||||
| use App\Service\AccountRoleHelper; | use App\Service\AccountRoleHelper; | ||||
| use Doctrine\ORM\EntityManagerInterface; | use Doctrine\ORM\EntityManagerInterface; | ||||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | ||||
| use Symfony\Component\HttpFoundation\BinaryFileResponse; | |||||
| use Symfony\Component\HttpFoundation\JsonResponse; | use Symfony\Component\HttpFoundation\JsonResponse; | ||||
| use Symfony\Component\HttpFoundation\Request; | use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpFoundation\Response; | use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\HttpFoundation\ResponseHeaderBag; | |||||
| use Symfony\Component\Routing\Attribute\Route; | use Symfony\Component\Routing\Attribute\Route; | ||||
| use Symfony\Bundle\SecurityBundle\Security; | use Symfony\Bundle\SecurityBundle\Security; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||||
| class ReportController extends AbstractController | class ReportController extends AbstractController | ||||
| { | { | ||||
| @@ -32,6 +36,8 @@ class ReportController extends AbstractController | |||||
| private readonly ProjectRepository $projectRepo, | private readonly ProjectRepository $projectRepo, | ||||
| private readonly ServiceRepository $serviceRepo, | private readonly ServiceRepository $serviceRepo, | ||||
| private readonly ClientRepository $clientRepo, | private readonly ClientRepository $clientRepo, | ||||
| private readonly ReportExportService $exportService, | |||||
| private readonly TranslatorInterface $translator, | |||||
| ) {} | ) {} | ||||
| #[Route('/reports/times', name: 'report_times')] | #[Route('/reports/times', name: 'report_times')] | ||||
| @@ -48,15 +54,11 @@ class ReportController extends AbstractController | |||||
| $isAdmin = $this->roleHelper->isAdmin(); | $isAdmin = $this->roleHelper->isAdmin(); | ||||
| $isTracker = $this->roleHelper->isTracker(); | $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) | // User-Liste für Filter-Dropdown (für Twig/JS) | ||||
| $account = $this->tenantContext->getAccount(); | |||||
| $accountUsers = $this->accountUserRepo->findBy(['account' => $account]); | |||||
| $userList = []; | $userList = []; | ||||
| foreach ($accountUsers as $au) { | foreach ($accountUsers as $au) { | ||||
| $userList[] = [ | $userList[] = [ | ||||
| @@ -66,14 +68,8 @@ class ReportController extends AbstractController | |||||
| ]; | ]; | ||||
| } | } | ||||
| // Filter aus GET-Parametern lesen | |||||
| $filterRaw = $request->query->all('filter'); | $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") | // Ob der Benutzer explizit Filter gesetzt hat (für "Alle anzeigen") | ||||
| $filterActive = !empty($request->query->all('filter')); | $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 ──────────────────────────────────────────────────────── | // ── Filter-Parsing ──────────────────────────────────────────────────────── | ||||
| private function parseFilters(array $f): array | private function parseFilters(array $f): array | ||||
| @@ -250,13 +334,13 @@ class ReportController extends AbstractController | |||||
| { | { | ||||
| $entry = $this->timeEntryRepo->find($id); | $entry = $this->timeEntryRepo->find($id); | ||||
| if (!$entry) { | if (!$entry) { | ||||
| return $this->json(['error' => 'Nicht gefunden'], 404); | |||||
| return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||||
| } | } | ||||
| /** @var User $currentUser */ | /** @var User $currentUser */ | ||||
| $currentUser = $this->security->getUser(); | $currentUser = $this->security->getUser(); | ||||
| if (!$this->roleHelper->isAdmin() && $entry->getUserId() !== $currentUser->getId()) { | 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()); | $entry->setInvoiced(!$entry->isInvoiced()); | ||||
| @@ -12,14 +12,16 @@ use Symfony\Component\HttpFoundation\JsonResponse; | |||||
| use Symfony\Component\HttpFoundation\Request; | use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpFoundation\Response; | use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\Routing\Attribute\Route; | use Symfony\Component\Routing\Attribute\Route; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||||
| class ServiceController extends AbstractController | class ServiceController extends AbstractController | ||||
| { | { | ||||
| public function __construct( | 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')] | #[Route('/services', name: 'service_index')] | ||||
| @@ -37,11 +39,11 @@ class ServiceController extends AbstractController | |||||
| public function create(Request $request): JsonResponse | public function create(Request $request): JsonResponse | ||||
| { | { | ||||
| if ($this->roleHelper->isTracker()) { | 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); | $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 = new Service(); | ||||
| $service->setName(trim($data['name'])); | $service->setName(trim($data['name'])); | ||||
| @@ -58,14 +60,14 @@ class ServiceController extends AbstractController | |||||
| public function update(int $id, Request $request): JsonResponse | public function update(int $id, Request $request): JsonResponse | ||||
| { | { | ||||
| if ($this->roleHelper->isTracker()) { | 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); | $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); | $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->setName(trim($data['name'])); | ||||
| $service->setBillable((bool) ($data['billable'] ?? true)); | $service->setBillable((bool) ($data['billable'] ?? true)); | ||||
| @@ -80,10 +82,10 @@ class ServiceController extends AbstractController | |||||
| public function delete(int $id): JsonResponse | public function delete(int $id): JsonResponse | ||||
| { | { | ||||
| if ($this->roleHelper->isTracker()) { | 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); | $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) { | if ($this->timeEntryRepo->countByService($service) > 0) { | ||||
| return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409); | return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409); | ||||
| @@ -99,10 +101,10 @@ class ServiceController extends AbstractController | |||||
| public function archive(int $id): JsonResponse | public function archive(int $id): JsonResponse | ||||
| { | { | ||||
| if ($this->roleHelper->isTracker()) { | 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); | $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()); | $service->setArchivedAt(new \DateTimeImmutable()); | ||||
| $this->em->flush(); | $this->em->flush(); | ||||
| @@ -114,10 +116,10 @@ class ServiceController extends AbstractController | |||||
| public function unarchive(int $id): JsonResponse | public function unarchive(int $id): JsonResponse | ||||
| { | { | ||||
| if ($this->roleHelper->isTracker()) { | 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); | $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); | $service->setArchivedAt(null); | ||||
| $this->em->flush(); | $this->em->flush(); | ||||
| @@ -21,6 +21,7 @@ use Symfony\Component\Mailer\MailerInterface; | |||||
| use Symfony\Component\Mime\Address; | use Symfony\Component\Mime\Address; | ||||
| use Symfony\Component\Routing\Attribute\Route; | use Symfony\Component\Routing\Attribute\Route; | ||||
| use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||||
| class TeamController extends AbstractController | class TeamController extends AbstractController | ||||
| { | { | ||||
| @@ -34,6 +35,7 @@ class TeamController extends AbstractController | |||||
| private readonly AccountRoleHelper $roleHelper, | private readonly AccountRoleHelper $roleHelper, | ||||
| private readonly MailerInterface $mailer, | private readonly MailerInterface $mailer, | ||||
| private readonly UrlGeneratorInterface $urlGenerator, | private readonly UrlGeneratorInterface $urlGenerator, | ||||
| private readonly TranslatorInterface $translator, | |||||
| private readonly string $appDomain, | private readonly string $appDomain, | ||||
| ) {} | ) {} | ||||
| @@ -63,7 +65,7 @@ class TeamController extends AbstractController | |||||
| public function invite(Request $request): JsonResponse | public function invite(Request $request): JsonResponse | ||||
| { | { | ||||
| if (!$this->roleHelper->isAdmin()) { | 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) ?? []; | $data = json_decode($request->getContent(), true) ?? []; | ||||
| @@ -73,12 +75,12 @@ class TeamController extends AbstractController | |||||
| $role = $data['role'] ?? AccountUser::ROLE_MEMBER; | $role = $data['role'] ?? AccountUser::ROLE_MEMBER; | ||||
| $errors = []; | $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)) { | 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)) { | if (!empty($errors)) { | ||||
| @@ -94,7 +96,7 @@ class TeamController extends AbstractController | |||||
| 'user' => $existingUser, | 'user' => $existingUser, | ||||
| ]); | ]); | ||||
| if ($alreadyMember !== null) { | 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 | public function archive(int $id): JsonResponse | ||||
| { | { | ||||
| if (!$this->roleHelper->isAdmin()) { | 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(); | $account = $this->tenantContext->getAccount(); | ||||
| $accountUser = $this->accountUserRepo->find($id); | $accountUser = $this->accountUserRepo->find($id); | ||||
| if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) { | 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()) { | 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()) { | 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()); | $accountUser->setArchivedAt(new \DateTimeImmutable()); | ||||
| @@ -152,14 +154,14 @@ class TeamController extends AbstractController | |||||
| public function unarchive(int $id): JsonResponse | public function unarchive(int $id): JsonResponse | ||||
| { | { | ||||
| if (!$this->roleHelper->isAdmin()) { | 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(); | $account = $this->tenantContext->getAccount(); | ||||
| $accountUser = $this->accountUserRepo->find($id); | $accountUser = $this->accountUserRepo->find($id); | ||||
| if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) { | 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); | $accountUser->setArchivedAt(null); | ||||
| @@ -172,30 +174,30 @@ class TeamController extends AbstractController | |||||
| public function edit(int $id, Request $request): JsonResponse | public function edit(int $id, Request $request): JsonResponse | ||||
| { | { | ||||
| if (!$this->roleHelper->isAdmin()) { | 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(); | $account = $this->tenantContext->getAccount(); | ||||
| $accountUser = $this->accountUserRepo->find($id); | $accountUser = $this->accountUserRepo->find($id); | ||||
| if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) { | 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) ?? []; | $data = json_decode($request->getContent(), true) ?? []; | ||||
| $firstName = trim($data['firstName'] ?? ''); | $firstName = trim($data['firstName'] ?? ''); | ||||
| $lastName = trim($data['lastName'] ?? ''); | $lastName = trim($data['lastName'] ?? ''); | ||||
| $email = trim($data['email'] ?? ''); | $email = trim($data['email'] ?? ''); | ||||
| $note = $data['note'] !== '' ? ($data['note'] ?? null) : null; | |||||
| $note = !empty($data['note']) ? $data['note'] : null; | |||||
| $role = $data['role'] ?? null; | $role = $data['role'] ?? null; | ||||
| $errors = []; | $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)) { | 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)) { | if (!empty($errors)) { | ||||
| @@ -208,14 +210,13 @@ class TeamController extends AbstractController | |||||
| if ($email !== $user->getEmail()) { | if ($email !== $user->getEmail()) { | ||||
| $existing = $this->userRepo->findOneBy(['email' => $email]); | $existing = $this->userRepo->findOneBy(['email' => $email]); | ||||
| if ($existing !== null) { | 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()); | $isSelf = ($user === $this->getUser()); | ||||
| if ($isSelf && $accountUser->isAdmin() && $role !== null && $role !== AccountUser::ROLE_ADMIN) { | 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); | $user->setFirstName($firstName); | ||||
| @@ -236,22 +237,22 @@ class TeamController extends AbstractController | |||||
| public function delete(int $id): JsonResponse | public function delete(int $id): JsonResponse | ||||
| { | { | ||||
| if (!$this->roleHelper->isAdmin()) { | 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(); | $account = $this->tenantContext->getAccount(); | ||||
| $accountUser = $this->accountUserRepo->find($id); | $accountUser = $this->accountUserRepo->find($id); | ||||
| if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) { | 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()) { | 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()) { | 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(); | $userId = $accountUser->getUser()->getId(); | ||||
| @@ -269,14 +270,14 @@ class TeamController extends AbstractController | |||||
| public function deleteInvite(int $id): JsonResponse | public function deleteInvite(int $id): JsonResponse | ||||
| { | { | ||||
| if (!$this->roleHelper->isAdmin()) { | 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(); | $account = $this->tenantContext->getAccount(); | ||||
| $invite = $this->inviteTokenRepo->find($id); | $invite = $this->inviteTokenRepo->find($id); | ||||
| if ($invite === null || $invite->getAccount()?->getId() !== $account?->getId()) { | 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); | $this->em->remove($invite); | ||||
| @@ -295,7 +296,7 @@ class TeamController extends AbstractController | |||||
| 'email' => $au->getUser()->getEmail(), | 'email' => $au->getUser()->getEmail(), | ||||
| 'note' => $au->getUser()->getNote(), | 'note' => $au->getUser()->getNote(), | ||||
| 'role' => $au->getRole(), | '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()) | $email = (new TemplatedEmail()) | ||||
| ->to(new Address($invite->getEmail(), $invite->getFirstName() . ' ' . $invite->getLastName())) | ->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') | ->htmlTemplate('email/team_invite.html.twig') | ||||
| ->context([ | ->context([ | ||||
| 'invite' => $invite, | 'invite' => $invite, | ||||
| @@ -16,6 +16,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; | |||||
| use Symfony\Component\HttpFoundation\Request; | use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpFoundation\Response; | use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\Routing\Attribute\Route; | use Symfony\Component\Routing\Attribute\Route; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||||
| class TimeTrackingController extends AbstractController | class TimeTrackingController extends AbstractController | ||||
| { | { | ||||
| @@ -27,6 +28,7 @@ class TimeTrackingController extends AbstractController | |||||
| private readonly TenantContext $tenantContext, | private readonly TenantContext $tenantContext, | ||||
| private readonly AccountRoleHelper $roleHelper, | private readonly AccountRoleHelper $roleHelper, | ||||
| private readonly Security $security, | private readonly Security $security, | ||||
| private readonly TranslatorInterface $translator, | |||||
| ) {} | ) {} | ||||
| // ── Hauptseite ──────────────────────────────────────────────────────────── | // ── Hauptseite ──────────────────────────────────────────────────────────── | ||||
| @@ -106,7 +108,7 @@ class TimeTrackingController extends AbstractController | |||||
| $project = $this->projectRepo->find($data['projectId'] ?? 0); | $project = $this->projectRepo->find($data['projectId'] ?? 0); | ||||
| if (!$project) { | 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'); | $tz = new \DateTimeZone('Europe/Berlin'); | ||||
| @@ -120,7 +122,7 @@ class TimeTrackingController extends AbstractController | |||||
| $newDuration = $this->parseDuration($data['duration'] ?? '0'); | $newDuration = $this->parseDuration($data['duration'] ?? '0'); | ||||
| $currentTotal = $this->timeEntryRepo->sumDurationByDateAndUserId($date, $user->getId()); | $currentTotal = $this->timeEntryRepo->sumDurationByDateAndUserId($date, $user->getId()); | ||||
| if ($currentTotal + $newDuration > 1440) { | 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(); | $entry = new TimeEntry(); | ||||
| @@ -149,20 +151,20 @@ class TimeTrackingController extends AbstractController | |||||
| { | { | ||||
| $entry = $this->timeEntryRepo->find($id); | $entry = $this->timeEntryRepo->find($id); | ||||
| if (!$entry) { | if (!$entry) { | ||||
| return $this->json(['error' => 'Nicht gefunden'], 404); | |||||
| return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||||
| } | } | ||||
| /** @var User $currentUser */ | /** @var User $currentUser */ | ||||
| $currentUser = $this->security->getUser(); | $currentUser = $this->security->getUser(); | ||||
| if (!$this->roleHelper->isAdmin() && $entry->getUserId() !== $currentUser->getId()) { | 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); | $data = json_decode($request->getContent(), true); | ||||
| $project = $this->projectRepo->find($data['projectId'] ?? 0); | $project = $this->projectRepo->find($data['projectId'] ?? 0); | ||||
| if (!$project) { | 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; | $service = null; | ||||
| @@ -173,7 +175,7 @@ class TimeTrackingController extends AbstractController | |||||
| $newDuration = $this->parseDuration($data['duration'] ?? '0'); | $newDuration = $this->parseDuration($data['duration'] ?? '0'); | ||||
| $currentTotal = $this->timeEntryRepo->sumDurationByDateAndUserId($entry->getDate(), $entry->getUserId()); | $currentTotal = $this->timeEntryRepo->sumDurationByDateAndUserId($entry->getDate(), $entry->getUserId()); | ||||
| if ($currentTotal - $entry->getDuration() + $newDuration > 1440) { | 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); | $entry->setProject($project); | ||||
| @@ -201,13 +203,13 @@ class TimeTrackingController extends AbstractController | |||||
| { | { | ||||
| $entry = $this->timeEntryRepo->find($id); | $entry = $this->timeEntryRepo->find($id); | ||||
| if (!$entry) { | if (!$entry) { | ||||
| return $this->json(['error' => 'Nicht gefunden'], 404); | |||||
| return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||||
| } | } | ||||
| /** @var User $currentUser */ | /** @var User $currentUser */ | ||||
| $currentUser = $this->security->getUser(); | $currentUser = $this->security->getUser(); | ||||
| if (!$this->roleHelper->isAdmin() && $entry->getUserId() !== $currentUser->getId()) { | 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(); | $date = $entry->getDate(); | ||||
| @@ -261,13 +263,15 @@ class TimeTrackingController extends AbstractController | |||||
| { | { | ||||
| $hour = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('H'); | $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 | private function parseDuration(string $input): int | ||||
| @@ -53,13 +53,8 @@ class AccountUser | |||||
| public function isTracker(): bool { return $this->role === self::ROLE_TRACKER; } | public function isTracker(): bool { return $this->role === self::ROLE_TRACKER; } | ||||
| public function isMemberOrAdmin(): bool { return $this->isAdmin() || $this->isMember(); } | 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\HttpKernel\KernelEvents; | ||||
| use Symfony\Component\Routing\RouterInterface; | use Symfony\Component\Routing\RouterInterface; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; | use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||||
| class ArchivedUserSubscriber implements EventSubscriberInterface | class ArchivedUserSubscriber implements EventSubscriberInterface | ||||
| { | { | ||||
| @@ -19,9 +20,8 @@ class ArchivedUserSubscriber implements EventSubscriberInterface | |||||
| private readonly AccountUserRepository $accountUserRepo, | private readonly AccountUserRepository $accountUserRepo, | ||||
| private readonly TenantContext $tenantContext, | private readonly TenantContext $tenantContext, | ||||
| private readonly RouterInterface $router, | private readonly RouterInterface $router, | ||||
| ) | |||||
| { | |||||
| } | |||||
| private readonly TranslatorInterface $translator, | |||||
| ) {} | |||||
| public function onKernelRequest(RequestEvent $event): void | public function onKernelRequest(RequestEvent $event): void | ||||
| { | { | ||||
| @@ -59,7 +59,7 @@ class ArchivedUserSubscriber implements EventSubscriberInterface | |||||
| // API: 401, sonst Redirect zu Login | // API: 401, sonst Redirect zu Login | ||||
| if (str_starts_with($request->getPathInfo(), '/api/')) { | 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 { | } else { | ||||
| $event->setResponse(new RedirectResponse($this->router->generate('app_login'))); | $event->setResponse(new RedirectResponse($this->router->generate('app_login'))); | ||||
| } | } | ||||
| @@ -46,104 +46,6 @@ class TimeEntryRepository extends ServiceEntityRepository | |||||
| return (int) $result; | 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 ──────────────────────────────────────── | // ── Zähler für abhängige Entitäten ──────────────────────────────────────── | ||||
| public function countByProject(Project $project): int | public function countByProject(Project $project): int | ||||
| @@ -263,6 +165,16 @@ class TimeEntryRepository extends ServiceEntityRepository | |||||
| ->getResult(); | ->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 | public function countFiltered(array $filters): int | ||||
| { | { | ||||
| return (int) $this->buildFilteredQuery($filters) | 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\Exception\CustomUserMessageAccountStatusException; | ||||
| use Symfony\Component\Security\Core\User\UserCheckerInterface; | use Symfony\Component\Security\Core\User\UserCheckerInterface; | ||||
| use Symfony\Component\Security\Core\User\UserInterface; | use Symfony\Component\Security\Core\User\UserInterface; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||||
| class ArchivedUserChecker implements UserCheckerInterface | class ArchivedUserChecker implements UserCheckerInterface | ||||
| { | { | ||||
| public function __construct( | public function __construct( | ||||
| private readonly AccountUserRepository $accountUserRepo, | private readonly AccountUserRepository $accountUserRepo, | ||||
| private readonly TenantContext $tenantContext, | private readonly TenantContext $tenantContext, | ||||
| ) | |||||
| { | |||||
| } | |||||
| private readonly TranslatorInterface $translator, | |||||
| ) {} | |||||
| public function checkPreAuth(UserInterface $user): void | public function checkPreAuth(UserInterface $user): void | ||||
| { | { | ||||
| @@ -34,7 +34,7 @@ class ArchivedUserChecker implements UserCheckerInterface | |||||
| ]); | ]); | ||||
| if ($accountUser !== null && $accountUser->isArchived()) { | 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 | class AccountRoleHelper | ||||
| { | { | ||||
| private ?AccountUser $cached = null; | |||||
| private bool $resolved = false; | |||||
| public function __construct( | public function __construct( | ||||
| private readonly Security $security, | private readonly Security $security, | ||||
| private readonly TenantContext $tenantContext, | private readonly TenantContext $tenantContext, | ||||
| @@ -20,6 +23,10 @@ class AccountRoleHelper | |||||
| public function getCurrentAccountUser(): ?AccountUser | public function getCurrentAccountUser(): ?AccountUser | ||||
| { | { | ||||
| if ($this->resolved) { | |||||
| return $this->cached; | |||||
| } | |||||
| $user = $this->security->getUser(); | $user = $this->security->getUser(); | ||||
| $account = $this->tenantContext->getAccount(); | $account = $this->tenantContext->getAccount(); | ||||
| @@ -27,10 +34,13 @@ class AccountRoleHelper | |||||
| return null; | return null; | ||||
| } | } | ||||
| return $this->accountUserRepo->findOneBy([ | |||||
| $this->cached = $this->accountUserRepo->findOneBy([ | |||||
| 'account' => $account, | 'account' => $account, | ||||
| 'user' => $user, | 'user' => $user, | ||||
| ]); | ]); | ||||
| $this->resolved = true; | |||||
| return $this->cached; | |||||
| } | } | ||||
| public function isAdmin(): bool { return $this->getCurrentAccountUser()?->isAdmin() ?? false; } | 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\Mime\Address; | ||||
| use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; | use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; | ||||
| use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | |||||
| class RegistrationService | class RegistrationService | ||||
| { | { | ||||
| @@ -24,6 +25,7 @@ class RegistrationService | |||||
| private readonly UserPasswordHasherInterface $passwordHasher, | private readonly UserPasswordHasherInterface $passwordHasher, | ||||
| private readonly UrlGeneratorInterface $urlGenerator, | private readonly UrlGeneratorInterface $urlGenerator, | ||||
| private readonly SlugGenerator $slugGenerator, | private readonly SlugGenerator $slugGenerator, | ||||
| private readonly TranslatorInterface $translator, | |||||
| private readonly string $appDomain, | private readonly string $appDomain, | ||||
| private readonly string $notifyEmail, | private readonly string $notifyEmail, | ||||
| ) {} | ) {} | ||||
| @@ -41,7 +43,7 @@ class RegistrationService | |||||
| // E-Mail bereits vergeben? | // E-Mail bereits vergeben? | ||||
| $existingUser = $this->centralEm->getRepository(User::class)->findOneBy(['email' => $email]); | $existingUser = $this->centralEm->getRepository(User::class)->findOneBy(['email' => $email]); | ||||
| if ($existingUser !== null) { | 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) | // 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]); | $token = $this->centralEm->getRepository(RegistrationToken::class)->findOneBy(['token' => $tokenString]); | ||||
| if ($token === null) { | if ($token === null) { | ||||
| throw new \InvalidArgumentException('Ungültiger Bestätigungslink.'); | |||||
| throw new \InvalidArgumentException($this->translator->trans('app.registration.confirm_invalid')); | |||||
| } | } | ||||
| if ($token->isExpired()) { | if ($token->isExpired()) { | ||||
| $this->centralEm->remove($token); | $this->centralEm->remove($token); | ||||
| $this->centralEm->flush(); | $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 | // Account anlegen | ||||
| @@ -150,7 +152,7 @@ class RegistrationService | |||||
| $email = (new TemplatedEmail()) | $email = (new TemplatedEmail()) | ||||
| ->to(new Address($token->getEmail(), $token->getFirstName() . ' ' . $token->getLastName())) | ->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') | ->htmlTemplate('email/registration_confirm.html.twig') | ||||
| ->context([ | ->context([ | ||||
| 'token' => $token, | 'token' => $token, | ||||
| @@ -166,7 +168,7 @@ class RegistrationService | |||||
| $email = (new TemplatedEmail()) | $email = (new TemplatedEmail()) | ||||
| ->to(new Address($user->getEmail(), $user->getFullName())) | ->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') | ->htmlTemplate('email/registration_welcome.html.twig') | ||||
| ->context([ | ->context([ | ||||
| 'user' => $user, | 'user' => $user, | ||||
| @@ -181,7 +183,7 @@ class RegistrationService | |||||
| { | { | ||||
| $email = (new TemplatedEmail()) | $email = (new TemplatedEmail()) | ||||
| ->to($this->notifyEmail) | ->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') | ->htmlTemplate('email/registration_notify.html.twig') | ||||
| ->context([ | ->context([ | ||||
| 'user' => $user, | '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> | <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> | <h1 class="register-success__title">{{ title }}</h1> | ||||
| <p class="register-success__text">{{ text|raw }}</p> | <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> | </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) #} | {# Hamburger-Navigation — nur im Minimal-Theme sichtbar (via CSS) #} | ||||
| <div class="hamburger-nav" id="hamburger-nav"> | <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> | <span class="hamburger-nav__icon"></span> | ||||
| </button> | </button> | ||||
| <div class="hamburger-nav__panel" id="hamburger-panel" hidden> | <div class="hamburger-nav__panel" id="hamburger-panel" hidden> | ||||
| @@ -3,42 +3,22 @@ | |||||
| months, monthsShort, weekdays, weekDays, currentWeekNumber, | months, monthsShort, weekdays, weekDays, currentWeekNumber, | ||||
| prevWeekUrl, nextWeekUrl #} | prevWeekUrl, nextWeekUrl #} | ||||
| {% from '_macros/helpers.html.twig' import smart_date %} | |||||
| <header class="tt-header"> | <header class="tt-header"> | ||||
| {# Minimal-Modus: kompakter Header mit Toggle #} | {# Minimal-Modus: kompakter Header mit Toggle #} | ||||
| <div class="tt-header__minimal-bar"> | <div class="tt-header__minimal-bar"> | ||||
| <div class="tt-header__minimal-date"> | <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> | </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> | </button> | ||||
| </div> | </div> | ||||
| <div class="tt-header__meta"> | <div class="tt-header__meta"> | ||||
| <div class="tt-header__date"> | <div class="tt-header__date"> | ||||
| {% set activStr = currentDate|date('Y-m-d') %} | |||||
| {% set 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> | ||||
| <div class="tt-header__kw">{{ 'app.date.week_label'|trans }} {{ currentWeekNumber }}</div> | <div class="tt-header__kw">{{ 'app.date.week_label'|trans }} {{ currentWeekNumber }}</div> | ||||
| </div> | </div> | ||||
| @@ -2,7 +2,7 @@ | |||||
| {% extends 'base.html.twig' %} | {% extends 'base.html.twig' %} | ||||
| {% block title %} | {% 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 %} | {% endblock %} | ||||
| {% block body %} | {% block body %} | ||||
| @@ -12,6 +12,18 @@ | |||||
| tab: '{{ tab }}', | tab: '{{ tab }}', | ||||
| isSuperAdmin: {{ isSuperAdmin ? 'true' : 'false' }}, | isSuperAdmin: {{ isSuperAdmin ? 'true' : 'false' }}, | ||||
| theme: '{{ user.theme|default('standard') }}', | 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> | </script> | ||||
| @@ -19,18 +31,18 @@ | |||||
| <div class="account-header"> | <div class="account-header"> | ||||
| <h1 class="account-header__title"> | <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> | </h1> | ||||
| {% if isAdmin %} | {% if isAdmin %} | ||||
| <nav class="account-tabs"> | <nav class="account-tabs"> | ||||
| <a href="{{ path('account_index', {tab: 'account'}) }}" | <a href="{{ path('account_index', {tab: 'account'}) }}" | ||||
| class="account-tab{% if tab == 'account' %} account-tab--active{% endif %}"> | class="account-tab{% if tab == 'account' %} account-tab--active{% endif %}"> | ||||
| Account | |||||
| {{ 'app.account.tab_account'|trans }} | |||||
| </a> | </a> | ||||
| <a href="{{ path('account_index', {tab: 'user'}) }}" | <a href="{{ path('account_index', {tab: 'user'}) }}" | ||||
| class="account-tab{% if tab == 'user' %} account-tab--active{% endif %}"> | class="account-tab{% if tab == 'user' %} account-tab--active{% endif %}"> | ||||
| Mein Benutzer | |||||
| {{ 'app.account.tab_user'|trans }} | |||||
| </a> | </a> | ||||
| </nav> | </nav> | ||||
| {% endif %} | {% endif %} | ||||
| @@ -44,16 +56,16 @@ | |||||
| <div class="account-form__grid" id="account-form"> | <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"> | <div class="account-form__field"> | ||||
| <input type="text" id="account-name" class="input" | <input type="text" id="account-name" class="input" | ||||
| value="{{ account.name|e('html_attr') }}" /> | value="{{ account.name|e('html_attr') }}" /> | ||||
| <span class="account-form__hint"> | <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> | </span> | ||||
| </div> | </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"> | <div class="account-form__field"> | ||||
| <select id="account-interval" class="select"> | <select id="account-interval" class="select"> | ||||
| {% for value, label in intervalOptions %} | {% for value, label in intervalOptions %} | ||||
| @@ -62,11 +74,11 @@ | |||||
| </option> | </option> | ||||
| {% endfor %} | {% endfor %} | ||||
| </select> | </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> | </div> | ||||
| {% if isSuperAdmin %} | {% 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-form__field"> | ||||
| <div class="account-color-field"> | <div class="account-color-field"> | ||||
| <input type="color" id="account-color-picker" | <input type="color" id="account-color-picker" | ||||
| @@ -77,13 +89,13 @@ | |||||
| class="input account-color-field__hex" | class="input account-color-field__hex" | ||||
| maxlength="7" placeholder="#3a7bbf" autocomplete="off" /> | maxlength="7" placeholder="#3a7bbf" autocomplete="off" /> | ||||
| </div> | </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> | </div> | ||||
| {% endif %} | {% endif %} | ||||
| <div class="account-form__actions"> | <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> | ||||
| </div> | </div> | ||||
| @@ -93,42 +105,42 @@ | |||||
| <div class="account-form__grid" id="user-form"> | <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"> | <div class="account-form__field"> | ||||
| <input type="text" id="user-firstname" class="input" | <input type="text" id="user-firstname" class="input" | ||||
| value="{{ user.firstName|e('html_attr') }}" /> | value="{{ user.firstName|e('html_attr') }}" /> | ||||
| </div> | </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"> | <div class="account-form__field"> | ||||
| <input type="text" id="user-lastname" class="input" | <input type="text" id="user-lastname" class="input" | ||||
| value="{{ user.lastName|e('html_attr') }}" /> | value="{{ user.lastName|e('html_attr') }}" /> | ||||
| </div> | </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"> | <div class="account-form__field"> | ||||
| <input type="email" id="user-email" class="input" | <input type="email" id="user-email" class="input" | ||||
| value="{{ user.email|e('html_attr') }}" /> | value="{{ user.email|e('html_attr') }}" /> | ||||
| </div> | </div> | ||||
| <label class="account-form__label">Passwort</label> | |||||
| <label class="account-form__label">{{ 'app.account.label_password'|trans }}</label> | |||||
| <div class="account-form__field"> | <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> | ||||
| <div class="account-form__pw-section" id="pw-section" hidden> | <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"> | <div class="account-form__field"> | ||||
| <input type="password" id="user-pw-current" class="input" autocomplete="current-password" /> | <input type="password" id="user-pw-current" class="input" autocomplete="current-password" /> | ||||
| </div> | </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"> | <div class="account-form__field"> | ||||
| <input type="password" id="user-pw-new" class="input" autocomplete="new-password" minlength="8" /> | <input type="password" id="user-pw-new" class="input" autocomplete="new-password" minlength="8" /> | ||||
| </div> | </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"> | <div class="account-form__field"> | ||||
| <input type="password" id="user-pw-repeat" class="input" autocomplete="new-password" /> | <input type="password" id="user-pw-repeat" class="input" autocomplete="new-password" /> | ||||
| </div> | </div> | ||||
| @@ -136,8 +148,8 @@ | |||||
| </div> | </div> | ||||
| <div class="account-form__actions"> | <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> | ||||
| </div> | </div> | ||||
| @@ -149,18 +161,18 @@ | |||||
| <hr class="account-form__divider"> | <hr class="account-form__divider"> | ||||
| </div> | </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="account-form__field"> | ||||
| <div class="theme-picker" id="theme-picker"> | <div class="theme-picker" id="theme-picker"> | ||||
| <label class="theme-option{% if user.theme|default('standard') == 'standard' %} theme-option--active{% endif %}" data-theme="standard"> | <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 %}> | <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> | ||||
| <label class="theme-option{% if user.theme|default('standard') == 'minimal' %} theme-option--active{% endif %}" data-theme="minimal"> | <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 %}> | <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> | </label> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| @@ -176,7 +188,7 @@ | |||||
| <div class="account-card account-card--owner"> | <div class="account-card account-card--owner"> | ||||
| <div class="account-form__grid"> | <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"> | <div class="account-form__field"> | ||||
| <select id="superadmin-select" class="select" | <select id="superadmin-select" class="select" | ||||
| {% if superAdminUserId != user.id %}disabled{% endif %}> | {% if superAdminUserId != user.id %}disabled{% endif %}> | ||||
| @@ -187,8 +199,7 @@ | |||||
| {% endfor %} | {% endfor %} | ||||
| </select> | </select> | ||||
| <p class="account-form__hint account-form__hint--owner"> | <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> | </p> | ||||
| </div> | </div> | ||||
| @@ -1,7 +1,7 @@ | |||||
| {# templates/client/index.html.twig #} | {# templates/client/index.html.twig #} | ||||
| {% extends 'base.html.twig' %} | {% extends 'base.html.twig' %} | ||||
| {% block title %}Kunden{% endblock %} | |||||
| {% block title %}{{ 'app.client.page_title'|trans }}{% endblock %} | |||||
| {% block body %} | {% block body %} | ||||
| @@ -9,46 +9,71 @@ | |||||
| window.CRUD = { | window.CRUD = { | ||||
| apiBase: '/api/clients', | apiBase: '/api/clients', | ||||
| clients: null, | 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> | </script> | ||||
| <div class="crud-page"> | <div class="crud-page"> | ||||
| <div class="crud-page__header"> | <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> | ||||
| <div class="crud-create" id="crud-create"> | <div class="crud-create" id="crud-create"> | ||||
| <div class="entry-form__grid"> | <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"> | <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> | </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> | </div> | ||||
| <label class="entry-form__label">Bemerkung</label> | |||||
| <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | |||||
| <div class="entry-form__field"> | <div class="entry-form__field"> | ||||
| <textarea id="create-note" class="textarea" rows="2"></textarea> | <textarea id="create-note" class="textarea" rows="2"></textarea> | ||||
| </div> | </div> | ||||
| <div class="entry-form__actions"> | <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> | ||||
| </div> | </div> | ||||
| <div class="crud-tabs"> | <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> | ||||
| <div class="crud-list" id="crud-list"> | <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__name">{{ client.name }}</span> | ||||
| <span class="crud-row__meta"> | <span class="crud-row__meta"> | ||||
| {% set count = client.projects|length %} | {% set count = client.projects|length %} | ||||
| {{ count }} {{ count == 1 ? 'Projekt' : 'Projekte' }} | |||||
| {{ count }} {{ count == 1 ? 'app.crud.project_singular'|trans : 'app.crud.project_plural'|trans }} | |||||
| </span> | </span> | ||||
| </div> | </div> | ||||
| <div class="crud-row__actions"> | <div class="crud-row__actions"> | ||||
| {% if client.isArchived() %} | {% 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' %} | {% include '_atoms/icon-restore.html.twig' %} | ||||
| </button> | </button> | ||||
| {% else %} | {% 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' %} | {% include '_atoms/icon-edit.html.twig' %} | ||||
| </button> | </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' %} | {% include '_atoms/icon-delete.html.twig' %} | ||||
| </button> | </button> | ||||
| {% endif %} | {% endif %} | ||||
| @@ -89,26 +114,26 @@ window.CRUD = { | |||||
| <div class="crud-row__edit" hidden> | <div class="crud-row__edit" hidden> | ||||
| <div class="entry-form__grid entry-form__grid--inline"> | <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"> | <div class="entry-form__field"> | ||||
| <input type="text" class="input edit-name" value="{{ client.name }}" /> | <input type="text" class="input edit-name" value="{{ client.name }}" /> | ||||
| </div> | </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" /> | 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> | </div> | ||||
| <label class="entry-form__label">Bemerkung</label> | |||||
| <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | |||||
| <div class="entry-form__field"> | <div class="entry-form__field"> | ||||
| <textarea class="textarea edit-note" rows="2">{{ client.note|default('') }}</textarea> | <textarea class="textarea edit-note" rows="2">{{ client.note|default('') }}</textarea> | ||||
| </div> | </div> | ||||
| <div class="entry-form__actions"> | <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> | ||||
| </div> | </div> | ||||
| @@ -117,8 +142,8 @@ window.CRUD = { | |||||
| </div> | </div> | ||||
| {% else %} | {% 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> | </div> | ||||
| {% endfor %} | {% endfor %} | ||||
| </div> | </div> | ||||
| @@ -4,7 +4,7 @@ | |||||
| <head> | <head> | ||||
| <meta charset="UTF-8"> | <meta charset="UTF-8"> | ||||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | <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') }} | {{ encore_entry_link_tags('app') }} | ||||
| </head> | </head> | ||||
| <body class="home-body"> | <body class="home-body"> | ||||
| @@ -12,15 +12,15 @@ | |||||
| <header class="home-header"> | <header class="home-header"> | ||||
| <div class="home-header__inner"> | <div class="home-header__inner"> | ||||
| <div class="home-header__brand">spawntree <span>Timetracker</span></div> | <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> | </div> | ||||
| </header> | </header> | ||||
| <main class="home-hero"> | <main class="home-hero"> | ||||
| <div class="home-hero__inner"> | <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> | </div> | ||||
| </main> | </main> | ||||
| @@ -10,7 +10,7 @@ | |||||
| <body class="login-body"> | <body class="login-body"> | ||||
| <div class="login-card"> | <div class="login-card"> | ||||
| <div class="login-card__title">{{ 'app.invite_error.title'|trans }}</div> | <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> | </div> | ||||
| </body> | </body> | ||||
| </html> | </html> | ||||
| @@ -14,11 +14,16 @@ | |||||
| <div class="login-card__title">{{ invite.account.name }}</div> | <div class="login-card__title">{{ invite.account.name }}</div> | ||||
| <p class="login-card__sub">{{ 'app.set_password.subtitle'|trans({'%name%': invite.firstName}) }}</p> | <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 %} | {% endif %} | ||||
| <form class="login-form" method="post"> | <form class="login-form" method="post"> | ||||
| <input type="hidden" name="_csrf_token" value="{{ csrf_token('invite_password') }}" /> | |||||
| <div class="login-form__grid"> | <div class="login-form__grid"> | ||||
| @@ -1,57 +1,82 @@ | |||||
| {# templates/project/index.html.twig #} | {# templates/project/index.html.twig #} | ||||
| {% extends 'base.html.twig' %} | {% extends 'base.html.twig' %} | ||||
| {% block title %}Projekte{% endblock %} | |||||
| {% block title %}{{ 'app.project.page_title'|trans }}{% endblock %} | |||||
| {% block body %} | {% block body %} | ||||
| <script> | <script> | ||||
| window.CRUD = { | window.CRUD = { | ||||
| apiBase: '/api/projects', | apiBase: '/api/projects', | ||||
| clients: {{ clients|map(c => { id: c.id, name: c.name })|json_encode|raw }}, | 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> | </script> | ||||
| <div class="crud-page"> | <div class="crud-page"> | ||||
| <div class="crud-page__header"> | <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> | ||||
| <div class="crud-create" id="crud-create"> | <div class="crud-create" id="crud-create"> | ||||
| <div class="entry-form__grid"> | <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"> | <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> | </div> | ||||
| <label class="entry-form__label">Kunde</label> | |||||
| <label class="entry-form__label">{{ 'app.crud.label_client'|trans }}</label> | |||||
| <div class="entry-form__field"> | <div class="entry-form__field"> | ||||
| <select id="create-client" class="select"> | <select id="create-client" class="select"> | ||||
| <option value="">Bitte wählen</option> | |||||
| <option value="">{{ 'app.crud.select_ph'|trans }}</option> | |||||
| {% for client in clients %} | {% for client in clients %} | ||||
| <option value="{{ client.id }}">{{ client.name }}</option> | <option value="{{ client.id }}">{{ client.name }}</option> | ||||
| {% endfor %} | {% endfor %} | ||||
| </select> | </select> | ||||
| </div> | </div> | ||||
| <label class="entry-form__label">Bemerkung</label> | |||||
| <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | |||||
| <div class="entry-form__field"> | <div class="entry-form__field"> | ||||
| <textarea id="create-note" class="textarea" rows="2"></textarea> | <textarea id="create-note" class="textarea" rows="2"></textarea> | ||||
| </div> | </div> | ||||
| <div class="entry-form__actions"> | <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> | ||||
| </div> | </div> | ||||
| <div class="crud-tabs"> | <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> | ||||
| <div class="crud-list" id="crud-list"> | <div class="crud-list" id="crud-list"> | ||||
| @@ -72,15 +97,15 @@ window.CRUD = { | |||||
| </div> | </div> | ||||
| <div class="crud-row__actions"> | <div class="crud-row__actions"> | ||||
| {% if project.isArchived() %} | {% 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> | </button> | ||||
| {% else %} | {% 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> | ||||
| <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> | </button> | ||||
| {% endif %} | {% endif %} | ||||
| </div> | </div> | ||||
| @@ -90,12 +115,12 @@ window.CRUD = { | |||||
| <div class="crud-row__edit" hidden> | <div class="crud-row__edit" hidden> | ||||
| <div class="entry-form__grid entry-form__grid--inline"> | <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"> | <div class="entry-form__field"> | ||||
| <input type="text" class="input edit-name" value="{{ project.name }}" /> | <input type="text" class="input edit-name" value="{{ project.name }}" /> | ||||
| </div> | </div> | ||||
| <label class="entry-form__label">Kunde</label> | |||||
| <label class="entry-form__label">{{ 'app.crud.label_client'|trans }}</label> | |||||
| <div class="entry-form__field"> | <div class="entry-form__field"> | ||||
| <select class="select edit-client"> | <select class="select edit-client"> | ||||
| {% for client in clients %} | {% for client in clients %} | ||||
| @@ -107,14 +132,14 @@ window.CRUD = { | |||||
| </select> | </select> | ||||
| </div> | </div> | ||||
| <label class="entry-form__label">Bemerkung</label> | |||||
| <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | |||||
| <div class="entry-form__field"> | <div class="entry-form__field"> | ||||
| <textarea class="textarea edit-note" rows="2">{{ project.note|default('') }}</textarea> | <textarea class="textarea edit-note" rows="2">{{ project.note|default('') }}</textarea> | ||||
| </div> | </div> | ||||
| <div class="entry-form__actions"> | <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> | ||||
| </div> | </div> | ||||
| @@ -124,7 +149,7 @@ window.CRUD = { | |||||
| </div> | </div> | ||||
| {% else %} | {% else %} | ||||
| <div class="empty-state"> | <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> | </div> | ||||
| {% endfor %} | {% endfor %} | ||||
| @@ -90,7 +90,20 @@ | |||||
| {% endblock %} | {% endblock %} | ||||
| {% endembed %} | {% 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') }} | {{ encore_entry_script_tags('registration') }} | ||||
| </body> | </body> | ||||
| @@ -124,16 +124,16 @@ | |||||
| <select class="select filter-select" data-filter-key="services"> | <select class="select filter-select" data-filter-key="services"> | ||||
| <option value="">...</option> | <option value="">...</option> | ||||
| {% if activeServices|length > 0 %} | {% if activeServices|length > 0 %} | ||||
| <optgroup label="Aktiv"> | |||||
| <optgroup label="{{ 'app.crud.tab_active'|trans }}"> | |||||
| {% for service in activeServices %} | {% 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 %} | {% endfor %} | ||||
| </optgroup> | </optgroup> | ||||
| {% endif %} | {% endif %} | ||||
| {% if archivedServices|length > 0 %} | {% if archivedServices|length > 0 %} | ||||
| <optgroup label="Archiviert"> | |||||
| <optgroup label="{{ 'app.crud.tab_archived'|trans }}"> | |||||
| {% for service in archivedServices %} | {% 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 %} | {% endfor %} | ||||
| </optgroup> | </optgroup> | ||||
| {% endif %} | {% endif %} | ||||
| @@ -172,14 +172,14 @@ | |||||
| <select class="select filter-select" data-filter-key="users"> | <select class="select filter-select" data-filter-key="users"> | ||||
| <option value="">...</option> | <option value="">...</option> | ||||
| {% if activeUsers|length > 0 %} | {% if activeUsers|length > 0 %} | ||||
| <optgroup label="Aktiv"> | |||||
| <optgroup label="{{ 'app.crud.tab_active'|trans }}"> | |||||
| {% for user in activeUsers %} | {% for user in activeUsers %} | ||||
| <option value="{{ user.id }}"{% if val == user.id %} selected{% endif %}>{{ user.name }}</option> | <option value="{{ user.id }}"{% if val == user.id %} selected{% endif %}>{{ user.name }}</option> | ||||
| {% endfor %} | {% endfor %} | ||||
| </optgroup> | </optgroup> | ||||
| {% endif %} | {% endif %} | ||||
| {% if archivedUsers|length > 0 %} | {% if archivedUsers|length > 0 %} | ||||
| <optgroup label="Archiviert"> | |||||
| <optgroup label="{{ 'app.crud.tab_archived'|trans }}"> | |||||
| {% for user in archivedUsers %} | {% for user in archivedUsers %} | ||||
| <option value="{{ user.id }}"{% if val == user.id %} selected{% endif %}>{{ user.name }}</option> | <option value="{{ user.id }}"{% if val == user.id %} selected{% endif %}>{{ user.name }}</option> | ||||
| {% endfor %} | {% endfor %} | ||||
| @@ -103,6 +103,35 @@ | |||||
| {{ 'app.report.toolbar_filter'|trans }} | {{ 'app.report.toolbar_filter'|trans }} | ||||
| </button> | </button> | ||||
| </div> | </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> | </div> | ||||
| {# ── Filter-Panel ─────────────────────────────────────────────────── #} | {# ── Filter-Panel ─────────────────────────────────────────────────── #} | ||||
| @@ -165,7 +194,7 @@ | |||||
| </div> | </div> | ||||
| <div class="report-table__cell report-table__cell--user"> | <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> | ||||
| <div class="report-table__cell report-table__cell--note"> | <div class="report-table__cell report-table__cell--note"> | ||||
| @@ -22,13 +22,16 @@ | |||||
| {% else %} | {% 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> | <div class="login-card__error">{{ 'app.forgot_password.error_invalid_email'|trans }}</div> | ||||
| {% endif %} | {% endif %} | ||||
| <p class="login-card__sub">{{ 'app.forgot_password.subtitle'|trans }}</p> | <p class="login-card__sub">{{ 'app.forgot_password.subtitle'|trans }}</p> | ||||
| <form class="login-form" method="post"> | <form class="login-form" method="post"> | ||||
| <input type="hidden" name="_csrf_token" value="{{ csrf_token('forgot_password') }}" /> | |||||
| <div class="login-form__grid"> | <div class="login-form__grid"> | ||||
| <label class="login-form__label" for="email">{{ 'app.login.label_email'|trans }}</label> | <label class="login-form__label" for="email">{{ 'app.login.label_email'|trans }}</label> | ||||
| @@ -29,13 +29,16 @@ | |||||
| {% else %} | {% 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> | <div class="login-card__error">{{ 'app.reset_password.error_too_short'|trans }}</div> | ||||
| {% elseif error == 'mismatch' %} | {% elseif error == 'mismatch' %} | ||||
| <div class="login-card__error">{{ 'app.reset_password.error_mismatch'|trans }}</div> | <div class="login-card__error">{{ 'app.reset_password.error_mismatch'|trans }}</div> | ||||
| {% endif %} | {% endif %} | ||||
| <form class="login-form" method="post"> | <form class="login-form" method="post"> | ||||
| <input type="hidden" name="_csrf_token" value="{{ csrf_token('reset_password') }}" /> | |||||
| <div class="login-form__grid"> | <div class="login-form__grid"> | ||||
| @@ -5,7 +5,34 @@ | |||||
| {% block body %} | {% block body %} | ||||
| <script> | <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> | </script> | ||||
| <div class="crud-page"> | <div class="crud-page"> | ||||
| @@ -1,22 +1,36 @@ | |||||
| {# templates/team/index.html.twig #} | {# templates/team/index.html.twig #} | ||||
| {% extends 'base.html.twig' %} | {% extends 'base.html.twig' %} | ||||
| {% block title %}Team{% endblock %} | |||||
| {% block title %}{{ 'app.team.page_title'|trans }}{% endblock %} | |||||
| {% block body %} | {% 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"> | ||||
| <div class="crud-page__header"> | <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> | ||||
| <div class="crud-tabs"> | <div class="crud-tabs"> | ||||
| <button class="crud-tab crud-tab--active" data-tab="active"> | <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> | ||||
| <button class="crud-tab" data-tab="archived"> | <button class="crud-tab" data-tab="archived"> | ||||
| Archiviert ({{ archivedUsers|length }}) | |||||
| {{ 'app.crud.tab_archived'|trans }} ({{ archivedUsers|length }}) | |||||
| </button> | </button> | ||||
| </div> | </div> | ||||
| @@ -37,22 +51,22 @@ | |||||
| <div class="crud-row__display"> | <div class="crud-row__display"> | ||||
| <div class="crud-row__info"> | <div class="crud-row__info"> | ||||
| <span class="crud-row__name">{{ au.user.fullName }}</span> | <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 %} | {% 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 %} | {% endif %} | ||||
| </div> | </div> | ||||
| <div class="crud-row__actions"> | <div class="crud-row__actions"> | ||||
| <button class="crud-row__btn crud-row__btn--edit" | <button class="crud-row__btn crud-row__btn--edit" | ||||
| data-action="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> | </button> | ||||
| {% if au.user.id != currentUserId %} | {% if au.user.id != currentUserId %} | ||||
| <button class="crud-row__btn crud-row__btn--delete" | <button class="crud-row__btn crud-row__btn--delete" | ||||
| data-action="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> | </button> | ||||
| {% endif %} | {% endif %} | ||||
| </div> | </div> | ||||
| @@ -61,57 +75,57 @@ | |||||
| <div class="crud-row__edit" hidden> | <div class="crud-row__edit" hidden> | ||||
| <div class="entry-form__grid entry-form__grid--inline"> | <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"> | <div class="entry-form__field"> | ||||
| <input type="text" class="input edit-first-name" | <input type="text" class="input edit-first-name" | ||||
| value="{{ au.user.firstName }}" /> | value="{{ au.user.firstName }}" /> | ||||
| </div> | </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"> | <div class="entry-form__field"> | ||||
| <input type="text" class="input edit-last-name" | <input type="text" class="input edit-last-name" | ||||
| value="{{ au.user.lastName }}" /> | value="{{ au.user.lastName }}" /> | ||||
| </div> | </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"> | <div class="entry-form__field"> | ||||
| <input type="email" class="input edit-email" | <input type="email" class="input edit-email" | ||||
| value="{{ au.user.email }}" /> | value="{{ au.user.email }}" /> | ||||
| </div> | </div> | ||||
| <label class="entry-form__label">Bemerkung</label> | |||||
| <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | |||||
| <div class="entry-form__field"> | <div class="entry-form__field"> | ||||
| <textarea class="textarea edit-note" rows="2">{{ au.user.note|default('') }}</textarea> | <textarea class="textarea edit-note" rows="2">{{ au.user.note|default('') }}</textarea> | ||||
| </div> | </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="entry-form__field"> | ||||
| <div class="team-role-selector{% if au.user.id == currentUserId and au.isAdmin() %} team-role-selector--disabled{% endif %}"> | <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' : '' %} | {% set roleDisabled = (au.user.id == currentUserId and au.isAdmin()) ? 'disabled' : '' %} | ||||
| <label class="team-role-option"> | <label class="team-role-option"> | ||||
| <input type="radio" class="edit-role" name="role-{{ au.id }}" | <input type="radio" class="edit-role" name="role-{{ au.id }}" | ||||
| value="tracker" {{ au.role == 'tracker' ? 'checked' : '' }} {{ roleDisabled }} /> | 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> | ||||
| <label class="team-role-option"> | <label class="team-role-option"> | ||||
| <input type="radio" class="edit-role" name="role-{{ au.id }}" | <input type="radio" class="edit-role" name="role-{{ au.id }}" | ||||
| value="member" {{ au.role == 'member' ? 'checked' : '' }} {{ roleDisabled }} /> | 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> | ||||
| <label class="team-role-option"> | <label class="team-role-option"> | ||||
| <input type="radio" class="edit-role" name="role-{{ au.id }}" | <input type="radio" class="edit-role" name="role-{{ au.id }}" | ||||
| value="admin" {{ au.role == 'admin' ? 'checked' : '' }} {{ roleDisabled }} /> | 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> | </label> | ||||
| </div> | </div> | ||||
| {% if au.user.id == currentUserId and au.isAdmin() %} | {% 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 %} | {% endif %} | ||||
| </div> | </div> | ||||
| <div class="entry-form__actions"> | <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> | ||||
| </div> | </div> | ||||
| @@ -127,14 +141,14 @@ | |||||
| <div class="crud-row__info"> | <div class="crud-row__info"> | ||||
| <span class="crud-row__name">{{ invite.firstName }} {{ invite.lastName }}</span> | <span class="crud-row__name">{{ invite.firstName }} {{ invite.lastName }}</span> | ||||
| <span class="crud-row__meta">({{ invite.email }})</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> | ||||
| <div class="crud-row__actions"> | <div class="crud-row__actions"> | ||||
| <button class="crud-row__btn crud-row__btn--delete" | <button class="crud-row__btn crud-row__btn--delete" | ||||
| data-action="delete-invite" | data-action="delete-invite" | ||||
| data-id="{{ invite.id }}" | 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> | </button> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| @@ -142,7 +156,7 @@ | |||||
| {% endfor %} | {% endfor %} | ||||
| {% if activeUsers is empty and pendingInvites is empty %} | {% 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 %} | {% endif %} | ||||
| </div> | </div> | ||||
| @@ -155,19 +169,19 @@ | |||||
| <div class="crud-row__display"> | <div class="crud-row__display"> | ||||
| <div class="crud-row__info"> | <div class="crud-row__info"> | ||||
| <span class="crud-row__name">{{ au.user.fullName }}</span> | <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> | ||||
| <div class="crud-row__actions"> | <div class="crud-row__actions"> | ||||
| <button class="crud-row__btn crud-row__btn--restore" | <button class="crud-row__btn crud-row__btn--restore" | ||||
| data-action="unarchive" | 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> | </button> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| {% else %} | {% else %} | ||||
| <div class="crud-list__empty">Keine archivierten Teammitglieder.</div> | |||||
| <div class="crud-list__empty">{{ 'app.team.empty_archived'|trans }}</div> | |||||
| {% endfor %} | {% endfor %} | ||||
| </div> | </div> | ||||
| @@ -178,7 +192,7 @@ | |||||
| <div class="modal-overlay" id="team-modal" hidden> | <div class="modal-overlay" id="team-modal" hidden> | ||||
| <div class="modal-card"> | <div class="modal-card"> | ||||
| <div class="modal-card__header"> | <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"> | <button class="modal-card__close" id="team-modal-close" type="button"> | ||||
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | <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"/> | <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="modal-card__body"> | ||||
| <div class="form-row"> | <div class="form-row"> | ||||
| <div class="form-field"> | <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" /> | <input class="input" type="text" id="inv-firstName" autocomplete="off" /> | ||||
| </div> | </div> | ||||
| <div class="form-field"> | <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" /> | <input class="input" type="text" id="inv-lastName" autocomplete="off" /> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <div class="form-field"> | <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" /> | <input class="input" type="email" id="inv-email" autocomplete="off" /> | ||||
| </div> | </div> | ||||
| <div class="form-field"> | <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"> | <div class="team-role-selector"> | ||||
| <label class="team-role-option"> | <label class="team-role-option"> | ||||
| <input type="radio" name="inv-role" value="tracker" /> | <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> | ||||
| <label class="team-role-option"> | <label class="team-role-option"> | ||||
| <input type="radio" name="inv-role" value="member" checked /> | <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> | ||||
| <label class="team-role-option"> | <label class="team-role-option"> | ||||
| <input type="radio" name="inv-role" value="admin" /> | <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> | </label> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <div class="modal-card__footer"> | <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> | </div> | ||||
| </div> | </div> | ||||
| @@ -234,4 +248,4 @@ | |||||
| {% block javascripts %} | {% block javascripts %} | ||||
| {{ parent() }} | {{ parent() }} | ||||
| {{ encore_entry_script_tags('team') }} | {{ encore_entry_script_tags('team') }} | ||||
| {% endblock %} | |||||
| {% endblock %} | |||||
| @@ -1,27 +1,15 @@ | |||||
| {# templates/timetracking/week.html.twig #} | {# templates/timetracking/week.html.twig #} | ||||
| {% extends 'base.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 months = deMonths() %} | ||||
| {% set monthsShort = deMonthsShort() %} | {% set monthsShort = deMonthsShort() %} | ||||
| {% set weekdays = deWeekdays() %} | {% set weekdays = deWeekdays() %} | ||||
| {% set weekdaysShort= deWeekdaysShort() %} | {% set weekdaysShort= deWeekdaysShort() %} | ||||
| {% block title %} | {% 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 %} | {% endblock %} | ||||
| {% block body %} | {% block body %} | ||||
| @@ -76,6 +64,8 @@ window.TT = { | |||||
| errorDailyLimitExceeded: {{ 'app.entry.error_daily_limit_exceeded'|trans|json_encode|raw }}, | errorDailyLimitExceeded: {{ 'app.entry.error_daily_limit_exceeded'|trans|json_encode|raw }}, | ||||
| warnDurationLong: {{ 'app.entry.warn_duration_long'|trans|json_encode|raw }}, | warnDurationLong: {{ 'app.entry.warn_duration_long'|trans|json_encode|raw }}, | ||||
| invoicedTitle: {{ 'app.entry.invoiced_title'|trans|json_encode|raw }}, | invoicedTitle: {{ 'app.entry.invoiced_title'|trans|json_encode|raw }}, | ||||
| noteShow: {{ 'app.entry.note_show'|trans|json_encode|raw }}, | |||||
| noteHide: {{ 'app.entry.note_hide'|trans|json_encode|raw }}, | |||||
| }, | }, | ||||
| }; | }; | ||||
| </script> | </script> | ||||
| @@ -143,7 +133,7 @@ window.TT = { | |||||
| {# Minimal-Modus: Bemerkung-Toggle (nur via CSS/JS sichtbar) #} | {# Minimal-Modus: Bemerkung-Toggle (nur via CSS/JS sichtbar) #} | ||||
| <div class="entry-form__note-toggle-row"> | <div class="entry-form__note-toggle-row"> | ||||
| <button type="button" class="entry-form__note-toggle" id="btn-note-toggle"> | <button type="button" class="entry-form__note-toggle" id="btn-note-toggle"> | ||||
| + Bemerkung hinzufügen | |||||
| {{ 'app.entry.note_show'|trans }} | |||||
| </button> | </button> | ||||
| </div> | </div> | ||||
| @@ -160,7 +150,7 @@ window.TT = { | |||||
| {% if timeEntries is not empty %} | {% if timeEntries is not empty %} | ||||
| <div class="entry-list__summary" id="entry-list-summary"> | <div class="entry-list__summary" id="entry-list-summary"> | ||||
| <button type="button" class="entry-list__summary-btn" id="btn-entries-toggle"> | <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-sep">·</span> | ||||
| <span class="entry-list__summary-total">{{ totalDuration }}</span> | <span class="entry-list__summary-total">{{ totalDuration }}</span> | ||||
| <span class="entry-list__summary-arrow">▾</span> | <span class="entry-list__summary-arrow">▾</span> | ||||
| @@ -1,13 +1,54 @@ | |||||
| # translations/messages.de.yaml | # translations/messages.de.yaml | ||||
| app: | 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: | date: | ||||
| today: "Heute" | today: "Heute" | ||||
| tomorrow: "Morgen" | tomorrow: "Morgen" | ||||
| yesterday: "Gestern" | yesterday: "Gestern" | ||||
| week_label: "Kalenderwoche" | week_label: "Kalenderwoche" | ||||
| week_short: "KW" | |||||
| nav: | nav: | ||||
| menu_open: "Menü öffnen" | |||||
| week_view: "Wochenansicht" | |||||
| prev_week: "Vorherige Woche" | prev_week: "Vorherige Woche" | ||||
| next_week: "Nächste Woche" | next_week: "Nächste Woche" | ||||
| month_view: "Monatsansicht öffnen/schließen" | 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_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." | error_daily_limit_exceeded: "Du kannst nicht mehr als 24 Stunden pro Tag loggen." | ||||
| warn_duration_long: "Die Dauer ist länger als 8 Stunden. Wirklich speichern?" | warn_duration_long: "Die Dauer ist länger als 8 Stunden. Wirklich speichern?" | ||||
| note_show: "+ Bemerkung hinzufügen" | |||||
| note_hide: "× Bemerkung ausblenden" | |||||
| invoiced_title: "Abgerechnet – Bearbeiten nicht möglich" | |||||
| count_one: "Eintrag" | |||||
| count_other: "Einträge" | |||||
| service: | service: | ||||
| billable: "Verrechenbar" | billable: "Verrechenbar" | ||||
| @@ -60,11 +106,35 @@ app: | |||||
| empty: "Noch keine Leistungen angelegt." | empty: "Noch keine Leistungen angelegt." | ||||
| crud: | 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: | report: | ||||
| page_title: "Reports: Zeiten" | page_title: "Reports: Zeiten" | ||||
| @@ -72,6 +142,27 @@ app: | |||||
| tab_times: "Zeiten" | tab_times: "Zeiten" | ||||
| tab_projects: "Projekte" | tab_projects: "Projekte" | ||||
| toolbar_filter: "Filtern/Gruppieren" | 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" | toolbar_edit: "Einträge bearbeiten" | ||||
| col_date: "Datum" | col_date: "Datum" | ||||
| col_client: "Kunde" | col_client: "Kunde" | ||||
| @@ -112,6 +203,85 @@ app: | |||||
| invoiced_yes: "Ja" | invoiced_yes: "Ja" | ||||
| invoiced_no: "Nein" | invoiced_no: "Nein" | ||||
| filter_neg: "Negativfilter" | 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: | forgot_password: | ||||
| page_title: "Passwort vergessen – spawntree" | page_title: "Passwort vergessen – spawntree" | ||||
| @@ -154,6 +324,12 @@ app: | |||||
| label_password: "Passwort" | label_password: "Passwort" | ||||
| label_password_repeat: "Wiederholen" | label_password_repeat: "Wiederholen" | ||||
| btn_submit: "Konto erstellen" | 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?" | already_registered: "Bereits registriert?" | ||||
| link_login: "Zur Anmeldung" | link_login: "Zur Anmeldung" | ||||
| @@ -172,6 +348,9 @@ app: | |||||
| invite_error: | invite_error: | ||||
| page_title: "Fehler – Einladungslink" | page_title: "Fehler – Einladungslink" | ||||
| title: "Ungültiger Link" | 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: | set_password: | ||||
| page_title: "Passwort festlegen – %name%" | page_title: "Passwort festlegen – %name%" | ||||
| @@ -179,9 +358,12 @@ app: | |||||
| label_password: "Passwort" | label_password: "Passwort" | ||||
| label_password_repeat: "Wiederholen" | label_password_repeat: "Wiederholen" | ||||
| btn_submit: "Passwort speichern & anmelden" | btn_submit: "Passwort speichern & anmelden" | ||||
| error_too_short: "Das Passwort muss mindestens 8 Zeichen haben." | |||||
| error_mismatch: "Die Passwörter stimmen nicht überein." | |||||
| email: | email: | ||||
| confirm: | confirm: | ||||
| subject: "Bitte bestätige deine Registrierung – spawntree Timetracker" | |||||
| greeting: "Hallo %name%," | greeting: "Hallo %name%," | ||||
| body: "bitte bestätige deine Registrierung für %company% mit einem Klick auf den Button." | body: "bitte bestätige deine Registrierung für %company% mit einem Klick auf den Button." | ||||
| btn: "E-Mail bestätigen" | btn: "E-Mail bestätigen" | ||||
| @@ -189,6 +371,7 @@ app: | |||||
| ignore: "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren." | ignore: "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren." | ||||
| notify: | notify: | ||||
| subject: "[Timetracker] Neue Registrierung: %name%" | |||||
| title: "Neue Registrierung im Timetracker" | title: "Neue Registrierung im Timetracker" | ||||
| col_company: "Firma" | col_company: "Firma" | ||||
| col_slug: "Slug" | col_slug: "Slug" | ||||
| @@ -197,6 +380,7 @@ app: | |||||
| col_date: "Datum" | col_date: "Datum" | ||||
| password_reset: | password_reset: | ||||
| subject: "Passwort zurücksetzen – %account%" | |||||
| greeting: "Hallo %name%," | greeting: "Hallo %name%," | ||||
| body: "du hast ein Zurücksetzen deines Passworts angefordert. Klicke auf den Button, um ein neues Passwort festzulegen." | body: "du hast ein Zurücksetzen deines Passworts angefordert. Klicke auf den Button, um ein neues Passwort festzulegen." | ||||
| btn: "Passwort zurücksetzen" | btn: "Passwort zurücksetzen" | ||||
| @@ -204,12 +388,14 @@ app: | |||||
| ignore: "Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren." | ignore: "Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren." | ||||
| welcome: | welcome: | ||||
| subject: "Willkommen beim spawntree Timetracker!" | |||||
| greeting: "Hallo %name%," | greeting: "Hallo %name%," | ||||
| body: "dein Konto für %company% ist jetzt aktiv. Los geht's!" | body: "dein Konto für %company% ist jetzt aktiv. Los geht's!" | ||||
| btn: "Zum Timetracker →" | btn: "Zum Timetracker →" | ||||
| url_label: "Deine URL:" | url_label: "Deine URL:" | ||||
| invite: | invite: | ||||
| subject: "Einladung zu %company%" | |||||
| title: "Du wurdest zu %company% eingeladen" | title: "Du wurdest zu %company% eingeladen" | ||||
| greeting: "Hallo %name%," | greeting: "Hallo %name%," | ||||
| role_added_pre: "du wurdest als" | 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." | cta: "Klicke auf den Button, um dein Passwort festzulegen und loszulegen. Der Link ist 7 Tage gültig." | ||||
| btn: "Passwort festlegen →" | btn: "Passwort festlegen →" | ||||
| ignore: "Wenn du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren." | 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 →" | |||||