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.
timetracking, HTTPS-Port 8459fetch()-APIDer 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, 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, 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/)
db): User, Account, AccountUser, Tokensdb_{slug}): Client, Project, Service, TimeEntryTenantRequestSubscriber (Prio 20) liest Subdomain → setzt TenantContextTenantConnectionMiddleware schaltet die DB-Connection auf db_{slug} umcentral und tenant# 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
| 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 |
1:30, 8 12 (von-bis), 1,75 (Dezimal)Account.trackingInterval (1/15/30/60 Min)/api/... Routen, JSON Request/Response, kein CSRF auf API-EndpunktenTimeEntry.timerStartedAt (nullable DateTimeImmutable) markiert laufende Timer. Pro User nur ein aktiver Timer gleichzeitig. Elapsed wird beim Stoppen auf trackingInterval gerundet und zu duration addiert.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.admin (alles), member (eigene + fremde Einträge sehen), tracker (nur eigene):root-Variablen (--color-primary, etc.)Alle UI-Texte leben zentral in translations/messages.de.yaml. Nirgends dürfen deutsche Strings direkt im Code stehen.
{{ 'app.section.key'|trans }} bzw. {{ 'app.key'|trans({'%placeholder%': value}) }}TranslatorInterface injizieren, $this->translator->trans('app.key') nutzenwindow.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')app.{section}.{key} (z.B. app.team.btn_new, app.error.access_denied, app.validation.email_required)Keine style="..."-Attribute in Twig-Templates oder JS-generiertem HTML. Stattdessen eigene SCSS-Klassen verwenden.
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-StylingGemeinsame 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-OperationenANIMATION_MS, FADE_MS, MINUTES_PER_DAYStoppuhr-Modul mit zwei Klassen:
SearchableSelect — Wiederverwendbares durchsuchbares Dropdown mit Gruppen, Keyboard-Navigation und FilterStopwatchManager — Timer-Steuerung: Start/Stop/Resume, Tick-Display (Sekunden-Auflösung), DOM-Integration mit window.entryManager, Tab-Title-Update[hidden]-Regelmain.scss enthält [hidden] { display: none !important; }. Kein &[hidden] { display: none !important; } in einzelnen Komponenten nötig.
readonly Constructor PromotionController-Abhängigkeiten immer mit private readonly deklarieren.
Komplexe Logik die in mehreren Templates vorkommt wird in templates/_macros/helpers.html.twig als Macro ausgelagert (z.B. smart_date).
| 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 und primaryColor setzen.
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)/reports/export/excel): PhpSpreadsheet, Autofilter, Frozen Header, Zebra-Stripes, Summenzeile/reports/export/csv): Semikolon-Trennzeichen, UTF-8 BOM, deutsche Zahlenformatierung/reports/export/pdf): Dompdf, A4 Querformat, professioneller Header + FooterAlle 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.
Live-Timer zum Tracken von Zeiteinträgen. UI in der Navigation (Desktop + Hamburger-Menü), zusätzlich Play-Button an jeder Entry-Row zum Fortsetzen.
TimeTrackingController (/api/timer/*)assets/scripts/stopwatch.js (importiert in app.js), enthält SearchableSelect-Klasse (durchsuchbare Dropdowns) und StopwatchManagerassets/styles/components/_stopwatch.scss_sections/nav.html.twig, Play-Button in timetracking/_entry_row.html.twig_atoms/icon-stopwatch.html.twig| 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 |
TimeEntry.timerStartedAt wird beim Start gesetzt, beim Stopp auf nullnow - timerStartedAt, gerundet auf Account.trackingInterval, addiert zu durationtt_timer_state): persistiert Timer-State über Page-Reloads, wird mit Server-State abgeglichentt_last_project_id, tt_last_service_id): merkt letzte Auswahl für Quick-StartRegistriert via Service-Tag in services.yaml (nicht via doctrine.yaml — DoctrineBundle 3.x unterstützt middlewares-Config-Key nicht):
App\Doctrine\TenantConnectionMiddleware:
tags:
- { name: doctrine.middleware, connection: tenant }