| @@ -8,7 +8,7 @@ Multi-Tenant-Architektur: jeder Account bekommt eine Subdomain und eigene Tenant | |||||
| ## Tech Stack | ## Tech Stack | ||||
| - **Backend**: Symfony 7.4, PHP 8.2+ (DDEV nutzt 8.4), Doctrine ORM, MariaDB 10.11 | - **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) | - **Export**: PhpSpreadsheet (Excel), Dompdf (PDF), natives PHP (CSV) | ||||
| - **Dev-Umgebung**: DDEV, Projektname `timetracking`, HTTPS-Port 8459 | - **Dev-Umgebung**: DDEV, Projektname `timetracking`, HTTPS-Port 8459 | ||||
| - **Keine** Symfony Forms – eigene HTML-Formulare mit `fetch()`-API | - **Keine** Symfony Forms – eigene HTML-Formulare mit `fetch()`-API | ||||
| @@ -42,7 +42,7 @@ httpdocs/ | |||||
| ├── assets/ | ├── assets/ | ||||
| │ ├── app.js # Webpack-Entry für Timetracking | │ ├── app.js # Webpack-Entry für Timetracking | ||||
| │ ├── styles/ # SCSS (main.scss als Entry) | │ ├── 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/ | ├── migrations/ | ||||
| │ ├── central/ # Doctrine-Migrations für Central-DB | │ ├── central/ # Doctrine-Migrations für Central-DB | ||||
| │ └── tenant/ # Doctrine-Migrations für Tenant-DB | │ └── tenant/ # Doctrine-Migrations für Tenant-DB | ||||
| @@ -94,6 +94,7 @@ bash httpdocs/deploy.sh | |||||
| | `team` | `assets/scripts/team.js` | Team-Verwaltung | | | `team` | `assets/scripts/team.js` | Team-Verwaltung | | ||||
| | `account` | `assets/scripts/account.js` | Account-Einstellungen | | | `account` | `assets/scripts/account.js` | Account-Einstellungen | | ||||
| | `report` | `assets/scripts/report.js` | Report-Seite | | | `report` | `assets/scripts/report.js` | Report-Seite | | ||||
| | `statistics` | `assets/scripts/statistics.js` | Statistiken-Seite | | |||||
| ## Konventionen | ## 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` | GET | Alle Kunden-Kontakte aus Lexware Office | | ||||
| | `/api/lexoffice/contacts/{contactId}` | GET | Einzelnen Kontakt abrufen | | | `/api/lexoffice/contacts/{contactId}` | GET | Einzelnen Kontakt abrufen | | ||||
| | `/api/lexoffice/invoices` | POST | Rechnungsentwurf in Lexware Office anlegen| | | `/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/clients/{id}/lexoffice-refresh` | PATCH | Kundenname aus Lexware aktualisieren | | ||||
| | `/api/entries/mark-invoiced` | POST | Gefilterte Einträge als abgerechnet markieren | | |||||
| ### Rechnungsentwurf aus Report | ### Rechnungsentwurf aus Report | ||||
| Auf der Report-Seite erscheint ein Invoice-Icon (vor den Export-Buttons, durch Separator getrennt), wenn: | 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 | 2. Dieser Kunde eine `lexofficeContactId` hat | ||||
| 3. Der User kein Tracker ist und ein API-Key hinterlegt ist | 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) | ### Verhalten (Kontaktverknüpfung) | ||||
| @@ -291,6 +296,41 @@ Serverseitige Logik: `ReportController` ermittelt via `TimeEntryRepository::find | |||||
| - Reload-Button pro Zeile zum Aktualisieren des Namens aus Lexware | - Reload-Button pro Zeile zum Aktualisieren des Namens aus Lexware | ||||
| - Kontakte werden clientseitig gecacht (einmal geladen pro Page-Session) | - 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 | ## TenantConnectionMiddleware | ||||
| Registriert via Service-Tag in `services.yaml` (nicht via `doctrine.yaml` — DoctrineBundle 3.x unterstützt `middlewares`-Config-Key nicht): | 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, | CategoryScale, | ||||
| LinearScale, | LinearScale, | ||||
| Tooltip, | Tooltip, | ||||
| DoughnutController, | |||||
| ArcElement, | |||||
| } from 'chart.js'; | } 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 t = createTranslator('Statistics'); | ||||
| const months = window.Statistics.monthsShort; | const months = window.Statistics.monthsShort; | ||||
| const weekdays = window.Statistics.weekdaysShort; | 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 chart = null; | ||||
| let donutCharts = { clients: null, projects: null, services: null }; | |||||
| let cachedData = null; | let cachedData = null; | ||||
| function formatLabel(key, groupBy) { | 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) { | async function loadAndRender(range, metric, userId) { | ||||
| const wrap = document.getElementById('stats-chart-wrap'); | const wrap = document.getElementById('stats-chart-wrap'); | ||||
| if (!wrap) return; | if (!wrap) return; | ||||
| @@ -202,6 +318,7 @@ async function loadAndRender(range, metric, userId) { | |||||
| if (!res.ok) throw new Error(res.statusText); | if (!res.ok) throw new Error(res.statusText); | ||||
| cachedData = await res.json(); | cachedData = await res.json(); | ||||
| renderChart(cachedData, metric); | renderChart(cachedData, metric); | ||||
| renderDonuts(cachedData, metric); | |||||
| } catch { | } catch { | ||||
| wrap.innerHTML = '<p class="statistics-error">' + t('errorLoad') + '</p>'; | wrap.innerHTML = '<p class="statistics-error">' + t('errorLoad') + '</p>'; | ||||
| } | } | ||||
| @@ -233,6 +350,7 @@ document.addEventListener('DOMContentLoaded', () => { | |||||
| metricSelect.addEventListener('change', () => { | metricSelect.addEventListener('change', () => { | ||||
| if (cachedData) { | if (cachedData) { | ||||
| renderChart(cachedData, metricSelect.value); | renderChart(cachedData, metricSelect.value); | ||||
| renderDonuts(cachedData, metricSelect.value); | |||||
| } | } | ||||
| }); | }); | ||||
| }); | }); | ||||
| @@ -140,3 +140,76 @@ | |||||
| background: $color-text-light; | 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 = $this->timeEntryRepo->getStatisticsData($range, $userId); | ||||
| $data['distribution'] = $this->timeEntryRepo->getDistributionData($range, $userId); | |||||
| return $this->json($data); | return $this->json($data); | ||||
| } | } | ||||
| @@ -434,6 +434,67 @@ class TimeEntryRepository extends ServiceEntityRepository | |||||
| return $keys; | 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 | public function sumRevenueFiltered(array $filters): float | ||||
| { | { | ||||
| $result = $this->buildFilteredQuery($filters) | $result = $this->buildFilteredQuery($filters) | ||||
| @@ -22,6 +22,11 @@ | |||||
| loading: {{ 'app.statistics.loading'|trans|json_encode|raw }}, | loading: {{ 'app.statistics.loading'|trans|json_encode|raw }}, | ||||
| errorLoad: {{ 'app.statistics.error_load'|trans|json_encode|raw }}, | errorLoad: {{ 'app.statistics.error_load'|trans|json_encode|raw }}, | ||||
| weekShort: {{ 'app.date.week_short'|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> | </script> | ||||
| @@ -93,6 +98,31 @@ | |||||
| </div> | </div> | ||||
| </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> | ||||
| </div> | </div> | ||||
| @@ -464,6 +464,11 @@ app: | |||||
| revenue: "Umsatz" | revenue: "Umsatz" | ||||
| loading: "Daten werden geladen…" | loading: "Daten werden geladen…" | ||||
| error_load: "Fehler beim Laden der Statistikdaten." | error_load: "Fehler beim Laden der Statistikdaten." | ||||
| clients: "Kunden" | |||||
| projects: "Projekte" | |||||
| services: "Leistungen" | |||||
| rest: "Rest" | |||||
| no_service: "Ohne Leistung" | |||||
| stopwatch: | stopwatch: | ||||
| title: "Stoppuhr" | title: "Stoppuhr" | ||||