A1: Jeder Monatstag ist als Liefertag erlaubt (UserAbo::allowedDeliveryDays), Tage 29-31 liefern in kuerzeren Monaten am letzten Tag. Eine Stelle AboHelper::dateForDayInMonth klammert; setNextDate, Erstausfuehrung, Admin-Termin und Pause rechnen darueber, der Folgemonat immer aus dem Liefertag (31.01 -> 28.02 -> 31.03). Liefertag-Aenderung gilt fuer den laufenden Zyklus und nur nach vorn: ist der Tag im Referenzmonat vorbei, wird abgelehnt, der Monat verschiebt sich nie (Rueckfrage R1). Admin-Filter "naechste Ausfuehrung" als Datumsbereich. Prognose-Kacheln bekommen die Kachel Monatsende, Team-Abos rechnen bis Monatsende. A2: Sperrfristen Default 0, Setting 0 ist ausdruecklich 0 (fiel bisher auf den Default zurueck). Bedingung Kalendertage <= Sperre, zentral in AboHelper::isWithinLock: am Ausfuehrungstag ab 0:00 Uhr fixiert, Vortag bis 23:59 Uhr frei, fuer Liefertag, Produkte, Pause und Kuendigung (R2). Texte mit _zero-Varianten ueber AboHelper::lockMessage. Sprachdateien de/en/es/fr, neuer AboDeliveryDayTest, bestehende Tests angepasst, Plan-Status mit Deploy-Schritt (Settings auf 0). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
388 lines
23 KiB
PHP
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-6 col-lg 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">
|
|
{{ ! empty($forecastDay['is_month_end']) ? __('home.forecast_month_end', ['date' => $forecastDay['date']]) : __('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
|