| @@ -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 | // 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; | const id = row.dataset.entryId; | ||||
| try { | 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) { | } 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 | 1fr // Bemerkung | ||||
| 80px // Stunden | 80px // Stunden | ||||
| 100px // Umsatz | 100px // Umsatz | ||||
| 36px; // Schloss | |||||
| 88px; // Aktionen (Edit + Delete + Schloss) | |||||
| align-items: center; | align-items: center; | ||||
| border-bottom: 1px solid $color-border; | border-bottom: 1px solid $color-border; | ||||
| padding: 0 $space-5; | padding: 0 $space-5; | ||||
| @@ -163,19 +163,27 @@ | |||||
| background: rgba($color-primary, 0.035); | background: rgba($color-primary, 0.035); | ||||
| } | } | ||||
| &:hover .report-action-btn { | |||||
| opacity: 1; | |||||
| } | |||||
| &--invoiced { | &--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--duration { color: $color-text-light; } | ||||
| .report-table__cell--revenue { 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 { | &:last-child { | ||||
| @@ -198,10 +206,11 @@ | |||||
| font-variant-numeric: tabular-nums; | font-variant-numeric: tabular-nums; | ||||
| } | } | ||||
| &--lock { | |||||
| &--actions { | |||||
| display: flex; | display: flex; | ||||
| justify-content: flex-start; | |||||
| align-items: center; | align-items: center; | ||||
| justify-content: flex-end; | |||||
| gap: $space-1; | |||||
| padding-right: 0; | padding-right: 0; | ||||
| } | } | ||||
| @@ -227,16 +236,6 @@ | |||||
| margin-top: 1px; | margin-top: 1px; | ||||
| } | } | ||||
| .report-table__date-link { | |||||
| color: $color-primary; | |||||
| text-decoration: none; | |||||
| white-space: nowrap; | |||||
| &:hover { | |||||
| text-decoration: underline; | |||||
| } | |||||
| } | |||||
| .report-table__empty { | .report-table__empty { | ||||
| padding: $space-10 $space-5; | padding: $space-10 $space-5; | ||||
| text-align: center; | text-align: center; | ||||
| @@ -244,6 +243,37 @@ | |||||
| font-size: $font-size-sm; | 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 ────────────────────────────────────────────────────────── | // ─── Schloss-Button ────────────────────────────────────────────────────────── | ||||
| .report-lock { | .report-lock { | ||||
| display: inline-flex; | 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 ──────────────────────────────────────────────────────── | // ─── 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 { | .report-pagination { | ||||
| display: grid; | display: grid; | ||||
| grid-template-columns: 1fr 80px 100px 36px; | |||||
| grid-template-columns: 1fr 80px 100px 88px; | |||||
| align-items: center; | align-items: center; | ||||
| padding: $space-3 $space-5; | padding: $space-3 $space-5; | ||||
| border-top: 1px solid $color-border; | border-top: 1px solid $color-border; | ||||
| @@ -324,5 +401,5 @@ | |||||
| } | } | ||||
| .report-pagination__lock-spacer { | .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\Central\AccountUserRepository; | ||||
| use App\Repository\Tenant\TimeEntryRepository; | use App\Repository\Tenant\TimeEntryRepository; | ||||
| use App\Repository\Tenant\ProjectRepository; | |||||
| use App\Repository\Tenant\ServiceRepository; | |||||
| use App\Service\TenantContext; | use App\Service\TenantContext; | ||||
| use App\Entity\Central\User; | |||||
| use App\Service\AccountRoleHelper; | |||||
| use Doctrine\ORM\EntityManagerInterface; | use Doctrine\ORM\EntityManagerInterface; | ||||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | ||||
| use Symfony\Component\HttpFoundation\JsonResponse; | use Symfony\Component\HttpFoundation\JsonResponse; | ||||
| use Symfony\Component\HttpFoundation\Request; | use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpFoundation\Response; | use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\Routing\Attribute\Route; | use Symfony\Component\Routing\Attribute\Route; | ||||
| use Symfony\Bundle\SecurityBundle\Security; | |||||
| class ReportController extends AbstractController | class ReportController extends AbstractController | ||||
| { | { | ||||
| @@ -21,6 +26,10 @@ class ReportController extends AbstractController | |||||
| private readonly TimeEntryRepository $timeEntryRepo, | private readonly TimeEntryRepository $timeEntryRepo, | ||||
| private readonly AccountUserRepository $accountUserRepo, | private readonly AccountUserRepository $accountUserRepo, | ||||
| private readonly TenantContext $tenantContext, | 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')] | #[Route('/reports/times', name: 'report_times')] | ||||
| @@ -31,6 +40,12 @@ class ReportController extends AbstractController | |||||
| $limit = 50; | $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 | // User-Map: userId → vollständiger Name | ||||
| $account = $this->tenantContext->getAccount(); | $account = $this->tenantContext->getAccount(); | ||||
| $accountUsers = $this->accountUserRepo->findBy(['account' => $account]); | $accountUsers = $this->accountUserRepo->findBy(['account' => $account]); | ||||
| @@ -39,10 +54,17 @@ class ReportController extends AbstractController | |||||
| $userMap[$au->getUser()->getId()] = $au->getUser()->getFullName(); | $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', [ | return $this->render('report/times.html.twig', [ | ||||
| 'entries' => $entries, | 'entries' => $entries, | ||||
| @@ -53,6 +75,11 @@ class ReportController extends AbstractController | |||||
| 'limit' => $limit, | 'limit' => $limit, | ||||
| 'validLimits' => self::VALID_LIMITS, | 'validLimits' => self::VALID_LIMITS, | ||||
| 'accountName' => $account?->getName() ?? '', | '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); | 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()); | $entry->setInvoiced(!$entry->isInvoiced()); | ||||
| $this->tenantEm->flush(); | $this->tenantEm->flush(); | ||||
| @@ -8,8 +8,10 @@ use App\Repository\Tenant\ProjectRepository; | |||||
| use App\Repository\Tenant\ServiceRepository; | use App\Repository\Tenant\ServiceRepository; | ||||
| use App\Repository\Tenant\TimeEntryRepository; | use App\Repository\Tenant\TimeEntryRepository; | ||||
| use App\Service\TenantContext; | use App\Service\TenantContext; | ||||
| use App\Service\AccountRoleHelper; | |||||
| use Doctrine\ORM\EntityManagerInterface; | use Doctrine\ORM\EntityManagerInterface; | ||||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | ||||
| use Symfony\Bundle\SecurityBundle\Security; | |||||
| use Symfony\Component\HttpFoundation\JsonResponse; | use Symfony\Component\HttpFoundation\JsonResponse; | ||||
| use Symfony\Component\HttpFoundation\Request; | use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpFoundation\Response; | use Symfony\Component\HttpFoundation\Response; | ||||
| @@ -23,6 +25,8 @@ class TimeTrackingController extends AbstractController | |||||
| private readonly ProjectRepository $projectRepo, | private readonly ProjectRepository $projectRepo, | ||||
| private readonly ServiceRepository $serviceRepo, | private readonly ServiceRepository $serviceRepo, | ||||
| private readonly TenantContext $tenantContext, | private readonly TenantContext $tenantContext, | ||||
| private readonly AccountRoleHelper $roleHelper, | |||||
| private readonly Security $security, | |||||
| ) {} | ) {} | ||||
| // ── Hauptseite ──────────────────────────────────────────────────────────── | // ── Hauptseite ──────────────────────────────────────────────────────────── | ||||
| @@ -142,6 +146,12 @@ class TimeTrackingController extends AbstractController | |||||
| return $this->json(['error' => 'Nicht gefunden'], 404); | 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); | $data = json_decode($request->getContent(), true); | ||||
| $project = $this->projectRepo->find($data['projectId'] ?? 0); | $project = $this->projectRepo->find($data['projectId'] ?? 0); | ||||
| @@ -182,6 +192,12 @@ class TimeTrackingController extends AbstractController | |||||
| return $this->json(['error' => 'Nicht gefunden'], 404); | 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(); | $date = $entry->getDate(); | ||||
| $userId = $entry->getUserId(); | $userId = $entry->getUserId(); | ||||
| @@ -96,6 +96,54 @@ class TimeEntryRepository extends ServiceEntityRepository | |||||
| return (float) ($result ?? 0.0); | 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 ──────────────────────────────────────── | // ── Zähler für abhängige Entitäten ──────────────────────────────────────── | ||||
| public function countByProject(Project $project): int | public function countByProject(Project $project): int | ||||
| @@ -12,6 +12,48 @@ | |||||
| {% block body %} | {% 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-page"> | ||||
| <div class="report-header"> | <div class="report-header"> | ||||
| @@ -79,7 +121,7 @@ | |||||
| {{ 'app.report.col_revenue'|trans }} | {{ 'app.report.col_revenue'|trans }} | ||||
| <span class="report-table__summary">{{ totalRevenue|number_format(2, ',', '.') }} €</span> | <span class="report-table__summary">{{ totalRevenue|number_format(2, ',', '.') }} €</span> | ||||
| </div> | </div> | ||||
| <div class="report-table__cell report-table__cell--lock"></div> | |||||
| <div class="report-table__cell report-table__cell--actions"></div> | |||||
| </div> | </div> | ||||
| {# ── Einträge ──────────────────────────────────────────────────── #} | {# ── Einträge ──────────────────────────────────────────────────── #} | ||||
| @@ -87,18 +129,20 @@ | |||||
| {% set service = entry.service %} | {% set service = entry.service %} | ||||
| {% set billable = (service is null or service.billable) %} | {% set billable = (service is null or service.billable) %} | ||||
| {% set hourlyRate = entry.project.client.hourlyRate %} | {% 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 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 %}" | <div class="report-table__row{% if entry.invoiced %} report-table__row--invoiced{% endif %}" | ||||
| data-entry-id="{{ entry.id }}" | 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' }}"> | data-invoiced="{{ entry.invoiced ? 'true' : 'false' }}"> | ||||
| <div class="report-table__cell report-table__cell--date"> | <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> | ||||
| <div class="report-table__cell report-table__cell--client"> | <div class="report-table__cell report-table__cell--client"> | ||||
| @@ -131,14 +175,66 @@ | |||||
| {% endif %} | {% endif %} | ||||
| </div> | </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> | </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> | </div> | ||||
| {% else %} | {% else %} | ||||
| <div class="report-table__empty">{{ 'app.report.no_entries'|trans }}</div> | <div class="report-table__empty">{{ 'app.report.no_entries'|trans }}</div> | ||||