From fa749a0fa9a92ca0699e86c5fe3bf487ca69642b Mon Sep 17 00:00:00 2001 From: FlorianEisenmenger Date: Wed, 17 Jun 2026 01:58:35 +0200 Subject: [PATCH] big update: cleanup, restructuring, translation keys + excel, csv and pdf exports + print page --- .claude/settings.local.json | 18 + CLAUDE.md | 139 +++ httpdocs/.env | 6 +- httpdocs/.env.dev | 4 - httpdocs/PROJEKT_KONTEXT.md | 20 +- httpdocs/assets/scripts/account.js | 358 ++++---- httpdocs/assets/scripts/calendar.js | 36 +- httpdocs/assets/scripts/crud.js | 202 ++-- httpdocs/assets/scripts/duration.js | 77 +- httpdocs/assets/scripts/entries.js | 216 ++--- httpdocs/assets/scripts/registration.js | 157 ++-- httpdocs/assets/scripts/report.js | 734 +++++++-------- httpdocs/assets/scripts/team.js | 420 +++++---- httpdocs/assets/scripts/utils.js | 26 + httpdocs/assets/styles/atoms/_inputs.scss | 2 +- httpdocs/assets/styles/atoms/_mixins.scss | 53 ++ httpdocs/assets/styles/atoms/_variables.scss | 50 +- .../assets/styles/components/_account.scss | 26 +- httpdocs/assets/styles/components/_crud.scss | 27 +- .../assets/styles/components/_entry-form.scss | 18 +- .../assets/styles/components/_entry-list.scss | 49 +- httpdocs/assets/styles/components/_login.scss | 13 +- .../styles/components/_month-calendar.scss | 16 +- .../assets/styles/components/_register.scss | 11 +- httpdocs/assets/styles/components/_team.scss | 24 +- .../assets/styles/components/_week-nav.scss | 26 +- httpdocs/assets/styles/main.scss | 7 +- httpdocs/assets/styles/sections/_home.scss | 8 +- httpdocs/assets/styles/sections/_report.scss | 183 ++-- .../assets/styles/sections/_timetracking.scss | 15 +- httpdocs/assets/styles/themes/_minimal.scss | 19 +- httpdocs/composer.json | 2 + httpdocs/composer.lock | 868 +++++++++++++++++- httpdocs/config/packages/doctrine.yaml | 3 +- httpdocs/config/services.yaml | 4 + httpdocs/src/Controller/AccountController.php | 33 +- httpdocs/src/Controller/ClientController.php | 32 +- httpdocs/src/Controller/InviteController.php | 29 +- .../Controller/PasswordResetController.php | 23 +- httpdocs/src/Controller/ProjectController.php | 38 +- .../src/Controller/RegistrationController.php | 19 +- httpdocs/src/Controller/ReportController.php | 116 ++- httpdocs/src/Controller/ServiceController.php | 32 +- httpdocs/src/Controller/TeamController.php | 65 +- .../src/Controller/TimeTrackingController.php | 32 +- httpdocs/src/Entity/Central/AccountUser.php | 9 +- .../ArchivedUserSubscriber.php | 8 +- .../Repository/Tenant/TimeEntryRepository.php | 108 +-- httpdocs/src/Security/ArchivedUserChecker.php | 8 +- httpdocs/src/Service/AccountRoleHelper.php | 12 +- httpdocs/src/Service/RegistrationService.php | 14 +- httpdocs/src/Service/ReportExportService.php | 395 ++++++++ .../src/Twig/Runtime/AppExtensionRuntime.php | 18 - httpdocs/templates/_atoms/icon-csv.html.twig | 5 + .../templates/_atoms/icon-excel.html.twig | 5 + httpdocs/templates/_atoms/icon-pdf.html.twig | 5 + .../templates/_atoms/icon-print.html.twig | 6 + .../_components/register-success.html.twig | 2 +- httpdocs/templates/_macros/helpers.html.twig | 15 + httpdocs/templates/_sections/nav.html.twig | 2 +- .../templates/_sections/tt-header.html.twig | 32 +- httpdocs/templates/account/index.html.twig | 71 +- httpdocs/templates/client/index.html.twig | 81 +- httpdocs/templates/home/index.html.twig | 10 +- httpdocs/templates/invite/error.html.twig | 2 +- .../templates/invite/set_password.html.twig | 9 +- httpdocs/templates/project/index.html.twig | 73 +- .../templates/registration/register.html.twig | 15 +- .../templates/report/_filter-panel.html.twig | 12 +- httpdocs/templates/report/times.html.twig | 31 +- .../security/forgot_password.html.twig | 5 +- .../security/reset_password.html.twig | 5 +- httpdocs/templates/service/index.html.twig | 29 +- httpdocs/templates/team/index.html.twig | 96 +- .../templates/timetracking/week.html.twig | 24 +- httpdocs/translations/messages.de.yaml | 208 ++++- 76 files changed, 3624 insertions(+), 1947 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 CLAUDE.md delete mode 100644 httpdocs/.env.dev create mode 100644 httpdocs/assets/scripts/utils.js create mode 100644 httpdocs/assets/styles/atoms/_mixins.scss create mode 100644 httpdocs/src/Service/ReportExportService.php delete mode 100644 httpdocs/src/Twig/Runtime/AppExtensionRuntime.php create mode 100644 httpdocs/templates/_atoms/icon-csv.html.twig create mode 100644 httpdocs/templates/_atoms/icon-excel.html.twig create mode 100644 httpdocs/templates/_atoms/icon-pdf.html.twig create mode 100644 httpdocs/templates/_atoms/icon-print.html.twig create mode 100644 httpdocs/templates/_macros/helpers.html.twig diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..419d660 --- /dev/null +++ b/.claude/settings.local.json @@ -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 *)" + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..861c4ad --- /dev/null +++ b/CLAUDE.md @@ -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 } +``` diff --git a/httpdocs/.env b/httpdocs/.env index c8d21ca..d4c3338 100644 --- a/httpdocs/.env +++ b/httpdocs/.env @@ -16,7 +16,7 @@ ###> symfony/framework-bundle ### APP_ENV=dev -APP_SECRET= +APP_SECRET=f19f2bcb34a48e20e66302a0e88408a9 APP_SHARE_DIR=var/share ###< symfony/framework-bundle ### @@ -37,7 +37,7 @@ DEFAULT_URI=http://localhost ###< doctrine/doctrine-bundle ### DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=10.11.2-MariaDB&charset=utf8mb4" -APP_DOMAIN=testtimetracking.ddev.site +APP_DOMAIN= # ── Mailer ──────────────────────────────────────────────────────────────────── # Lokal (DDEV Mailpit): smtp://127.0.0.1:1025 @@ -45,4 +45,4 @@ APP_DOMAIN=testtimetracking.ddev.site MAILER_DSN=smtp://127.0.0.1:1025 # Benachrichtigung bei Neuanmeldung -REGISTRATION_NOTIFY_EMAIL=re@spawntree.de +REGISTRATION_NOTIFY_EMAIL= diff --git a/httpdocs/.env.dev b/httpdocs/.env.dev deleted file mode 100644 index 18bbff6..0000000 --- a/httpdocs/.env.dev +++ /dev/null @@ -1,4 +0,0 @@ - -###> symfony/framework-bundle ### -APP_SECRET=f19f2bcb34a48e20e66302a0e88408a9 -###< symfony/framework-bundle ### diff --git a/httpdocs/PROJEKT_KONTEXT.md b/httpdocs/PROJEKT_KONTEXT.md index d84fb74..c30dccf 100644 --- a/httpdocs/PROJEKT_KONTEXT.md +++ b/httpdocs/PROJEKT_KONTEXT.md @@ -16,7 +16,7 @@ Technisch ähnlich wie **mite.de** – wir orientieren uns stark am UI/UX von mi - **Backend**: Symfony 7, PHP 8.2, Doctrine ORM, MariaDB - **Frontend**: Twig, SCSS (Webpack Encore), Vanilla JS (keine jQuery, kein Framework) - **SCSS-Struktur**: Atoms → Components → Sections → Themes (BEM-ähnlich) -- **Dev**: DDEV (Port 8456 HTTPS), PHPMyAdmin installiert +- **Dev**: DDEV (Port 8459 HTTPS), PHPMyAdmin installiert - **Kein** Symfony Forms – eigene HTML-Formulare mit fetch()-API --- @@ -24,7 +24,7 @@ Technisch ähnlich wie **mite.de** – wir orientieren uns stark am UI/UX von mi ## Multi-Mandanten-Architektur ### Konzept -- Jeder Account (Firma) bekommt eine eigene **Subdomain**: `spawntree.testtimetracking.ddev.site` +- Jeder Account (Firma) bekommt eine eigene **Subdomain**: `spawntree.timetracking.ddev.site` - Jeder Account bekommt eine **eigene Tenant-Datenbank**: `db_spawntree` - Die **Central-DB** (`db`) enthält accountübergreifende Daten: User, Account, AccountUser, Token - Die **Tenant-DB** enthält accountspezifische Daten: Client, Project, Service, TimeEntry @@ -131,6 +131,9 @@ Technisch ähnlich wie **mite.de** – wir orientieren uns stark am UI/UX von mi ### Reports - `GET /reports/times` → `report_times` +- `GET /reports/export/excel` → `report_export_excel` (Excel-Download mit aktuellen Filtern) +- `GET /reports/export/csv` → `report_export_csv` (CSV-Download mit aktuellen Filtern) +- `GET /reports/export/pdf` → `report_export_pdf` (PDF-Download mit aktuellen Filtern) ### Team (nur Admins) - `GET /team` → `team_index` @@ -163,6 +166,7 @@ Technisch ähnlich wie **mite.de** – wir orientieren uns stark am UI/UX von mi - `BrandColorService` – leitet aus einem Hex-Farbwert (`primaryColor`) ein komplettes 6-Farben-Palette-Array via HSL-Offsets ab; wird in `AppExtension` und `AccountController` genutzt - `AppExtension` – Twig-Funktionen: `deMonths()`, `deMonthsShort()`, `deWeekdays()`, `deWeekdaysShort()`, `isCurrentUserAdmin()`, `isCurrentUserMemberOrAdmin()`, `getCurrentUserRole()`, **`brandPalette()`** (gibt das berechnete Farbpaletten-Array zurück, oder `null` wenn Standardfarbe) - `AppExtensionRuntime` – Runtime-Teil der Twig-Extension +- `ReportExportService` – generiert Excel (PhpSpreadsheet), CSV und PDF (Dompdf) Exporte; nutzt Translations für alle Labels - `AccessDeniedHandler` – leitet bei 403 auf Login um --- @@ -360,7 +364,7 @@ Migration ausführen: `ddev exec php bin/console doctrine:migrations:migrate --e ## Was noch fehlt / TODO - [ ] Filter auf Report-Seite (Datumsbereich, Projekt, Service, User) -- [ ] Export (CSV / PDF) +- [x] Export (Excel / CSV / PDF) – `ReportExportService`, Icons in Toolbar - [ ] Timer-Funktion (Live-Zeiterfassung) - [ ] Wochenübersicht mit Summen pro Tag (im Wochenkalender) - [ ] E-Mail-Konfiguration für Produktivbetrieb (aktuell DDEV Mailpit) @@ -379,10 +383,10 @@ bash 2-update-tenant-db.sh --- ## DDEV-Konfiguration -- Projekt: `testtimetracking` -- Hauptdomain: `https://testtimetracking.ddev.site:8456` -- Tenant-Subdomain Beispiel: `https://spawntree.testtimetracking.ddev.site:8456` -- PHPMyAdmin: `https://testtimetracking.ddev.site:8037` +- Projekt: `timetracking` +- Hauptdomain: `https://timetracking.ddev.site:8459` +- Tenant-Subdomain Beispiel: `https://spawntree.timetracking.ddev.site:8459` +- PHPMyAdmin: `https://timetracking.ddev.site:8037` - MariaDB: User `db`, Passwort `db`, Central-DB `db` - `.env`: `DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=10.11.2-MariaDB&charset=utf8mb4"` -- `APP_DOMAIN=testtimetracking.ddev.site:8456` (für Subdomain-Erkennung und E-Mail-Links) \ No newline at end of file +- `APP_DOMAIN=timetracking.ddev.site:8459` (für Subdomain-Erkennung und E-Mail-Links) \ No newline at end of file diff --git a/httpdocs/assets/scripts/account.js b/httpdocs/assets/scripts/account.js index 83aed1d..16db77c 100644 --- a/httpdocs/assets/scripts/account.js +++ b/httpdocs/assets/scripts/account.js @@ -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; + } + }); + } }); diff --git a/httpdocs/assets/scripts/calendar.js b/httpdocs/assets/scripts/calendar.js index 66bd9b2..f5089de 100644 --- a/httpdocs/assets/scripts/calendar.js +++ b/httpdocs/assets/scripts/calendar.js @@ -1,9 +1,8 @@ // assets/scripts/calendar.js -// Strings aus window.TT.i18n – keine hardcodierten deutschen Texte mehr -function t(key) { - return window.TT?.i18n?.[key] ?? key; -} +import { esc, createTranslator, FADE_MS } from './utils.js'; + +const t = createTranslator('TT'); class WeekCalendar { constructor() { @@ -19,9 +18,9 @@ class WeekCalendar { this.today = new Date(); this.today.setHours(0, 0, 0, 0); - this.monthOpen = false; - this.monthDate = new Date(this.activeDate); - this.monthEl = null; + this.monthOpen = false; + this.monthDate = new Date(this.activeDate); + this.monthEl = null; if (!this.nav) return; this.init(); @@ -69,7 +68,7 @@ class WeekCalendar { window.history.pushState({}, '', `/week/${this.formatDate(this.getMonday(this.activeDate))}`); window.entryManager?.loadEntriesForDate(this.formatDate(this.activeDate)); - }, 180); + }, FADE_MS); } renderWeekDays() { @@ -77,12 +76,11 @@ class WeekCalendar { this.daysContainer.innerHTML = ''; for (let i = 0; i < 7; i++) { - const d = new Date(monday); + const d = new Date(monday); d.setDate(d.getDate() + i); const isActive = this.isSameDay(d, this.activeDate); const isToday = this.isSameDay(d, this.today); - // Führungsnull: padStart(2, '0') const dayNum = String(d.getDate()).padStart(2, '0'); const monthShort = this.monthsShort[d.getMonth()] ?? ''; @@ -93,8 +91,8 @@ class WeekCalendar { + (isToday ? ' week-nav__day--today' : ''); a.dataset.date = this.formatDate(d); a.innerHTML = ` - ${this.weekdaysShort[i] ?? ''} - ${dayNum}. ${monthShort} + ${esc(this.weekdaysShort[i] ?? '')} + ${dayNum}. ${esc(monthShort)} `; this.daysContainer.appendChild(a); } @@ -120,9 +118,8 @@ class WeekCalendar { const tomorrow = new Date(this.today); tomorrow.setDate(this.today.getDate() + 1); const yesterday = new Date(this.today); yesterday.setDate(this.today.getDate() - 1); - // JS getDay(): 0=So, 1=Mo...6=Sa → weekdays[0]=Montag, also index = getDay()-1, So=6 - const jsDay = d.getDay(); - const isoIdx = jsDay === 0 ? 6 : jsDay - 1; + const jsDay = d.getDay(); + const isoIdx = jsDay === 0 ? 6 : jsDay - 1; const weekday = this.weekdays[isoIdx] ?? ''; let prefix; @@ -149,7 +146,6 @@ class WeekCalendar { this.monthEl = document.createElement('div'); this.monthEl.className = 'month-calendar month-calendar--hidden'; - // Align calendar's right edge with the calendar icon button's right edge const calRect = this.calBtn.getBoundingClientRect(); const headerRect = this.header.getBoundingClientRect(); this.monthEl.style.right = `${Math.max(0, headerRect.right - calRect.right)}px`; @@ -167,7 +163,9 @@ class WeekCalendar { if (!this.monthEl) return; this.monthEl.classList.remove('month-calendar--visible'); this.monthEl.classList.add('month-calendar--hidden'); - setTimeout(() => { this.monthEl?.remove(); this.monthEl = null; }, 280); + const el = this.monthEl; + setTimeout(() => el.remove(), FADE_MS + 100); + this.monthEl = null; this.monthOpen = false; this.calBtn.classList.remove('week-nav__cal--active'); } @@ -189,7 +187,7 @@ class WeekCalendar { - ${this.months[month] ?? ''} ${year} + ${esc(this.months[month] ?? '')} ${year} @@ -206,7 +204,7 @@ class WeekCalendar {
- ${this.weekdaysShort.map(d => `${d}`).join('')} + ${this.weekdaysShort.map(d => `${esc(d)}`).join('')}
`; diff --git a/httpdocs/assets/scripts/crud.js b/httpdocs/assets/scripts/crud.js index 5111d11..190e4e1 100644 --- a/httpdocs/assets/scripts/crud.js +++ b/httpdocs/assets/scripts/crud.js @@ -1,29 +1,31 @@ // assets/scripts/crud.js -// Generisches CRUD-Handler für Kunden, Projekte, Leistungen + +import { esc, createTranslator, ANIMATION_MS, removeWithAnimation, animateIn } from './utils.js'; const api = window.CRUD?.apiBase ?? ''; -// ── Hilfsfunktionen ─────────────────────────────────────────────────────────── +const t = createTranslator('CRUD'); + +// ── Hilfsfunktionen ────────────────────────────────────────────────────────── function buildClientOptions(selectedId = null) { const clients = window.CRUD?.clients ?? []; - let html = ''; + let html = ``; clients.forEach(c => { const sel = String(c.id) === String(selectedId) ? ' selected' : ''; - html += ``; + html += ``; }); return html; } function rowPrefix() { - // Ermittelt den Entitätstyp aus der URL if (location.pathname.includes('/clients')) return 'client'; if (location.pathname.includes('/projects')) return 'project'; if (location.pathname.includes('/services')) return 'service'; return 'row'; } -// ── Create-Formular ─────────────────────────────────────────────────────────── +// ── Create-Formular ────────────────────────────────────────────────────────── function initCreateForm() { const btnNew = document.getElementById('btn-new'); @@ -47,8 +49,7 @@ function initCreateForm() { } function resetCreateForm() { - const fields = ['create-name', 'create-note']; - fields.forEach(id => { + ['create-name', 'create-note'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); @@ -62,10 +63,12 @@ function resetCreateForm() { async function createEntity() { const name = document.getElementById('create-name')?.value?.trim(); - if (!name) { alert('Bitte einen Namen eingeben.'); return; } + if (!name) { alert(t('errorNoName')); return; } + const btn = document.getElementById('btn-create-save'); const body = buildCreateBody(); + if (btn) btn.disabled = true; try { const res = await fetch(api, { method: 'POST', @@ -75,7 +78,7 @@ async function createEntity() { if (!res.ok) { const err = await res.json().catch(() => ({})); - alert(err.error ?? 'Fehler beim Speichern.'); + alert(err.error ?? t('errorSave')); return; } @@ -83,10 +86,10 @@ async function createEntity() { appendRowToList(data); document.getElementById('crud-create')?.classList.remove('crud-create--visible'); resetCreateForm(); - - } catch (err) { - console.error(err); - alert('Fehler beim Speichern.'); + } catch { + alert(t('errorSave')); + } finally { + if (btn) btn.disabled = false; } } @@ -96,22 +99,19 @@ function buildCreateBody() { note: document.getElementById('create-note')?.value || null, }; - // Kunden-spezifisch const rate = document.getElementById('create-rate'); if (rate) body.hourlyRate = rate.value || null; - // Projekt-spezifisch const client = document.getElementById('create-client'); - if (client) body.clientId = parseInt(client.value) || null; + if (client) body.clientId = parseInt(client.value, 10) || null; - // Leistungs-spezifisch const billable = document.getElementById('create-billable'); if (billable) body.billable = billable.checked; return body; } -// ── Liste: Event Delegation ──────────────────────────────────────────────────── +// ── Liste: Event Delegation ────────────────────────────────────────────────── function initList() { const list = document.getElementById('crud-list'); @@ -121,21 +121,20 @@ function initList() { const actionEl = e.target.closest('[data-action]'); if (!actionEl) return; - const action = actionEl.dataset.action; - const row = e.target.closest('.crud-row'); + const row = e.target.closest('.crud-row'); if (!row) return; - switch (action) { - case 'edit': openEdit(row); break; - case 'delete': deleteRow(row); break; - case 'save': saveEdit(row); break; - case 'cancel': closeEdit(row); break; - case 'unarchive': unarchiveRow(row); break; + switch (actionEl.dataset.action) { + case 'edit': openEdit(row); break; + case 'delete': deleteRow(row); break; + case 'save': saveEdit(row); break; + case 'cancel': closeEdit(row); break; + case 'unarchive': unarchiveRow(row); break; } }); } -// ── Inline Edit ─────────────────────────────────────────────────────────────── +// ── Inline Edit ────────────────────────────────────────────────────────────── function openEdit(row) { row.querySelector('.crud-row__display').hidden = true; @@ -149,29 +148,31 @@ function closeEdit(row) { } async function saveEdit(row) { - const id = row.dataset.id; - const name = row.querySelector('.edit-name')?.value?.trim(); + const saveBtn = row.querySelector('[data-action="save"]'); + if (saveBtn?.disabled) return; - if (!name) { alert('Bitte einen Namen eingeben.'); return; } + const name = row.querySelector('.edit-name')?.value?.trim(); + if (!name) { alert(t('errorNoName')); return; } const body = buildEditBody(row); + if (saveBtn) saveBtn.disabled = true; try { - const res = await fetch(`${api}/${id}`, { + const res = await fetch(`${api}/${row.dataset.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); - if (!res.ok) { alert('Fehler beim Speichern.'); return; } + if (!res.ok) { alert(t('errorSave')); return; } const data = await res.json(); updateRowDisplay(row, data); closeEdit(row); - - } catch (err) { - console.error(err); - alert('Fehler beim Speichern.'); + } catch { + alert(t('errorSave')); + } finally { + if (saveBtn) saveBtn.disabled = false; } } @@ -181,15 +182,12 @@ function buildEditBody(row) { note: row.querySelector('.edit-note')?.value || null, }; - // Kunden const rate = row.querySelector('.edit-rate'); if (rate) body.hourlyRate = rate.value || null; - // Projekt const client = row.querySelector('.edit-client'); - if (client) body.clientId = parseInt(client.value) || null; + if (client) body.clientId = parseInt(client.value, 10) || null; - // Leistung const billable = row.querySelector('.edit-billable'); if (billable) body.billable = billable.checked; @@ -201,19 +199,14 @@ function updateRowDisplay(row, data) { const metaEl = row.querySelector('.crud-row__meta'); if (nameEl) nameEl.textContent = data.name; - - // Kunden: Meta-Text unverändert (Projektanzahl ändert sich nicht) - // Projekte: Client-Name aktualisieren if (data.clientName && metaEl) metaEl.textContent = data.clientName; - // data-Attribute aktualisieren row.dataset.name = data.name; - if (data.hourlyRate !== undefined) row.dataset.rate = data.hourlyRate ?? ''; + if (data.hourlyRate !== undefined) row.dataset.rate = data.hourlyRate ?? ''; if (data.clientId !== undefined) row.dataset.clientId = data.clientId; if (data.billable !== undefined) row.dataset.billable = data.billable ? '1' : '0'; - if (data.note !== undefined) row.dataset.note = data.note ?? ''; + if (data.note !== undefined) row.dataset.note = data.note ?? ''; - // Edit-Felder aktualisieren const editName = row.querySelector('.edit-name'); if (editName) editName.value = data.name; @@ -227,58 +220,54 @@ function updateRowDisplay(row, data) { if (editBillable) editBillable.checked = !!data.billable; } -// ── Delete ──────────────────────────────────────────────────────────────────── +// ── Delete ─────────────────────────────────────────────────────────────────── async function deleteRow(row) { - if (!confirm('Wirklich löschen?')) return; + if (!confirm(t('confirmDelete'))) return; try { const res = await fetch(`${api}/${row.dataset.id}`, { method: 'DELETE' }); if (res.status === 409) { - if (confirm('Dieser Eintrag hat abhängige Zeiteinträge und kann nicht gelöscht werden.\nStattdessen archivieren?')) { + if (confirm(t('confirmArchive'))) { await archiveRow(row); } return; } - if (!res.ok) { alert('Fehler beim Löschen.'); return; } - - row.classList.add('crud-row--removing'); - setTimeout(() => row.remove(), 280); + if (!res.ok) { alert(t('errorDelete')); return; } + removeWithAnimation(row, 'crud-row--removing'); } catch { - alert('Fehler beim Löschen.'); + alert(t('errorDelete')); } } async function archiveRow(row) { try { const res = await fetch(`${api}/${row.dataset.id}/archive`, { method: 'PATCH' }); - if (!res.ok) { alert('Fehler beim Archivieren.'); return; } + if (!res.ok) { alert(t('errorArchive')); return; } row.dataset.archived = '1'; row.classList.add('crud-row--archived'); updateRowArchivedState(row, true); filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active'); - } catch { - alert('Fehler beim Archivieren.'); + alert(t('errorArchive')); } } async function unarchiveRow(row) { try { const res = await fetch(`${api}/${row.dataset.id}/unarchive`, { method: 'PATCH' }); - if (!res.ok) { alert('Fehler beim Wiederherstellen.'); return; } + if (!res.ok) { alert(t('errorRestore')); return; } row.dataset.archived = '0'; row.classList.remove('crud-row--archived'); updateRowArchivedState(row, false); filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active'); - } catch { - alert('Fehler beim Wiederherstellen.'); + alert(t('errorRestore')); } } @@ -288,16 +277,16 @@ function updateRowArchivedState(row, archived) { if (archived) { actions.innerHTML = ` - `; row.querySelector('.crud-row__edit')?.remove(); } else { actions.innerHTML = ` - - `; } @@ -306,8 +295,8 @@ function updateRowArchivedState(row, archived) { function filterByTab(tab) { document.querySelectorAll('#crud-list .crud-row').forEach(row => { row.hidden = tab === 'active' - ? row.dataset.archived === '1' - : row.dataset.archived === '0'; + ? row.dataset.archived === '1' + : row.dataset.archived === '0'; }); } @@ -329,7 +318,7 @@ function initTabs() { }); } -// ── Neue Zeile einfügen ─────────────────────────────────────────────────────── +// ── Neue Zeile einfügen ────────────────────────────────────────────────────── function appendRowToList(data) { const list = document.getElementById('crud-list'); @@ -337,9 +326,8 @@ function appendRowToList(data) { const html = buildRowHTML(data); - // Services haben Gruppen → in die richtige Gruppe einfügen if (data.billable !== undefined) { - const groupLabel = data.billable ? 'Verrechenbar' : 'Nicht-verrechenbar'; + const groupLabel = data.billable ? t('groupBillable') : t('groupNotBillable'); let targetGroup = null; list.querySelectorAll('.crud-list__group').forEach(g => { @@ -351,17 +339,14 @@ function appendRowToList(data) { if (targetGroup) { targetGroup.insertAdjacentHTML('beforeend', html); } else { - // Gruppe existiert noch nicht → neu anlegen - const groupHtml = `
${groupLabel}
${html}
`; + const groupHtml = `
${esc(groupLabel)}
${html}
`; if (!data.billable) { - // Nicht-verrechenbar immer ans Ende list.insertAdjacentHTML('beforeend', groupHtml); } else { - // Verrechenbar vor die erste existierende Gruppe const firstGroup = list.querySelector('.crud-list__group'); firstGroup - ? firstGroup.insertAdjacentHTML('beforebegin', groupHtml) - : list.insertAdjacentHTML('beforeend', groupHtml); + ? firstGroup.insertAdjacentHTML('beforebegin', groupHtml) + : list.insertAdjacentHTML('beforeend', groupHtml); } } } else { @@ -370,11 +355,7 @@ function appendRowToList(data) { const prefix = rowPrefix(); const el = document.getElementById(`${prefix}-${data.id}`); - if (el) { - requestAnimationFrame(() => requestAnimationFrame(() => { - el.classList.remove('crud-row--new'); - })); - } + if (el) animateIn(el, 'crud-row--new'); } function buildRowHTML(data) { @@ -382,48 +363,45 @@ function buildRowHTML(data) { let metaHtml = ''; let editFields = ''; - // Kunden if (data.projectCount !== undefined) { const c = data.projectCount; - metaHtml = `${c} ${c === 1 ? 'Projekt' : 'Projekte'}`; + metaHtml = `${c} ${c === 1 ? t('projectSingular') : t('projectPlural')}`; editFields = ` - -
- + +
+
- +
- -
`; + +
`; } - // Projekte if (data.clientName !== undefined && data.projectCount === undefined) { - metaHtml = `${data.clientName}`; + metaHtml = `${esc(data.clientName)}`; editFields = ` - -
- + +
+
- -
`; + +
`; } - // Leistungen if (data.billable !== undefined) { editFields = ` - -
- + +
+
- -
`; + +
`; } return ` @@ -431,22 +409,22 @@ function buildRowHTML(data) { id="${prefix}-${data.id}" data-id="${data.id}" data-archived="0" - data-name="${data.name}" - ${data.hourlyRate !== undefined ? `data-rate="${data.hourlyRate ?? ''}"` : ''} + data-name="${esc(data.name)}" + ${data.hourlyRate !== undefined ? `data-rate="${esc(data.hourlyRate ?? '')}"` : ''} ${data.clientId !== undefined ? `data-client-id="${data.clientId}"` : ''} ${data.billable !== undefined ? `data-billable="${data.billable ? '1' : '0'}"` : ''} - data-note="${data.note ?? ''}"> + data-note="${esc(data.note ?? '')}">
- ${data.name} + ${esc(data.name)} ${metaHtml}
- -
@@ -456,15 +434,15 @@ function buildRowHTML(data) {
${editFields}
- - + +
`; } -// ── Init ────────────────────────────────────────────────────────────────────── +// ── Init ───────────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { initCreateForm(); diff --git a/httpdocs/assets/scripts/duration.js b/httpdocs/assets/scripts/duration.js index 702ef6f..9c91e66 100644 --- a/httpdocs/assets/scripts/duration.js +++ b/httpdocs/assets/scripts/duration.js @@ -1,96 +1,75 @@ // assets/scripts/duration.js -// Zentrale Logik für Zeiteingabe – wird von entries.js importiert -// ── Konfiguration ───────────────────────────────────────────────────────────── -// Auf false setzen um Viertelstunden-Runden zu deaktivieren export const DURATION_CONFIG = { roundToQuarter: true, }; -// ── Parser ──────────────────────────────────────────────────────────────────── - -/** - * Parst Zeiteingaben in Minuten. - * - * Unterstützte Formate: - * "1:30" → 90 (Stunden:Minuten) - * "8 12" → 240 (von 8 bis 12 Uhr) - * "1,75" → 105 (Dezimalstunden mit Komma) - * "1.75" → 105 (Dezimalstunden mit Punkt) - * "2" → 120 (nur Stunden als ganze Zahl) - * "0:00" → 0 (Stopp/Reset) - */ export function parseDuration(input) { input = String(input).trim(); if (!input || input === '0' || input === '0:00') return 0; - // "8 12" → von 8 bis 12 Uhr + // "8 12" -> von 8 bis 12 Uhr if (/^\d+\s+\d+$/.test(input)) { const parts = input.split(/\s+/).map(Number); - const minutes = (parts[1] - parts[0]) * 60; - return Math.max(0, minutes); + return Math.max(0, (parts[1] - parts[0]) * 60); } - // "1:30" → Stunden:Minuten + // "1:30" -> Stunden:Minuten if (input.includes(':')) { - const [h, m] = input.split(':').map(s => parseInt(s) || 0); + const [h, m] = input.split(':').map(s => parseInt(s, 10) || 0); return h * 60 + m; } - // "1,75" oder "1.75" → Dezimalstunden + // "1,75" oder "1.75" -> Dezimalstunden if (input.includes(',') || input.includes('.')) { const hours = parseFloat(input.replace(',', '.')); return isNaN(hours) ? 0 : Math.round(hours * 60); } - // "2" → 2 Stunden - const hours = parseInt(input); + // "2" -> 2 Stunden + const hours = parseInt(input, 10); return isNaN(hours) ? 0 : hours * 60; } -// ── Rounding ────────────────────────────────────────────────────────────────── - -/** - * Rundet Minuten auf die nächste Viertelstunde auf. - * 0 bleibt 0 (Stopp). - */ export function roundToQuarter(minutes) { if (!DURATION_CONFIG.roundToQuarter) return minutes; if (minutes === 0) return 0; - const interval = window.TT?.trackingInterval ?? 15; + const interval = window.TT?.trackingInterval ?? window.Report?.trackingInterval ?? 15; return Math.ceil(minutes / interval) * interval; } -// ── Formatter ───────────────────────────────────────────────────────────────── - export function formatMinutes(minutes) { const h = Math.floor(minutes / 60); const m = minutes % 60; return `${h}:${String(m).padStart(2, '0')}`; } -// ── Blur-Handler (global, per Event Delegation) ─────────────────────────────── -// Reagiert auf blur an allen Dauer-Inputs, egal ob server-gerendert oder JS-erstellt +export function validateDuration(minutes) { + if (minutes > 1440) return { status: 'error' }; + if (minutes > 480) return { status: 'warn' }; + return { status: 'ok' }; +} + +export function parseAndValidate(raw) { + const minutes = roundToQuarter(parseDuration(raw)); + const formatted = formatMinutes(minutes); + + if (minutes === 0) return { minutes, formatted, error: 'errorZeroDuration' }; + + const v = validateDuration(minutes); + if (v.status === 'error') return { minutes, formatted, error: 'errorDurationTooLong' }; + if (v.status === 'warn') return { minutes, formatted, warn: 'warnDurationLong' }; + + return { minutes, formatted }; +} export function initDurationBlurHandler() { document.addEventListener('blur', e => { if (!(e.target instanceof Element)) return; if (!e.target.matches('#create-duration, .edit-duration')) return; - const raw = e.target.value; - const minutes = roundToQuarter(parseDuration(raw)); + const minutes = roundToQuarter(parseDuration(e.target.value)); e.target.value = formatMinutes(minutes); - }, true); // capture=true, weil blur nicht bubbled -} - -/** - * Validiert eine Dauer in Minuten. - * > 1440 (24h) → error - * > 480 (8h) → warn - */ -export function validateDuration(minutes) { - if (minutes > 1440) return { status: 'error' }; - if (minutes > 480) return { status: 'warn' }; - return { status: 'ok' }; + }, true); } diff --git a/httpdocs/assets/scripts/entries.js b/httpdocs/assets/scripts/entries.js index 70ff18e..836fe74 100644 --- a/httpdocs/assets/scripts/entries.js +++ b/httpdocs/assets/scripts/entries.js @@ -1,14 +1,17 @@ // assets/scripts/entries.js -import { parseDuration, roundToQuarter, formatMinutes, initDurationBlurHandler, validateDuration } from './duration.js'; + +import { parseAndValidate, initDurationBlurHandler } from './duration.js'; +import { esc, createTranslator, ANIMATION_MS, FADE_MS, MINUTES_PER_DAY, removeWithAnimation, animateIn } from './utils.js'; const LAST_PROJECT_KEY = 'tt_last_project_id'; const LAST_SERVICE_KEY = 'tt_last_service_id'; +const NOTE_KEY = 'tt_minimal_note_open'; const LOCK_SVG = ``; -function t(key) { - return window.TT?.i18n?.[key] ?? key; -} +const t = createTranslator('TT'); + +// ── Select-Builder ─────────────────────────────────────────────────────────── function buildProjectOptions(selectedId = null) { const groups = {}; @@ -19,10 +22,10 @@ function buildProjectOptions(selectedId = null) { let html = ``; for (const [client, projects] of Object.entries(groups)) { - html += ``; + html += ``; projects.forEach(p => { const sel = String(p.id) === String(selectedId) ? ' selected' : ''; - html += ``; + html += ``; }); html += ''; } @@ -35,36 +38,33 @@ function buildServiceOptions(selectedId = null) { let html = ``; - if (billable.length) { - html += ``; - billable.forEach(s => { + const addGroup = (label, list) => { + if (!list.length) return; + html += ``; + list.forEach(s => { const sel = String(s.id) === String(selectedId) ? ' selected' : ''; - html += ``; + html += ``; }); html += ''; - } + }; - if (notBillable.length) { - html += ``; - notBillable.forEach(s => { - const sel = String(s.id) === String(selectedId) ? ' selected' : ''; - html += ``; - }); - html += ''; - } + addGroup(t('billable'), billable); + addGroup(t('notBillable'), notBillable); return html; } +// ── Row HTML ───────────────────────────────────────────────────────────────── + function buildEntryRowHTML(entry, animate = false) { - const servicePart = entry.serviceName ? ` / ${entry.serviceName}` : ''; - const notePart = entry.note ? `
${entry.note}
` : ''; + const servicePart = entry.serviceName ? ` / ${esc(entry.serviceName)}` : ''; + const notePart = entry.note ? `
${esc(entry.note)}
` : ''; const invoiced = !!entry.invoiced; const actionsHtml = invoiced - ? `${entry.durationFormatted} + ? `${esc(entry.durationFormatted)} ${LOCK_SVG}` - : `${entry.durationFormatted} + : `${esc(entry.durationFormatted)} @@ -78,7 +78,7 @@ function buildEntryRowHTML(entry, animate = false) {
+ value="${esc(entry.durationFormatted)}" autocomplete="off" />
? ${t('durationHint')} @@ -91,7 +91,7 @@ function buildEntryRowHTML(entry, animate = false) {
- +
@@ -107,12 +107,12 @@ function buildEntryRowHTML(entry, animate = false) { data-duration="${entry.duration}" data-project-id="${entry.projectId}" data-service-id="${entry.serviceId ?? ''}" - data-note="${(entry.note ?? '').replace(/"/g, '"')}" + data-note="${esc(entry.note ?? '')}" data-invoiced="${invoiced ? 'true' : 'false'}">
-
${entry.clientName} / ${entry.projectName}${servicePart}
+
${esc(entry.clientName)} / ${esc(entry.projectName)}${servicePart}
${notePart}
@@ -123,6 +123,23 @@ function buildEntryRowHTML(entry, animate = false) {
`; } +// ── Helpers ────────────────────────────────────────────────────────────────── + +function getDailyTotalMinutes() { + let total = 0; + document.querySelectorAll('#entry-items .entry-row').forEach(row => { + total += parseInt(row.dataset.duration, 10) || 0; + }); + return total; +} + +function saveLastProject(id) { if (id) localStorage.setItem(LAST_PROJECT_KEY, id); } +function getLastProject() { return localStorage.getItem(LAST_PROJECT_KEY); } +function saveLastService(id) { if (id) localStorage.setItem(LAST_SERVICE_KEY, id); } +function getLastService() { return localStorage.getItem(LAST_SERVICE_KEY); } + +// ── EntryManager ───────────────────────────────────────────────────────────── + class EntryManager { constructor() { this.list = document.getElementById('entry-list'); @@ -133,13 +150,8 @@ class EntryManager { const cp = document.getElementById('create-project'); const cs = document.getElementById('create-service'); - document.getElementById('create-service')?.addEventListener('change', e => { - saveLastService(e.target.value); - }); - - document.getElementById('create-project')?.addEventListener('change', e => { - saveLastProject(e.target.value); - }); + cs?.addEventListener('change', e => saveLastService(e.target.value)); + cp?.addEventListener('change', e => saveLastProject(e.target.value)); if (cp) { const lastProject = getLastProject(); @@ -182,13 +194,13 @@ class EntryManager { return; } - // Klick auf Anzeige-Bereich (kein Button) → Edit öffnen if (e.target.closest('.entry-row__display') && row.dataset.invoiced !== 'true') { this.openEdit(row); } } async createEntry() { + const btn = document.getElementById('btn-create'); const durationRaw = document.getElementById('create-duration')?.value ?? '0:00'; const projectId = document.getElementById('create-project')?.value; const serviceId = document.getElementById('create-service')?.value; @@ -196,36 +208,27 @@ class EntryManager { if (!projectId) { alert(t('errorNoProject')); return; } - const duration = formatMinutes(roundToQuarter(parseDuration(durationRaw))); - - if (duration === '0:00') { - alert(t('errorZeroDuration')); - return; - } - - const rawMinutes = roundToQuarter(parseDuration(durationRaw)); - const validation = validateDuration(rawMinutes); - if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; } - if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return; - - if (getDailyTotalMinutes() + rawMinutes > 1440) { alert(t('errorDailyLimitExceeded')); return; } + const dur = parseAndValidate(durationRaw); + if (dur.error) { alert(t(dur.error)); return; } + if (dur.warn && !confirm(t(dur.warn))) return; + if (getDailyTotalMinutes() + dur.minutes > MINUTES_PER_DAY) { alert(t('errorDailyLimitExceeded')); return; } + if (btn) btn.disabled = true; try { const res = await fetch('/api/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date: window.TT.activeDate, - duration, - projectId: parseInt(projectId), - serviceId: serviceId ? parseInt(serviceId) : null, + duration: dur.formatted, + projectId: parseInt(projectId, 10), + serviceId: serviceId ? parseInt(serviceId, 10) : null, note: note || null, }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); - console.error('API Fehler:', res.status, err); alert(t('errorSave') + (err.error ? `\n${err.error}` : '')); return; } @@ -234,10 +237,10 @@ class EntryManager { this.addEntryToDOM(data.entry); this.updateTotal(data.totalDuration); this.resetCreateForm(); - - } catch (err) { - console.error('Netzwerkfehler:', err); + } catch { alert(t('errorSave')); + } finally { + if (btn) btn.disabled = false; } } @@ -255,9 +258,7 @@ class EntryManager { items.insertAdjacentHTML('beforeend', buildEntryRowHTML(entry, true)); const el = document.getElementById(`entry-${entry.id}`); - requestAnimationFrame(() => requestAnimationFrame(() => { - el?.classList.remove('entry-row--new'); - })); + if (el) animateIn(el, 'entry-row--new'); } resetCreateForm() { @@ -272,9 +273,7 @@ class EntryManager { } openEdit(row) { - // Safety-Guard: invoiced-Einträge können nicht geöffnet werden if (row.dataset.invoiced === 'true') return; - // Kein Edit-Formular vorhanden → nicht öffnen const editSection = row.querySelector('.entry-row__edit'); if (!editSection) return; @@ -303,6 +302,9 @@ class EntryManager { } async saveEdit(row) { + const saveBtn = row.querySelector('[data-action="save"]'); + if (saveBtn?.disabled) return; + const id = row.dataset.id; const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00'; const projectId = row.querySelector('.edit-project')?.value; @@ -311,35 +313,30 @@ class EntryManager { if (!projectId) { alert(t('errorNoProject')); return; } - const duration = formatMinutes(roundToQuarter(parseDuration(durationRaw))); + const dur = parseAndValidate(durationRaw); + if (dur.error) { alert(t(dur.error)); return; } + if (dur.warn && !confirm(t(dur.warn))) return; - if (duration === '0:00') { - alert(t('errorZeroDuration')); + const currentMinutes = parseInt(row.dataset.duration, 10) || 0; + if (getDailyTotalMinutes() - currentMinutes + dur.minutes > MINUTES_PER_DAY) { + alert(t('errorDailyLimitExceeded')); return; } - const rawMinutes = roundToQuarter(parseDuration(durationRaw)); - const validation = validateDuration(rawMinutes); - if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; } - if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return; - - const currentEntryMinutes = parseInt(row.dataset.duration) || 0; - if (getDailyTotalMinutes() - currentEntryMinutes + rawMinutes > 1440) { alert(t('errorDailyLimitExceeded')); return; } - + if (saveBtn) saveBtn.disabled = true; try { const res = await fetch(`/api/entries/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - duration, - projectId: parseInt(projectId), - serviceId: serviceId ? parseInt(serviceId) : null, + duration: dur.formatted, + projectId: parseInt(projectId, 10), + serviceId: serviceId ? parseInt(serviceId, 10) : null, note: note || null, }), }); if (!res.ok) { - console.error('PATCH fehlgeschlagen:', res.status); alert(t('errorSave')); return; } @@ -348,10 +345,10 @@ class EntryManager { this.updateRowDisplay(row, data.entry); this.updateTotal(data.totalDuration); this.closeEdit(row); - - } catch (err) { - console.error('saveEdit Fehler:', err); + } catch { alert(t('errorSave')); + } finally { + if (saveBtn) saveBtn.disabled = false; } } @@ -388,14 +385,14 @@ class EntryManager { if (!res.ok) { alert(t('errorDelete')); return; } const data = await res.json(); - row.classList.add('entry-row--removing'); + removeWithAnimation(row, 'entry-row--removing'); setTimeout(() => { - row.remove(); this.updateTotal(data.totalDuration); this.checkIfEmpty(); - }, 280); - - } catch { alert(t('errorDelete')); } + }, ANIMATION_MS); + } catch { + alert(t('errorDelete')); + } } async loadEntriesForDate(dateStr) { @@ -403,9 +400,9 @@ class EntryManager { try { this.list.classList.add('entry-list--fading'); - await new Promise(r => setTimeout(r, 180)); + await new Promise(r => setTimeout(r, FADE_MS)); - const res = await fetch(`/api/entries?date=${dateStr}`); + const res = await fetch(`/api/entries?date=${dateStr}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); @@ -428,7 +425,7 @@ class EntryManager { let html = '
'; entries.forEach(e => { html += buildEntryRowHTML(e, false); }); html += `
- ${totalDuration}
`; + ${esc(totalDuration)}
`; this.list.innerHTML = html; this.emptyState = null; @@ -449,7 +446,7 @@ class EntryManager { footer.id = 'entry-footer'; this.list.appendChild(footer); } - footer.innerHTML = `${totalDuration}`; + footer.innerHTML = `${esc(totalDuration)}`; } hideEmptyState() { this.emptyState?.remove(); this.emptyState = null; } @@ -466,33 +463,7 @@ class EntryManager { } } -function getDailyTotalMinutes() { - let total = 0; - document.querySelectorAll('#entry-items .entry-row').forEach(row => { - total += parseInt(row.dataset.duration) || 0; - }); - return total; -} - -function saveLastProject(projectId) { - if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId); -} - -function getLastProject() { - return localStorage.getItem(LAST_PROJECT_KEY); -} - -function saveLastService(serviceId) { - if (serviceId) localStorage.setItem(LAST_SERVICE_KEY, serviceId); -} - -function getLastService() { - return localStorage.getItem(LAST_SERVICE_KEY); -} - -// ── Minimal-Modus-Initialisierung ───────────────────────────────────────────── - -const NOTE_KEY = 'tt_minimal_note_open'; +// ── Minimal-Modus ──────────────────────────────────────────────────────────── function initMinimalMode() { if (document.body.dataset.theme !== 'minimal') return; @@ -527,9 +498,9 @@ function initWeekToggle() { } function initNoteToggle() { - const btn = document.getElementById('btn-note-toggle'); - const label = document.querySelector('.entry-form__label--note'); - const field = document.querySelector('.entry-form__field--note'); + const btn = document.getElementById('btn-note-toggle'); + const label = document.querySelector('.entry-form__label--note'); + const field = document.querySelector('.entry-form__field--note'); if (!btn) return; const open = localStorage.getItem(NOTE_KEY) === '1'; @@ -539,7 +510,7 @@ function initNoteToggle() { const nowOpen = label?.classList.toggle('is-visible'); field?.classList.toggle('is-visible'); btn.classList.toggle('is-open', !!nowOpen); - btn.textContent = nowOpen ? '× Bemerkung ausblenden' : '+ Bemerkung hinzufügen'; + btn.textContent = nowOpen ? t('noteHide') : t('noteShow'); localStorage.setItem(NOTE_KEY, nowOpen ? '1' : '0'); }); } @@ -549,18 +520,17 @@ function setNoteVisible(open, btn, label, field) { label?.classList.add('is-visible'); field?.classList.add('is-visible'); btn.classList.add('is-open'); - btn.textContent = '× Bemerkung ausblenden'; + btn.textContent = t('noteHide'); } else { - btn.textContent = '+ Bemerkung hinzufügen'; + btn.textContent = t('noteShow'); } } function initEntriesToggle() { - const summaryBtn = document.getElementById('btn-entries-toggle'); - const entryList = document.getElementById('entry-list'); + const summaryBtn = document.getElementById('btn-entries-toggle'); + const entryList = document.getElementById('entry-list'); if (!summaryBtn || !entryList) return; - // Immer eingeklappt beim Laden entryList.classList.add('is-collapsed'); summaryBtn.setAttribute('aria-expanded', 'false'); diff --git a/httpdocs/assets/scripts/registration.js b/httpdocs/assets/scripts/registration.js index 318b64c..110878d 100644 --- a/httpdocs/assets/scripts/registration.js +++ b/httpdocs/assets/scripts/registration.js @@ -1,87 +1,88 @@ // assets/scripts/registration.js +import { esc, createTranslator } from './utils.js'; + +const t = createTranslator('Register'); + document.addEventListener('DOMContentLoaded', () => { - const form = document.getElementById('register-form'); - const companyInput = document.getElementById('companyName'); - const slugPreview = document.getElementById('slug-preview'); - const submitBtn = document.getElementById('submit-btn'); - const errorBox = document.getElementById('register-errors'); - const appDomain = window.REGISTER_APP_DOMAIN ?? ''; + const form = document.getElementById('register-form'); + const companyInput = document.getElementById('companyName'); + const slugPreview = document.getElementById('slug-preview'); + const submitBtn = document.getElementById('submit-btn'); + const errorBox = document.getElementById('register-errors'); + const appDomain = window.Register?.appDomain ?? ''; + + // ── Slug-Vorschau ───────────────────────────────────────────────────────── + + let debounceTimer = null; + companyInput?.addEventListener('input', () => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(async () => { + const value = companyInput.value.trim(); + if (!value) { slugPreview.textContent = ''; return; } - // ── Slug-Vorschau ───────────────────────────────────────────────────────── - let debounce = null; - companyInput?.addEventListener('input', () => { - clearTimeout(debounce); - debounce = setTimeout(async () => { - const value = companyInput.value.trim(); - if (!value) { slugPreview.textContent = ''; return; } + try { + const res = await fetch('/api/register/preview-slug', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ companyName: value }), + }); + const data = await res.json(); + slugPreview.textContent = data.slug ? data.slug + '.' + appDomain : '–'; + } catch { + slugPreview.textContent = ''; + } + }, 350); + }); - try { - const res = await fetch('/api/register/preview-slug', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ companyName: value }), - }); - const data = await res.json(); - slugPreview.textContent = data.slug ? data.slug + '.' + appDomain : '–'; - } catch { - slugPreview.textContent = ''; - } - }, 350); - }); + // ── Formular absenden ───────────────────────────────────────────────────── - // ── Formular absenden ───────────────────────────────────────────────────── - form?.addEventListener('submit', async (e) => { - e.preventDefault(); - errorBox.innerHTML = ''; - submitBtn.disabled = true; - submitBtn.textContent = 'Wird gesendet …'; + form?.addEventListener('submit', async (e) => { + e.preventDefault(); + errorBox.innerHTML = ''; + submitBtn.disabled = true; + submitBtn.textContent = t('sending'); - const payload = { - companyName: document.getElementById('companyName').value, - email: document.getElementById('email').value, - firstName: document.getElementById('firstName').value, - lastName: document.getElementById('lastName').value, - password: document.getElementById('password').value, - passwordRepeat: document.getElementById('passwordRepeat').value, - }; + const payload = { + companyName: document.getElementById('companyName').value, + email: document.getElementById('email').value, + firstName: document.getElementById('firstName').value, + lastName: document.getElementById('lastName').value, + password: document.getElementById('password').value, + passwordRepeat: document.getElementById('passwordRepeat').value, + }; - try { - const res = await fetch('/api/register', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const data = await res.json(); + try { + const res = await fetch('/api/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); - if (res.ok) { - document.querySelector('.register-page').innerHTML = ` -
-
-

Fast geschafft!

-

- Wir haben eine Bestätigungs-E-Mail an - ${payload.email} geschickt. -

-

- Bitte klicke auf den Link in der E-Mail um dein Konto zu aktivieren. - Der Link ist 24 Stunden gültig. -

-
- `; - } 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 = '

Verbindungsfehler. Bitte versuche es erneut.

'; - submitBtn.disabled = false; - submitBtn.textContent = 'Konto erstellen'; - } - }); -}); \ No newline at end of file + if (res.ok) { + const text = t('successText').replace('%email%', `${esc(payload.email)}`); + document.querySelector('.register-page').innerHTML = ` +
+
+

${t('successTitle')}

+

${text}

+

${t('successHint')}

+
+ `; + } 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 = `

${esc(t('errorConnection'))}

`; + submitBtn.disabled = false; + submitBtn.textContent = t('btnSubmit'); + } + }); +}); diff --git a/httpdocs/assets/scripts/report.js b/httpdocs/assets/scripts/report.js index a46d966..354c632 100644 --- a/httpdocs/assets/scripts/report.js +++ b/httpdocs/assets/scripts/report.js @@ -1,477 +1,437 @@ // assets/scripts/report.js -import { - parseDuration, - roundToQuarter, - formatMinutes, - validateDuration, - initDurationBlurHandler, -} from './duration.js'; - -// ── Hilfsfunktionen ─────────────────────────────────────────────────────────── - -function t(key) { - return window.Report?.i18n?.[key] ?? key; -} +import { parseAndValidate, initDurationBlurHandler } from './duration.js'; +import { esc, createTranslator } from './utils.js'; + +const t = createTranslator('Report'); + +// ── Hilfsfunktionen ────────────────────────────────────────────────────────── function populateProjectSelect(select, selectedId) { - const projects = window.Report?.projects ?? []; - select.innerHTML = ''; - projects.forEach(p => { - const opt = document.createElement('option'); - opt.value = p.id; - opt.textContent = `${p.clientName} / ${p.name}`; - if (p.id === selectedId) opt.selected = true; - select.appendChild(opt); - }); + const projects = window.Report?.projects ?? []; + select.innerHTML = ''; + projects.forEach(p => { + const opt = document.createElement('option'); + opt.value = p.id; + opt.textContent = `${p.clientName} / ${p.name}`; + if (p.id === selectedId) opt.selected = true; + select.appendChild(opt); + }); } function populateServiceSelect(select, selectedId) { - const services = window.Report?.services ?? []; - const billable = services.filter(s => s.billable); - const notBillable = services.filter(s => !s.billable); - - select.innerHTML = ``; - - 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 = ``; + + function addGroup(label, list) { + if (!list.length) return; + const group = document.createElement('optgroup'); + group.label = label; + list.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.id; + opt.textContent = s.name; + if (s.id === selectedId) opt.selected = true; + group.appendChild(opt); + }); + select.appendChild(group); + } - addGroup(t('billable'), billable); - addGroup(t('notBillable'), notBillable); + addGroup(t('billable'), billable); + addGroup(t('notBillable'), notBillable); } -// ── Edit öffnen ─────────────────────────────────────────────────────────────── +// ── Edit öffnen ────────────────────────────────────────────────────────────── function openEdit(row) { - document.querySelectorAll('.report-table__row--editing').forEach(r => { - if (r !== row) closeEdit(r); - }); + document.querySelectorAll('.report-table__row--editing').forEach(r => { + if (r !== row) closeEdit(r); + }); - const editForm = row.querySelector('.report-row__edit'); - if (!editForm) return; + const editForm = row.querySelector('.report-row__edit'); + if (!editForm) return; - // Selects klonen um akkumulierte Listener zu vermeiden - const oldProjectSel = row.querySelector('.edit-project'); - const oldServiceSel = row.querySelector('.edit-service'); - const projectSel = oldProjectSel.cloneNode(false); - const serviceSel = oldServiceSel.cloneNode(false); - oldProjectSel.replaceWith(projectSel); - oldServiceSel.replaceWith(serviceSel); + const oldProjectSel = row.querySelector('.edit-project'); + const oldServiceSel = row.querySelector('.edit-service'); + const projectSel = oldProjectSel.cloneNode(false); + const serviceSel = oldServiceSel.cloneNode(false); + oldProjectSel.replaceWith(projectSel); + oldServiceSel.replaceWith(serviceSel); - const projectId = parseInt(row.dataset.projectId) || null; - const serviceId = parseInt(row.dataset.serviceId) || null; + const projectId = parseInt(row.dataset.projectId, 10) || null; + const serviceId = parseInt(row.dataset.serviceId, 10) || null; - populateProjectSelect(projectSel, projectId); - populateServiceSelect(serviceSel, serviceId); + populateProjectSelect(projectSel, projectId); + populateServiceSelect(serviceSel, serviceId); - projectSel.addEventListener('change', () => { - populateServiceSelect(row.querySelector('.edit-service'), null); - }); + projectSel.addEventListener('change', () => { + populateServiceSelect(row.querySelector('.edit-service'), null); + }); - editForm.hidden = false; - row.classList.add('report-table__row--editing'); - row.querySelector('.edit-duration')?.focus(); + editForm.hidden = false; + row.classList.add('report-table__row--editing'); + row.querySelector('.edit-duration')?.focus(); } function closeEdit(row) { - const editForm = row.querySelector('.report-row__edit'); - if (!editForm) return; - editForm.hidden = true; - row.classList.remove('report-table__row--editing'); + const editForm = row.querySelector('.report-row__edit'); + if (!editForm) return; + editForm.hidden = true; + row.classList.remove('report-table__row--editing'); } -// ── Speichern ───────────────────────────────────────────────────────────────── +// ── Speichern ──────────────────────────────────────────────────────────────── async function saveEdit(row) { - const id = row.dataset.entryId; - const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00'; - const projectId = row.querySelector('.edit-project')?.value; - const serviceId = row.querySelector('.edit-service')?.value; - const note = row.querySelector('.edit-note')?.value ?? ''; - - if (!projectId) { alert(t('errorNoProject')); return; } - - const rawMinutes = roundToQuarter(parseDuration(durationRaw)); - - if (rawMinutes === 0) { alert(t('errorZeroDuration')); return; } - - const validation = validateDuration(rawMinutes); - if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; } - if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return; - - try { - const res = await fetch(`/api/entries/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - duration: formatMinutes(rawMinutes), - projectId: parseInt(projectId), - serviceId: serviceId ? parseInt(serviceId) : null, - note: note || null, - }), - }); - - if (!res.ok) { - const err = await res.json().catch(() => ({})); - alert(err.error ?? t('errorSave')); - return; - } - - window.location.reload(); + const saveBtn = row.querySelector('[data-action="save"]'); + if (saveBtn?.disabled) return; + + const id = row.dataset.entryId; + const projectId = row.querySelector('.edit-project')?.value; + const serviceId = row.querySelector('.edit-service')?.value; + const note = row.querySelector('.edit-note')?.value ?? ''; + + if (!projectId) { alert(t('errorNoProject')); return; } + + const dur = parseAndValidate(row.querySelector('.edit-duration')?.value ?? '0:00'); + if (dur.error) { alert(t(dur.error)); return; } + if (dur.warn && !confirm(t(dur.warn))) return; + + if (saveBtn) saveBtn.disabled = true; + try { + const res = await fetch(`/api/entries/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + duration: dur.formatted, + projectId: parseInt(projectId, 10), + serviceId: serviceId ? parseInt(serviceId, 10) : null, + note: note || null, + }), + }); - } catch { - alert(t('errorSave')); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.error ?? t('errorSave')); + return; } + + window.location.reload(); + } catch { + alert(t('errorSave')); + } finally { + if (saveBtn) saveBtn.disabled = false; + } } -// ── Löschen ─────────────────────────────────────────────────────────────────── +// ── Löschen ────────────────────────────────────────────────────────────────── async function deleteEntry(row) { - if (!confirm(t('confirmDelete'))) return; - - const id = row.dataset.entryId; - - try { - const res = await fetch(`/api/entries/${id}`, { method: 'DELETE' }); - if (!res.ok) { alert(t('errorDelete')); return; } - window.location.reload(); - } catch { - alert(t('errorDelete')); - } + if (!confirm(t('confirmDelete'))) return; + + try { + const res = await fetch(`/api/entries/${row.dataset.entryId}`, { method: 'DELETE' }); + if (!res.ok) { alert(t('errorDelete')); return; } + window.location.reload(); + } catch { + alert(t('errorDelete')); + } } -// ── Abgerechnet toggeln ─────────────────────────────────────────────────────── +// ── Abgerechnet toggeln ────────────────────────────────────────────────────── async function toggleInvoiced(row) { - const id = row.dataset.entryId; - const btn = row.querySelector('[data-action="toggle-invoiced"]'); + const id = row.dataset.entryId; + const btn = row.querySelector('[data-action="toggle-invoiced"]'); - try { - const res = await fetch(`/api/entries/${id}/invoiced`, { method: 'PATCH' }); - if (!res.ok) return; + try { + const res = await fetch(`/api/entries/${id}/invoiced`, { method: 'PATCH' }); + if (!res.ok) return; - const data = await res.json(); - const invoiced = data.invoiced; + const data = await res.json(); + const invoiced = data.invoiced; - row.dataset.invoiced = invoiced ? 'true' : 'false'; - row.classList.toggle('report-table__row--invoiced', invoiced); + row.dataset.invoiced = invoiced ? 'true' : 'false'; + row.classList.toggle('report-table__row--invoiced', invoiced); - if (btn) { - btn.classList.toggle('report-lock--invoiced', invoiced); - btn.title = invoiced ? t('btnUnlock') : t('btnLock'); - } - - } catch (err) { - console.error('Fehler beim Toggeln des Abrechnungsstatus:', err); + if (btn) { + btn.classList.toggle('report-lock--invoiced', invoiced); + btn.title = invoiced ? t('btnUnlock') : t('btnLock'); } + } catch (err) { + console.error('toggleInvoiced error:', err); + } } -// ── Event-Delegation ────────────────────────────────────────────────────────── +// ── Event-Delegation ───────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { - initDurationBlurHandler(); - - const table = document.querySelector('.report-table'); - if (!table) return; + initDurationBlurHandler(); + const table = document.querySelector('.report-table'); + if (table) { table.addEventListener('click', e => { - const btn = e.target.closest('[data-action]'); - if (!btn) return; - - const row = btn.closest('.report-table__row'); - if (!row) return; - - switch (btn.dataset.action) { - case 'edit': openEdit(row); break; - case 'cancel': closeEdit(row); break; - case 'save': saveEdit(row); break; - case 'delete': deleteEntry(row); break; - case 'toggle-invoiced': toggleInvoiced(row); break; - } + const btn = e.target.closest('[data-action]'); + if (!btn) return; + + const row = btn.closest('.report-table__row'); + if (!row) return; + + switch (btn.dataset.action) { + case 'edit': openEdit(row); break; + case 'cancel': closeEdit(row); break; + case 'save': saveEdit(row); break; + case 'delete': deleteEntry(row); break; + case 'toggle-invoiced': toggleInvoiced(row); break; + } }); + } + + new ReportFilter().init(); + initExportButtons(); + initPrintButton(); }); -// ── ReportFilter ────────────────────────────────────────────────────────────── +// ── ReportFilter ───────────────────────────────────────────────────────────── class ReportFilter { - constructor() { - this.panel = document.getElementById('report-filter'); - this.toggleBtn = document.getElementById('btn-filter-toggle'); - this.applyBtn = document.getElementById('btn-filter-apply'); - this.hideBtn = document.getElementById('btn-filter-hide'); - this.periodSel = document.querySelector('.filter-period-select'); - this.customDates = document.querySelector('.filter-custom-dates'); - } - - init() { - if (!this.panel) return; + constructor() { + this.panel = document.getElementById('report-filter'); + this.toggleBtn = document.getElementById('btn-filter-toggle'); + this.applyBtn = document.getElementById('btn-filter-apply'); + this.hideBtn = document.getElementById('btn-filter-hide'); + this.periodSel = document.querySelector('.filter-period-select'); + this.customDates = document.querySelector('.filter-custom-dates'); + } + + init() { + if (!this.panel) return; + + this.toggleBtn?.addEventListener('click', () => this.togglePanel()); + this.hideBtn?.addEventListener('click', () => this.hidePanel()); + this.applyBtn?.addEventListener('click', () => this.applyFilters()); + + this.panel.querySelectorAll('.filter-row__checkbox').forEach(cb => { + cb.addEventListener('change', () => { + this.syncRowState(cb.closest('.filter-row'), cb.checked); + }); + }); - // Toolbar-Toggle - this.toggleBtn?.addEventListener('click', () => this.togglePanel()); + this.panel.querySelectorAll('.filter-select, .filter-note-input').forEach(el => { + el.addEventListener('mousedown', () => this.activateRowByControl(el)); + }); - // Ausblenden-Button - this.hideBtn?.addEventListener('click', () => this.hidePanel()); + this.periodSel?.addEventListener('change', () => { + this.activateRowByControl(this.periodSel); + this.toggleCustomDates(this.periodSel.value === 'custom'); + }); - // Filtern-Button - this.applyBtn?.addEventListener('click', () => this.applyFilters()); + this.panel.querySelectorAll('.filter-row__add').forEach(btn => { + btn.addEventListener('click', () => this.addControl(btn)); + }); - // Checkbox-Änderungen - this.panel.querySelectorAll('.filter-row__checkbox').forEach(cb => { - cb.addEventListener('change', () => { - const row = cb.closest('.filter-row'); - this.syncRowState(row, cb.checked); - }); - }); + this.panel.addEventListener('click', e => { + const removeBtn = e.target.closest('.filter-row__remove'); + if (removeBtn) this.removeControl(removeBtn); + }); - // Klick auf ausgegrautem Control → Checkbox aktivieren - this.panel.querySelectorAll('.filter-select, .filter-note-input').forEach(el => { - el.addEventListener('mousedown', () => this.activateRowByControl(el)); - }); + this.panel.addEventListener('change', e => { + const sel = e.target.closest('.filter-select'); + if (!sel) return; + const container = sel.closest('.filter-row__controls'); + if (container) this.refreshGroupSelects(container); + }); - // Zeitraum-Select → Custom-Felder zeigen/verstecken - this.periodSel?.addEventListener('change', () => { - const row = this.periodSel.closest('.filter-row'); - this.activateRowByControl(this.periodSel); - this.toggleCustomDates(this.periodSel.value === 'custom'); - }); + this.panel.querySelectorAll('.filter-row').forEach(row => { + const cb = row.querySelector('.filter-row__checkbox'); + this.syncRowState(row, cb?.checked ?? false); + }); - // Plus-Buttons - this.panel.querySelectorAll('.filter-row__add').forEach(btn => { - btn.addEventListener('click', () => this.addControl(btn)); - }); + this.panel.querySelectorAll('.filter-row__controls').forEach(container => { + this.refreshGroupSelects(container); + }); + } + + togglePanel() { + const isHidden = this.panel.hasAttribute('hidden'); + if (isHidden) { + this.panel.removeAttribute('hidden'); + this.toggleBtn?.classList.add('report-toolbar__action--active'); + } else { + this.hidePanel(); + } + } + + hidePanel() { + this.panel.setAttribute('hidden', ''); + this.toggleBtn?.classList.remove('report-toolbar__action--active'); + } + + syncRowState(row, active) { + row.classList.toggle('filter-row--inactive', !active); + } + + activateRowByControl(el) { + const row = el.closest('.filter-row'); + if (!row) return; + const cb = row.querySelector('.filter-row__checkbox'); + if (cb && !cb.checked) { + cb.checked = true; + this.syncRowState(row, true); + } + } - // Remove-Buttons (via Delegation, da sie dynamisch entstehen) - this.panel.addEventListener('click', e => { - const removeBtn = e.target.closest('.filter-row__remove'); - if (removeBtn) this.removeControl(removeBtn); - }); + toggleCustomDates(show) { + if (!this.customDates) return; + this.customDates.toggleAttribute('hidden', !show); + } - // Select-Änderung → Optionen in der Gruppe aktualisieren - this.panel.addEventListener('change', e => { - const sel = e.target.closest('.filter-select'); - if (!sel) return; - const container = sel.closest('.filter-row__controls'); - if (container) this.refreshGroupSelects(container); - }); + addControl(btn) { + const targetId = btn.dataset.target; + const container = document.getElementById(targetId); + if (!container) return; - // Initialer Zustand - this.panel.querySelectorAll('.filter-row').forEach(row => { - const cb = row.querySelector('.filter-row__checkbox'); - this.syncRowState(row, cb?.checked ?? false); - }); + const template = container.querySelector('.filter-row__control-group'); + if (!template) return; - // Bereits geladene Mehrfach-Selects deduplizieren (nach Seiten-Reload mit Filtern) - this.panel.querySelectorAll('.filter-row__controls').forEach(container => { - this.refreshGroupSelects(container); - }); - } + const clone = template.cloneNode(true); - // ── Panel toggeln ───────────────────────────────────────────────────────── + const clonedSelect = clone.querySelector('.filter-select'); + if (clonedSelect) clonedSelect.value = ''; - togglePanel() { - const isHidden = this.panel.hasAttribute('hidden'); - if (isHidden) { - this.panel.removeAttribute('hidden'); - this.toggleBtn?.classList.add('report-toolbar__action--active'); - } else { - this.hidePanel(); - } + if (!clone.querySelector('.filter-row__remove')) { + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'filter-row__remove'; + removeBtn.textContent = '×'; + clone.appendChild(removeBtn); } - hidePanel() { - this.panel.setAttribute('hidden', ''); - this.toggleBtn?.classList.remove('report-toolbar__action--active'); - } + clone.querySelector('.filter-select')?.addEventListener('mousedown', () => { + this.activateRowByControl(clone.querySelector('.filter-select')); + }); - // ── Row-Zustand (aktiv / inaktiv) ───────────────────────────────────────── + container.appendChild(clone); + this.refreshGroupSelects(container); - syncRowState(row, active) { - row.classList.toggle('filter-row--inactive', !active); + const row = btn.closest('.filter-row'); + const cb = row?.querySelector('.filter-row__checkbox'); + if (cb && !cb.checked) { + cb.checked = true; + this.syncRowState(row, true); } - activateRowByControl(el) { - const row = el.closest('.filter-row'); - if (!row) return; - const cb = row.querySelector('.filter-row__checkbox'); - if (cb && !cb.checked) { - cb.checked = true; - this.syncRowState(row, true); - } + clonedSelect?.focus(); + } + + removeControl(removeBtn) { + const group = removeBtn.closest('.filter-row__control-group'); + const container = group?.parentElement; + group?.remove(); + + if (container && !container.querySelector('.filter-row__control-group')) { + const row = container.closest('.filter-row'); + const cb = row?.querySelector('.filter-row__checkbox'); + if (cb) { + cb.checked = false; + this.syncRowState(row, false); + } } - // ── Zeitraum: Custom-Felder ──────────────────────────────────────────────── - - toggleCustomDates(show) { - if (!this.customDates) return; - if (show) { - this.customDates.removeAttribute('hidden'); - } else { - this.customDates.setAttribute('hidden', ''); - } - } + if (container) this.refreshGroupSelects(container); + } - // ── Plus: weiteres Control hinzufügen ───────────────────────────────────── + refreshGroupSelects(container) { + const selects = [...container.querySelectorAll('.filter-select')]; + if (selects.length < 2) return; - addControl(btn) { - const targetId = btn.dataset.target; - const filterKey = btn.dataset.filterKey; - const container = document.getElementById(targetId); - if (!container) return; + const selectedValues = new Set( + selects.map(s => s.value).filter(v => v !== '') + ); - // Erste Gruppe als Template klonen - const template = container.querySelector('.filter-row__control-group'); - if (!template) return; + selects.forEach(sel => { + const ownValue = sel.value; + sel.querySelectorAll('option').forEach(opt => { + if (!opt.value) return; + opt.hidden = selectedValues.has(opt.value) && opt.value !== ownValue; + }); + }); + } - const clone = template.cloneNode(true); + applyFilters() { + const params = new URLSearchParams(); + params.set('limit', String(window.Report?.limit ?? 50)); - // Select zurücksetzen - const clonedSelect = clone.querySelector('.filter-select'); - if (clonedSelect) clonedSelect.value = ''; + this.panel.querySelectorAll('.filter-row').forEach(row => { + const cb = row.querySelector('.filter-row__checkbox'); + if (!cb?.checked) return; - // Remove-Button hinzufügen (falls noch keiner da) - if (!clone.querySelector('.filter-row__remove')) { - const removeBtn = document.createElement('button'); - removeBtn.type = 'button'; - removeBtn.className = 'filter-row__remove'; - removeBtn.textContent = '×'; - clone.appendChild(removeBtn); - } + const key = row.dataset.filterKey; - // Neu: Klick auf den geklonten Select aktiviert ebenfalls die Row - clone.querySelector('.filter-select')?.addEventListener('mousedown', () => { - this.activateRowByControl(clone.querySelector('.filter-select')); + if (['clients', 'projects', 'services', 'users'].includes(key)) { + row.querySelectorAll('.filter-select').forEach(sel => { + if (sel.value) params.append(`filter[${key}][]`, sel.value); }); - - container.appendChild(clone); - - // Optionen deduplizieren - this.refreshGroupSelects(container); - - // Row aktivieren - const row = btn.closest('.filter-row'); - const cb = row?.querySelector('.filter-row__checkbox'); - if (cb && !cb.checked) { - cb.checked = true; - this.syncRowState(row, true); + if (row.querySelector('.filter-neg-checkbox')?.checked) { + params.set(`filter[${key}_neg]`, '1'); } - clonedSelect?.focus(); - } - - // ── Minus: Control entfernen ────────────────────────────────────────────── - - removeControl(removeBtn) { - const group = removeBtn.closest('.filter-row__control-group'); - const container = group?.parentElement; - group?.remove(); - - // Wenn keine Controls mehr übrig → Checkbox deaktivieren - if (container && !container.querySelector('.filter-row__control-group')) { - const row = container.closest('.filter-row'); - const cb = row?.querySelector('.filter-row__checkbox'); - if (cb) { - cb.checked = false; - this.syncRowState(row, false); - } + } else if (key === 'period') { + const val = this.periodSel?.value; + if (!val) return; + params.set('filter[period]', val); + + if (val === 'custom' && this.customDates) { + const get = field => this.customDates.querySelector(`[data-date-field="${field}"]`)?.value ?? ''; + const fromDay = get('from-day').padStart(2, '0'); + const fromMonth = get('from-month').padStart(2, '0'); + const fromYear = get('from-year'); + const toDay = get('to-day').padStart(2, '0'); + const toMonth = get('to-month').padStart(2, '0'); + const toYear = get('to-year'); + + if (fromYear && fromMonth && fromDay) { + params.set('filter[date_from]', `${fromYear}-${fromMonth}-${fromDay}`); + } + if (toYear && toMonth && toDay) { + params.set('filter[date_to]', `${toYear}-${toMonth}-${toDay}`); + } + } + if (row.querySelector('.filter-neg-checkbox')?.checked) { + params.set('filter[period_neg]', '1'); } - // Verbleibende Selects aktualisieren - if (container) this.refreshGroupSelects(container); - } - - // ── Optionen in Mehrfach-Selects deduplizieren ──────────────────────────── - - refreshGroupSelects(container) { - const selects = [...container.querySelectorAll('.filter-select')]; - if (selects.length < 2) return; + } else if (key === 'note') { + const val = row.querySelector('.filter-note-input')?.value?.trim(); + if (val) params.set('filter[note]', val); - // Alle gewählten Values sammeln - const selectedValues = new Set( - selects.map(s => s.value).filter(v => v !== '') - ); + } else if (key === 'invoiced') { + const checked = row.querySelector('.filter-invoiced-radio:checked'); + if (checked) params.set('filter[invoiced]', checked.value); + } + }); - selects.forEach(sel => { - const ownValue = sel.value; - sel.querySelectorAll('option').forEach(opt => { - if (!opt.value) return; // "..." immer sichtbar lassen - // Verstecken wenn woanders gewählt, aber nicht beim eigenen Select - opt.hidden = selectedValues.has(opt.value) && opt.value !== ownValue; - }); - }); - } + window.location.href = `/reports/times?${params}`; + } +} - // ── Filter anwenden → URL bauen und navigieren ──────────────────────────── - - applyFilters() { - const params = new URLSearchParams(); - params.set('limit', String(window.Report?.limit ?? 50)); - - this.panel.querySelectorAll('.filter-row').forEach(row => { - const cb = row.querySelector('.filter-row__checkbox'); - if (!cb?.checked) return; - - const key = row.dataset.filterKey; - - if (['clients', 'projects', 'services', 'users'].includes(key)) { - row.querySelectorAll('.filter-select').forEach(sel => { - if (sel.value) params.append(`filter[${key}][]`, sel.value); - }); - if (row.querySelector('.filter-neg-checkbox')?.checked) { - params.set(`filter[${key}_neg]`, '1'); - } - - } else if (key === 'period') { - const val = this.periodSel?.value; - if (!val) return; - params.set('filter[period]', val); - - if (val === 'custom' && this.customDates) { - const get = field => this.customDates.querySelector(`[data-date-field="${field}"]`)?.value ?? ''; - const fromDay = get('from-day').padStart(2, '0'); - const fromMonth = get('from-month').padStart(2, '0'); - const fromYear = get('from-year'); - const toDay = get('to-day').padStart(2, '0'); - const toMonth = get('to-month').padStart(2, '0'); - const toYear = get('to-year'); - - if (fromYear && fromMonth && fromDay) { - params.set('filter[date_from]', `${fromYear}-${fromMonth}-${fromDay}`); - } - if (toYear && toMonth && toDay) { - params.set('filter[date_to]', `${toYear}-${toMonth}-${toDay}`); - } - } - if (row.querySelector('.filter-neg-checkbox')?.checked) { - params.set('filter[period_neg]', '1'); - } - - } else if (key === 'note') { - const val = row.querySelector('.filter-note-input')?.value?.trim(); - if (val) params.set('filter[note]', val); - - } else if (key === 'invoiced') { - const checked = row.querySelector('.filter-invoiced-radio:checked'); - if (checked) params.set('filter[invoiced]', checked.value); - } - }); +// ── Export ──────────────────────────────────────────────────────────────────── - window.location.href = `/reports/times?${params}`; - } +function initExportButtons() { + ['excel', 'csv', 'pdf'].forEach(format => { + document.getElementById(`btn-export-${format}`)?.addEventListener('click', () => { + const params = new URLSearchParams(window.location.search); + params.delete('limit'); + window.location.href = `/reports/export/${format}?${params}`; + }); + }); } -// ── Init ────────────────────────────────────────────────────────────────────── - -document.addEventListener('DOMContentLoaded', () => { - new ReportFilter().init(); -}); +function initPrintButton() { + document.getElementById('btn-print')?.addEventListener('click', () => { + window.print(); + }); +} diff --git a/httpdocs/assets/scripts/team.js b/httpdocs/assets/scripts/team.js index 8664d5f..92ef1b1 100644 --- a/httpdocs/assets/scripts/team.js +++ b/httpdocs/assets/scripts/team.js @@ -1,223 +1,261 @@ -// team.js +// assets/scripts/team.js + +import { esc, createTranslator, ANIMATION_MS, removeWithAnimation } from './utils.js'; + +const t = createTranslator('Team'); + document.addEventListener('DOMContentLoaded', () => { - // ── Tabs ───────────────────────────────────────────────────────────────────── - document.querySelectorAll('.crud-tab').forEach(tab => { - tab.addEventListener('click', () => { - document.querySelectorAll('.crud-tab').forEach(t => - t.classList.toggle('crud-tab--active', t === tab) - ); - document.querySelectorAll('[data-tab-panel]').forEach(panel => { - panel.hidden = panel.dataset.tabPanel !== tab.dataset.tab; - }); - }); - }); + // ── Tabs ────────────────────────────────────────────────────────────────── - // ── Einlade-Modal ───────────────────────────────────────────────────────────── - const modal = document.getElementById('team-modal'); - const errorsBox = document.getElementById('team-modal-errors'); - - const openModal = () => { modal.hidden = false; }; - const closeModal = () => { - modal.hidden = true; - errorsBox.hidden = true; - ['inv-firstName', 'inv-lastName', 'inv-email'].forEach(id => { - document.getElementById(id).value = ''; - }); - const defaultRole = modal.querySelector('input[name="inv-role"][value="member"]'); - if (defaultRole) defaultRole.checked = true; - }; + document.querySelectorAll('.crud-tab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.crud-tab').forEach(t => + t.classList.toggle('crud-tab--active', t === tab) + ); + document.querySelectorAll('[data-tab-panel]').forEach(panel => { + panel.hidden = panel.dataset.tabPanel !== tab.dataset.tab; + }); + }); + }); - document.getElementById('team-invite-btn').addEventListener('click', openModal); - document.getElementById('team-modal-close').addEventListener('click', closeModal); - document.getElementById('team-modal-cancel').addEventListener('click', closeModal); - modal.addEventListener('click', e => { if (e.target === modal) closeModal(); }); - - document.getElementById('team-modal-submit').addEventListener('click', async () => { - const payload = { - firstName: document.getElementById('inv-firstName').value.trim(), - lastName: document.getElementById('inv-lastName').value.trim(), - email: document.getElementById('inv-email').value.trim(), - role: modal.querySelector('input[name="inv-role"]:checked')?.value ?? 'member', - }; - - const res = await fetch('/api/team/invite', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const data = await res.json(); + // ── Einlade-Modal ───────────────────────────────────────────────────────── - if (!res.ok) { - errorsBox.hidden = false; - errorsBox.innerHTML = '
    ' + (data.errors ?? [data.error]).map(e => `
  • ${e}
  • `).join('') + '
'; - 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 = '
    ' + errors.map(e => `
  • ${esc(e)}
  • `).join('') + '
'; + return; + } + + closeModal(); + window.location.reload(); + } catch { + errorsBox.hidden = false; + errorsBox.innerHTML = `
  • ${esc(t('errorSave'))}
`; + } 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')); } -}); \ No newline at end of file + } +}); diff --git a/httpdocs/assets/scripts/utils.js b/httpdocs/assets/scripts/utils.js new file mode 100644 index 0000000..b172f4d --- /dev/null +++ b/httpdocs/assets/scripts/utils.js @@ -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); + })); +} diff --git a/httpdocs/assets/styles/atoms/_inputs.scss b/httpdocs/assets/styles/atoms/_inputs.scss index 2cdfd58..8287e96 100644 --- a/httpdocs/assets/styles/atoms/_inputs.scss +++ b/httpdocs/assets/styles/atoms/_inputs.scss @@ -52,7 +52,7 @@ } } -// ─── Select Label Tag (wie "Dogument", "Verrechenbar") ─────────────────────── +// ─── Select Label Tag (z.B. "Verrechenbar") ───────────────────────────────── .select-hint { font-size: $font-size-xs; color: $color-text-muted; diff --git a/httpdocs/assets/styles/atoms/_mixins.scss b/httpdocs/assets/styles/atoms/_mixins.scss new file mode 100644 index 0000000..02e28f1 --- /dev/null +++ b/httpdocs/assets/styles/atoms/_mixins.scss @@ -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; +} + diff --git a/httpdocs/assets/styles/atoms/_variables.scss b/httpdocs/assets/styles/atoms/_variables.scss index d1ac98a..084a009 100644 --- a/httpdocs/assets/styles/atoms/_variables.scss +++ b/httpdocs/assets/styles/atoms/_variables.scss @@ -1,5 +1,4 @@ // ─── Color Palette ─────────────────────────────────────────────────────────── -// Compile-time values (used in rgba() functions; keep as hex) $color-primary: #4a90d9; $color-primary-dark: #3178b8; $color-primary-light: #6aaee8; @@ -12,20 +11,6 @@ $color-accent-light: #f5bc3a; $color-white: #ffffff; $color-bg: #dce9f5; - -// ─── CSS Custom Properties (runtime-overridable via brand color) ────────────── -:root { - --color-primary: #{$color-primary}; - --color-primary-dark: #{$color-primary-dark}; - --color-primary-light: #{$color-primary-light}; - --color-header-from: #{$color-header-from}; - --color-header-to: #{$color-header-to}; - --color-bg: #{$color-bg}; - --color-primary-rgb: 74, 144, 217; - --header-text: #{$color-white}; - --header-text-muted: rgba(255, 255, 255, 0.75); - --header-overlay: rgba(255, 255, 255, 0.18); -} $color-card: #f0f0f0; $color-card-white: #ffffff; @@ -40,21 +25,30 @@ $color-input-border: #b8c4d0; $color-day-active-bg: #1a2a3a; $color-day-active-text:#ffffff; -$color-day-hover: rgba(255,255,255,0.2); $color-error: #c83232; - $color-success: #2d9e60; $color-success-bg: #e6f5ee; - $color-activate: #3a9a3a; $color-activate-light: #4ab44a; - $color-warning: #b86200; $color-warning-light: #e8820a; - $color-overlay: rgba(0, 0, 0, 0.45); +// ─── CSS Custom Properties (runtime-overridable via brand color) ────────────── +:root { + --color-primary: #{$color-primary}; + --color-primary-dark: #{$color-primary-dark}; + --color-primary-light: #{$color-primary-light}; + --color-header-from: #{$color-header-from}; + --color-header-to: #{$color-header-to}; + --color-bg: #{$color-bg}; + --color-primary-rgb: 74, 144, 217; + --header-text: #{$color-white}; + --header-text-muted: rgba(255, 255, 255, 0.75); + --header-overlay: rgba(255, 255, 255, 0.18); +} + // ─── Typography ────────────────────────────────────────────────────────────── $font-family-base: 'DM Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; $font-size-xs: 0.7rem; @@ -89,12 +83,12 @@ $radius-xl: 24px; $radius-pill: 100px; // ─── Shadows ───────────────────────────────────────────────────────────────── -$shadow-card: 0 2px 12px rgba(0, 60, 120, 0.08); -$shadow-header: 0 2px 16px rgba(0, 50, 120, 0.2); -$shadow-calendar:0 8px 32px rgba(0, 60, 120, 0.35); -$shadow-input: 0 1px 3px rgba(0, 40, 80, 0.06) inset; -$shadow-focus: 0 0 0 3px rgba(#4a90d9, 0.15); -$shadow-button: 0 2px 8px rgba(240, 165, 0, 0.35); +$shadow-card: 0 2px 12px rgba(0, 60, 120, 0.08); +$shadow-header: 0 2px 16px rgba(0, 50, 120, 0.2); +$shadow-calendar: 0 8px 32px rgba(0, 60, 120, 0.35); +$shadow-input: 0 1px 3px rgba(0, 40, 80, 0.06) inset; +$shadow-focus: 0 0 0 3px rgba($color-primary, 0.15); +$shadow-button: 0 2px 8px rgba($color-accent, 0.35); // ─── Transitions ───────────────────────────────────────────────────────────── $transition-fast: 0.15s ease; @@ -102,5 +96,7 @@ $transition-base: 0.2s ease; $transition-slow: 0.3s ease; // ─── Layout ────────────────────────────────────────────────────────────────── -$header-height: 88px; +$header-height: 88px; $content-max-width: 860px; +$icon-btn-size: 28px; +$icon-svg-size: 14px; diff --git a/httpdocs/assets/styles/components/_account.scss b/httpdocs/assets/styles/components/_account.scss index 575c780..6429eb9 100644 --- a/httpdocs/assets/styles/components/_account.scss +++ b/httpdocs/assets/styles/components/_account.scss @@ -1,22 +1,15 @@ @use '../atoms/variables' as *; +@use '../atoms/mixins' as *; // ─── Page ───────────────────────────────────────────────────────────────────── .account-page { - min-height: 100vh; - background: var(--color-bg); - display: flex; - flex-direction: column; + @include page-shell; } // ─── Header ────────────────────────────────────────────────────────────────── .account-header { - background: linear-gradient(135deg, var(--color-header-from) 0%, var(--color-header-to) 100%); + @include section-header; padding: $space-6; - display: flex; - align-items: center; - justify-content: space-between; - gap: $space-6; - box-shadow: $shadow-header; } .account-header__title { @@ -74,9 +67,7 @@ // ─── Karte ─────────────────────────────────────────────────────────────────── .account-card { - background: $color-card-white; - border-radius: $radius-lg; - box-shadow: $shadow-card; + @include card; padding: $space-8; } @@ -89,9 +80,8 @@ } .account-form__label { - font-size: $font-size-sm; + @include form-label; font-weight: $font-weight-medium; - color: $color-text-muted; padding-top: 7px; } @@ -146,11 +136,7 @@ // ─── Passwort-Sektion (toggle) ──────────────────────────────────────────────── .account-form__pw-section { - display: contents; // bleibt im Grid-Fluss - - &[hidden] { - display: none !important; - } + display: contents; } // ─── Actions ───────────────────────────────────────────────────────────────── diff --git a/httpdocs/assets/styles/components/_crud.scss b/httpdocs/assets/styles/components/_crud.scss index 66e218b..881de00 100644 --- a/httpdocs/assets/styles/components/_crud.scss +++ b/httpdocs/assets/styles/components/_crud.scss @@ -1,4 +1,5 @@ @use '../atoms/variables' as *; +@use '../atoms/mixins' as *; // ─── CRUD Seiten Layout ──────────────────────────────────────────────────────── .crud-page { @@ -22,9 +23,7 @@ // ─── Liste ───────────────────────────────────────────────────────────────────── .crud-list { - background: $color-card-white; - border-radius: $radius-lg; - box-shadow: $shadow-card; + @include card; overflow: hidden; } @@ -86,20 +85,11 @@ } .crud-row__btn { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 50%; - background: transparent; - border: none; - cursor: pointer; + @include icon-btn; opacity: 0; - transition: opacity $transition-fast, background $transition-fast, color $transition-fast; color: $color-text-muted; - svg { width: 14px; height: 14px; pointer-events: none; } + svg { width: $icon-svg-size; height: $icon-svg-size; } &--edit:hover { background: rgba(var(--color-primary-rgb), 0.1); color: var(--color-primary); } &--delete:hover{ background: rgba($color-error, 0.1); color: $color-error; } @@ -114,15 +104,11 @@ border-top: 1px solid rgba($color-border, 0.5); } -.crud-row__display[hidden] { display: none !important; } - // ─── Create-Formular oben ────────────────────────────────────────────────────── .crud-create { - background: $color-card; - border-radius: $radius-lg; + @include card($color-card); padding: $space-5 $space-6; margin-bottom: $space-4; - box-shadow: $shadow-card; display: none; &--visible { display: block; } @@ -131,11 +117,10 @@ // ─── Tabs (Aktiv / Archiviert) ───────────────────────────────────────────────── .crud-tabs { display: inline-flex; - background: $color-card-white; + @include card; border-radius: $radius-pill; padding: 3px; margin-bottom: $space-4; - box-shadow: $shadow-card; } .crud-tab { diff --git a/httpdocs/assets/styles/components/_entry-form.scss b/httpdocs/assets/styles/components/_entry-form.scss index 04859b3..6e6e96c 100644 --- a/httpdocs/assets/styles/components/_entry-form.scss +++ b/httpdocs/assets/styles/components/_entry-form.scss @@ -1,11 +1,10 @@ @use '../atoms/variables' as *; +@use '../atoms/mixins' as *; // ─── Entry Form Card ───────────────────────────────────────────────────────── .entry-form { - background: $color-card; - border-radius: $radius-lg; + @include card($color-card); padding: $space-6 $space-8; - box-shadow: $shadow-card; } .entry-form__grid { @@ -30,6 +29,19 @@ gap: $space-2; } +.entry-form__field--rate { + gap: $space-2; +} + +.entry-form__unit { + color: $color-text-muted; + font-size: $font-size-sm; +} + +.input--rate { + width: 100px; +} + .entry-form__field--selects { display: flex; gap: $space-3; diff --git a/httpdocs/assets/styles/components/_entry-list.scss b/httpdocs/assets/styles/components/_entry-list.scss index 4e0067e..3a332ee 100644 --- a/httpdocs/assets/styles/components/_entry-list.scss +++ b/httpdocs/assets/styles/components/_entry-list.scss @@ -1,10 +1,9 @@ @use '../atoms/variables' as *; +@use '../atoms/mixins' as *; // ─── Entry List Container ────────────────────────────────────────────────── .entry-list { - background: $color-card-white; - border-radius: $radius-lg; - box-shadow: $shadow-card; + @include card; overflow: hidden; transition: opacity 0.18s ease; @@ -13,10 +12,8 @@ // ─── Empty State ────────────────────────────────────────────────────────── .empty-state { - background: $color-card-white; - border-radius: $radius-lg; + @include card; padding: $space-6 $space-8; - box-shadow: $shadow-card; } .empty-state__title { @@ -30,8 +27,7 @@ .entry-list__footer { display: flex; justify-content: flex-end; - // 2 Buttons (28px) + 2× gap (8px) + eigener padding = Badge bündig - padding: $space-3 calc(#{$space-8} + 28px + 28px + #{$space-2} + #{$space-2}); + padding: $space-3 calc(#{$space-8} + #{$icon-btn-size} + #{$icon-btn-size} + #{$space-2} + #{$space-2}); border-top: 1px solid $color-border; } @@ -51,13 +47,11 @@ &:last-child { border-bottom: none; } - // Fade-in bei neuem Eintrag &--new { opacity: 0; transform: translateY(-6px); } - // Fade-out beim Löschen &--removing { opacity: 0; transform: translateX(12px); @@ -79,13 +73,7 @@ &:hover { background: rgba(var(--color-primary-rgb), 0.05); - .entry-row__btn { - opacity: 1; - } - } - - &[hidden] { - display: none !important; + .entry-row__btn { opacity: 1; } } } @@ -98,18 +86,14 @@ font-size: $font-size-base; font-weight: $font-weight-bold; color: $color-text-dark; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include text-truncate; } .entry-row__note { font-size: $font-size-sm; color: $color-text-muted; margin-top: 2px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include text-truncate; } .entry-row__actions { @@ -132,25 +116,15 @@ } .entry-row__btn { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 50%; - background: transparent; - border: none; - cursor: pointer; + @include icon-btn; opacity: 0; - transition: opacity $transition-fast, background $transition-fast, color $transition-fast; color: $color-text-muted; - svg { width: 14px; height: 14px; pointer-events: none; } + svg { width: $icon-svg-size; height: $icon-svg-size; } &--edit:hover { background: rgba(var(--color-primary-rgb), 0.1); color: var(--color-primary); } &--delete:hover{ background: rgba($color-error, 0.1); color: $color-error; } - // immer sichtbar auf Touch-Geräten @media (hover: none) { opacity: 1; } } @@ -159,11 +133,11 @@ display: flex; align-items: center; justify-content: center; - width: calc(28px + #{$space-2} + 28px); + width: calc(#{$icon-btn-size} + #{$space-2} + #{$icon-btn-size}); flex-shrink: 0; color: $color-text-dark; - svg { width: 14px; height: 14px; pointer-events: none; } + svg { width: $icon-svg-size; height: $icon-svg-size; pointer-events: none; } } // ─── Abgerechneter Eintrag ──────────────────────────────────────────────── @@ -181,7 +155,6 @@ } .entry-form__grid--inline { - // Gleiche Grid-Struktur wie das Haupt-Formular display: grid; grid-template-columns: 130px 1fr; gap: $space-3 $space-6; diff --git a/httpdocs/assets/styles/components/_login.scss b/httpdocs/assets/styles/components/_login.scss index d7bd0ea..deaa333 100644 --- a/httpdocs/assets/styles/components/_login.scss +++ b/httpdocs/assets/styles/components/_login.scss @@ -1,4 +1,5 @@ @use '../atoms/variables' as *; +@use '../atoms/mixins' as *; // ─── Login Page ─────────────────────────────────────────────────────────────── .login-body { @@ -11,12 +12,10 @@ // ─── Card ───────────────────────────────────────────────────────────────────── .login-card { - background: $color-card-white; - border-radius: $radius-xl; + @include card($color-card-white, $radius-xl); padding: $space-10 $space-12; width: 100%; max-width: 540px; - box-shadow: $shadow-card; } .login-card__title { @@ -47,10 +46,8 @@ } .login-form__label { + @include form-label; font-size: $font-size-base; - color: $color-text-muted; - text-align: right; - padding-right: $space-2; } .login-form__field { @@ -76,7 +73,7 @@ } } -// ─── Footer-Link (z. B. "Zurück zur Anmeldung") ─────────────────────────────── +// ─── Footer-Link ────────────────────────────────────────────────────────────── .login-form__footer { text-align: center; margin-top: $space-6; @@ -141,4 +138,4 @@ .login-form__submit { padding: $space-3 $space-10; font-size: $font-size-md; -} \ No newline at end of file +} diff --git a/httpdocs/assets/styles/components/_month-calendar.scss b/httpdocs/assets/styles/components/_month-calendar.scss index 7f74ff6..a743bcf 100644 --- a/httpdocs/assets/styles/components/_month-calendar.scss +++ b/httpdocs/assets/styles/components/_month-calendar.scss @@ -1,4 +1,5 @@ @use '../atoms/variables' as *; +@use '../atoms/mixins' as *; // ─── Monatskalender Container ───────────────────────────────────────────────── .month-calendar { @@ -44,17 +45,8 @@ } .month-calendar__arrow { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 50%; - background: transparent; - border: none; + @include icon-btn; color: var(--header-text); - cursor: pointer; - transition: background $transition-fast; &:hover { background: var(--header-overlay); } @@ -62,7 +54,6 @@ } .month-calendar__close { - // erbt .week-nav__cal Styles – hier nur Positionierung margin-left: 0; } @@ -112,13 +103,11 @@ background: var(--header-overlay); } - // Tage aus Vor-/Nachmonat &--other { color: var(--header-text-muted); cursor: default; } - // Heutiger Tag &--today { font-weight: $font-weight-bold; background: $color-white; @@ -129,7 +118,6 @@ } } - // Ausgewählter Tag &--active:not(&--today) { background: var(--header-overlay); font-weight: $font-weight-bold; diff --git a/httpdocs/assets/styles/components/_register.scss b/httpdocs/assets/styles/components/_register.scss index 7f06ec2..f11cda9 100644 --- a/httpdocs/assets/styles/components/_register.scss +++ b/httpdocs/assets/styles/components/_register.scss @@ -1,4 +1,5 @@ @use '../atoms/variables' as *; +@use '../atoms/mixins' as *; .register-body { min-height: 100vh; @@ -16,10 +17,8 @@ } .register-card { - background: $color-card-white; - border-radius: $radius-xl; + @include card($color-card-white, $radius-xl); padding: $space-10 $space-12; - box-shadow: $shadow-card; } .register-card__brand { @@ -186,8 +185,12 @@ margin-bottom: $space-3; } +.register-success__btn { + margin-top: $space-6; +} + .register-success__hint { font-size: $font-size-sm; color: $color-text-muted; line-height: $line-height-base; -} \ No newline at end of file +} diff --git a/httpdocs/assets/styles/components/_team.scss b/httpdocs/assets/styles/components/_team.scss index 2564744..b39d90e 100644 --- a/httpdocs/assets/styles/components/_team.scss +++ b/httpdocs/assets/styles/components/_team.scss @@ -1,4 +1,5 @@ @use '../atoms/variables' as *; +@use '../atoms/mixins' as *; // ─── Ausstehend-Badge ────────────────────────────────────────────────────────── .team-badge { @@ -24,14 +25,10 @@ align-items: center; justify-content: center; z-index: 200; - - &[hidden] { display: none !important; } } .modal-card { - background: $color-card-white; - border-radius: $radius-lg; - box-shadow: $shadow-card; + @include card; width: 100%; max-width: 460px; padding: 0; @@ -53,18 +50,11 @@ } .modal-card__close { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - background: transparent; - border: none; - cursor: pointer; + @include icon-btn; color: $color-text-muted; - border-radius: 50%; - transition: background $transition-fast; + svg { width: 16px; height: 16px; } + &:hover { background: rgba($color-border, 0.5); } } @@ -111,8 +101,6 @@ color: $color-error; font-size: $font-size-sm; - &[hidden] { display: none !important; } - ul { margin: 0; padding-left: 1.2em; } } @@ -161,4 +149,4 @@ margin-top: $space-1; font-size: $font-size-xs; color: $color-text-muted; -} \ No newline at end of file +} diff --git a/httpdocs/assets/styles/components/_week-nav.scss b/httpdocs/assets/styles/components/_week-nav.scss index 8778b64..c02307b 100644 --- a/httpdocs/assets/styles/components/_week-nav.scss +++ b/httpdocs/assets/styles/components/_week-nav.scss @@ -1,4 +1,5 @@ @use '../atoms/variables' as *; +@use '../atoms/mixins' as *; // ─── Wrapper ───────────────────────────────────────────────────────────────── .week-nav { @@ -14,19 +15,9 @@ // ─── Pfeile ────────────────────────────────────────────────────────────────── .week-nav__arrow { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 50%; - background: transparent; - border: none; + @include icon-btn; color: var(--header-text); - cursor: pointer; text-decoration: none; - flex-shrink: 0; - transition: background $transition-fast; &:hover { background: var(--header-overlay); } @@ -101,21 +92,12 @@ // ─── Kalender-Icon ─────────────────────────────────────────────────────────── .week-nav__cal { - display: flex; - align-items: center; - justify-content: center; - width: 34px; - height: 34px; - border-radius: $radius-md; + @include icon-btn(34px, $radius-md); background: var(--header-overlay); color: var(--header-text); - cursor: pointer; - border: none; margin-left: $space-1; - flex-shrink: 0; - transition: background $transition-fast; - svg { width: 16px; height: 16px; pointer-events: none; } + svg { width: 16px; height: 16px; } &:hover, &--active { background: var(--header-overlay); } diff --git a/httpdocs/assets/styles/main.scss b/httpdocs/assets/styles/main.scss index b0f6e43..b1de747 100644 --- a/httpdocs/assets/styles/main.scss +++ b/httpdocs/assets/styles/main.scss @@ -1,5 +1,6 @@ // ─── Atoms ──────────────────────────────────────────────────────────────────── @use 'atoms/variables' as *; +@use 'atoms/mixins' as *; @use 'atoms/typography'; @use 'atoms/buttons'; @use 'atoms/inputs'; @@ -27,6 +28,8 @@ @use 'themes/minimal'; // ─── Reset / Base ───────────────────────────────────────────────────────────── +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap'); + *, *::before, *::after { @@ -43,4 +46,6 @@ body { background: var(--color-bg); } -@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap'); +[hidden] { + display: none !important; +} diff --git a/httpdocs/assets/styles/sections/_home.scss b/httpdocs/assets/styles/sections/_home.scss index fdbf07d..413449b 100644 --- a/httpdocs/assets/styles/sections/_home.scss +++ b/httpdocs/assets/styles/sections/_home.scss @@ -1,10 +1,8 @@ @use '../atoms/variables' as *; +@use '../atoms/mixins' as *; .home-body { - min-height: 100vh; - background: var(--color-bg); - display: flex; - flex-direction: column; + @include page-shell; } // ─── Header ────────────────────────────────────────────────────────────────── @@ -64,4 +62,4 @@ .home-hero__cta { font-size: $font-size-md; padding: $space-4 $space-10; -} \ No newline at end of file +} diff --git a/httpdocs/assets/styles/sections/_report.scss b/httpdocs/assets/styles/sections/_report.scss index 4f2b3f9..d5e83a2 100644 --- a/httpdocs/assets/styles/sections/_report.scss +++ b/httpdocs/assets/styles/sections/_report.scss @@ -1,22 +1,15 @@ @use '../atoms/variables' as *; +@use '../atoms/mixins' as *; // ─── Page ───────────────────────────────────────────────────────────────────── .report-page { - min-height: 100vh; - background: var(--color-bg); - display: flex; - flex-direction: column; + @include page-shell; } // ─── Header ────────────────────────────────────────────────────────────────── .report-header { - background: linear-gradient(135deg, var(--color-header-from) 0%, var(--color-header-to) 100%); + @include section-header; padding: $space-4 $space-6; - display: flex; - align-items: center; - justify-content: space-between; - gap: $space-6; - box-shadow: $shadow-header; } .report-header__title { @@ -72,9 +65,7 @@ // ─── Karte ─────────────────────────────────────────────────────────────────── .report-card { - background: $color-card-white; - border-radius: $radius-lg; - box-shadow: $shadow-card; + @include card; overflow: hidden; } @@ -93,6 +84,31 @@ gap: $space-6; } +.report-toolbar__right { + display: flex; + align-items: center; + gap: $space-2; +} + +.report-toolbar__export { + @include icon-btn(30px, $radius-sm); + color: $color-text-light; + + svg { width: 18px; height: 18px; } + + &:hover { + color: var(--color-primary); + background: rgba(var(--color-primary-rgb), 0.08); + } +} + +.report-toolbar__separator { + width: 1px; + height: 18px; + background: $color-border; + margin: 0 $space-1; +} + .report-toolbar__action { display: inline-flex; align-items: center; @@ -104,8 +120,8 @@ text-decoration: none; svg { - width: 14px; - height: 14px; + width: $icon-svg-size; + height: $icon-svg-size; flex-shrink: 0; } @@ -133,7 +149,7 @@ 1fr // Bemerkung 80px // Stunden 100px // Umsatz - 88px; // Aktionen (Edit + Delete + Schloss) + 88px; // Aktionen align-items: center; border-bottom: 1px solid $color-border; padding: 0 $space-5; @@ -142,7 +158,6 @@ .report-table__head { padding-top: $space-2; padding-bottom: $space-2; - background: transparent; .report-table__cell { font-size: $font-size-xs; @@ -161,34 +176,24 @@ &:hover { background: rgba(var(--color-primary-rgb), 0.05); - } - &:hover .report-action-btn { - opacity: 1; + .report-action-btn { opacity: 1; } } - &--invoiced { - .report-table__cell--date { color: $color-text-light; } - .report-table__cell--client { color: $color-text-light; } - .report-table__cell--project { color: $color-text-light; } - .report-table__cell--service { color: $color-text-light; } - .report-table__cell--user { color: $color-text-light; } - .report-table__cell--note { color: $color-text-light; } - .report-table__cell--duration { color: $color-text-light; } - .report-table__cell--revenue { color: $color-text-light; } + &--invoiced .report-table__cell { + &--date, &--client, &--project, &--service, + &--user, &--note, &--duration, &--revenue { + color: $color-text-light; + } } &--editing { background: rgba(var(--color-primary-rgb), 0.05); - .report-table__cell--actions { - visibility: hidden; - } + .report-table__cell--actions { visibility: hidden; } } - &:last-child { - border-bottom: none; - } + &:last-child { border-bottom: none; } } .report-table__cell { @@ -217,9 +222,7 @@ &--note { color: $color-text-muted; font-size: $font-size-sm; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include text-truncate; } } @@ -245,23 +248,11 @@ // ─── Aktions-Buttons (Edit / Delete) ───────────────────────────────────────── .report-action-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - border: none; - background: none; - cursor: pointer; - color: $color-text-light; - border-radius: $radius-sm; + @include icon-btn(26px, $radius-sm); opacity: 0; - transition: opacity $transition-fast, color $transition-fast, background $transition-fast; + color: $color-text-light; - svg { - width: 14px; - height: 14px; - } + svg { width: $icon-svg-size; height: $icon-svg-size; } &:hover { color: $color-text-muted; @@ -276,22 +267,10 @@ // ─── Schloss-Button ────────────────────────────────────────────────────────── .report-lock { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border: none; - background: none; - cursor: pointer; + @include icon-btn(24px, $radius-sm); color: $color-text-light; - border-radius: $radius-sm; - transition: color $transition-fast, background $transition-fast; - svg { - width: 14px; - height: 14px; - } + svg { width: $icon-svg-size; height: $icon-svg-size; } &:hover { color: $color-text-muted; @@ -325,11 +304,7 @@ } .report-row__edit-label { - font-size: $font-size-sm; - color: $color-text-muted; - text-align: right; - padding-right: $space-2; - white-space: nowrap; + @include form-label; } .report-row__edit-field { @@ -379,9 +354,7 @@ text-decoration: underline; cursor: pointer; - &:hover { - color: var(--color-primary-dark); - } + &:hover { color: var(--color-primary-dark); } } strong { @@ -401,7 +374,7 @@ } .report-pagination__lock-spacer { - // Platzhalter für die Aktions-Spalte – hält die Ausrichtung + // Platzhalter – hält Spalten-Ausrichtung mit der Tabelle } // ─── Toolbar-Button (klickbar) ──────────────────────────────────────────────── @@ -462,29 +435,28 @@ button.report-toolbar__action { padding: $space-2 0; border-bottom: 1px solid rgba($color-border, 0.6); - &:last-child { - border-bottom: none; - } + &:last-child { border-bottom: none; } - // Ausgegraut wenn inaktiv – aber klickbar! &--inactive { .filter-select, .filter-note-input, - .filter-period-select { + .filter-period-select, + .filter-radio { opacity: 0.5; - color: $color-text-muted; } - .filter-row__add { - opacity: 0.4; + .filter-select, + .filter-note-input, + .filter-period-select { + color: $color-text-muted; } - .filter-radio { - opacity: 0.5; + .filter-row__add, + .filter-neg { + opacity: 0.4; } .filter-neg { - opacity: 0.4; pointer-events: none; } } @@ -497,7 +469,7 @@ button.report-toolbar__action { cursor: pointer; font-size: $font-size-sm; color: $color-text-base; - padding-top: 7px; // optisch mit den Selects ausrichten + padding-top: 7px; user-select: none; } @@ -552,31 +524,23 @@ button.report-toolbar__action { display: flex; align-items: center; gap: $space-3; - padding-top: 7px; // vertikal mit Select ausrichten + padding-top: 7px; flex-shrink: 0; white-space: nowrap; &--no-add { - padding-left: calc(22px + #{$space-3}); // Platz für fehlenden Add-Button + padding-left: calc(22px + #{$space-3}); } } // ─── Plus- und Minus-Button ─────────────────────────────────────────────────── .filter-row__add { - width: 22px; - height: 22px; + @include icon-btn(22px, $radius-sm); border: 1px solid $color-input-border; background: $color-white; - border-radius: $radius-sm; - cursor: pointer; font-size: $font-size-md; line-height: 1; color: $color-text-muted; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - transition: border-color $transition-fast, color $transition-fast; &:hover { border-color: var(--color-primary); @@ -585,20 +549,10 @@ button.report-toolbar__action { } .filter-row__remove { - width: 20px; - height: 20px; - border: none; - background: none; - cursor: pointer; + @include icon-btn(20px, $radius-sm); font-size: $font-size-md; line-height: 1; color: $color-text-light; - display: flex; - align-items: center; - justify-content: center; - border-radius: $radius-sm; - flex-shrink: 0; - transition: color $transition-fast, background $transition-fast; &:hover { color: $color-error; @@ -612,11 +566,6 @@ button.report-toolbar__action { flex-direction: column; gap: $space-2; margin-top: $space-2; - - // [hidden] wird durch display:flex überschrieben – explizit gegensteuern - &[hidden] { - display: none; - } } .filter-date-group { @@ -679,9 +628,7 @@ button.report-toolbar__action { text-underline-offset: 2px; transition: color $transition-fast; - &:hover { - color: $color-text-base; - } + &:hover { color: $color-text-base; } } // ─── Negativfilter-Checkbox ─────────────────────────────────────────────────── diff --git a/httpdocs/assets/styles/sections/_timetracking.scss b/httpdocs/assets/styles/sections/_timetracking.scss index 1d6ac23..e6e99a8 100644 --- a/httpdocs/assets/styles/sections/_timetracking.scss +++ b/httpdocs/assets/styles/sections/_timetracking.scss @@ -1,26 +1,19 @@ @use '../atoms/variables' as *; +@use '../atoms/mixins' as *; // ─── Page Wrapper ───────────────────────────────────────────────────────────── .tt-page { - min-height: 100vh; - background: var(--color-bg); - display: flex; - flex-direction: column; + @include page-shell; } // ─── Header Section ────────────────────────────────────────────────────────── .tt-header { - background: linear-gradient(135deg, var(--color-header-from) 0%, var(--color-header-to) 100%); + @include section-header; padding: $space-4 $space-6; - display: flex; - align-items: center; - justify-content: space-between; - gap: $space-6; min-height: $header-height; position: sticky; top: 0; z-index: 100; - box-shadow: $shadow-header; } .tt-header__meta { @@ -47,7 +40,7 @@ max-width: $content-max-width; width: 100%; margin: 0 auto; - padding: $space-6 $space-6; + padding: $space-6; display: flex; flex-direction: column; gap: $space-4; diff --git a/httpdocs/assets/styles/themes/_minimal.scss b/httpdocs/assets/styles/themes/_minimal.scss index 916cd39..782251a 100644 --- a/httpdocs/assets/styles/themes/_minimal.scss +++ b/httpdocs/assets/styles/themes/_minimal.scss @@ -1,11 +1,12 @@ @use '../atoms/variables' as *; +@use '../atoms/mixins' as *; // ─── Minimal Theme ───────────────────────────────────────────────────────────── // Gilt nur wenn body[data-theme="minimal"] gesetzt ist. // Standard-Theme bleibt vollständig unverändert. body[data-theme="minimal"] { - background: #fff; + background: $color-white; // ── Normale Top-Nav ausblenden ────────────────────────────────────────────── .main-nav { display: none; } @@ -18,11 +19,11 @@ body[data-theme="minimal"] { } // ── Page-Background weiß ─────────────────────────────────────────────────── - .tt-page { background: #fff; } + .tt-page { background: $color-white; } // ── TT-Header: kein Gradient, kein Schatten, cleaner Rahmen ─────────────── .tt-header { - background: #fff; + background: $color-white; box-shadow: none; border-bottom: 1px solid $color-border; padding: $space-3 $space-5; @@ -85,7 +86,7 @@ body[data-theme="minimal"] { // ── Entry Form: cleaner, größere Inputs ──────────────────────────────────── .entry-form { - background: #fff; + background: $color-white; border: none; border-radius: 0; padding: $space-4 0; @@ -153,7 +154,7 @@ body[data-theme="minimal"] { } .entry-list { - background: #fff; + background: $color-white; box-shadow: none; border: 1px solid $color-border; border-radius: $radius-md; @@ -164,7 +165,7 @@ body[data-theme="minimal"] { .crud-page, .account-page, .team-page { - background: #fff; + background: $color-white; } } @@ -180,7 +181,7 @@ body[data-theme="minimal"] { height: 52px; border: none; border-radius: $radius-lg; - background: #fff; + background: $color-white; cursor: pointer; display: flex; align-items: center; @@ -230,7 +231,7 @@ body[data-theme="minimal"] { top: calc(100% + #{$space-2}); right: 0; min-width: 200px; - background: #fff; + background: $color-white; border: 1px solid $color-border; border-radius: $radius-md; box-shadow: 0 8px 24px rgba(0,0,0,0.12); @@ -303,7 +304,7 @@ body[data-theme="minimal"] { align-items: center; gap: $space-2; padding: $space-3 $space-4; - background: #fff; + background: $color-white; border: 1px solid $color-border; border-radius: $radius-md; font-size: $font-size-base; diff --git a/httpdocs/composer.json b/httpdocs/composer.json index 35c97cb..cf89f09 100644 --- a/httpdocs/composer.json +++ b/httpdocs/composer.json @@ -12,6 +12,8 @@ "doctrine/doctrine-bundle": "^3.2.2", "doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/orm": "^3.6.6", + "dompdf/dompdf": "^3.1", + "phpoffice/phpspreadsheet": "^5.8", "symfony/console": "7.4.*", "symfony/dotenv": "7.4.*", "symfony/flex": "^2.10", diff --git a/httpdocs/composer.lock b/httpdocs/composer.lock index e32a8ff..1b387d4 100644 --- a/httpdocs/composer.lock +++ b/httpdocs/composer.lock @@ -4,8 +4,84 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dae707f4e483331f467dcf211922216c", + "content-hash": "6a52005068f345beb15a732e99cbb73a", "packages": [ + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, { "name": "doctrine/collections", "version": "2.6.0", @@ -1120,6 +1196,161 @@ }, "time": "2026-02-08T16:21:46+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.5", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + }, + "time": "2026-03-03T13:54:37+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, { "name": "egulias/email-validator", "version": "4.0.4", @@ -1187,6 +1418,258 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "maennchen/zipstream-php", + "version": "3.2.2", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.86", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2026-04-11T18:38:28+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -1290,6 +1773,115 @@ ], "time": "2026-01-02T08:56:05+00:00" }, + { + "name": "phpoffice/phpspreadsheet", + "version": "5.8.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "01964d92536edf1a3a874b9580a52824bebf6fbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/01964d92536edf1a3a874b9580a52824bebf6fbb", + "reference": "01964d92536edf1a3a874b9580a52824bebf6fbb", + "shasum": "" + }, + "require": { + "composer/pcre": "^1||^2||^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-filter": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": "^8.1", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "dompdf/dompdf": "^2.0 || ^3.0", + "ext-intl": "*", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.5", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1 || ^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", + "phpstan/phpstan-phpunit": "^1.0 || ^2.0", + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + }, + { + "name": "Owen Leibman" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.8.0" + }, + "time": "2026-06-07T03:51:10+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -1540,6 +2132,137 @@ }, "time": "2024-09-11T13:17:53+00:00" }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.3.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.32 || 2.1.32", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7", + "phpunit/phpunit": "8.5.52", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.2.8", + "rector/type-perfect": "1.0.0 || 2.1.0", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.4.x-dev" + } + }, + "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0" + }, + "time": "2026-03-03T17:31:43+00:00" + }, { "name": "symfony/asset", "version": "v7.4.8", @@ -6314,6 +7037,149 @@ ], "time": "2026-05-20T07:20:23+00:00" }, + { + "name": "thecodingmachine/safe", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2026-02-04T18:08:13+00:00" + }, { "name": "twig/twig", "version": "v3.26.0", diff --git a/httpdocs/config/packages/doctrine.yaml b/httpdocs/config/packages/doctrine.yaml index 3b37d14..1e63fa9 100644 --- a/httpdocs/config/packages/doctrine.yaml +++ b/httpdocs/config/packages/doctrine.yaml @@ -10,8 +10,7 @@ doctrine: # ersetzt dbname zur Laufzeit mit 'db_{slug}' url: '%env(resolve:DATABASE_URL)%' profiling_collect_backtrace: '%kernel.debug%' - # middlewares: - # - App\Doctrine\TenantConnectionMiddleware + # Middleware wird via Service-Tag registriert (services.yaml) orm: default_entity_manager: central diff --git a/httpdocs/config/services.yaml b/httpdocs/config/services.yaml index ff2569b..0a8b8ff 100644 --- a/httpdocs/config/services.yaml +++ b/httpdocs/config/services.yaml @@ -65,5 +65,9 @@ services: arguments: $appDomain: '%app.domain%' + App\Doctrine\TenantConnectionMiddleware: + tags: + - { name: doctrine.middleware, connection: tenant } + App\Controller\InviteController: arguments: ~ diff --git a/httpdocs/src/Controller/AccountController.php b/httpdocs/src/Controller/AccountController.php index 29ab3c3..7bfdb83 100644 --- a/httpdocs/src/Controller/AccountController.php +++ b/httpdocs/src/Controller/AccountController.php @@ -14,6 +14,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Contracts\Translation\TranslatorInterface; class AccountController extends AbstractController { @@ -24,6 +25,7 @@ class AccountController extends AbstractController private readonly UserRepository $userRepo, private readonly UserPasswordHasherInterface $passwordHasher, private readonly BrandColorService $brandColorService, + private readonly TranslatorInterface $translator, ) {} #[Route('/account', name: 'account_index')] @@ -56,10 +58,10 @@ class AccountController extends AbstractController 'adminUsers' => $adminUsers, 'superAdminUserId' => $account->getSuperAdminUser()?->getId(), 'intervalOptions' => [ - 1 => 'Minuten', - 15 => 'Viertelstunde', - 30 => 'Halbe Stunde', - 60 => 'Stunde', + 1 => $this->translator->trans('app.account.interval_minutes'), + 15 => $this->translator->trans('app.account.interval_quarter'), + 30 => $this->translator->trans('app.account.interval_half'), + 60 => $this->translator->trans('app.account.interval_hour'), ], ]); } @@ -72,7 +74,7 @@ class AccountController extends AbstractController $accountUser = $this->accountUserRepo->findOneBy(['account' => $account, 'user' => $user]); if (!$accountUser?->isAdmin()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $data = json_decode($request->getContent(), true) ?? []; @@ -91,7 +93,7 @@ class AccountController extends AbstractController if (array_key_exists('primaryColor', $data)) { $hex = $data['primaryColor'] === '' ? null : trim($data['primaryColor']); if ($hex !== null && !$this->brandColorService->isValid($hex)) { - return $this->json(['error' => 'Ungültiger Hex-Farbwert.'], 422); + return $this->json(['error' => $this->translator->trans('app.error.invalid_hex')], 422); } $account->setPrimaryColor($hex); } @@ -110,29 +112,28 @@ class AccountController extends AbstractController $accountUser = $this->accountUserRepo->findOneBy(['account' => $account, 'user' => $currentUser]); if (!$accountUser?->isAdmin()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } - // Nur der aktuelle Superadmin darf den Besitzer übertragen if ($account->getSuperAdminUser()?->getId() !== $currentUser->getId()) { - return $this->json(['error' => 'Nur der aktuelle Kontoinhaber kann diese Funktion nutzen.'], 403); + return $this->json(['error' => $this->translator->trans('app.account.superadmin_only')], 403); } $data = json_decode($request->getContent(), true) ?? []; $userId = (int) ($data['userId'] ?? 0); if ($userId === $currentUser->getId()) { - return $this->json(['error' => 'Du bist bereits Kontoinhaber.'], 400); + return $this->json(['error' => $this->translator->trans('app.account.already_owner')], 400); } $newOwner = $this->userRepo->find($userId); if ($newOwner === null) { - return $this->json(['error' => 'Benutzer nicht gefunden.'], 404); + return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); } $newAccountUser = $this->accountUserRepo->findOneBy(['account' => $account, 'user' => $newOwner]); if ($newAccountUser === null || !$newAccountUser->isAdmin() || $newAccountUser->isArchived()) { - return $this->json(['error' => 'Der Benutzer muss aktiver Administrator sein.'], 400); + return $this->json(['error' => $this->translator->trans('app.account.new_owner_must_be_admin')], 400); } $account->setSuperAdminUser($newOwner); @@ -160,7 +161,7 @@ class AccountController extends AbstractController if ($newEmail !== $user->getEmail()) { $existing = $this->userRepo->findOneBy(['email' => $newEmail]); if ($existing !== null && $existing->getId() !== $user->getId()) { - return $this->json(['error' => 'Diese E-Mail-Adresse wird bereits verwendet.'], 409); + return $this->json(['error' => $this->translator->trans('app.error.email_taken')], 409); } $user->setEmail($newEmail); } @@ -172,13 +173,13 @@ class AccountController extends AbstractController if (!empty($data['newPassword'])) { if (empty($data['currentPassword'])) { - return $this->json(['error' => 'Aktuelles Passwort ist erforderlich.'], 400); + return $this->json(['error' => $this->translator->trans('app.validation.password_current_required')], 400); } if (!$this->passwordHasher->isPasswordValid($user, $data['currentPassword'])) { - return $this->json(['error' => 'Das aktuelle Passwort ist falsch.'], 400); + return $this->json(['error' => $this->translator->trans('app.validation.password_current_wrong')], 400); } if (strlen($data['newPassword']) < 8) { - return $this->json(['error' => 'Das neue Passwort muss mindestens 8 Zeichen haben.'], 400); + return $this->json(['error' => $this->translator->trans('app.validation.password_new_min_length')], 400); } $user->setPassword($this->passwordHasher->hashPassword($user, $data['newPassword'])); } diff --git a/httpdocs/src/Controller/ClientController.php b/httpdocs/src/Controller/ClientController.php index 71bce9e..60cb78d 100644 --- a/httpdocs/src/Controller/ClientController.php +++ b/httpdocs/src/Controller/ClientController.php @@ -12,14 +12,16 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Contracts\Translation\TranslatorInterface; class ClientController extends AbstractController { public function __construct( - private EntityManagerInterface $em, - private ClientRepository $clientRepo, - private TimeEntryRepository $timeEntryRepo, - private readonly AccountRoleHelper $roleHelper, + private readonly EntityManagerInterface $em, + private readonly ClientRepository $clientRepo, + private readonly TimeEntryRepository $timeEntryRepo, + private readonly AccountRoleHelper $roleHelper, + private readonly TranslatorInterface $translator, ) {} #[Route('/clients', name: 'client_index')] @@ -37,12 +39,12 @@ class ClientController extends AbstractController public function create(Request $request): JsonResponse { if ($this->roleHelper->isTracker()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $data = json_decode($request->getContent(), true); if (empty($data['name'])) { - return $this->json(['error' => 'Name ist erforderlich'], 400); + return $this->json(['error' => $this->translator->trans('app.error.name_required')], 400); } $client = new Client(); @@ -60,15 +62,15 @@ class ClientController extends AbstractController public function update(int $id, Request $request): JsonResponse { if ($this->roleHelper->isTracker()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $client = $this->clientRepo->find($id); - if (!$client) return $this->json(['error' => 'Nicht gefunden'], 404); + if (!$client) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); $data = json_decode($request->getContent(), true); if (empty($data['name'])) { - return $this->json(['error' => 'Name ist erforderlich'], 400); + return $this->json(['error' => $this->translator->trans('app.error.name_required')], 400); } $client->setName(trim($data['name'])); @@ -84,10 +86,10 @@ class ClientController extends AbstractController public function delete(int $id): JsonResponse { if ($this->roleHelper->isTracker()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $client = $this->clientRepo->find($id); - if (!$client) return $this->json(['error' => 'Nicht gefunden'], 404); + if (!$client) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); if ($this->timeEntryRepo->countByClient($client) > 0) { return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409); @@ -103,10 +105,10 @@ class ClientController extends AbstractController public function archive(int $id): JsonResponse { if ($this->roleHelper->isTracker()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $client = $this->clientRepo->find($id); - if (!$client) return $this->json(['error' => 'Nicht gefunden'], 404); + if (!$client) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); $client->setArchivedAt(new \DateTimeImmutable()); $this->em->flush(); @@ -118,10 +120,10 @@ class ClientController extends AbstractController public function unarchive(int $id): JsonResponse { if ($this->roleHelper->isTracker()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $client = $this->clientRepo->find($id); - if (!$client) return $this->json(['error' => 'Nicht gefunden'], 404); + if (!$client) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); $client->setArchivedAt(null); $this->em->flush(); diff --git a/httpdocs/src/Controller/InviteController.php b/httpdocs/src/Controller/InviteController.php index f794528..6924aac 100644 --- a/httpdocs/src/Controller/InviteController.php +++ b/httpdocs/src/Controller/InviteController.php @@ -26,41 +26,46 @@ class InviteController extends AbstractController private readonly Security $security, ) {} + private function renderInviteError(string $errorKey): Response + { + return $this->render('invite/error.html.twig', ['error' => $errorKey]); + } + #[Route('/invite/{token}', name: 'app_invite')] public function setPassword(string $token, Request $request): Response { $invite = $this->inviteTokenRepo->findOneBy(['token' => $token]); if ($invite === null) { - return $this->render('invite/error.html.twig', [ - 'error' => 'Dieser Einladungslink ist ungültig.', - ]); + return $this->renderInviteError('link_invalid'); } if ($invite->isExpired()) { - return $this->render('invite/error.html.twig', [ - 'error' => 'Dieser Einladungslink ist abgelaufen (gültig 7 Tage).', - ]); + return $this->renderInviteError('link_expired'); } - // Account-Kontext prüfen (Sicherheit: Link muss auf richtigem Subdomain geöffnet werden) $account = $this->tenantContext->getAccount(); if ($account === null || $account->getId() !== $invite->getAccount()?->getId()) { - return $this->render('invite/error.html.twig', [ - 'error' => 'Dieser Einladungslink gehört zu einem anderen Account.', - ]); + return $this->renderInviteError('link_wrong_account'); } $error = null; if ($request->isMethod('POST')) { + if (!$this->isCsrfTokenValid('invite_password', $request->request->get('_csrf_token'))) { + return $this->render('invite/set_password.html.twig', [ + 'invite' => $invite, + 'error' => 'csrf', + ]); + } + $password = $request->request->get('password', ''); $passwordRepeat = $request->request->get('passwordRepeat', ''); if (strlen($password) < 8) { - $error = 'Das Passwort muss mindestens 8 Zeichen haben.'; + $error = 'too_short'; } elseif ($password !== $passwordRepeat) { - $error = 'Die Passwörter stimmen nicht überein.'; + $error = 'mismatch'; } else { // User anlegen (oder existierenden finden, falls E-Mail schon vorhanden) $user = $this->userRepo->findOneBy(['email' => $invite->getEmail()]); diff --git a/httpdocs/src/Controller/PasswordResetController.php b/httpdocs/src/Controller/PasswordResetController.php index a997ea8..75b96d2 100644 --- a/httpdocs/src/Controller/PasswordResetController.php +++ b/httpdocs/src/Controller/PasswordResetController.php @@ -18,6 +18,7 @@ use Symfony\Component\Mime\Address; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Contracts\Translation\TranslatorInterface; class PasswordResetController extends AbstractController { @@ -30,6 +31,7 @@ class PasswordResetController extends AbstractController private readonly UserPasswordHasherInterface $passwordHasher, private readonly Security $security, private readonly UrlGeneratorInterface $urlGenerator, + private readonly TranslatorInterface $translator, ) {} #[Route('/forgot-password', name: 'app_forgot_password', methods: ['GET', 'POST'])] @@ -45,6 +47,15 @@ class PasswordResetController extends AbstractController $error = null; if ($request->isMethod('POST')) { + if (!$this->isCsrfTokenValid('forgot_password', $request->request->get('_csrf_token'))) { + $error = 'invalid_csrf'; + return $this->render('security/forgot_password.html.twig', [ + 'accountName' => $account->getName(), + 'sent' => false, + 'error' => $error, + ]); + } + $email = trim($request->request->get('email', '')); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { @@ -115,6 +126,16 @@ class PasswordResetController extends AbstractController $error = null; if ($request->isMethod('POST')) { + if (!$this->isCsrfTokenValid('reset_password', $request->request->get('_csrf_token'))) { + $error = 'invalid_csrf'; + return $this->render('security/reset_password.html.twig', [ + 'accountName' => $resetToken->getAccount()->getName(), + 'invalid' => false, + 'expired' => false, + 'error' => $error, + ]); + } + $password = $request->request->get('password', ''); $passwordRepeat = $request->request->get('passwordRepeat', ''); @@ -163,7 +184,7 @@ class PasswordResetController extends AbstractController $email = (new TemplatedEmail()) ->to(new Address($user->getEmail(), $user->getFullName())) - ->subject('Passwort zurücksetzen – ' . $token->getAccount()->getName()) + ->subject($this->translator->trans('app.email.password_reset.subject', ['%account%' => $token->getAccount()->getName()])) ->htmlTemplate('email/password_reset.html.twig') ->context([ 'token' => $token, diff --git a/httpdocs/src/Controller/ProjectController.php b/httpdocs/src/Controller/ProjectController.php index f17bb35..52aa10c 100644 --- a/httpdocs/src/Controller/ProjectController.php +++ b/httpdocs/src/Controller/ProjectController.php @@ -13,15 +13,17 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Contracts\Translation\TranslatorInterface; class ProjectController extends AbstractController { public function __construct( - private EntityManagerInterface $em, - private ProjectRepository $projectRepo, - private ClientRepository $clientRepo, - private TimeEntryRepository $timeEntryRepo, - private readonly AccountRoleHelper $roleHelper, + private readonly EntityManagerInterface $em, + private readonly ProjectRepository $projectRepo, + private readonly ClientRepository $clientRepo, + private readonly TimeEntryRepository $timeEntryRepo, + private readonly AccountRoleHelper $roleHelper, + private readonly TranslatorInterface $translator, ) {} #[Route('/projects', name: 'project_index')] @@ -40,13 +42,13 @@ class ProjectController extends AbstractController public function create(Request $request): JsonResponse { if ($this->roleHelper->isTracker()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $data = json_decode($request->getContent(), true); $client = $this->clientRepo->find($data['clientId'] ?? 0); - if (empty($data['name'])) return $this->json(['error' => 'Name ist erforderlich'], 400); - if (!$client) return $this->json(['error' => 'Kunde nicht gefunden'], 400); + if (empty($data['name'])) return $this->json(['error' => $this->translator->trans('app.error.name_required')], 400); + if (!$client) return $this->json(['error' => $this->translator->trans('app.error.client_not_found')], 400); $project = new Project(); $project->setName(trim($data['name'])); @@ -63,16 +65,16 @@ class ProjectController extends AbstractController public function update(int $id, Request $request): JsonResponse { if ($this->roleHelper->isTracker()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $project = $this->projectRepo->find($id); - if (!$project) return $this->json(['error' => 'Nicht gefunden'], 404); + if (!$project) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); $data = json_decode($request->getContent(), true); $client = $this->clientRepo->find($data['clientId'] ?? 0); - if (empty($data['name'])) return $this->json(['error' => 'Name ist erforderlich'], 400); - if (!$client) return $this->json(['error' => 'Kunde nicht gefunden'], 400); + if (empty($data['name'])) return $this->json(['error' => $this->translator->trans('app.error.name_required')], 400); + if (!$client) return $this->json(['error' => $this->translator->trans('app.error.client_not_found')], 400); $project->setName(trim($data['name'])); $project->setClient($client); @@ -87,10 +89,10 @@ class ProjectController extends AbstractController public function delete(int $id): JsonResponse { if ($this->roleHelper->isTracker()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $project = $this->projectRepo->find($id); - if (!$project) return $this->json(['error' => 'Nicht gefunden'], 404); + if (!$project) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); if ($this->timeEntryRepo->countByProject($project) > 0) { return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409); @@ -106,10 +108,10 @@ class ProjectController extends AbstractController public function archive(int $id): JsonResponse { if ($this->roleHelper->isTracker()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $project = $this->projectRepo->find($id); - if (!$project) return $this->json(['error' => 'Nicht gefunden'], 404); + if (!$project) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); $project->setArchivedAt(new \DateTimeImmutable()); $this->em->flush(); @@ -121,10 +123,10 @@ class ProjectController extends AbstractController public function unarchive(int $id): JsonResponse { if ($this->roleHelper->isTracker()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $project = $this->projectRepo->find($id); - if (!$project) return $this->json(['error' => 'Nicht gefunden'], 404); + if (!$project) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); $project->setArchivedAt(null); $this->em->flush(); diff --git a/httpdocs/src/Controller/RegistrationController.php b/httpdocs/src/Controller/RegistrationController.php index 569374a..15052e8 100644 --- a/httpdocs/src/Controller/RegistrationController.php +++ b/httpdocs/src/Controller/RegistrationController.php @@ -10,12 +10,14 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Contracts\Translation\TranslatorInterface; class RegistrationController extends AbstractController { public function __construct( private readonly RegistrationService $registrationService, private readonly SlugGenerator $slugGenerator, + private readonly TranslatorInterface $translator, private readonly string $appDomain, private readonly LoggerInterface $logger, ) {} @@ -62,12 +64,12 @@ class RegistrationController extends AbstractController $passwordRepeat = $data['passwordRepeat'] ?? ''; $errors = []; - if ($companyName === '') { $errors[] = 'Firmenname ist erforderlich.'; } - if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Keine gültige E-Mail-Adresse.'; } - if ($firstName === '') { $errors[] = 'Vorname ist erforderlich.'; } - if ($lastName === '') { $errors[] = 'Nachname ist erforderlich.'; } - if (strlen($password) < 8) { $errors[] = 'Passwort muss mindestens 8 Zeichen lang sein.'; } - if ($password !== $passwordRepeat) { $errors[] = 'Passwörter stimmen nicht überein.'; } + if ($companyName === '') { $errors[] = $this->translator->trans('app.validation.company_name_required'); } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = $this->translator->trans('app.validation.email_invalid'); } + if ($firstName === '') { $errors[] = $this->translator->trans('app.validation.first_name_required'); } + if ($lastName === '') { $errors[] = $this->translator->trans('app.validation.last_name_required'); } + if (strlen($password) < 8) { $errors[] = $this->translator->trans('app.validation.password_min_length'); } + if ($password !== $passwordRepeat) { $errors[] = $this->translator->trans('app.validation.password_mismatch'); } if (!empty($errors)) { return $this->json(['errors' => $errors], Response::HTTP_UNPROCESSABLE_ENTITY); @@ -82,11 +84,8 @@ class RegistrationController extends AbstractController return $this->json(['errors' => [$e->getMessage()]], Response::HTTP_UNPROCESSABLE_ENTITY); } catch (\Throwable $e) { $this->logger->error('Registration failed: ' . $e->getMessage(), ['exception' => $e]); - return $this->json(['errors' => ['Ein Fehler ist aufgetreten. Bitte versuche es erneut.']], Response::HTTP_INTERNAL_SERVER_ERROR); + return $this->json(['errors' => [$this->translator->trans('app.error.generic')]], Response::HTTP_INTERNAL_SERVER_ERROR); } -// } catch (\Throwable $e) { -// return $this->json(['errors' => ['Ein Fehler ist aufgetreten. Bitte versuche es erneut.']], Response::HTTP_INTERNAL_SERVER_ERROR); -// } } #[Route('/verify/{token}', name: 'app_verify')] diff --git a/httpdocs/src/Controller/ReportController.php b/httpdocs/src/Controller/ReportController.php index 9035973..3011937 100644 --- a/httpdocs/src/Controller/ReportController.php +++ b/httpdocs/src/Controller/ReportController.php @@ -7,16 +7,20 @@ use App\Repository\Tenant\ClientRepository; use App\Repository\Tenant\TimeEntryRepository; use App\Repository\Tenant\ProjectRepository; use App\Repository\Tenant\ServiceRepository; +use App\Service\ReportExportService; use App\Service\TenantContext; use App\Entity\Central\User; use App\Service\AccountRoleHelper; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\Routing\Attribute\Route; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Contracts\Translation\TranslatorInterface; class ReportController extends AbstractController { @@ -32,6 +36,8 @@ class ReportController extends AbstractController private readonly ProjectRepository $projectRepo, private readonly ServiceRepository $serviceRepo, private readonly ClientRepository $clientRepo, + private readonly ReportExportService $exportService, + private readonly TranslatorInterface $translator, ) {} #[Route('/reports/times', name: 'report_times')] @@ -48,15 +54,11 @@ class ReportController extends AbstractController $isAdmin = $this->roleHelper->isAdmin(); $isTracker = $this->roleHelper->isTracker(); - // User-Map: userId → vollständiger Name - $account = $this->tenantContext->getAccount(); - $accountUsers = $this->accountUserRepo->findBy(['account' => $account]); - $userMap = []; - foreach ($accountUsers as $au) { - $userMap[$au->getUser()->getId()] = $au->getUser()->getFullName(); - } + $userMap = $this->buildUserMap(); // User-Liste für Filter-Dropdown (für Twig/JS) + $account = $this->tenantContext->getAccount(); + $accountUsers = $this->accountUserRepo->findBy(['account' => $account]); $userList = []; foreach ($accountUsers as $au) { $userList[] = [ @@ -66,14 +68,8 @@ class ReportController extends AbstractController ]; } - // Filter aus GET-Parametern lesen $filterRaw = $request->query->all('filter'); - $filters = $this->parseFilters($filterRaw); - - // Tracker: immer auf eigenen User beschränken - if ($isTracker) { - $filters['userIds'] = [$currentUserId]; - } + $filters = $this->resolveFilters($request); // Ob der Benutzer explizit Filter gesetzt hat (für "Alle anzeigen") $filterActive = !empty($request->query->all('filter')); @@ -126,6 +122,94 @@ class ReportController extends AbstractController ]); } + // ── Excel-Export ───────────────────────────────────────────────────────── + + #[Route('/reports/export/excel', name: 'report_export_excel')] + public function exportExcel(Request $request): BinaryFileResponse + { + $filters = $this->resolveFilters($request); + $entries = $this->timeEntryRepo->findAllFiltered($filters); + + $accountName = $this->tenantContext->getAccount()?->getName() ?? ''; + $userMap = $this->buildUserMap(); + + $tmpFile = $this->exportService->generateExcel($entries, $userMap, $accountName); + $filename = 'Zeitreport_' . $accountName . '_' . date('Y-m-d') . '.xlsx'; + + $response = new BinaryFileResponse($tmpFile); + $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename); + $response->deleteFileAfterSend(true); + + return $response; + } + + #[Route('/reports/export/csv', name: 'report_export_csv')] + public function exportCsv(Request $request): BinaryFileResponse + { + $filters = $this->resolveFilters($request); + $entries = $this->timeEntryRepo->findAllFiltered($filters); + + $accountName = $this->tenantContext->getAccount()?->getName() ?? ''; + $userMap = $this->buildUserMap(); + + $tmpFile = $this->exportService->generateCsv($entries, $userMap); + $filename = 'Zeitreport_' . $accountName . '_' . date('Y-m-d') . '.csv'; + + $response = new BinaryFileResponse($tmpFile); + $response->headers->set('Content-Type', 'text/csv; charset=UTF-8'); + $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename); + $response->deleteFileAfterSend(true); + + return $response; + } + + #[Route('/reports/export/pdf', name: 'report_export_pdf')] + public function exportPdf(Request $request): BinaryFileResponse + { + $filters = $this->resolveFilters($request); + $entries = $this->timeEntryRepo->findAllFiltered($filters); + + $accountName = $this->tenantContext->getAccount()?->getName() ?? ''; + $userMap = $this->buildUserMap(); + + $tmpFile = $this->exportService->generatePdf($entries, $userMap, $accountName); + $filename = 'Zeitreport_' . $accountName . '_' . date('Y-m-d') . '.pdf'; + + $response = new BinaryFileResponse($tmpFile); + $response->headers->set('Content-Type', 'application/pdf'); + $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename); + $response->deleteFileAfterSend(true); + + return $response; + } + + // ── Shared Helpers ─────────────────────────────────────────────────────── + + private function resolveFilters(Request $request): array + { + $filterRaw = $request->query->all('filter'); + $filters = $this->parseFilters($filterRaw); + + if ($this->roleHelper->isTracker()) { + /** @var User $currentUser */ + $currentUser = $this->security->getUser(); + $filters['userIds'] = [$currentUser->getId()]; + } + + return $filters; + } + + private function buildUserMap(): array + { + $account = $this->tenantContext->getAccount(); + $accountUsers = $this->accountUserRepo->findBy(['account' => $account]); + $userMap = []; + foreach ($accountUsers as $au) { + $userMap[$au->getUser()->getId()] = $au->getUser()->getFullName(); + } + return $userMap; + } + // ── Filter-Parsing ──────────────────────────────────────────────────────── private function parseFilters(array $f): array @@ -250,13 +334,13 @@ class ReportController extends AbstractController { $entry = $this->timeEntryRepo->find($id); if (!$entry) { - return $this->json(['error' => 'Nicht gefunden'], 404); + return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); } /** @var User $currentUser */ $currentUser = $this->security->getUser(); if (!$this->roleHelper->isAdmin() && $entry->getUserId() !== $currentUser->getId()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $entry->setInvoiced(!$entry->isInvoiced()); diff --git a/httpdocs/src/Controller/ServiceController.php b/httpdocs/src/Controller/ServiceController.php index 71916e5..db88b04 100644 --- a/httpdocs/src/Controller/ServiceController.php +++ b/httpdocs/src/Controller/ServiceController.php @@ -12,14 +12,16 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Contracts\Translation\TranslatorInterface; class ServiceController extends AbstractController { public function __construct( - private EntityManagerInterface $em, - private ServiceRepository $serviceRepo, - private TimeEntryRepository $timeEntryRepo, - private readonly AccountRoleHelper $roleHelper, + private readonly EntityManagerInterface $em, + private readonly ServiceRepository $serviceRepo, + private readonly TimeEntryRepository $timeEntryRepo, + private readonly AccountRoleHelper $roleHelper, + private readonly TranslatorInterface $translator, ) {} #[Route('/services', name: 'service_index')] @@ -37,11 +39,11 @@ class ServiceController extends AbstractController public function create(Request $request): JsonResponse { if ($this->roleHelper->isTracker()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $data = json_decode($request->getContent(), true); - if (empty($data['name'])) return $this->json(['error' => 'Name ist erforderlich'], 400); + if (empty($data['name'])) return $this->json(['error' => $this->translator->trans('app.error.name_required')], 400); $service = new Service(); $service->setName(trim($data['name'])); @@ -58,14 +60,14 @@ class ServiceController extends AbstractController public function update(int $id, Request $request): JsonResponse { if ($this->roleHelper->isTracker()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $service = $this->serviceRepo->find($id); - if (!$service) return $this->json(['error' => 'Nicht gefunden'], 404); + if (!$service) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); $data = json_decode($request->getContent(), true); - if (empty($data['name'])) return $this->json(['error' => 'Name ist erforderlich'], 400); + if (empty($data['name'])) return $this->json(['error' => $this->translator->trans('app.error.name_required')], 400); $service->setName(trim($data['name'])); $service->setBillable((bool) ($data['billable'] ?? true)); @@ -80,10 +82,10 @@ class ServiceController extends AbstractController public function delete(int $id): JsonResponse { if ($this->roleHelper->isTracker()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $service = $this->serviceRepo->find($id); - if (!$service) return $this->json(['error' => 'Nicht gefunden'], 404); + if (!$service) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); if ($this->timeEntryRepo->countByService($service) > 0) { return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409); @@ -99,10 +101,10 @@ class ServiceController extends AbstractController public function archive(int $id): JsonResponse { if ($this->roleHelper->isTracker()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $service = $this->serviceRepo->find($id); - if (!$service) return $this->json(['error' => 'Nicht gefunden'], 404); + if (!$service) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); $service->setArchivedAt(new \DateTimeImmutable()); $this->em->flush(); @@ -114,10 +116,10 @@ class ServiceController extends AbstractController public function unarchive(int $id): JsonResponse { if ($this->roleHelper->isTracker()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $service = $this->serviceRepo->find($id); - if (!$service) return $this->json(['error' => 'Nicht gefunden'], 404); + if (!$service) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); $service->setArchivedAt(null); $this->em->flush(); diff --git a/httpdocs/src/Controller/TeamController.php b/httpdocs/src/Controller/TeamController.php index 2d961a0..4cc4f1d 100644 --- a/httpdocs/src/Controller/TeamController.php +++ b/httpdocs/src/Controller/TeamController.php @@ -21,6 +21,7 @@ use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Address; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Contracts\Translation\TranslatorInterface; class TeamController extends AbstractController { @@ -34,6 +35,7 @@ class TeamController extends AbstractController private readonly AccountRoleHelper $roleHelper, private readonly MailerInterface $mailer, private readonly UrlGeneratorInterface $urlGenerator, + private readonly TranslatorInterface $translator, private readonly string $appDomain, ) {} @@ -63,7 +65,7 @@ class TeamController extends AbstractController public function invite(Request $request): JsonResponse { if (!$this->roleHelper->isAdmin()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $data = json_decode($request->getContent(), true) ?? []; @@ -73,12 +75,12 @@ class TeamController extends AbstractController $role = $data['role'] ?? AccountUser::ROLE_MEMBER; $errors = []; - if ($email === '') { $errors[] = 'E-Mail ist erforderlich.'; } - if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Keine gültige E-Mail-Adresse.'; } - if ($firstName === '') { $errors[] = 'Vorname ist erforderlich.'; } - if ($lastName === '') { $errors[] = 'Nachname ist erforderlich.'; } + if ($email === '') { $errors[] = $this->translator->trans('app.validation.email_required'); } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = $this->translator->trans('app.validation.email_invalid'); } + if ($firstName === '') { $errors[] = $this->translator->trans('app.validation.first_name_required'); } + if ($lastName === '') { $errors[] = $this->translator->trans('app.validation.last_name_required'); } if (!in_array($role, [AccountUser::ROLE_ADMIN, AccountUser::ROLE_MEMBER, AccountUser::ROLE_TRACKER], true)) { - $errors[] = 'Ungültige Rolle.'; + $errors[] = $this->translator->trans('app.error.invalid_role'); } if (!empty($errors)) { @@ -94,7 +96,7 @@ class TeamController extends AbstractController 'user' => $existingUser, ]); if ($alreadyMember !== null) { - return $this->json(['errors' => ['Diese Person ist bereits Mitglied dieses Accounts.']], 409); + return $this->json(['errors' => [$this->translator->trans('app.team.already_member')]], 409); } } @@ -124,22 +126,22 @@ class TeamController extends AbstractController public function archive(int $id): JsonResponse { if (!$this->roleHelper->isAdmin()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $account = $this->tenantContext->getAccount(); $accountUser = $this->accountUserRepo->find($id); if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) { - return $this->json(['error' => 'Nicht gefunden'], 404); + return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); } if ($accountUser->getUser() === $this->getUser()) { - return $this->json(['error' => 'Du kannst dich nicht selbst archivieren.'], 400); + return $this->json(['error' => $this->translator->trans('app.team.cannot_archive_self')], 400); } if ($account->getSuperAdminUser()?->getId() === $accountUser->getUser()->getId()) { - return $this->json(['error' => 'Der Kontoinhaber kann nicht archiviert werden.'], 403); + return $this->json(['error' => $this->translator->trans('app.team.cannot_archive_owner')], 403); } $accountUser->setArchivedAt(new \DateTimeImmutable()); @@ -152,14 +154,14 @@ class TeamController extends AbstractController public function unarchive(int $id): JsonResponse { if (!$this->roleHelper->isAdmin()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $account = $this->tenantContext->getAccount(); $accountUser = $this->accountUserRepo->find($id); if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) { - return $this->json(['error' => 'Nicht gefunden'], 404); + return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); } $accountUser->setArchivedAt(null); @@ -172,30 +174,30 @@ class TeamController extends AbstractController public function edit(int $id, Request $request): JsonResponse { if (!$this->roleHelper->isAdmin()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $account = $this->tenantContext->getAccount(); $accountUser = $this->accountUserRepo->find($id); if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) { - return $this->json(['error' => 'Nicht gefunden'], 404); + return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); } $data = json_decode($request->getContent(), true) ?? []; $firstName = trim($data['firstName'] ?? ''); $lastName = trim($data['lastName'] ?? ''); $email = trim($data['email'] ?? ''); - $note = $data['note'] !== '' ? ($data['note'] ?? null) : null; + $note = !empty($data['note']) ? $data['note'] : null; $role = $data['role'] ?? null; $errors = []; - if ($firstName === '') { $errors[] = 'Vorname ist erforderlich.'; } - if ($lastName === '') { $errors[] = 'Nachname ist erforderlich.'; } - if ($email === '') { $errors[] = 'E-Mail ist erforderlich.'; } - if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Keine gültige E-Mail-Adresse.'; } + if ($firstName === '') { $errors[] = $this->translator->trans('app.validation.first_name_required'); } + if ($lastName === '') { $errors[] = $this->translator->trans('app.validation.last_name_required'); } + if ($email === '') { $errors[] = $this->translator->trans('app.validation.email_required'); } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = $this->translator->trans('app.validation.email_invalid'); } if ($role !== null && !in_array($role, [AccountUser::ROLE_ADMIN, AccountUser::ROLE_MEMBER, AccountUser::ROLE_TRACKER], true)) { - $errors[] = 'Ungültige Rolle.'; + $errors[] = $this->translator->trans('app.error.invalid_role'); } if (!empty($errors)) { @@ -208,14 +210,13 @@ class TeamController extends AbstractController if ($email !== $user->getEmail()) { $existing = $this->userRepo->findOneBy(['email' => $email]); if ($existing !== null) { - return $this->json(['errors' => ['Diese E-Mail-Adresse wird bereits verwendet.']], 409); + return $this->json(['errors' => [$this->translator->trans('app.error.email_taken')]], 409); } } - // Eigene Rolle: Admin darf sich nicht selbst degradieren $isSelf = ($user === $this->getUser()); if ($isSelf && $accountUser->isAdmin() && $role !== null && $role !== AccountUser::ROLE_ADMIN) { - return $this->json(['errors' => ['Du kannst deine eigene Administratoren-Rolle nicht ändern.']], 400); + return $this->json(['errors' => [$this->translator->trans('app.team.cannot_change_own_role')]], 400); } $user->setFirstName($firstName); @@ -236,22 +237,22 @@ class TeamController extends AbstractController public function delete(int $id): JsonResponse { if (!$this->roleHelper->isAdmin()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $account = $this->tenantContext->getAccount(); $accountUser = $this->accountUserRepo->find($id); if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) { - return $this->json(['error' => 'Nicht gefunden'], 404); + return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); } if ($accountUser->getUser() === $this->getUser()) { - return $this->json(['error' => 'Du kannst dich nicht selbst entfernen.'], 400); + return $this->json(['error' => $this->translator->trans('app.team.cannot_remove_self')], 400); } if ($account->getSuperAdminUser()?->getId() === $accountUser->getUser()->getId()) { - return $this->json(['error' => 'Der Kontoinhaber kann nicht entfernt werden.'], 403); + return $this->json(['error' => $this->translator->trans('app.team.cannot_remove_owner')], 403); } $userId = $accountUser->getUser()->getId(); @@ -269,14 +270,14 @@ class TeamController extends AbstractController public function deleteInvite(int $id): JsonResponse { if (!$this->roleHelper->isAdmin()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $account = $this->tenantContext->getAccount(); $invite = $this->inviteTokenRepo->find($id); if ($invite === null || $invite->getAccount()?->getId() !== $account?->getId()) { - return $this->json(['error' => 'Nicht gefunden'], 404); + return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); } $this->em->remove($invite); @@ -295,7 +296,7 @@ class TeamController extends AbstractController 'email' => $au->getUser()->getEmail(), 'note' => $au->getUser()->getNote(), 'role' => $au->getRole(), - 'roleLabel' => $au->getRoleLabel(), + 'roleLabel' => $this->translator->trans('app.role.' . $au->getRole()), ]; } @@ -309,7 +310,7 @@ class TeamController extends AbstractController $email = (new TemplatedEmail()) ->to(new Address($invite->getEmail(), $invite->getFirstName() . ' ' . $invite->getLastName())) - ->subject('Einladung zu ' . $invite->getAccount()->getName()) + ->subject($this->translator->trans('app.email.invite.subject', ['%company%' => $invite->getAccount()->getName()])) ->htmlTemplate('email/team_invite.html.twig') ->context([ 'invite' => $invite, diff --git a/httpdocs/src/Controller/TimeTrackingController.php b/httpdocs/src/Controller/TimeTrackingController.php index 4acf15a..9ca9ee9 100644 --- a/httpdocs/src/Controller/TimeTrackingController.php +++ b/httpdocs/src/Controller/TimeTrackingController.php @@ -16,6 +16,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Contracts\Translation\TranslatorInterface; class TimeTrackingController extends AbstractController { @@ -27,6 +28,7 @@ class TimeTrackingController extends AbstractController private readonly TenantContext $tenantContext, private readonly AccountRoleHelper $roleHelper, private readonly Security $security, + private readonly TranslatorInterface $translator, ) {} // ── Hauptseite ──────────────────────────────────────────────────────────── @@ -106,7 +108,7 @@ class TimeTrackingController extends AbstractController $project = $this->projectRepo->find($data['projectId'] ?? 0); if (!$project) { - return $this->json(['error' => 'Projekt nicht gefunden'], 400); + return $this->json(['error' => $this->translator->trans('app.error.project_not_found')], 400); } $tz = new \DateTimeZone('Europe/Berlin'); @@ -120,7 +122,7 @@ class TimeTrackingController extends AbstractController $newDuration = $this->parseDuration($data['duration'] ?? '0'); $currentTotal = $this->timeEntryRepo->sumDurationByDateAndUserId($date, $user->getId()); if ($currentTotal + $newDuration > 1440) { - return $this->json(['error' => 'Du kannst nicht mehr als 24 Stunden pro Tag loggen.'], 422); + return $this->json(['error' => $this->translator->trans('app.error.daily_limit')], 422); } $entry = new TimeEntry(); @@ -149,20 +151,20 @@ class TimeTrackingController extends AbstractController { $entry = $this->timeEntryRepo->find($id); if (!$entry) { - return $this->json(['error' => 'Nicht gefunden'], 404); + return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); } /** @var User $currentUser */ $currentUser = $this->security->getUser(); if (!$this->roleHelper->isAdmin() && $entry->getUserId() !== $currentUser->getId()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $data = json_decode($request->getContent(), true); $project = $this->projectRepo->find($data['projectId'] ?? 0); if (!$project) { - return $this->json(['error' => 'Projekt nicht gefunden'], 400); + return $this->json(['error' => $this->translator->trans('app.error.project_not_found')], 400); } $service = null; @@ -173,7 +175,7 @@ class TimeTrackingController extends AbstractController $newDuration = $this->parseDuration($data['duration'] ?? '0'); $currentTotal = $this->timeEntryRepo->sumDurationByDateAndUserId($entry->getDate(), $entry->getUserId()); if ($currentTotal - $entry->getDuration() + $newDuration > 1440) { - return $this->json(['error' => 'Du kannst nicht mehr als 24 Stunden pro Tag loggen.'], 422); + return $this->json(['error' => $this->translator->trans('app.error.daily_limit')], 422); } $entry->setProject($project); @@ -201,13 +203,13 @@ class TimeTrackingController extends AbstractController { $entry = $this->timeEntryRepo->find($id); if (!$entry) { - return $this->json(['error' => 'Nicht gefunden'], 404); + return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); } /** @var User $currentUser */ $currentUser = $this->security->getUser(); if (!$this->roleHelper->isAdmin() && $entry->getUserId() !== $currentUser->getId()) { - return $this->json(['error' => 'Zugriff verweigert'], 403); + return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); } $date = $entry->getDate(); @@ -261,13 +263,15 @@ class TimeTrackingController extends AbstractController { $hour = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('H'); - return match(true) { - $hour >= 5 && $hour < 11 => 'Guten Morgen', - $hour >= 11 && $hour < 14 => 'Mahlzeit', - $hour >= 14 && $hour < 18 => 'Guten Tag', - $hour >= 18 && $hour < 22 => 'Guten Abend', - default => 'Gute Nacht', + $key = match(true) { + $hour >= 5 && $hour < 11 => 'app.greeting.morning', + $hour >= 11 && $hour < 14 => 'app.greeting.noon', + $hour >= 14 && $hour < 18 => 'app.greeting.afternoon', + $hour >= 18 && $hour < 22 => 'app.greeting.evening', + default => 'app.greeting.night', }; + + return $this->translator->trans($key); } private function parseDuration(string $input): int diff --git a/httpdocs/src/Entity/Central/AccountUser.php b/httpdocs/src/Entity/Central/AccountUser.php index 54eb647..89f7ae2 100644 --- a/httpdocs/src/Entity/Central/AccountUser.php +++ b/httpdocs/src/Entity/Central/AccountUser.php @@ -53,13 +53,8 @@ class AccountUser public function isTracker(): bool { return $this->role === self::ROLE_TRACKER; } public function isMemberOrAdmin(): bool { return $this->isAdmin() || $this->isMember(); } - public function getRoleLabel(): string + public function getRoleLabelKey(): string { - return match ($this->role) { - self::ROLE_ADMIN => 'Administrator', - self::ROLE_MEMBER => 'Standard', - self::ROLE_TRACKER => 'Zeiterfasser', - default => $this->role, - }; + return 'app.role.' . $this->role; } } \ No newline at end of file diff --git a/httpdocs/src/EventSubscriber/ArchivedUserSubscriber.php b/httpdocs/src/EventSubscriber/ArchivedUserSubscriber.php index cb6913b..79d71dd 100644 --- a/httpdocs/src/EventSubscriber/ArchivedUserSubscriber.php +++ b/httpdocs/src/EventSubscriber/ArchivedUserSubscriber.php @@ -11,6 +11,7 @@ use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Contracts\Translation\TranslatorInterface; class ArchivedUserSubscriber implements EventSubscriberInterface { @@ -19,9 +20,8 @@ class ArchivedUserSubscriber implements EventSubscriberInterface private readonly AccountUserRepository $accountUserRepo, private readonly TenantContext $tenantContext, private readonly RouterInterface $router, - ) - { - } + private readonly TranslatorInterface $translator, + ) {} public function onKernelRequest(RequestEvent $event): void { @@ -59,7 +59,7 @@ class ArchivedUserSubscriber implements EventSubscriberInterface // API: 401, sonst Redirect zu Login if (str_starts_with($request->getPathInfo(), '/api/')) { - $event->setResponse(new JsonResponse(['error' => 'Konto deaktiviert.'], 401)); + $event->setResponse(new JsonResponse(['error' => $this->translator->trans('app.account.deactivated_api')], 401)); } else { $event->setResponse(new RedirectResponse($this->router->generate('app_login'))); } diff --git a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php index 9ec0727..12564d6 100644 --- a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php +++ b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php @@ -46,104 +46,6 @@ class TimeEntryRepository extends ServiceEntityRepository return (int) $result; } - // ── Report ──────────────────────────────────────────────────────────────── - - public function findForReport(int $limit = 50): array - { - return $this->createQueryBuilder('t') - ->join('t.project', 'p') - ->join('p.client', 'c') - ->leftJoin('t.service', 's') - ->addSelect('p', 'c', 's') - ->orderBy('t.date', 'DESC') - ->addOrderBy('t.createdAt', 'DESC') - ->setMaxResults($limit) - ->getQuery() - ->getResult(); - } - - public function countAll(): int - { - return (int) $this->createQueryBuilder('t') - ->select('COUNT(t.id)') - ->getQuery() - ->getSingleScalarResult(); - } - - public function sumDurationAll(): int - { - $result = $this->createQueryBuilder('t') - ->select('SUM(t.duration)') - ->getQuery() - ->getSingleScalarResult(); - - return (int) $result; - } - - public function sumRevenueAll(): float - { - $result = $this->createQueryBuilder('t') - ->select('SUM(c.hourlyRate * t.duration / 60)') - ->join('t.project', 'p') - ->join('p.client', 'c') - ->leftJoin('t.service', 's') - ->where('c.hourlyRate IS NOT NULL') - ->andWhere('(s IS NULL OR s.billable = :billable)') - ->setParameter('billable', true) - ->getQuery() - ->getSingleScalarResult(); - - return (float) ($result ?? 0.0); - } - - // ── Report: nach User gefiltert (für Tracker) ───────────────────────────── - - public function findForReportByUserId(int $userId, int $limit = 50): array - { - return $this->createQueryBuilder('t') - ->join('t.project', 'p') - ->join('p.client', 'c') - ->leftJoin('t.service', 's') - ->addSelect('p', 'c', 's') - ->where('t.userId = :userId') - ->setParameter('userId', $userId) - ->orderBy('t.date', 'DESC') - ->addOrderBy('t.createdAt', 'DESC') - ->setMaxResults($limit) - ->getQuery() - ->getResult(); - } - - public function sumDurationByUserId(int $userId): int - { - $result = $this->createQueryBuilder('t') - ->select('SUM(t.duration)') - ->where('t.userId = :userId') - ->setParameter('userId', $userId) - ->getQuery() - ->getSingleScalarResult(); - - return (int) $result; - } - - public function sumRevenueByUserId(int $userId): float - { - $result = $this->createQueryBuilder('t') - ->select('SUM(c.hourlyRate * t.duration / 60)') - ->join('t.project', 'p') - ->join('p.client', 'c') - ->leftJoin('t.service', 's') - ->where('t.userId = :userId') - ->andWhere('c.hourlyRate IS NOT NULL') - ->andWhere('(s IS NULL OR s.billable = :billable)') - ->setParameter('userId', $userId) - ->setParameter('billable', true) - ->getQuery() - ->getSingleScalarResult(); - - return (float) ($result ?? 0.0); - } - // ── Zähler für abhängige Entitäten ──────────────────────────────────────── public function countByProject(Project $project): int @@ -263,6 +165,16 @@ class TimeEntryRepository extends ServiceEntityRepository ->getResult(); } + public function findAllFiltered(array $filters): array + { + return $this->buildFilteredQuery($filters) + ->addSelect('p', 'c', 's') + ->orderBy('t.date', 'DESC') + ->addOrderBy('t.createdAt', 'DESC') + ->getQuery() + ->getResult(); + } + public function countFiltered(array $filters): int { return (int) $this->buildFilteredQuery($filters) diff --git a/httpdocs/src/Security/ArchivedUserChecker.php b/httpdocs/src/Security/ArchivedUserChecker.php index 7e92399..80276b9 100644 --- a/httpdocs/src/Security/ArchivedUserChecker.php +++ b/httpdocs/src/Security/ArchivedUserChecker.php @@ -7,15 +7,15 @@ use App\Service\TenantContext; use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException; use Symfony\Component\Security\Core\User\UserCheckerInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Contracts\Translation\TranslatorInterface; class ArchivedUserChecker implements UserCheckerInterface { public function __construct( private readonly AccountUserRepository $accountUserRepo, private readonly TenantContext $tenantContext, - ) - { - } + private readonly TranslatorInterface $translator, + ) {} public function checkPreAuth(UserInterface $user): void { @@ -34,7 +34,7 @@ class ArchivedUserChecker implements UserCheckerInterface ]); if ($accountUser !== null && $accountUser->isArchived()) { - throw new CustomUserMessageAccountStatusException('Dein Konto wurde deaktiviert.'); + throw new CustomUserMessageAccountStatusException($this->translator->trans('app.account.deactivated')); } } } \ No newline at end of file diff --git a/httpdocs/src/Service/AccountRoleHelper.php b/httpdocs/src/Service/AccountRoleHelper.php index ace2ed3..8f1cf53 100644 --- a/httpdocs/src/Service/AccountRoleHelper.php +++ b/httpdocs/src/Service/AccountRoleHelper.php @@ -12,6 +12,9 @@ use Symfony\Bundle\SecurityBundle\Security; */ class AccountRoleHelper { + private ?AccountUser $cached = null; + private bool $resolved = false; + public function __construct( private readonly Security $security, private readonly TenantContext $tenantContext, @@ -20,6 +23,10 @@ class AccountRoleHelper public function getCurrentAccountUser(): ?AccountUser { + if ($this->resolved) { + return $this->cached; + } + $user = $this->security->getUser(); $account = $this->tenantContext->getAccount(); @@ -27,10 +34,13 @@ class AccountRoleHelper return null; } - return $this->accountUserRepo->findOneBy([ + $this->cached = $this->accountUserRepo->findOneBy([ 'account' => $account, 'user' => $user, ]); + $this->resolved = true; + + return $this->cached; } public function isAdmin(): bool { return $this->getCurrentAccountUser()?->isAdmin() ?? false; } diff --git a/httpdocs/src/Service/RegistrationService.php b/httpdocs/src/Service/RegistrationService.php index fb70164..bf05ff4 100644 --- a/httpdocs/src/Service/RegistrationService.php +++ b/httpdocs/src/Service/RegistrationService.php @@ -13,6 +13,7 @@ use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Address; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Contracts\Translation\TranslatorInterface; class RegistrationService { @@ -24,6 +25,7 @@ class RegistrationService private readonly UserPasswordHasherInterface $passwordHasher, private readonly UrlGeneratorInterface $urlGenerator, private readonly SlugGenerator $slugGenerator, + private readonly TranslatorInterface $translator, private readonly string $appDomain, private readonly string $notifyEmail, ) {} @@ -41,7 +43,7 @@ class RegistrationService // E-Mail bereits vergeben? $existingUser = $this->centralEm->getRepository(User::class)->findOneBy(['email' => $email]); if ($existingUser !== null) { - throw new \DomainException('Diese E-Mail-Adresse wird bereits verwendet.'); + throw new \DomainException($this->translator->trans('app.registration.email_taken')); } // Pending Token für dieselbe E-Mail? (doppeltes Absenden verhindern) @@ -83,13 +85,13 @@ class RegistrationService $token = $this->centralEm->getRepository(RegistrationToken::class)->findOneBy(['token' => $tokenString]); if ($token === null) { - throw new \InvalidArgumentException('Ungültiger Bestätigungslink.'); + throw new \InvalidArgumentException($this->translator->trans('app.registration.confirm_invalid')); } if ($token->isExpired()) { $this->centralEm->remove($token); $this->centralEm->flush(); - throw new \InvalidArgumentException('Dieser Link ist abgelaufen (gültig 24 Stunden). Bitte registriere dich erneut.'); + throw new \InvalidArgumentException($this->translator->trans('app.registration.confirm_expired')); } // Account anlegen @@ -150,7 +152,7 @@ class RegistrationService $email = (new TemplatedEmail()) ->to(new Address($token->getEmail(), $token->getFirstName() . ' ' . $token->getLastName())) - ->subject('Bitte bestätige deine Registrierung – spawntree Timetracker') + ->subject($this->translator->trans('app.email.confirm.subject')) ->htmlTemplate('email/registration_confirm.html.twig') ->context([ 'token' => $token, @@ -166,7 +168,7 @@ class RegistrationService $email = (new TemplatedEmail()) ->to(new Address($user->getEmail(), $user->getFullName())) - ->subject('Willkommen beim spawntree Timetracker!') + ->subject($this->translator->trans('app.email.welcome.subject')) ->htmlTemplate('email/registration_welcome.html.twig') ->context([ 'user' => $user, @@ -181,7 +183,7 @@ class RegistrationService { $email = (new TemplatedEmail()) ->to($this->notifyEmail) - ->subject('[Timetracker] Neue Registrierung: ' . $account->getName()) + ->subject($this->translator->trans('app.email.notify.subject', ['%name%' => $account->getName()])) ->htmlTemplate('email/registration_notify.html.twig') ->context([ 'user' => $user, diff --git a/httpdocs/src/Service/ReportExportService.php b/httpdocs/src/Service/ReportExportService.php new file mode 100644 index 0000000..41613a5 --- /dev/null +++ b/httpdocs/src/Service/ReportExportService.php @@ -0,0 +1,395 @@ +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 $userMap + * @return array{rows: list, 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 $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 $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 $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 = '' . htmlspecialchars($headers[0]) . '' + . '' . htmlspecialchars($headers[1]) . '' + . '' . htmlspecialchars($headers[2]) . '' + . '' . htmlspecialchars($headers[3]) . '' + . '' . htmlspecialchars($headers[4]) . '' + . '' . htmlspecialchars($headers[5]) . '' + . '' . htmlspecialchars($headers[6]) . '' + . '' . htmlspecialchars($headers[7]) . '' + . '' . htmlspecialchars($invoicedTh) . ''; + + $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 .= "" + . '' . htmlspecialchars($r['date']->format('d.m.Y')) . '' + . '' . htmlspecialchars($r['client']) . '' + . '' . htmlspecialchars($r['project']) . '' + . '' . htmlspecialchars($r['service']) . '' + . '' . htmlspecialchars($r['user']) . '' + . '' . htmlspecialchars($r['note']) . '' + . '' . $hours . '' + . '' . $revenue . '' + . '' . ($r['invoiced'] ? $yes : $no) . '' + . ''; + } + + $totalHours = number_format($data['totalHours'], 2, ',', '.'); + $totalRevenue = number_format($data['totalRevenue'], 2, ',', '.') . ' €'; + $sumLabel = htmlspecialchars($sumLabel); + + return << + + + + + + +
+

{$title}

+
{$created} · {$countLabel}
+
+ + {$thHtml} + + {$rowsHtml} + + + + + + + +
{$sumLabel}{$totalHours}{$totalRevenue}
+ + + +HTML; + } +} diff --git a/httpdocs/src/Twig/Runtime/AppExtensionRuntime.php b/httpdocs/src/Twig/Runtime/AppExtensionRuntime.php deleted file mode 100644 index eb9e189..0000000 --- a/httpdocs/src/Twig/Runtime/AppExtensionRuntime.php +++ /dev/null @@ -1,18 +0,0 @@ - + + + + diff --git a/httpdocs/templates/_atoms/icon-excel.html.twig b/httpdocs/templates/_atoms/icon-excel.html.twig new file mode 100644 index 0000000..ac6df9e --- /dev/null +++ b/httpdocs/templates/_atoms/icon-excel.html.twig @@ -0,0 +1,5 @@ + + + + + diff --git a/httpdocs/templates/_atoms/icon-pdf.html.twig b/httpdocs/templates/_atoms/icon-pdf.html.twig new file mode 100644 index 0000000..c36bbbb --- /dev/null +++ b/httpdocs/templates/_atoms/icon-pdf.html.twig @@ -0,0 +1,5 @@ + + + + + diff --git a/httpdocs/templates/_atoms/icon-print.html.twig b/httpdocs/templates/_atoms/icon-print.html.twig new file mode 100644 index 0000000..ac829a2 --- /dev/null +++ b/httpdocs/templates/_atoms/icon-print.html.twig @@ -0,0 +1,6 @@ + + + + + + diff --git a/httpdocs/templates/_components/register-success.html.twig b/httpdocs/templates/_components/register-success.html.twig index 308078a..6220845 100644 --- a/httpdocs/templates/_components/register-success.html.twig +++ b/httpdocs/templates/_components/register-success.html.twig @@ -10,5 +10,5 @@
{{ icon }}

{{ title }}

{{ text|raw }}

- {{ btn_label }} + {{ btn_label }}
diff --git a/httpdocs/templates/_macros/helpers.html.twig b/httpdocs/templates/_macros/helpers.html.twig new file mode 100644 index 0000000..35abc5e --- /dev/null +++ b/httpdocs/templates/_macros/helpers.html.twig @@ -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 %} diff --git a/httpdocs/templates/_sections/nav.html.twig b/httpdocs/templates/_sections/nav.html.twig index 9a21ee4..fa3e88c 100644 --- a/httpdocs/templates/_sections/nav.html.twig +++ b/httpdocs/templates/_sections/nav.html.twig @@ -45,7 +45,7 @@ {# Hamburger-Navigation — nur im Minimal-Theme sichtbar (via CSS) #}
-