# 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), Chart.js (Statistiken) - **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, statistics) ├── 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 | | `statistics` | `assets/scripts/statistics.js` | Statistiken-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) - **Labels**: Optionales Freitext-Label pro `TimeEntry` (max. 255 Zeichen). Autocomplete-Vorschläge basierend auf bisherigen Labels des gewählten Projekts (`/api/labels?projectId=X`) oder Freitextsuche (`/api/labels?q=X`). Chip-UI im Entry-Formular, Textfeld in der Stoppuhr. - **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. ### Sortierung Alle Spalten der Report-Tabelle sind serverseitig sortierbar (Klick auf Spaltenheader togglet ASC/DESC). URL-Parameter: `?sort={column}&dir={ASC|DESC}`. Default: `sort=date&dir=DESC`. Sortierbare Spalten: `date`, `client`, `project`, `service`, `label`, `user`, `note`, `duration`, `revenue`. Die Revenue-Sortierung nutzt einen `HIDDEN`-Select-Alias (`COALESCE(p.hourlyRate, c.hourlyRate, s.hourlyRate) * t.duration`), da DQL keine berechneten Ausdrücke direkt im `ORDER BY` unterstützt. Sekundärsort ist immer `t.createdAt DESC`. Twig-Macro `sort_header` im Template rendert die klickbaren Spaltenheader mit Sort-Indikator (▴/▾). JS (`initSortHeaders()`) setzt `sort`/`dir` als URL-Parameter und navigiert. ## Labels Optionales Freitext-Label pro Zeiteintrag zur Kategorisierung (z.B. Ticketnummer, Feature-Name). ### Datenmodell - `TimeEntry.label` (`VARCHAR(255)`, nullable, indiziert via `idx_time_entry_label`) - Wird bei Create, Update und Timer-Start gesetzt ### API | Route | Method | Beschreibung | |-----------------|--------|-----------------------------------------------------------| | `/api/labels` | GET | Autocomplete: `?projectId=X` (Top 5) oder `?q=X` (Suche) | ### Frontend - **Entry-Formular** (Create + Edit): Chip-UI mit Autocomplete-Dropdown. Beim Projektwechsel werden projektspezifische Label-Vorschläge geladen (`findTopLabelsByProject`). Freitextsuche über `searchLabels`. - **Stoppuhr-Popover**: Einfaches Textfeld (Desktop + Hamburger-Menü), Label wird beim Timer-Start mitgesendet. - **Report-Tabelle**: Eigene sortierbare Spalte (`label`), Anzeige als Badge. - **Report-Filter**: Label-Filter mit Mehrfachauswahl, Autocomplete und Negation (`labels_neg`). Filterlogik: `IN` (default) oder `NOT IN` (negiert). ## 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/lexoffice/invoices` | POST | Rechnungsentwurf in Lexware Office anlegen| | `/api/lexoffice/invoice-preview` | GET | Gruppierte Rechnungspositionen-Vorschau | | `/api/clients/{id}/lexoffice-refresh` | PATCH | Kundenname aus Lexware aktualisieren | | `/api/entries/mark-invoiced` | POST | Gefilterte Einträge als abgerechnet markieren | ### Rechnungsentwurf aus Report Auf der Report-Seite erscheint ein Invoice-Icon (vor den Export-Buttons, durch Separator getrennt), wenn: 1. Die gefilterten **nicht-abgerechneten** Einträge genau **einen** Kunden enthalten 2. Dieser Kunde eine `lexofficeContactId` hat 3. Der User kein Tracker ist und ein API-Key hinterlegt ist Beim Klick öffnet sich ein **Invoice-Modal** mit Rechnungsvorschau: - **Gruppierung**: Positionen gruppierbar nach Leistung (`service`), Projekt (`project`) oder Label (`label`) — Auswahl via Radio-Buttons - **Vorschau-Tabelle**: Zeigt Bezeichnung, Menge (Stunden), Einheit, VK (Netto) und Gesamtbetrag pro Position. Positionen mit gleichem Rate werden zusammengefasst, Sub-Items als Beschreibung - **Datenquelle**: `GET /api/lexoffice/invoice-preview?groupBy={service|project|label}&{filter-params}` — nutzt `TimeEntryRepository::getGroupedForInvoice()`, nur nicht-abgerechnete Einträge - **Checkbox „Als abgerechnet markieren"**: Standardmäßig aktiviert, markiert nach Rechnungserstellung alle gefilterten Einträge via `POST /api/entries/mark-invoiced` - **Rechnungserstellung**: `POST /api/lexoffice/invoices` mit echten Line-Items (Name, Stunden, Stundensatz, optional Beschreibung). `LexofficeService::createInvoiceDraft()` unterstützt optionalen `$lineItems`-Parameter - **Nach Erfolg**: Confirm-Dialog mit Option, den Entwurf direkt in Lexware Office zu öffnen (`https://app.lexware.de/permalink/invoices/edit/{id}`). Bei aktivierter Checkbox wird die Seite neu geladen Serverseitige Logik: `ReportController` ermittelt via `TimeEntryRepository::findDistinctClientIdsFiltered()` und `findDateRangeFiltered()` die Daten (mit `invoiced => false` Filter). `LexofficeService::createInvoiceDraft()` ruft `POST /v1/invoices` auf. ### Verhalten (Kontaktverknüpfung) - 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) ## Statistiken Arbeitszeit-Statistik als gestapeltes Balkendiagramm (Chart.js), erreichbar über den Statistik-Button im Report-Header. Tab-Navigation zwischen „Zeiten" und „Statistiken". ### Architektur - **Backend**: `ReportController::statistics()` (Seite) + `ReportController::statisticsData()` (API) - **Frontend**: `assets/scripts/statistics.js` (eigener Webpack-Entry), nutzt Chart.js (tree-shaked: nur `BarController`, `BarElement`, `CategoryScale`, `LinearScale`, `Tooltip`) - **Styles**: `assets/styles/sections/_statistics.scss` - **Template**: `templates/report/statistics.html.twig` ### API | Route | Method | Beschreibung | |--------------------|--------|-------------------------------------------------------| | `/api/statistics` | GET | Aggregierte Zeitdaten: `?range={12months|6months|4weeks}&userId={id}` | ### Daten-Aggregation `TimeEntryRepository::getStatisticsData()` aggregiert Zeiteinträge in Buckets: - **12months**: Monats-Buckets (`Y-m`), letztes Jahr - **6months**: Wochen-Buckets (`o-W`), letztes halbes Jahr - **4weeks**: Tages-Buckets (`Y-m-d`), letzte 4 Wochen Rückgabe: `labels`, `billable`, `nonBillable`, `billableRevenue`, `nonBillableRevenue`, `groupBy`. Billable/Non-Billable Trennung via `Service.billable`. Revenue-Berechnung nutzt die Stundensatz-Kaskade. ### Frontend-Features - **Metrik-Umschalter**: Stunden oder Umsatz (clientseitig, kein neuer API-Call) - **User-Filter**: Admins/Members können nach einzelnem User filtern oder alle sehen (Account-Name als „Alle") - **Tracker**: sieht nur eigene Daten (serverseitig erzwungen) - **Tages-Ansicht**: Alternierende Wochen-Bänder als visuelle Hilfe (Custom Chart.js Plugin `weekBands`) - **Tooltip**: Zeigt formatierte Stunden (`h:mm`) oder Umsatz (`€`) je nach Metrik - **Brand-Farbe**: Billable-Balken nutzen `--color-primary`, Non-Billable grau ## 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 } ```