mivita/resources/views/dashboard/_statistics.blade.php
Kevin 94e2b13384 Statistik: uebrige Audit-Korrekturen (Abo-Regeln, Prognose, Sortierung)
A01 — Abo-Regeln waren ueber das Aktiv-Feld umgehbar. Das
Selbstbedienungsformular hat gar keinen Aktiv-Schalter, es spiegelt den Zustand
nur in einem versteckten Feld zurueck; `updateStatus()` hat den Wert trotzdem
uebernommen. Wer den Request manipulierte, konnte sein eigenes Abo dauerhaft
deaktivieren und so die Mindestlaufzeit umgehen, am Pausenpfad mit seinen
Sperren vorbei. Das Feld wirkt jetzt nur noch im Admin-Pfad.

A16 — Statuswechsel wurden nur aus dem Formular protokolliert. Recovery-Zahlung,
Retry, externe Erledigung und die DSGVO-Anonymisierung schreiben `active` direkt
auf das Modell. Das ist heikel, weil die historische Bestandsrueckrechnung ein
Abo verwirft, solange eine protokollierte Deaktivierung nicht durch eine
spaetere protokollierte Reaktivierung aufgehoben ist — ein stiller
`active = true` nimmt es dauerhaft aus allen Folgemonaten.

Der Eintrag liegt deshalb jetzt am Model (`UserAbo::logStatusTransition()`) und
nicht in den einzelnen Diensten: so kann ihn ein kuenftiger Schreibpfad nicht
vergessen, und er entsteht in derselben Transaktion wie die Statusaenderung.
Nebeneffekt: Die DSGVO-Anonymisierung setzt `active = false` ohne `cancel_date`
— bisher fiel ein so abgeschaltetes Abo rueckwirkend aus allen vergangenen
Monaten, jetzt bleibt die Historie korrekt.

A13 — Ein heute faelliges Abo galt ab Mitternacht als erledigt, der
Ausfuehrungsjob laeuft aber erst um 04:00. In diesem Fenster fehlten die Punkte
in der Prognose UND im Ist. Der neue Scope `UserAbo::scopeOpenExecutionUntil()`
zaehlt den heutigen Liefertag mit, solange keine Ausfuehrung angelegt ist —
genau diese Bedingung verhindert die Doppelzaehlung. Alle drei Prognosen nutzen
jetzt denselben Scope statt je einer eigenen Kopie der Datumsregel.

A11 — Der Namens-Tiebreak der Top 3 lief nach dem Abschneiden auf 25
Kandidaten, bei Gleichstand entschied also die Reihenfolge der Abfrage. Die
Auswahlgrenze wird jetzt um alle Gleichstaende an dieser Grenze erweitert, und
Kandidaten ohne Account fallen vorher weg statt die Plaetze zu blockieren. Als
letzte Regel entscheidet die user_id, damit eine gecachte Rangliste
reproduzierbar bleibt.

A14 — Sortiert wurde ueber den angezeigten Zellentext. Unter `en` liefert die
Anwendung `Y-m-d` und `1,000.00`, damit ergab jedes Datum 0 und `1,000.00` wurde
zur 1. Die Zellen tragen jetzt `data-sort-value` mit dem unformatierten Wert;
ohne Attribut gilt weiter der Textweg. Dabei mitgenommen: Umlaute landeten hinter
dem Z (jetzt localeCompare), leere Werte lagen als 0 zwischen den negativen
Stornowerten, und `next_date` wurde doppelt formatiert.

Ausserdem A08 (Prognose auch ohne erreichte Qualifikationsstufe), A09
(Summenzeile im Neupartner-CSV), A10 (Lieferungszahl vorgeladen), A17
(Faelligkeitsgruppen ohne Monatsueberlauf), A18 (Formelinhalte im CSV als Text)
und A19 (fehlgeschlagene Provisionsberechnung ist als solche erkennbar statt
0,00 EUR).

Audit-Befunde A01, A08, A09, A10, A11, A13, A14, A16, A17, A18, A19.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:19:38 +00:00

388 lines
23 KiB
PHP

@if (! empty($monthlyStats))
@php
$s = $monthlyStats;
$isVip = auth()->user()?->isVIP() ?? false;
$m = $s['month'];
$y = $s['year'];
$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;
// "+N"-Badge der Team-Abo-Kacheln: VIP-Drilldown gefiltert auf neue Abos im Monat.
$newAbosLink = fn (string $metric) => $isVip
? route('user_backoffice_statistics_details', ['line' => 0, 'metric' => $metric, 'month' => $m, 'year' => $y, 'only_new' => 1])
: null;
$fmt = fn ($value) => number_format((float) $value, 0, ',', '.');
// Solange die Monatsqualifikation aussteht, zaehlen die beiden
// Payline-Struktur-Kacheln nach den Ebenen des Karriere-Levels — das
// wird an der Kachel ausgewiesen, damit die Zahl einordenbar bleibt.
$paylineFallbackNote = ! empty($s['payline_is_career_fallback'])
? __('home.payline_career_fallback_hint')
: null;
$commissionFailed = (bool) ($s['commission']['failed'] ?? false);
$clusters = [
[
'title' => __('home.stats_group_turnover'),
'col' => 'col-md-6 col-lg-3',
'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,
],
[
'label' => __('home.commission_today'),
// Eine fehlgeschlagene Berechnung darf nicht wie ein Nullmonat aussehen.
'value' => $commissionFailed
? '—'
: number_format((float) ($s['commission']['total'] ?? 0), 2, ',', '.') . ' €',
'icon' => 'ion-md-cash',
'color' => $commissionFailed ? 'secondary' : 'warning',
'link' => null, 'new' => null,
'live' => ! $commissionFailed && (bool) ($s['commission']['is_live'] ?? false),
'note' => $commissionFailed ? __('home.commission_unavailable') : null,
],
],
],
[
'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_team'),
'col' => 'col-sm-6 col-lg-3',
'tiles' => [
[
'label' => __('home.firstlines_count'), 'value' => $fmt($s['firstline_count'] ?? 0),
'icon' => 'ion-md-git-branch', 'color' => 'info',
'link' => route('user_team_firstlines', ['month' => $m, 'year' => $y]), 'new' => null, 'live' => false,
],
[
'label' => __('home.team_partner_total'), 'value' => $fmt($s['team_partner_count'] ?? 0),
'icon' => 'ion-md-people', 'color' => 'secondary', 'link' => null, 'new' => null, 'live' => false,
],
[
'label' => __('home.payline_partner_count'), 'value' => $fmt($s['payline_member_count'] ?? 0),
'icon' => 'ion-md-podium', 'color' => 'success', 'link' => null, 'new' => null, 'live' => false,
'note' => $paylineFallbackNote,
],
[
'label' => __('home.payline_abo_count'), 'value' => $fmt($s['payline_abo_count'] ?? 0),
'icon' => 'ion-md-repeat', 'color' => 'success', 'link' => $teamShow,
'new' => $s['payline_abos_new'] ?? 0, 'live' => false,
'note' => $paylineFallbackNote,
],
],
],
[
'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'], 'new_link' => $newAbosLink('team_partner_abos'), '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'], 'new_link' => $newAbosLink('team_customer_abos'), 'live' => false,
],
],
],
];
@endphp
<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($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 }}" {{ $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 }}" {{ $y == $year ? 'selected' : '' }}>
{{ $year }}</option>
@endforeach
</select>
</div>
</h5>
<div class="card-body">
@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="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
@if (! empty($tile['note']))
<i class="ion ion-md-information-circle-outline text-warning ml-1"
style="font-size: .9rem;" title="{{ $tile['note'] }}"></i>
@endif
</div>
<div class="font-weight-bold text-large">
{{ $tile['value'] }}
@if (! empty($tile['new']) && $tile['new'] > 0)
@if (! empty($tile['new_link']))
<span class="badge badge-pill badge-success ml-1 dashboard-stat-new-badge"
data-href="{{ $tile['new_link'] }}" role="link" tabindex="0"
title="{{ __('abo.new_this_month') }}">+{{ $tile['new'] }}</span>
@else
<span class="badge badge-pill badge-success ml-1"
title="{{ __('abo.new_this_month') }}">+{{ $tile['new'] }}</span>
@endif
@endif
</div>
</div>
@if ($tile['link'])
</a>
@else
</div>
@endif
</div>
@endforeach
</div>
@endforeach
@if (! empty($s['level']))
@php($level = $s['level'])
<h6 class="text-muted text-uppercase font-weight-bold small mb-3 mt-2 pt-3 border-top">
{{ __('home.level_current') }}
@if ($level['is_live'])
<span class="badge badge-pill badge-info ml-1" title="{{ __('home.live_calculation_hint') }}">Live</span>
@endif
<span class="ion ion-md-information-circle-outline ml-1" title="{{ __('home.level_hint') }}"></span>
</h6>
<div class="d-flex align-items-center flex-wrap mb-2">
@php($isQualified = $level['is_qualified'] ?? true)
<span class="badge {{ $isQualified ? 'badge-primary' : 'badge-secondary' }} mr-3 mb-2"
style="font-size: 1rem; padding: .5rem .9rem;">
{{ $isQualified ? $level['name'] : __('home.level_not_qualified_yet') }}
</span>
<div class="mb-2">
@if ($level['next_name'] !== null && $level['next_name'] !== '')
{{-- Ohne Monatsquali fehlen typischerweise nur persoenliche Punkte
(das erste Level hat qual_pp = 0) — dann darf hier kein Satz
mit "0 Payline-Punkte" stehen. --}}
@if ($level['missing_pp'] > 0)
<div>
{!! __('home.level_next_missing', [
'level' => '<strong>'.e($level['next_name']).'</strong>',
'points' => '<strong>'.$fmt($level['missing_pp']).'</strong>',
]) !!}
</div>
@if ($level['missing_personal'] > 0)
<div class="text-muted small">
{{ __('home.level_next_missing_personal', ['points' => $fmt($level['missing_personal'])]) }}
</div>
@endif
@elseif ($level['missing_personal'] > 0)
<div>
{!! __('home.level_next_missing_personal_only', [
'level' => '<strong>'.e($level['next_name']).'</strong>',
'points' => '<strong>'.$fmt($level['missing_personal']).'</strong>',
]) !!}
</div>
@endif
@else
<div class="text-success">{{ __('home.level_top_reached') }}</div>
@endif
{{-- Karriere-Level nur als Zusatz, wenn es von der Monats-Quali abweicht --}}
@if ($level['career_name'] !== '' && $level['career_name'] !== $level['name'])
<div class="text-muted small">{{ __('home.level_career') }}: {{ $level['career_name'] }}</div>
@endif
</div>
</div>
@endif
@if (! empty($s['payline_forecast']['days']))
<h6 class="text-muted text-uppercase font-weight-bold small mb-3 mt-2 pt-3 border-top">
{{ __('home.payline_forecast_title') }}
<span class="badge badge-pill badge-info ml-1"
title="{{ __('home.payline_forecast_hint') }}">Live</span>
</h6>
{{-- Woraus die Basis der vier Kacheln besteht (Kundenwunsch 2026-09-07):
HomeController uebergibt customer_points + team_payline_points. --}}
<div class="text-muted small mb-3 mt-n2">{{ __('home.payline_forecast_basis') }}</div>
<div class="row">
@foreach ($s['payline_forecast']['days'] as $forecastDay)
<div class="col-sm-6 col-lg-3 mb-3">
<div class="card h-100 {{ $forecastDay['is_past'] ? 'bg-light' : '' }}">
<div class="card-body py-3 text-center">
<div class="text-muted small">
{{ __('home.forecast_day', ['date' => $forecastDay['date']]) }}
</div>
<div class="font-weight-bold text-large">{{ $fmt($forecastDay['total']) }}</div>
@if ($forecastDay['upcoming'] > 0)
<div class="small text-success">
+{{ $fmt($forecastDay['upcoming']) }} {{ __('home.forecast_upcoming') }}
</div>
@else
<div class="small text-muted">
{{ $forecastDay['is_past'] ? __('home.forecast_done') : '—' }}
</div>
@endif
</div>
</div>
</div>
@endforeach
</div>
@if (! empty($s['level_forecast']))
@php($lf = $s['level_forecast'])
<div class="alert alert-light border mb-0">
<div class="small text-muted mb-1">
{{ __('home.forecast_level_title') }}
<span class="badge badge-pill badge-warning ml-1"
title="{{ __('home.forecast_level_disclaimer') }}">{{ __('home.forecast_unverbindlich') }}</span>
</div>
@if ($lf['next_name'] !== null && $lf['next_name'] !== '')
@if ($lf['reached'])
<div>
{!! __('home.forecast_level_reached', [
'level' => '<strong>'.e($lf['next_name']).'</strong>',
'volume' => '<strong>'.$fmt($lf['personal_required']).'</strong>',
]) !!}
</div>
@else
<div>
{!! __('home.forecast_level_missing', [
'level' => '<strong>'.e($lf['next_name']).'</strong>',
'points' => '<strong>'.$fmt($lf['missing_pp']).'</strong>',
]) !!}
@if ($lf['missing_personal'] > 0)
{{ __('home.forecast_level_missing_personal', ['points' => $fmt($lf['missing_personal'])]) }}
@endif
</div>
@endif
@endif
<div>
@if ($lf['estimated_level_name'] !== '')
{!! __('home.forecast_commission', [
'amount' => '<strong>'.number_format((float) $lf['estimated_commission'], 2, ',', '.').' €</strong>',
'level' => e($lf['estimated_level_name']),
]) !!}
@else
{{-- Noch keine Stufe erreicht: die Prognose nennt einen Betrag,
aber keine Stufe, auf deren Saetzen er beruht. --}}
{!! __('home.forecast_commission_no_level', [
'amount' => '<strong>'.number_format((float) $lf['estimated_commission'], 2, ',', '.').' €</strong>',
]) !!}
@endif
</div>
</div>
@elseif (! empty($s['level']) && ! ($s['level']['is_qualified'] ?? true))
{{-- Ohne Qualifikationsstufe gibt es keine Provisionssaetze, mit denen
sich eine Prognose rechnen liesse. Statt den Block wortlos
wegzulassen, wird der Grund genannt. --}}
<div class="alert alert-light border mb-0 text-muted small">
<i class="ion ion-md-information-circle-outline mr-1"></i>
{{ __('home.forecast_level_needs_qualification') }}
</div>
@endif
@endif
</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() {
function updateStatsFilter() {
var month = $('#stats-month-filter').val();
var year = $('#stats-year-filter').val();
var url = new URL(window.location.href);
url.searchParams.set('stats_month', month);
url.searchParams.set('stats_year', year);
window.location.href = url.toString();
}
$('#stats-month-filter, #stats-year-filter').on('change', function() {
updateStatsFilter();
});
// Das "+N"-Badge liegt innerhalb des Kachel-Links: eigenen Ziel-Link
// öffnen statt der Kachel-Navigation (kein verschachteltes <a> möglich).
$('.dashboard-stat-new-badge').css('cursor', 'pointer').on('click', function(e) {
e.preventDefault();
e.stopPropagation();
window.location.href = $(this).data('href');
});
});
</script>
@endif