Przeglądaj źródła

big update: cleanup, restructuring, translation keys + excel, csv and pdf exports + print page

master
FlorianEisenmenger 5 godzin temu
rodzic
commit
fa749a0fa9
76 zmienionych plików z 3624 dodań i 1947 usunięć
  1. +18
    -0
      .claude/settings.local.json
  2. +139
    -0
      CLAUDE.md
  3. +3
    -3
      httpdocs/.env
  4. +0
    -4
      httpdocs/.env.dev
  5. +12
    -8
      httpdocs/PROJEKT_KONTEXT.md
  6. +187
    -171
      httpdocs/assets/scripts/account.js
  7. +17
    -19
      httpdocs/assets/scripts/calendar.js
  8. +90
    -112
      httpdocs/assets/scripts/crud.js
  9. +28
    -49
      httpdocs/assets/scripts/duration.js
  10. +93
    -123
      httpdocs/assets/scripts/entries.js
  11. +79
    -78
      httpdocs/assets/scripts/registration.js
  12. +347
    -387
      httpdocs/assets/scripts/report.js
  13. +229
    -191
      httpdocs/assets/scripts/team.js
  14. +26
    -0
      httpdocs/assets/scripts/utils.js
  15. +1
    -1
      httpdocs/assets/styles/atoms/_inputs.scss
  16. +53
    -0
      httpdocs/assets/styles/atoms/_mixins.scss
  17. +23
    -27
      httpdocs/assets/styles/atoms/_variables.scss
  18. +6
    -20
      httpdocs/assets/styles/components/_account.scss
  19. +6
    -21
      httpdocs/assets/styles/components/_crud.scss
  20. +15
    -3
      httpdocs/assets/styles/components/_entry-form.scss
  21. +11
    -38
      httpdocs/assets/styles/components/_entry-list.scss
  22. +5
    -8
      httpdocs/assets/styles/components/_login.scss
  23. +2
    -14
      httpdocs/assets/styles/components/_month-calendar.scss
  24. +7
    -4
      httpdocs/assets/styles/components/_register.scss
  25. +6
    -18
      httpdocs/assets/styles/components/_team.scss
  26. +4
    -22
      httpdocs/assets/styles/components/_week-nav.scss
  27. +6
    -1
      httpdocs/assets/styles/main.scss
  28. +3
    -5
      httpdocs/assets/styles/sections/_home.scss
  29. +65
    -118
      httpdocs/assets/styles/sections/_report.scss
  30. +4
    -11
      httpdocs/assets/styles/sections/_timetracking.scss
  31. +10
    -9
      httpdocs/assets/styles/themes/_minimal.scss
  32. +2
    -0
      httpdocs/composer.json
  33. +867
    -1
      httpdocs/composer.lock
  34. +1
    -2
      httpdocs/config/packages/doctrine.yaml
  35. +4
    -0
      httpdocs/config/services.yaml
  36. +17
    -16
      httpdocs/src/Controller/AccountController.php
  37. +17
    -15
      httpdocs/src/Controller/ClientController.php
  38. +17
    -12
      httpdocs/src/Controller/InviteController.php
  39. +22
    -1
      httpdocs/src/Controller/PasswordResetController.php
  40. +20
    -18
      httpdocs/src/Controller/ProjectController.php
  41. +9
    -10
      httpdocs/src/Controller/RegistrationController.php
  42. +100
    -16
      httpdocs/src/Controller/ReportController.php
  43. +17
    -15
      httpdocs/src/Controller/ServiceController.php
  44. +33
    -32
      httpdocs/src/Controller/TeamController.php
  45. +18
    -14
      httpdocs/src/Controller/TimeTrackingController.php
  46. +2
    -7
      httpdocs/src/Entity/Central/AccountUser.php
  47. +4
    -4
      httpdocs/src/EventSubscriber/ArchivedUserSubscriber.php
  48. +10
    -98
      httpdocs/src/Repository/Tenant/TimeEntryRepository.php
  49. +4
    -4
      httpdocs/src/Security/ArchivedUserChecker.php
  50. +11
    -1
      httpdocs/src/Service/AccountRoleHelper.php
  51. +8
    -6
      httpdocs/src/Service/RegistrationService.php
  52. +395
    -0
      httpdocs/src/Service/ReportExportService.php
  53. +0
    -18
      httpdocs/src/Twig/Runtime/AppExtensionRuntime.php
  54. +5
    -0
      httpdocs/templates/_atoms/icon-csv.html.twig
  55. +5
    -0
      httpdocs/templates/_atoms/icon-excel.html.twig
  56. +5
    -0
      httpdocs/templates/_atoms/icon-pdf.html.twig
  57. +6
    -0
      httpdocs/templates/_atoms/icon-print.html.twig
  58. +1
    -1
      httpdocs/templates/_components/register-success.html.twig
  59. +15
    -0
      httpdocs/templates/_macros/helpers.html.twig
  60. +1
    -1
      httpdocs/templates/_sections/nav.html.twig
  61. +6
    -26
      httpdocs/templates/_sections/tt-header.html.twig
  62. +41
    -30
      httpdocs/templates/account/index.html.twig
  63. +53
    -28
      httpdocs/templates/client/index.html.twig
  64. +5
    -5
      httpdocs/templates/home/index.html.twig
  65. +1
    -1
      httpdocs/templates/invite/error.html.twig
  66. +7
    -2
      httpdocs/templates/invite/set_password.html.twig
  67. +49
    -24
      httpdocs/templates/project/index.html.twig
  68. +14
    -1
      httpdocs/templates/registration/register.html.twig
  69. +6
    -6
      httpdocs/templates/report/_filter-panel.html.twig
  70. +30
    -1
      httpdocs/templates/report/times.html.twig
  71. +4
    -1
      httpdocs/templates/security/forgot_password.html.twig
  72. +4
    -1
      httpdocs/templates/security/reset_password.html.twig
  73. +28
    -1
      httpdocs/templates/service/index.html.twig
  74. +55
    -41
      httpdocs/templates/team/index.html.twig
  75. +7
    -17
      httpdocs/templates/timetracking/week.html.twig
  76. +203
    -5
      httpdocs/translations/messages.de.yaml

+ 18
- 0
.claude/settings.local.json Wyświetl plik

@@ -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 *)"
]
}
}

+ 139
- 0
CLAUDE.md Wyświetl plik

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

+ 3
- 3
httpdocs/.env Wyświetl plik

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

+ 0
- 4
httpdocs/.env.dev Wyświetl plik

@@ -1,4 +0,0 @@

###> symfony/framework-bundle ###
APP_SECRET=f19f2bcb34a48e20e66302a0e88408a9
###< symfony/framework-bundle ###

+ 12
- 8
httpdocs/PROJEKT_KONTEXT.md Wyświetl plik

@@ -16,7 +16,7 @@ Technisch ähnlich wie **mite.de** – wir orientieren uns stark am UI/UX von mi
- **Backend**: Symfony 7, PHP 8.2, Doctrine ORM, MariaDB
- **Frontend**: Twig, SCSS (Webpack Encore), Vanilla JS (keine jQuery, kein Framework)
- **SCSS-Struktur**: Atoms → Components → Sections → Themes (BEM-ähnlich)
- **Dev**: DDEV (Port 8456 HTTPS), PHPMyAdmin installiert
- **Dev**: DDEV (Port 8459 HTTPS), PHPMyAdmin installiert
- **Kein** Symfony Forms – eigene HTML-Formulare mit fetch()-API

---
@@ -24,7 +24,7 @@ Technisch ähnlich wie **mite.de** – wir orientieren uns stark am UI/UX von mi
## Multi-Mandanten-Architektur

### Konzept
- Jeder Account (Firma) bekommt eine eigene **Subdomain**: `spawntree.testtimetracking.ddev.site`
- Jeder Account (Firma) bekommt eine eigene **Subdomain**: `spawntree.timetracking.ddev.site`
- Jeder Account bekommt eine **eigene Tenant-Datenbank**: `db_spawntree`
- Die **Central-DB** (`db`) enthält accountübergreifende Daten: User, Account, AccountUser, Token
- Die **Tenant-DB** enthält accountspezifische Daten: Client, Project, Service, TimeEntry
@@ -131,6 +131,9 @@ Technisch ähnlich wie **mite.de** – wir orientieren uns stark am UI/UX von mi

### Reports
- `GET /reports/times` → `report_times`
- `GET /reports/export/excel` → `report_export_excel` (Excel-Download mit aktuellen Filtern)
- `GET /reports/export/csv` → `report_export_csv` (CSV-Download mit aktuellen Filtern)
- `GET /reports/export/pdf` → `report_export_pdf` (PDF-Download mit aktuellen Filtern)

### Team (nur Admins)
- `GET /team` → `team_index`
@@ -163,6 +166,7 @@ Technisch ähnlich wie **mite.de** – wir orientieren uns stark am UI/UX von mi
- `BrandColorService` – leitet aus einem Hex-Farbwert (`primaryColor`) ein komplettes 6-Farben-Palette-Array via HSL-Offsets ab; wird in `AppExtension` und `AccountController` genutzt
- `AppExtension` – Twig-Funktionen: `deMonths()`, `deMonthsShort()`, `deWeekdays()`, `deWeekdaysShort()`, `isCurrentUserAdmin()`, `isCurrentUserMemberOrAdmin()`, `getCurrentUserRole()`, **`brandPalette()`** (gibt das berechnete Farbpaletten-Array zurück, oder `null` wenn Standardfarbe)
- `AppExtensionRuntime` – Runtime-Teil der Twig-Extension
- `ReportExportService` – generiert Excel (PhpSpreadsheet), CSV und PDF (Dompdf) Exporte; nutzt Translations für alle Labels
- `AccessDeniedHandler` – leitet bei 403 auf Login um

---
@@ -360,7 +364,7 @@ Migration ausführen: `ddev exec php bin/console doctrine:migrations:migrate --e

## Was noch fehlt / TODO
- [ ] Filter auf Report-Seite (Datumsbereich, Projekt, Service, User)
- [ ] Export (CSV / PDF)
- [x] Export (Excel / CSV / PDF) – `ReportExportService`, Icons in Toolbar
- [ ] Timer-Funktion (Live-Zeiterfassung)
- [ ] Wochenübersicht mit Summen pro Tag (im Wochenkalender)
- [ ] E-Mail-Konfiguration für Produktivbetrieb (aktuell DDEV Mailpit)
@@ -379,10 +383,10 @@ bash 2-update-tenant-db.sh
---

## DDEV-Konfiguration
- Projekt: `testtimetracking`
- Hauptdomain: `https://testtimetracking.ddev.site:8456`
- Tenant-Subdomain Beispiel: `https://spawntree.testtimetracking.ddev.site:8456`
- PHPMyAdmin: `https://testtimetracking.ddev.site:8037`
- Projekt: `timetracking`
- Hauptdomain: `https://timetracking.ddev.site:8459`
- Tenant-Subdomain Beispiel: `https://spawntree.timetracking.ddev.site:8459`
- PHPMyAdmin: `https://timetracking.ddev.site:8037`
- MariaDB: User `db`, Passwort `db`, Central-DB `db`
- `.env`: `DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=10.11.2-MariaDB&charset=utf8mb4"`
- `APP_DOMAIN=testtimetracking.ddev.site:8456` (für Subdomain-Erkennung und E-Mail-Links)
- `APP_DOMAIN=timetracking.ddev.site:8459` (für Subdomain-Erkennung und E-Mail-Links)

+ 187
- 171
httpdocs/assets/scripts/account.js Wyświetl plik

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

+ 17
- 19
httpdocs/assets/scripts/calendar.js Wyświetl plik

@@ -1,9 +1,8 @@
// assets/scripts/calendar.js
// Strings aus window.TT.i18n – keine hardcodierten deutschen Texte mehr

function t(key) {
return window.TT?.i18n?.[key] ?? key;
}
import { esc, createTranslator, FADE_MS } from './utils.js';
const t = createTranslator('TT');

class WeekCalendar {
constructor() {
@@ -19,9 +18,9 @@ class WeekCalendar {
this.today = new Date();
this.today.setHours(0, 0, 0, 0);

this.monthOpen = false;
this.monthDate = new Date(this.activeDate);
this.monthEl = null;
this.monthOpen = false;
this.monthDate = new Date(this.activeDate);
this.monthEl = null;

if (!this.nav) return;
this.init();
@@ -69,7 +68,7 @@ class WeekCalendar {

window.history.pushState({}, '', `/week/${this.formatDate(this.getMonday(this.activeDate))}`);
window.entryManager?.loadEntriesForDate(this.formatDate(this.activeDate));
}, 180);
}, FADE_MS);
}

renderWeekDays() {
@@ -77,12 +76,11 @@ class WeekCalendar {
this.daysContainer.innerHTML = '';

for (let i = 0; i < 7; i++) {
const d = new Date(monday);
const d = new Date(monday);
d.setDate(d.getDate() + i);
const isActive = this.isSameDay(d, this.activeDate);
const isToday = this.isSameDay(d, this.today);

// Führungsnull: padStart(2, '0')
const dayNum = String(d.getDate()).padStart(2, '0');
const monthShort = this.monthsShort[d.getMonth()] ?? '';

@@ -93,8 +91,8 @@ class WeekCalendar {
+ (isToday ? ' week-nav__day--today' : '');
a.dataset.date = this.formatDate(d);
a.innerHTML = `
<span class="week-nav__day-name">${this.weekdaysShort[i] ?? ''}</span>
<span class="week-nav__day-date">${dayNum}. ${monthShort}</span>
<span class="week-nav__day-name">${esc(this.weekdaysShort[i] ?? '')}</span>
<span class="week-nav__day-date">${dayNum}. ${esc(monthShort)}</span>
`;
this.daysContainer.appendChild(a);
}
@@ -120,9 +118,8 @@ class WeekCalendar {
const tomorrow = new Date(this.today); tomorrow.setDate(this.today.getDate() + 1);
const yesterday = new Date(this.today); yesterday.setDate(this.today.getDate() - 1);

// JS getDay(): 0=So, 1=Mo...6=Sa → weekdays[0]=Montag, also index = getDay()-1, So=6
const jsDay = d.getDay();
const isoIdx = jsDay === 0 ? 6 : jsDay - 1;
const jsDay = d.getDay();
const isoIdx = jsDay === 0 ? 6 : jsDay - 1;
const weekday = this.weekdays[isoIdx] ?? '';

let prefix;
@@ -149,7 +146,6 @@ class WeekCalendar {
this.monthEl = document.createElement('div');
this.monthEl.className = 'month-calendar month-calendar--hidden';

// Align calendar's right edge with the calendar icon button's right edge
const calRect = this.calBtn.getBoundingClientRect();
const headerRect = this.header.getBoundingClientRect();
this.monthEl.style.right = `${Math.max(0, headerRect.right - calRect.right)}px`;
@@ -167,7 +163,9 @@ class WeekCalendar {
if (!this.monthEl) return;
this.monthEl.classList.remove('month-calendar--visible');
this.monthEl.classList.add('month-calendar--hidden');
setTimeout(() => { this.monthEl?.remove(); this.monthEl = null; }, 280);
const el = this.monthEl;
setTimeout(() => el.remove(), FADE_MS + 100);
this.monthEl = null;
this.monthOpen = false;
this.calBtn.classList.remove('week-nav__cal--active');
}
@@ -189,7 +187,7 @@ class WeekCalendar {
<button class="month-calendar__arrow month-nav-prev" title="${t('prevMonth')}">
<svg viewBox="0 0 8 14" fill="none"><path d="M7 1L1 7L7 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<span class="month-calendar__title">${this.months[month] ?? ''} ${year}</span>
<span class="month-calendar__title">${esc(this.months[month] ?? '')} ${year}</span>
<button class="month-calendar__arrow month-nav-next" title="${t('nextMonth')}">
<svg viewBox="0 0 8 14" fill="none"><path d="M1 1L7 7L1 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
@@ -206,7 +204,7 @@ class WeekCalendar {
</div>
<div class="month-calendar__grid">
<div class="month-calendar__weekdays">
${this.weekdaysShort.map(d => `<span>${d}</span>`).join('')}
${this.weekdaysShort.map(d => `<span>${esc(d)}</span>`).join('')}
</div>
<div class="month-calendar__days">`;



+ 90
- 112
httpdocs/assets/scripts/crud.js Wyświetl plik

@@ -1,29 +1,31 @@
// assets/scripts/crud.js
// Generisches CRUD-Handler für Kunden, Projekte, Leistungen

import { esc, createTranslator, ANIMATION_MS, removeWithAnimation, animateIn } from './utils.js';

const api = window.CRUD?.apiBase ?? '';

// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
const t = createTranslator('CRUD');

// ── Hilfsfunktionen ──────────────────────────────────────────────────────────

function buildClientOptions(selectedId = null) {
const clients = window.CRUD?.clients ?? [];
let html = '<option value="">Bitte wählen</option>';
let html = `<option value="">${t('selectPh')}</option>`;
clients.forEach(c => {
const sel = String(c.id) === String(selectedId) ? ' selected' : '';
html += `<option value="${c.id}"${sel}>${c.name}</option>`;
html += `<option value="${c.id}"${sel}>${esc(c.name)}</option>`;
});
return html;
}

function rowPrefix() {
// Ermittelt den Entitätstyp aus der URL
if (location.pathname.includes('/clients')) return 'client';
if (location.pathname.includes('/projects')) return 'project';
if (location.pathname.includes('/services')) return 'service';
return 'row';
}

// ── Create-Formular ──────────────────────────────────────────────────────────
// ── Create-Formular ──────────────────────────────────────────────────────────

function initCreateForm() {
const btnNew = document.getElementById('btn-new');
@@ -47,8 +49,7 @@ function initCreateForm() {
}

function resetCreateForm() {
const fields = ['create-name', 'create-note'];
fields.forEach(id => {
['create-name', 'create-note'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
@@ -62,10 +63,12 @@ function resetCreateForm() {

async function createEntity() {
const name = document.getElementById('create-name')?.value?.trim();
if (!name) { alert('Bitte einen Namen eingeben.'); return; }
if (!name) { alert(t('errorNoName')); return; }

const btn = document.getElementById('btn-create-save');
const body = buildCreateBody();

if (btn) btn.disabled = true;
try {
const res = await fetch(api, {
method: 'POST',
@@ -75,7 +78,7 @@ async function createEntity() {

if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err.error ?? 'Fehler beim Speichern.');
alert(err.error ?? t('errorSave'));
return;
}

@@ -83,10 +86,10 @@ async function createEntity() {
appendRowToList(data);
document.getElementById('crud-create')?.classList.remove('crud-create--visible');
resetCreateForm();
} catch (err) {
console.error(err);
alert('Fehler beim Speichern.');
} catch {
alert(t('errorSave'));
} finally {
if (btn) btn.disabled = false;
}
}

@@ -96,22 +99,19 @@ function buildCreateBody() {
note: document.getElementById('create-note')?.value || null,
};

// Kunden-spezifisch
const rate = document.getElementById('create-rate');
if (rate) body.hourlyRate = rate.value || null;

// Projekt-spezifisch
const client = document.getElementById('create-client');
if (client) body.clientId = parseInt(client.value) || null;
if (client) body.clientId = parseInt(client.value, 10) || null;

// Leistungs-spezifisch
const billable = document.getElementById('create-billable');
if (billable) body.billable = billable.checked;

return body;
}

// ── Liste: Event Delegation ────────────────────────────────────────────────────
// ── Liste: Event Delegation ──────────────────────────────────────────────────

function initList() {
const list = document.getElementById('crud-list');
@@ -121,21 +121,20 @@ function initList() {
const actionEl = e.target.closest('[data-action]');
if (!actionEl) return;

const action = actionEl.dataset.action;
const row = e.target.closest('.crud-row');
const row = e.target.closest('.crud-row');
if (!row) return;

switch (action) {
case 'edit': openEdit(row); break;
case 'delete': deleteRow(row); break;
case 'save': saveEdit(row); break;
case 'cancel': closeEdit(row); break;
case 'unarchive': unarchiveRow(row); break;
switch (actionEl.dataset.action) {
case 'edit': openEdit(row); break;
case 'delete': deleteRow(row); break;
case 'save': saveEdit(row); break;
case 'cancel': closeEdit(row); break;
case 'unarchive': unarchiveRow(row); break;
}
});
}

// ── Inline Edit ──────────────────────────────────────────────────────────────
// ── Inline Edit ──────────────────────────────────────────────────────────────

function openEdit(row) {
row.querySelector('.crud-row__display').hidden = true;
@@ -149,29 +148,31 @@ function closeEdit(row) {
}

async function saveEdit(row) {
const id = row.dataset.id;
const name = row.querySelector('.edit-name')?.value?.trim();
const saveBtn = row.querySelector('[data-action="save"]');
if (saveBtn?.disabled) return;

if (!name) { alert('Bitte einen Namen eingeben.'); return; }
const name = row.querySelector('.edit-name')?.value?.trim();
if (!name) { alert(t('errorNoName')); return; }

const body = buildEditBody(row);

if (saveBtn) saveBtn.disabled = true;
try {
const res = await fetch(`${api}/${id}`, {
const res = await fetch(`${api}/${row.dataset.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});

if (!res.ok) { alert('Fehler beim Speichern.'); return; }
if (!res.ok) { alert(t('errorSave')); return; }

const data = await res.json();
updateRowDisplay(row, data);
closeEdit(row);
} catch (err) {
console.error(err);
alert('Fehler beim Speichern.');
} catch {
alert(t('errorSave'));
} finally {
if (saveBtn) saveBtn.disabled = false;
}
}

@@ -181,15 +182,12 @@ function buildEditBody(row) {
note: row.querySelector('.edit-note')?.value || null,
};

// Kunden
const rate = row.querySelector('.edit-rate');
if (rate) body.hourlyRate = rate.value || null;

// Projekt
const client = row.querySelector('.edit-client');
if (client) body.clientId = parseInt(client.value) || null;
if (client) body.clientId = parseInt(client.value, 10) || null;

// Leistung
const billable = row.querySelector('.edit-billable');
if (billable) body.billable = billable.checked;

@@ -201,19 +199,14 @@ function updateRowDisplay(row, data) {
const metaEl = row.querySelector('.crud-row__meta');

if (nameEl) nameEl.textContent = data.name;

// Kunden: Meta-Text unverändert (Projektanzahl ändert sich nicht)
// Projekte: Client-Name aktualisieren
if (data.clientName && metaEl) metaEl.textContent = data.clientName;

// data-Attribute aktualisieren
row.dataset.name = data.name;
if (data.hourlyRate !== undefined) row.dataset.rate = data.hourlyRate ?? '';
if (data.hourlyRate !== undefined) row.dataset.rate = data.hourlyRate ?? '';
if (data.clientId !== undefined) row.dataset.clientId = data.clientId;
if (data.billable !== undefined) row.dataset.billable = data.billable ? '1' : '0';
if (data.note !== undefined) row.dataset.note = data.note ?? '';
if (data.note !== undefined) row.dataset.note = data.note ?? '';

// Edit-Felder aktualisieren
const editName = row.querySelector('.edit-name');
if (editName) editName.value = data.name;

@@ -227,58 +220,54 @@ function updateRowDisplay(row, data) {
if (editBillable) editBillable.checked = !!data.billable;
}

// ── Delete ───────────────────────────────────────────────────────────────────
// ── Delete ───────────────────────────────────────────────────────────────────

async function deleteRow(row) {
if (!confirm('Wirklich löschen?')) return;
if (!confirm(t('confirmDelete'))) return;

try {
const res = await fetch(`${api}/${row.dataset.id}`, { method: 'DELETE' });

if (res.status === 409) {
if (confirm('Dieser Eintrag hat abhängige Zeiteinträge und kann nicht gelöscht werden.\nStattdessen archivieren?')) {
if (confirm(t('confirmArchive'))) {
await archiveRow(row);
}
return;
}

if (!res.ok) { alert('Fehler beim Löschen.'); return; }

row.classList.add('crud-row--removing');
setTimeout(() => row.remove(), 280);
if (!res.ok) { alert(t('errorDelete')); return; }

removeWithAnimation(row, 'crud-row--removing');
} catch {
alert('Fehler beim Löschen.');
alert(t('errorDelete'));
}
}

async function archiveRow(row) {
try {
const res = await fetch(`${api}/${row.dataset.id}/archive`, { method: 'PATCH' });
if (!res.ok) { alert('Fehler beim Archivieren.'); return; }
if (!res.ok) { alert(t('errorArchive')); return; }

row.dataset.archived = '1';
row.classList.add('crud-row--archived');
updateRowArchivedState(row, true);
filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active');

} catch {
alert('Fehler beim Archivieren.');
alert(t('errorArchive'));
}
}

async function unarchiveRow(row) {
try {
const res = await fetch(`${api}/${row.dataset.id}/unarchive`, { method: 'PATCH' });
if (!res.ok) { alert('Fehler beim Wiederherstellen.'); return; }
if (!res.ok) { alert(t('errorRestore')); return; }

row.dataset.archived = '0';
row.classList.remove('crud-row--archived');
updateRowArchivedState(row, false);
filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active');

} catch {
alert('Fehler beim Wiederherstellen.');
alert(t('errorRestore'));
}
}

@@ -288,16 +277,16 @@ function updateRowArchivedState(row, archived) {

if (archived) {
actions.innerHTML = `
<button class="crud-row__btn crud-row__btn--restore" data-action="unarchive" title="Wiederherstellen">
<button class="crud-row__btn crud-row__btn--restore" data-action="unarchive" title="${t('btnRestore')}">
<svg viewBox="0 0 16 16" fill="none"><path d="M2 8a6 6 0 1 1 1.5 3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M2 13V9h4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>`;
row.querySelector('.crud-row__edit')?.remove();
} else {
actions.innerHTML = `
<button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="Bearbeiten">
<button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="${t('btnEdit')}">
<svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="Löschen">
<button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="${t('btnDelete')}">
<svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>`;
}
@@ -306,8 +295,8 @@ function updateRowArchivedState(row, archived) {
function filterByTab(tab) {
document.querySelectorAll('#crud-list .crud-row').forEach(row => {
row.hidden = tab === 'active'
? row.dataset.archived === '1'
: row.dataset.archived === '0';
? row.dataset.archived === '1'
: row.dataset.archived === '0';
});
}

@@ -329,7 +318,7 @@ function initTabs() {
});
}

// ── Neue Zeile einfügen ──────────────────────────────────────────────────────
// ── Neue Zeile einfügen ──────────────────────────────────────────────────────

function appendRowToList(data) {
const list = document.getElementById('crud-list');
@@ -337,9 +326,8 @@ function appendRowToList(data) {

const html = buildRowHTML(data);

// Services haben Gruppen → in die richtige Gruppe einfügen
if (data.billable !== undefined) {
const groupLabel = data.billable ? 'Verrechenbar' : 'Nicht-verrechenbar';
const groupLabel = data.billable ? t('groupBillable') : t('groupNotBillable');
let targetGroup = null;

list.querySelectorAll('.crud-list__group').forEach(g => {
@@ -351,17 +339,14 @@ function appendRowToList(data) {
if (targetGroup) {
targetGroup.insertAdjacentHTML('beforeend', html);
} else {
// Gruppe existiert noch nicht → neu anlegen
const groupHtml = `<div class="crud-list__group"><div class="crud-list__group-label">${groupLabel}</div>${html}</div>`;
const groupHtml = `<div class="crud-list__group"><div class="crud-list__group-label">${esc(groupLabel)}</div>${html}</div>`;
if (!data.billable) {
// Nicht-verrechenbar immer ans Ende
list.insertAdjacentHTML('beforeend', groupHtml);
} else {
// Verrechenbar vor die erste existierende Gruppe
const firstGroup = list.querySelector('.crud-list__group');
firstGroup
? firstGroup.insertAdjacentHTML('beforebegin', groupHtml)
: list.insertAdjacentHTML('beforeend', groupHtml);
? firstGroup.insertAdjacentHTML('beforebegin', groupHtml)
: list.insertAdjacentHTML('beforeend', groupHtml);
}
}
} else {
@@ -370,11 +355,7 @@ function appendRowToList(data) {

const prefix = rowPrefix();
const el = document.getElementById(`${prefix}-${data.id}`);
if (el) {
requestAnimationFrame(() => requestAnimationFrame(() => {
el.classList.remove('crud-row--new');
}));
}
if (el) animateIn(el, 'crud-row--new');
}

function buildRowHTML(data) {
@@ -382,48 +363,45 @@ function buildRowHTML(data) {
let metaHtml = '';
let editFields = '';

// Kunden
if (data.projectCount !== undefined) {
const c = data.projectCount;
metaHtml = `<span class="crud-row__meta">${c} ${c === 1 ? 'Projekt' : 'Projekte'}</span>`;
metaHtml = `<span class="crud-row__meta">${c} ${c === 1 ? t('projectSingular') : t('projectPlural')}</span>`;
editFields = `
<label class="entry-form__label">Name</label>
<div class="entry-form__field"><input type="text" class="input edit-name" value="${data.name}" /></div>
<label class="entry-form__label">Stundensatz</label>
<label class="entry-form__label">${t('labelName')}</label>
<div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div>
<label class="entry-form__label">${t('labelRate')}</label>
<div class="entry-form__field" style="gap:8px">
<input type="number" class="input edit-rate" style="width:100px" value="${data.hourlyRate ?? ''}" step="0.01" min="0" />
<input type="number" class="input edit-rate" style="width:100px" value="${esc(data.hourlyRate ?? '')}" step="0.01" min="0" />
<span style="color:#7a8a9a;font-size:0.875rem">€</span>
</div>
<label class="entry-form__label">Bemerkung</label>
<div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${data.note ?? ''}</textarea></div>`;
<label class="entry-form__label">${t('labelNote')}</label>
<div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`;
}

// Projekte
if (data.clientName !== undefined && data.projectCount === undefined) {
metaHtml = `<span class="crud-row__meta">${data.clientName}</span>`;
metaHtml = `<span class="crud-row__meta">${esc(data.clientName)}</span>`;
editFields = `
<label class="entry-form__label">Name</label>
<div class="entry-form__field"><input type="text" class="input edit-name" value="${data.name}" /></div>
<label class="entry-form__label">Kunde</label>
<label class="entry-form__label">${t('labelName')}</label>
<div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div>
<label class="entry-form__label">${t('labelClient')}</label>
<div class="entry-form__field"><select class="select edit-client">${buildClientOptions(data.clientId)}</select></div>
<label class="entry-form__label">Bemerkung</label>
<div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${data.note ?? ''}</textarea></div>`;
<label class="entry-form__label">${t('labelNote')}</label>
<div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`;
}

// Leistungen
if (data.billable !== undefined) {
editFields = `
<label class="entry-form__label">Name</label>
<div class="entry-form__field"><input type="text" class="input edit-name" value="${data.name}" /></div>
<label class="entry-form__label">Verrechenbar</label>
<label class="entry-form__label">${t('labelName')}</label>
<div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div>
<label class="entry-form__label">${t('labelBillable')}</label>
<div class="entry-form__field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" class="edit-billable" ${data.billable ? 'checked' : ''} />
<span style="font-size:0.875rem">Ja, diese Leistung ist verrechenbar</span>
<span style="font-size:0.875rem">${t('billableLabel')}</span>
</label>
</div>
<label class="entry-form__label">Bemerkung</label>
<div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${data.note ?? ''}</textarea></div>`;
<label class="entry-form__label">${t('labelNote')}</label>
<div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`;
}

return `
@@ -431,22 +409,22 @@ function buildRowHTML(data) {
id="${prefix}-${data.id}"
data-id="${data.id}"
data-archived="0"
data-name="${data.name}"
${data.hourlyRate !== undefined ? `data-rate="${data.hourlyRate ?? ''}"` : ''}
data-name="${esc(data.name)}"
${data.hourlyRate !== undefined ? `data-rate="${esc(data.hourlyRate ?? '')}"` : ''}
${data.clientId !== undefined ? `data-client-id="${data.clientId}"` : ''}
${data.billable !== undefined ? `data-billable="${data.billable ? '1' : '0'}"` : ''}
data-note="${data.note ?? ''}">
data-note="${esc(data.note ?? '')}">

<div class="crud-row__display">
<div class="crud-row__info">
<span class="crud-row__name">${data.name}</span>
<span class="crud-row__name">${esc(data.name)}</span>
${metaHtml}
</div>
<div class="crud-row__actions">
<button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="Bearbeiten">
<button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="${t('btnEdit')}">
<svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="Löschen">
<button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="${t('btnDelete')}">
<svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
@@ -456,15 +434,15 @@ function buildRowHTML(data) {
<div class="entry-form__grid entry-form__grid--inline">
${editFields}
<div class="entry-form__actions">
<button type="button" class="btn btn-primary" data-action="save">Sichern</button>
<button type="button" class="btn btn-secondary" data-action="cancel">Abbrechen</button>
<button type="button" class="btn btn-primary" data-action="save">${t('btnSave')}</button>
<button type="button" class="btn btn-secondary" data-action="cancel">${t('btnCancel')}</button>
</div>
</div>
</div>
</div>`;
}

// ── Init ─────────────────────────────────────────────────────────────────────
// ── Init ─────────────────────────────────────────────────────────────────────

document.addEventListener('DOMContentLoaded', () => {
initCreateForm();


+ 28
- 49
httpdocs/assets/scripts/duration.js Wyświetl plik

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

+ 93
- 123
httpdocs/assets/scripts/entries.js Wyświetl plik

@@ -1,14 +1,17 @@
// assets/scripts/entries.js
import { parseDuration, roundToQuarter, formatMinutes, initDurationBlurHandler, validateDuration } from './duration.js';

import { parseAndValidate, initDurationBlurHandler } from './duration.js';
import { esc, createTranslator, ANIMATION_MS, FADE_MS, MINUTES_PER_DAY, removeWithAnimation, animateIn } from './utils.js';

const LAST_PROJECT_KEY = 'tt_last_project_id';
const LAST_SERVICE_KEY = 'tt_last_service_id';
const NOTE_KEY = 'tt_minimal_note_open';

const LOCK_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="3" y="7.5" width="10" height="7" rx="1.5" stroke="currentColor" stroke-width="1.3"/><path d="M5.5 7.5V5.5a2.5 2.5 0 015 0v2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`;

function t(key) {
return window.TT?.i18n?.[key] ?? key;
}
const t = createTranslator('TT');
// ── Select-Builder ───────────────────────────────────────────────────────────

function buildProjectOptions(selectedId = null) {
const groups = {};
@@ -19,10 +22,10 @@ function buildProjectOptions(selectedId = null) {

let html = `<option value="">${t('selectPh')}</option>`;
for (const [client, projects] of Object.entries(groups)) {
html += `<optgroup label="${client}">`;
html += `<optgroup label="${esc(client)}">`;
projects.forEach(p => {
const sel = String(p.id) === String(selectedId) ? ' selected' : '';
html += `<option value="${p.id}"${sel}>${p.name}</option>`;
html += `<option value="${p.id}"${sel}>${esc(p.name)}</option>`;
});
html += '</optgroup>';
}
@@ -35,36 +38,33 @@ function buildServiceOptions(selectedId = null) {

let html = `<option value="">${t('selectPh')}</option>`;

if (billable.length) {
html += `<optgroup label="${t('billable')}">`;
billable.forEach(s => {
const addGroup = (label, list) => {
if (!list.length) return;
html += `<optgroup label="${esc(label)}">`;
list.forEach(s => {
const sel = String(s.id) === String(selectedId) ? ' selected' : '';
html += `<option value="${s.id}"${sel}>${s.name}</option>`;
html += `<option value="${s.id}"${sel}>${esc(s.name)}</option>`;
});
html += '</optgroup>';
}
};

if (notBillable.length) {
html += `<optgroup label="${t('notBillable')}">`;
notBillable.forEach(s => {
const sel = String(s.id) === String(selectedId) ? ' selected' : '';
html += `<option value="${s.id}"${sel}>${s.name}</option>`;
});
html += '</optgroup>';
}
addGroup(t('billable'), billable);
addGroup(t('notBillable'), notBillable);

return html;
}

// ── Row HTML ─────────────────────────────────────────────────────────────────

function buildEntryRowHTML(entry, animate = false) {
const servicePart = entry.serviceName ? ` / ${entry.serviceName}` : '';
const notePart = entry.note ? `<div class="entry-row__note">${entry.note}</div>` : '';
const servicePart = entry.serviceName ? ` / ${esc(entry.serviceName)}` : '';
const notePart = entry.note ? `<div class="entry-row__note">${esc(entry.note)}</div>` : '';
const invoiced = !!entry.invoiced;

const actionsHtml = invoiced
? `<span class="entry-row__badge">${entry.durationFormatted}</span>
? `<span class="entry-row__badge">${esc(entry.durationFormatted)}</span>
<span class="entry-row__lock-indicator" title="${t('invoicedTitle')}">${LOCK_SVG}</span>`
: `<span class="entry-row__badge">${entry.durationFormatted}</span>
: `<span class="entry-row__badge">${esc(entry.durationFormatted)}</span>
<button class="entry-row__btn entry-row__btn--edit" title="${t('btnEdit')}" data-action="edit">
<svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
@@ -78,7 +78,7 @@ function buildEntryRowHTML(entry, animate = false) {
<label class="entry-form__label">${t('labelDuration')}</label>
<div class="entry-form__field">
<input type="text" class="input input--sm edit-duration"
value="${entry.durationFormatted}" autocomplete="off" />
value="${esc(entry.durationFormatted)}" autocomplete="off" />
<div class="duration-help">
<span class="duration-help__icon">?</span>
<span class="duration-help__hint">${t('durationHint')}</span>
@@ -91,7 +91,7 @@ function buildEntryRowHTML(entry, animate = false) {
</div>
<label class="entry-form__label">${t('labelNote')}</label>
<div class="entry-form__field">
<textarea class="textarea edit-note" rows="3">${entry.note ?? ''}</textarea>
<textarea class="textarea edit-note" rows="3">${esc(entry.note ?? '')}</textarea>
</div>
<div class="entry-form__actions">
<button type="button" class="btn btn-primary" data-action="save">${t('btnSave')}</button>
@@ -107,12 +107,12 @@ function buildEntryRowHTML(entry, animate = false) {
data-duration="${entry.duration}"
data-project-id="${entry.projectId}"
data-service-id="${entry.serviceId ?? ''}"
data-note="${(entry.note ?? '').replace(/"/g, '&quot;')}"
data-note="${esc(entry.note ?? '')}"
data-invoiced="${invoiced ? 'true' : 'false'}">

<div class="entry-row__display">
<div class="entry-row__info">
<div class="entry-row__title">${entry.clientName} / ${entry.projectName}${servicePart}</div>
<div class="entry-row__title">${esc(entry.clientName)} / ${esc(entry.projectName)}${servicePart}</div>
${notePart}
</div>
<div class="entry-row__actions">
@@ -123,6 +123,23 @@ function buildEntryRowHTML(entry, animate = false) {
</div>`;
}

// ── Helpers ──────────────────────────────────────────────────────────────────

function getDailyTotalMinutes() {
let total = 0;
document.querySelectorAll('#entry-items .entry-row').forEach(row => {
total += parseInt(row.dataset.duration, 10) || 0;
});
return total;
}

function saveLastProject(id) { if (id) localStorage.setItem(LAST_PROJECT_KEY, id); }
function getLastProject() { return localStorage.getItem(LAST_PROJECT_KEY); }
function saveLastService(id) { if (id) localStorage.setItem(LAST_SERVICE_KEY, id); }
function getLastService() { return localStorage.getItem(LAST_SERVICE_KEY); }

// ── EntryManager ─────────────────────────────────────────────────────────────

class EntryManager {
constructor() {
this.list = document.getElementById('entry-list');
@@ -133,13 +150,8 @@ class EntryManager {
const cp = document.getElementById('create-project');
const cs = document.getElementById('create-service');

document.getElementById('create-service')?.addEventListener('change', e => {
saveLastService(e.target.value);
});

document.getElementById('create-project')?.addEventListener('change', e => {
saveLastProject(e.target.value);
});
cs?.addEventListener('change', e => saveLastService(e.target.value));
cp?.addEventListener('change', e => saveLastProject(e.target.value));

if (cp) {
const lastProject = getLastProject();
@@ -182,13 +194,13 @@ class EntryManager {
return;
}

// Klick auf Anzeige-Bereich (kein Button) → Edit öffnen
if (e.target.closest('.entry-row__display') && row.dataset.invoiced !== 'true') {
this.openEdit(row);
}
}

async createEntry() {
const btn = document.getElementById('btn-create');
const durationRaw = document.getElementById('create-duration')?.value ?? '0:00';
const projectId = document.getElementById('create-project')?.value;
const serviceId = document.getElementById('create-service')?.value;
@@ -196,36 +208,27 @@ class EntryManager {

if (!projectId) { alert(t('errorNoProject')); return; }

const duration = formatMinutes(roundToQuarter(parseDuration(durationRaw)));

if (duration === '0:00') {
alert(t('errorZeroDuration'));
return;
}

const rawMinutes = roundToQuarter(parseDuration(durationRaw));
const validation = validateDuration(rawMinutes);
if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; }
if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return;

if (getDailyTotalMinutes() + rawMinutes > 1440) { alert(t('errorDailyLimitExceeded')); return; }
const dur = parseAndValidate(durationRaw);
if (dur.error) { alert(t(dur.error)); return; }
if (dur.warn && !confirm(t(dur.warn))) return;
if (getDailyTotalMinutes() + dur.minutes > MINUTES_PER_DAY) { alert(t('errorDailyLimitExceeded')); return; }

if (btn) btn.disabled = true;
try {
const res = await fetch('/api/entries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
date: window.TT.activeDate,
duration,
projectId: parseInt(projectId),
serviceId: serviceId ? parseInt(serviceId) : null,
duration: dur.formatted,
projectId: parseInt(projectId, 10),
serviceId: serviceId ? parseInt(serviceId, 10) : null,
note: note || null,
}),
});

if (!res.ok) {
const err = await res.json().catch(() => ({}));
console.error('API Fehler:', res.status, err);
alert(t('errorSave') + (err.error ? `\n${err.error}` : ''));
return;
}
@@ -234,10 +237,10 @@ class EntryManager {
this.addEntryToDOM(data.entry);
this.updateTotal(data.totalDuration);
this.resetCreateForm();

} catch (err) {
console.error('Netzwerkfehler:', err);
} catch {
alert(t('errorSave'));
} finally {
if (btn) btn.disabled = false;
}
}

@@ -255,9 +258,7 @@ class EntryManager {
items.insertAdjacentHTML('beforeend', buildEntryRowHTML(entry, true));

const el = document.getElementById(`entry-${entry.id}`);
requestAnimationFrame(() => requestAnimationFrame(() => {
el?.classList.remove('entry-row--new');
}));
if (el) animateIn(el, 'entry-row--new');
}

resetCreateForm() {
@@ -272,9 +273,7 @@ class EntryManager {
}

openEdit(row) {
// Safety-Guard: invoiced-Einträge können nicht geöffnet werden
if (row.dataset.invoiced === 'true') return;
// Kein Edit-Formular vorhanden → nicht öffnen
const editSection = row.querySelector('.entry-row__edit');
if (!editSection) return;

@@ -303,6 +302,9 @@ class EntryManager {
}

async saveEdit(row) {
const saveBtn = row.querySelector('[data-action="save"]');
if (saveBtn?.disabled) return;

const id = row.dataset.id;
const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00';
const projectId = row.querySelector('.edit-project')?.value;
@@ -311,35 +313,30 @@ class EntryManager {

if (!projectId) { alert(t('errorNoProject')); return; }

const duration = formatMinutes(roundToQuarter(parseDuration(durationRaw)));
const dur = parseAndValidate(durationRaw);
if (dur.error) { alert(t(dur.error)); return; }
if (dur.warn && !confirm(t(dur.warn))) return;

if (duration === '0:00') {
alert(t('errorZeroDuration'));
const currentMinutes = parseInt(row.dataset.duration, 10) || 0;
if (getDailyTotalMinutes() - currentMinutes + dur.minutes > MINUTES_PER_DAY) {
alert(t('errorDailyLimitExceeded'));
return;
}

const rawMinutes = roundToQuarter(parseDuration(durationRaw));
const validation = validateDuration(rawMinutes);
if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; }
if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return;

const currentEntryMinutes = parseInt(row.dataset.duration) || 0;
if (getDailyTotalMinutes() - currentEntryMinutes + rawMinutes > 1440) { alert(t('errorDailyLimitExceeded')); return; }

if (saveBtn) saveBtn.disabled = true;
try {
const res = await fetch(`/api/entries/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
duration,
projectId: parseInt(projectId),
serviceId: serviceId ? parseInt(serviceId) : null,
duration: dur.formatted,
projectId: parseInt(projectId, 10),
serviceId: serviceId ? parseInt(serviceId, 10) : null,
note: note || null,
}),
});

if (!res.ok) {
console.error('PATCH fehlgeschlagen:', res.status);
alert(t('errorSave'));
return;
}
@@ -348,10 +345,10 @@ class EntryManager {
this.updateRowDisplay(row, data.entry);
this.updateTotal(data.totalDuration);
this.closeEdit(row);

} catch (err) {
console.error('saveEdit Fehler:', err);
} catch {
alert(t('errorSave'));
} finally {
if (saveBtn) saveBtn.disabled = false;
}
}

@@ -388,14 +385,14 @@ class EntryManager {
if (!res.ok) { alert(t('errorDelete')); return; }

const data = await res.json();
row.classList.add('entry-row--removing');
removeWithAnimation(row, 'entry-row--removing');
setTimeout(() => {
row.remove();
this.updateTotal(data.totalDuration);
this.checkIfEmpty();
}, 280);

} catch { alert(t('errorDelete')); }
}, ANIMATION_MS);
} catch {
alert(t('errorDelete'));
}
}

async loadEntriesForDate(dateStr) {
@@ -403,9 +400,9 @@ class EntryManager {

try {
this.list.classList.add('entry-list--fading');
await new Promise(r => setTimeout(r, 180));
await new Promise(r => setTimeout(r, FADE_MS));

const res = await fetch(`/api/entries?date=${dateStr}`);
const res = await fetch(`/api/entries?date=${dateStr}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();

@@ -428,7 +425,7 @@ class EntryManager {
let html = '<div class="entry-list__items" id="entry-items">';
entries.forEach(e => { html += buildEntryRowHTML(e, false); });
html += `</div><div class="entry-list__footer" id="entry-footer">
<span class="entry-list__total">${totalDuration}</span></div>`;
<span class="entry-list__total">${esc(totalDuration)}</span></div>`;

this.list.innerHTML = html;
this.emptyState = null;
@@ -449,7 +446,7 @@ class EntryManager {
footer.id = 'entry-footer';
this.list.appendChild(footer);
}
footer.innerHTML = `<span class="entry-list__total">${totalDuration}</span>`;
footer.innerHTML = `<span class="entry-list__total">${esc(totalDuration)}</span>`;
}

hideEmptyState() { this.emptyState?.remove(); this.emptyState = null; }
@@ -466,33 +463,7 @@ class EntryManager {
}
}

function getDailyTotalMinutes() {
let total = 0;
document.querySelectorAll('#entry-items .entry-row').forEach(row => {
total += parseInt(row.dataset.duration) || 0;
});
return total;
}

function saveLastProject(projectId) {
if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId);
}

function getLastProject() {
return localStorage.getItem(LAST_PROJECT_KEY);
}

function saveLastService(serviceId) {
if (serviceId) localStorage.setItem(LAST_SERVICE_KEY, serviceId);
}

function getLastService() {
return localStorage.getItem(LAST_SERVICE_KEY);
}

// ── Minimal-Modus-Initialisierung ─────────────────────────────────────────────

const NOTE_KEY = 'tt_minimal_note_open';
// ── Minimal-Modus ────────────────────────────────────────────────────────────

function initMinimalMode() {
if (document.body.dataset.theme !== 'minimal') return;
@@ -527,9 +498,9 @@ function initWeekToggle() {
}

function initNoteToggle() {
const btn = document.getElementById('btn-note-toggle');
const label = document.querySelector('.entry-form__label--note');
const field = document.querySelector('.entry-form__field--note');
const btn = document.getElementById('btn-note-toggle');
const label = document.querySelector('.entry-form__label--note');
const field = document.querySelector('.entry-form__field--note');
if (!btn) return;

const open = localStorage.getItem(NOTE_KEY) === '1';
@@ -539,7 +510,7 @@ function initNoteToggle() {
const nowOpen = label?.classList.toggle('is-visible');
field?.classList.toggle('is-visible');
btn.classList.toggle('is-open', !!nowOpen);
btn.textContent = nowOpen ? '× Bemerkung ausblenden' : '+ Bemerkung hinzufügen';
btn.textContent = nowOpen ? t('noteHide') : t('noteShow');
localStorage.setItem(NOTE_KEY, nowOpen ? '1' : '0');
});
}
@@ -549,18 +520,17 @@ function setNoteVisible(open, btn, label, field) {
label?.classList.add('is-visible');
field?.classList.add('is-visible');
btn.classList.add('is-open');
btn.textContent = '× Bemerkung ausblenden';
btn.textContent = t('noteHide');
} else {
btn.textContent = '+ Bemerkung hinzufügen';
btn.textContent = t('noteShow');
}
}

function initEntriesToggle() {
const summaryBtn = document.getElementById('btn-entries-toggle');
const entryList = document.getElementById('entry-list');
const summaryBtn = document.getElementById('btn-entries-toggle');
const entryList = document.getElementById('entry-list');
if (!summaryBtn || !entryList) return;

// Immer eingeklappt beim Laden
entryList.classList.add('is-collapsed');
summaryBtn.setAttribute('aria-expanded', 'false');



+ 79
- 78
httpdocs/assets/scripts/registration.js Wyświetl plik

@@ -1,87 +1,88 @@
// assets/scripts/registration.js

import { esc, createTranslator } from './utils.js';

const t = createTranslator('Register');

document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('register-form');
const companyInput = document.getElementById('companyName');
const slugPreview = document.getElementById('slug-preview');
const submitBtn = document.getElementById('submit-btn');
const errorBox = document.getElementById('register-errors');
const appDomain = window.REGISTER_APP_DOMAIN ?? '';
const form = document.getElementById('register-form');
const companyInput = document.getElementById('companyName');
const slugPreview = document.getElementById('slug-preview');
const submitBtn = document.getElementById('submit-btn');
const errorBox = document.getElementById('register-errors');
const appDomain = window.Register?.appDomain ?? '';

// ── Slug-Vorschau ─────────────────────────────────────────────────────────

let debounceTimer = null;
companyInput?.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const value = companyInput.value.trim();
if (!value) { slugPreview.textContent = ''; return; }

// ── Slug-Vorschau ─────────────────────────────────────────────────────────
let debounce = null;
companyInput?.addEventListener('input', () => {
clearTimeout(debounce);
debounce = setTimeout(async () => {
const value = companyInput.value.trim();
if (!value) { slugPreview.textContent = ''; return; }
try {
const res = await fetch('/api/register/preview-slug', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ companyName: value }),
});
const data = await res.json();
slugPreview.textContent = data.slug ? data.slug + '.' + appDomain : '–';
} catch {
slugPreview.textContent = '';
}
}, 350);
});

try {
const res = await fetch('/api/register/preview-slug', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ companyName: value }),
});
const data = await res.json();
slugPreview.textContent = data.slug ? data.slug + '.' + appDomain : '–';
} catch {
slugPreview.textContent = '';
}
}, 350);
});
// ── Formular absenden ─────────────────────────────────────────────────────

// ── Formular absenden ─────────────────────────────────────────────────────
form?.addEventListener('submit', async (e) => {
e.preventDefault();
errorBox.innerHTML = '';
submitBtn.disabled = true;
submitBtn.textContent = 'Wird gesendet …';
form?.addEventListener('submit', async (e) => {
e.preventDefault();
errorBox.innerHTML = '';
submitBtn.disabled = true;
submitBtn.textContent = t('sending');

const payload = {
companyName: document.getElementById('companyName').value,
email: document.getElementById('email').value,
firstName: document.getElementById('firstName').value,
lastName: document.getElementById('lastName').value,
password: document.getElementById('password').value,
passwordRepeat: document.getElementById('passwordRepeat').value,
};
const payload = {
companyName: document.getElementById('companyName').value,
email: document.getElementById('email').value,
firstName: document.getElementById('firstName').value,
lastName: document.getElementById('lastName').value,
password: document.getElementById('password').value,
passwordRepeat: document.getElementById('passwordRepeat').value,
};

try {
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
try {
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();

if (res.ok) {
document.querySelector('.register-page').innerHTML = `
<div class="register-success">
<div class="register-success__icon">✓</div>
<h2 class="register-success__title">Fast geschafft!</h2>
<p class="register-success__text">
Wir haben eine Bestätigungs-E-Mail an
<strong>${payload.email}</strong> geschickt.
</p>
<p class="register-success__hint">
Bitte klicke auf den Link in der E-Mail um dein Konto zu aktivieren.
Der Link ist 24 Stunden gültig.
</p>
</div>
`;
} else {
(data.errors ?? ['Unbekannter Fehler.']).forEach(msg => {
const p = document.createElement('p');
p.textContent = msg;
errorBox.appendChild(p);
});
submitBtn.disabled = false;
submitBtn.textContent = 'Konto erstellen';
}
} catch {
errorBox.innerHTML = '<p>Verbindungsfehler. Bitte versuche es erneut.</p>';
submitBtn.disabled = false;
submitBtn.textContent = 'Konto erstellen';
}
});
});
if (res.ok) {
const text = t('successText').replace('%email%', `<strong>${esc(payload.email)}</strong>`);
document.querySelector('.register-page').innerHTML = `
<div class="register-success">
<div class="register-success__icon">✓</div>
<h2 class="register-success__title">${t('successTitle')}</h2>
<p class="register-success__text">${text}</p>
<p class="register-success__hint">${t('successHint')}</p>
</div>
`;
} else {
(data.errors ?? [t('errorUnknown')]).forEach(msg => {
const p = document.createElement('p');
p.textContent = msg;
errorBox.appendChild(p);
});
submitBtn.disabled = false;
submitBtn.textContent = t('btnSubmit');
}
} catch {
errorBox.innerHTML = `<p>${esc(t('errorConnection'))}</p>`;
submitBtn.disabled = false;
submitBtn.textContent = t('btnSubmit');
}
});
});

+ 347
- 387
httpdocs/assets/scripts/report.js Wyświetl plik

@@ -1,477 +1,437 @@
// assets/scripts/report.js

import {
parseDuration,
roundToQuarter,
formatMinutes,
validateDuration,
initDurationBlurHandler,
} from './duration.js';

// ── Hilfsfunktionen ───────────────────────────────────────────────────────────

function t(key) {
return window.Report?.i18n?.[key] ?? key;
}
import { parseAndValidate, initDurationBlurHandler } from './duration.js';
import { esc, createTranslator } from './utils.js';

const t = createTranslator('Report');

// ── Hilfsfunktionen ──────────────────────────────────────────────────────────

function populateProjectSelect(select, selectedId) {
const projects = window.Report?.projects ?? [];
select.innerHTML = '';
projects.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = `${p.clientName} / ${p.name}`;
if (p.id === selectedId) opt.selected = true;
select.appendChild(opt);
});
const projects = window.Report?.projects ?? [];
select.innerHTML = '';
projects.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = `${p.clientName} / ${p.name}`;
if (p.id === selectedId) opt.selected = true;
select.appendChild(opt);
});
}

function populateServiceSelect(select, selectedId) {
const services = window.Report?.services ?? [];
const billable = services.filter(s => s.billable);
const notBillable = services.filter(s => !s.billable);
select.innerHTML = `<option value="">${t('selectPh')}</option>`;
function addGroup(label, list) {
if (!list.length) return;
const group = document.createElement('optgroup');
group.label = label;
list.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.name;
if (s.id === selectedId) opt.selected = true;
group.appendChild(opt);
});
select.appendChild(group);
}
const services = window.Report?.services ?? [];
const billable = services.filter(s => s.billable);
const notBillable = services.filter(s => !s.billable);
select.innerHTML = `<option value="">${t('selectPh')}</option>`;
function addGroup(label, list) {
if (!list.length) return;
const group = document.createElement('optgroup');
group.label = label;
list.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.name;
if (s.id === selectedId) opt.selected = true;
group.appendChild(opt);
});
select.appendChild(group);
}

addGroup(t('billable'), billable);
addGroup(t('notBillable'), notBillable);
addGroup(t('billable'), billable);
addGroup(t('notBillable'), notBillable);
}

// ── Edit öffnen ──────────────────────────────────────────────────────────────
// ── Edit öffnen ──────────────────────────────────────────────────────────────

function openEdit(row) {
document.querySelectorAll('.report-table__row--editing').forEach(r => {
if (r !== row) closeEdit(r);
});
document.querySelectorAll('.report-table__row--editing').forEach(r => {
if (r !== row) closeEdit(r);
});

const editForm = row.querySelector('.report-row__edit');
if (!editForm) return;
const editForm = row.querySelector('.report-row__edit');
if (!editForm) return;

// Selects klonen um akkumulierte Listener zu vermeiden
const oldProjectSel = row.querySelector('.edit-project');
const oldServiceSel = row.querySelector('.edit-service');
const projectSel = oldProjectSel.cloneNode(false);
const serviceSel = oldServiceSel.cloneNode(false);
oldProjectSel.replaceWith(projectSel);
oldServiceSel.replaceWith(serviceSel);
const oldProjectSel = row.querySelector('.edit-project');
const oldServiceSel = row.querySelector('.edit-service');
const projectSel = oldProjectSel.cloneNode(false);
const serviceSel = oldServiceSel.cloneNode(false);
oldProjectSel.replaceWith(projectSel);
oldServiceSel.replaceWith(serviceSel);

const projectId = parseInt(row.dataset.projectId) || null;
const serviceId = parseInt(row.dataset.serviceId) || null;
const projectId = parseInt(row.dataset.projectId, 10) || null;
const serviceId = parseInt(row.dataset.serviceId, 10) || null;

populateProjectSelect(projectSel, projectId);
populateServiceSelect(serviceSel, serviceId);
populateProjectSelect(projectSel, projectId);
populateServiceSelect(serviceSel, serviceId);

projectSel.addEventListener('change', () => {
populateServiceSelect(row.querySelector('.edit-service'), null);
});
projectSel.addEventListener('change', () => {
populateServiceSelect(row.querySelector('.edit-service'), null);
});

editForm.hidden = false;
row.classList.add('report-table__row--editing');
row.querySelector('.edit-duration')?.focus();
editForm.hidden = false;
row.classList.add('report-table__row--editing');
row.querySelector('.edit-duration')?.focus();
}

function closeEdit(row) {
const editForm = row.querySelector('.report-row__edit');
if (!editForm) return;
editForm.hidden = true;
row.classList.remove('report-table__row--editing');
const editForm = row.querySelector('.report-row__edit');
if (!editForm) return;
editForm.hidden = true;
row.classList.remove('report-table__row--editing');
}

// ── Speichern ────────────────────────────────────────────────────────────────
// ── Speichern ────────────────────────────────────────────────────────────────

async function saveEdit(row) {
const id = row.dataset.entryId;
const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00';
const projectId = row.querySelector('.edit-project')?.value;
const serviceId = row.querySelector('.edit-service')?.value;
const note = row.querySelector('.edit-note')?.value ?? '';

if (!projectId) { alert(t('errorNoProject')); return; }

const rawMinutes = roundToQuarter(parseDuration(durationRaw));

if (rawMinutes === 0) { alert(t('errorZeroDuration')); return; }

const validation = validateDuration(rawMinutes);
if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; }
if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return;

try {
const res = await fetch(`/api/entries/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
duration: formatMinutes(rawMinutes),
projectId: parseInt(projectId),
serviceId: serviceId ? parseInt(serviceId) : null,
note: note || null,
}),
});

if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err.error ?? t('errorSave'));
return;
}

window.location.reload();
const saveBtn = row.querySelector('[data-action="save"]');
if (saveBtn?.disabled) return;

const id = row.dataset.entryId;
const projectId = row.querySelector('.edit-project')?.value;
const serviceId = row.querySelector('.edit-service')?.value;
const note = row.querySelector('.edit-note')?.value ?? '';

if (!projectId) { alert(t('errorNoProject')); return; }

const dur = parseAndValidate(row.querySelector('.edit-duration')?.value ?? '0:00');
if (dur.error) { alert(t(dur.error)); return; }
if (dur.warn && !confirm(t(dur.warn))) return;

if (saveBtn) saveBtn.disabled = true;
try {
const res = await fetch(`/api/entries/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
duration: dur.formatted,
projectId: parseInt(projectId, 10),
serviceId: serviceId ? parseInt(serviceId, 10) : null,
note: note || null,
}),
});

} catch {
alert(t('errorSave'));
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err.error ?? t('errorSave'));
return;
}

window.location.reload();
} catch {
alert(t('errorSave'));
} finally {
if (saveBtn) saveBtn.disabled = false;
}
}

// ── Löschen ───────────────────────────────────────────────────────────────────
// ── Löschen ──────────────────────────────────────────────────────────────────

async function deleteEntry(row) {
if (!confirm(t('confirmDelete'))) return;

const id = row.dataset.entryId;

try {
const res = await fetch(`/api/entries/${id}`, { method: 'DELETE' });
if (!res.ok) { alert(t('errorDelete')); return; }
window.location.reload();
} catch {
alert(t('errorDelete'));
}
if (!confirm(t('confirmDelete'))) return;

try {
const res = await fetch(`/api/entries/${row.dataset.entryId}`, { method: 'DELETE' });
if (!res.ok) { alert(t('errorDelete')); return; }
window.location.reload();
} catch {
alert(t('errorDelete'));
}
}

// ── Abgerechnet toggeln ──────────────────────────────────────────────────────
// ── Abgerechnet toggeln ──────────────────────────────────────────────────────

async function toggleInvoiced(row) {
const id = row.dataset.entryId;
const btn = row.querySelector('[data-action="toggle-invoiced"]');
const id = row.dataset.entryId;
const btn = row.querySelector('[data-action="toggle-invoiced"]');

try {
const res = await fetch(`/api/entries/${id}/invoiced`, { method: 'PATCH' });
if (!res.ok) return;
try {
const res = await fetch(`/api/entries/${id}/invoiced`, { method: 'PATCH' });
if (!res.ok) return;

const data = await res.json();
const invoiced = data.invoiced;
const data = await res.json();
const invoiced = data.invoiced;

row.dataset.invoiced = invoiced ? 'true' : 'false';
row.classList.toggle('report-table__row--invoiced', invoiced);
row.dataset.invoiced = invoiced ? 'true' : 'false';
row.classList.toggle('report-table__row--invoiced', invoiced);

if (btn) {
btn.classList.toggle('report-lock--invoiced', invoiced);
btn.title = invoiced ? t('btnUnlock') : t('btnLock');
}

} catch (err) {
console.error('Fehler beim Toggeln des Abrechnungsstatus:', err);
if (btn) {
btn.classList.toggle('report-lock--invoiced', invoiced);
btn.title = invoiced ? t('btnUnlock') : t('btnLock');
}
} catch (err) {
console.error('toggleInvoiced error:', err);
}
}

// ── Event-Delegation ──────────────────────────────────────────────────────────
// ── Event-Delegation ─────────────────────────────────────────────────────────

document.addEventListener('DOMContentLoaded', () => {
initDurationBlurHandler();

const table = document.querySelector('.report-table');
if (!table) return;
initDurationBlurHandler();

const table = document.querySelector('.report-table');
if (table) {
table.addEventListener('click', e => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const row = btn.closest('.report-table__row');
if (!row) return;
switch (btn.dataset.action) {
case 'edit': openEdit(row); break;
case 'cancel': closeEdit(row); break;
case 'save': saveEdit(row); break;
case 'delete': deleteEntry(row); break;
case 'toggle-invoiced': toggleInvoiced(row); break;
}
const btn = e.target.closest('[data-action]');
if (!btn) return;
const row = btn.closest('.report-table__row');
if (!row) return;
switch (btn.dataset.action) {
case 'edit': openEdit(row); break;
case 'cancel': closeEdit(row); break;
case 'save': saveEdit(row); break;
case 'delete': deleteEntry(row); break;
case 'toggle-invoiced': toggleInvoiced(row); break;
}
});
}

new ReportFilter().init();
initExportButtons();
initPrintButton();
});

// ── ReportFilter ──────────────────────────────────────────────────────────────
// ── ReportFilter ─────────────────────────────────────────────────────────────

class ReportFilter {
constructor() {
this.panel = document.getElementById('report-filter');
this.toggleBtn = document.getElementById('btn-filter-toggle');
this.applyBtn = document.getElementById('btn-filter-apply');
this.hideBtn = document.getElementById('btn-filter-hide');
this.periodSel = document.querySelector('.filter-period-select');
this.customDates = document.querySelector('.filter-custom-dates');
}

init() {
if (!this.panel) return;
constructor() {
this.panel = document.getElementById('report-filter');
this.toggleBtn = document.getElementById('btn-filter-toggle');
this.applyBtn = document.getElementById('btn-filter-apply');
this.hideBtn = document.getElementById('btn-filter-hide');
this.periodSel = document.querySelector('.filter-period-select');
this.customDates = document.querySelector('.filter-custom-dates');
}

init() {
if (!this.panel) return;

this.toggleBtn?.addEventListener('click', () => this.togglePanel());
this.hideBtn?.addEventListener('click', () => this.hidePanel());
this.applyBtn?.addEventListener('click', () => this.applyFilters());

this.panel.querySelectorAll('.filter-row__checkbox').forEach(cb => {
cb.addEventListener('change', () => {
this.syncRowState(cb.closest('.filter-row'), cb.checked);
});
});

// Toolbar-Toggle
this.toggleBtn?.addEventListener('click', () => this.togglePanel());
this.panel.querySelectorAll('.filter-select, .filter-note-input').forEach(el => {
el.addEventListener('mousedown', () => this.activateRowByControl(el));
});

// Ausblenden-Button
this.hideBtn?.addEventListener('click', () => this.hidePanel());
this.periodSel?.addEventListener('change', () => {
this.activateRowByControl(this.periodSel);
this.toggleCustomDates(this.periodSel.value === 'custom');
});

// Filtern-Button
this.applyBtn?.addEventListener('click', () => this.applyFilters());
this.panel.querySelectorAll('.filter-row__add').forEach(btn => {
btn.addEventListener('click', () => this.addControl(btn));
});

// Checkbox-Änderungen
this.panel.querySelectorAll('.filter-row__checkbox').forEach(cb => {
cb.addEventListener('change', () => {
const row = cb.closest('.filter-row');
this.syncRowState(row, cb.checked);
});
});
this.panel.addEventListener('click', e => {
const removeBtn = e.target.closest('.filter-row__remove');
if (removeBtn) this.removeControl(removeBtn);
});

// Klick auf ausgegrautem Control → Checkbox aktivieren
this.panel.querySelectorAll('.filter-select, .filter-note-input').forEach(el => {
el.addEventListener('mousedown', () => this.activateRowByControl(el));
});
this.panel.addEventListener('change', e => {
const sel = e.target.closest('.filter-select');
if (!sel) return;
const container = sel.closest('.filter-row__controls');
if (container) this.refreshGroupSelects(container);
});

// Zeitraum-Select → Custom-Felder zeigen/verstecken
this.periodSel?.addEventListener('change', () => {
const row = this.periodSel.closest('.filter-row');
this.activateRowByControl(this.periodSel);
this.toggleCustomDates(this.periodSel.value === 'custom');
});
this.panel.querySelectorAll('.filter-row').forEach(row => {
const cb = row.querySelector('.filter-row__checkbox');
this.syncRowState(row, cb?.checked ?? false);
});

// Plus-Buttons
this.panel.querySelectorAll('.filter-row__add').forEach(btn => {
btn.addEventListener('click', () => this.addControl(btn));
});
this.panel.querySelectorAll('.filter-row__controls').forEach(container => {
this.refreshGroupSelects(container);
});
}

togglePanel() {
const isHidden = this.panel.hasAttribute('hidden');
if (isHidden) {
this.panel.removeAttribute('hidden');
this.toggleBtn?.classList.add('report-toolbar__action--active');
} else {
this.hidePanel();
}
}

hidePanel() {
this.panel.setAttribute('hidden', '');
this.toggleBtn?.classList.remove('report-toolbar__action--active');
}

syncRowState(row, active) {
row.classList.toggle('filter-row--inactive', !active);
}

activateRowByControl(el) {
const row = el.closest('.filter-row');
if (!row) return;
const cb = row.querySelector('.filter-row__checkbox');
if (cb && !cb.checked) {
cb.checked = true;
this.syncRowState(row, true);
}
}

// Remove-Buttons (via Delegation, da sie dynamisch entstehen)
this.panel.addEventListener('click', e => {
const removeBtn = e.target.closest('.filter-row__remove');
if (removeBtn) this.removeControl(removeBtn);
});
toggleCustomDates(show) {
if (!this.customDates) return;
this.customDates.toggleAttribute('hidden', !show);
}

// Select-Änderung → Optionen in der Gruppe aktualisieren
this.panel.addEventListener('change', e => {
const sel = e.target.closest('.filter-select');
if (!sel) return;
const container = sel.closest('.filter-row__controls');
if (container) this.refreshGroupSelects(container);
});
addControl(btn) {
const targetId = btn.dataset.target;
const container = document.getElementById(targetId);
if (!container) return;

// Initialer Zustand
this.panel.querySelectorAll('.filter-row').forEach(row => {
const cb = row.querySelector('.filter-row__checkbox');
this.syncRowState(row, cb?.checked ?? false);
});
const template = container.querySelector('.filter-row__control-group');
if (!template) return;

// Bereits geladene Mehrfach-Selects deduplizieren (nach Seiten-Reload mit Filtern)
this.panel.querySelectorAll('.filter-row__controls').forEach(container => {
this.refreshGroupSelects(container);
});
}
const clone = template.cloneNode(true);

// ── Panel toggeln ─────────────────────────────────────────────────────────
const clonedSelect = clone.querySelector('.filter-select');
if (clonedSelect) clonedSelect.value = '';

togglePanel() {
const isHidden = this.panel.hasAttribute('hidden');
if (isHidden) {
this.panel.removeAttribute('hidden');
this.toggleBtn?.classList.add('report-toolbar__action--active');
} else {
this.hidePanel();
}
if (!clone.querySelector('.filter-row__remove')) {
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'filter-row__remove';
removeBtn.textContent = '×';
clone.appendChild(removeBtn);
}

hidePanel() {
this.panel.setAttribute('hidden', '');
this.toggleBtn?.classList.remove('report-toolbar__action--active');
}
clone.querySelector('.filter-select')?.addEventListener('mousedown', () => {
this.activateRowByControl(clone.querySelector('.filter-select'));
});

// ── Row-Zustand (aktiv / inaktiv) ─────────────────────────────────────────
container.appendChild(clone);
this.refreshGroupSelects(container);

syncRowState(row, active) {
row.classList.toggle('filter-row--inactive', !active);
const row = btn.closest('.filter-row');
const cb = row?.querySelector('.filter-row__checkbox');
if (cb && !cb.checked) {
cb.checked = true;
this.syncRowState(row, true);
}

activateRowByControl(el) {
const row = el.closest('.filter-row');
if (!row) return;
const cb = row.querySelector('.filter-row__checkbox');
if (cb && !cb.checked) {
cb.checked = true;
this.syncRowState(row, true);
}
clonedSelect?.focus();
}

removeControl(removeBtn) {
const group = removeBtn.closest('.filter-row__control-group');
const container = group?.parentElement;
group?.remove();

if (container && !container.querySelector('.filter-row__control-group')) {
const row = container.closest('.filter-row');
const cb = row?.querySelector('.filter-row__checkbox');
if (cb) {
cb.checked = false;
this.syncRowState(row, false);
}
}

// ── Zeitraum: Custom-Felder ────────────────────────────────────────────────

toggleCustomDates(show) {
if (!this.customDates) return;
if (show) {
this.customDates.removeAttribute('hidden');
} else {
this.customDates.setAttribute('hidden', '');
}
}
if (container) this.refreshGroupSelects(container);
}

// ── Plus: weiteres Control hinzufügen ─────────────────────────────────────
refreshGroupSelects(container) {
const selects = [...container.querySelectorAll('.filter-select')];
if (selects.length < 2) return;

addControl(btn) {
const targetId = btn.dataset.target;
const filterKey = btn.dataset.filterKey;
const container = document.getElementById(targetId);
if (!container) return;
const selectedValues = new Set(
selects.map(s => s.value).filter(v => v !== '')
);

// Erste Gruppe als Template klonen
const template = container.querySelector('.filter-row__control-group');
if (!template) return;
selects.forEach(sel => {
const ownValue = sel.value;
sel.querySelectorAll('option').forEach(opt => {
if (!opt.value) return;
opt.hidden = selectedValues.has(opt.value) && opt.value !== ownValue;
});
});
}

const clone = template.cloneNode(true);
applyFilters() {
const params = new URLSearchParams();
params.set('limit', String(window.Report?.limit ?? 50));

// Select zurücksetzen
const clonedSelect = clone.querySelector('.filter-select');
if (clonedSelect) clonedSelect.value = '';
this.panel.querySelectorAll('.filter-row').forEach(row => {
const cb = row.querySelector('.filter-row__checkbox');
if (!cb?.checked) return;

// Remove-Button hinzufügen (falls noch keiner da)
if (!clone.querySelector('.filter-row__remove')) {
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'filter-row__remove';
removeBtn.textContent = '×';
clone.appendChild(removeBtn);
}
const key = row.dataset.filterKey;

// Neu: Klick auf den geklonten Select aktiviert ebenfalls die Row
clone.querySelector('.filter-select')?.addEventListener('mousedown', () => {
this.activateRowByControl(clone.querySelector('.filter-select'));
if (['clients', 'projects', 'services', 'users'].includes(key)) {
row.querySelectorAll('.filter-select').forEach(sel => {
if (sel.value) params.append(`filter[${key}][]`, sel.value);
});

container.appendChild(clone);

// Optionen deduplizieren
this.refreshGroupSelects(container);

// Row aktivieren
const row = btn.closest('.filter-row');
const cb = row?.querySelector('.filter-row__checkbox');
if (cb && !cb.checked) {
cb.checked = true;
this.syncRowState(row, true);
if (row.querySelector('.filter-neg-checkbox')?.checked) {
params.set(`filter[${key}_neg]`, '1');
}

clonedSelect?.focus();
}

// ── Minus: Control entfernen ──────────────────────────────────────────────

removeControl(removeBtn) {
const group = removeBtn.closest('.filter-row__control-group');
const container = group?.parentElement;
group?.remove();

// Wenn keine Controls mehr übrig → Checkbox deaktivieren
if (container && !container.querySelector('.filter-row__control-group')) {
const row = container.closest('.filter-row');
const cb = row?.querySelector('.filter-row__checkbox');
if (cb) {
cb.checked = false;
this.syncRowState(row, false);
}
} else if (key === 'period') {
const val = this.periodSel?.value;
if (!val) return;
params.set('filter[period]', val);

if (val === 'custom' && this.customDates) {
const get = field => this.customDates.querySelector(`[data-date-field="${field}"]`)?.value ?? '';
const fromDay = get('from-day').padStart(2, '0');
const fromMonth = get('from-month').padStart(2, '0');
const fromYear = get('from-year');
const toDay = get('to-day').padStart(2, '0');
const toMonth = get('to-month').padStart(2, '0');
const toYear = get('to-year');

if (fromYear && fromMonth && fromDay) {
params.set('filter[date_from]', `${fromYear}-${fromMonth}-${fromDay}`);
}
if (toYear && toMonth && toDay) {
params.set('filter[date_to]', `${toYear}-${toMonth}-${toDay}`);
}
}
if (row.querySelector('.filter-neg-checkbox')?.checked) {
params.set('filter[period_neg]', '1');
}

// Verbleibende Selects aktualisieren
if (container) this.refreshGroupSelects(container);
}

// ── Optionen in Mehrfach-Selects deduplizieren ────────────────────────────

refreshGroupSelects(container) {
const selects = [...container.querySelectorAll('.filter-select')];
if (selects.length < 2) return;
} else if (key === 'note') {
const val = row.querySelector('.filter-note-input')?.value?.trim();
if (val) params.set('filter[note]', val);

// Alle gewählten Values sammeln
const selectedValues = new Set(
selects.map(s => s.value).filter(v => v !== '')
);
} else if (key === 'invoiced') {
const checked = row.querySelector('.filter-invoiced-radio:checked');
if (checked) params.set('filter[invoiced]', checked.value);
}
});

selects.forEach(sel => {
const ownValue = sel.value;
sel.querySelectorAll('option').forEach(opt => {
if (!opt.value) return; // "..." immer sichtbar lassen
// Verstecken wenn woanders gewählt, aber nicht beim eigenen Select
opt.hidden = selectedValues.has(opt.value) && opt.value !== ownValue;
});
});
}
window.location.href = `/reports/times?${params}`;
}
}

// ── Filter anwenden → URL bauen und navigieren ────────────────────────────

applyFilters() {
const params = new URLSearchParams();
params.set('limit', String(window.Report?.limit ?? 50));

this.panel.querySelectorAll('.filter-row').forEach(row => {
const cb = row.querySelector('.filter-row__checkbox');
if (!cb?.checked) return;

const key = row.dataset.filterKey;

if (['clients', 'projects', 'services', 'users'].includes(key)) {
row.querySelectorAll('.filter-select').forEach(sel => {
if (sel.value) params.append(`filter[${key}][]`, sel.value);
});
if (row.querySelector('.filter-neg-checkbox')?.checked) {
params.set(`filter[${key}_neg]`, '1');
}

} else if (key === 'period') {
const val = this.periodSel?.value;
if (!val) return;
params.set('filter[period]', val);

if (val === 'custom' && this.customDates) {
const get = field => this.customDates.querySelector(`[data-date-field="${field}"]`)?.value ?? '';
const fromDay = get('from-day').padStart(2, '0');
const fromMonth = get('from-month').padStart(2, '0');
const fromYear = get('from-year');
const toDay = get('to-day').padStart(2, '0');
const toMonth = get('to-month').padStart(2, '0');
const toYear = get('to-year');

if (fromYear && fromMonth && fromDay) {
params.set('filter[date_from]', `${fromYear}-${fromMonth}-${fromDay}`);
}
if (toYear && toMonth && toDay) {
params.set('filter[date_to]', `${toYear}-${toMonth}-${toDay}`);
}
}
if (row.querySelector('.filter-neg-checkbox')?.checked) {
params.set('filter[period_neg]', '1');
}

} else if (key === 'note') {
const val = row.querySelector('.filter-note-input')?.value?.trim();
if (val) params.set('filter[note]', val);

} else if (key === 'invoiced') {
const checked = row.querySelector('.filter-invoiced-radio:checked');
if (checked) params.set('filter[invoiced]', checked.value);
}
});
// ── Export ────────────────────────────────────────────────────────────────────

window.location.href = `/reports/times?${params}`;
}
function initExportButtons() {
['excel', 'csv', 'pdf'].forEach(format => {
document.getElementById(`btn-export-${format}`)?.addEventListener('click', () => {
const params = new URLSearchParams(window.location.search);
params.delete('limit');
window.location.href = `/reports/export/${format}?${params}`;
});
});
}

// ── Init ──────────────────────────────────────────────────────────────────────

document.addEventListener('DOMContentLoaded', () => {
new ReportFilter().init();
});
function initPrintButton() {
document.getElementById('btn-print')?.addEventListener('click', () => {
window.print();
});
}

+ 229
- 191
httpdocs/assets/scripts/team.js Wyświetl plik

@@ -1,223 +1,261 @@
// team.js
// assets/scripts/team.js

import { esc, createTranslator, ANIMATION_MS, removeWithAnimation } from './utils.js';

const t = createTranslator('Team');

document.addEventListener('DOMContentLoaded', () => {

// ── Tabs ─────────────────────────────────────────────────────────────────────
document.querySelectorAll('.crud-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.crud-tab').forEach(t =>
t.classList.toggle('crud-tab--active', t === tab)
);
document.querySelectorAll('[data-tab-panel]').forEach(panel => {
panel.hidden = panel.dataset.tabPanel !== tab.dataset.tab;
});
});
});
// ── Tabs ──────────────────────────────────────────────────────────────────

// ── Einlade-Modal ─────────────────────────────────────────────────────────────
const modal = document.getElementById('team-modal');
const errorsBox = document.getElementById('team-modal-errors');

const openModal = () => { modal.hidden = false; };
const closeModal = () => {
modal.hidden = true;
errorsBox.hidden = true;
['inv-firstName', 'inv-lastName', 'inv-email'].forEach(id => {
document.getElementById(id).value = '';
});
const defaultRole = modal.querySelector('input[name="inv-role"][value="member"]');
if (defaultRole) defaultRole.checked = true;
};
document.querySelectorAll('.crud-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.crud-tab').forEach(t =>
t.classList.toggle('crud-tab--active', t === tab)
);
document.querySelectorAll('[data-tab-panel]').forEach(panel => {
panel.hidden = panel.dataset.tabPanel !== tab.dataset.tab;
});
});
});

document.getElementById('team-invite-btn').addEventListener('click', openModal);
document.getElementById('team-modal-close').addEventListener('click', closeModal);
document.getElementById('team-modal-cancel').addEventListener('click', closeModal);
modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });

document.getElementById('team-modal-submit').addEventListener('click', async () => {
const payload = {
firstName: document.getElementById('inv-firstName').value.trim(),
lastName: document.getElementById('inv-lastName').value.trim(),
email: document.getElementById('inv-email').value.trim(),
role: modal.querySelector('input[name="inv-role"]:checked')?.value ?? 'member',
};

const res = await fetch('/api/team/invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
// ── Einlade-Modal ─────────────────────────────────────────────────────────

if (!res.ok) {
errorsBox.hidden = false;
errorsBox.innerHTML = '<ul>' + (data.errors ?? [data.error]).map(e => `<li>${e}</li>`).join('') + '</ul>';
return;
}
const modal = document.getElementById('team-modal');
const errorsBox = document.getElementById('team-modal-errors');

closeModal();
window.location.reload();
const openModal = () => { modal.hidden = false; };
const closeModal = () => {
modal.hidden = true;
errorsBox.hidden = true;
['inv-firstName', 'inv-lastName', 'inv-email'].forEach(id => {
document.getElementById(id).value = '';
});
const defaultRole = modal.querySelector('input[name="inv-role"][value="member"]');
if (defaultRole) defaultRole.checked = true;
};

document.getElementById('team-invite-btn').addEventListener('click', openModal);
document.getElementById('team-modal-close').addEventListener('click', closeModal);
document.getElementById('team-modal-cancel').addEventListener('click', closeModal);
modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });

const submitBtn = document.getElementById('team-modal-submit');
submitBtn.addEventListener('click', async () => {
const payload = {
firstName: document.getElementById('inv-firstName').value.trim(),
lastName: document.getElementById('inv-lastName').value.trim(),
email: document.getElementById('inv-email').value.trim(),
role: modal.querySelector('input[name="inv-role"]:checked')?.value ?? 'member',
};

// ── Listen-Delegation: aktive User + Einladungen ───────────────────────────
const list = document.getElementById('team-list');
if (list) {
list.addEventListener('click', e => {
const actionEl = e.target.closest('[data-action]');
if (!actionEl) return;

const action = actionEl.dataset.action;
const row = e.target.closest('.crud-row');
if (!row) return;

switch (action) {
case 'edit': openEdit(row); break;
case 'save': saveEdit(row); break;
case 'cancel': closeEdit(row); break;
case 'delete': deleteMember(row); break;
case 'delete-invite': deleteInvite(actionEl.dataset.id, row); break;
}
});
submitBtn.disabled = true;
try {
const res = await fetch('/api/team/invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();

if (!res.ok) {
errorsBox.hidden = false;
const errors = data.errors ?? [data.error];
errorsBox.innerHTML = '<ul>' + errors.map(e => `<li>${esc(e)}</li>`).join('') + '</ul>';
return;
}

closeModal();
window.location.reload();
} catch {
errorsBox.hidden = false;
errorsBox.innerHTML = `<ul><li>${esc(t('errorSave'))}</li></ul>`;
} finally {
submitBtn.disabled = false;
}
});

// ── Listen-Delegation: aktive User + Einladungen ──────────────────────────

const list = document.getElementById('team-list');
if (list) {
list.addEventListener('click', e => {
const actionEl = e.target.closest('[data-action]');
if (!actionEl) return;

const row = e.target.closest('.crud-row');
if (!row) return;

switch (actionEl.dataset.action) {
case 'edit': openEdit(row); break;
case 'save': saveEdit(row); break;
case 'cancel': closeEdit(row); break;
case 'delete': deleteMember(row); break;
case 'delete-invite': deleteInvite(actionEl.dataset.id, row); break;
}
});
}

// ── Listen-Delegation: archivierte User ───────────────────────────────────
const archivedList = document.getElementById('team-list-archived');
if (archivedList) {
archivedList.addEventListener('click', e => {
const actionEl = e.target.closest('[data-action]');
if (!actionEl) return;
const row = e.target.closest('.crud-row');
if (!row) return;

if (actionEl.dataset.action === 'unarchive') {
unarchiveMember(row);
}
});
}
// ── Listen-Delegation: archivierte User ───────────────────────────────────

// ── Inline Edit ───────────────────────────────────────────────────────────
function openEdit(row) {
row.querySelector('.crud-row__display').hidden = true;
row.querySelector('.crud-row__edit').hidden = false;
row.querySelector('.edit-first-name')?.focus();
}
const archivedList = document.getElementById('team-list-archived');
if (archivedList) {
archivedList.addEventListener('click', e => {
const actionEl = e.target.closest('[data-action]');
if (!actionEl) return;
const row = e.target.closest('.crud-row');
if (!row) return;

function closeEdit(row) {
row.querySelector('.crud-row__display').hidden = false;
row.querySelector('.crud-row__edit').hidden = true;
if (actionEl.dataset.action === 'unarchive') {
unarchiveMember(row);
}
});
}

// Felder auf ursprüngliche Werte zurücksetzen
row.querySelector('.edit-first-name').value = row.dataset.firstName ?? '';
row.querySelector('.edit-last-name').value = row.dataset.lastName ?? '';
row.querySelector('.edit-email').value = row.dataset.email ?? '';
row.querySelector('.edit-note').value = row.dataset.note ?? '';
// ── Inline Edit ───────────────────────────────────────────────────────────

const currentRole = row.dataset.role;
row.querySelectorAll('.edit-role').forEach(radio => {
radio.checked = radio.value === currentRole;
});
}
function openEdit(row) {
row.querySelector('.crud-row__display').hidden = true;
row.querySelector('.crud-row__edit').hidden = false;
row.querySelector('.edit-first-name')?.focus();
}

async function saveEdit(row) {
const id = row.dataset.id;
const firstName = row.querySelector('.edit-first-name').value.trim();
const lastName = row.querySelector('.edit-last-name').value.trim();
const email = row.querySelector('.edit-email').value.trim();
const note = row.querySelector('.edit-note').value || null;
const role = row.querySelector('.edit-role:checked')?.value ?? row.dataset.role;

const res = await fetch(`/api/team/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ firstName, lastName, email, note, role }),
});

if (!res.ok) {
const data = await res.json();
alert((data.errors ?? [data.error]).join('\n'));
return;
}
function closeEdit(row) {
row.querySelector('.crud-row__display').hidden = false;
row.querySelector('.crud-row__edit').hidden = true;

const data = await res.json();
updateDisplay(row, data);
closeEdit(row);
}
row.querySelector('.edit-first-name').value = row.dataset.firstName ?? '';
row.querySelector('.edit-last-name').value = row.dataset.lastName ?? '';
row.querySelector('.edit-email').value = row.dataset.email ?? '';
row.querySelector('.edit-note').value = row.dataset.note ?? '';

function updateDisplay(row, data) {
row.querySelector('.crud-row__name').textContent = data.fullName;
row.querySelector('.crud-row__meta').textContent = `(${data.roleLabel})`;

row.dataset.firstName = data.firstName;
row.dataset.lastName = data.lastName;
row.dataset.email = data.email;
row.dataset.note = data.note ?? '';
row.dataset.role = data.role;

// Edit-Felder aktualisieren
row.querySelector('.edit-first-name').value = data.firstName;
row.querySelector('.edit-last-name').value = data.lastName;
row.querySelector('.edit-email').value = data.email;
row.querySelector('.edit-note').value = data.note ?? '';
row.querySelectorAll('.edit-role').forEach(radio => {
radio.checked = radio.value === data.role;
});
const currentRole = row.dataset.role;
row.querySelectorAll('.edit-role').forEach(radio => {
radio.checked = radio.value === currentRole;
});
}

async function saveEdit(row) {
const saveBtn = row.querySelector('[data-action="save"]');
if (saveBtn?.disabled) return;

const id = row.dataset.id;
const firstName = row.querySelector('.edit-first-name').value.trim();
const lastName = row.querySelector('.edit-last-name').value.trim();
const email = row.querySelector('.edit-email').value.trim();
const note = row.querySelector('.edit-note').value || null;
const role = row.querySelector('.edit-role:checked')?.value ?? row.dataset.role;

if (saveBtn) saveBtn.disabled = true;
try {
const res = await fetch(`/api/team/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ firstName, lastName, email, note, role }),
});

if (!res.ok) {
const data = await res.json();
alert((data.errors ?? [data.error]).join('\n'));
return;
}

const data = await res.json();
updateDisplay(row, data);
closeEdit(row);
} catch {
alert(t('errorSave'));
} finally {
if (saveBtn) saveBtn.disabled = false;
}
}

function updateDisplay(row, data) {
row.querySelector('.crud-row__name').textContent = data.fullName;
row.querySelector('.crud-row__meta').textContent = `(${data.roleLabel})`;

row.dataset.firstName = data.firstName;
row.dataset.lastName = data.lastName;
row.dataset.email = data.email;
row.dataset.note = data.note ?? '';
row.dataset.role = data.role;

row.querySelector('.edit-first-name').value = data.firstName;
row.querySelector('.edit-last-name').value = data.lastName;
row.querySelector('.edit-email').value = data.email;
row.querySelector('.edit-note').value = data.note ?? '';
row.querySelectorAll('.edit-role').forEach(radio => {
radio.checked = radio.value === data.role;
});
}

// ── Delete ────────────────────────────────────────────────────────────────
async function deleteMember(row) {
if (!confirm('Wirklich entfernen?')) return;
// ── Delete ────────────────────────────────────────────────────────────────

const id = row.dataset.id;
const res = await fetch(`/api/team/${id}`, { method: 'DELETE' });
async function deleteMember(row) {
if (!confirm(t('confirmDelete'))) return;

if (res.status === 409) {
if (confirm('Dieser Benutzer hat Zeiteinträge und kann nicht gelöscht werden.\nStattdessen archivieren?')) {
await archiveMember(row);
}
return;
}
try {
const res = await fetch(`/api/team/${row.dataset.id}`, { method: 'DELETE' });

if (!res.ok) {
const data = await res.json();
alert(data.error ?? 'Fehler beim Löschen.');
return;
if (res.status === 409) {
if (confirm(t('confirmArchive'))) {
await archiveMember(row);
}

row.classList.add('crud-row--removing');
setTimeout(() => row.remove(), 280);
return;
}

if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.error ?? t('errorDelete'));
return;
}

removeWithAnimation(row, 'crud-row--removing');
} catch {
alert(t('errorDelete'));
}

async function deleteInvite(id, row) {
if (!confirm('Einladung zurückziehen?')) return;

const res = await fetch(`/api/team/invite/${id}`, { method: 'DELETE' });
if (!res.ok) {
const data = await res.json();
alert(data.error ?? 'Fehler');
return;
}

row.classList.add('crud-row--removing');
setTimeout(() => row.remove(), 280);
}

async function deleteInvite(id, row) {
if (!confirm(t('confirmRevokeInvite'))) return;

try {
const res = await fetch(`/api/team/invite/${id}`, { method: 'DELETE' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.error ?? t('errorGeneric'));
return;
}

removeWithAnimation(row, 'crud-row--removing');
} catch {
alert(t('errorGeneric'));
}
}

// ── Archive / Unarchive ───────────────────────────────────────────────────
async function archiveMember(row) {
const id = row.dataset.id;
const res = await fetch(`/api/team/${id}/archive`, { method: 'PATCH' });
// ── Archive / Unarchive ───────────────────────────────────────────────────

if (!res.ok) { alert('Fehler beim Archivieren.'); return; }
async function archiveMember(row) {
try {
const res = await fetch(`/api/team/${row.dataset.id}/archive`, { method: 'PATCH' });
if (!res.ok) { alert(t('errorArchive')); return; }

row.classList.add('crud-row--removing');
setTimeout(() => window.location.reload(), 280);
removeWithAnimation(row, 'crud-row--removing');
setTimeout(() => window.location.reload(), ANIMATION_MS);
} catch {
alert(t('errorArchive'));
}
}

async function unarchiveMember(row) {
const id = row.dataset.id;
const res = await fetch(`/api/team/${id}/unarchive`, { method: 'PATCH' });

if (!res.ok) { alert('Fehler beim Wiederherstellen.'); return; }
async function unarchiveMember(row) {
try {
const res = await fetch(`/api/team/${row.dataset.id}/unarchive`, { method: 'PATCH' });
if (!res.ok) { alert(t('errorRestore')); return; }

row.classList.add('crud-row--removing');
setTimeout(() => window.location.reload(), 280);
removeWithAnimation(row, 'crud-row--removing');
setTimeout(() => window.location.reload(), ANIMATION_MS);
} catch {
alert(t('errorRestore'));
}
});
}
});

+ 26
- 0
httpdocs/assets/scripts/utils.js Wyświetl plik

@@ -0,0 +1,26 @@
// assets/scripts/utils.js

const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };

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

+ 1
- 1
httpdocs/assets/styles/atoms/_inputs.scss Wyświetl plik

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


+ 53
- 0
httpdocs/assets/styles/atoms/_mixins.scss Wyświetl plik

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


+ 23
- 27
httpdocs/assets/styles/atoms/_variables.scss Wyświetl plik

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

+ 6
- 20
httpdocs/assets/styles/components/_account.scss Wyświetl plik

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


+ 6
- 21
httpdocs/assets/styles/components/_crud.scss Wyświetl plik

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


+ 15
- 3
httpdocs/assets/styles/components/_entry-form.scss Wyświetl plik

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


+ 11
- 38
httpdocs/assets/styles/components/_entry-list.scss Wyświetl plik

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


+ 5
- 8
httpdocs/assets/styles/components/_login.scss Wyświetl plik

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

+ 2
- 14
httpdocs/assets/styles/components/_month-calendar.scss Wyświetl plik

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


+ 7
- 4
httpdocs/assets/styles/components/_register.scss Wyświetl plik

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

+ 6
- 18
httpdocs/assets/styles/components/_team.scss Wyświetl plik

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

+ 4
- 22
httpdocs/assets/styles/components/_week-nav.scss Wyświetl plik

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


+ 6
- 1
httpdocs/assets/styles/main.scss Wyświetl plik

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

+ 3
- 5
httpdocs/assets/styles/sections/_home.scss Wyświetl plik

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

+ 65
- 118
httpdocs/assets/styles/sections/_report.scss Wyświetl plik

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


+ 4
- 11
httpdocs/assets/styles/sections/_timetracking.scss Wyświetl plik

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


+ 10
- 9
httpdocs/assets/styles/themes/_minimal.scss Wyświetl plik

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


+ 2
- 0
httpdocs/composer.json Wyświetl plik

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


+ 867
- 1
httpdocs/composer.lock Wyświetl plik

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


+ 1
- 2
httpdocs/config/packages/doctrine.yaml Wyświetl plik

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


+ 4
- 0
httpdocs/config/services.yaml Wyświetl plik

@@ -65,5 +65,9 @@ services:
arguments:
$appDomain: '%app.domain%'

App\Doctrine\TenantConnectionMiddleware:
tags:
- { name: doctrine.middleware, connection: tenant }

App\Controller\InviteController:
arguments: ~

+ 17
- 16
httpdocs/src/Controller/AccountController.php Wyświetl plik

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


+ 17
- 15
httpdocs/src/Controller/ClientController.php Wyświetl plik

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


+ 17
- 12
httpdocs/src/Controller/InviteController.php Wyświetl plik

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


+ 22
- 1
httpdocs/src/Controller/PasswordResetController.php Wyświetl plik

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


+ 20
- 18
httpdocs/src/Controller/ProjectController.php Wyświetl plik

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


+ 9
- 10
httpdocs/src/Controller/RegistrationController.php Wyświetl plik

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


+ 100
- 16
httpdocs/src/Controller/ReportController.php Wyświetl plik

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


+ 17
- 15
httpdocs/src/Controller/ServiceController.php Wyświetl plik

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


+ 33
- 32
httpdocs/src/Controller/TeamController.php Wyświetl plik

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


+ 18
- 14
httpdocs/src/Controller/TimeTrackingController.php Wyświetl plik

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


+ 2
- 7
httpdocs/src/Entity/Central/AccountUser.php Wyświetl plik

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

+ 4
- 4
httpdocs/src/EventSubscriber/ArchivedUserSubscriber.php Wyświetl plik

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


+ 10
- 98
httpdocs/src/Repository/Tenant/TimeEntryRepository.php Wyświetl plik

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


+ 4
- 4
httpdocs/src/Security/ArchivedUserChecker.php Wyświetl plik

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

+ 11
- 1
httpdocs/src/Service/AccountRoleHelper.php Wyświetl plik

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


+ 8
- 6
httpdocs/src/Service/RegistrationService.php Wyświetl plik

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


+ 395
- 0
httpdocs/src/Service/ReportExportService.php Wyświetl plik

@@ -0,0 +1,395 @@
<?php

namespace App\Service;

use App\Entity\Tenant\TimeEntry;
use Dompdf\Dompdf;
use Dompdf\Options;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Contracts\Translation\TranslatorInterface;

class ReportExportService
{
private const HEADER_KEYS = [
'app.report.export_col_date',
'app.report.export_col_client',
'app.report.export_col_project',
'app.report.export_col_service',
'app.report.export_col_user',
'app.report.export_col_note',
'app.report.export_col_hours',
'app.report.export_col_revenue',
'app.report.export_col_invoiced',
];

private const EXCEL_WIDTHS = [14, 22, 22, 20, 20, 36, 12, 14, 14];

private const COLOR_HEADER_BG = 'E8EFF7';
private const COLOR_HEADER_TEXT = '2D3748';
private const COLOR_BORDER = 'D1D9E6';
private const COLOR_STRIPE = 'F7F9FC';

public function __construct(
private readonly TranslatorInterface $translator,
) {}

private function t(string $id, array $params = []): string
{
return $this->translator->trans($id, $params);
}

private function headers(): array
{
return array_map(fn(string $key) => $this->t($key), self::HEADER_KEYS);
}

// ── Data Preparation ─────────────────────────────────────────────────────

/**
* @param TimeEntry[] $entries
* @param array<int, string> $userMap
* @return array{rows: list<array>, totalHours: float, totalRevenue: float}
*/
private function prepareData(array $entries, array $userMap): array
{
$rows = [];
$totalMinutes = 0;
$totalRevenue = 0.0;

foreach ($entries as $entry) {
$service = $entry->getService();
$client = $entry->getProject()?->getClient();
$billable = $service === null || $service->isBillable();
$rate = $client?->getHourlyRate();
$hours = $entry->getDuration() / 60;
$revenue = ($billable && $rate !== null) ? $rate * $hours : null;

$totalMinutes += $entry->getDuration();
if ($revenue !== null) {
$totalRevenue += $revenue;
}

$rows[] = [
'date' => $entry->getDate(),
'client' => $client?->getName() ?? '',
'project' => $entry->getProject()?->getName() ?? '',
'service' => $service?->getName() ?? '',
'user' => $userMap[$entry->getUserId()] ?? $this->t('app.report.user_fallback', ['%id%' => $entry->getUserId()]),
'note' => $entry->getNote() ?? '',
'hours' => $hours,
'revenue' => $revenue,
'invoiced' => $entry->isInvoiced(),
];
}

return [
'rows' => $rows,
'totalHours' => $totalMinutes / 60,
'totalRevenue' => $totalRevenue,
];
}

// ── Excel ────────────────────────────────────────────────────────────────

/**
* @param TimeEntry[] $entries
* @param array<int, string> $userMap
*/
public function generateExcel(array $entries, array $userMap, string $accountName): string
{
$data = $this->prepareData($entries, $userMap);

$spreadsheet = new Spreadsheet();
$spreadsheet->getProperties()
->setTitle($this->t('app.report.export_title', ['%account%' => $accountName]))
->setCreator($accountName);

$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle($this->t('app.report.export_col_hours'));

$this->excelWriteHeader($sheet);
$lastRow = $this->excelWriteData($sheet, $data['rows']);
$this->excelWriteSummary($sheet, $data, $lastRow + 1);
$this->excelApplyStyles($sheet, $lastRow);

$tmpFile = tempnam(sys_get_temp_dir(), 'report_') . '.xlsx';
(new Xlsx($spreadsheet))->save($tmpFile);
$spreadsheet->disconnectWorksheets();

return $tmpFile;
}

private function excelWriteHeader(Worksheet $sheet): void
{
$headers = $this->headers();
$cols = range('A', 'I');

foreach ($cols as $i => $col) {
$sheet->setCellValue($col . '1', $headers[$i]);
$sheet->getColumnDimension($col)->setWidth(self::EXCEL_WIDTHS[$i]);
}

$style = $sheet->getStyle('A1:I1');
$style->getFont()->setBold(true)->setSize(10)->getColor()->setRGB(self::COLOR_HEADER_TEXT);
$style->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setRGB(self::COLOR_HEADER_BG);
$style->getAlignment()->setVertical(Alignment::VERTICAL_CENTER);
$style->getBorders()->getBottom()->setBorderStyle(Border::BORDER_THIN)->getColor()->setRGB(self::COLOR_BORDER);
$sheet->getRowDimension(1)->setRowHeight(28);
}

private function excelWriteData(Worksheet $sheet, array $rows): int
{
$yes = $this->t('app.report.export_yes');
$no = $this->t('app.report.export_no');
$row = 2;

foreach ($rows as $r) {
$sheet->setCellValue("A{$row}", Date::dateTimeToExcel($r['date']));
$sheet->setCellValue("B{$row}", $r['client']);
$sheet->setCellValue("C{$row}", $r['project']);
$sheet->setCellValue("D{$row}", $r['service']);
$sheet->setCellValue("E{$row}", $r['user']);
$sheet->setCellValue("F{$row}", $r['note']);
$sheet->setCellValue("G{$row}", $r['hours']);

if ($r['revenue'] !== null) {
$sheet->setCellValue("H{$row}", $r['revenue']);
}

$sheet->setCellValue("I{$row}", $r['invoiced'] ? $yes : $no);

if ($row % 2 === 1) {
$sheet->getStyle("A{$row}:I{$row}")
->getFill()->setFillType(Fill::FILL_SOLID)
->getStartColor()->setRGB(self::COLOR_STRIPE);
}

$sheet->getRowDimension($row)->setRowHeight(22);
$row++;
}

return $row - 1;
}

private function excelWriteSummary(Worksheet $sheet, array $data, int $summaryRow): void
{
if (empty($data['rows'])) {
return;
}

$sheet->setCellValue("F{$summaryRow}", $this->t('app.report.export_sum'));
$sheet->setCellValue("G{$summaryRow}", $data['totalHours']);
$sheet->setCellValue("H{$summaryRow}", $data['totalRevenue']);

$style = $sheet->getStyle("A{$summaryRow}:I{$summaryRow}");
$style->getFont()->setBold(true)->setSize(10);
$style->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setRGB(self::COLOR_HEADER_BG);
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN)->getColor()->setRGB(self::COLOR_BORDER);
$sheet->getStyle("F{$summaryRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
$sheet->getRowDimension($summaryRow)->setRowHeight(28);
}

private function excelApplyStyles(Worksheet $sheet, int $lastDataRow): void
{
if ($lastDataRow < 2) {
return;
}

$dataRange = "A2:I{$lastDataRow}";
$sheet->getStyle($dataRange)->getFont()->setSize(10);
$sheet->getStyle($dataRange)->getAlignment()->setVertical(Alignment::VERTICAL_CENTER);
$sheet->getStyle($dataRange)->getBorders()->getBottom()
->setBorderStyle(Border::BORDER_HAIR)->getColor()->setRGB(self::COLOR_BORDER);

$sheet->getStyle("A2:A{$lastDataRow}")->getNumberFormat()->setFormatCode('DD.MM.YYYY');

$sheet->getStyle("G2:G{$lastDataRow}")->getNumberFormat()->setFormatCode('#,##0.00');
$sheet->getStyle("G2:G{$lastDataRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);

$summaryRow = $lastDataRow + 1;
$revenueRange = "H2:H{$summaryRow}";
$sheet->getStyle($revenueRange)->getNumberFormat()->setFormatCode('#,##0.00\ "€"');
$sheet->getStyle($revenueRange)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);

$sheet->getStyle("G{$summaryRow}")->getNumberFormat()->setFormatCode('#,##0.00');
$sheet->getStyle("G{$summaryRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);

$sheet->getStyle("I2:I{$lastDataRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);

$sheet->setAutoFilter("A1:I{$lastDataRow}");
$sheet->freezePane('A2');
}

// ── CSV ──────────────────────────────────────────────────────────────────

/**
* @param TimeEntry[] $entries
* @param array<int, string> $userMap
*/
public function generateCsv(array $entries, array $userMap): string
{
$data = $this->prepareData($entries, $userMap);
$yes = $this->t('app.report.export_yes');
$no = $this->t('app.report.export_no');
$tmpFile = tempnam(sys_get_temp_dir(), 'report_') . '.csv';
$handle = fopen($tmpFile, 'w');

fwrite($handle, "\xEF\xBB\xBF");
fputcsv($handle, $this->headers(), ';');

foreach ($data['rows'] as $r) {
fputcsv($handle, [
$r['date']->format('d.m.Y'),
$r['client'],
$r['project'],
$r['service'],
$r['user'],
$r['note'],
number_format($r['hours'], 2, ',', ''),
$r['revenue'] !== null ? number_format($r['revenue'], 2, ',', '') : '',
$r['invoiced'] ? $yes : $no,
], ';');
}

if (!empty($data['rows'])) {
fputcsv($handle, [
'', '', '', '', '', $this->t('app.report.export_sum'),
number_format($data['totalHours'], 2, ',', ''),
number_format($data['totalRevenue'], 2, ',', ''),
'',
], ';');
}

fclose($handle);

return $tmpFile;
}

// ── PDF ──────────────────────────────────────────────────────────────────

/**
* @param TimeEntry[] $entries
* @param array<int, string> $userMap
*/
public function generatePdf(array $entries, array $userMap, string $accountName): string
{
$data = $this->prepareData($entries, $userMap);
$html = $this->buildPdfHtml($data, $accountName);

$options = new Options();
$options->setDefaultFont('Helvetica');
$options->setIsRemoteEnabled(false);

$dompdf = new Dompdf($options);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'landscape');
$dompdf->render();

$tmpFile = tempnam(sys_get_temp_dir(), 'report_') . '.pdf';
file_put_contents($tmpFile, $dompdf->output());

return $tmpFile;
}

private function buildPdfHtml(array $data, string $accountName): string
{
$date = date('d.m.Y');
$title = $this->t('app.report.export_title', ['%account%' => $accountName]);
$created = $this->t('app.report.export_created_at', ['%date%' => $date]);
$count = count($data['rows']);
$countLabel = $count === 1
? $this->t('app.report.export_entry_count_one')
: $this->t('app.report.export_entry_count', ['%count%' => $count]);

$headers = $this->headers();
$invoicedTh = $this->t('app.report.export_col_invoiced_short');
$sumLabel = $this->t('app.report.export_sum');
$yes = $this->t('app.report.export_yes');
$no = $this->t('app.report.export_no');

$thHtml = '<th>' . htmlspecialchars($headers[0]) . '</th>'
. '<th>' . htmlspecialchars($headers[1]) . '</th>'
. '<th>' . htmlspecialchars($headers[2]) . '</th>'
. '<th>' . htmlspecialchars($headers[3]) . '</th>'
. '<th>' . htmlspecialchars($headers[4]) . '</th>'
. '<th>' . htmlspecialchars($headers[5]) . '</th>'
. '<th class="num">' . htmlspecialchars($headers[6]) . '</th>'
. '<th class="num">' . htmlspecialchars($headers[7]) . '</th>'
. '<th class="center">' . htmlspecialchars($invoicedTh) . '</th>';

$rowsHtml = '';
foreach ($data['rows'] as $i => $r) {
$stripe = $i % 2 === 1 ? ' style="background:#f7f9fc"' : '';
$revenue = $r['revenue'] !== null ? number_format($r['revenue'], 2, ',', '.') . ' €' : '';
$hours = number_format($r['hours'], 2, ',', '.');

$rowsHtml .= "<tr{$stripe}>"
. '<td>' . htmlspecialchars($r['date']->format('d.m.Y')) . '</td>'
. '<td>' . htmlspecialchars($r['client']) . '</td>'
. '<td>' . htmlspecialchars($r['project']) . '</td>'
. '<td>' . htmlspecialchars($r['service']) . '</td>'
. '<td>' . htmlspecialchars($r['user']) . '</td>'
. '<td class="note">' . htmlspecialchars($r['note']) . '</td>'
. '<td class="num">' . $hours . '</td>'
. '<td class="num">' . $revenue . '</td>'
. '<td class="center">' . ($r['invoiced'] ? $yes : $no) . '</td>'
. '</tr>';
}

$totalHours = number_format($data['totalHours'], 2, ',', '.');
$totalRevenue = number_format($data['totalRevenue'], 2, ',', '.') . ' €';
$sumLabel = htmlspecialchars($sumLabel);

return <<<HTML
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<style>
@page { margin: 0; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Helvetica, Arial, sans-serif; font-size: 9px; color: #2d3748; padding: 18mm 16mm; }
.header { padding: 0 0 12px; border-bottom: 2px solid #3a7bbf; margin-bottom: 12px; }
.header h1 { font-size: 16px; color: #3a7bbf; font-weight: 700; }
.header .meta { font-size: 9px; color: #718096; margin-top: 3px; }
table { width: 100%; border-collapse: collapse; }
th { background: #e8eff7; color: #2d3748; font-size: 8px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em; text-align: left;
padding: 6px 8px; border-bottom: 1px solid #d1d9e6; }
td { padding: 5px 8px; border-bottom: 1px solid #e8ecf1; font-size: 9px; vertical-align: top; }
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.center { text-align: center; }
.note { color: #718096; max-width: 200px; overflow: hidden; text-overflow: ellipsis; }
.summary td { font-weight: 700; background: #e8eff7; border-top: 2px solid #d1d9e6; border-bottom: none; padding: 7px 8px; }
.footer { margin-top: 16px; font-size: 8px; color: #a0aec0; text-align: right; }
</style>
</head>
<body>
<div class="header">
<h1>{$title}</h1>
<div class="meta">{$created} · {$countLabel}</div>
</div>
<table>
<thead><tr>{$thHtml}</tr></thead>
<tbody>
{$rowsHtml}
<tr class="summary">
<td colspan="6" class="num">{$sumLabel}</td>
<td class="num">{$totalHours}</td>
<td class="num">{$totalRevenue}</td>
<td></td>
</tr>
</tbody>
</table>
<div class="footer">{$accountName} · {$created}</div>
</body>
</html>
HTML;
}
}

+ 0
- 18
httpdocs/src/Twig/Runtime/AppExtensionRuntime.php Wyświetl plik

@@ -1,18 +0,0 @@
<?php

namespace App\Twig\Runtime;

use Twig\Extension\RuntimeExtensionInterface;

class AppExtensionRuntime implements RuntimeExtensionInterface
{
public function __construct()
{
// Inject dependencies if needed
}

public function doSomething($value)
{
// ...
}
}

+ 5
- 0
httpdocs/templates/_atoms/icon-csv.html.twig Wyświetl plik

@@ -0,0 +1,5 @@
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.5 1.5h5l3 3v9a1 1 0 01-1 1h-7a1 1 0 01-1-1v-11a1 1 0 011-1z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.5 1.5v3h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 9h6M5 11h6M8 7v6" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
</svg>

+ 5
- 0
httpdocs/templates/_atoms/icon-excel.html.twig Wyświetl plik

@@ -0,0 +1,5 @@
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.5 1.5h5l3 3v9a1 1 0 01-1 1h-7a1 1 0 01-1-1v-11a1 1 0 011-1z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.5 1.5v3h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 7.5l4 5M10 7.5l-4 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>

+ 5
- 0
httpdocs/templates/_atoms/icon-pdf.html.twig Wyświetl plik

@@ -0,0 +1,5 @@
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.5 1.5h5l3 3v9a1 1 0 01-1 1h-7a1 1 0 01-1-1v-11a1 1 0 011-1z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.5 1.5v3h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 8h2.5a1 1 0 010 2H5V8zm0 0v4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

+ 6
- 0
httpdocs/templates/_atoms/icon-print.html.twig Wyświetl plik

@@ -0,0 +1,6 @@
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 5V1.5h8V5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="2" y="5" width="12" height="6" rx="1" stroke="currentColor" stroke-width="1.2"/>
<path d="M4 9.5h8v5H4z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="10.5" cy="7.5" r=".75" fill="currentColor"/>
</svg>

+ 1
- 1
httpdocs/templates/_components/register-success.html.twig Wyświetl plik

@@ -10,5 +10,5 @@
<div class="register-success__icon{% if modifier is defined and modifier %} register-success__icon{{ modifier }}{% endif %}">{{ icon }}</div>
<h1 class="register-success__title">{{ title }}</h1>
<p class="register-success__text">{{ text|raw }}</p>
<a href="{{ btn_href }}" class="btn btn-primary" style="margin-top: 1.5rem;">{{ btn_label }}</a>
<a href="{{ btn_href }}" class="btn btn-primary register-success__btn">{{ btn_label }}</a>
</div>

+ 15
- 0
httpdocs/templates/_macros/helpers.html.twig Wyświetl plik

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

+ 1
- 1
httpdocs/templates/_sections/nav.html.twig Wyświetl plik

@@ -45,7 +45,7 @@

{# Hamburger-Navigation — nur im Minimal-Theme sichtbar (via CSS) #}
<div class="hamburger-nav" id="hamburger-nav">
<button class="hamburger-nav__toggle" id="hamburger-toggle" aria-label="Menü öffnen" aria-expanded="false">
<button class="hamburger-nav__toggle" id="hamburger-toggle" aria-label="{{ 'app.nav.menu_open'|trans }}" aria-expanded="false">
<span class="hamburger-nav__icon"></span>
</button>
<div class="hamburger-nav__panel" id="hamburger-panel" hidden>


+ 6
- 26
httpdocs/templates/_sections/tt-header.html.twig Wyświetl plik

@@ -3,42 +3,22 @@
months, monthsShort, weekdays, weekDays, currentWeekNumber,
prevWeekUrl, nextWeekUrl #}

{% from '_macros/helpers.html.twig' import smart_date %}

<header class="tt-header">
{# Minimal-Modus: kompakter Header mit Toggle #}
<div class="tt-header__minimal-bar">
<div class="tt-header__minimal-date">
{% set activStr = currentDate|date('Y-m-d') %}
{% set monthName = months[currentDate|date('n') - 1] %}
{% set weekdayIdx = currentDate|date('N') - 1 %}
{% if activStr == todayStr %}
{{ 'app.date.today'|trans }}, {{ currentDate|date('j') }}. {{ monthName }}
{% elseif activStr == tomorrowStr %}
{{ 'app.date.tomorrow'|trans }}, {{ currentDate|date('j') }}. {{ monthName }}
{% elseif activStr == yesterdayStr %}
{{ 'app.date.yesterday'|trans }}, {{ currentDate|date('j') }}. {{ monthName }}
{% else %}
{{ weekdays[weekdayIdx] }}, {{ currentDate|date('j') }}. {{ monthName }}
{% endif %}
{{ smart_date(currentDate, todayStr, tomorrowStr, yesterdayStr, months, weekdays) }}
</div>
<button type="button" class="tt-header__week-toggle" id="btn-week-toggle" aria-expanded="false" title="Wochenansicht">
KW {{ currentWeekNumber }} ▾
<button type="button" class="tt-header__week-toggle" id="btn-week-toggle" aria-expanded="false" title="{{ 'app.nav.week_view'|trans }}">
{{ 'app.date.week_short'|trans }} {{ currentWeekNumber }} ▾
</button>
</div>

<div class="tt-header__meta">
<div class="tt-header__date">
{% set activStr = currentDate|date('Y-m-d') %}
{% set monthName = months[currentDate|date('n') - 1] %}
{% set weekdayIdx = currentDate|date('N') - 1 %}
{% if activStr == todayStr %}
{{ 'app.date.today'|trans }}, {{ currentDate|date('j') }}. {{ monthName }}
{% elseif activStr == tomorrowStr %}
{{ 'app.date.tomorrow'|trans }}, {{ currentDate|date('j') }}. {{ monthName }}
{% elseif activStr == yesterdayStr %}
{{ 'app.date.yesterday'|trans }}, {{ currentDate|date('j') }}. {{ monthName }}
{% else %}
{{ weekdays[weekdayIdx] }}, {{ currentDate|date('j') }}. {{ monthName }}
{% endif %}
{{ smart_date(currentDate, todayStr, tomorrowStr, yesterdayStr, months, weekdays) }}
</div>
<div class="tt-header__kw">{{ 'app.date.week_label'|trans }} {{ currentWeekNumber }}</div>
</div>


+ 41
- 30
httpdocs/templates/account/index.html.twig Wyświetl plik

@@ -2,7 +2,7 @@
{% extends 'base.html.twig' %}

{% block title %}
{% if tab == 'account' %}Account{% else %}Mein Benutzer{% endif %}
{% if tab == 'account' %}{{ 'app.account.page_title_account'|trans }}{% else %}{{ 'app.account.page_title_user'|trans }}{% endif %}
{% endblock %}

{% block body %}
@@ -12,6 +12,18 @@
tab: '{{ tab }}',
isSuperAdmin: {{ isSuperAdmin ? 'true' : 'false' }},
theme: '{{ user.theme|default('standard') }}',
i18n: {
invalidHex: {{ 'app.account.invalid_hex'|trans|json_encode|raw }},
saved: {{ 'app.account.saved'|trans|json_encode|raw }},
savedReloading: {{ 'app.account.saved_reloading'|trans|json_encode|raw }},
ownerChanged: {{ 'app.account.owner_changed'|trans|json_encode|raw }},
ownerConfirm: {{ 'app.account.owner_confirm'|trans|json_encode|raw }},
themeChanged: {{ 'app.account.theme_changed'|trans|json_encode|raw }},
passwordMismatch: {{ 'app.account.password_mismatch'|trans|json_encode|raw }},
changeLabel: {{ 'app.account.change_label'|trans|json_encode|raw }},
cancelLabel: {{ 'app.account.cancel_label'|trans|json_encode|raw }},
errorGeneric: {{ 'app.account.error_generic'|trans|json_encode|raw }},
},
};
</script>

@@ -19,18 +31,18 @@

<div class="account-header">
<h1 class="account-header__title">
{% if tab == 'account' %}Account{% else %}Mein Benutzer{% endif %}
{% if tab == 'account' %}{{ 'app.account.page_title_account'|trans }}{% else %}{{ 'app.account.page_title_user'|trans }}{% endif %}
</h1>

{% if isAdmin %}
<nav class="account-tabs">
<a href="{{ path('account_index', {tab: 'account'}) }}"
class="account-tab{% if tab == 'account' %} account-tab--active{% endif %}">
Account
{{ 'app.account.tab_account'|trans }}
</a>
<a href="{{ path('account_index', {tab: 'user'}) }}"
class="account-tab{% if tab == 'user' %} account-tab--active{% endif %}">
Mein Benutzer
{{ 'app.account.tab_user'|trans }}
</a>
</nav>
{% endif %}
@@ -44,16 +56,16 @@

<div class="account-form__grid" id="account-form">

<label class="account-form__label" for="account-name">Firmenname</label>
<label class="account-form__label" for="account-name">{{ 'app.account.label_company_name'|trans }}</label>
<div class="account-form__field">
<input type="text" id="account-name" class="input"
value="{{ account.name|e('html_attr') }}" />
<span class="account-form__hint">
Subdomain: <strong>{{ account.slug }}.{{ app.request.host|split('.')|slice(1)|join('.') }}</strong> — ändert sich nicht.
{{ 'app.account.hint_subdomain'|trans({'%subdomain%': account.slug ~ '.' ~ (app.request.host|split('.')|slice(1)|join('.'))}) }}
</span>
</div>

<label class="account-form__label" for="account-interval">Zeitintervall</label>
<label class="account-form__label" for="account-interval">{{ 'app.account.label_interval'|trans }}</label>
<div class="account-form__field">
<select id="account-interval" class="select">
{% for value, label in intervalOptions %}
@@ -62,11 +74,11 @@
</option>
{% endfor %}
</select>
<span class="account-form__hint">Auf welche Einheit werden erfasste Zeiten aufgerundet.</span>
<span class="account-form__hint">{{ 'app.account.hint_interval'|trans }}</span>
</div>

{% if isSuperAdmin %}
<label class="account-form__label" for="account-color">Hauptfarbe</label>
<label class="account-form__label" for="account-color">{{ 'app.account.label_color'|trans }}</label>
<div class="account-form__field">
<div class="account-color-field">
<input type="color" id="account-color-picker"
@@ -77,13 +89,13 @@
class="input account-color-field__hex"
maxlength="7" placeholder="#3a7bbf" autocomplete="off" />
</div>
<span class="account-form__hint">Hex-Farbe für das Standard-Theme aller Benutzer. Standard: #3a7bbf</span>
<span class="account-form__hint">{{ 'app.account.hint_color'|trans }}</span>
</div>
{% endif %}

<div class="account-form__actions">
<button type="button" class="btn btn-primary" id="btn-account-save">Sichern</button>
<a href="{{ path('account_index', {tab: 'account'}) }}" class="btn btn-secondary">Abbrechen</a>
<button type="button" class="btn btn-primary" id="btn-account-save">{{ 'app.entry.btn_save'|trans }}</button>
<a href="{{ path('account_index', {tab: 'account'}) }}" class="btn btn-secondary">{{ 'app.entry.btn_cancel'|trans }}</a>
</div>

</div>
@@ -93,42 +105,42 @@

<div class="account-form__grid" id="user-form">

<label class="account-form__label" for="user-firstname">Vorname</label>
<label class="account-form__label" for="user-firstname">{{ 'app.account.label_first_name'|trans }}</label>
<div class="account-form__field">
<input type="text" id="user-firstname" class="input"
value="{{ user.firstName|e('html_attr') }}" />
</div>

<label class="account-form__label" for="user-lastname">Nachname</label>
<label class="account-form__label" for="user-lastname">{{ 'app.account.label_last_name'|trans }}</label>
<div class="account-form__field">
<input type="text" id="user-lastname" class="input"
value="{{ user.lastName|e('html_attr') }}" />
</div>

<label class="account-form__label" for="user-email">E-Mail</label>
<label class="account-form__label" for="user-email">{{ 'app.account.label_email'|trans }}</label>
<div class="account-form__field">
<input type="email" id="user-email" class="input"
value="{{ user.email|e('html_attr') }}" />
</div>

<label class="account-form__label">Passwort</label>
<label class="account-form__label">{{ 'app.account.label_password'|trans }}</label>
<div class="account-form__field">
<a href="#" class="account-form__link" id="btn-pw-toggle">ändern</a>
<a href="#" class="account-form__link" id="btn-pw-toggle">{{ 'app.account.change_label'|trans }}</a>
</div>

<div class="account-form__pw-section" id="pw-section" hidden>

<label class="account-form__label" for="user-pw-current">Aktuelles Passwort</label>
<label class="account-form__label" for="user-pw-current">{{ 'app.account.label_current_password'|trans }}</label>
<div class="account-form__field">
<input type="password" id="user-pw-current" class="input" autocomplete="current-password" />
</div>

<label class="account-form__label" for="user-pw-new">Neues Passwort</label>
<label class="account-form__label" for="user-pw-new">{{ 'app.account.label_new_password'|trans }}</label>
<div class="account-form__field">
<input type="password" id="user-pw-new" class="input" autocomplete="new-password" minlength="8" />
</div>

<label class="account-form__label" for="user-pw-repeat">Wiederholen</label>
<label class="account-form__label" for="user-pw-repeat">{{ 'app.account.label_password_repeat'|trans }}</label>
<div class="account-form__field">
<input type="password" id="user-pw-repeat" class="input" autocomplete="new-password" />
</div>
@@ -136,8 +148,8 @@
</div>

<div class="account-form__actions">
<button type="button" class="btn btn-primary" id="btn-user-save">Sichern</button>
<a href="{{ path('account_index', {tab: 'user'}) }}" class="btn btn-secondary">Abbrechen</a>
<button type="button" class="btn btn-primary" id="btn-user-save">{{ 'app.entry.btn_save'|trans }}</button>
<a href="{{ path('account_index', {tab: 'user'}) }}" class="btn btn-secondary">{{ 'app.entry.btn_cancel'|trans }}</a>
</div>

</div>
@@ -149,18 +161,18 @@
<hr class="account-form__divider">
</div>

<label class="account-form__label">Darstellung</label>
<label class="account-form__label">{{ 'app.account.label_appearance'|trans }}</label>
<div class="account-form__field">
<div class="theme-picker" id="theme-picker">
<label class="theme-option{% if user.theme|default('standard') == 'standard' %} theme-option--active{% endif %}" data-theme="standard">
<input type="radio" name="theme" value="standard"{% if user.theme|default('standard') == 'standard' %} checked{% endif %}>
<span class="theme-option__label">Standard</span>
<span class="theme-option__desc">Volle Navigation, alle Felder sichtbar</span>
<span class="theme-option__label">{{ 'app.account.theme_standard'|trans }}</span>
<span class="theme-option__desc">{{ 'app.account.theme_standard_desc'|trans }}</span>
</label>
<label class="theme-option{% if user.theme|default('standard') == 'minimal' %} theme-option--active{% endif %}" data-theme="minimal">
<input type="radio" name="theme" value="minimal"{% if user.theme|default('standard') == 'minimal' %} checked{% endif %}>
<span class="theme-option__label">Minimal</span>
<span class="theme-option__desc">Ablenkungsfreie Ansicht, Hamburger-Menü</span>
<span class="theme-option__label">{{ 'app.account.theme_minimal'|trans }}</span>
<span class="theme-option__desc">{{ 'app.account.theme_minimal_desc'|trans }}</span>
</label>
</div>
</div>
@@ -176,7 +188,7 @@
<div class="account-card account-card--owner">
<div class="account-form__grid">

<label class="account-form__label" for="superadmin-select">Besitzer des Accounts</label>
<label class="account-form__label" for="superadmin-select">{{ 'app.account.label_owner'|trans }}</label>
<div class="account-form__field">
<select id="superadmin-select" class="select"
{% if superAdminUserId != user.id %}disabled{% endif %}>
@@ -187,8 +199,7 @@
{% endfor %}
</select>
<p class="account-form__hint account-form__hint--owner">
Der Besitzer des Accounts ist für die Verwaltung der Zahlungsdaten zuständig.
Nur er kann den Account kündigen.
{{ 'app.account.hint_owner'|trans }}
</p>
</div>



+ 53
- 28
httpdocs/templates/client/index.html.twig Wyświetl plik

@@ -1,7 +1,7 @@
{# templates/client/index.html.twig #}
{% extends 'base.html.twig' %}

{% block title %}Kunden{% endblock %}
{% block title %}{{ 'app.client.page_title'|trans }}{% endblock %}

{% block body %}

@@ -9,46 +9,71 @@
window.CRUD = {
apiBase: '/api/clients',
clients: null,
i18n: {
confirmDelete: {{ 'app.crud.confirm_delete'|trans|json_encode|raw }},
confirmArchive: {{ 'app.crud.confirm_archive'|trans|json_encode|raw }},
errorSave: {{ 'app.crud.error_save'|trans|json_encode|raw }},
errorDelete: {{ 'app.crud.error_delete'|trans|json_encode|raw }},
errorArchive: {{ 'app.crud.error_archive'|trans|json_encode|raw }},
errorRestore: {{ 'app.crud.error_restore'|trans|json_encode|raw }},
errorNoName: {{ 'app.crud.error_no_name'|trans|json_encode|raw }},
selectPh: {{ 'app.crud.select_ph'|trans|json_encode|raw }},
labelName: {{ 'app.crud.label_name'|trans|json_encode|raw }},
labelRate: {{ 'app.crud.label_rate'|trans|json_encode|raw }},
labelNote: {{ 'app.crud.label_note'|trans|json_encode|raw }},
labelClient: {{ 'app.crud.label_client'|trans|json_encode|raw }},
labelBillable: {{ 'app.service.label_billable'|trans|json_encode|raw }},
billableLabel: {{ 'app.service.billable_checkbox'|trans|json_encode|raw }},
btnSave: {{ 'app.entry.btn_save'|trans|json_encode|raw }},
btnCancel: {{ 'app.entry.btn_cancel'|trans|json_encode|raw }},
btnEdit: {{ 'app.entry.btn_edit'|trans|json_encode|raw }},
btnDelete: {{ 'app.entry.btn_delete'|trans|json_encode|raw }},
btnRestore: {{ 'app.crud.btn_restore'|trans|json_encode|raw }},
groupBillable: {{ 'app.service.billable'|trans|json_encode|raw }},
groupNotBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }},
projectSingular: {{ 'app.crud.project_singular'|trans|json_encode|raw }},
projectPlural: {{ 'app.crud.project_plural'|trans|json_encode|raw }},
},
};
</script>

<div class="crud-page">

<div class="crud-page__header">
<h1 class="crud-page__title">Kunden</h1>
<button class="btn btn-primary" id="btn-new">Neuer Kunde</button>
<h1 class="crud-page__title">{{ 'app.client.page_title'|trans }}</h1>
<button class="btn btn-primary" id="btn-new">{{ 'app.client.btn_new'|trans }}</button>
</div>

<div class="crud-create" id="crud-create">
<div class="entry-form__grid">

<label class="entry-form__label">Name</label>
<label class="entry-form__label">{{ 'app.crud.label_name'|trans }}</label>
<div class="entry-form__field">
<input type="text" id="create-name" class="input" placeholder="Kundenname" />
<input type="text" id="create-name" class="input" placeholder="{{ 'app.client.placeholder_name'|trans }}" />
</div>

<label class="entry-form__label">Stundensatz</label>
<div class="entry-form__field" style="gap: 8px">
<input type="number" id="create-rate" class="input" style="width:100px" placeholder="0,00" step="0.01" min="0" />
<span style="color: var(--color-text-muted, #7a8a9a); font-size: 0.875rem">€</span>
<label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label>
<div class="entry-form__field entry-form__field--rate">
<input type="number" id="create-rate" class="input input--rate" placeholder="0,00" step="0.01" min="0" />
<span class="entry-form__unit">&euro;</span>
</div>

<label class="entry-form__label">Bemerkung</label>
<label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label>
<div class="entry-form__field">
<textarea id="create-note" class="textarea" rows="2"></textarea>
</div>

<div class="entry-form__actions">
<button type="button" class="btn btn-primary" id="btn-create-save">Erstellen</button>
<button type="button" class="btn btn-secondary" id="btn-create-cancel">Abbrechen</button>
<button type="button" class="btn btn-primary" id="btn-create-save">{{ 'app.entry.btn_create'|trans }}</button>
<button type="button" class="btn btn-secondary" id="btn-create-cancel">{{ 'app.entry.btn_cancel'|trans }}</button>
</div>

</div>
</div>

<div class="crud-tabs">
<button class="crud-tab crud-tab--active" data-tab="active">Aktiv</button>
<button class="crud-tab" data-tab="archived">Archiviert</button>
<button class="crud-tab crud-tab--active" data-tab="active">{{ 'app.crud.tab_active'|trans }}</button>
<button class="crud-tab" data-tab="archived">{{ 'app.crud.tab_archived'|trans }}</button>
</div>

<div class="crud-list" id="crud-list">
@@ -66,19 +91,19 @@ window.CRUD = {
<span class="crud-row__name">{{ client.name }}</span>
<span class="crud-row__meta">
{% set count = client.projects|length %}
{{ count }} {{ count == 1 ? 'Projekt' : 'Projekte' }}
{{ count }} {{ count == 1 ? 'app.crud.project_singular'|trans : 'app.crud.project_plural'|trans }}
</span>
</div>
<div class="crud-row__actions">
{% if client.isArchived() %}
<button class="crud-row__btn crud-row__btn--restore" data-action="unarchive" title="Wiederherstellen">
<button class="crud-row__btn crud-row__btn--restore" data-action="unarchive" title="{{ 'app.crud.btn_restore'|trans }}">
{% include '_atoms/icon-restore.html.twig' %}
</button>
{% else %}
<button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="Bearbeiten">
<button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="{{ 'app.entry.btn_edit'|trans }}">
{% include '_atoms/icon-edit.html.twig' %}
</button>
<button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="Löschen">
<button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="{{ 'app.entry.btn_delete'|trans }}">
{% include '_atoms/icon-delete.html.twig' %}
</button>
{% endif %}
@@ -89,26 +114,26 @@ window.CRUD = {
<div class="crud-row__edit" hidden>
<div class="entry-form__grid entry-form__grid--inline">

<label class="entry-form__label">Name</label>
<label class="entry-form__label">{{ 'app.crud.label_name'|trans }}</label>
<div class="entry-form__field">
<input type="text" class="input edit-name" value="{{ client.name }}" />
</div>

<label class="entry-form__label">Stundensatz</label>
<div class="entry-form__field" style="gap: 8px">
<input type="number" class="input edit-rate" style="width:100px"
<label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label>
<div class="entry-form__field entry-form__field--rate">
<input type="number" class="input input--rate edit-rate"
value="{{ client.hourlyRate|default('') }}" step="0.01" min="0" />
<span style="color: var(--color-text-muted, #7a8a9a); font-size: 0.875rem">€</span>
<span class="entry-form__unit">&euro;</span>
</div>

<label class="entry-form__label">Bemerkung</label>
<label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label>
<div class="entry-form__field">
<textarea class="textarea edit-note" rows="2">{{ client.note|default('') }}</textarea>
</div>

<div class="entry-form__actions">
<button type="button" class="btn btn-primary" data-action="save">Sichern</button>
<button type="button" class="btn btn-secondary" data-action="cancel">Abbrechen</button>
<button type="button" class="btn btn-primary" data-action="save">{{ 'app.entry.btn_save'|trans }}</button>
<button type="button" class="btn btn-secondary" data-action="cancel">{{ 'app.entry.btn_cancel'|trans }}</button>
</div>

</div>
@@ -117,8 +142,8 @@ window.CRUD = {

</div>
{% else %}
<div class="empty-state" style="padding: 2rem">
<p class="empty-state__title">Noch keine Kunden angelegt.</p>
<div class="empty-state">
<p class="empty-state__title">{{ 'app.client.empty'|trans }}</p>
</div>
{% endfor %}
</div>


+ 5
- 5
httpdocs/templates/home/index.html.twig Wyświetl plik

@@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>spawntree Timetracker</title>
<title>{{ 'app.home.title'|trans }}</title>
{{ encore_entry_link_tags('app') }}
</head>
<body class="home-body">
@@ -12,15 +12,15 @@
<header class="home-header">
<div class="home-header__inner">
<div class="home-header__brand">spawntree <span>Timetracker</span></div>
<a href="{{ path('app_register') }}" class="btn btn-primary">Kostenlos starten</a>
<a href="{{ path('app_register') }}" class="btn btn-primary">{{ 'app.home.btn_start'|trans }}</a>
</div>
</header>

<main class="home-hero">
<div class="home-hero__inner">
<h1 class="home-hero__title">Zeiterfassung,<br>die nicht nervt.</h1>
<p class="home-hero__sub">Einfach, schnell, ohne Overhead. Dein Team, deine Projekte, deine Zeit.</p>
<a href="{{ path('app_register') }}" class="btn btn-primary home-hero__cta">Jetzt registrieren →</a>
<h1 class="home-hero__title">{{ 'app.home.hero_title'|trans }}</h1>
<p class="home-hero__sub">{{ 'app.home.hero_sub'|trans }}</p>
<a href="{{ path('app_register') }}" class="btn btn-primary home-hero__cta">{{ 'app.home.btn_register'|trans }}</a>
</div>
</main>



+ 1
- 1
httpdocs/templates/invite/error.html.twig Wyświetl plik

@@ -10,7 +10,7 @@
<body class="login-body">
<div class="login-card">
<div class="login-card__title">{{ 'app.invite_error.title'|trans }}</div>
<div class="login-card__error">{{ error }}</div>
<div class="login-card__error">{{ ('app.invite_error.' ~ error)|trans }}</div>
</div>
</body>
</html>

+ 7
- 2
httpdocs/templates/invite/set_password.html.twig Wyświetl plik

@@ -14,11 +14,16 @@
<div class="login-card__title">{{ invite.account.name }}</div>
<p class="login-card__sub">{{ 'app.set_password.subtitle'|trans({'%name%': invite.firstName}) }}</p>

{% if error %}
<div class="login-card__error">{{ error }}</div>
{% if error == 'csrf' %}
<div class="login-card__error">{{ 'app.csrf_error'|trans }}</div>
{% elseif error == 'too_short' %}
<div class="login-card__error">{{ 'app.set_password.error_too_short'|trans }}</div>
{% elseif error == 'mismatch' %}
<div class="login-card__error">{{ 'app.set_password.error_mismatch'|trans }}</div>
{% endif %}

<form class="login-form" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('invite_password') }}" />

<div class="login-form__grid">



+ 49
- 24
httpdocs/templates/project/index.html.twig Wyświetl plik

@@ -1,57 +1,82 @@
{# templates/project/index.html.twig #}
{% extends 'base.html.twig' %}

{% block title %}Projekte{% endblock %}
{% block title %}{{ 'app.project.page_title'|trans }}{% endblock %}

{% block body %}
<script>
window.CRUD = {
apiBase: '/api/projects',
clients: {{ clients|map(c => { id: c.id, name: c.name })|json_encode|raw }},
i18n: {
confirmDelete: {{ 'app.crud.confirm_delete'|trans|json_encode|raw }},
confirmArchive: {{ 'app.crud.confirm_archive'|trans|json_encode|raw }},
errorSave: {{ 'app.crud.error_save'|trans|json_encode|raw }},
errorDelete: {{ 'app.crud.error_delete'|trans|json_encode|raw }},
errorArchive: {{ 'app.crud.error_archive'|trans|json_encode|raw }},
errorRestore: {{ 'app.crud.error_restore'|trans|json_encode|raw }},
errorNoName: {{ 'app.crud.error_no_name'|trans|json_encode|raw }},
selectPh: {{ 'app.crud.select_ph'|trans|json_encode|raw }},
labelName: {{ 'app.crud.label_name'|trans|json_encode|raw }},
labelRate: {{ 'app.crud.label_rate'|trans|json_encode|raw }},
labelNote: {{ 'app.crud.label_note'|trans|json_encode|raw }},
labelClient: {{ 'app.crud.label_client'|trans|json_encode|raw }},
labelBillable: {{ 'app.service.label_billable'|trans|json_encode|raw }},
billableLabel: {{ 'app.service.billable_checkbox'|trans|json_encode|raw }},
btnSave: {{ 'app.entry.btn_save'|trans|json_encode|raw }},
btnCancel: {{ 'app.entry.btn_cancel'|trans|json_encode|raw }},
btnEdit: {{ 'app.entry.btn_edit'|trans|json_encode|raw }},
btnDelete: {{ 'app.entry.btn_delete'|trans|json_encode|raw }},
btnRestore: {{ 'app.crud.btn_restore'|trans|json_encode|raw }},
groupBillable: {{ 'app.service.billable'|trans|json_encode|raw }},
groupNotBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }},
projectSingular: {{ 'app.crud.project_singular'|trans|json_encode|raw }},
projectPlural: {{ 'app.crud.project_plural'|trans|json_encode|raw }},
},
};
</script>

<div class="crud-page">

<div class="crud-page__header">
<h1 class="crud-page__title">Projekte</h1>
<button class="btn btn-primary" id="btn-new">Neues Projekt</button>
<h1 class="crud-page__title">{{ 'app.project.page_title'|trans }}</h1>
<button class="btn btn-primary" id="btn-new">{{ 'app.project.btn_new'|trans }}</button>
</div>

<div class="crud-create" id="crud-create">
<div class="entry-form__grid">

<label class="entry-form__label">Name</label>
<label class="entry-form__label">{{ 'app.crud.label_name'|trans }}</label>
<div class="entry-form__field">
<input type="text" id="create-name" class="input" placeholder="Projektname" />
<input type="text" id="create-name" class="input" placeholder="{{ 'app.project.placeholder_name'|trans }}" />
</div>

<label class="entry-form__label">Kunde</label>
<label class="entry-form__label">{{ 'app.crud.label_client'|trans }}</label>
<div class="entry-form__field">
<select id="create-client" class="select">
<option value="">Bitte wählen</option>
<option value="">{{ 'app.crud.select_ph'|trans }}</option>
{% for client in clients %}
<option value="{{ client.id }}">{{ client.name }}</option>
{% endfor %}
</select>
</div>

<label class="entry-form__label">Bemerkung</label>
<label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label>
<div class="entry-form__field">
<textarea id="create-note" class="textarea" rows="2"></textarea>
</div>

<div class="entry-form__actions">
<button type="button" class="btn btn-primary" id="btn-create-save">Erstellen</button>
<button type="button" class="btn btn-secondary" id="btn-create-cancel">Abbrechen</button>
<button type="button" class="btn btn-primary" id="btn-create-save">{{ 'app.entry.btn_create'|trans }}</button>
<button type="button" class="btn btn-secondary" id="btn-create-cancel">{{ 'app.entry.btn_cancel'|trans }}</button>
</div>

</div>
</div>

<div class="crud-tabs">
<button class="crud-tab crud-tab--active" data-tab="active">Aktiv</button>
<button class="crud-tab" data-tab="archived">Archiviert</button>
<button class="crud-tab crud-tab--active" data-tab="active">{{ 'app.crud.tab_active'|trans }}</button>
<button class="crud-tab" data-tab="archived">{{ 'app.crud.tab_archived'|trans }}</button>
</div>

<div class="crud-list" id="crud-list">
@@ -72,15 +97,15 @@ window.CRUD = {
</div>
<div class="crud-row__actions">
{% if project.isArchived() %}
<button class="crud-row__btn crud-row__btn--restore" data-action="unarchive" title="Wiederherstellen">
<svg viewBox="0 0 16 16" fill="none"><path d="M2 8a6 6 0 1 1 1.5 3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M2 13V9h4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
<button class="crud-row__btn crud-row__btn--restore" data-action="unarchive" title="{{ 'app.crud.btn_restore'|trans }}">
{% include '_atoms/icon-restore.html.twig' %}
</button>
{% else %}
<button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="Bearbeiten">
<svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
<button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="{{ 'app.entry.btn_edit'|trans }}">
{% include '_atoms/icon-edit.html.twig' %}
</button>
<button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="Löschen">
<svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
<button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="{{ 'app.entry.btn_delete'|trans }}">
{% include '_atoms/icon-delete.html.twig' %}
</button>
{% endif %}
</div>
@@ -90,12 +115,12 @@ window.CRUD = {
<div class="crud-row__edit" hidden>
<div class="entry-form__grid entry-form__grid--inline">

<label class="entry-form__label">Name</label>
<label class="entry-form__label">{{ 'app.crud.label_name'|trans }}</label>
<div class="entry-form__field">
<input type="text" class="input edit-name" value="{{ project.name }}" />
</div>

<label class="entry-form__label">Kunde</label>
<label class="entry-form__label">{{ 'app.crud.label_client'|trans }}</label>
<div class="entry-form__field">
<select class="select edit-client">
{% for client in clients %}
@@ -107,14 +132,14 @@ window.CRUD = {
</select>
</div>

<label class="entry-form__label">Bemerkung</label>
<label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label>
<div class="entry-form__field">
<textarea class="textarea edit-note" rows="2">{{ project.note|default('') }}</textarea>
</div>

<div class="entry-form__actions">
<button type="button" class="btn btn-primary" data-action="save">Sichern</button>
<button type="button" class="btn btn-secondary" data-action="cancel">Abbrechen</button>
<button type="button" class="btn btn-primary" data-action="save">{{ 'app.entry.btn_save'|trans }}</button>
<button type="button" class="btn btn-secondary" data-action="cancel">{{ 'app.entry.btn_cancel'|trans }}</button>
</div>

</div>
@@ -124,7 +149,7 @@ window.CRUD = {
</div>
{% else %}
<div class="empty-state">
<p class="empty-state__title">Noch keine Projekte angelegt.</p>
<p class="empty-state__title">{{ 'app.project.empty'|trans }}</p>
</div>
{% endfor %}



+ 14
- 1
httpdocs/templates/registration/register.html.twig Wyświetl plik

@@ -90,7 +90,20 @@
{% endblock %}
{% endembed %}

<script>window.REGISTER_APP_DOMAIN = '{{ appDomain }}';</script>
<script>
window.Register = {
appDomain: '{{ appDomain }}',
i18n: {
btnSubmit: {{ 'app.register.btn_submit'|trans|json_encode|raw }},
sending: {{ 'app.register.sending'|trans|json_encode|raw }},
errorUnknown: {{ 'app.register.error_unknown'|trans|json_encode|raw }},
errorConnection: {{ 'app.register.error_connection'|trans|json_encode|raw }},
successTitle: {{ 'app.register.success_title'|trans|json_encode|raw }},
successText: {{ 'app.register.success_text'|trans|json_encode|raw }},
successHint: {{ 'app.register.success_hint'|trans|json_encode|raw }},
}
};
</script>
{{ encore_entry_script_tags('registration') }}

</body>


+ 6
- 6
httpdocs/templates/report/_filter-panel.html.twig Wyświetl plik

@@ -124,16 +124,16 @@
<select class="select filter-select" data-filter-key="services">
<option value="">...</option>
{% if activeServices|length > 0 %}
<optgroup label="Aktiv">
<optgroup label="{{ 'app.crud.tab_active'|trans }}">
{% for service in activeServices %}
<option value="{{ service.id }}"{% if val == service.id %} selected{% endif %}>{{ service.name }}{% if not service.billable %} (nicht-verrechenbar){% endif %}</option>
<option value="{{ service.id }}"{% if val == service.id %} selected{% endif %}>{{ service.name }}{% if not service.billable %} ({{ 'app.service.not_billable'|trans }}){% endif %}</option>
{% endfor %}
</optgroup>
{% endif %}
{% if archivedServices|length > 0 %}
<optgroup label="Archiviert">
<optgroup label="{{ 'app.crud.tab_archived'|trans }}">
{% for service in archivedServices %}
<option value="{{ service.id }}"{% if val == service.id %} selected{% endif %}>{{ service.name }}{% if not service.billable %} (nicht-verrechenbar){% endif %}</option>
<option value="{{ service.id }}"{% if val == service.id %} selected{% endif %}>{{ service.name }}{% if not service.billable %} ({{ 'app.service.not_billable'|trans }}){% endif %}</option>
{% endfor %}
</optgroup>
{% endif %}
@@ -172,14 +172,14 @@
<select class="select filter-select" data-filter-key="users">
<option value="">...</option>
{% if activeUsers|length > 0 %}
<optgroup label="Aktiv">
<optgroup label="{{ 'app.crud.tab_active'|trans }}">
{% for user in activeUsers %}
<option value="{{ user.id }}"{% if val == user.id %} selected{% endif %}>{{ user.name }}</option>
{% endfor %}
</optgroup>
{% endif %}
{% if archivedUsers|length > 0 %}
<optgroup label="Archiviert">
<optgroup label="{{ 'app.crud.tab_archived'|trans }}">
{% for user in archivedUsers %}
<option value="{{ user.id }}"{% if val == user.id %} selected{% endif %}>{{ user.name }}</option>
{% endfor %}


+ 30
- 1
httpdocs/templates/report/times.html.twig Wyświetl plik

@@ -103,6 +103,35 @@
{{ 'app.report.toolbar_filter'|trans }}
</button>
</div>
<div class="report-toolbar__right">
<button class="report-toolbar__export"
id="btn-export-excel"
type="button"
title="{{ 'app.report.export_excel'|trans }}">
{% include '_atoms/icon-excel.html.twig' %}
</button>
<button class="report-toolbar__export"
id="btn-export-csv"
type="button"
title="{{ 'app.report.export_csv'|trans }}">
{% include '_atoms/icon-csv.html.twig' %}
</button>
<button class="report-toolbar__export"
id="btn-export-pdf"
type="button"
title="{{ 'app.report.export_pdf'|trans }}">
{% include '_atoms/icon-pdf.html.twig' %}
</button>

<span class="report-toolbar__separator"></span>

<button class="report-toolbar__export"
id="btn-print"
type="button"
title="{{ 'app.report.print'|trans }}">
{% include '_atoms/icon-print.html.twig' %}
</button>
</div>
</div>

{# ── Filter-Panel ─────────────────────────────────────────────────── #}
@@ -165,7 +194,7 @@
</div>

<div class="report-table__cell report-table__cell--user">
{{ userMap[entry.userId] ?? ('User #' ~ entry.userId) }}
{{ userMap[entry.userId] ?? 'app.report.user_fallback'|trans({'%id%': entry.userId}) }}
</div>

<div class="report-table__cell report-table__cell--note">


+ 4
- 1
httpdocs/templates/security/forgot_password.html.twig Wyświetl plik

@@ -22,13 +22,16 @@

{% else %}

{% if error == 'invalid_email' %}
{% if error == 'invalid_csrf' %}
<div class="login-card__error">{{ 'app.csrf_error'|trans }}</div>
{% elseif error == 'invalid_email' %}
<div class="login-card__error">{{ 'app.forgot_password.error_invalid_email'|trans }}</div>
{% endif %}

<p class="login-card__sub">{{ 'app.forgot_password.subtitle'|trans }}</p>

<form class="login-form" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('forgot_password') }}" />

<div class="login-form__grid">
<label class="login-form__label" for="email">{{ 'app.login.label_email'|trans }}</label>


+ 4
- 1
httpdocs/templates/security/reset_password.html.twig Wyświetl plik

@@ -29,13 +29,16 @@

{% else %}

{% if error == 'too_short' %}
{% if error == 'invalid_csrf' %}
<div class="login-card__error">{{ 'app.csrf_error'|trans }}</div>
{% elseif error == 'too_short' %}
<div class="login-card__error">{{ 'app.reset_password.error_too_short'|trans }}</div>
{% elseif error == 'mismatch' %}
<div class="login-card__error">{{ 'app.reset_password.error_mismatch'|trans }}</div>
{% endif %}

<form class="login-form" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('reset_password') }}" />

<div class="login-form__grid">



+ 28
- 1
httpdocs/templates/service/index.html.twig Wyświetl plik

@@ -5,7 +5,34 @@

{% block body %}
<script>
window.CRUD = { apiBase: '/api/services' };
window.CRUD = {
apiBase: '/api/services',
i18n: {
confirmDelete: {{ 'app.crud.confirm_delete'|trans|json_encode|raw }},
confirmArchive: {{ 'app.crud.confirm_archive'|trans|json_encode|raw }},
errorSave: {{ 'app.crud.error_save'|trans|json_encode|raw }},
errorDelete: {{ 'app.crud.error_delete'|trans|json_encode|raw }},
errorArchive: {{ 'app.crud.error_archive'|trans|json_encode|raw }},
errorRestore: {{ 'app.crud.error_restore'|trans|json_encode|raw }},
errorNoName: {{ 'app.crud.error_no_name'|trans|json_encode|raw }},
selectPh: {{ 'app.crud.select_ph'|trans|json_encode|raw }},
labelName: {{ 'app.crud.label_name'|trans|json_encode|raw }},
labelRate: {{ 'app.crud.label_rate'|trans|json_encode|raw }},
labelNote: {{ 'app.crud.label_note'|trans|json_encode|raw }},
labelClient: {{ 'app.crud.label_client'|trans|json_encode|raw }},
labelBillable: {{ 'app.service.label_billable'|trans|json_encode|raw }},
billableLabel: {{ 'app.service.billable_checkbox'|trans|json_encode|raw }},
btnSave: {{ 'app.entry.btn_save'|trans|json_encode|raw }},
btnCancel: {{ 'app.entry.btn_cancel'|trans|json_encode|raw }},
btnEdit: {{ 'app.entry.btn_edit'|trans|json_encode|raw }},
btnDelete: {{ 'app.entry.btn_delete'|trans|json_encode|raw }},
btnRestore: {{ 'app.crud.btn_restore'|trans|json_encode|raw }},
groupBillable: {{ 'app.service.billable'|trans|json_encode|raw }},
groupNotBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }},
projectSingular: {{ 'app.crud.project_singular'|trans|json_encode|raw }},
projectPlural: {{ 'app.crud.project_plural'|trans|json_encode|raw }},
},
};
</script>

<div class="crud-page">


+ 55
- 41
httpdocs/templates/team/index.html.twig Wyświetl plik

@@ -1,22 +1,36 @@
{# templates/team/index.html.twig #}
{% extends 'base.html.twig' %}

{% block title %}Team{% endblock %}
{% block title %}{{ 'app.team.page_title'|trans }}{% endblock %}

{% block body %}
<script>
window.Team = {
i18n: {
confirmDelete: {{ 'app.team.confirm_delete'|trans|json_encode|raw }},
confirmArchive: {{ 'app.team.confirm_archive'|trans|json_encode|raw }},
confirmRevokeInvite:{{ 'app.team.confirm_revoke_invite'|trans|json_encode|raw }},
errorSave: {{ 'app.team.error_save'|trans|json_encode|raw }},
errorDelete: {{ 'app.team.error_delete'|trans|json_encode|raw }},
errorArchive: {{ 'app.team.error_archive'|trans|json_encode|raw }},
errorRestore: {{ 'app.team.error_restore'|trans|json_encode|raw }},
errorGeneric: {{ 'app.team.error_generic'|trans|json_encode|raw }},
}
};
</script>
<div class="crud-page">

<div class="crud-page__header">
<h1 class="crud-page__title">Team</h1>
<button class="btn btn-primary" id="team-invite-btn">Neuer Benutzer</button>
<h1 class="crud-page__title">{{ 'app.team.page_title'|trans }}</h1>
<button class="btn btn-primary" id="team-invite-btn">{{ 'app.team.btn_new'|trans }}</button>
</div>

<div class="crud-tabs">
<button class="crud-tab crud-tab--active" data-tab="active">
Aktiv ({{ activeUsers|length + pendingInvites|length }})
{{ 'app.crud.tab_active'|trans }} ({{ activeUsers|length + pendingInvites|length }})
</button>
<button class="crud-tab" data-tab="archived">
Archiviert ({{ archivedUsers|length }})
{{ 'app.crud.tab_archived'|trans }} ({{ archivedUsers|length }})
</button>
</div>

@@ -37,22 +51,22 @@
<div class="crud-row__display">
<div class="crud-row__info">
<span class="crud-row__name">{{ au.user.fullName }}</span>
<span class="crud-row__meta">({{ au.roleLabel }})</span>
<span class="crud-row__meta">({{ au.roleLabelKey|trans }})</span>
{% if au.user.password is null %}
<span class="team-badge team-badge--pending">Einladung ausstehend</span>
<span class="team-badge team-badge--pending">{{ 'app.team.invite_pending'|trans }}</span>
{% endif %}
</div>
<div class="crud-row__actions">
<button class="crud-row__btn crud-row__btn--edit"
data-action="edit"
title="Bearbeiten">
<svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
title="{{ 'app.entry.btn_edit'|trans }}">
{% include '_atoms/icon-edit.html.twig' %}
</button>
{% if au.user.id != currentUserId %}
<button class="crud-row__btn crud-row__btn--delete"
data-action="delete"
title="Entfernen">
<svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
title="{{ 'app.team.btn_remove'|trans }}">
{% include '_atoms/icon-delete.html.twig' %}
</button>
{% endif %}
</div>
@@ -61,57 +75,57 @@
<div class="crud-row__edit" hidden>
<div class="entry-form__grid entry-form__grid--inline">

<label class="entry-form__label">Vorname</label>
<label class="entry-form__label">{{ 'app.team.label_first_name'|trans }}</label>
<div class="entry-form__field">
<input type="text" class="input edit-first-name"
value="{{ au.user.firstName }}" />
</div>

<label class="entry-form__label">Nachname</label>
<label class="entry-form__label">{{ 'app.team.label_last_name'|trans }}</label>
<div class="entry-form__field">
<input type="text" class="input edit-last-name"
value="{{ au.user.lastName }}" />
</div>

<label class="entry-form__label">E-Mail</label>
<label class="entry-form__label">{{ 'app.team.label_email'|trans }}</label>
<div class="entry-form__field">
<input type="email" class="input edit-email"
value="{{ au.user.email }}" />
</div>

<label class="entry-form__label">Bemerkung</label>
<label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label>
<div class="entry-form__field">
<textarea class="textarea edit-note" rows="2">{{ au.user.note|default('') }}</textarea>
</div>

<label class="entry-form__label">Rolle</label>
<label class="entry-form__label">{{ 'app.team.label_role'|trans }}</label>
<div class="entry-form__field">
<div class="team-role-selector{% if au.user.id == currentUserId and au.isAdmin() %} team-role-selector--disabled{% endif %}">
{% set roleDisabled = (au.user.id == currentUserId and au.isAdmin()) ? 'disabled' : '' %}
<label class="team-role-option">
<input type="radio" class="edit-role" name="role-{{ au.id }}"
value="tracker" {{ au.role == 'tracker' ? 'checked' : '' }} {{ roleDisabled }} />
<span class="team-role-option__label">Zeiterfasser</span>
<span class="team-role-option__label">{{ 'app.team.role_tracker'|trans }}</span>
</label>
<label class="team-role-option">
<input type="radio" class="edit-role" name="role-{{ au.id }}"
value="member" {{ au.role == 'member' ? 'checked' : '' }} {{ roleDisabled }} />
<span class="team-role-option__label">Standard-Nutzer</span>
<span class="team-role-option__label">{{ 'app.team.role_member'|trans }}</span>
</label>
<label class="team-role-option">
<input type="radio" class="edit-role" name="role-{{ au.id }}"
value="admin" {{ au.role == 'admin' ? 'checked' : '' }} {{ roleDisabled }} />
<span class="team-role-option__label">Administrator</span>
<span class="team-role-option__label">{{ 'app.team.role_admin'|trans }}</span>
</label>
</div>
{% if au.user.id == currentUserId and au.isAdmin() %}
<p class="team-role-hint">Eigene Administrator-Rolle kann nicht geändert werden.</p>
<p class="team-role-hint">{{ 'app.team.role_change_disabled'|trans }}</p>
{% endif %}
</div>

<div class="entry-form__actions">
<button type="button" class="btn btn-primary" data-action="save">Sichern</button>
<button type="button" class="btn btn-secondary" data-action="cancel">Abbrechen</button>
<button type="button" class="btn btn-primary" data-action="save">{{ 'app.entry.btn_save'|trans }}</button>
<button type="button" class="btn btn-secondary" data-action="cancel">{{ 'app.entry.btn_cancel'|trans }}</button>
</div>

</div>
@@ -127,14 +141,14 @@
<div class="crud-row__info">
<span class="crud-row__name">{{ invite.firstName }} {{ invite.lastName }}</span>
<span class="crud-row__meta">({{ invite.email }})</span>
<span class="team-badge team-badge--pending">Einladung ausstehend</span>
<span class="team-badge team-badge--pending">{{ 'app.team.invite_pending'|trans }}</span>
</div>
<div class="crud-row__actions">
<button class="crud-row__btn crud-row__btn--delete"
data-action="delete-invite"
data-id="{{ invite.id }}"
title="Einladung zurückziehen">
<svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
title="{{ 'app.team.btn_revoke_invite'|trans }}">
{% include '_atoms/icon-delete.html.twig' %}
</button>
</div>
</div>
@@ -142,7 +156,7 @@
{% endfor %}

{% if activeUsers is empty and pendingInvites is empty %}
<div class="crud-list__empty">Noch keine aktiven Teammitglieder.</div>
<div class="crud-list__empty">{{ 'app.team.empty_active'|trans }}</div>
{% endif %}

</div>
@@ -155,19 +169,19 @@
<div class="crud-row__display">
<div class="crud-row__info">
<span class="crud-row__name">{{ au.user.fullName }}</span>
<span class="crud-row__meta">({{ au.roleLabel }})</span>
<span class="crud-row__meta">({{ au.roleLabelKey|trans }})</span>
</div>
<div class="crud-row__actions">
<button class="crud-row__btn crud-row__btn--restore"
data-action="unarchive"
title="Wiederherstellen">
<svg viewBox="0 0 16 16" fill="none"><path d="M2 8a6 6 0 1 1 1.5 3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M2 13V9h4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
title="{{ 'app.crud.btn_restore'|trans }}">
{% include '_atoms/icon-restore.html.twig' %}
</button>
</div>
</div>
</div>
{% else %}
<div class="crud-list__empty">Keine archivierten Teammitglieder.</div>
<div class="crud-list__empty">{{ 'app.team.empty_archived'|trans }}</div>
{% endfor %}

</div>
@@ -178,7 +192,7 @@
<div class="modal-overlay" id="team-modal" hidden>
<div class="modal-card">
<div class="modal-card__header">
<h2 class="modal-card__title">Neuen Benutzer einladen</h2>
<h2 class="modal-card__title">{{ 'app.team.modal_title'|trans }}</h2>
<button class="modal-card__close" id="team-modal-close" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
@@ -191,40 +205,40 @@
<div class="modal-card__body">
<div class="form-row">
<div class="form-field">
<label class="form-field__label" for="inv-firstName">Vorname</label>
<label class="form-field__label" for="inv-firstName">{{ 'app.team.label_first_name'|trans }}</label>
<input class="input" type="text" id="inv-firstName" autocomplete="off" />
</div>
<div class="form-field">
<label class="form-field__label" for="inv-lastName">Nachname</label>
<label class="form-field__label" for="inv-lastName">{{ 'app.team.label_last_name'|trans }}</label>
<input class="input" type="text" id="inv-lastName" autocomplete="off" />
</div>
</div>
<div class="form-field">
<label class="form-field__label" for="inv-email">E-Mail</label>
<label class="form-field__label" for="inv-email">{{ 'app.team.label_email'|trans }}</label>
<input class="input" type="email" id="inv-email" autocomplete="off" />
</div>
<div class="form-field">
<label class="form-field__label">Rolle</label>
<label class="form-field__label">{{ 'app.team.label_role'|trans }}</label>
<div class="team-role-selector">
<label class="team-role-option">
<input type="radio" name="inv-role" value="tracker" />
<span class="team-role-option__label">Zeiterfasser</span>
<span class="team-role-option__label">{{ 'app.team.role_tracker'|trans }}</span>
</label>
<label class="team-role-option">
<input type="radio" name="inv-role" value="member" checked />
<span class="team-role-option__label">Standard-Nutzer</span>
<span class="team-role-option__label">{{ 'app.team.role_member'|trans }}</span>
</label>
<label class="team-role-option">
<input type="radio" name="inv-role" value="admin" />
<span class="team-role-option__label">Administrator</span>
<span class="team-role-option__label">{{ 'app.team.role_admin'|trans }}</span>
</label>
</div>
</div>
</div>

<div class="modal-card__footer">
<button class="btn btn-secondary" id="team-modal-cancel" type="button">Abbrechen</button>
<button class="btn btn-cta" id="team-modal-submit" type="button">Einladung senden</button>
<button class="btn btn-secondary" id="team-modal-cancel" type="button">{{ 'app.entry.btn_cancel'|trans }}</button>
<button class="btn btn-cta" id="team-modal-submit" type="button">{{ 'app.team.btn_invite'|trans }}</button>
</div>
</div>
</div>
@@ -234,4 +248,4 @@
{% block javascripts %}
{{ parent() }}
{{ encore_entry_script_tags('team') }}
{% endblock %}
{% endblock %}

+ 7
- 17
httpdocs/templates/timetracking/week.html.twig Wyświetl plik

@@ -1,27 +1,15 @@
{# templates/timetracking/week.html.twig #}
{% extends 'base.html.twig' %}

{#
Datums-Arrays kommen aus AppExtension (single source of truth).
Skalare Strings kommen aus messages.de.yaml via |trans.
#}
{% from '_macros/helpers.html.twig' import smart_date %}

{% set months = deMonths() %}
{% set monthsShort = deMonthsShort() %}
{% set weekdays = deWeekdays() %}
{% set weekdaysShort= deWeekdaysShort() %}

{% block title %}
{% set monthName = months[currentDate|date('n') - 1] %}
{% set activStr = currentDate|date('Y-m-d') %}
{% if activStr == todayStr %}
{{ 'app.date.today'|trans }}, {{ currentDate|date('j') }}. {{ monthName }}
{% elseif activStr == tomorrowStr %}
{{ 'app.date.tomorrow'|trans }}, {{ currentDate|date('j') }}. {{ monthName }}
{% elseif activStr == yesterdayStr %}
{{ 'app.date.yesterday'|trans }}, {{ currentDate|date('j') }}. {{ monthName }}
{% else %}
{{ weekdays[currentDate|date('N') - 1] }}, {{ currentDate|date('j') }}. {{ monthName }}
{% endif %}
{{ smart_date(currentDate, todayStr, tomorrowStr, yesterdayStr, months, weekdays) }}
{% endblock %}

{% block body %}
@@ -76,6 +64,8 @@ window.TT = {
errorDailyLimitExceeded: {{ 'app.entry.error_daily_limit_exceeded'|trans|json_encode|raw }},
warnDurationLong: {{ 'app.entry.warn_duration_long'|trans|json_encode|raw }},
invoicedTitle: {{ 'app.entry.invoiced_title'|trans|json_encode|raw }},
noteShow: {{ 'app.entry.note_show'|trans|json_encode|raw }},
noteHide: {{ 'app.entry.note_hide'|trans|json_encode|raw }},
},
};
</script>
@@ -143,7 +133,7 @@ window.TT = {
{# Minimal-Modus: Bemerkung-Toggle (nur via CSS/JS sichtbar) #}
<div class="entry-form__note-toggle-row">
<button type="button" class="entry-form__note-toggle" id="btn-note-toggle">
+ Bemerkung hinzufügen
{{ 'app.entry.note_show'|trans }}
</button>
</div>

@@ -160,7 +150,7 @@ window.TT = {
{% if timeEntries is not empty %}
<div class="entry-list__summary" id="entry-list-summary">
<button type="button" class="entry-list__summary-btn" id="btn-entries-toggle">
<span class="entry-list__summary-count">{{ timeEntries|length }} {{ timeEntries|length == 1 ? 'Eintrag' : 'Einträge' }}</span>
<span class="entry-list__summary-count">{{ timeEntries|length }} {{ timeEntries|length == 1 ? 'app.entry.count_one'|trans : 'app.entry.count_other'|trans }}</span>
<span class="entry-list__summary-sep">·</span>
<span class="entry-list__summary-total">{{ totalDuration }}</span>
<span class="entry-list__summary-arrow">▾</span>


+ 203
- 5
httpdocs/translations/messages.de.yaml Wyświetl plik

@@ -1,13 +1,54 @@
# translations/messages.de.yaml

app:
csrf_error: "Ungültiger Sicherheitstoken. Bitte lade die Seite neu."

error:
not_found: "Nicht gefunden"
access_denied: "Zugriff verweigert"
project_not_found: "Projekt nicht gefunden"
client_not_found: "Kunde nicht gefunden"
name_required: "Name ist erforderlich"
daily_limit: "Du kannst nicht mehr als 24 Stunden pro Tag loggen."
invalid_role: "Ungültige Rolle."
invalid_hex: "Ungültiger Hex-Farbwert."
generic: "Ein Fehler ist aufgetreten. Bitte versuche es erneut."
email_taken: "Diese E-Mail-Adresse wird bereits verwendet."

validation:
email_required: "E-Mail ist erforderlich."
email_invalid: "Keine gültige E-Mail-Adresse."
first_name_required: "Vorname ist erforderlich."
last_name_required: "Nachname ist erforderlich."
company_name_required: "Firmenname ist erforderlich."
password_min_length: "Passwort muss mindestens 8 Zeichen lang sein."
password_mismatch: "Passwörter stimmen nicht überein."
password_current_required: "Aktuelles Passwort ist erforderlich."
password_current_wrong: "Das aktuelle Passwort ist falsch."
password_new_min_length: "Das neue Passwort muss mindestens 8 Zeichen haben."

greeting:
morning: "Guten Morgen"
noon: "Mahlzeit"
afternoon: "Guten Tag"
evening: "Guten Abend"
night: "Gute Nacht"

role:
admin: "Administrator"
member: "Standard"
tracker: "Zeiterfasser"

date:
today: "Heute"
tomorrow: "Morgen"
yesterday: "Gestern"
week_label: "Kalenderwoche"
week_short: "KW"

nav:
menu_open: "Menü öffnen"
week_view: "Wochenansicht"
prev_week: "Vorherige Woche"
next_week: "Nächste Woche"
month_view: "Monatsansicht öffnen/schließen"
@@ -48,6 +89,11 @@ app:
error_duration_too_long: "Eine Dauer von mehr als 24 Stunden ist nicht möglich."
error_daily_limit_exceeded: "Du kannst nicht mehr als 24 Stunden pro Tag loggen."
warn_duration_long: "Die Dauer ist länger als 8 Stunden. Wirklich speichern?"
note_show: "+ Bemerkung hinzufügen"
note_hide: "× Bemerkung ausblenden"
invoiced_title: "Abgerechnet – Bearbeiten nicht möglich"
count_one: "Eintrag"
count_other: "Einträge"

service:
billable: "Verrechenbar"
@@ -60,11 +106,35 @@ app:
empty: "Noch keine Leistungen angelegt."

crud:
label_name: "Name"
label_note: "Bemerkung"
tab_active: "Aktiv"
tab_archived: "Archiviert"
btn_restore: "Wiederherstellen"
label_name: "Name"
label_note: "Bemerkung"
label_rate: "Stundensatz"
label_client: "Kunde"
tab_active: "Aktiv"
tab_archived: "Archiviert"
btn_restore: "Wiederherstellen"
select_ph: "Bitte wählen"
confirm_delete: "Wirklich löschen?"
confirm_archive: "Dieser Eintrag hat abhängige Zeiteinträge und kann nicht gelöscht werden.\nStattdessen archivieren?"
error_save: "Fehler beim Speichern."
error_delete: "Fehler beim Löschen."
error_archive: "Fehler beim Archivieren."
error_restore: "Fehler beim Wiederherstellen."
error_no_name: "Bitte einen Namen eingeben."
project_singular: "Projekt"
project_plural: "Projekte"

client:
page_title: "Kunden"
btn_new: "Neuer Kunde"
placeholder_name: "Kundenname"
empty: "Noch keine Kunden angelegt."

project:
page_title: "Projekte"
btn_new: "Neues Projekt"
placeholder_name: "Projektname"
empty: "Noch keine Projekte angelegt."

report:
page_title: "Reports: Zeiten"
@@ -72,6 +142,27 @@ app:
tab_times: "Zeiten"
tab_projects: "Projekte"
toolbar_filter: "Filtern/Gruppieren"
export_excel: "Als Excel exportieren"
export_csv: "Als CSV exportieren"
export_pdf: "Als PDF exportieren"
print: "Drucken"
export_col_date: "Datum"
export_col_client: "Kunde"
export_col_project: "Projekt"
export_col_service: "Leistung"
export_col_user: "Benutzer"
export_col_note: "Bemerkung"
export_col_hours: "Stunden"
export_col_revenue: "Umsatz"
export_col_invoiced: "Abgerechnet"
export_col_invoiced_short: "Abger."
export_sum: "Summe"
export_yes: "Ja"
export_no: "Nein"
export_title: "Zeitreport – %account%"
export_created_at: "Erstellt am %date%"
export_entry_count: "%count% Einträge"
export_entry_count_one: "1 Eintrag"
toolbar_edit: "Einträge bearbeiten"
col_date: "Datum"
col_client: "Kunde"
@@ -112,6 +203,85 @@ app:
invoiced_yes: "Ja"
invoiced_no: "Nein"
filter_neg: "Negativfilter"
user_fallback: "Benutzer #%id%"

team:
page_title: "Team"
btn_new: "Neuer Benutzer"
btn_invite: "Einladung senden"
btn_remove: "Entfernen"
btn_revoke_invite: "Einladung zurückziehen"
invite_pending: "Einladung ausstehend"
modal_title: "Neuen Benutzer einladen"
label_first_name: "Vorname"
label_last_name: "Nachname"
label_email: "E-Mail"
label_role: "Rolle"
role_tracker: "Zeiterfasser"
role_member: "Standard-Nutzer"
role_admin: "Administrator"
role_change_disabled: "Eigene Administrator-Rolle kann nicht geändert werden."
empty_active: "Noch keine aktiven Teammitglieder."
empty_archived: "Keine archivierten Teammitglieder."
confirm_delete: "Wirklich entfernen?"
confirm_archive: "Dieser Benutzer hat Zeiteinträge und kann nicht gelöscht werden.\nStattdessen archivieren?"
confirm_revoke_invite: "Einladung zurückziehen?"
error_save: "Fehler beim Speichern."
error_delete: "Fehler beim Löschen."
error_archive: "Fehler beim Archivieren."
error_restore: "Fehler beim Wiederherstellen."
error_generic: "Fehler"
already_member: "Diese Person ist bereits Mitglied dieses Accounts."
cannot_archive_self: "Du kannst dich nicht selbst archivieren."
cannot_archive_owner: "Der Kontoinhaber kann nicht archiviert werden."
cannot_remove_self: "Du kannst dich nicht selbst entfernen."
cannot_remove_owner: "Der Kontoinhaber kann nicht entfernt werden."
cannot_change_own_role: "Du kannst deine eigene Administratoren-Rolle nicht ändern."

account:
page_title_account: "Account"
page_title_user: "Mein Benutzer"
tab_account: "Account"
tab_user: "Mein Benutzer"
label_company_name: "Firmenname"
hint_subdomain: "Subdomain: %subdomain% — ändert sich nicht."
label_interval: "Zeitintervall"
hint_interval: "Auf welche Einheit werden erfasste Zeiten aufgerundet."
label_color: "Hauptfarbe"
hint_color: "Hex-Farbe für das Standard-Theme aller Benutzer. Standard: #3a7bbf"
label_first_name: "Vorname"
label_last_name: "Nachname"
label_email: "E-Mail"
label_password: "Passwort"
label_current_password: "Aktuelles Passwort"
label_new_password: "Neues Passwort"
label_password_repeat: "Wiederholen"
label_appearance: "Darstellung"
theme_standard: "Standard"
theme_standard_desc: "Volle Navigation, alle Felder sichtbar"
theme_minimal: "Minimal"
theme_minimal_desc: "Ablenkungsfreie Ansicht, Hamburger-Menü"
label_owner: "Besitzer des Accounts"
hint_owner: "Der Besitzer des Accounts ist für die Verwaltung der Zahlungsdaten zuständig. Nur er kann den Account kündigen."
invalid_hex: "Ungültiger Hex-Wert. Beispiel: #3a7bbf"
saved: "Gespeichert."
saved_reloading: "Gespeichert. Seite wird neu geladen…"
owner_changed: "Kontoinhaber geändert. Seite wird neu geladen…"
owner_confirm: "%name% zum neuen Kontoinhaber machen?"
theme_changed: "Darstellung geändert."
password_mismatch: "Die Passwörter stimmen nicht überein."
change_label: "ändern"
cancel_label: "abbrechen"
error_generic: "Fehler"
superadmin_only: "Nur der aktuelle Kontoinhaber kann diese Funktion nutzen."
already_owner: "Du bist bereits Kontoinhaber."
new_owner_must_be_admin: "Der Benutzer muss aktiver Administrator sein."
deactivated: "Dein Konto wurde deaktiviert."
deactivated_api: "Konto deaktiviert."
interval_minutes: "Minuten"
interval_quarter: "Viertelstunde"
interval_half: "Halbe Stunde"
interval_hour: "Stunde"

forgot_password:
page_title: "Passwort vergessen – spawntree"
@@ -154,6 +324,12 @@ app:
label_password: "Passwort"
label_password_repeat: "Wiederholen"
btn_submit: "Konto erstellen"
sending: "Wird gesendet …"
error_unknown: "Unbekannter Fehler."
error_connection: "Verbindungsfehler. Bitte versuche es erneut."
success_title: "Fast geschafft!"
success_text: "Wir haben eine Bestätigungs-E-Mail an %email% geschickt."
success_hint: "Bitte klicke auf den Link in der E-Mail um dein Konto zu aktivieren. Der Link ist 24 Stunden gültig."
already_registered: "Bereits registriert?"
link_login: "Zur Anmeldung"

@@ -172,6 +348,9 @@ app:
invite_error:
page_title: "Fehler – Einladungslink"
title: "Ungültiger Link"
link_invalid: "Dieser Einladungslink ist ungültig."
link_expired: "Dieser Einladungslink ist abgelaufen (gültig 7 Tage)."
link_wrong_account: "Dieser Einladungslink gehört zu einem anderen Account."

set_password:
page_title: "Passwort festlegen – %name%"
@@ -179,9 +358,12 @@ app:
label_password: "Passwort"
label_password_repeat: "Wiederholen"
btn_submit: "Passwort speichern & anmelden"
error_too_short: "Das Passwort muss mindestens 8 Zeichen haben."
error_mismatch: "Die Passwörter stimmen nicht überein."

email:
confirm:
subject: "Bitte bestätige deine Registrierung – spawntree Timetracker"
greeting: "Hallo %name%,"
body: "bitte bestätige deine Registrierung für %company% mit einem Klick auf den Button."
btn: "E-Mail bestätigen"
@@ -189,6 +371,7 @@ app:
ignore: "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren."

notify:
subject: "[Timetracker] Neue Registrierung: %name%"
title: "Neue Registrierung im Timetracker"
col_company: "Firma"
col_slug: "Slug"
@@ -197,6 +380,7 @@ app:
col_date: "Datum"

password_reset:
subject: "Passwort zurücksetzen – %account%"
greeting: "Hallo %name%,"
body: "du hast ein Zurücksetzen deines Passworts angefordert. Klicke auf den Button, um ein neues Passwort festzulegen."
btn: "Passwort zurücksetzen"
@@ -204,12 +388,14 @@ app:
ignore: "Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren."

welcome:
subject: "Willkommen beim spawntree Timetracker!"
greeting: "Hallo %name%,"
body: "dein Konto für %company% ist jetzt aktiv. Los geht's!"
btn: "Zum Timetracker →"
url_label: "Deine URL:"

invite:
subject: "Einladung zu %company%"
title: "Du wurdest zu %company% eingeladen"
greeting: "Hallo %name%,"
role_added_pre: "du wurdest als"
@@ -220,3 +406,15 @@ app:
cta: "Klicke auf den Button, um dein Passwort festzulegen und loszulegen. Der Link ist 7 Tage gültig."
btn: "Passwort festlegen →"
ignore: "Wenn du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren."

registration:
email_taken: "Diese E-Mail-Adresse wird bereits verwendet."
confirm_invalid: "Ungültiger Bestätigungslink."
confirm_expired: "Dieser Link ist abgelaufen (gültig 24 Stunden). Bitte registriere dich erneut."

home:
title: "spawntree Timetracker"
btn_start: "Kostenlos starten"
hero_title: "Zeiterfassung, die nicht nervt."
hero_sub: "Einfach, schnell, ohne Overhead. Dein Team, deine Projekte, deine Zeit."
btn_register: "Jetzt registrieren →"

Ładowanie…
Anuluj
Zapisz