| @@ -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): | |||
| @@ -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 `<div class="statistics-donut__legend-item"> | |||
| <span class="statistics-donut__legend-dot" style="background:${colors[i]}"></span> | |||
| <span class="statistics-donut__legend-label">${esc(label)}</span> | |||
| <span class="statistics-donut__legend-value">${formatted} (${pct}%)</span> | |||
| </div>`; | |||
| }).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 = '<p class="statistics-error">' + t('errorLoad') + '</p>'; | |||
| } | |||
| @@ -233,6 +350,7 @@ document.addEventListener('DOMContentLoaded', () => { | |||
| metricSelect.addEventListener('change', () => { | |||
| if (cachedData) { | |||
| renderChart(cachedData, metricSelect.value); | |||
| renderDonuts(cachedData, metricSelect.value); | |||
| } | |||
| }); | |||
| }); | |||
| @@ -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; | |||
| } | |||
| @@ -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); | |||
| } | |||
| @@ -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) | |||
| @@ -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 }}, | |||
| } | |||
| }; | |||
| </script> | |||
| @@ -93,6 +98,31 @@ | |||
| </div> | |||
| </div> | |||
| <div class="statistics-donuts" id="stats-donuts"> | |||
| <div class="statistics-donut"> | |||
| <h3 class="statistics-donut__title">{{ 'app.statistics.clients'|trans }}</h3> | |||
| <div class="statistics-donut__wrap"> | |||
| <canvas id="donut-clients"></canvas> | |||
| </div> | |||
| <div class="statistics-donut__legend" id="donut-legend-clients"></div> | |||
| </div> | |||
| <div class="statistics-donut"> | |||
| <h3 class="statistics-donut__title">{{ 'app.statistics.projects'|trans }}</h3> | |||
| <div class="statistics-donut__wrap"> | |||
| <canvas id="donut-projects"></canvas> | |||
| </div> | |||
| <div class="statistics-donut__legend" id="donut-legend-projects"></div> | |||
| </div> | |||
| <div class="statistics-donut"> | |||
| <h3 class="statistics-donut__title">{{ 'app.statistics.services'|trans }}</h3> | |||
| <div class="statistics-donut__wrap"> | |||
| <canvas id="donut-services"></canvas> | |||
| </div> | |||
| <div class="statistics-donut__legend" id="donut-legend-services"></div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| @@ -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" | |||