# Kontext: spawntree Timetracker ## Wer ich bin Flo Eisenmenger, Geschäftsführer der spawntree GmbH (kleine Webagentur, Hamburg). DDEV-Entwicklungsumgebung, Symfony 7.x, PHP 8.2, MariaDB 10.11. --- ## Was wir bauen Ein internes Timetracking-Tool – zunächst nur für mich, später als SaaS. Technisch ähnlich wie **mite.de** – wir orientieren uns stark am UI/UX von mite. --- ## Tech Stack - **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 (BEM-ähnlich) - **Dev**: DDEV (Port 8456 HTTPS), PHPMyAdmin installiert - **Kein** Symfony Forms – eigene HTML-Formulare mit fetch()-API --- ## Multi-Mandanten-Architektur ### Konzept - Jeder Account (Firma) bekommt eine eigene **Subdomain**: `spawntree.testtimetracking.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 ### Doctrine: Zwei Connections / Entity Manager - `central` → `App\Entity\Central\*` (User, Account, AccountUser, InviteToken, RegistrationToken) - `tenant` → `App\Entity\Tenant\*` (Client, Project, Service, TimeEntry) - Tenant-Connection wechselt DB-Name zur Laufzeit via `TenantConnectionMiddleware` ### Subdomain-Erkennung - `TenantRequestSubscriber` (Priorität 20, vor Firewall) liest den Subdomain-Slug aus dem Host - Findet den Account → setzt ihn in `TenantContext` - Kein Account für Slug → Redirect zur Hauptdomain - `TenantContext` ist ein Request-scoped Service, der den aktiven Account hält --- ## Datenbankstruktur ### Central-DB: `App\Entity\Central` #### `User` - `id`, `email` (unique), `firstName`, `lastName`, `password` (nullable), `note` - Implementiert `UserInterface`, `PasswordAuthenticatedUserInterface` - `getFullName()` → `"Flo Eisenmenger"` #### `Account` - `id`, `name`, `slug` (unique, → Subdomain), `trackingInterval` (smallint, default 1) - `createdAt`, `superAdminUser` (ManyToOne → User, nullable) - `accountUsers` (OneToMany → AccountUser) - `getTenantDbName()` → `"db_" . str_replace('-', '_', slug)` - `isSuperAdmin(User)` → bool - `getAdmins()` → Collection aktiver Admin-AccountUser #### `AccountUser` - `id`, `account` (ManyToOne), `user` (ManyToOne), `role` (admin/member/tracker), `archivedAt` - Konstanten: `ROLE_ADMIN`, `ROLE_MEMBER`, `ROLE_TRACKER` - `isArchived()`, `isAdmin()`, `isMember()`, `isTracker()`, `isMemberOrAdmin()` - `getRoleLabel()` → Deutsch #### `InviteToken` - `id`, `token`, `account`, `email`, `firstName`, `lastName`, `role`, `createdAt` - `isExpired()` (7 Tage) #### `RegistrationToken` - Für die E-Mail-Bestätigung bei Neuregistrierung ### Tenant-DB: `App\Entity\Tenant` #### `Client` (Kunde) - `id`, `name`, `hourlyRate` (decimal, nullable), `note` - Hat viele `Project`s #### `Project` - `id`, `name`, `client` (ManyToOne → Client), `note` #### `Service` (Leistung) - `id`, `name`, `billable` (bool, default true), `note` #### `TimeEntry` - `id`, `date` (DATE_IMMUTABLE), `duration` (int, **Minuten**), `userId` (int, kein FK – cross-DB) - `project` (ManyToOne), `service` (ManyToOne, nullable), `note`, `invoiced` (bool, default false) - `createdAt`, `updatedAt` (via `@PreUpdate`) - `toArray()` für JSON-Responses (inkl. projectName, clientName, serviceName, serviceBillable, invoiced) - `getDurationFormatted()` → `"1:30"` --- ## Routing-Übersicht ### Öffentliche Routen (Hauptdomain) - `GET /` → `app_home` (HomeController – Landing Page oder Redirect) - `GET /register` → `app_register` (Registrierungsseite) - `POST /api/register` → `api_register` - `POST /api/register/preview-slug` → `api_register_preview_slug` (Live-Slug-Vorschau) - `GET /verify/{token}` → `app_verify` (E-Mail-Bestätigung) ### Auth (Subdomain) - `GET /login` → `app_login` - `GET /logout` → `app_logout` - `GET /invite/{token}` → `app_invite` (Passwort setzen nach Einladung) ### Timetracking - `GET /` → Redirect (je nach Login-Status) - `GET /week` → `timetracking_week` - `GET /week/{date}` → `timetracking_week_date` - `GET /api/entries?date=Y-m-d` → Einträge für einen Tag (JSON) - `POST /api/entries` → Eintrag erstellen - `PATCH /api/entries/{id}` → Eintrag bearbeiten - `DELETE /api/entries/{id}` → Eintrag löschen - `PATCH /api/entries/{id}/invoiced` → Abrechnungsstatus toggeln ### CRUD-Seiten - `GET /clients` → `client_index` - `POST /api/clients`, `PATCH /api/clients/{id}`, `DELETE /api/clients/{id}` - `GET /projects` → `project_index` - `POST /api/projects`, `PATCH /api/projects/{id}`, `DELETE /api/projects/{id}` - `GET /services` → `service_index` - `POST /api/services`, `PATCH /api/services/{id}`, `DELETE /api/services/{id}` ### Reports - `GET /reports/times` → `report_times` ### Team (nur Admins) - `GET /team` → `team_index` - `POST /api/team/invite` → Einladung versenden - `PATCH /api/team/{id}` → Mitglied bearbeiten - `PATCH /api/team/{id}/archive` → Mitglied archivieren - `PATCH /api/team/{id}/unarchive` → Mitglied reaktivieren - `DELETE /api/team/{id}` → Mitglied entfernen - `DELETE /api/team/invite/{id}` → Einladung löschen ### Account-Einstellungen - `GET /account` → `account_index` (Tab: account / user, je nach Rolle) - `PATCH /api/account` → Name + trackingInterval (nur Admin) - `PATCH /api/account/superadmin` → Kontoinhaber übertragen (nur aktueller Superadmin) - `PATCH /api/account/user` → Eigene Profildaten / Passwort ändern --- ## Services & Subscribers - `TenantContext` – hält aktiven Account für den Request - `TenantConnectionMiddleware` – wechselt Tenant-DB-Name zur Laufzeit - `TenantRequestSubscriber` – liest Subdomain, setzt TenantContext (Prio 20) - `ArchivedUserChecker` – blockiert Login für archivierte User - `ArchivedUserSubscriber` – wirft Exception bei archivierten Usern während des Requests - `SlidingSessionSubscriber` – verlängert Session bei Aktivität - `AccountRoleHelper` – `isAdmin()`, `isMember()`, `isTracker()` für den aktuellen User/Account - `RegistrationService` – `startRegistration()`, `confirm(token)` – erstellt Account + DB + User - `SlugGenerator` – generiert/prüft Slugs aus Firmennamen - `AppExtension` / `AppExtensionRuntime` – Twig-Funktionen: `deMonths()`, `deMonthsShort()`, `deWeekdays()`, `deWeekdaysShort()` - `AccessDeniedHandler` – leitet bei 403 auf Login um --- ## Template-Struktur ``` templates/ ├── base.html.twig ├── home/ │ └── index.html.twig ← Landing Page (Hauptdomain ohne Subdomain) ├── security/ │ └── login.html.twig ├── registration/ │ ├── register.html.twig │ ├── confirmed.html.twig ← Nach E-Mail-Bestätigung │ └── confirm_error.html.twig ├── invite/ │ ├── set_password.html.twig ← Passwort setzen nach Einladung │ └── error.html.twig ├── email/ │ ├── team_invite.html.twig │ ├── registration_welcome.html.twig │ ├── registration_confirm.html.twig │ └── registration_notify.html.twig ├── timetracking/ │ ├── week.html.twig ← Hauptseite Zeiterfassung │ └── _entry_row.html.twig ├── client/ │ └── index.html.twig ├── project/ │ └── index.html.twig ├── service/ │ └── index.html.twig ├── team/ │ └── index.html.twig ← Team-Verwaltung (nur Admins) ├── account/ │ └── index.html.twig ← Account- + Profil-Einstellungen (Tabs) └── report/ └── times.html.twig ← Zeiteinträge-Report ``` --- ## SCSS-Struktur ``` assets/styles/ ├── main.scss ← Entry Point ├── atoms/ │ ├── _variables.scss │ ├── _typography.scss │ ├── _buttons.scss ← .btn, .btn-primary, .btn-secondary │ └── _inputs.scss ← .input, .select, .textarea ├── components/ │ ├── _week-nav.scss │ ├── _month-calendar.scss │ ├── _entry-form.scss │ ├── _entry-list.scss │ ├── _duration-help.scss │ ├── _main-nav.scss │ ├── _greeting.scss │ ├── _crud.scss ← Kunden/Projekte/Leistungen │ ├── _login.scss │ ├── _register.scss │ ├── _team.scss │ ├── _account.scss │ └── _report.scss └── sections/ ├── _timetracking.scss ← .tt-page, .tt-header, .tt-content └── _home.scss ← Landing Page ``` --- ## JS-Struktur ``` assets/ ├── app.js ← Entry: importiert main.scss, calendar.js, entries.js └── scripts/ ├── calendar.js ← WeekCalendar (Wochennavigation, Monatsansicht) ├── entries.js ← EntryManager (CRUD, fetch, localStorage) ├── duration.js ← parseDuration(), roundToQuarter(), formatMinutes(), │ validateDuration(), initDurationBlurHandler() ├── crud.js ← Entry: generisches CRUD (Kunden/Projekte/Leistungen) ├── registration.js ← Entry: Registrierungs-Flow, Live-Slug-Vorschau ├── team.js ← Entry: Team-Verwaltung ├── account.js ← Entry: Account- + Profil-Einstellungen └── report.js ← Entry: Report-Seite, Edit + Invoiced-Toggle ``` ### Webpack Encore Entries | Entry | Datei | Genutzt auf | |---------------|------------------------------|------------------------------------| | `app` | `assets/app.js` | Timetracking-Wochenansicht | | `crud` | `assets/scripts/crud.js` | Client/Project/Service-Seiten | | `registration`| `assets/scripts/registration.js` | Registrierungsseite | | `team` | `assets/scripts/team.js` | Team-Seite | | `account` | `assets/scripts/account.js` | Account-Einstellungen | | `report` | `assets/scripts/report.js` | Report-Seite | --- ## Wichtige Konventionen ### Durations - Gespeichert als **Integer (Minuten)** in der DB - Eingabe: `1:30`, `8 12` (von-bis), `1,75` (Dezimal), `0:00` (Reset) - Runden: konfigurierbar per `Account.trackingInterval` (1, 15, 30, 60 Minuten) - Anzeige: `formatMinutes()` → `"1:30"` ### Translations - Alle UI-Strings in `translations/messages.de.yaml` - Datums-Arrays in `AppExtension.php` als Twig-Funktionen: `deMonths()`, `deMonthsShort()`, `deWeekdays()`, `deWeekdaysShort()` - JS bekommt Strings via `window.TT.i18n` (Timetracking) bzw. `window.Report.i18n` (Report) etc. - JS-Zugriff: `function t(key) { return window.X?.i18n?.[key] ?? key; }` ### API-Pattern - Alle API-Routen unter `/api/...` - JSON Request/Response - CSRF nur bei `form_login` (Symfony Security), sonst kein CSRF - Fehler: `{ error: "..." }` mit passendem HTTP-Status - Validierungsfehler: `{ errors: ["..."] }` mit 422 ### Rollen | Rolle | Kann | |-----------|----------------------------------------------------| | `admin` | Alles: Team verwalten, alle Einträge sehen/bearbeiten, Account-Einstellungen | | `member` | Eigene Einträge + alle fremden Einträge sehen (kein Team-Zugriff) | | `tracker` | Nur eigene Einträge sehen und bearbeiten | ### Archivierung vs. Löschen (Team) - User mit Zeiteinträgen können nur **archiviert** werden (nicht gelöscht) - Archivierte User können sich nicht einloggen (`ArchivedUserChecker`) - Kontoinhaber (`superAdminUser`) kann nicht archiviert/gelöscht werden --- ## Was noch fehlt / TODO - [ ] Filter auf Report-Seite (Datumsbereich, Projekt, Service, User) - [ ] Export (CSV / PDF) - [ ] Timer-Funktion (Live-Zeiterfassung) - [ ] Wochenübersicht mit Summen pro Tag (im Wochenkalender) - [ ] E-Mail-Konfiguration für Produktivbetrieb (aktuell DDEV Mailpit) - [ ] Passwort-Reset-Flow --- ## Seed-Daten ```bash bash 1-reset-and-seed.sh # → DB droppen, Migrations ausführen, app:seed aufrufen bash 2-update-tenant-db.sh # → Tenant-DB-Schema aktualisieren ``` `app:seed` legt an: 1 Account (spawntree), 1 Admin-User, 5 Leistungen (4 verrechenbar, 1 intern), 10 Kunden mit je 1–3 Projekten. --- ## 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` - 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)