Flo Eisenmenger, Geschäftsführer der spawntree GmbH (kleine Webagentur, Hamburg). DDEV-Entwicklungsumgebung, Symfony 7.x, PHP 8.2, MariaDB 10.11.
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.
spawntree.testtimetracking.ddev.sitedb_spawntreedb) enthält accountübergreifende Daten: User, Account, AccountUser, Tokencentral → App\Entity\Central\* (User, Account, AccountUser, InviteToken, RegistrationToken)tenant → App\Entity\Tenant\* (Client, Project, Service, TimeEntry)TenantConnectionMiddlewareTenantRequestSubscriber (Priorität 20, vor Firewall) liest den Subdomain-Slug aus dem HostTenantContextTenantContext ist ein Request-scoped Service, der den aktiven Account hältApp\Entity\CentralUserid, email (unique), firstName, lastName, password (nullable), noteUserInterface, PasswordAuthenticatedUserInterfacegetFullName() → "Flo Eisenmenger"Accountid, name, slug (unique, → Subdomain), trackingInterval (smallint, default 1)createdAt, superAdminUser (ManyToOne → User, nullable)accountUsers (OneToMany → AccountUser)getTenantDbName() → "db_" . str_replace('-', '_', slug)isSuperAdmin(User) → boolgetAdmins() → Collection aktiver Admin-AccountUserAccountUserid, account (ManyToOne), user (ManyToOne), role (admin/member/tracker), archivedAtROLE_ADMIN, ROLE_MEMBER, ROLE_TRACKERisArchived(), isAdmin(), isMember(), isTracker(), isMemberOrAdmin()getRoleLabel() → DeutschInviteTokenid, token, account, email, firstName, lastName, role, createdAtisExpired() (7 Tage)RegistrationTokenApp\Entity\TenantClient (Kunde)id, name, hourlyRate (decimal, nullable), noteProjectsProjectid, name, client (ManyToOne → Client), noteService (Leistung)id, name, billable (bool, default true), noteTimeEntryid, 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"GET / → app_home (HomeController – Landing Page oder Redirect)GET /register → app_register (Registrierungsseite)POST /api/register → api_registerPOST /api/register/preview-slug → api_register_preview_slug (Live-Slug-Vorschau)GET /verify/{token} → app_verify (E-Mail-Bestätigung)GET /login → app_loginGET /logout → app_logoutGET /invite/{token} → app_invite (Passwort setzen nach Einladung)GET / → Redirect (je nach Login-Status)GET /week → timetracking_weekGET /week/{date} → timetracking_week_dateGET /api/entries?date=Y-m-d → Einträge für einen Tag (JSON)POST /api/entries → Eintrag erstellenPATCH /api/entries/{id} → Eintrag bearbeitenDELETE /api/entries/{id} → Eintrag löschenPATCH /api/entries/{id}/invoiced → Abrechnungsstatus toggelnGET /clients → client_indexPOST /api/clients, PATCH /api/clients/{id}, DELETE /api/clients/{id}GET /projects → project_indexPOST /api/projects, PATCH /api/projects/{id}, DELETE /api/projects/{id}GET /services → service_indexPOST /api/services, PATCH /api/services/{id}, DELETE /api/services/{id}GET /reports/times → report_timesGET /team → team_indexPOST /api/team/invite → Einladung versendenPATCH /api/team/{id} → Mitglied bearbeitenPATCH /api/team/{id}/archive → Mitglied archivierenPATCH /api/team/{id}/unarchive → Mitglied reaktivierenDELETE /api/team/{id} → Mitglied entfernenDELETE /api/team/invite/{id} → Einladung löschenGET /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 ändernTenantContext – hält aktiven Account für den RequestTenantConnectionMiddleware – wechselt Tenant-DB-Name zur LaufzeitTenantRequestSubscriber – liest Subdomain, setzt TenantContext (Prio 20)ArchivedUserChecker – blockiert Login für archivierte UserArchivedUserSubscriber – wirft Exception bei archivierten Usern während des RequestsSlidingSessionSubscriber – verlängert Session bei AktivitätAccountRoleHelper – isAdmin(), isMember(), isTracker() für den aktuellen User/AccountRegistrationService – startRegistration(), confirm(token) – erstellt Account + DB + UserSlugGenerator – generiert/prüft Slugs aus FirmennamenAppExtension / AppExtensionRuntime – Twig-Funktionen: deMonths(), deMonthsShort(), deWeekdays(), deWeekdaysShort()AccessDeniedHandler – leitet bei 403 auf Login umtemplates/
├── 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
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
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
| 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 |
1:30, 8 12 (von-bis), 1,75 (Dezimal), 0:00 (Reset)Account.trackingInterval (1, 15, 30, 60 Minuten)formatMinutes() → "1:30"translations/messages.de.yamlAppExtension.php als Twig-Funktionen: deMonths(), deMonthsShort(), deWeekdays(), deWeekdaysShort()window.TT.i18n (Timetracking) bzw. window.Report.i18n (Report) etc.function t(key) { return window.X?.i18n?.[key] ?? key; }/api/...form_login (Symfony Security), sonst kein CSRF{ error: "..." } mit passendem HTTP-Status{ errors: ["..."] } mit 422| 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 |
ArchivedUserChecker)superAdminUser) kann nicht archiviert/gelöscht werdenbash 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.
testtimetrackinghttps://testtimetracking.ddev.site:8456https://spawntree.testtimetracking.ddev.site:8456https://testtimetracking.ddev.site:8037db, 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)