diff --git a/httpdocs/PROJEKT_KONTEXT.md b/httpdocs/PROJEKT_KONTEXT.md new file mode 100644 index 0000000..591f9dd --- /dev/null +++ b/httpdocs/PROJEKT_KONTEXT.md @@ -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"` diff --git a/httpdocs/assets/scripts/report.js b/httpdocs/assets/scripts/report.js index 031c7b4..e575f2d 100644 --- a/httpdocs/assets/scripts/report.js +++ b/httpdocs/assets/scripts/report.js @@ -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 = ``; + + 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; + } + }); }); diff --git a/httpdocs/assets/styles/sections/_report.scss b/httpdocs/assets/styles/sections/_report.scss index 5910cce..f17a6fa 100644 --- a/httpdocs/assets/styles/sections/_report.scss +++ b/httpdocs/assets/styles/sections/_report.scss @@ -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 } diff --git a/httpdocs/src/Controller/ReportController.php b/httpdocs/src/Controller/ReportController.php index 9c2ba4b..e40e861 100644 --- a/httpdocs/src/Controller/ReportController.php +++ b/httpdocs/src/Controller/ReportController.php @@ -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(); diff --git a/httpdocs/src/Controller/TimeTrackingController.php b/httpdocs/src/Controller/TimeTrackingController.php index 565ba3b..002ab52 100644 --- a/httpdocs/src/Controller/TimeTrackingController.php +++ b/httpdocs/src/Controller/TimeTrackingController.php @@ -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(); diff --git a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php index 24271dd..046b340 100644 --- a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php +++ b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php @@ -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 diff --git a/httpdocs/templates/report/times.html.twig b/httpdocs/templates/report/times.html.twig index d42f500..c19f64a 100644 --- a/httpdocs/templates/report/times.html.twig +++ b/httpdocs/templates/report/times.html.twig @@ -12,6 +12,48 @@ {% block body %} + +
@@ -79,7 +121,7 @@ {{ 'app.report.col_revenue'|trans }} {{ totalRevenue|number_format(2, ',', '.') }} €
-
+
{# ── 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) %}
- - {{ entry.date|date('j') }}. {{ monthShort }} {{ entry.date|date('y') }} - + {{ entry.date|date('j') }}. {{ monthShort }} {{ entry.date|date('y') }}
@@ -131,14 +175,66 @@ {% endif %}
-
- +
+ {% if canEdit and not entry.invoiced %} + + + {% endif %} + {% if canEdit %} + + {% endif %}
+ {# ── Inline-Edit-Formular ───────────────────────────────── #} + {% if canEdit and not entry.invoiced %} + + {% endif %} +
{% else %}
{{ 'app.report.no_entries'|trans }}