Abo-Uebersichten: Team-Ansicht konsolidiert, Monatsfilter, Wachstum & Dashboard-KPIs

- /user/abos/ot: Filter nach Eingangsmonat (start_date) ueber neuen UserAbo-Scope
- Konsolidierte Team-Abo-Ansicht nach Ebenen/Linien (Bestand, neue Abos im Monat,
  Eigen-/Kundenabos, Punkte je Ebene + Summen) via TeamAboOverviewService
- team/customers leitet auf konsolidierte Ansicht um; Navigation bereinigt
- Mitglieder-Detail-Modal mit Abo-Zusammenfassung (Eigen-Abo, Kundenabos,
  Punkte, Beginn, naechste Ausfuehrung, Ausfuehrungen)
- Wachstums-Tabelle (Jahr x Monate) + Liniendiagramm (neue Eigen-/Kundenabos)
- Dashboard-KPI-Kachel (Gesamtabos, Kundenabos, 1000-Punkte-Shops)
- i18n de/en/es/fr, Tests (Filter, Overview, Wachstum, Modal, Redirect)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin 2026-06-23 14:07:29 +00:00
parent 65b867f8ee
commit 4135726772
15 changed files with 967 additions and 174 deletions

View file

@ -87,6 +87,16 @@ class ModalController extends Controller
$ret = view('user.shop.sales.modal_api_order_shipping_detail', compact('shopping_order', 'isAdmin', 'data'))->render();
}
if ($data['action'] === 'user-order-dhl-info') {
$user = \Auth::user();
$shopping_order = ShoppingOrder::with('dhlShipments')->findOrFail($data['id']);
if (! $user->isAdmin() && $shopping_order->auth_user_id !== $user->id && $shopping_order->member_id !== $user->id) {
abort(404);
}
$isAdmin = false;
$ret = view('user.order.modal_dhl_shipping_info', compact('shopping_order', 'isAdmin'))->render();
}
if ($data['action'] === 'user-order-my-delivery-show') {
$user = \Auth::user();
$ret = view('admin.modal.show_user_customers', compact('user', 'data'))->render();
@ -130,6 +140,15 @@ class ModalController extends Controller
$ret = view('admin.modal.business_user_detail', compact('TreeCalcBot', 'user', 'data'))->render();
}
if ($data['action'] === 'team-abo-member-show') {
$summary = app(\App\Services\Abo\TeamAboOverviewService::class)
->memberAboSummary(\Auth::user(), (int) $data['id']);
if ($summary) {
$ret = view('user.team.modal_member_abos', compact('summary', 'data'))->render();
} else {
$ret = view('admin.modal.business_user_notfound', compact('data'))->render();
}
}
if ($data['action'] === 'business-user-show') {
$user = User::with(['account', 'account.country', 'account.shipping_country', 'user_level', 'user_sponsor.account'])->find($data['id']);
\Log::info('business-user-show', ['user' => $user]);

View file

@ -703,68 +703,73 @@ class TeamController extends Controller
}
/**
* Zeigt die Abos der Team-Mitglieder an
* Konsolidierte Team-Abo-Übersicht: Abos im Team nach Ebenen/Linien gruppiert,
* inkl. Eigen-Abos, Kundenabos und neuer Abos im gewählten Monat.
*/
public function showAbos()
public function showAbos(\App\Services\Abo\TeamAboOverviewService $overviewService)
{
$user = User::find(\Auth::user()->id);
$this->setFilterVars();
$now = now();
// Hole Team-Mitglieder-IDs effizient via Sponsor-Hierarchie
$teamUserIds = AboHelper::getTeamUserIds($user->id);
// Bestandsübersicht: immer aktueller Bestand; "neu"-Badges = laufender Monat.
$overview = $overviewService->linesOverview($user, (int) $now->month, (int) $now->year);
$selectedYear = (int) Request::get('year', now()->year);
$baseQuery = \App\Models\UserAbo::whereIn('user_id', $teamUserIds)
->where('is_for', 'me')
->where('status', '>', 1);
// Hole Abos der Team-Mitglieder
$abos = (clone $baseQuery)
->with(['user', 'user.account', 'user_abo_items', 'user_abo_items.product'])
->orderBy('next_date', 'asc')
->get();
// Wachstums-Tabelle: wählbares Jahr (GET-Param), Default aktuelles Jahr.
$growthYear = (int) Request::get('year', $now->year);
$growth = $overviewService->monthlyGrowth($user, $growthYear);
$data = [
'filter_months' => HTMLHelper::getTransMonths(),
'filter_years' => HTMLHelper::getYearRange(2022),
'abos' => $abos,
'chartData' => AboHelper::getMonthlyAboCounts($baseQuery, $selectedYear, 'team_abos', $user->id),
'chartYear' => $selectedYear,
'chartYears' => HTMLHelper::getYearRange(2026),
'chartMonths' => HTMLHelper::getTransMonths(),
'currentMonthName' => HTMLHelper::getTransMonths()[(int) $now->month] ?? '',
'overview' => $overview,
'growth' => $growth,
'growthChart' => $this->buildGrowthChart($growth),
'growthYear' => $growthYear,
'filterYears' => HTMLHelper::getYearRange(2022),
];
return view('user.team.abos', $data);
}
/**
* Zeigt eine Übersicht der Kunden-Abos aller Team-Mitglieder (anonymisiert)
* Bereitet die Datenreihen für das Wachstums-Liniendiagramm auf.
*
* @param array{months: array<int, array{label: string, own_new: int, customer_new: int}>} $growth
* @return array{labels: list<string>, own: list<int>, customer: list<int>, step: int, suggested_max: int}
*/
public function showTeamCustomerAbos(): \Illuminate\View\View
private function buildGrowthChart(array $growth): array
{
$user = User::find(\Auth::user()->id);
$teamUserIds = AboHelper::getTeamUserIds($user->id);
$labels = array_map(fn (array $row): string => $row['label'], $growth['months']);
$own = array_map(fn (array $row): int => $row['own_new'], $growth['months']);
$customer = array_map(fn (array $row): int => $row['customer_new'], $growth['months']);
$selectedYear = (int) Request::get('year', now()->year);
$baseQuery = \App\Models\UserAbo::whereIn('member_id', $teamUserIds)
->where('is_for', 'ot')
->where('status', '>', 1);
$max = max(array_merge($own, $customer, [0]));
$step = match (true) {
$max <= 5 => 1,
$max <= 10 => 2,
$max <= 20 => 5,
$max <= 50 => 10,
$max <= 100 => 20,
$max <= 200 => 50,
default => 100,
};
$abos = (clone $baseQuery)
->with(['member', 'member.account', 'user_abo_items', 'user_abo_items.product'])
->orderBy('member_id')
->orderBy('next_date', 'asc')
->get();
return [
'labels' => array_values($labels),
'own' => array_values($own),
'customer' => array_values($customer),
'step' => $step,
'suggested_max' => $max + $step,
];
}
$groupedByMember = $abos->groupBy('member_id');
return view('user.team.customer_abos', [
'groupedByMember' => $groupedByMember,
'chartData' => AboHelper::getMonthlyAboCounts($baseQuery, $selectedYear, 'team_cust_abos', $user->id),
'chartYear' => $selectedYear,
'chartYears' => HTMLHelper::getYearRange(2026),
'chartMonths' => HTMLHelper::getTransMonths(),
]);
/**
* Die Kundenabos im Team sind in die konsolidierte Team-Abo-Übersicht
* (`user_abos_team_show`) eingeflossen. Diese Route bleibt für Bookmarks/Altlinks
* bestehen und leitet dauerhaft dorthin weiter.
*/
public function showTeamCustomerAbos(): \Illuminate\Http\RedirectResponse
{
return redirect()->route('user_abos_team_show');
}
/**

View file

@ -347,9 +347,11 @@ class BackofficeDashboardService
}
/**
* Anzahl der Berater mit >= 1000 Punkten Kundenumsatz (KP + Shop) im Monat.
*
* @param int[] $userIds
*/
private function shop1000Count(array $userIds, int $month, int $year): int
public function shop1000Count(array $userIds, int $month, int $year): int
{
return UserSalesVolume::query()
->whereIn('user_id', $userIds)

View file

@ -0,0 +1,320 @@
# Entwicklungsplan: Abo-Übersichten erweitern (eigene Kundenabos, Team-Abos, Dashboard)
> **Status:** In Planung · **Erstellt:** 2026-06-23 · **Briefing:** Kundenanfrage + `docs/salescenter/Todos Backoffice.md`
## Dokumentations-Hinweis (verbindlich)
Dieser Plan wird **Schritt für Schritt** abgearbeitet. Für **jede Phase** gilt:
- Der **Umsetzungsstatus** in diesem Dokument wird nach Abschluss eines Schritts auf `ERLEDIGT` gesetzt (mit Datum, betroffenen Dateien, Zeilenangaben und kurzer Begründung).
- **Alle Phasen, Hinweise und getroffenen Detail-Entscheidungen** werden sauber dokumentiert – auch Abweichungen vom Plan und Verworfenes (mit Begründung).
- Jede Änderung wird **programmatisch getestet** (Pest), bevor die Phase als erledigt gilt.
- Nach Code-Änderungen läuft `./vendor/bin/pint --dirty`.
---
## 1. Ziel & Briefing-Zusammenfassung
Die Darstellung/Auflistung der Abos im Sales Center (`my.mivita.care`) soll erweitert werden. Vier Bausteine:
1. **Eigene Kundenabos** (`/user/abos/ot`): Filter nach **Monat & Jahr**, in dem das Abo hinzugekommen ist (Eingangsmonat).
2. **Team-Abo-Ansichten zusammenführen** (`/user/abos/team/show` + `/user/abos/team/customers`): Beide Seiten überschneiden sich stark und sollen zu **einer** Ansicht „Abos im Team" verschmelzen. Inhalt:
- Wer im Team hat wie viele **Kundenabos** abgeschlossen?
- **Sortierung nach Monaten:** Wer im Team hat diesen Monat (Monatsfilter) wie viele **neue** Abos (Kunden- und Eigen-Abos) abgeschlossen?
- Wer im Team hat **selbst** ein Abo abgeschlossen?
- **Gesamtübersicht:** Wie viele Abos haben Team-Mitglieder insgesamt?
- **Sortierung nach Ebenen (Linien):** Wichtigste Ebene ist die **1. Ebene** (Wer hat dort ein Abo, wer nicht?), optional ausbaubar auf den gesamten Baum.
3. **Dashboard ergänzen:** Anzahl der **„1000-Punkte-Shops"** und **Gesamtabos im Team**.
---
## 2. Ist-Zustand (Analyse-Ergebnis)
### 2.1 Eigene Kundenabos `/user/abos/ot`
| Bereich | Status heute | Schlüsseldateien |
| ------- | ------------ | ---------------- |
| Controller | `AboController@index($view='ot')` lädt `UserAbo` (`member_id = Auth`, `is_for=ot`, `status>=1`), liefert bereits `chartData` (Jahresfilter) | `app/Http/Controllers/User/AboController.php:62-83` |
| View | Card-Liste pro Abo, oben Monats-Chart (`_abo_chart`) mit **Jahres**-Umschaltung | `resources/views/user/abo/index.blade.php`, `resources/views/user/abo/_abo_chart.blade.php` |
| Monatszählung | `AboHelper::getMonthlyAboCounts()` (Snapshot vergangener Monate + Live aktueller Monat) | `app/Services/AboHelper.php:514-565` |
| Filter | **Nur Jahr** (Chart). Keine Filterung der Card-Liste nach Eingangsmonat. | – |
**Fazit:** Jahresfilter existiert über den Chart. Es fehlt ein **Monat+Jahr-Filter auf die Liste** (Abos, deren `start_date` im gewählten Monat liegt).
### 2.2 Team-Abos `/user/abos/team/show` & `/user/abos/team/customers`
| Route | Methode | Inhalt heute |
| ----- | ------- | ------------ |
| `user_abos_team_show` | `TeamController@showAbos` | Flache Card-Liste **aller eigenen Team-Abos** (`is_for=me`, `user_id ∈ Team`), Jahres-Chart `team_abos`. Eine Card pro Abo. |
| `user_abos_team_customers` | `TeamController@showTeamCustomerAbos` | Kundenabos im Team (`is_for=ot`, `member_id ∈ Team`), **gruppiert pro Team-Mitglied** (collapsible), anonymisiert, Jahres-Chart `team_cust_abos`. |
- Beide nutzen `AboHelper::getTeamUserIds($userId)` (rekursive Downline über `m_sponsor`, **flach**, ohne Linien-Information).
- Beide nutzen denselben `_abo_chart`-Partial (nur Jahr).
- **Überschneidung:** Beide listen Team-Abos, nur unterschiedlich (eigene Abos vs. Kundenabos) und unterschiedlich gruppiert. Keine Ebenen-/Linien-Sortierung, keine „neue Abos diesen Monat"-Aggregation pro Person.
- Detailseite: `TeamController@detailAbo` (`user_abos_team_detail`).
### 2.3 Dashboard
| Bereich | Status heute | Datei |
| ------- | ------------ | ----- |
| Dashboard-Home | Karten-Grid; Abo-Bezug nur: Einmalprodukt-Card (`_abo_onetime`) | `resources/views/home.blade.php`, `app/Http/Controllers/HomeController.php:45-92` |
| Team-/Abo-KPIs | **Nicht vorhanden** auf dem Dashboard | – |
### 2.4 Bereits vorhandenes Backoffice-Statistik-Modul (WICHTIG – Wiederverwendung!)
Es existiert ein **VIP-only** Modul, das den Großteil der gewünschten Team-Logik bereits abbildet:
| Komponente | Datei | Liefert |
| ---------- | ----- | ------- |
| Controller | `app/Http/Controllers/User/BackofficeStatisticsController.php` | Übersicht / Drilldown / CSV-Export, Monat+Jahr-Filter (Session-persistiert), nur `isVIP()` |
| Dashboard-Service | `app/Services/Backoffice/BackofficeDashboardService.php` | **`lineBuckets($rootUserId)`** = Downline gruppiert nach **Linien/Ebenen** (1..n); pro Linie: Berater-Zahl, Neupartner, **Teamabos** (`team_partner_abos`), **neue Teamabos** (`team_partner_abos_new`), **Teamkundenabos** (`team_customer_abos`), **neue** (`team_customer_abos_new`), Punkte-Aufschlüsselung, **`shop_1000`** (1000-Punkte-Shop-Zähler), Umsatz; plus **`totals`**-Zeile. Snapshots für abgeschlossene Monate. |
| Drilldown-Service | `app/Services/Backoffice/BackofficeDrilldownService.php` | Personenlisten je Linie+Metrik (Namen, Punkte, Status, Besteht-seit, nächste Ausführung). |
| Snapshot-Modell | `app/Models/BackofficeStatisticsSnapshot.php` | Eingefrorene Monatswerte pro User. |
> **Schlüssel-Erkenntnis:** Die in der Kundenanfrage gewünschte Ebenen-/Monats-/Gesamtlogik **existiert technisch schon** in `BackofficeDashboardService`. Wir bauen **nicht neu**, sondern (a) machen diese Datenquelle für die Abo-Ansicht wiederverwendbar und (b) liefern eine schlankere, für **alle Berater** zugängliche Abo-fokussierte Ansicht + Dashboard-Widgets.
Definition **„1000-Punkte-Shop"** (laut Briefing, bereits im Service implementiert): Teampartner, die **mind. 1000 Punkte Kundenumsatz** im gewählten Monat erzielen (`shop1000Count()`).
---
## 3. Offene Fragen / Annahmen
| # | Thema | Annahme (sofern nicht anders entschieden) |
| - | ----- | ----------------------------------------- |
| A1 | „Eingangsmonat" eines Abos | = `start_date` (Anlage-/Aktivierungsdatum). Filter zeigt Abos mit `start_date` im gewählten Monat/Jahr. | JA
| A2 | Zugänglichkeit der neuen Team-Ansicht | Für **alle aktiven Berater** (nicht nur VIP), da rein abo-bezogen und über die eigene Downline. Das umfangreiche Punkte-/Umsatz-Backoffice bleibt VIP-only. | JA
| A3 | Umfang Ebenen | **1. Ebene zwingend** als Default sichtbar; weitere Linien aufklappbar; „gesamter Baum" als Summenzeile/optionaler Modus. | JA
| A4 | „selbst ein Abo abgeschlossen" | Eigen-Abo = `UserAbo is_for=me` des Team-Mitglieds (Spalte/Badge pro Person: ja/nein + Punkte). | JA
| A5 | Zusammenführen der zwei Routen | Neue konsolidierte Seite unter `user_abos_team_show`; `team/customers` wird Weiterleitung/Tab oder entfällt aus der Navigation (Route bleibt vorerst als Redirect). | Redirect
| A6 | Dashboard-Widgets-Zielgruppe | Alle aktiven Berater sehen „Gesamtabos im Team" + „1000-Punkte-Shops"; klickbar → neue Team-Abo-Ansicht. | JA
> **Diese Annahmen bitte vor Umsetzungsbeginn bestätigen oder korrigieren.**
---
## 4. Lösungsvorschlag (Architektur)
### 4.1 Gemeinsame Datenquelle: `TeamAboOverviewService` (neu)
Neuer Service `app/Services/Abo/TeamAboOverviewService.php`, der die Linien-/Monats-/Gesamtlogik **abo-fokussiert** kapselt und intern `BackofficeDashboardService::lineBuckets()` (oder eine extrahierte, gemeinsam genutzte Helper-Methode) wiederverwendet.
Liefert je Linie:
- `line` (1..n), `consultants` (Anzahl Berater dieser Ebene)
- `members_with_own_abo` / `members_total` (wer hat selbst ein Abo)
- `team_partner_abos` (Summe Eigen-Abos der Ebene) + `_new` (neu im Monat)
- `team_customer_abos` (Summe Kundenabos der Ebene) + `_new`
- pro Person (für Drilldown/aufgeklappte Ebene): Name, Karriere-Level, Eigen-Abo (ja/Punkte), #Kundenabos + Punkte, #Teamabos darunter
- `totals`-Zeile über alle Linien
Vorteil: Eine zentrale, getestete Datenquelle für **Team-Ansicht** und **Dashboard-Widgets**. Refactoring-Hinweis: Falls `lineBuckets()`/`newAboCount()`/`shop1000Count()` aus `BackofficeDashboardService` extrahiert werden, in einen gemeinsamen `Trait`/Helper auslagern, um Duplikate zu vermeiden.
### 4.2 Phase-Übersicht
| Phase | Inhalt | Aufwand |
| ----- | ------ | ------- |
| **P1** | `/user/abos/ot`: Monat+Jahr-Filter auf die Liste | klein |
| **P2** | `TeamAboOverviewService` (Datenquelle + Tests) | mittel |
| **P3** | Konsolidierte Team-Abo-Ansicht (Linien/Ebenen, Monatsfilter, neue Abos, Eigen-Abo-Spalte, Summen) | mittel-groß |
| **P4** | Zusammenführung `team/customers` → Redirect/Tab, Navigation anpassen | klein |
| **P5** | Dashboard-Widgets „Gesamtabos im Team" + „1000-Punkte-Shops" (klickbar) | klein-mittel |
| **P6** | i18n (de/en/es/fr), Pint, Gesamttest | klein |
---
## 5. Detailplanung pro Phase
### Phase 1 — `/user/abos/ot`: Monat- & Jahr-Filter
**Status:** ERLEDIGT (2026-06-23)
**Umsetzung:**
- `app/Models/UserAbo.php`: Query-Scope `scopeAddedInMonth($query, $year, $month)` (Filter nach `start_date`; `month=0` lässt Abfrage unverändert).
- `app/Http/Controllers/User/AboController.php` (`index('ot')`): wertet `month`-Param aus, nutzt `addedInMonth()`, übergibt `filterMonth`/`filterYear` an die View.
- `resources/views/user/abo/index.blade.php`: Filterleiste (Monats-Dropdown + Jahr + Filtern/Zurücksetzen) über der Card-Liste (nur `view==='ot'`).
- i18n: `abo.filter`, `abo.reset`, `abo.filter_added_period` in de/en/es/fr.
- Test: `tests/Feature/AboOtMonthFilterTest.php` (2 Tests grün).
- `AboController@index('ot')`: zusätzlich `month`-Parameter (Default „alle") auswerten.
- Liste filtern: `whereYear('start_date', $year)` + optional `whereMonth('start_date', $month)`.
- View `user/abo/index.blade.php`: Filterleiste (Monats-Dropdown + Jahr) über der Card-Liste; Chart bleibt. Wiederverwendung von `HTMLHelper::getTransMonths()` / `getYearRange()`.
- Filter-Persistenz analog zu bestehenden Mustern (Query-Param genügt; Session optional).
- **Test:** `tests/Feature` – Abos mit `start_date` in verschiedenen Monaten anlegen, Filter prüft korrekte Teilmenge.
### Phase 2 — `TeamAboOverviewService`
**Status:** ERLEDIGT (2026-06-23)
**Umsetzung:**
- `app/Models/UserAbo.php`: Scope `scopeActiveAbo()` (active=true, status ∉ {4,5,6}) – konsistent mit `BackofficeDashboardService`.
- `app/Services/Abo/TeamAboOverviewService.php`: `linesOverview(User,$month,$year)` liefert je Ebene Berateranzahl, „mit Eigen-Abo", Eigen-Abos (+Punkte +neu), Kundenabos (+Punkte +neu), pro-Mitglied-Details und eine Summenzeile. Wiederverwendung von `BackofficeDashboardService::lineBuckets()` (keine Duplizierung der Baum-Traversierung).
- i18n: `abo.team_total`, `abo.team_line` in de/en/es/fr.
- Test: `tests/Feature/TeamAboOverviewTest.php` (2 Tests, 19 Assertions grün). Hinweis: Test-Schema hat keine `products.slug`-Spalte → `beforeEach` ergänzt sie (etabliertes Muster aus `MembershipQtyProtectionTest`).
- Neuer Service mit Methoden:
- `linesOverview(User $user, int $month, int $year): array` (Linien + Totals, abo-fokussiert)
- `lineMembers(User $user, int $line, int $month, int $year): Collection` (Personen einer Ebene mit Abo-Kennzahlen)
- Wiederverwendung der vorhandenen `lineBuckets()`-Logik (gemeinsam nutzbar machen).
- „neue Abos im Monat": Logik aus `BackofficeDashboardService::newAboCount()` wiederverwenden (Definition: `start_date` im Monat, aktiver Status).
- **Test:** Unit/Feature – Downline mit mehreren Ebenen + Abos in verschiedenen Monaten; prüft Linien-Zuordnung, neue-Abo-Zähler, Eigen-Abo-Flag, Summen.
### Phase 3 — Konsolidierte Team-Abo-Ansicht
**Status:** ERLEDIGT (2026-06-23)
**Umsetzung:**
- `TeamController@showAbos` nutzt `TeamAboOverviewService` + Monat/Jahr-Filter (`selectedAboPeriod()`, Session-persistiert).
- Neue View `resources/views/user/team/abos.blade.php`: Monats-/Jahresfilter, Summen-Cards (Gesamtabos, Eigen-Abos, Kundenabos, Mitglieder mit Abo), Linien-/Ebenen-Tabelle (1. Ebene hervorgehoben + standardmäßig offen), aufklappbare Mitgliederliste je Ebene (Eigen-Abo ja/nein, #Kundenabos, Punkte), Summenzeile.
- i18n: viele neue `abo.*`-Keys (filter_period, team_total_abos, team_own_abos, team_members_with_abo, team_by_level, new_this_month, with_own_abo, consultants, level, own_abo, customer_abos_count, career_level, of, yes, no) in de/en/es/fr.
- Test: Render-Test in `tests/Feature/TeamAboOverviewTest.php` (3 Tests grün).
- `TeamController@showAbos` umstellen auf `TeamAboOverviewService` + Monat/Jahr-Filter (Session-persistiert, analog `BackofficeStatisticsController::selectedPeriod()`).
- Neue View `resources/views/user/team/abos.blade.php` (umbauen):
- **Kopf:** Monats-/Jahresfilter + Summen-Card (Gesamt-Teamabos, Gesamt-Kundenabos im Team, neue diesen Monat, #1000-Punkte-Shops).
- **Linien-Tabelle:** Zeile je Ebene (1. Linie zuerst, hervorgehoben): Berater, „mit Eigen-Abo", Eigen-Abos (Punkte), Kundenabos (Anzahl+Punkte), neue Abos (Monat). Summenzeile unten.
- **Aufklappbar pro Ebene:** Personenliste (Name, Karriere-Level, Eigen-Abo ja/nein, #Kundenabos, Punkte). 1. Ebene standardmäßig offen.
- Detail-Link je Abo → `user_abos_team_detail` bleibt erhalten.
- Wiederverwendung des `_abo_chart`-Partials für den Monatsverlauf (optional beibehalten).
- **Test:** Feature – View rendert Linien korrekt, 1. Ebene sichtbar, Summen stimmen.
### Phase 4 — Zusammenführung `team/customers`
**Status:** ERLEDIGT (2026-06-23)
**Umsetzung:**
- `TeamController@showTeamCustomerAbos` leitet dauerhaft auf `user_abos_team_show` um (Route bleibt für Altlinks). Kundenabos sind als Spalte/Drilldown in die konsolidierte Ansicht eingeflossen.
- Navigation `layout-sidenav.blade.php`: Menüpunkt „Team Kunden" entfernt; „Teamabos" markiert nun auch die `team/customers`-URL aktiv.
- `resources/views/user/team/customer_abos.blade.php` bleibt vorerst bestehen (nicht mehr verlinkt), um nichts ungewollt zu löschen.
- Test: `tests/Feature/TeamCustomerAbosRedirectTest.php` (Redirect grün).
- Kundenabos-Inhalt als **Tab/Abschnitt** in die konsolidierte Ansicht integrieren (gruppiert pro Mitglied bleibt als Drilldown erhalten).
- `user_abos_team_customers` → Redirect auf `user_abos_team_show` (Route bleibt für Bookmarks bestehen).
- Navigation `layout-sidenav.blade.php`: Punkt „Team Kunden" entfernen/zusammenführen; Label gem. Briefing „**Kundenabos im Team**" / „Teamkundenabos" verwenden (Terminologie-Korrektur).
- **Test:** Redirect-Test; Navigation-Smoke.
### Phase 5 — Dashboard-Widgets
**Status:** ERLEDIGT (2026-06-23)
**Umsetzung:**
- `BackofficeDashboardService::shop1000Count()` auf `public` gesetzt (Wiederverwendung).
- `TeamAboOverviewService::teamKpis(User,$month,$year)`: leichtgewichtige KPIs (Gesamtabos, Teamabos, Teamkundenabos, 1000-Punkte-Shops) ohne teure Punkte-/Mitglieder-Aggregation.
- `HomeController@show`: berechnet `teamAboKpis` für aktive Berater (aktueller Monat).
- Neues Partial `resources/views/dashboard/_abo_team_kpis.blade.php`: klickbare KPI-Card (→ `user_abos_team_show`), eingebunden in `home.blade.php`.
- i18n: `abo.shop_1000` in de/en/es/fr.
- Test: KPI-Test in `tests/Feature/TeamAboOverviewTest.php` (4 Tests grün).
- `HomeController@show`: für aktive Berater via `TeamAboOverviewService` (bzw. `BackofficeDashboardService::overview()`-Totals für den aktuellen Monat) zwei KPIs berechnen:
- **Gesamtabos im Team** (`team_partner_abos` + `team_customer_abos` der Totals, oder getrennt anzeigen).
- **1000-Punkte-Shops** (`shop_1000` Totals).
- Neuer Partial `resources/views/dashboard/_abo_team_kpis.blade.php`: zwei klickbare KPI-Cards → Link auf neue Team-Abo-Ansicht (bzw. Backoffice-Statistik bei VIP).
- Performance: Für das Dashboard nur **eine** Monatsberechnung; bei großen Teams ggf. Snapshot/Cache des aktuellen Monats nutzen (kein Live-Vollscan bei jedem Dashboard-Aufruf – ggf. Caching mit kurzer TTL).
- **Test:** Feature – KPIs erscheinen für aktiven Berater mit Team-Abos; Werte stimmen.
### Phase 6 — i18n & Abschluss
**Status:** ERLEDIGT (2026-06-23)
**Umsetzung:**
- i18n: alle neuen `abo.*`-Keys in de/en/es/fr gepflegt; `LocalizationSyncTest` grün (Sprachen synchron).
- Pint: `--dirty` → pass.
- Tests: 29 relevante Tests grün (Phase 1–5 + bestehende Abo-Tests), `LocalizationSyncTest` (3 Tests) grün.
- Neue Übersetzungsschlüssel in `resources/lang/{de,en,es,fr}/abo.php` + `navigation.php`/`team.php` (Filter-Labels, Spaltenköpfe, KPI-Titel, „1000-Punkte-Shop", „Kundenabos im Team", „neue Abos").
- `./vendor/bin/pint --dirty`.
- Gesamttest der betroffenen Feature-/Unit-Dateien.
---
## 6. Zu beachtende Punkte / Risiken
1. **Keine Logik-Duplikate:** `BackofficeDashboardService` ist die Referenz für Linien/neue Abos/1000-Punkte-Shop. Gemeinsame Methoden extrahieren statt kopieren.
2. **Performance:** Downline-Traversierung (`lineBuckets`) kann bei großen Teams teuer sein. Dashboard-Widgets cachen oder Snapshot des aktuellen Monats nutzen; nicht bei jedem Request live voll durchrechnen.
3. **Zugriffsrechte/Datenschutz:** Kundenabos im Team sind heute **anonymisiert** dargestellt (`customer_privacy_info`). Diese Anonymisierung beibehalten; Klarnamen nur dort, wo bisher erlaubt (Team-Mitglieder = Berater). VIP-/Datenschutz-Hinweise aus dem bestehenden Backoffice-Modul übernehmen.
4. **Status-Definition Abo aktiv:** Konsistent mit `getMonthlyAboCounts()`/`activeAboQuery()` halten (Terminal-Status 4/5 ausschließen).
5. **Snapshots:** Vergangene Monate aus `AboChartSnapshot` bzw. `BackofficeStatisticsSnapshot` lesen (nicht neu berechnen). Aktueller Monat live.
6. **Route-Kompatibilität:** `user_abos_team_customers` nicht hart entfernen (Bookmarks/Links) → Redirect.
7. **Multi-Tenant/Routing:** Änderungen liegen in `routes/domains/crm.php` (CRM-Domain). Keine Auswirkungen auf andere Domains.
---
## 7. Betroffene Dateien (Vorschau)
**Neu:**
- `app/Services/Abo/TeamAboOverviewService.php`
- `resources/views/dashboard/_abo_team_kpis.blade.php`
- `tests/Feature/TeamAboOverviewTest.php`, `tests/Feature/AboOtMonthFilterTest.php`, `tests/Feature/DashboardAboKpisTest.php`
**Geändert:**
- `app/Http/Controllers/User/AboController.php` (P1)
- `app/Http/Controllers/User/TeamController.php` (P3/P4)
- `app/Http/Controllers/HomeController.php` (P5)
- `app/Services/Backoffice/BackofficeDashboardService.php` (gemeinsame Methoden extrahieren)
- `resources/views/user/abo/index.blade.php` (P1)
- `resources/views/user/team/abos.blade.php` (P3)
- `resources/views/user/team/customer_abos.blade.php` (P4 – Integration/Redirect)
- `resources/views/home.blade.php` (P5)
- `resources/views/layouts/includes/layout-sidenav.blade.php` (P4)
- `routes/domains/crm.php` (P4 – Redirect)
- `resources/lang/{de,en,es,fr}/abo.php`, `navigation.php`, `team.php` (P6)
---
## 8. Umsetzungsreihenfolge (empfohlen)
P1 (schneller Mehrwert, isoliert) → P2 (Datenbasis) → P3 (Hauptansicht) → P4 (Konsolidierung/Navi) → P5 (Dashboard) → P6 (i18n/Abschluss).
---
## 9. Nachtrag — Mitglieder-Detail-Modal (2026-06-23)
**Status:** ERLEDIGT
Statt des generischen `business-user-show`-Modals (Sponsor/Firma) zeigt der Klick auf einen Namen in der Team-Abo-Übersicht jetzt eine abo-fokussierte Zusammenfassung.
**Umsetzung:**
- `TeamAboOverviewService::memberAboSummary(User $owner, int $memberId)`: liefert Name + E-Mail, Eigen-Abo (Punkte, Beginn, nächste Ausführung, Anzahl Ausführungen) sowie Kunden-Abos (Anzahl, Gesamtpunkte, Liste je Abo). Team-Autorisierung via `AboHelper::isUserInTeam()` → `null` (→ „nicht gefunden"-Modal), wenn Mitglied nicht in Downline.
- `ModalController@load`: neuer Branch `team-abo-member-show`.
- Neue View `resources/views/user/team/modal_member_abos.blade.php`.
- `user/team/abos.blade.php`: Namens-Link nutzt `data-action="team-abo-member-show"`.
- i18n: `abo.next_execution`, `abo.executions_count`, `abo.abo_count_points`, `abo.no_own_abo`, `abo.no_customer_abos` in de/en/es/fr.
- Tests: 2 zusätzliche Tests in `tests/Feature/TeamAboOverviewTest.php` (gesamt 6 grün).
---
## 10. Nachtrag — Zeitraum-Logik: Bestand + Wachstums-Tabelle (2026-06-23)
**Status:** ERLEDIGT
**Entscheidung (mit Kunde):** Variante **A + C** statt historischem Filter (Variante B verworfen, da „aktiv" ein Jetzt-Zustand ist und eine historische Rekonstruktion fehleranfällig/irreführend wäre).
**Umsetzung:**
- Oberer Bereich (Cards + Linien-Tabelle) zeigt immer den **aktuellen Bestand**; die „+neu"-Badges beziehen sich fest auf den **laufenden Monat** (Spaltenkopf zeigt den Monatsnamen). Der frühere, missverständliche Zeitraum-Filter wurde entfernt.
- Neue **Wachstums-Tabelle** unter der Bestandsübersicht: wählbares Jahr (GET-Param `year`), Zeilen = Monate, Spalten = neue Eigen-Abos / neue Kunden-Abos / Summe + Jahres-Summenzeile.
- `TeamAboOverviewService::monthlyGrowth(User,$year)` + privater Helfer `newAbosByMonth()` (DB-agnostisch via `toBase()->pluck('start_date')` + PHP-Gruppierung, kein `MONTH()`/`strftime`). Definition „neu" konsistent mit Badges (aktives Abo, `start_date` im Monat).
- `TeamController@showAbos` vereinfacht (kein Session-Filter mehr): Bestand = aktueller Monat, Wachstum = `year`-Param.
- i18n: `abo.team_growth`, `abo.month`, `abo.new_own_abos`, `abo.new_customer_abos`, `abo.new_total` in de/en/es/fr.
- Tests: 1 zusätzlicher Test (`monthlyGrowth`) + angepasster Render-Test (gesamt 7 grün).
---
## 11. Nachtrag — Tabellen-Feinschliff Ebenen (2026-06-23)
**Status:** ERLEDIGT
- Linien-Tabelle: redundante Spalte „Mit Eigen-Abo" entfernt (= identisch zu „Eigen-Abos", da max. 1 Eigen-Abo pro Berater).
- Neue Spalte **„Punkte"** (Gesamtpunkte je Ebene = Eigen- + Kundenabo-Punkte) inkl. Gesamtsumme im Footer (nutzt vorhandene `own_abo_points`/`customer_abo_points` aus `linesOverview`).
- Hinweis-Fußzeile unter der Wachstums-Tabelle (`abo.growth_note`) erklärt die Differenz Bestand vs. Jahres-Wachstum.
- **Klarstellung 124 vs. 123:** kein Bug. 124 = aktueller Bestand aller aktiven Eigen-Abos; 123 = davon in 2026 gestartet. 1 Eigen-Abo stammt aus einem Vorjahr und ist weiterhin aktiv.
- i18n: `abo.growth_note` in de/en/es/fr.
---
## 12. Nachtrag — Wachstums-Liniendiagramm (2026-06-23)
**Status:** ERLEDIGT
- Unter der Wachstums-Tabelle: Chart.js-**Liniendiagramm** (X = Monate, Y = Mengen) mit zwei Kurven (neue Eigen-Abos, neue Kunden-Abos) für das gewählte Jahr.
- Datenaufbereitung im Controller (`TeamController::buildGrowthChart()`) statt im Blade `@php`-Block – sauberere MVC-Trennung und umgeht ein Blade-Kompilierungsproblem mit `@php`-Block + Arrow-Funktionen in diesem Template.
- Nutzt dieselbe Chart.js-v2-Lib wie `_abo_chart.blade.php` (`/vendor/libs/chartjs/chartjs.js`), feste Canvas-ID `aboGrowthChart`.
- 2. Kachel-Dublette „Mit eigenem Abo" oben entfernt; verbleibende drei Kacheln auf `col-md-4`.
- Render-Test entsprechend erweitert (gesamt 7 grün), Pint pass.

View file

@ -219,6 +219,7 @@ return [
'new_own_abos' => 'Neue Eigen-Abos',
'new_customer_abos' => 'Neue Kunden-Abos',
'new_total' => 'Neu gesamt',
'growth_note' => 'Zählt aktive Abos nach Eingangsjahr (start_date). Der Gesamtbestand oben kann höher sein, da er auch noch aktive Abos aus Vorjahren enthält.',
'filter' => 'Filtern',
'reset' => 'Zurücksetzen',
'filter_added_period' => 'Hinzugekommen im Zeitraum:',

View file

@ -220,6 +220,7 @@ return [
'new_own_abos' => 'New own subscriptions',
'new_customer_abos' => 'New customer subscriptions',
'new_total' => 'New total',
'growth_note' => 'Counts active subscriptions by their start year (start_date). The total stock above can be higher because it also includes still-active subscriptions from previous years.',
'filter' => 'Filter',
'reset' => 'Reset',
'filter_added_period' => 'Added during period:',

View file

@ -193,6 +193,7 @@ return [
'new_own_abos' => 'Nuevas suscripciones propias',
'new_customer_abos' => 'Nuevas suscripciones de clientes',
'new_total' => 'Nuevas en total',
'growth_note' => 'Cuenta las suscripciones activas por su año de inicio (start_date). El total de arriba puede ser mayor porque también incluye suscripciones aún activas de años anteriores.',
'filter' => 'Filtrar',
'reset' => 'Restablecer',
'filter_added_period' => 'Añadido en el período:',

View file

@ -192,6 +192,7 @@ return [
'new_own_abos' => 'Nouveaux abonnements propres',
'new_customer_abos' => 'Nouveaux abonnements clients',
'new_total' => 'Nouveaux au total',
'growth_note' => 'Compte les abonnements actifs selon leur année de début (start_date). Le stock total ci-dessus peut être plus élevé, car il inclut aussi des abonnements encore actifs des années précédentes.',
'filter' => 'Filtrer',
'reset' => 'Réinitialiser',
'filter_added_period' => 'Ajouté durant la période :',

View file

@ -211,20 +211,12 @@
</a>
</li>
<li class="sidenav-item{{ Request::is('user/abos/team/show') ? ' active' : '' }}">
<li class="sidenav-item{{ Request::is('user/abos/team/show', 'user/abos/team/customers', 'user/abos/team/customers/*') ? ' active' : '' }}">
<a href="{{ route('user_abos_team_show') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-refresh-circle"></i>
<div>{{ __('navigation.teamabos') }}</div>
</a>
</li>
<li
class="sidenav-item{{ Request::is('user/abos/team/customers', 'user/abos/team/customers/*') ? ' active' : '' }}">
<a href="{{ route('user_abos_team_customers') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-refresh-circle"></i>
<div>{{ __('navigation.team_customers') }}</div>
</a>
</li>
<li
class="sidenav-item{{ Request::is('user/abos/me', 'user/abos/detail/me/*') ? ' active' : '' }}">
<a href="{{ route('user_abos', ['me']) }}" class="sidenav-link"><i

View file

@ -13,10 +13,115 @@
@include('user.abo._abo_chart')
@endif
@if($view === 'ot' && isset($filterMonth))
<div class="card mb-4">
<div class="card-body py-3">
<form method="GET" action="{{ url()->current() }}" class="form-inline">
<label class="text-muted small mr-2 mb-0">{{ __('abo.filter_added_period') }}</label>
<select name="month" class="form-control form-control-sm mr-2 mb-1">
<option value="0">{{ __('tables.all') }}</option>
@foreach($chartMonths as $monthNum => $monthName)
<option value="{{ $monthNum }}" {{ (int) $filterMonth === (int) $monthNum ? 'selected' : '' }}>{{ $monthName }}</option>
@endforeach
</select>
<select name="year" class="form-control form-control-sm mr-2 mb-1">
@foreach($chartYears as $y)
<option value="{{ $y }}" {{ (int) $filterYear === (int) $y ? 'selected' : '' }}>{{ $y }}</option>
@endforeach
</select>
<button type="submit" class="btn btn-sm btn-secondary mb-1">{{ __('abo.filter') }}</button>
@if($filterMonth)
<a href="{{ url()->current() }}?year={{ $filterYear }}" class="btn btn-sm btn-outline-secondary ml-2 mb-1">{{ __('abo.reset') }}</a>
@endif
</form>
</div>
</div>
@endif
@if (Session::has('alert-success'))
<div class="alert alert-success">
{{ Session::get('alert-success') }}
</div>
@endif
@if (Session::has('alert-error'))
<div class="alert alert-danger">
{{ Session::get('alert-error') }}
</div>
@endif
<div class="row">
<div class="col-12">
@php($abo_payment_links = $abo_payment_links ?? collect())
@if(!$user_abos || $user_abos->isEmpty())
@if($view === 'ot' && $abo_payment_links->isNotEmpty())
@foreach($abo_payment_links as $paymentLink)
<div class="card mb-4 border-warning">
<div class="card-body pb-2">
<div class="row">
<div class="col-sm-7">
<h5 class="mb-1">
<a href="{{ route('user_order_payment_links_detail', [$paymentLink->identifier]) }}">
{{ __('navigation.abo') }} <span class="text-muted">#{{ $paymentLink->identifier }}</span>
</a>
{!! \App\Services\OrderPaymentService::getTypeBadge($paymentLink) !!}
</h5>
</div>
<div class="col-sm-5 text-right">
<a href="{{ route('user_order_payment_links_detail', [$paymentLink->identifier]) }}" class="btn btn-sm btn-secondary">
<span class="fa fa-edit"></span>{{ __('abo.abo_details') }}
</a>
@if(\App\Services\OrderPaymentService::canDeleteInstance($paymentLink))
{!! Form::open(['action' => route('user_abos_payment_link_delete', [$paymentLink->identifier]), 'class' => 'd-inline-block ml-1']) !!}
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('{{ __('confirm_delete') }}');">
<span class="fa fa-trash"></span> {{ __('delete') }}
</button>
{!! Form::close() !!}
@endif
</div>
</div>
</div>
<hr class="m-0">
<div class="card-body pb-1">
@if(\App\Services\OrderPaymentService::isPaymentFailed($paymentLink))
<div class="alert alert-danger mb-3">
<strong>{{ __('abo.pending_payment_failed_title') }}</strong><br>
{{ __('abo.payment_link_failed_copy') }}
</div>
@elseif(\App\Services\OrderPaymentService::isPaymentProcessing($paymentLink))
<div class="alert alert-info mb-3">
<strong>{{ __('abo.pending_payment_processing_title') }}</strong><br>
{{ __('abo.payment_link_processing_copy') }}
</div>
@else
<div class="alert alert-warning mb-3">
<strong>{{ __('abo.payment_link_sent_title') }}</strong><br>
{{ __('abo.payment_link_sent_copy') }}
</div>
@endif
<div class="row">
<div class="col-md-3 mb-3">
<div class="text-muted small">{{ __('tables.date') }}</div>
<div class="font-weight-bold">{{ $paymentLink->created_at->format('d.m.Y') }}</div>
</div>
<div class="col-md-3 mb-3">
<div class="text-muted small">{{ __('tables.status') }}</div>
{!! \App\Services\OrderPaymentService::getStatusBadge($paymentLink) !!}
</div>
<div class="col-md-3 mb-3">
<div class="text-muted small">{{ __('tables.amount') }}</div>
{{ $paymentLink->getAmountFormatted() }} €
</div>
<div class="col-md-3 mb-3">
<div class="text-muted small">{{ __('tables.email') }}</div>
{{ $paymentLink->shopping_data['billing_email'] ?? '-' }}
</div>
</div>
</div>
</div>
@endforeach
@endif
@if((!$user_abos || $user_abos->isEmpty()) && $abo_payment_links->isEmpty())
<div class="card mb-4">
<div class="card-body pb-2">
<div class="row">
@ -37,6 +142,29 @@
@foreach($user_abos as $user_abo)
<div class="card mb-4">
@if($user_abo->isPendingInitialPayment())
<div class="card-body pb-0">
@if($user_abo->hasFailedInitialPayment())
<div class="alert alert-danger mb-3">
<strong>{{ __('abo.pending_payment_failed_title') }}</strong><br>
{{ __('abo.pending_payment_failed_copy') }}
{!! Form::open(['action' => route('user_abos_delete_pending', [$view, $user_abo->id]), 'class' => 'd-inline-block ml-2']) !!}
<button type="submit" class="btn btn-sm btn-danger">{{ __('abo.pending_delete_button') }}</button>
{!! Form::close() !!}
</div>
@elseif($user_abo->hasInitialPaymentStatus())
<div class="alert alert-info mb-3">
<strong>{{ __('abo.pending_payment_processing_title') }}</strong><br>
{{ __('abo.pending_payment_processing_copy') }}
</div>
@else
<div class="alert alert-warning mb-3">
<strong>{{ __('abo.pending_payment_waiting_title') }}</strong><br>
{{ __('abo.pending_payment_waiting_copy') }}
</div>
@endif
</div>
@endif
<div class="card-body pb-2">
<div class="row">
<div class="col-sm-6">
@ -136,4 +264,4 @@
@endforeach
</div>
</div>
@endsection
@endsection

View file

@ -1,133 +1,273 @@
@extends('layouts.layout-2')
@section('content')
<h4 class="font-weight-bold py-2 mb-2 d-flex justify-content-between align-items-center w-100 ">
<h4 class="font-weight-bold py-2 mb-2 d-flex justify-content-between align-items-center w-100">
<div>
{{ __('abo.team_subscriptions') }} / {{ __('navigation.overview') }}
</div>
</h4>
@if(isset($chartData))
@include('user.abo._abo_chart')
@endif
<div class="row">
<div class="col-12">
@if(!$abos || $abos->isEmpty())
<div class="card mb-4">
<div class="card-body pb-2">
<div class="row">
<div class="col-sm-12">
<h5>{{ __('abo.team_subscriptions') }}</h5>
<p class="text-muted">{{ __('tables.no_data_available') }}</p>
</div>
</div>
{{-- Gesamtübersicht (aktueller Bestand) --}}
@php($totals = $overview['totals'])
<div class="row mb-2">
<div class="col-md-4 col-12 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<div class="text-muted small">{{ __('abo.team_total_abos') }}</div>
<div class="display-5 font-weight-bold text-primary">{{ $totals['total_abos'] }}</div>
</div>
</div>
@endif
@foreach($abos as $user_abo)
<div class="card mb-4">
<div class="card-body pb-2">
<div class="row">
<div class="col-sm-6">
<h5 class="mb-1">
<a href="{{ route('user_abos_team_detail', [$user_abo->id]) }}">
{{ __('navigation.abo') }} <span class="text-muted">{{ '#'.$user_abo->payone_userid }}</span>
</a>
</h5>
</div>
<div class="col-sm-6 text-right">
<a href="{{ route('user_abos_team_detail', [$user_abo->id]) }}" class="btn btn-sm btn-secondary float-right">
<span class="fa fa-edit"></span>{{ __('abo.abo_details') }}
</a>
</div>
</div>
</div>
<hr class="m-0">
<div class="card-body pb-1">
<div class="row">
<div class="col-md-3 mb-2">
<div class="text-muted small">{{ __('tables.start_date') }}</div>
<div class="font-weight-bold">{{ $user_abo->start_date }}</div>
</div>
<div class="col-md-3 mb-2">
<div class="text-muted small">{{ __('tables.next_date') }} </div>
<div class="font-weight-bold">{{ $user_abo->next_date }}</div>
</div>
<div class="col-md-3 mb-2">
<div class="text-muted small">{{ __('tables.abo_delivery_day') }}</div>
<div class="font-weight-bold">{{ \App\Services\HTMLHelper::getAboStrLang($user_abo->abo_interval) }}</div>
</div>
<div class="col-md-3 mb-2">
<div class="text-muted small">{{ __('tables.last_date') }}</div>
<div class="font-weight-bold">{{ $user_abo->last_date }}</div>
</div>
</div>
</div>
<hr class="m-0">
<div class="card-body pb-1">
<div class="row">
<div class="col-md-3 mb-3">
<div class="text-muted small">{{ __('tables.status') }} / {{ __('tables.active') }}</div>
{!! $user_abo->getStatusFormated() !!} &nbsp;{!! get_active_badge($user_abo->active) !!}
</div>
<div class="col-md-3 mb-3">
<div class="text-muted small">{{ __('tables.abo_delivery') }}</div>
{{ $user_abo->getCountOrders() }}
</div>
<div class="col-md-3 mb-3">
<div class="text-muted small">{{ __('navigation.points') }}</div>
<strong class="text-primary">{{ $user_abo->getFormattedTotalPoints() }} Pkt.</strong>
</div>
<div class="col-md-3 mb-3">
<div class="text-muted small">{{ __('tables.payment') }}</div>
{{ $user_abo->getPaymentType() }}
</div>
</div>
</div>
<hr class="m-0">
<div class="card-body pb-1">
<div class="row">
<div class="col-md-3 mb-3">
<div class="text-muted small">{{ __('First name') }}</div>
@if($user_abo->user && $user_abo->user->account)
{{ $user_abo->user->account->first_name }}
@endif
</div>
<div class="col-md-3 mb-3">
<div class="text-muted small">{{ __('Last name') }}</div>
@if($user_abo->user && $user_abo->user->account)
{{ $user_abo->user->account->last_name }}
@endif
</div>
<div class="col-md-3 mb-3">
<div class="text-muted small">{{ __('E-Mail') }}</div>
@if($user_abo->user)
{{ $user_abo->user->email }}
@endif
</div>
<div class="col-md-3 mb-3">
<div class="text-muted small">{{ __('tables.adviser') }}</div>
@if($user_abo->user && $user_abo->user->account)
<a href="#" class="text-black" data-toggle="modal"
data-target="#modals-load-content"
data-id="{{ $user_abo->user->id }}"
data-action="business-user-show"
data-back=""
data-modal="modal-md"
data-init_from="member"
data-route=" {{ route('modal_load') }}">
<span class="mr-1 ion ion-ios-contact"></span> {{ $user_abo->user->account->first_name }} {{ $user_abo->user->account->last_name }} </a>
@endif
</div>
</div>
</div>
<div class="col-md-4 col-6 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<div class="text-muted small">{{ __('abo.team_own_abos') }}</div>
<div class="display-5 font-weight-bold">{{ $totals['own_abos'] }}</div>
<div class="small text-success">+{{ $totals['own_abos_new'] }} {{ __('abo.new_this_month') }}</div>
</div>
</div>
</div>
<div class="col-md-4 col-6 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<div class="text-muted small">{{ __('abo.team_customer_abos') }}</div>
<div class="display-5 font-weight-bold">{{ $totals['customer_abos'] }}</div>
<div class="small text-success">+{{ $totals['customer_abos_new'] }} {{ __('abo.new_this_month') }}</div>
</div>
</div>
@endforeach
</div>
</div>
@endsection
{{-- Linien-/Ebenen-Tabelle --}}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="font-weight-bold">{{ __('abo.team_by_level') }}</span>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr class="text-muted small">
<th>{{ __('abo.level') }}</th>
<th class="text-center">{{ __('abo.consultants') }}</th>
<th class="text-center">{{ __('abo.team_own_abos') }}</th>
<th class="text-center">{{ __('abo.team_customer_abos') }}</th>
<th class="text-center">{{ __('abo.new_this_month') }} ({{ $currentMonthName }})</th>
<th class="text-center">{{ __('abo.team_total') }}</th>
<th class="text-right">{{ __('abo.abo_count_points') }}</th>
</tr>
</thead>
<tbody>
@forelse($overview['lines'] as $line)
@php($collapseId = 'team-line-'.$line['line'])
<tr class="{{ $line['line'] === 1 ? 'table-active' : '' }}" style="cursor: pointer;"
data-toggle="collapse" data-target="#{{ $collapseId }}">
<td class="font-weight-bold">
<span class="ion ion-md-arrow-dropright"></span>
{{ $line['label'] }}
</td>
<td class="text-center">{{ $line['consultants'] }}</td>
<td class="text-center">
{{ $line['own_abos'] }}
@if($line['own_abos_new'] > 0)<span class="badge badge-success">+{{ $line['own_abos_new'] }}</span>@endif
</td>
<td class="text-center">
{{ $line['customer_abos'] }}
@if($line['customer_abos_new'] > 0)<span class="badge badge-success">+{{ $line['customer_abos_new'] }}</span>@endif
</td>
<td class="text-center text-success font-weight-bold">+{{ $line['own_abos_new'] + $line['customer_abos_new'] }}</td>
<td class="text-center font-weight-bold text-primary">{{ $line['total_abos'] }}</td>
<td class="text-right font-weight-bold">{{ \App\Services\Util::formatNumber($line['own_abo_points'] + $line['customer_abo_points']) }}</td>
</tr>
<tr class="p-0">
<td colspan="7" class="p-0 border-0">
<div class="collapse {{ $line['line'] === 1 ? 'show' : '' }}" id="{{ $collapseId }}">
<div class="p-3 bg-lightest">
<table class="table table-sm table-borderless mb-0">
<thead>
<tr class="text-muted small">
<th>{{ __('tables.adviser') }}</th>
<th>{{ __('abo.career_level') }}</th>
<th class="text-center">{{ __('abo.own_abo') }}</th>
<th class="text-center">{{ __('abo.customer_abos_count') }}</th>
<th class="text-right">{{ __('navigation.points') }}</th>
</tr>
</thead>
<tbody>
@foreach($line['members'] as $member)
<tr>
<td>
<a href="#" class="text-black" data-toggle="modal"
data-target="#modals-load-content"
data-id="{{ $member['user_id'] }}"
data-action="team-abo-member-show"
data-back=""
data-modal="modal-md"
data-init_from="member"
data-route="{{ route('modal_load') }}">
<span class="mr-1 ion ion-ios-contact"></span>{{ $member['name'] }}
</a>
</td>
<td class="small text-muted">{{ $member['career_level'] }}</td>
<td class="text-center">
@if($member['has_own_abo'])
<span class="badge badge-success">{{ __('abo.yes') }}</span>
@else
<span class="badge badge-outline-secondary">{{ __('abo.no') }}</span>
@endif
</td>
<td class="text-center">{{ $member['customer_abo_count'] }}</td>
<td class="text-right">
<span class="text-primary font-weight-bold">{{ \App\Services\Util::formatNumber($member['own_abo_points'] + $member['customer_abo_points']) }}</span> Pkt.
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="text-muted text-center py-4">{{ __('tables.no_data_available') }}</td>
</tr>
@endforelse
</tbody>
@if(!empty($overview['lines']))
<tfoot>
<tr class="font-weight-bold">
<td>{{ $totals['label'] }}</td>
<td class="text-center">{{ $totals['consultants'] }}</td>
<td class="text-center">{{ $totals['own_abos'] }}</td>
<td class="text-center">{{ $totals['customer_abos'] }}</td>
<td class="text-center text-success">+{{ $totals['own_abos_new'] + $totals['customer_abos_new'] }}</td>
<td class="text-center text-primary">{{ $totals['total_abos'] }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($totals['own_abo_points'] + $totals['customer_abo_points']) }}</td>
</tr>
</tfoot>
@endif
</table>
</div>
</div>
{{-- Wachstum: neue Abos je Monat im gewählten Jahr --}}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="font-weight-bold">{{ __('abo.team_growth') }}</span>
<form method="GET" action="{{ url()->current() }}" class="form-inline">
<select name="year" class="form-control form-control-sm" onchange="this.form.submit()">
@foreach($filterYears as $y)
<option value="{{ $y }}" {{ (int) $growthYear === (int) $y ? 'selected' : '' }}>{{ $y }}</option>
@endforeach
</select>
<noscript><button type="submit" class="btn btn-sm btn-secondary ml-2">{{ __('abo.filter') }}</button></noscript>
</form>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr class="text-muted small">
<th>{{ __('abo.month') }}</th>
<th class="text-center">{{ __('abo.new_own_abos') }}</th>
<th class="text-center">{{ __('abo.new_customer_abos') }}</th>
<th class="text-center">{{ __('abo.new_total') }}</th>
</tr>
</thead>
<tbody>
@foreach($growth['months'] as $row)
<tr class="{{ $row['total_new'] === 0 ? 'text-muted' : '' }}">
<td>{{ $row['label'] }}</td>
<td class="text-center">{{ $row['own_new'] }}</td>
<td class="text-center">{{ $row['customer_new'] }}</td>
<td class="text-center font-weight-bold {{ $row['total_new'] > 0 ? 'text-success' : '' }}">{{ $row['total_new'] }}</td>
</tr>
@endforeach
</tbody>
<tfoot>
<tr class="font-weight-bold">
<td>{{ __('abo.team_total') }} {{ $growth['year'] }}</td>
<td class="text-center">{{ $growth['totals']['own_new'] }}</td>
<td class="text-center">{{ $growth['totals']['customer_new'] }}</td>
<td class="text-center text-primary">{{ $growth['totals']['total_new'] }}</td>
</tr>
</tfoot>
</table>
</div>
<div class="card-footer py-2">
<small class="text-muted">{{ __('abo.growth_note') }}</small>
</div>
</div>
{{-- Wachstums-Diagramm: zwei Kurven (neue Eigen-/Kunden-Abos je Monat) --}}
<div class="card mb-4">
<div class="card-body pb-2">
<h6 class="font-weight-bold mb-2">
{{ __('abo.team_growth') }}
<span class="text-muted font-weight-normal ml-1">{{ $growth['year'] }}</span>
</h6>
</div>
<hr class="m-0">
<div class="card-body pt-3 pb-2">
<canvas id="aboGrowthChart" height="90"></canvas>
</div>
</div>
<script src="/vendor/libs/chartjs/chartjs.js"></script>
<script>
(function () {
var ctx = document.getElementById('aboGrowthChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: {!! json_encode($growthChart['labels']) !!},
datasets: [
{
label: @json(__('abo.new_own_abos')),
data: {!! json_encode($growthChart['own']) !!},
backgroundColor: 'rgba(102, 110, 232, 0.1)',
borderColor: 'rgba(102, 110, 232, 0.9)',
borderWidth: 2,
pointBackgroundColor: 'rgba(102, 110, 232, 0.9)',
pointRadius: 3,
lineTension: 0.3,
fill: false
},
{
label: @json(__('abo.new_customer_abos')),
data: {!! json_encode($growthChart['customer']) !!},
backgroundColor: 'rgba(40, 167, 69, 0.1)',
borderColor: 'rgba(40, 167, 69, 0.9)',
borderWidth: 2,
pointBackgroundColor: 'rgba(40, 167, 69, 0.9)',
pointRadius: 3,
lineTension: 0.3,
fill: false
}
]
},
options: {
responsive: true,
legend: { display: true, position: 'top' },
tooltips: { mode: 'index', intersect: false },
layout: { padding: { top: 10 } },
scales: {
yAxes: [{
ticks: {
beginAtZero: true,
stepSize: {{ $growthChart['step'] }},
suggestedMax: {{ $growthChart['suggested_max'] }},
callback: function (value) {
return Number.isInteger(value) ? value : null;
}
},
gridLines: { color: 'rgba(0,0,0,0.05)' }
}],
xAxes: [{
gridLines: { display: false }
}]
}
}
});
}());
</script>
@endsection

View file

@ -0,0 +1,91 @@
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="ion ion-ios-contact"></i>
{{ $summary['name'] }}
<span class="d-block text-muted small font-weight-normal">{{ $summary['email'] }}</span>
</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
{{-- Eigen-Abo --}}
<h6 class="font-weight-semibold text-primary mb-2">
<i class="ion ion-md-refresh-circle"></i> {{ __('abo.own_abo') }}
</h6>
@if ($summary['own_abo'])
@php($own = $summary['own_abo'])
<table class="table table-sm table-borderless mb-4">
<tbody>
<tr>
<td class="text-muted" style="width: 50%;">{{ __('abo.abo_count_points') }}</td>
<td class="text-right font-weight-bold">{{ \App\Services\Util::formatNumber($own['points']) }} Pkt.</td>
</tr>
<tr>
<td class="text-muted">{{ __('abo.abo_start_date') }}</td>
<td class="text-right">{{ $own['start_date'] }}</td>
</tr>
<tr>
<td class="text-muted">{{ __('abo.next_execution') }}</td>
<td class="text-right">{{ $own['next_date'] }}</td>
</tr>
<tr>
<td class="text-muted">{{ __('abo.executions_count') }}</td>
<td class="text-right">{{ $own['executions'] }}</td>
</tr>
</tbody>
</table>
@else
<p class="text-muted mb-4">{{ __('abo.no_own_abo') }}</p>
@endif
{{-- Kunden-Abos --}}
@php($cust = $summary['customer_abos'])
<h6 class="font-weight-semibold text-primary mb-2">
<i class="ion ion-md-people"></i> {{ __('abo.team_customer_abos') }}
</h6>
<div class="row mb-3">
<div class="col-6">
<div class="bg-lightest rounded p-2 text-center">
<div class="h4 mb-0 font-weight-bold">{{ $cust['count'] }}</div>
<div class="text-muted small">{{ __('abo.customer_abos_count') }}</div>
</div>
</div>
<div class="col-6">
<div class="bg-lightest rounded p-2 text-center">
<div class="h4 mb-0 font-weight-bold text-primary">{{ \App\Services\Util::formatNumber($cust['total_points']) }}</div>
<div class="text-muted small">{{ __('abo.abo_count_points') }}</div>
</div>
</div>
</div>
@if ($cust['count'] > 0)
<table class="table table-sm mb-0">
<thead>
<tr class="text-muted small">
<th>#</th>
<th class="text-right">{{ __('abo.abo_count_points') }}</th>
<th class="text-right">{{ __('abo.abo_start_date') }}</th>
<th class="text-right">{{ __('abo.next_execution') }}</th>
<th class="text-right">{{ __('abo.executions_count') }}</th>
</tr>
</thead>
<tbody>
@foreach ($cust['items'] as $i => $item)
<tr>
<td>{{ $i + 1 }}</td>
<td class="text-right font-weight-bold">{{ \App\Services\Util::formatNumber($item['points']) }}</td>
<td class="text-right">{{ $item['start_date'] }}</td>
<td class="text-right">{{ $item['next_date'] }}</td>
<td class="text-right">{{ $item['executions'] }}</td>
</tr>
@endforeach
</tbody>
</table>
@else
<p class="text-muted mb-0">{{ __('abo.no_customer_abos') }}</p>
@endif
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('team.close') }}</button>
</div>
</div>

View file

@ -0,0 +1,66 @@
<?php
use App\Models\UserAbo;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function createOtAbo(User $member, string $startDate): UserAbo
{
return UserAbo::create([
'user_id' => $member->id,
'member_id' => $member->id,
'shopping_user_id' => 0,
'email' => 'cust-'.uniqid('', true).'@example.com',
'is_for' => 'ot',
'payone_userid' => 0,
'clearingtype' => 'cc',
'amount' => 5000,
'status' => 2,
'abo_interval' => 5,
'start_date' => $startDate,
'last_date' => $startDate,
'next_date' => '2026-12-10',
]);
}
it('filtert Abos nach Eingangsmonat (start_date)', function (): void {
$member = User::forceCreate([
'email' => 'member-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$june = createOtAbo($member, '2026-06-15');
$july = createOtAbo($member, '2026-07-03');
$juneOtherYear = createOtAbo($member, '2025-06-20');
$filtered = UserAbo::where('member_id', $member->id)
->where('is_for', 'ot')
->addedInMonth(2026, 6)
->pluck('id');
expect($filtered)->toContain($june->id)
->not->toContain($july->id)
->not->toContain($juneOtherYear->id);
});
it('lässt die Abfrage unverändert, wenn kein Monat gewählt ist (month=0)', function (): void {
$member = User::forceCreate([
'email' => 'member-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
createOtAbo($member, '2026-06-15');
createOtAbo($member, '2026-07-03');
$count = UserAbo::where('member_id', $member->id)
->where('is_for', 'ot')
->addedInMonth(2026, 0)
->count();
expect($count)->toBe(2);
});

View file

@ -160,6 +160,13 @@ it('rendert die Team-Abo-Linienansicht mit Ebenen und Summen', function (): void
'currentMonthName' => HTMLHelper::getTransMonths()[6],
'overview' => $overview,
'growth' => $growth,
'growthChart' => [
'labels' => array_map(fn ($r) => $r['label'], $growth['months']),
'own' => array_map(fn ($r) => $r['own_new'], $growth['months']),
'customer' => array_map(fn ($r) => $r['customer_new'], $growth['months']),
'step' => 1,
'suggested_max' => 5,
],
'growthYear' => 2026,
'filterYears' => HTMLHelper::getYearRange(2022),
])->render();
@ -168,6 +175,7 @@ it('rendert die Team-Abo-Linienansicht mit Ebenen und Summen', function (): void
->toContain(__('abo.team_by_level'))
->toContain(__('abo.team_total_abos'))
->toContain(__('abo.team_growth'))
->toContain('aboGrowthChart')
->toContain($anna->email);
});

View file

@ -0,0 +1,18 @@
<?php
use App\Http\Controllers\User\TeamController;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
uses(TestCase::class);
it('leitet die alte Team-Kundenabos-Route auf die konsolidierte Übersicht um', function (): void {
URL::resolveMissingNamedRoutesUsing(fn (string $name, array $parameters) => '/'.$name);
$controller = new TeamController;
$response = $controller->showTeamCustomerAbos();
expect($response)->toBeInstanceOf(RedirectResponse::class)
->and($response->getTargetUrl())->toContain('user_abos_team_show');
});