Procházet zdrojové kódy

edit reports

master
FlorianEisenmenger před 1 týdnem
rodič
revize
698d6d3801
7 změnil soubory, kde provedl 697 přidání a 61 odebrání
  1. +199
    -0
      httpdocs/PROJEKT_KONTEXT.md
  2. +185
    -18
      httpdocs/assets/scripts/report.js
  3. +104
    -27
      httpdocs/assets/styles/sections/_report.scss
  4. +37
    -4
      httpdocs/src/Controller/ReportController.php
  5. +16
    -0
      httpdocs/src/Controller/TimeTrackingController.php
  6. +48
    -0
      httpdocs/src/Repository/Tenant/TimeEntryRepository.php
  7. +108
    -12
      httpdocs/templates/report/times.html.twig

+ 199
- 0
httpdocs/PROJEKT_KONTEXT.md Zobrazit soubor

@@ -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"`

+ 185
- 18
httpdocs/assets/scripts/report.js Zobrazit soubor

@@ -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;
}
});
});

+ 104
- 27
httpdocs/assets/styles/sections/_report.scss Zobrazit soubor

@@ -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
}

+ 37
- 4
httpdocs/src/Controller/ReportController.php Zobrazit soubor

@@ -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();



+ 16
- 0
httpdocs/src/Controller/TimeTrackingController.php Zobrazit soubor

@@ -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();



+ 48
- 0
httpdocs/src/Repository/Tenant/TimeEntryRepository.php Zobrazit soubor

@@ -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


+ 108
- 12
httpdocs/templates/report/times.html.twig Zobrazit soubor

@@ -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>


Načítá se…
Zrušit
Uložit