From 6e2f94f59b0b40326af3e3b49cc4ed077b6fbda9 Mon Sep 17 00:00:00 2001 From: FlorianEisenmenger Date: Fri, 19 Jun 2026 00:03:38 +0200 Subject: [PATCH] more statistics --- CLAUDE.md | 58 +++++++-- httpdocs/assets/scripts/statistics.js | 122 +++++++++++++++++- .../assets/styles/sections/_statistics.scss | 73 +++++++++++ httpdocs/src/Controller/ReportController.php | 1 + .../Repository/Tenant/TimeEntryRepository.php | 61 +++++++++ .../templates/report/statistics.html.twig | 30 +++++ httpdocs/translations/messages.de.yaml | 5 + 7 files changed, 339 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8cf2541..08775c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ Multi-Tenant-Architektur: jeder Account bekommt eine Subdomain und eigene Tenant ## Tech Stack - **Backend**: Symfony 7.4, PHP 8.2+ (DDEV nutzt 8.4), Doctrine ORM, MariaDB 10.11 -- **Frontend**: Twig, SCSS (Webpack Encore), Vanilla JS (kein Framework, kein jQuery) +- **Frontend**: Twig, SCSS (Webpack Encore), Vanilla JS (kein Framework, kein jQuery), Chart.js (Statistiken) - **Export**: PhpSpreadsheet (Excel), Dompdf (PDF), natives PHP (CSV) - **Dev-Umgebung**: DDEV, Projektname `timetracking`, HTTPS-Port 8459 - **Keine** Symfony Forms – eigene HTML-Formulare mit `fetch()`-API @@ -42,7 +42,7 @@ httpdocs/ ├── assets/ │ ├── app.js # Webpack-Entry für Timetracking │ ├── styles/ # SCSS (main.scss als Entry) -│ └── scripts/ # JS-Module (calendar, entries, crud, stopwatch, searchable-select, team, account, report) +│ └── scripts/ # JS-Module (calendar, entries, crud, stopwatch, searchable-select, team, account, report, statistics) ├── migrations/ │ ├── central/ # Doctrine-Migrations für Central-DB │ └── tenant/ # Doctrine-Migrations für Tenant-DB @@ -94,6 +94,7 @@ bash httpdocs/deploy.sh | `team` | `assets/scripts/team.js` | Team-Verwaltung | | `account` | `assets/scripts/account.js` | Account-Einstellungen | | `report` | `assets/scripts/report.js` | Report-Seite | +| `statistics` | `assets/scripts/statistics.js` | Statistiken-Seite | ## Konventionen @@ -267,22 +268,26 @@ Optionale Verknüpfung von Kunden mit Lexware Office Kontakten. Nur aktiv wenn ` | `/api/lexoffice/contacts` | GET | Alle Kunden-Kontakte aus Lexware Office | | `/api/lexoffice/contacts/{contactId}` | GET | Einzelnen Kontakt abrufen | | `/api/lexoffice/invoices` | POST | Rechnungsentwurf in Lexware Office anlegen| +| `/api/lexoffice/invoice-preview` | GET | Gruppierte Rechnungspositionen-Vorschau | | `/api/clients/{id}/lexoffice-refresh` | PATCH | Kundenname aus Lexware aktualisieren | +| `/api/entries/mark-invoiced` | POST | Gefilterte Einträge als abgerechnet markieren | ### Rechnungsentwurf aus Report Auf der Report-Seite erscheint ein Invoice-Icon (vor den Export-Buttons, durch Separator getrennt), wenn: -1. Die gefilterten Einträge genau **einen** Kunden enthalten +1. Die gefilterten **nicht-abgerechneten** Einträge genau **einen** Kunden enthalten 2. Dieser Kunde eine `lexofficeContactId` hat 3. Der User kein Tracker ist und ein API-Key hinterlegt ist -Beim Klick wird via `POST /api/lexoffice/invoices` ein Rechnungsentwurf erstellt: -- Kunde vorausgewählt (`address.contactId`) -- Leistungszeitraum (`shippingType: serviceperiod`) mit Start-/Enddatum aus `MIN(t.date)` / `MAX(t.date)` der gefilterten Einträge -- Platzhalter-Lineitem (wird in Lexware Office befüllt) -- Nach Erfolg: Confirm-Dialog mit Option, den Entwurf direkt in Lexware Office zu öffnen (`https://app.lexware.de/permalink/invoices/edit/{id}`) +Beim Klick öffnet sich ein **Invoice-Modal** mit Rechnungsvorschau: +- **Gruppierung**: Positionen gruppierbar nach Leistung (`service`), Projekt (`project`) oder Label (`label`) — Auswahl via Radio-Buttons +- **Vorschau-Tabelle**: Zeigt Bezeichnung, Menge (Stunden), Einheit, VK (Netto) und Gesamtbetrag pro Position. Positionen mit gleichem Rate werden zusammengefasst, Sub-Items als Beschreibung +- **Datenquelle**: `GET /api/lexoffice/invoice-preview?groupBy={service|project|label}&{filter-params}` — nutzt `TimeEntryRepository::getGroupedForInvoice()`, nur nicht-abgerechnete Einträge +- **Checkbox „Als abgerechnet markieren"**: Standardmäßig aktiviert, markiert nach Rechnungserstellung alle gefilterten Einträge via `POST /api/entries/mark-invoiced` +- **Rechnungserstellung**: `POST /api/lexoffice/invoices` mit echten Line-Items (Name, Stunden, Stundensatz, optional Beschreibung). `LexofficeService::createInvoiceDraft()` unterstützt optionalen `$lineItems`-Parameter +- **Nach Erfolg**: Confirm-Dialog mit Option, den Entwurf direkt in Lexware Office zu öffnen (`https://app.lexware.de/permalink/invoices/edit/{id}`). Bei aktivierter Checkbox wird die Seite neu geladen -Serverseitige Logik: `ReportController` ermittelt via `TimeEntryRepository::findDistinctClientIdsFiltered()` und `findDateRangeFiltered()` die Daten. `LexofficeService::createInvoiceDraft()` ruft `POST /v1/invoices` auf. +Serverseitige Logik: `ReportController` ermittelt via `TimeEntryRepository::findDistinctClientIdsFiltered()` und `findDateRangeFiltered()` die Daten (mit `invoiced => false` Filter). `LexofficeService::createInvoiceDraft()` ruft `POST /v1/invoices` auf. ### Verhalten (Kontaktverknüpfung) @@ -291,6 +296,41 @@ Serverseitige Logik: `ReportController` ermittelt via `TimeEntryRepository::find - Reload-Button pro Zeile zum Aktualisieren des Namens aus Lexware - Kontakte werden clientseitig gecacht (einmal geladen pro Page-Session) +## Statistiken + +Arbeitszeit-Statistik als gestapeltes Balkendiagramm (Chart.js), erreichbar über den Statistik-Button im Report-Header. Tab-Navigation zwischen „Zeiten" und „Statistiken". + +### Architektur + +- **Backend**: `ReportController::statistics()` (Seite) + `ReportController::statisticsData()` (API) +- **Frontend**: `assets/scripts/statistics.js` (eigener Webpack-Entry), nutzt Chart.js (tree-shaked: nur `BarController`, `BarElement`, `CategoryScale`, `LinearScale`, `Tooltip`) +- **Styles**: `assets/styles/sections/_statistics.scss` +- **Template**: `templates/report/statistics.html.twig` + +### API + +| Route | Method | Beschreibung | +|--------------------|--------|-------------------------------------------------------| +| `/api/statistics` | GET | Aggregierte Zeitdaten: `?range={12months|6months|4weeks}&userId={id}` | + +### Daten-Aggregation + +`TimeEntryRepository::getStatisticsData()` aggregiert Zeiteinträge in Buckets: +- **12months**: Monats-Buckets (`Y-m`), letztes Jahr +- **6months**: Wochen-Buckets (`o-W`), letztes halbes Jahr +- **4weeks**: Tages-Buckets (`Y-m-d`), letzte 4 Wochen + +Rückgabe: `labels`, `billable`, `nonBillable`, `billableRevenue`, `nonBillableRevenue`, `groupBy`. Billable/Non-Billable Trennung via `Service.billable`. Revenue-Berechnung nutzt die Stundensatz-Kaskade. + +### Frontend-Features + +- **Metrik-Umschalter**: Stunden oder Umsatz (clientseitig, kein neuer API-Call) +- **User-Filter**: Admins/Members können nach einzelnem User filtern oder alle sehen (Account-Name als „Alle") +- **Tracker**: sieht nur eigene Daten (serverseitig erzwungen) +- **Tages-Ansicht**: Alternierende Wochen-Bänder als visuelle Hilfe (Custom Chart.js Plugin `weekBands`) +- **Tooltip**: Zeigt formatierte Stunden (`h:mm`) oder Umsatz (`€`) je nach Metrik +- **Brand-Farbe**: Billable-Balken nutzen `--color-primary`, Non-Billable grau + ## TenantConnectionMiddleware Registriert via Service-Tag in `services.yaml` (nicht via `doctrine.yaml` — DoctrineBundle 3.x unterstützt `middlewares`-Config-Key nicht): diff --git a/httpdocs/assets/scripts/statistics.js b/httpdocs/assets/scripts/statistics.js index 0589650..f948513 100644 --- a/httpdocs/assets/scripts/statistics.js +++ b/httpdocs/assets/scripts/statistics.js @@ -6,16 +6,26 @@ import { CategoryScale, LinearScale, Tooltip, + DoughnutController, + ArcElement, } from 'chart.js'; -import { createTranslator } from './utils.js'; +import { createTranslator, esc } from './utils.js'; -Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip); +Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, DoughnutController, ArcElement); const t = createTranslator('Statistics'); const months = window.Statistics.monthsShort; const weekdays = window.Statistics.weekdaysShort; +const DONUT_COLORS = [ + '#4a90d9', '#f0a500', '#2d9e60', '#c83232', + '#8b5cf6', '#e85d75', '#00b4d8', '#ff8c42', +]; +const REST_COLOR = '#d0d8e0'; +const THRESHOLD = 0.05; + let chart = null; +let donutCharts = { clients: null, projects: null, services: null }; let cachedData = null; function formatLabel(key, groupBy) { @@ -188,6 +198,112 @@ function renderChart(data, metric) { }); } +function applyThreshold(items, metric) { + const valueKey = metric === 'revenue' ? 'revenue' : 'hours'; + const total = items.reduce((sum, item) => sum + item[valueKey], 0); + if (total === 0) return { labels: [], values: [], colors: [] }; + + const visible = []; + let restValue = 0; + let colorIdx = 0; + + for (const item of items) { + const val = item[valueKey]; + if (val / total >= THRESHOLD) { + visible.push({ name: item.name || t('noService'), value: val, color: DONUT_COLORS[colorIdx % DONUT_COLORS.length] }); + colorIdx++; + } else { + restValue += val; + } + } + + if (restValue > 0) { + visible.push({ name: t('rest'), value: restValue, color: REST_COLOR }); + } + + return { + labels: visible.map(v => v.name), + values: visible.map(v => v.value), + colors: visible.map(v => v.color), + }; +} + +function renderDonut(canvasId, legendId, chartKey, items, metric) { + const canvas = document.getElementById(canvasId); + const legendEl = document.getElementById(legendId); + if (!canvas) return; + + if (donutCharts[chartKey]) { + donutCharts[chartKey].destroy(); + donutCharts[chartKey] = null; + } + + const { labels, values, colors } = applyThreshold(items, metric); + const isRevenue = metric === 'revenue'; + const total = values.reduce((s, v) => s + v, 0); + + if (total === 0) { + if (legendEl) legendEl.innerHTML = ''; + return; + } + + donutCharts[chartKey] = new Chart(canvas, { + type: 'doughnut', + data: { + labels, + datasets: [{ + data: values, + backgroundColor: colors, + borderWidth: 2, + borderColor: '#ffffff', + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + cutout: '62%', + animation: { duration: 600, easing: 'easeOutQuart' }, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: '#1a2a3a', + bodyFont: { size: 13 }, + padding: 10, + cornerRadius: 6, + callbacks: { + label(ctx) { + const val = ctx.parsed; + const pct = ((val / total) * 100).toFixed(1) + '%'; + const formatted = isRevenue ? formatCurrency(val) : formatHours(val); + return ctx.label + ': ' + formatted + ' (' + pct + ')'; + }, + }, + }, + }, + }, + }); + + if (legendEl) { + legendEl.innerHTML = labels.map((label, i) => { + const pct = ((values[i] / total) * 100).toFixed(1); + const formatted = isRevenue ? formatCurrency(values[i]) : formatHours(values[i]); + return `
+ + ${esc(label)} + ${formatted} (${pct}%) +
`; + }).join(''); + } +} + +function renderDonuts(data, metric) { + if (!data?.distribution) return; + const d = data.distribution; + renderDonut('donut-clients', 'donut-legend-clients', 'clients', d.clients, metric); + renderDonut('donut-projects', 'donut-legend-projects', 'projects', d.projects, metric); + renderDonut('donut-services', 'donut-legend-services', 'services', d.services, metric); +} + async function loadAndRender(range, metric, userId) { const wrap = document.getElementById('stats-chart-wrap'); if (!wrap) return; @@ -202,6 +318,7 @@ async function loadAndRender(range, metric, userId) { if (!res.ok) throw new Error(res.statusText); cachedData = await res.json(); renderChart(cachedData, metric); + renderDonuts(cachedData, metric); } catch { wrap.innerHTML = '

' + t('errorLoad') + '

'; } @@ -233,6 +350,7 @@ document.addEventListener('DOMContentLoaded', () => { metricSelect.addEventListener('change', () => { if (cachedData) { renderChart(cachedData, metricSelect.value); + renderDonuts(cachedData, metricSelect.value); } }); }); diff --git a/httpdocs/assets/styles/sections/_statistics.scss b/httpdocs/assets/styles/sections/_statistics.scss index 84dd8a2..cc95a4c 100644 --- a/httpdocs/assets/styles/sections/_statistics.scss +++ b/httpdocs/assets/styles/sections/_statistics.scss @@ -140,3 +140,76 @@ background: $color-text-light; } } + +// ─── Donuts ──────────────────────────────────────────────────────────────── +.statistics-donuts { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: $space-5; + padding: $space-5; + + @include tablet { + grid-template-columns: 1fr; + gap: $space-4; + } +} + +.statistics-donut { + @include card(#ffffff, $radius-md); + padding: $space-4; + display: flex; + flex-direction: column; + align-items: center; +} + +.statistics-donut__title { + font-size: $font-size-sm; + font-weight: $font-weight-bold; + color: $color-text-dark; + margin-bottom: $space-3; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.statistics-donut__wrap { + position: relative; + width: 100%; + max-width: 200px; + aspect-ratio: 1; +} + +.statistics-donut__legend { + width: 100%; + margin-top: $space-4; + display: flex; + flex-direction: column; + gap: $space-1; +} + +.statistics-donut__legend-item { + display: flex; + align-items: center; + gap: $space-2; + font-size: $font-size-xs; + color: $color-text-base; + line-height: 1.4; +} + +.statistics-donut__legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.statistics-donut__legend-label { + @include text-truncate; + flex: 1; + min-width: 0; +} + +.statistics-donut__legend-value { + flex-shrink: 0; + color: $color-text-muted; + white-space: nowrap; +} diff --git a/httpdocs/src/Controller/ReportController.php b/httpdocs/src/Controller/ReportController.php index 1164f15..3859482 100644 --- a/httpdocs/src/Controller/ReportController.php +++ b/httpdocs/src/Controller/ReportController.php @@ -206,6 +206,7 @@ class ReportController extends AbstractController } $data = $this->timeEntryRepo->getStatisticsData($range, $userId); + $data['distribution'] = $this->timeEntryRepo->getDistributionData($range, $userId); return $this->json($data); } diff --git a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php index 1569eeb..96d757b 100644 --- a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php +++ b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php @@ -434,6 +434,67 @@ class TimeEntryRepository extends ServiceEntityRepository return $keys; } + public function getDistributionData(string $range, ?int $userId = null): array + { + $now = new \DateTimeImmutable('today'); + + $dateFrom = match ($range) { + '6months' => $now->modify('-6 months +1 day'), + '4weeks' => $now->modify('-27 days'), + default => $now->modify('-12 months +1 day'), + }; + + $buildBase = function () use ($dateFrom, $now, $userId) { + $qb = $this->createQueryBuilder('t') + ->join('t.project', 'p') + ->join('p.client', 'c') + ->leftJoin('t.service', 's') + ->where('t.date >= :dateFrom') + ->andWhere('t.date <= :dateTo') + ->setParameter('dateFrom', $dateFrom->format('Y-m-d')) + ->setParameter('dateTo', $now->format('Y-m-d')); + + if ($userId !== null) { + $qb->andWhere('t.userId = :userId') + ->setParameter('userId', $userId); + } + + return $qb; + }; + + $rateExpr = 'COALESCE(p.hourlyRate, c.hourlyRate, s.hourlyRate)'; + + $clients = $buildBase() + ->select("c.name AS name, SUM(t.duration) AS totalMinutes, SUM($rateExpr * t.duration / 60) AS totalRevenue") + ->groupBy('c.id, c.name') + ->orderBy('totalMinutes', 'DESC') + ->getQuery()->getScalarResult(); + + $projects = $buildBase() + ->select("p.name AS name, SUM(t.duration) AS totalMinutes, SUM($rateExpr * t.duration / 60) AS totalRevenue") + ->groupBy('p.id, p.name') + ->orderBy('totalMinutes', 'DESC') + ->getQuery()->getScalarResult(); + + $services = $buildBase() + ->select("s.name AS name, SUM(t.duration) AS totalMinutes, SUM($rateExpr * t.duration / 60) AS totalRevenue") + ->groupBy('s.id, s.name') + ->orderBy('totalMinutes', 'DESC') + ->getQuery()->getScalarResult(); + + $mapRow = static fn(array $r): array => [ + 'name' => $r['name'], + 'hours' => round((int) $r['totalMinutes'] / 60, 2), + 'revenue' => round((float) ($r['totalRevenue'] ?? 0), 2), + ]; + + return [ + 'clients' => array_map($mapRow, $clients), + 'projects' => array_map($mapRow, $projects), + 'services' => array_map($mapRow, $services), + ]; + } + public function sumRevenueFiltered(array $filters): float { $result = $this->buildFilteredQuery($filters) diff --git a/httpdocs/templates/report/statistics.html.twig b/httpdocs/templates/report/statistics.html.twig index 0e1c0b2..22adea1 100644 --- a/httpdocs/templates/report/statistics.html.twig +++ b/httpdocs/templates/report/statistics.html.twig @@ -22,6 +22,11 @@ loading: {{ 'app.statistics.loading'|trans|json_encode|raw }}, errorLoad: {{ 'app.statistics.error_load'|trans|json_encode|raw }}, weekShort: {{ 'app.date.week_short'|trans|json_encode|raw }}, + clients: {{ 'app.statistics.clients'|trans|json_encode|raw }}, + projects: {{ 'app.statistics.projects'|trans|json_encode|raw }}, + services: {{ 'app.statistics.services'|trans|json_encode|raw }}, + rest: {{ 'app.statistics.rest'|trans|json_encode|raw }}, + noService: {{ 'app.statistics.no_service'|trans|json_encode|raw }}, } }; @@ -93,6 +98,31 @@ + +
+
+

{{ 'app.statistics.clients'|trans }}

+
+ +
+
+
+
+

{{ 'app.statistics.projects'|trans }}

+
+ +
+
+
+
+

{{ 'app.statistics.services'|trans }}

+
+ +
+
+
+
+ diff --git a/httpdocs/translations/messages.de.yaml b/httpdocs/translations/messages.de.yaml index 8ed531c..baff9fc 100644 --- a/httpdocs/translations/messages.de.yaml +++ b/httpdocs/translations/messages.de.yaml @@ -464,6 +464,11 @@ app: revenue: "Umsatz" loading: "Daten werden geladen…" error_load: "Fehler beim Laden der Statistikdaten." + clients: "Kunden" + projects: "Projekte" + services: "Leistungen" + rest: "Rest" + no_service: "Ohne Leistung" stopwatch: title: "Stoppuhr"