| @@ -0,0 +1,199 @@ | |||
| # 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 ggf. 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 mehr – eigene HTML-Formulare mit fetch()-API | |||
| --- | |||
| ## Datenbankstruktur (Entities) | |||
| ### `User` | |||
| - `id`, `email` (unique), `firstName`, `lastName`, `password` (nullable), `note` | |||
| - Standard-User: `f.eisenmenger@spawntree.de` / Flo Eisenmenger | |||
| ### `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**), `user`, `project`, `service` (nullable), `note`, `createdAt`, `updatedAt` | |||
| - `toArray()` Methode für JSON-Responses | |||
| --- | |||
| ## Routing-Übersicht | |||
| ### Timetracking (Hauptseite) | |||
| - `GET /` → `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 | |||
| ### 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}` | |||
| --- | |||
| ## Template-Struktur | |||
| ``` | |||
| templates/ | |||
| ├── base.html.twig ← Basistemplate mit Nav-Include | |||
| ├── _nav.html.twig ← Dunkle Top-Navigation | |||
| ├── timetracking/ | |||
| │ ├── week.html.twig ← Hauptseite (Zeiterfassung) | |||
| │ └── _entry_row.html.twig ← Partial: einzelne Zeiteintrag-Zeile | |||
| ├── client/ | |||
| │ └── index.html.twig ← Kundenliste mit Inline-Edit | |||
| ├── project/ | |||
| │ └── index.html.twig ← Projektliste mit Inline-Edit | |||
| └── service/ | |||
| └── index.html.twig ← Leistungsliste mit Inline-Edit | |||
| ``` | |||
| --- | |||
| ## SCSS-Struktur | |||
| ``` | |||
| assets/styles/ | |||
| ├── main.scss ← Entry Point, importiert alles | |||
| ├── atoms/ | |||
| │ ├── _variables.scss ← Farben, Spacing, etc. | |||
| │ ├── _typography.scss | |||
| │ ├── _buttons.scss ← .btn, .btn-primary, .btn-secondary | |||
| │ └── _inputs.scss ← .input, .select, .textarea | |||
| └── components/ | |||
| ├── _week-nav.scss ← Wochennavigation im Header | |||
| ├── _month-calendar.scss ← Monatskalender (Popup) | |||
| ├── _entry-form.scss ← Zeiterfassungs-Formular | |||
| ├── _entry-list.scss ← Eintrags-Liste inkl. Inline-Edit | |||
| ├── _duration-help.scss ← "?"-Tooltip beim Dauerfeld | |||
| ├── _main-nav.scss ← Dunkle Top-Nav | |||
| ├── _greeting.scss ← Begrüßungszeile | |||
| └── _crud.scss ← CRUD-Seiten (Kunden/Projekte/Leistungen) | |||
| sections/ | |||
| └── _timetracking.scss ← .tt-page, .tt-header, .tt-content | |||
| ``` | |||
| --- | |||
| ## JS-Struktur | |||
| ``` | |||
| assets/ | |||
| ├── app.js ← Webpack Entry für Hauptseite | |||
| │ importiert: main.scss, calendar.js, entries.js | |||
| ├── scripts/ | |||
| │ ├── calendar.js ← WeekCalendar Klasse | |||
| │ │ - Wochennavigation mit Slide-Animation | |||
| │ │ - Monatsansicht (Popup) | |||
| │ │ - Ruft window.entryManager.loadEntriesForDate() auf | |||
| │ ├── entries.js ← EntryManager Klasse | |||
| │ │ - Event Delegation auf #entry-list | |||
| │ │ - CRUD via fetch() | |||
| │ │ - localStorage für letztes Projekt/Leistung | |||
| │ │ - Importiert aus duration.js | |||
| │ ├── duration.js ← Hilfsfunktionen (Export) | |||
| │ │ - parseDuration() (1:30, 8 12, 1,75) | |||
| │ │ - roundToQuarter() (konfigurierbar) | |||
| │ │ - DURATION_CONFIG.roundToQuarter = true | |||
| │ │ - initDurationBlurHandler() | |||
| │ └── crud.js ← Webpack Entry für CRUD-Seiten | |||
| │ Generic für Kunden/Projekte/Leistungen | |||
| ``` | |||
| **Wichtig**: `entries.js` nutzt ES Module `import/export` – funktioniert mit Webpack Encore. | |||
| --- | |||
| ## Webpack Encore (webpack.config.js) | |||
| Zwei Entries: | |||
| - `app` → `./assets/app.js` (Hauptseite) | |||
| - `crud` → `./assets/scripts/crud.js` (CRUD-Seiten) | |||
| CRUD-Seiten laden `crud.js` via `{{ encore_entry_script_tags('crud') }}` im Twig-Block. | |||
| --- | |||
| ## Wichtige Konventionen | |||
| ### Durations | |||
| - Gespeichert als **Integer (Minuten)** in der DB | |||
| - Eingabe: `1:30`, `8 12` (von-bis), `1,75` (Dezimal), `0:00` (Reset) | |||
| - Automatisch auf **15-Minuten-Schritt aufgerundet** (konfigurierbar) | |||
| - Anzeige: `formatMinutes()` → `"1:30"` | |||
| ### Translations | |||
| - Alle UI-Strings in `translations/messages.de.yaml` | |||
| - Datums-Arrays (Monate, Wochentage) in `AppExtension.php` als Twig-Functions: `deMonths()`, `deMonthsShort()`, `deWeekdays()`, `deWeekdaysShort()` | |||
| - JS bekommt alle Strings via `window.TT.i18n` (aus Twig gesetzt) | |||
| - JS-Zugriff: `function t(key) { return window.TT?.i18n?.[key] ?? key; }` | |||
| ### API-Pattern | |||
| - Alle API-Routen unter `/api/...` | |||
| - JSON Request/Response | |||
| - Kein CSRF (reine JSON-API) | |||
| - Fehler: `{ error: "..." }` mit passendem HTTP-Status | |||
| ### Aktiver User | |||
| Aktuell hardcoded auf `f.eisenmenger@spawntree.de`. | |||
| Auth (Login/Session) ist noch **nicht gebaut** – kommt später. | |||
| --- | |||
| ## Was noch fehlt / TODO | |||
| - [ ] Login / Authentifizierung (Symfony Security) | |||
| - [ ] Reports-Seite | |||
| - [ ] Wochenübersicht mit Summen | |||
| - [ ] Export (CSV / PDF) | |||
| - [ ] Multi-User / Mandantenfähigkeit | |||
| - [ ] Timer-Funktion (Live-Zeiterfassung) | |||
| - [ ] Passwort-Hashing für User-Entity | |||
| --- | |||
| ## Seed-Daten (reset-and-seed.sh) | |||
| ```bash | |||
| bash reset-and-seed.sh | |||
| # → DB droppen, Migrations ausführen, app:seed aufrufen | |||
| ``` | |||
| `app:seed` legt an: 1 User, 5 Leistungen (4 verrechenbar, 1 intern), 10 Kunden mit je 1-3 Projekten. | |||
| --- | |||
| ## DDEV-Konfiguration | |||
| - Projekt: `testtimetracking` | |||
| - URL: `https://testtimetracking.ddev.site:8456` | |||
| - PHPMyAdmin: `https://testtimetracking.ddev.site:8037` (nach `ddev get ddev/ddev-phpmyadmin`) | |||
| - MariaDB: User `db`, Passwort `db`, DB `db` | |||
| - `.env`: `DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=10.11.2-MariaDB&charset=utf8mb4"` | |||
| @@ -1,32 +1,199 @@ | |||
| // assets/scripts/report.js | |||
| document.addEventListener('DOMContentLoaded', () => { | |||
| document.addEventListener('click', async (e) => { | |||
| const btn = e.target.closest('[data-action="toggle-invoiced"]'); | |||
| if (!btn) return; | |||
| import { | |||
| parseDuration, | |||
| roundToQuarter, | |||
| formatMinutes, | |||
| validateDuration, | |||
| initDurationBlurHandler, | |||
| } from './duration.js'; | |||
| // ── Hilfsfunktionen ─────────────────────────────────────────────────────────── | |||
| function t(key) { | |||
| return window.Report?.i18n?.[key] ?? key; | |||
| } | |||
| function populateProjectSelect(select, selectedId) { | |||
| const projects = window.Report?.projects ?? []; | |||
| select.innerHTML = ''; | |||
| projects.forEach(p => { | |||
| const opt = document.createElement('option'); | |||
| opt.value = p.id; | |||
| opt.textContent = `${p.clientName} / ${p.name}`; | |||
| if (p.id === selectedId) opt.selected = true; | |||
| select.appendChild(opt); | |||
| }); | |||
| } | |||
| function populateServiceSelect(select, selectedId) { | |||
| const services = window.Report?.services ?? []; | |||
| const billable = services.filter(s => s.billable); | |||
| const notBillable = services.filter(s => !s.billable); | |||
| select.innerHTML = `<option value="">${t('selectPh')}</option>`; | |||
| function addGroup(label, list) { | |||
| if (!list.length) return; | |||
| const group = document.createElement('optgroup'); | |||
| group.label = label; | |||
| list.forEach(s => { | |||
| const opt = document.createElement('option'); | |||
| opt.value = s.id; | |||
| opt.textContent = s.name; | |||
| if (s.id === selectedId) opt.selected = true; | |||
| group.appendChild(opt); | |||
| }); | |||
| select.appendChild(group); | |||
| } | |||
| addGroup(t('billable'), billable); | |||
| addGroup(t('notBillable'), notBillable); | |||
| } | |||
| // ── Edit öffnen ─────────────────────────────────────────────────────────────── | |||
| function openEdit(row) { | |||
| document.querySelectorAll('.report-table__row--editing').forEach(r => { | |||
| if (r !== row) closeEdit(r); | |||
| }); | |||
| const editForm = row.querySelector('.report-row__edit'); | |||
| if (!editForm) return; | |||
| // Selects klonen um akkumulierte Listener zu vermeiden | |||
| const oldProjectSel = row.querySelector('.edit-project'); | |||
| const oldServiceSel = row.querySelector('.edit-service'); | |||
| const projectSel = oldProjectSel.cloneNode(false); | |||
| const serviceSel = oldServiceSel.cloneNode(false); | |||
| oldProjectSel.replaceWith(projectSel); | |||
| oldServiceSel.replaceWith(serviceSel); | |||
| const projectId = parseInt(row.dataset.projectId) || null; | |||
| const serviceId = parseInt(row.dataset.serviceId) || null; | |||
| populateProjectSelect(projectSel, projectId); | |||
| populateServiceSelect(serviceSel, serviceId); | |||
| projectSel.addEventListener('change', () => { | |||
| populateServiceSelect(row.querySelector('.edit-service'), null); | |||
| }); | |||
| editForm.hidden = false; | |||
| row.classList.add('report-table__row--editing'); | |||
| row.querySelector('.edit-duration')?.focus(); | |||
| } | |||
| function closeEdit(row) { | |||
| const editForm = row.querySelector('.report-row__edit'); | |||
| if (!editForm) return; | |||
| editForm.hidden = true; | |||
| row.classList.remove('report-table__row--editing'); | |||
| } | |||
| // ── Speichern ───────────────────────────────────────────────────────────────── | |||
| async function saveEdit(row) { | |||
| const id = row.dataset.entryId; | |||
| const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00'; | |||
| const projectId = row.querySelector('.edit-project')?.value; | |||
| const serviceId = row.querySelector('.edit-service')?.value; | |||
| const note = row.querySelector('.edit-note')?.value ?? ''; | |||
| if (!projectId) { alert(t('errorNoProject')); return; } | |||
| const rawMinutes = roundToQuarter(parseDuration(durationRaw)); | |||
| const row = btn.closest('[data-entry-id]'); | |||
| if (!row) return; | |||
| if (rawMinutes === 0) { alert(t('errorZeroDuration')); return; } | |||
| const validation = validateDuration(rawMinutes); | |||
| if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; } | |||
| if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return; | |||
| try { | |||
| const res = await fetch(`/api/entries/${id}`, { | |||
| method: 'PATCH', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify({ | |||
| duration: formatMinutes(rawMinutes), | |||
| projectId: parseInt(projectId), | |||
| serviceId: serviceId ? parseInt(serviceId) : null, | |||
| note: note || null, | |||
| }), | |||
| }); | |||
| if (!res.ok) { alert(t('errorSave')); return; } | |||
| window.location.reload(); | |||
| } catch { | |||
| alert(t('errorSave')); | |||
| } | |||
| } | |||
| // ── Löschen ─────────────────────────────────────────────────────────────────── | |||
| async function deleteEntry(row) { | |||
| if (!confirm(t('confirmDelete'))) return; | |||
| const id = row.dataset.entryId; | |||
| try { | |||
| const res = await fetch(`/api/entries/${id}/invoiced`, { method: 'PATCH' }); | |||
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |||
| const res = await fetch(`/api/entries/${id}`, { method: 'DELETE' }); | |||
| if (!res.ok) { alert(t('errorDelete')); return; } | |||
| window.location.reload(); | |||
| } catch { | |||
| alert(t('errorDelete')); | |||
| } | |||
| } | |||
| const data = await res.json(); | |||
| const invoiced = data.invoiced; | |||
| // ── Abgerechnet toggeln ─────────────────────────────────────────────────────── | |||
| row.dataset.invoiced = invoiced ? 'true' : 'false'; | |||
| row.classList.toggle('report-table__row--invoiced', invoiced); | |||
| async function toggleInvoiced(row) { | |||
| const id = row.dataset.entryId; | |||
| const btn = row.querySelector('[data-action="toggle-invoiced"]'); | |||
| btn.classList.toggle('report-lock--invoiced', invoiced); | |||
| btn.title = invoiced | |||
| ? (window.REPORT?.i18n?.btnUnlock ?? '') | |||
| : (window.REPORT?.i18n?.btnLock ?? ''); | |||
| try { | |||
| const res = await fetch(`/api/entries/${id}/invoiced`, { method: 'PATCH' }); | |||
| if (!res.ok) return; | |||
| const data = await res.json(); | |||
| const invoiced = data.invoiced; | |||
| row.dataset.invoiced = invoiced ? 'true' : 'false'; | |||
| row.classList.toggle('report-table__row--invoiced', invoiced); | |||
| if (btn) { | |||
| btn.classList.toggle('report-lock--invoiced', invoiced); | |||
| btn.title = invoiced ? t('btnUnlock') : t('btnLock'); | |||
| } | |||
| } catch (err) { | |||
| console.error('Fehler beim Toggeln des Abrechnungsstatus:', err); | |||
| console.error('Fehler beim Toggeln des Abrechnungsstatus:', err); | |||
| } | |||
| }); | |||
| } | |||
| // ── Event-Delegation ────────────────────────────────────────────────────────── | |||
| document.addEventListener('DOMContentLoaded', () => { | |||
| initDurationBlurHandler(); | |||
| const table = document.querySelector('.report-table'); | |||
| if (!table) return; | |||
| table.addEventListener('click', e => { | |||
| const btn = e.target.closest('[data-action]'); | |||
| if (!btn) return; | |||
| const row = btn.closest('.report-table__row'); | |||
| if (!row) return; | |||
| switch (btn.dataset.action) { | |||
| case 'edit': openEdit(row); break; | |||
| case 'cancel': closeEdit(row); break; | |||
| case 'save': saveEdit(row); break; | |||
| case 'delete': deleteEntry(row); break; | |||
| case 'toggle-invoiced': toggleInvoiced(row); break; | |||
| } | |||
| }); | |||
| }); | |||
| @@ -133,7 +133,7 @@ | |||
| 1fr // Bemerkung | |||
| 80px // Stunden | |||
| 100px // Umsatz | |||
| 36px; // Schloss | |||
| 88px; // Aktionen (Edit + Delete + Schloss) | |||
| align-items: center; | |||
| border-bottom: 1px solid $color-border; | |||
| padding: 0 $space-5; | |||
| @@ -163,19 +163,27 @@ | |||
| background: rgba($color-primary, 0.035); | |||
| } | |||
| &:hover .report-action-btn { | |||
| opacity: 1; | |||
| } | |||
| &--invoiced { | |||
| // Kein opacity – das würde auch das Schloss-Icon abdunkeln/aufhellen. | |||
| // Selektiv nur die Text-Zellen dämpfen: | |||
| .report-table__cell--date { color: $color-text-light; } | |||
| .report-table__cell--client { color: $color-text-light; } | |||
| .report-table__cell--project { color: $color-text-light; } | |||
| .report-table__cell--service { color: $color-text-light; } | |||
| .report-table__cell--user { color: $color-text-light; } | |||
| .report-table__cell--note { color: $color-text-light; } | |||
| .report-table__cell--date { color: $color-text-light; } | |||
| .report-table__cell--client { color: $color-text-light; } | |||
| .report-table__cell--project { color: $color-text-light; } | |||
| .report-table__cell--service { color: $color-text-light; } | |||
| .report-table__cell--user { color: $color-text-light; } | |||
| .report-table__cell--note { color: $color-text-light; } | |||
| .report-table__cell--duration { color: $color-text-light; } | |||
| .report-table__cell--revenue { color: $color-text-light; } | |||
| } | |||
| &--editing { | |||
| background: rgba($color-primary, 0.04); | |||
| .report-table__date-link { color: $color-text-light; text-decoration: none; } | |||
| .report-table__cell--actions { | |||
| visibility: hidden; | |||
| } | |||
| } | |||
| &:last-child { | |||
| @@ -198,10 +206,11 @@ | |||
| font-variant-numeric: tabular-nums; | |||
| } | |||
| &--lock { | |||
| &--actions { | |||
| display: flex; | |||
| justify-content: flex-start; | |||
| align-items: center; | |||
| justify-content: flex-end; | |||
| gap: $space-1; | |||
| padding-right: 0; | |||
| } | |||
| @@ -227,16 +236,6 @@ | |||
| margin-top: 1px; | |||
| } | |||
| .report-table__date-link { | |||
| color: $color-primary; | |||
| text-decoration: none; | |||
| white-space: nowrap; | |||
| &:hover { | |||
| text-decoration: underline; | |||
| } | |||
| } | |||
| .report-table__empty { | |||
| padding: $space-10 $space-5; | |||
| text-align: center; | |||
| @@ -244,6 +243,37 @@ | |||
| font-size: $font-size-sm; | |||
| } | |||
| // ─── Aktions-Buttons (Edit / Delete) ───────────────────────────────────────── | |||
| .report-action-btn { | |||
| display: inline-flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| width: 26px; | |||
| height: 26px; | |||
| border: none; | |||
| background: none; | |||
| cursor: pointer; | |||
| color: $color-text-light; | |||
| border-radius: $radius-sm; | |||
| opacity: 0; | |||
| transition: opacity $transition-fast, color $transition-fast, background $transition-fast; | |||
| svg { | |||
| width: 14px; | |||
| height: 14px; | |||
| } | |||
| &:hover { | |||
| color: $color-text-muted; | |||
| background: rgba($color-text-dark, 0.06); | |||
| } | |||
| &--delete:hover { | |||
| color: $color-error; | |||
| background: rgba($color-error, 0.08); | |||
| } | |||
| } | |||
| // ─── Schloss-Button ────────────────────────────────────────────────────────── | |||
| .report-lock { | |||
| display: inline-flex; | |||
| @@ -278,13 +308,60 @@ | |||
| } | |||
| } | |||
| // ─── Inline-Edit-Formular ──────────────────────────────────────────────────── | |||
| .report-row__edit { | |||
| grid-column: 1 / -1; | |||
| padding: $space-4 0; | |||
| background: rgba($color-primary, 0.025); | |||
| border-top: 1px solid $color-border; | |||
| } | |||
| .report-row__edit-grid { | |||
| display: grid; | |||
| grid-template-columns: 140px 1fr; | |||
| gap: $space-3 $space-5; | |||
| align-items: center; | |||
| max-width: 680px; | |||
| } | |||
| .report-row__edit-label { | |||
| font-size: $font-size-sm; | |||
| color: $color-text-muted; | |||
| text-align: right; | |||
| padding-right: $space-2; | |||
| white-space: nowrap; | |||
| } | |||
| .report-row__edit-field { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: $space-2; | |||
| &--selects { | |||
| gap: $space-3; | |||
| .select { | |||
| flex: 1; | |||
| min-width: 160px; | |||
| } | |||
| } | |||
| .textarea { | |||
| width: 100%; | |||
| } | |||
| } | |||
| .report-row__edit-actions { | |||
| grid-column: 2; | |||
| display: flex; | |||
| gap: $space-3; | |||
| padding-top: $space-2; | |||
| } | |||
| // ─── Pagination-Footer ──────────────────────────────────────────────────────── | |||
| // Gleiche Grid-Spalten wie .report-table__head/.report-table__row: | |||
| // 1fr entspricht allen Spalten links der Zahlen (Anzeigen-Text) | |||
| // 80px = Stunden, 100px = Umsatz, 36px = Schloss-Platzhalter | |||
| .report-pagination { | |||
| display: grid; | |||
| grid-template-columns: 1fr 80px 100px 36px; | |||
| grid-template-columns: 1fr 80px 100px 88px; | |||
| align-items: center; | |||
| padding: $space-3 $space-5; | |||
| border-top: 1px solid $color-border; | |||
| @@ -324,5 +401,5 @@ | |||
| } | |||
| .report-pagination__lock-spacer { | |||
| // Platzhalter für die Schloss-Spalte – hält die Ausrichtung | |||
| // Platzhalter für die Aktions-Spalte – hält die Ausrichtung | |||
| } | |||
| @@ -4,13 +4,18 @@ namespace App\Controller; | |||
| use App\Repository\Central\AccountUserRepository; | |||
| use App\Repository\Tenant\TimeEntryRepository; | |||
| use App\Repository\Tenant\ProjectRepository; | |||
| use App\Repository\Tenant\ServiceRepository; | |||
| use App\Service\TenantContext; | |||
| use App\Entity\Central\User; | |||
| use App\Service\AccountRoleHelper; | |||
| use Doctrine\ORM\EntityManagerInterface; | |||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | |||
| use Symfony\Component\HttpFoundation\JsonResponse; | |||
| use Symfony\Component\HttpFoundation\Request; | |||
| use Symfony\Component\HttpFoundation\Response; | |||
| use Symfony\Component\Routing\Attribute\Route; | |||
| use Symfony\Bundle\SecurityBundle\Security; | |||
| class ReportController extends AbstractController | |||
| { | |||
| @@ -21,6 +26,10 @@ class ReportController extends AbstractController | |||
| private readonly TimeEntryRepository $timeEntryRepo, | |||
| private readonly AccountUserRepository $accountUserRepo, | |||
| private readonly TenantContext $tenantContext, | |||
| private readonly AccountRoleHelper $roleHelper, | |||
| private readonly Security $security, | |||
| private readonly ProjectRepository $projectRepo, | |||
| private readonly ServiceRepository $serviceRepo, | |||
| ) {} | |||
| #[Route('/reports/times', name: 'report_times')] | |||
| @@ -31,6 +40,12 @@ class ReportController extends AbstractController | |||
| $limit = 50; | |||
| } | |||
| /** @var User $currentUser */ | |||
| $currentUser = $this->security->getUser(); | |||
| $currentUserId = $currentUser->getId(); | |||
| $isAdmin = $this->roleHelper->isAdmin(); | |||
| $isTracker = $this->roleHelper->isTracker(); | |||
| // User-Map: userId → vollständiger Name | |||
| $account = $this->tenantContext->getAccount(); | |||
| $accountUsers = $this->accountUserRepo->findBy(['account' => $account]); | |||
| @@ -39,10 +54,17 @@ class ReportController extends AbstractController | |||
| $userMap[$au->getUser()->getId()] = $au->getUser()->getFullName(); | |||
| } | |||
| $entries = $this->timeEntryRepo->findForReport($limit); | |||
| $totalCount = $this->timeEntryRepo->countAll(); | |||
| $totalMinutes = $this->timeEntryRepo->sumDurationAll(); | |||
| $totalRevenue = $this->timeEntryRepo->sumRevenueAll(); | |||
| if ($isTracker) { | |||
| $entries = $this->timeEntryRepo->findForReportByUserId($currentUserId, $limit); | |||
| $totalCount = $this->timeEntryRepo->countByUserId($currentUserId); | |||
| $totalMinutes = $this->timeEntryRepo->sumDurationByUserId($currentUserId); | |||
| $totalRevenue = $this->timeEntryRepo->sumRevenueByUserId($currentUserId); | |||
| } else { | |||
| $entries = $this->timeEntryRepo->findForReport($limit); | |||
| $totalCount = $this->timeEntryRepo->countAll(); | |||
| $totalMinutes = $this->timeEntryRepo->sumDurationAll(); | |||
| $totalRevenue = $this->timeEntryRepo->sumRevenueAll(); | |||
| } | |||
| return $this->render('report/times.html.twig', [ | |||
| 'entries' => $entries, | |||
| @@ -53,6 +75,11 @@ class ReportController extends AbstractController | |||
| 'limit' => $limit, | |||
| 'validLimits' => self::VALID_LIMITS, | |||
| 'accountName' => $account?->getName() ?? '', | |||
| 'currentUserId' => $currentUserId, | |||
| 'isAdmin' => $isAdmin, | |||
| 'projects' => $this->projectRepo->findAllWithClient(), | |||
| 'services' => $this->serviceRepo->findAllOrderedByBillable(), | |||
| 'trackingInterval' => $this->tenantContext->getAccount()?->getTrackingInterval() ?? 1, | |||
| ]); | |||
| } | |||
| @@ -66,6 +93,12 @@ class ReportController extends AbstractController | |||
| return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| } | |||
| /** @var User $currentUser */ | |||
| $currentUser = $this->security->getUser(); | |||
| if (!$this->roleHelper->isAdmin() && $entry->getUserId() !== $currentUser->getId()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| } | |||
| $entry->setInvoiced(!$entry->isInvoiced()); | |||
| $this->tenantEm->flush(); | |||
| @@ -8,8 +8,10 @@ use App\Repository\Tenant\ProjectRepository; | |||
| use App\Repository\Tenant\ServiceRepository; | |||
| use App\Repository\Tenant\TimeEntryRepository; | |||
| use App\Service\TenantContext; | |||
| use App\Service\AccountRoleHelper; | |||
| use Doctrine\ORM\EntityManagerInterface; | |||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | |||
| use Symfony\Bundle\SecurityBundle\Security; | |||
| use Symfony\Component\HttpFoundation\JsonResponse; | |||
| use Symfony\Component\HttpFoundation\Request; | |||
| use Symfony\Component\HttpFoundation\Response; | |||
| @@ -23,6 +25,8 @@ class TimeTrackingController extends AbstractController | |||
| private readonly ProjectRepository $projectRepo, | |||
| private readonly ServiceRepository $serviceRepo, | |||
| private readonly TenantContext $tenantContext, | |||
| private readonly AccountRoleHelper $roleHelper, | |||
| private readonly Security $security, | |||
| ) {} | |||
| // ── Hauptseite ──────────────────────────────────────────────────────────── | |||
| @@ -142,6 +146,12 @@ class TimeTrackingController extends AbstractController | |||
| return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| } | |||
| /** @var User $currentUser */ | |||
| $currentUser = $this->security->getUser(); | |||
| if (!$this->roleHelper->isAdmin() && $entry->getUserId() !== $currentUser->getId()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| } | |||
| $data = json_decode($request->getContent(), true); | |||
| $project = $this->projectRepo->find($data['projectId'] ?? 0); | |||
| @@ -182,6 +192,12 @@ class TimeTrackingController extends AbstractController | |||
| return $this->json(['error' => 'Nicht gefunden'], 404); | |||
| } | |||
| /** @var User $currentUser */ | |||
| $currentUser = $this->security->getUser(); | |||
| if (!$this->roleHelper->isAdmin() && $entry->getUserId() !== $currentUser->getId()) { | |||
| return $this->json(['error' => 'Zugriff verweigert'], 403); | |||
| } | |||
| $date = $entry->getDate(); | |||
| $userId = $entry->getUserId(); | |||
| @@ -96,6 +96,54 @@ class TimeEntryRepository extends ServiceEntityRepository | |||
| return (float) ($result ?? 0.0); | |||
| } | |||
| // ── Report: nach User gefiltert (für Tracker) ───────────────────────────── | |||
| public function findForReportByUserId(int $userId, int $limit = 50): array | |||
| { | |||
| return $this->createQueryBuilder('t') | |||
| ->join('t.project', 'p') | |||
| ->join('p.client', 'c') | |||
| ->leftJoin('t.service', 's') | |||
| ->addSelect('p', 'c', 's') | |||
| ->where('t.userId = :userId') | |||
| ->setParameter('userId', $userId) | |||
| ->orderBy('t.date', 'DESC') | |||
| ->addOrderBy('t.createdAt', 'DESC') | |||
| ->setMaxResults($limit) | |||
| ->getQuery() | |||
| ->getResult(); | |||
| } | |||
| public function sumDurationByUserId(int $userId): int | |||
| { | |||
| $result = $this->createQueryBuilder('t') | |||
| ->select('SUM(t.duration)') | |||
| ->where('t.userId = :userId') | |||
| ->setParameter('userId', $userId) | |||
| ->getQuery() | |||
| ->getSingleScalarResult(); | |||
| return (int) $result; | |||
| } | |||
| public function sumRevenueByUserId(int $userId): float | |||
| { | |||
| $result = $this->createQueryBuilder('t') | |||
| ->select('SUM(c.hourlyRate * t.duration / 60)') | |||
| ->join('t.project', 'p') | |||
| ->join('p.client', 'c') | |||
| ->leftJoin('t.service', 's') | |||
| ->where('t.userId = :userId') | |||
| ->andWhere('c.hourlyRate IS NOT NULL') | |||
| ->andWhere('(s IS NULL OR s.billable = :billable)') | |||
| ->setParameter('userId', $userId) | |||
| ->setParameter('billable', true) | |||
| ->getQuery() | |||
| ->getSingleScalarResult(); | |||
| return (float) ($result ?? 0.0); | |||
| } | |||
| // ── Zähler für abhängige Entitäten ──────────────────────────────────────── | |||
| public function countByProject(Project $project): int | |||
| @@ -12,6 +12,48 @@ | |||
| {% block body %} | |||
| <script> | |||
| window.Report = { | |||
| trackingInterval: {{ trackingInterval }}, | |||
| currentUserId: {{ currentUserId }}, | |||
| isAdmin: {{ isAdmin ? 'true' : 'false' }}, | |||
| projects: [ | |||
| {% for project in projects %} | |||
| { | |||
| id: {{ project.id }}, | |||
| name: {{ project.name|json_encode|raw }}, | |||
| clientName: {{ project.client.name|json_encode|raw }} }{% if not loop.last %},{% endif %} | |||
| {% endfor %} | |||
| ], | |||
| services: [ | |||
| {% for service in services %} | |||
| { | |||
| id: {{ service.id }}, | |||
| name: {{ service.name|json_encode|raw }}, | |||
| billable: {{ service.billable ? 'true' : 'false' }} }{% if not loop.last %},{% endif %} | |||
| {% endfor %} | |||
| ], | |||
| i18n: { | |||
| btnSave: {{ 'app.entry.btn_save'|trans|json_encode|raw }}, | |||
| btnCancel: {{ 'app.entry.btn_cancel'|trans|json_encode|raw }}, | |||
| btnEdit: {{ 'app.entry.btn_edit'|trans|json_encode|raw }}, | |||
| btnDelete: {{ 'app.entry.btn_delete'|trans|json_encode|raw }}, | |||
| confirmDelete: {{ 'app.entry.confirm_delete'|trans|json_encode|raw }}, | |||
| errorSave: {{ 'app.entry.error_save'|trans|json_encode|raw }}, | |||
| errorDelete: {{ 'app.entry.error_delete'|trans|json_encode|raw }}, | |||
| errorNoProject: {{ 'app.entry.error_no_project'|trans|json_encode|raw }}, | |||
| errorZeroDuration: {{ 'app.entry.error_zero_duration'|trans|json_encode|raw }}, | |||
| errorDurationTooLong:{{ 'app.entry.error_duration_too_long'|trans|json_encode|raw }}, | |||
| warnDurationLong: {{ 'app.entry.warn_duration_long'|trans|json_encode|raw }}, | |||
| billable: {{ 'app.service.billable'|trans|json_encode|raw }}, | |||
| notBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }}, | |||
| selectPh: {{ 'app.entry.select_placeholder'|trans|json_encode|raw }}, | |||
| btnLock: {{ 'app.report.btn_lock'|trans|json_encode|raw }}, | |||
| btnUnlock: {{ 'app.report.btn_unlock'|trans|json_encode|raw }}, | |||
| } | |||
| }; | |||
| </script> | |||
| <div class="report-page"> | |||
| <div class="report-header"> | |||
| @@ -79,7 +121,7 @@ | |||
| {{ 'app.report.col_revenue'|trans }} | |||
| <span class="report-table__summary">{{ totalRevenue|number_format(2, ',', '.') }} €</span> | |||
| </div> | |||
| <div class="report-table__cell report-table__cell--lock"></div> | |||
| <div class="report-table__cell report-table__cell--actions"></div> | |||
| </div> | |||
| {# ── Einträge ──────────────────────────────────────────────────── #} | |||
| @@ -87,18 +129,20 @@ | |||
| {% set service = entry.service %} | |||
| {% set billable = (service is null or service.billable) %} | |||
| {% set hourlyRate = entry.project.client.hourlyRate %} | |||
| {% set weekDateStr = entry.date|date('Y-m-d') %} | |||
| {% set editUrl = path('timetracking_week_date', {date: weekDateStr}) ~ '?editEntry=' ~ entry.id %} | |||
| {% set monthShort = monthsShort[entry.date|date('n') - 1] %} | |||
| {% set canEdit = isAdmin or (entry.userId == currentUserId) %} | |||
| <div class="report-table__row{% if entry.invoiced %} report-table__row--invoiced{% endif %}" | |||
| data-entry-id="{{ entry.id }}" | |||
| data-user-id="{{ entry.userId }}" | |||
| data-project-id="{{ entry.project.id }}" | |||
| data-service-id="{{ entry.service ? entry.service.id : '' }}" | |||
| data-duration="{{ entry.duration }}" | |||
| data-note="{{ entry.note|default('')|e('html_attr') }}" | |||
| data-invoiced="{{ entry.invoiced ? 'true' : 'false' }}"> | |||
| <div class="report-table__cell report-table__cell--date"> | |||
| <a href="{{ editUrl }}" class="report-table__date-link"> | |||
| {{ entry.date|date('j') }}. {{ monthShort }} {{ entry.date|date('y') }} | |||
| </a> | |||
| {{ entry.date|date('j') }}. {{ monthShort }} {{ entry.date|date('y') }} | |||
| </div> | |||
| <div class="report-table__cell report-table__cell--client"> | |||
| @@ -131,14 +175,66 @@ | |||
| {% endif %} | |||
| </div> | |||
| <div class="report-table__cell report-table__cell--lock"> | |||
| <button class="report-lock{% if entry.invoiced %} report-lock--invoiced{% endif %}" | |||
| data-action="toggle-invoiced" | |||
| title="{{ entry.invoiced ? 'app.report.btn_unlock'|trans : 'app.report.btn_lock'|trans }}"> | |||
| {% include '_atoms/icon-lock.html.twig' %} | |||
| </button> | |||
| <div class="report-table__cell report-table__cell--actions"> | |||
| {% if canEdit and not entry.invoiced %} | |||
| <button class="report-action-btn report-action-btn--edit" | |||
| data-action="edit" | |||
| title="{{ 'app.entry.btn_edit'|trans }}"> | |||
| {% include '_atoms/icon-edit.html.twig' %} | |||
| </button> | |||
| <button class="report-action-btn report-action-btn--delete" | |||
| data-action="delete" | |||
| title="{{ 'app.entry.btn_delete'|trans }}"> | |||
| {% include '_atoms/icon-delete.html.twig' %} | |||
| </button> | |||
| {% endif %} | |||
| {% if canEdit %} | |||
| <button class="report-lock{% if entry.invoiced %} report-lock--invoiced{% endif %}" | |||
| data-action="toggle-invoiced" | |||
| title="{{ entry.invoiced ? 'app.report.btn_unlock'|trans : 'app.report.btn_lock'|trans }}"> | |||
| {% include '_atoms/icon-lock.html.twig' %} | |||
| </button> | |||
| {% endif %} | |||
| </div> | |||
| {# ── Inline-Edit-Formular ───────────────────────────────── #} | |||
| {% if canEdit and not entry.invoiced %} | |||
| <div class="report-row__edit" hidden> | |||
| <div class="report-row__edit-grid"> | |||
| <label class="report-row__edit-label">{{ 'app.entry.label_duration'|trans }}</label> | |||
| <div class="report-row__edit-field"> | |||
| <input type="text" | |||
| class="input input--sm edit-duration" | |||
| value="{{ entry.durationFormatted }}" | |||
| autocomplete="off" /> | |||
| {% include '_atoms/duration-help.html.twig' %} | |||
| </div> | |||
| <label class="report-row__edit-label">{{ 'app.entry.label_project_service'|trans }}</label> | |||
| <div class="report-row__edit-field report-row__edit-field--selects"> | |||
| <select class="select edit-project"></select> | |||
| <select class="select edit-service"></select> | |||
| </div> | |||
| <label class="report-row__edit-label">{{ 'app.entry.label_note'|trans }}</label> | |||
| <div class="report-row__edit-field"> | |||
| <textarea class="textarea edit-note" rows="2">{{ entry.note|default('') }}</textarea> | |||
| </div> | |||
| <div class="report-row__edit-actions"> | |||
| <button type="button" class="btn btn-primary" data-action="save"> | |||
| {{ 'app.entry.btn_save'|trans }} | |||
| </button> | |||
| <button type="button" class="btn btn-secondary" data-action="cancel"> | |||
| {{ 'app.entry.btn_cancel'|trans }} | |||
| </button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| {% endif %} | |||
| </div> | |||
| {% else %} | |||
| <div class="report-table__empty">{{ 'app.report.no_entries'|trans }}</div> | |||