Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 
 
 
 

17 KiB

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 → Themes (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

  • centralApp\Entity\Central\* (User, Account, AccountUser, InviteToken, RegistrationToken)
  • tenantApp\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
  • theme (VARCHAR 20, default 'standard') – Darstellungs-Theme des Users
  • Implementiert UserInterface, PasswordAuthenticatedUserInterface
  • getFullName()"Flo Eisenmenger"

Account

  • id, name, slug (unique, → Subdomain), trackingInterval (smallint, default 1)
  • primaryColor (VARCHAR 7, nullable) – Hauptfarbe des Standard-Themes (Hex, z.B. #3a7bbf). Nur Superadmin kann sie setzen. Wird für alle User des Accounts angewendet.
  • 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 Projects

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 /registerapp_register (Registrierungsseite)
  • POST /api/registerapi_register
  • POST /api/register/preview-slugapi_register_preview_slug (Live-Slug-Vorschau)
  • GET /verify/{token}app_verify (E-Mail-Bestätigung)

Auth (Subdomain)

  • GET /loginapp_login
  • GET /logoutapp_logout
  • GET /invite/{token}app_invite (Passwort setzen nach Einladung)
  • GET /password-reset → Passwort-Reset anfordern
  • GET /password-reset/{token} → Neues Passwort setzen

Timetracking

  • GET / → Redirect (je nach Login-Status)
  • GET /weektimetracking_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 /clientsclient_index
  • POST /api/clients, PATCH /api/clients/{id}, DELETE /api/clients/{id}
  • GET /projectsproject_index
  • POST /api/projects, PATCH /api/projects/{id}, DELETE /api/projects/{id}
  • GET /servicesservice_index
  • POST /api/services, PATCH /api/services/{id}, DELETE /api/services/{id}

Reports

  • GET /reports/timesreport_times

Team (nur Admins)

  • GET /teamteam_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 /accountaccount_index (Tab: account / user, je nach Rolle)
  • PATCH /api/account → Name + trackingInterval + primaryColor (nur Admin; primaryColor nur vom Superadmin befüllt)
  • PATCH /api/account/superadmin → Kontoinhaber übertragen (nur aktueller Superadmin)
  • PATCH /api/account/user → Eigene Profildaten / Passwort / Theme ä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
  • AccountRoleHelperisAdmin(), isMember(), isTracker() für den aktuellen User/Account
  • RegistrationServicestartRegistration(), confirm(token) – erstellt Account + DB + User
  • SlugGenerator – generiert/prüft Slugs aus Firmennamen
  • 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
  • AccessDeniedHandler – leitet bei 403 auf Login um

Theme-System

User-Themes

Jeder User kann sein persönliches Theme wählen (gespeichert in User.theme):

  • standard – Volle Navigation, alle Felder sichtbar (Default)
  • minimal – Ablenkungsfreie Ansicht: keine Top-Nav, Hamburger-Menü, Wochenansicht einklappbar, borderlose Eingabefelder, Entry-Liste per Klick aufklappbar

body[data-theme="minimal"] wird in base.html.twig gesetzt.

Brand-Farbe (Account-Level)

Der Superadmin kann in den Account-Einstellungen eine Hauptfarbe (Hex, z.B. #3a7bbf) hinterlegen. Diese gilt für alle User des Accounts im Standard-Theme.

Funktionsweise:

  1. Account.primaryColor wird in der DB gespeichert
  2. BrandColorService::compute($hex) leitet daraus 6 Farbvarianten ab (HSL-Offsets: primary +9L, primaryDark -3L, primaryLight +20L/-5S, headerFrom +13L/-4S, headerTo = Basisfarbe, bg +40L/-30S)
  3. AppExtension::brandPalette() gibt die Palette zurück (oder null bei Standardfarbe #3a7bbf)
  4. base.html.twig injiziert ein <style>:root { --color-xxx: ... }</style> Block wenn eine Custom-Farbe gesetzt ist
  5. SCSS nutzt var(--color-xxx) für alle sichtbaren Farbverwendungen (Gradienten, Texte, Borders, Backgrounds). Subtile rgba($color-primary, 0.02–0.1) Tints bleiben compilierte SCSS-Werte (praktisch unsichtbar)

CSS Custom Properties (:root Defaults in _variables.scss):

  • --color-primary: #4a90d9
  • --color-primary-dark: #3178b8
  • --color-primary-light: #6aaee8
  • --color-header-from: #5b9fd6
  • --color-header-to: #3a7bbf
  • --color-bg: #dce9f5
  • --color-primary-rgb: 74, 144, 217

Template-Struktur

templates/
├── base.html.twig               ← Brand-Farbe CSS-Injection hier
├── _sections/
│   ├── nav.html.twig            ← Top-Nav + Hamburger-Nav
│   ├── tt-header.html.twig      ← Minimal-Bar + Wochennavigation (collapsible)
│   └── _atoms/                  ← Icons, duration-help, etc.
├── 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               ← SCSS-Vars + :root CSS Custom Properties
│   ├── _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
│   └── _report.scss
└── themes/
    └── _minimal.scss                 ← body[data-theme="minimal"] Overrides

JS-Struktur

assets/
├── app.js                      ← Entry: importiert main.scss, calendar.js, entries.js
└── scripts/
    ├── calendar.js             ← WeekCalendar (Wochennavigation, Monatsansicht)
    │                              Positioniert Monatskalender relativ zum Cal-Icon
    ├── entries.js              ← EntryManager (CRUD, fetch, localStorage)
    │                              + initMinimalMode() (WeekToggle, NoteToggle, EntriesToggle)
    │                              + window._updateWeekToggle(kw) für KW-Sync bei Navigation
    ├── duration.js             ← parseDuration(), roundToQuarter(), formatMinutes(),
    │                              validateDuration(), initDurationBlurHandler()
    ├── nav.js                  ← Hamburger-Navigation (Minimal-Theme)
    ├── 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
    │                              Farbfeld: Picker ↔ Hex-Input synchron,
    │                              Theme-Picker, Passwort-Toggle
    └── 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

Migrations (Central-DB)

Version Inhalt
Version20260523* Initiales Schema (User, Account, AccountUser, Token)
Version20260524* Passwort-Reset, Invite-Flow
Version20260526120000 user.theme VARCHAR(20) DEFAULT ‘standard’
Version20260526150000 account.primary_color VARCHAR(7) NULL

Migration ausführen: ddev exec php bin/console doctrine:migrations:migrate --em=central --no-interaction


Was noch fehlt / TODO


Seed-Daten

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)