Backoffice-Statistik vereinfacht & Dashboard-Monatsstatistik konsolidiert

- Statistik-Uebersicht: Tabelle auf Linie/Berater/Umsatz(Punkte)/Teamabos/
  Teamkundenabos reduziert; Neupartner als grünes "+N"-Badge in der
  Berater-Spalte; 1000-Punkte-Shop & Neupartner als klickbare KPI-Cards
  (Augen-Icon/Liste anzeigen)
- Teamkundenabos-Drilldown pro Teampartner gruppiert (Anzahl + Volumenpunkte
  gesamt, aufklappbar je Abo), CSV-Export angepasst
- Home: fehlerhafte Monatsstatistik-Card (inkonsistente aktiv-Definition,
  kein Monatsbezug, schwere Live-Logik im Blade) mit der Abo-KPI-Card
  zusammengefuehrt; Logik nach HomeController::buildMonthlyStats() ausgelagert,
  konsistent ueber TeamAboOverviewService/BackofficeDashboardService
- Team-Payline fuer laufenden Monat live berechnet und 6h gecacht (Snapshot)
- Kacheln in Cluster (Umsatz/Neupartner/Abos) gruppiert
- Services: teamKpis() um *_new ergaenzt, personalAboKpis() & newPartnersCount() neu
- i18n (de/en/es/fr), Tests ergaenzt/aktualisiert

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-06-24 13:00:38 +00:00
parent 4135726772
commit 386851aee8
20 changed files with 631 additions and 323 deletions

View file

@ -7,10 +7,15 @@ use App\Models\Incentive;
use App\Models\IncentiveParticipant;
use App\Models\ShoppingPayment;
use App\Models\UserAbo;
use App\Models\UserBusiness;
use App\Services\Abo\TeamAboOverviewService;
use App\Services\AboHelper;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\BusinessPlan\TreeCalcBot;
use App\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Request;
use Util;
@ -79,10 +84,9 @@ class HomeController extends Controller
}
}
$teamAboKpis = null;
$monthlyStats = null;
if ($user->isActiveAccount()) {
$teamAboKpis = app(\App\Services\Abo\TeamAboOverviewService::class)
->teamKpis($user, (int) Carbon::now()->month, (int) Carbon::now()->year);
$monthlyStats = $this->buildMonthlyStats($user);
}
$data = [
@ -92,12 +96,92 @@ class HomeController extends Controller
'activeIncentive' => $activeIncentive,
'incentiveParticipant' => $incentiveParticipant,
'oneTimeAbo' => $oneTimeAbo,
'teamAboKpis' => $teamAboKpis,
'monthlyStats' => $monthlyStats,
];
return view('home', $data);
}
/**
* Kennzahlen der Monatsstatistik-Card. Bündelt persönliche Punkte/Abos, Team-Abo-KPIs
* (konsistent über {@see TeamAboOverviewService}) und die Team-Payline-Punkte.
*
* @return array<string, mixed>
*/
private function buildMonthlyStats(User $user): array
{
$month = max(1, min(12, (int) Request::get('stats_month', now()->month)));
$year = (int) Request::get('stats_year', now()->year);
$salesVolume = $user->getUserSalesVolume($month, $year, 'first');
[$paylinePoints, $paylineIsLive] = $this->teamPaylinePoints($user, $month, $year);
$firstlineIds = User::where('m_sponsor', $user->id)
->whereColumn('id', '!=', 'm_sponsor')
->whereNull('deleted_at')
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
$dashboardService = app(BackofficeDashboardService::class);
$teamOverview = app(TeamAboOverviewService::class);
return array_merge(
[
'month' => $month,
'year' => $year,
'customer_points' => $salesVolume ? (float) $salesVolume->getPointsKPSum() : 0.0,
'team_payline_points' => $paylinePoints,
'payline_is_live' => $paylineIsLive,
'direct_new_partners' => $dashboardService->newPartnersCount($firstlineIds, $month, $year),
],
$teamOverview->teamKpis($user, $month, $year),
$teamOverview->personalAboKpis($user, $month, $year)
);
}
/**
* Team-Payline-Punkte: aus dem UserBusiness-Snapshot (abgeschlossene Monate) oder
* – für den laufenden Monat – einmalig live berechnet und für Folge-Aufrufe gecacht.
*
* @return array{0: float, 1: bool} [Punkte, istLive]
*/
private function teamPaylinePoints(User $user, int $month, int $year): array
{
$userBusiness = UserBusiness::where('user_id', $user->id)
->where('month', $month)
->where('year', $year)
->first();
if ($userBusiness) {
return [(float) ($userBusiness->payline_points ?? 0), false];
}
$points = Cache::remember(
"home_payline_{$user->id}_{$month}_{$year}",
now()->addHours(6),
fn (): float => $this->calculatePaylineLive($user, $month, $year)
);
return [(float) $points, true];
}
private function calculatePaylineLive(User $user, int $month, int $year): float
{
try {
$treeCalcBot = new TreeCalcBot($month, $year, 'member');
$treeCalcBot->initBusinesslUserDetail($user);
if ($treeCalcBot->business_user) {
return (float) ($treeCalcBot->business_user->payline_points ?? 0);
}
} catch (\Throwable $e) {
\Log::warning("Live-Berechnung Payline-Punkte fehlgeschlagen für User {$user->id}: ".$e->getMessage());
}
return 0.0;
}
public function loadingModal()
{

View file

@ -201,7 +201,11 @@ class BackofficeStatisticsController extends Controller
*/
private function csvHeaders(string $metric): array
{
if (in_array($metric, ['team_partner_abos', 'team_customer_abos'], true)) {
if ($metric === 'team_customer_abos') {
return ['Teampartner', 'E-Mail', 'Karriere-Level', 'Anzahl Kundenabos', 'Volumenpunkte gesamt'];
}
if ($metric === 'team_partner_abos') {
return ['Name', 'E-Mail', 'Karriere-Level', 'Berater', 'Abo-Punkte', 'Status', 'Status-Grund', 'Besteht seit', 'Naechste Ausfuehrung', 'Lieferungen'];
}
@ -218,7 +222,17 @@ class BackofficeStatisticsController extends Controller
*/
private function csvRow(string $metric, array $row): array
{
if (in_array($metric, ['team_partner_abos', 'team_customer_abos'], true)) {
if ($metric === 'team_customer_abos') {
return [
$row['name'] ?? '',
$row['email'] ?? '',
$row['career_level'] ?? '',
$row['abo_count'] ?? 0,
$row['points'] ?? 0,
];
}
if ($metric === 'team_partner_abos') {
return [
$row['name'] ?? '',
$row['email'] ?? '',
@ -266,7 +280,11 @@ class BackofficeStatisticsController extends Controller
$metric = $details['metric'];
$summary = $details['summary'];
if (in_array($metric, ['team_partner_abos', 'team_customer_abos'], true)) {
if ($metric === 'team_customer_abos') {
return ['Summe', $summary['count'].' Teampartner', '', $summary['abo_count'], $summary['points']];
}
if ($metric === 'team_partner_abos') {
return ['Summe', $summary['count'].' Eintraege', '', '', $summary['points'], '', '', '', '', $summary['deliveries']];
}

View file

@ -53,7 +53,7 @@ class TeamAboOverviewService
/**
* Leichtgewichtige Kennzahlen für das Dashboard (ohne Punkte-/Mitglieder-Details).
*
* @return array{team_total_abos: int, team_partner_abos: int, team_customer_abos: int, shop_1000: int}
* @return array{team_total_abos: int, team_partner_abos: int, team_partner_abos_new: int, team_customer_abos: int, team_customer_abos_new: int, shop_1000: int, new_partners: int}
*/
public function teamKpis(User $user, int $month, int $year): array
{
@ -63,8 +63,11 @@ class TeamAboOverviewService
return [
'team_total_abos' => 0,
'team_partner_abos' => 0,
'team_partner_abos_new' => 0,
'team_customer_abos' => 0,
'team_customer_abos_new' => 0,
'shop_1000' => 0,
'new_partners' => 0,
];
}
@ -74,8 +77,29 @@ class TeamAboOverviewService
return [
'team_total_abos' => $partnerAbos + $customerAbos,
'team_partner_abos' => $partnerAbos,
'team_partner_abos_new' => $this->newAboCount($teamUserIds, 'user_id', 'me', $month, $year),
'team_customer_abos' => $customerAbos,
'team_customer_abos_new' => $this->newAboCount($teamUserIds, 'member_id', 'ot', $month, $year),
'shop_1000' => $this->dashboardService->shop1000Count($teamUserIds, $month, $year),
'new_partners' => $this->dashboardService->newPartnersCount($teamUserIds, $month, $year),
];
}
/**
* Persönliche Abo-Kennzahlen des Beraters selbst (eigenes Abo + eigene Kundenabos),
* konsistent mit der activeAbo-Definition. Inklusive der im Monat neu hinzugekommenen.
*
* @return array{own_abos: int, own_abos_new: int, own_customer_abos: int, own_customer_abos_new: int}
*/
public function personalAboKpis(User $user, int $month, int $year): array
{
$userId = (int) $user->id;
return [
'own_abos' => UserAbo::query()->activeAbo()->where('is_for', 'me')->where('user_id', $userId)->count(),
'own_abos_new' => $this->newAboCount([$userId], 'user_id', 'me', $month, $year),
'own_customer_abos' => UserAbo::query()->activeAbo()->where('is_for', 'ot')->where('member_id', $userId)->count(),
'own_customer_abos_new' => $this->newAboCount([$userId], 'member_id', 'ot', $month, $year),
];
}

View file

@ -346,6 +346,30 @@ class BackofficeDashboardService
];
}
/**
* Anzahl der Neupartner (aktiver Account, im Monat aktiv geworden).
* Konsistent mit der Drilldown-Liste {@see BackofficeDrilldownService::newPartnerRows()}.
*
* @param int[] $userIds
*/
public function newPartnersCount(array $userIds, int $month, int $year): int
{
if ($userIds === []) {
return 0;
}
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
return User::query()
->whereIn('id', $userIds)
->whereNotNull('m_level')
->whereNotNull('payment_account')
->where('payment_account', '>', now())
->whereBetween('active_date', [$startDate, $endDate])
->count();
}
/**
* Anzahl der Berater mit >= 1000 Punkten Kundenumsatz (KP + Shop) im Monat.
*

View file

@ -47,12 +47,13 @@ class BackofficeDrilldownService
/**
* @param array<int, array<string, mixed>> $rows
* @return array{count: int, points: float, own_points: float, external_points: float, customer_abo_points: float, customer_single_order_points: float, customer_other_points: float, total_points: float, deliveries: int}
* @return array{count: int, abo_count: int, points: float, own_points: float, external_points: float, customer_abo_points: float, customer_single_order_points: float, customer_other_points: float, total_points: float, deliveries: int}
*/
private function summary(array $rows): array
{
return [
'count' => count($rows),
'abo_count' => (int) collect($rows)->sum('abo_count'),
'points' => (float) collect($rows)->sum('points'),
'own_points' => (float) collect($rows)->sum('own_points'),
'external_points' => (float) collect($rows)->sum('external_points'),
@ -157,37 +158,47 @@ class BackofficeDrilldownService
}
/**
* Teamkundenabos pro Teampartner (member_id) gruppiert: je Teampartner die
* Anzahl der Kundenabos, die Gesamt-Volumenpunkte sowie die einzelnen Abos
* mit Punkten je Abo. Kundennamen bleiben aus Datenschutzgründen außen vor.
*
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function customerAboRows(array $userIds, int $month, int $year): array
{
return $this->activeAboQuery()
->with(['member.account', 'member.user_level', 'user.account', 'user_abo_items.product', 'user_abo_orders.shopping_order.shopping_payments.payment_transactions'])
->with(['member.account', 'member.user_level', 'user_abo_items.product'])
->whereIn('member_id', $userIds)
->where('is_for', 'ot')
->orderBy('member_id')
->orderBy('next_date')
->get()
->map(fn (UserAbo $abo) => [
'type' => 'customer_abo',
'abo_id' => $abo->id,
'user_id' => $abo->user_id,
'member_id' => $abo->member_id,
'name' => $abo->user ? $this->userName($abo->user) : ($abo->email ?: '#'.$abo->user_id),
'email' => $abo->email ?: $abo->user?->email,
'consultant_name' => $abo->member ? $this->userName($abo->member) : '#'.$abo->member_id,
'career_level' => $abo->member ? $this->careerLevel($abo->member) : '-',
'points' => $abo->getTotalPoints(),
'start_date' => $this->formatDate($abo->getRawOriginal('start_date')),
'is_new_this_month' => $this->isAboNewInMonth($abo, $month, $year),
'next_date' => $this->formatDate($abo->next_date),
'deliveries' => $abo->getCountOrders(),
'status' => $abo->status,
'status_label' => $abo->getStatusType(),
'status_badge' => $abo->getStatusFormated(),
'status_reason' => $this->aboStatusReason($abo),
])
->groupBy(fn (UserAbo $abo) => (int) $abo->member_id)
->map(function ($abos) use ($month, $year) {
$consultant = $abos->first()->member;
$memberId = (int) $abos->first()->member_id;
$aboItems = $abos->map(fn (UserAbo $abo) => [
'abo_id' => $abo->id,
'points' => (float) $abo->getTotalPoints(),
'start_date' => $this->formatDate($abo->getRawOriginal('start_date')),
'next_date' => $this->formatDate($abo->next_date),
'is_new_this_month' => $this->isAboNewInMonth($abo, $month, $year),
])->values()->all();
return [
'type' => 'customer_abo_group',
'consultant_id' => $memberId,
'name' => $consultant ? $this->userName($consultant) : '#'.$memberId,
'email' => $consultant?->email,
'career_level' => $consultant ? $this->careerLevel($consultant) : '-',
'abo_count' => $abos->count(),
'points' => (float) $abos->sum(fn (UserAbo $abo) => $abo->getTotalPoints()),
'abos' => $aboItems,
];
})
->sortByDesc('points')
->values()
->all();
}

View file

@ -209,6 +209,7 @@ return [
'yes' => 'Ja',
'no' => 'Nein',
'shop_1000' => '1000-Punkte-Shops',
'new_partners' => 'Neupartner',
'next_execution' => 'Nächste Ausführung',
'executions_count' => 'Ausführungen',
'abo_count_points' => 'Punkte',

View file

@ -55,6 +55,12 @@ return [
'team_new_partners' => 'Neupartner im Team',
'customer_subscriptions' => 'Kundenabos',
'team_subscriptions' => 'Teamabos',
'team_customer_subscriptions' => 'Team-Kundenabos',
'own_subscriptions' => 'Eigene Abos',
'own_customer_subscriptions' => 'Eigene Kundenabos',
'stats_group_turnover' => 'Umsatz',
'stats_group_new_partners' => 'Neupartner',
'stats_group_subscriptions' => 'Abos',
'own' => 'Eigene',
'live_calculation_hint' => 'Live-Berechnung (noch nicht abgeschlossen)',
'live_calculation_hint_text' => 'Wird erst zum Monatsende berechnet.',

View file

@ -210,6 +210,7 @@ return [
'yes' => 'Yes',
'no' => 'No',
'shop_1000' => '1000-Point Shops',
'new_partners' => 'New partners',
'next_execution' => 'Next execution',
'executions_count' => 'Executions',
'abo_count_points' => 'Points',

View file

@ -54,6 +54,12 @@ return [
'team_new_partners' => 'New Partners in Team',
'customer_subscriptions' => 'Customer Subscriptions',
'team_subscriptions' => 'Team Subscriptions',
'team_customer_subscriptions' => 'Team Customer Subscriptions',
'own_subscriptions' => 'Own Subscriptions',
'own_customer_subscriptions' => 'Own Customer Subscriptions',
'stats_group_turnover' => 'Turnover',
'stats_group_new_partners' => 'New Partners',
'stats_group_subscriptions' => 'Subscriptions',
'own' => 'Own',
'live_calculation_hint' => 'Live calculation (not yet finalized)',
'live_calculation_hint_text' => 'Will be calculated at the end of the month.',

View file

@ -183,6 +183,7 @@ return [
'yes' => 'Sí',
'no' => 'No',
'shop_1000' => 'Tiendas de 1000 puntos',
'new_partners' => 'Nuevos socios',
'next_execution' => 'Próxima ejecución',
'executions_count' => 'Ejecuciones',
'abo_count_points' => 'Puntos',

View file

@ -54,6 +54,12 @@ return [
'team_new_partners' => 'Nuevos socios en el equipo',
'customer_subscriptions' => 'Suscripciones de clientes',
'team_subscriptions' => 'Suscripciones del equipo',
'team_customer_subscriptions' => 'Suscripciones de clientes del equipo',
'own_subscriptions' => 'Suscripciones propias',
'own_customer_subscriptions' => 'Suscripciones de clientes propias',
'stats_group_turnover' => 'Volumen',
'stats_group_new_partners' => 'Nuevos socios',
'stats_group_subscriptions' => 'Suscripciones',
'own' => 'Propio',
'live_calculation_hint' => 'Cálculo en vivo (no finalizado)',
'live_calculation_hint_text' => 'Se calculará al final del mes.',

View file

@ -182,6 +182,7 @@ return [
'yes' => 'Oui',
'no' => 'Non',
'shop_1000' => 'Boutiques 1000 points',
'new_partners' => 'Nouveaux partenaires',
'next_execution' => 'Prochaine exécution',
'executions_count' => 'Exécutions',
'abo_count_points' => 'Points',

View file

@ -55,6 +55,12 @@ return [
'team_new_partners' => 'Nouveaux partenaires dans l’équipe',
'customer_subscriptions' => 'Abonnements clients',
'team_subscriptions' => 'Abonnements équipe',
'team_customer_subscriptions' => 'Abonnements clients équipe',
'own_subscriptions' => 'Abonnements propres',
'own_customer_subscriptions' => 'Abonnements clients propres',
'stats_group_turnover' => 'Chiffre d\'affaires',
'stats_group_new_partners' => 'Nouveaux partenaires',
'stats_group_subscriptions' => 'Abonnements',
'own' => 'Propres',
'live_calculation_hint' => 'Calcul en direct (pas encore terminé)',
'live_calculation_hint_text' => 'Ne sera calculé qu’à la fin du mois.',

View file

@ -1,33 +0,0 @@
@if (!empty($teamAboKpis) && ($teamAboKpis['team_total_abos'] > 0 || $teamAboKpis['shop_1000'] > 0))
<div class="col-xl-6">
<div class="card mb-4">
<h5 class="card-header with-elements border-bottom">
<div class="card-header-title">
<i class="ion ion-md-refresh-circle text-primary mr-1"></i> {{ __('abo.team_subscriptions') }}
</div>
</h5>
<div class="card-body">
<div class="row text-center">
<div class="col-4">
<a href="{{ route('user_abos_team_show') }}" class="text-decoration-none">
<div class="display-4 font-weight-bold text-primary">{{ $teamAboKpis['team_total_abos'] }}</div>
<div class="text-muted small">{{ __('abo.team_total_abos') }}</div>
</a>
</div>
<div class="col-4">
<a href="{{ route('user_abos_team_show') }}" class="text-decoration-none">
<div class="display-4 font-weight-bold">{{ $teamAboKpis['team_customer_abos'] }}</div>
<div class="text-muted small">{{ __('abo.team_customer_abos') }}</div>
</a>
</div>
<div class="col-4">
<a href="{{ route('user_abos_team_show') }}" class="text-decoration-none">
<div class="display-4 font-weight-bold text-success">{{ $teamAboKpis['shop_1000'] }}</div>
<div class="text-muted small">{{ __('abo.shop_1000') }}</div>
</a>
</div>
</div>
</div>
</div>
</div>
@endif

View file

@ -1,261 +1,168 @@
@php
$selectedMonth = request()->get('stats_month', date('n'));
$selectedYear = request()->get('stats_year', date('Y'));
@if (! empty($monthlyStats))
@php
$s = $monthlyStats;
$isVip = auth()->user()?->isVIP() ?? false;
$m = $s['month'];
$y = $s['year'];
// Start- und Enddatum für den Monat
$startDate = \Carbon\Carbon::createFromDate($selectedYear, $selectedMonth, 1)->startOfMonth();
$endDate = \Carbon\Carbon::createFromDate($selectedYear, $selectedMonth, 1)->endOfMonth();
$teamShow = route('user_abos_team_show');
$shop1000Link = $isVip
? route('user_backoffice_statistics_details', ['line' => 0, 'metric' => 'shop_1000', 'month' => $m, 'year' => $y])
: $teamShow;
$newPartnersLink = $isVip
? route('user_backoffice_statistics_details', ['line' => 0, 'metric' => 'new_partners', 'month' => $m, 'year' => $y])
: null;
// UserBusiness für den Monat laden (enthält Payline-Punkte)
$userBusiness = \App\Models\UserBusiness::where('user_id', $user->id)
->where('month', $selectedMonth)
->where('year', $selectedYear)
->first();
$fmt = fn ($value) => number_format((float) $value, 0, ',', '.');
// UserSalesVolume für KP-Punkte
$userSalesVolume = $user->getUserSalesVolume($selectedMonth, $selectedYear, 'first');
$clusters = [
[
'title' => __('home.stats_group_turnover'),
'col' => 'col-md-6 col-lg-4',
'tiles' => [
[
'label' => __('home.customer_turnover_points'), 'value' => $fmt($s['customer_points']),
'icon' => 'ion-md-cart', 'color' => 'primary', 'link' => null, 'new' => null, 'live' => false,
],
[
'label' => __('home.team_turnover_points') . ' (Payline)', 'value' => $fmt($s['team_payline_points']),
'icon' => 'ion-md-people', 'color' => 'success', 'link' => null, 'new' => null, 'live' => $s['payline_is_live'],
],
[
'label' => __('abo.shop_1000'), 'value' => $fmt($s['shop_1000']),
'icon' => 'ion-md-trophy', 'color' => 'success', 'link' => $shop1000Link, 'new' => null, 'live' => false,
],
],
],
[
'title' => __('home.stats_group_new_partners'),
'col' => 'col-md-6',
'tiles' => [
[
'label' => __('home.direct_new_partners'), 'value' => $fmt($s['direct_new_partners']),
'icon' => 'ion-md-person-add', 'color' => 'info', 'link' => null, 'new' => null, 'live' => false,
],
[
'label' => __('home.team_new_partners'), 'value' => $fmt($s['new_partners']),
'icon' => 'ion-md-contacts', 'color' => 'warning', 'link' => $newPartnersLink, 'new' => null, 'live' => false,
],
],
],
[
'title' => __('home.stats_group_subscriptions'),
'col' => 'col-sm-6 col-lg-3',
'tiles' => [
[
'label' => __('home.own_subscriptions'), 'value' => $fmt($s['own_abos']),
'icon' => 'ion-md-person', 'color' => 'secondary', 'link' => route('user_abos', 'me'), 'new' => $s['own_abos_new'], 'live' => false,
],
[
'label' => __('home.own_customer_subscriptions'), 'value' => $fmt($s['own_customer_abos']),
'icon' => 'ion-md-repeat', 'color' => 'secondary', 'link' => route('user_abos', 'ot'), 'new' => $s['own_customer_abos_new'], 'live' => false,
],
[
'label' => __('home.team_subscriptions'), 'value' => $fmt($s['team_partner_abos']),
'icon' => 'ion-md-sync', 'color' => 'dark', 'link' => $teamShow, 'new' => $s['team_partner_abos_new'], 'live' => false,
],
[
'label' => __('home.team_customer_subscriptions'), 'value' => $fmt($s['team_customer_abos']),
'icon' => 'ion-md-git-network', 'color' => 'dark', 'link' => $teamShow, 'new' => $s['team_customer_abos_new'], 'live' => false,
],
],
],
];
@endphp
// Kunden-Umsatz Punkte (KP - Eigene Punkte + Shop)
$customerPoints = $userSalesVolume ? $userSalesVolume->getPointsKPSum() : 0;
// Team-Umsatz Punkte (Payline) - Live-Berechnung wenn UserBusiness nicht verfügbar
$teamPaylinePoints = 0;
$isLiveCalculation = false;
if ($userBusiness) {
// Gespeicherte Daten aus UserBusiness verwenden
$teamPaylinePoints = $userBusiness->payline_points ?? 0;
} else {
// Live-Berechnung über TreeCalcBot (für aktuellen Monat)
$isLiveCalculation = true;
try {
$treeCalcBot = new \App\Services\BusinessPlan\TreeCalcBot(
(int) $selectedMonth,
(int) $selectedYear,
'member',
);
$treeCalcBot->initBusinesslUserDetail($user);
if ($treeCalcBot->business_user) {
$teamPaylinePoints = $treeCalcBot->business_user->payline_points ?? 0;
}
} catch (\Exception $e) {
\Log::warning("Live-Berechnung Payline-Punkte fehlgeschlagen für User {$user->id}: " . $e->getMessage());
$teamPaylinePoints = 0;
}
}
// Direkte Neupartner (Firstlines) in diesem Monat
$directNewPartners = \App\User::where('m_sponsor', $user->id)
->whereColumn('id', '!=', 'm_sponsor')
->where('active_date', '>=', $startDate)
->where('active_date', '<=', $endDate)
->where('m_level', '!=', null)
->where('payment_account', '!=', null)
->count();
// ===== REKURSIVE TEAM-STRUKTUR =====
// Hilfsfunktion um alle Team-Mitglieder rekursiv zu sammeln (inkl. Sponsor-Kette)
$getAllTeamMemberIds = function ($sponsorId, $maxDepth = 20) use (&$getAllTeamMemberIds) {
static $cache = [];
if (isset($cache[$sponsorId])) {
return $cache[$sponsorId];
}
$teamIds = [];
$currentLevel = [$sponsorId];
$depth = 0;
while (!empty($currentLevel) && $depth < $maxDepth) {
$children = \App\User::whereIn('m_sponsor', $currentLevel)
->whereColumn('id', '!=', 'm_sponsor')
->where('m_level', '!=', null)
->where('payment_account', '!=', null)
->whereNull('deleted_at')
->pluck('id')
->toArray();
$teamIds = array_merge($teamIds, $children);
$currentLevel = $children;
$depth++;
}
$cache[$sponsorId] = $teamIds;
return $teamIds;
};
// Alle Team-Mitglieder rekursiv laden
$allTeamMemberIds = $getAllTeamMemberIds($user->id);
// Neupartner im gesamten Team (rekursiv im Marketingplan)
$teamNewPartners = 0;
if (!empty($allTeamMemberIds)) {
$teamNewPartners = \App\User::whereIn('id', $allTeamMemberIds)
->where('active_date', '>=', $startDate)
->where('active_date', '<=', $endDate)
->count();
}
// Kundenabos (is_for = 'customer' oder shop-bezogen)
$customerAbos = \App\Models\UserAbo::where('member_id', $user->id)
->where('is_for', 'ot')
->whereIn('status', [1, 2]) // aktive Abos
->where('active', true)
->count();
// Eigene Abos
$ownAbos = \App\Models\UserAbo::where('user_id', $user->id)
->where('is_for', 'me')
->whereIn('status', [1, 2])
->where('active', true)
->count();
// Team-Abos (rekursiv über gesamtes Team im Marketingplan)
$teamAbos = 0;
if (!empty($allTeamMemberIds)) {
$teamAbos = \App\Models\UserAbo::whereIn('user_id', $allTeamMemberIds)
->where('is_for', 'me')
->whereIn('status', [1, 2])
->where('active', true)
->count();
}
@endphp
@if ($user->isActiveAccount())
<div class="d-flex col-xl-12 align-items-stretch">
<div class="card w-100 mb-4">
<h5 class="card-header with-elements d-flex justify-content-between align-items-center flex-wrap">
<div class="card-header-title">
<i class="ion ion-md-stats mr-2"></i>{{ __('home.monthly_statistics') }} -
{{ HTMLHelper::getMonth($selectedMonth) }} {{ $selectedYear }}
{{ HTMLHelper::getMonth($m) }} {{ $y }}
</div>
<div class="d-flex align-items-center mt-2 mt-md-0">
<select id="stats-month-filter" class="form-control custom-select form-control-sm mr-2"
style="width: auto;">
@foreach (HTMLHelper::getTransMonths() as $monthNum => $monthName)
<option value="{{ $monthNum }}" {{ $selectedMonth == $monthNum ? 'selected' : '' }}>
<option value="{{ $monthNum }}" {{ $m == $monthNum ? 'selected' : '' }}>
{{ $monthName }}</option>
@endforeach
</select>
<select id="stats-year-filter" class="form-control custom-select form-control-sm"
style="width: auto;">
@foreach (HTMLHelper::getYearRange() as $year)
<option value="{{ $year }}" {{ $selectedYear == $year ? 'selected' : '' }}>
<option value="{{ $year }}" {{ $y == $year ? 'selected' : '' }}>
{{ $year }}</option>
@endforeach
</select>
</div>
</h5>
<div class="card-body">
<div class="row">
{{-- Kunden-Umsatz Punkte --}}
<div class="col-md-6 col-lg-4 mb-4">
<div class="d-flex align-items-center">
<div class="bg-primary text-white rounded-circle d-flex align-items-center justify-content-center"
style="width: 50px; height: 50px;">
<i class="ion ion-md-cart" style="font-size: 1.5rem;"></i>
</div>
<div class="ml-3">
<div class="text-muted small">{{ __('home.customer_turnover_points') }}</div>
<div class="font-weight-bold text-large">
{{ number_format($customerPoints, 0, ',', '.') }}</div>
</div>
</div>
</div>
{{-- Team-Umsatz Punkte (Payline) --}}
<div class="col-md-6 col-lg-4 mb-4">
<div class="d-flex align-items-center">
<div class="bg-success text-white rounded-circle d-flex align-items-center justify-content-center"
style="width: 50px; height: 50px;">
<i class="ion ion-md-people" style="font-size: 1.5rem;"></i>
</div>
<div class="ml-3">
<div class="text-muted small">
{{ __('home.team_turnover_points') }} (Payline)
@if ($isLiveCalculation)
<span class="badge badge-pill badge-info ml-1"
title="{{ __('home.live_calculation_hint') }}">Live</span>
@endif
@foreach ($clusters as $cluster)
<h6 class="text-muted text-uppercase font-weight-bold small mb-3 {{ ! $loop->first ? 'mt-2 pt-3 border-top' : '' }}">
{{ $cluster['title'] }}
</h6>
<div class="row {{ ! $loop->last ? 'mb-2' : '' }}">
@foreach ($cluster['tiles'] as $tile)
<div class="{{ $cluster['col'] }} mb-4">
@if ($tile['link'])
<a href="{{ $tile['link'] }}"
class="d-flex align-items-center text-decoration-none text-body dashboard-stat-link">
@else
<div class="d-flex align-items-center">
@endif
<div class="bg-{{ $tile['color'] }} text-white rounded-circle d-flex align-items-center justify-content-center"
style="width: 50px; height: 50px; flex: 0 0 auto;">
<i class="ion {{ $tile['icon'] }}" style="font-size: 1.5rem;"></i>
</div>
<div class="font-weight-bold text-large">
{{ number_format($teamPaylinePoints, 0, ',', '.') }}
<div class="ml-3">
<div class="text-muted small">
{{ $tile['label'] }}
@if ($tile['live'])
<span class="badge badge-pill badge-info ml-1"
title="{{ __('home.live_calculation_hint') }}">Live</span>
@endif
@if ($tile['link'])
<i class="ion ion-md-open text-primary ml-1" style="font-size: .8rem;"></i>
@endif
</div>
<div class="font-weight-bold text-large">
{{ $tile['value'] }}
@if (! empty($tile['new']) && $tile['new'] > 0)
<span class="badge badge-pill badge-success ml-1"
title="{{ __('abo.new_this_month') }}">+{{ $tile['new'] }}</span>
@endif
</div>
</div>
@if ($tile['link'])
</a>
@else
</div>
@endif
</div>
</div>
@endforeach
</div>
{{-- Direkte Neupartner --}}
<div class="col-md-6 col-lg-4 mb-4">
<div class="d-flex align-items-center">
<div class="bg-info text-white rounded-circle d-flex align-items-center justify-content-center"
style="width: 50px; height: 50px;">
<i class="ion ion-md-person-add" style="font-size: 1.5rem;"></i>
</div>
<div class="ml-3">
<div class="text-muted small">{{ __('home.direct_new_partners') }}
</div>
<div class="font-weight-bold text-large">{{ $directNewPartners }}</div>
</div>
</div>
</div>
{{-- Neupartner im Team --}}
<div class="col-md-6 col-lg-4 mb-4">
<div class="d-flex align-items-center">
<div class="bg-warning text-white rounded-circle d-flex align-items-center justify-content-center"
style="width: 50px; height: 50px;">
<i class="ion ion-md-contacts" style="font-size: 1.5rem;"></i>
</div>
<div class="ml-3">
<div class="text-muted small">{{ __('home.team_new_partners') }}</div>
<div class="font-weight-bold text-large">{{ $teamNewPartners }}</div>
</div>
</div>
</div>
{{-- Kundenabos --}}
<div class="col-md-6 col-lg-4 mb-4">
<div class="d-flex align-items-center">
<div class="bg-secondary text-white rounded-circle d-flex align-items-center justify-content-center"
style="width: 50px; height: 50px;">
<i class="ion ion-md-repeat" style="font-size: 1.5rem;"></i>
</div>
<div class="ml-3">
<div class="text-muted small">{{ __('home.customer_subscriptions') }}
</div>
<div class="font-weight-bold text-large">{{ $customerAbos }} +
{{ $ownAbos }}
{{ __('home.own') }}</div>
</div>
</div>
</div>
{{-- Team-Abos --}}
<div class="col-md-6 col-lg-4 mb-4">
<div class="d-flex align-items-center">
<div class="bg-dark text-white rounded-circle d-flex align-items-center justify-content-center"
style="width: 50px; height: 50px;">
<i class="ion ion-md-sync" style="font-size: 1.5rem;"></i>
</div>
<div class="ml-3">
<div class="text-muted small">{{ __('home.team_subscriptions') }}</div>
<div class="font-weight-bold text-large">{{ $teamAbos }}</div>
</div>
</div>
</div>
</div>
@endforeach
</div>
</div>
</div>
<style>
.dashboard-stat-link {
transition: transform .15s ease-in-out;
}
.dashboard-stat-link:hover {
transform: translateY(-2px);
}
.dashboard-stat-link:hover .text-large {
color: #4e73df;
}
</style>
<script>
$(document).ready(function() {
// Filter für Monat/Jahr - Statistiken
function updateStatsFilter() {
var month = $('#stats-month-filter').val();
var year = $('#stats-year-filter').val();

View file

@ -32,8 +32,6 @@
@include('dashboard._abo_onetime')
@if (Auth::user()->isActiveAccount())
@include('dashboard._abo_team_kpis')
@include('dashboard._news', ['newsColClass' => empty($oneTimeAbo) ? 'col-xl-12' : 'col-xl-6'])
@include('dashboard._incentive')

View file

@ -78,7 +78,10 @@
<th data-sortable="true"><span class="sortable-label">Name <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th data-sortable="true"><span class="sortable-label">E-Mail <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th data-sortable="true"><span class="sortable-label">Karriere-Level <i class="ion ion-ios-swap sort-icon"></i></span></th>
@if (in_array($details['metric'], ['team_partner_abos', 'team_customer_abos'], true))
@if ($details['metric'] === 'team_customer_abos')
<th class="text-right" data-sortable="true" data-sort-type="number"><span class="sortable-label justify-content-end">Kundenabos <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th class="text-right" data-sortable="true" data-sort-type="number"><span class="sortable-label justify-content-end">Volumenpunkte gesamt <i class="ion ion-ios-swap sort-icon"></i></span></th>
@elseif ($details['metric'] === 'team_partner_abos')
<th data-sortable="true"><span class="sortable-label">Berater <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th class="text-right" data-sortable="true" data-sort-type="number"><span class="sortable-label justify-content-end">Abo-Punkte <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th data-sortable="true"><span class="sortable-label">Status <i class="ion ion-ios-swap sort-icon"></i></span></th>
@ -105,7 +108,32 @@
<td class="font-weight-semibold">{{ $row['name'] }}</td>
<td>{{ $row['email'] ?? '-' }}</td>
<td>{{ $row['career_level'] ?? '-' }}</td>
@if (in_array($details['metric'], ['team_partner_abos', 'team_customer_abos'], true))
@if ($details['metric'] === 'team_customer_abos')
<td class="text-right">{{ number_format($row['abo_count'], 0, ',', '.') }}</td>
<td class="text-right">
<span class="font-weight-bold">{{ \App\Services\Util::formatNumber($row['points']) }}</span>
@if (! empty($row['abos']))
<a class="d-block small" data-toggle="collapse"
href="#customer-abos-{{ $loop->index }}" role="button">
Abos anzeigen
</a>
<div class="collapse text-left" id="customer-abos-{{ $loop->index }}">
<ul class="list-unstyled small text-muted mt-2 mb-0">
@foreach ($row['abos'] as $aboItem)
<li>
{{ $aboItem['start_date'] ?? '-' }} →
{{ $aboItem['next_date'] ?? '-' }}:
<span class="font-weight-semibold">{{ \App\Services\Util::formatNumber($aboItem['points']) }}</span> P.
@if (! empty($aboItem['is_new_this_month']))
<span class="badge badge-success ml-1">Neu</span>
@endif
</li>
@endforeach
</ul>
</div>
@endif
</td>
@elseif ($details['metric'] === 'team_partner_abos')
<td>{{ $row['consultant_name'] ?? $row['name'] }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($row['points']) }}</td>
<td>
@ -149,7 +177,10 @@
<td>Summe</td>
<td class="text-muted">{{ number_format($details['summary']['count'], 0, ',', '.') }} Einträge</td>
<td></td>
@if (in_array($details['metric'], ['team_partner_abos', 'team_customer_abos'], true))
@if ($details['metric'] === 'team_customer_abos')
<td class="text-right">{{ number_format($details['summary']['abo_count'], 0, ',', '.') }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['points']) }}</td>
@elseif ($details['metric'] === 'team_partner_abos')
<td></td>
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['points']) }}</td>
<td></td>

View file

@ -61,33 +61,20 @@
@php
$clickableMetrics = [
'consultants',
'new_partners',
'total_points',
'team_partner_abos',
'team_customer_abos',
'own_points',
'external_points',
'customer_abo_points',
'customer_single_order_points',
'customer_other_points',
'total_points',
'shop_1000',
];
$metricHeadings = [
'consultants' => 'Berater',
'total_points' => 'Umsatz (Punkte)',
'team_partner_abos' => 'Teamabos',
'team_customer_abos' => 'Teamkundenabos',
];
$formatValue = function ($value, string $metric): string {
if (
in_array(
$metric,
[
'own_points',
'external_points',
'customer_abo_points',
'customer_single_order_points',
'customer_other_points',
'total_points',
],
true,
)
) {
if ($metric === 'total_points') {
return \App\Services\Util::formatNumber($value);
}
@ -96,13 +83,61 @@
$newAboMetric = function (array $row, string $metric): ?string {
return match ($metric) {
'consultants' => 'new_partners',
'team_partner_abos' => 'team_partner_abos_new',
'team_customer_abos' => 'team_customer_abos_new',
default => null,
};
};
$selectedMonthName = $filterMonths[(int) $selectedMonth] ?? $selectedMonth;
@endphp
<div class="row mb-4">
<div class="col-md-6 mb-3 mb-md-0">
<a href="{{ route('user_backoffice_statistics_details', [
'line' => 0,
'metric' => 'new_partners',
'month' => $selectedMonth,
'year' => $selectedYear,
]) }}" class="card h-100 text-decoration-none backoffice-kpi-card">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<div class="text-muted small">Neupartner ({{ $selectedMonthName }} {{ $selectedYear }})</div>
<div class="display-4 font-weight-bold text-primary">
{{ number_format((float) ($statistics['totals']['new_partners'] ?? 0), 0, ',', '.') }}
</div>
<div class="text-primary small font-weight-semibold mt-1">
<span class="ion ion-md-eye mr-1"></span> Liste anzeigen
</div>
</div>
<span class="ion ion-md-person-add display-4 text-muted"></span>
</div>
</a>
</div>
<div class="col-md-6">
<a href="{{ route('user_backoffice_statistics_details', [
'line' => 0,
'metric' => 'shop_1000',
'month' => $selectedMonth,
'year' => $selectedYear,
]) }}" class="card h-100 text-decoration-none backoffice-kpi-card">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<div class="text-muted small">1000-Punkte-Shops ({{ $selectedMonthName }} {{ $selectedYear }})</div>
<div class="display-4 font-weight-bold text-success">
{{ number_format((float) ($statistics['totals']['shop_1000'] ?? 0), 0, ',', '.') }}
</div>
<div class="text-primary small font-weight-semibold mt-1">
<span class="ion ion-md-eye mr-1"></span> Liste anzeigen
</div>
</div>
<span class="ion ion-md-trophy display-4 text-muted"></span>
</div>
</a>
</div>
</div>
<div class="card mb-4">
<div class="card-body p-0">
<div class="table-responsive">
@ -110,17 +145,9 @@
<thead>
<tr>
<th>Linie</th>
<th class="text-right">Berater</th>
<th class="text-right">Neupartner</th>
<th class="text-right">Teamabos</th>
<th class="text-right">Teamkundenabos</th>
<th class="text-right">Eigenpunkte</th>
<th class="text-right">Externe Punkte</th>
<th class="text-right">Kundenabo-Punkte</th>
<th class="text-right">Einzelbestellungs-Punkte</th>
<th class="text-right">Sonstige Kundenpunkte</th>
<th class="text-right">Gesamtpunkte</th>
<th class="text-right">1000 Punkte Shop</th>
@foreach ($clickableMetrics as $metric)
<th class="text-right">{{ $metricHeadings[$metric] }}</th>
@endforeach
</tr>
</thead>
<tbody>
@ -196,7 +223,8 @@
aus der jeweiligen Linie zugeordnet sind.
</p>
<p class="text-muted mb-0">
Externe Punkte kommen aktuell aus `month_shop_points`, Eigenpunkte aus `month_KP_points`.
Umsatz = Gesamtpunkte der Linie (Eigen- und Kundenpunkte zusammen). Ein Klick auf eine Zahl
öffnet die zugehörige Namensliste.
</p>
</div>
</div>
@ -215,6 +243,19 @@
</div>
<style>
.backoffice-kpi-card {
cursor: pointer;
transition: box-shadow .15s ease-in-out, transform .15s ease-in-out, border-color .15s ease-in-out;
border: 1px solid rgba(0, 0, 0, .08);
}
.backoffice-kpi-card:hover,
.backoffice-kpi-card:focus {
border-color: #4e73df;
box-shadow: 0 .25rem .75rem rgba(78, 115, 223, .25);
transform: translateY(-2px);
}
.backoffice-statistics-click-badge,
.backoffice-statistics-empty-badge {
display: inline-block;

View file

@ -318,6 +318,16 @@ it('rendert eine Suche in der Detailtabelle', function () {
expect($indexHtml)->toContain('user_backoffice_statistics_overview_export');
expect($indexHtml)->toContain('Datenquelle:');
expect($indexHtml)->toContain('Berechnet in');
// Vereinfachte Tabelle: nur noch Berater/Umsatz/Teamabos/Teamkundenabos
expect($indexHtml)->toContain("'total_points'");
expect($indexHtml)->toContain('Umsatz (Punkte)');
expect($indexHtml)->not->toContain('Eigenpunkte');
expect($indexHtml)->not->toContain('Einzelbestellungs-Punkte');
// Neupartner & 1000-Punkte-Shop als klickbare KPI-Cards
expect($indexHtml)->toContain("'metric' => 'new_partners'");
expect($indexHtml)->toContain("'metric' => 'shop_1000'");
});
it('erfasst die Herkunft bei Kundenbestellungen im Shop-Checkout', function () {

View file

@ -0,0 +1,165 @@
<?php
use App\Models\Product;
use App\Models\UserAbo;
use App\Models\UserAboItem;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\BackofficeDrilldownService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
if (! Schema::connection('sqlite')->hasColumn('products', 'slug')) {
Schema::connection('sqlite')->table('products', function ($table) {
$table->string('slug')->nullable();
});
}
});
function boMakeUser(?int $sponsorId = null, ?string $activeDate = null, ?string $paymentAccount = null): User
{
return User::forceCreate([
'email' => 'bo-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'm_sponsor' => $sponsorId,
'active_date' => $activeDate,
'payment_account' => $paymentAccount ?? now()->addYear(),
]);
}
function boMakeAbo(int $memberId, string $isFor, string $startDate, int $points = 0): UserAbo
{
$abo = UserAbo::create([
'user_id' => $memberId,
'member_id' => $memberId,
'shopping_user_id' => 0,
'email' => 'a-'.uniqid('', true).'@example.com',
'is_for' => $isFor,
'payone_userid' => 0,
'clearingtype' => 'cc',
'amount' => 5000,
'active' => true,
'status' => 2,
'abo_interval' => 5,
'start_date' => $startDate,
'last_date' => $startDate,
'next_date' => '2026-12-10',
]);
if ($points > 0) {
$product = Product::forceCreate([
'name' => 'P-'.uniqid('', true),
'title' => 'Test',
'price' => 10.0,
'tax' => 19.0,
'active' => true,
'pos' => 1,
'weight' => 100,
'points' => $points,
'slug' => 'p-'.uniqid('', true),
]);
UserAboItem::create([
'user_abo_id' => $abo->id,
'product_id' => $product->id,
'comp' => 0,
'qty' => 1,
'status' => 1,
]);
}
return $abo->fresh();
}
it('gruppiert Teamkundenabos pro Teampartner mit Punkten je Abo und gesamt', function (): void {
$root = boMakeUser();
$anna = boMakeUser($root->id);
$lena = boMakeUser($root->id);
// Anna: 2 Kundenabos (20 + 30 Punkte), Lena: 1 Kundenabo (15 Punkte)
boMakeAbo($anna->id, 'ot', '2026-06-10', 20);
boMakeAbo($anna->id, 'ot', '2026-05-10', 30);
boMakeAbo($lena->id, 'ot', '2026-06-12', 15);
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'team_customer_abos', 6, 2026);
expect($details['rows'])->toHaveCount(2);
// Nach Gesamtpunkten absteigend: Anna (50) vor Lena (15)
$first = $details['rows'][0];
expect($first['type'])->toBe('customer_abo_group')
->and($first['consultant_id'])->toBe($anna->id)
->and($first['abo_count'])->toBe(2)
->and($first['points'])->toBe(50.0)
->and($first['abos'])->toHaveCount(2);
expect($details['summary']['abo_count'])->toBe(3)
->and($details['summary']['points'])->toBe(65.0);
});
it('markiert im Teamkundenabo-Drilldown die im Monat neuen Abos', function (): void {
$root = boMakeUser();
$anna = boMakeUser($root->id);
boMakeAbo($anna->id, 'ot', '2026-06-10', 20); // neu im Juni
boMakeAbo($anna->id, 'ot', '2026-05-10', 30); // nicht neu
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'team_customer_abos', 6, 2026);
$abos = collect($details['rows'][0]['abos']);
expect($abos->where('is_new_this_month', true))->toHaveCount(1)
->and($abos->where('is_new_this_month', false))->toHaveCount(1);
});
it('zaehlt Neupartner nur fuer den gewaehlten Monat mit aktivem Account', function (): void {
$root = boMakeUser();
$newInJune = boMakeUser($root->id, '2026-06-15');
$newInMay = boMakeUser($root->id, '2026-05-15');
$expiredAccount = boMakeUser($root->id, '2026-06-20', now()->subDay());
$service = app(BackofficeDashboardService::class);
$teamIds = $service->teamUserIds($root->id);
expect($service->newPartnersCount($teamIds, 6, 2026))->toBe(1)
->and($service->newPartnersCount($teamIds, 5, 2026))->toBe(1);
});
it('rendert die kombinierte Monatsstatistik-Card mit Abo- und Neupartner-Kennzahlen', function (): void {
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
$html = view('dashboard._statistics', [
'monthlyStats' => [
'month' => 6,
'year' => 2026,
'customer_points' => 1200.0,
'team_payline_points' => 3400.0,
'payline_is_live' => true,
'direct_new_partners' => 2,
'team_total_abos' => 5,
'team_partner_abos' => 3,
'team_partner_abos_new' => 1,
'team_customer_abos' => 2,
'team_customer_abos_new' => 1,
'shop_1000' => 1,
'new_partners' => 4,
'own_abos' => 1,
'own_abos_new' => 0,
'own_customer_abos' => 2,
'own_customer_abos_new' => 1,
],
])->render();
expect($html)
->toContain(__('home.monthly_statistics'))
->toContain(__('home.team_customer_subscriptions'))
->toContain(__('abo.shop_1000'))
->toContain(__('home.team_new_partners'))
->toContain('user_abos_team_show')
->toContain('+1'); // "+neu"-Badge im gewählten Monat
});