浏览代码

more statistics

master
父节点
当前提交
6e2f94f59b
共有 7 个文件被更改,包括 339 次插入11 次删除
  1. +49
    -9
      CLAUDE.md
  2. +120
    -2
      httpdocs/assets/scripts/statistics.js
  3. +73
    -0
      httpdocs/assets/styles/sections/_statistics.scss
  4. +1
    -0
      httpdocs/src/Controller/ReportController.php
  5. +61
    -0
      httpdocs/src/Repository/Tenant/TimeEntryRepository.php
  6. +30
    -0
      httpdocs/templates/report/statistics.html.twig
  7. +5
    -0
      httpdocs/translations/messages.de.yaml

+ 49
- 9
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):


+ 120
- 2
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 `<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);
}
});
});

+ 73
- 0
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;
}

+ 1
- 0
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);
}


+ 61
- 0
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)


+ 30
- 0
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 }},
}
};
</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>


+ 5
- 0
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"


正在加载...
取消
保存