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:
parent
4135726772
commit
386851aee8
20 changed files with 631 additions and 323 deletions
|
|
@ -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()
|
||||
{
|
||||
|
||||
|
|
|
|||
|
|
@ -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']];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
165
tests/Feature/BackofficeStatisticsDrilldownTest.php
Normal file
165
tests/Feature/BackofficeStatisticsDrilldownTest.php
Normal 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
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue