# 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, LexofficeService, 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, stopwatch, searchable-select, 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 - **Timer/Stoppuhr**: `TimeEntry.timerStartedAt` (nullable `DateTimeImmutable`) markiert laufende Timer. Pro User nur ein aktiver Timer gleichzeitig. Elapsed wird beim Stoppen auf `trackingInterval` gerundet und zu `duration` addiert. - **Stundensätze (hourlyRate)**: Dreistufige Kaskade `Project.hourlyRate → Client.hourlyRate → Service.hourlyRate` (via `COALESCE`). Kunden und Projekte haben einen Rate-Mode-Toggle (Standard-Sätze vs. eigener Stundensatz), Services immer ein direktes Feld. Revenue-Berechnung in `TimeEntryRepository::sumRevenueFiltered()` nutzt die Kaskade. - **Rollen**: `admin` (alles), `member` (eigene + fremde Einträge sehen), `tracker` (nur eigene) - **CSS Custom Properties**: Brand-Farben via `:root`-Variablen (`--color-primary`, etc.) ### Translations — keine hardcodierten Strings Alle UI-Texte leben zentral in `translations/messages.de.yaml`. Nirgends dürfen deutsche Strings direkt im Code stehen. - **Twig**: `{{ 'app.section.key'|trans }}` bzw. `{{ 'app.key'|trans({'%placeholder%': value}) }}` - **PHP Controller/Services**: `TranslatorInterface` injizieren, `$this->translator->trans('app.key')` nutzen - **JS**: Strings werden im Twig-Template als `window.XX.i18n`-Objekt übergeben (z.B. `window.CRUD.i18n`, `window.ACCOUNT.i18n`, `window.STOPWATCH.i18n`). JS-Module nutzen `createTranslator('XX')` aus `utils.js` zum Zugriff: `const t = createTranslator('CRUD'); t('confirmDelete')` - **Schlüsselstruktur**: `app.{section}.{key}` (z.B. `app.team.btn_new`, `app.error.access_denied`, `app.validation.email_required`) ### Kein Inline-CSS Keine `style="..."`-Attribute in Twig-Templates oder JS-generiertem HTML. Stattdessen eigene SCSS-Klassen verwenden. ### SCSS: Mixins statt Duplikation Wiederkehrende Patterns sind in `atoms/_mixins.scss` ausgelagert. Bei neuen Komponenten die vorhandenen Mixins nutzen: - `@include card($bg, $radius)` — Karte mit Schatten - `@include icon-btn($size, $shape)` — Icon-Button (rund, transparent) - `@include page-shell` — Seiten-Grundlayout (min-height, flex-column, bg) - `@include section-header` — Header-Gradient mit Flex-Layout - `@include text-truncate` — Einzeiliger Text mit Ellipsis - `@include form-label` — Formular-Label-Styling ### JS: utils.js Gemeinsame Hilfsfunktionen in `assets/scripts/utils.js`: - `esc(str)` — HTML-Escaping für dynamische Inhalte (XSS-Prävention). Immer nutzen wenn User-Daten in JS-generiertes HTML eingefügt werden. - `createTranslator(namespace)` — i18n-Zugriff (siehe Translations) - `removeWithAnimation(el, className)` / `animateIn(el, className)` — Animierte DOM-Operationen - Konstanten: `ANIMATION_MS`, `FADE_MS`, `MINUTES_PER_DAY` ### JS: searchable-select.js Wiederverwendbares durchsuchbares Dropdown-Modul (`SearchableSelect`-Klasse) mit Gruppen, Keyboard-Navigation, Filter und `onSelect`-Callback. Wird von `stopwatch.js` (Projekt/Service-Auswahl) und `crud.js` (Lexoffice-Kontaktauswahl) importiert. ### JS: stopwatch.js Stoppuhr-Modul: - `StopwatchManager` — Timer-Steuerung: Start/Stop/Resume, Tick-Display (Sekunden-Auflösung), DOM-Integration mit `window.entryManager`, Tab-Title-Update ### Globale `[hidden]`-Regel `main.scss` enthält `[hidden] { display: none !important; }`. Kein `&[hidden] { display: none !important; }` in einzelnen Komponenten nötig. ### PHP: `readonly` Constructor Promotion Controller-Abhängigkeiten immer mit `private readonly` deklarieren. ### Twig: Wiederverwendbare Macros Komplexe Logik die in mehreren Templates vorkommt wird in `templates/_macros/helpers.html.twig` als Macro ausgelagert (z.B. `smart_date`). ## 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, `primaryColor` und `lexofficeApiKey` 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. ## Stoppuhr / Timer Live-Timer zum Tracken von Zeiteinträgen. UI in der Navigation (Desktop + Hamburger-Menü), zusätzlich Play-Button an jeder Entry-Row zum Fortsetzen. ### Architektur - **Backend**: Timer-API im `TimeTrackingController` (`/api/timer/*`) - **Frontend**: `assets/scripts/stopwatch.js` (importiert in `app.js`), nutzt `SearchableSelect` aus `searchable-select.js` - **Styles**: `assets/styles/components/_stopwatch.scss` - **Template**: Popover in `_sections/nav.html.twig`, Play-Button in `timetracking/_entry_row.html.twig` - **Icon**: `_atoms/icon-stopwatch.html.twig` ### Timer-API Endpunkte | Route | Method | Beschreibung | |---------------------------|--------|--------------------------------------------| | `/api/timer/status` | GET | Prüft ob ein Timer läuft | | `/api/timer/options` | GET | Projekte + Services für Select-Dropdowns | | `/api/timer/start` | POST | Neuen Timer starten (erstellt TimeEntry) | | `/api/timer/start/{id}` | POST | Bestehenden Eintrag fortsetzen (Resume) | | `/api/timer/stop` | POST | Laufenden Timer stoppen + Duration addieren| ### Timer-Logik - `TimeEntry.timerStartedAt` wird beim Start gesetzt, beim Stopp auf `null` - Beim Stopp: elapsed = `now - timerStartedAt`, gerundet auf `Account.trackingInterval`, addiert zu `duration` - Maximal 1440 Min/Tag (Overflow-Schutz) - Conflict (409): wenn bereits ein Timer läuft, User wird gefragt ob der alte gestoppt werden soll - LocalStorage (`tt_timer_state`): persistiert Timer-State über Page-Reloads, wird mit Server-State abgeglichen - LocalStorage (`tt_last_project_id`, `tt_last_service_id`): merkt letzte Auswahl für Quick-Start ## Lexoffice-Integration (Lexware Office) Optionale Verknüpfung von Kunden mit Lexware Office Kontakten. Nur aktiv wenn `Account.lexofficeApiKey` gesetzt ist. ### Datenmodell - `Account.lexofficeApiKey` (Central-DB): API-Key für Lexware Office, gespeichert pro Account - `Client.lexofficeContactId` (Tenant-DB): UUID des verknüpften Lexware-Kontakts (nullable) ### Architektur - **Service**: `LexofficeService` — HTTP-Client für Lexware Office API (`https://api.lexware.io/v1`), paginierter Kontakt-Abruf mit Rate-Limit-Retry - **Controller**: `LexofficeController` — API-Endpunkte für Kontaktlisten und Einzelkontakte - **Client-Controller**: `lexoffice-refresh`-Endpunkt zum Aktualisieren des Kundennamens aus Lexware - **Frontend**: Lexoffice-Logik in `crud.js`, nutzt `SearchableSelect` für Kontakt-Dropdown - **Account-Settings**: API-Key-Verwaltung im Account-Tab (Eingabe/Ändern mit Maskierung) ### API-Endpunkte | Route | Method | Beschreibung | |------------------------------------------|--------|-------------------------------------------| | `/api/lexoffice/contacts` | GET | Alle Kunden-Kontakte aus Lexware Office | | `/api/lexoffice/contacts/{contactId}` | GET | Einzelnen Kontakt abrufen | | `/api/clients/{id}/lexoffice-refresh` | PATCH | Kundenname aus Lexware aktualisieren | ### Verhalten - Beim Verknüpfen wird der Kundenname aus Lexware übernommen, das Name-Feld wird disabled - Bereits verknüpfte Kontakte werden in der Dropdown-Liste ausgeblendet (Duplikat-Schutz) - Reload-Button pro Zeile zum Aktualisieren des Namens aus Lexware - Kontakte werden clientseitig gecacht (einmal geladen pro Page-Session) ## 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 } ```