1690 lines
109 KiB
PHP
1690 lines
109 KiB
PHP
<div id="stats-component">
|
|
|
|
{{-- Filters --}}
|
|
<div class="mt-6 p-4 bg-theme-surface rounded-md border border-theme-border">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">
|
|
{{ __('Filters') }}
|
|
</h3>
|
|
<x-jetstream.secondary-button wire:click="clearFilters" wire:loading.attr="disabled">
|
|
{{ __('Clear filters') }}
|
|
</x-jetstream.secondary-button>
|
|
</div>
|
|
|
|
<p class="text-sm text-theme-secondary mb-4">
|
|
{{ __('Filters narrow the selection: a profile must match every active filter.') }}
|
|
</p>
|
|
|
|
{{-- Location --}}
|
|
<div class="mb-4">
|
|
<x-checkbox id="stats-filter-location"
|
|
label="{{ __('Filter by profile location') }}"
|
|
wire:model.live="filterByLocation" />
|
|
|
|
@if ($filterByLocation)
|
|
<div class="mt-3" wire:ignore>
|
|
<livewire:mailings.location-filter wire:key="stats-location-filter" />
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Registration date --}}
|
|
<div class="mb-4">
|
|
<x-checkbox id="stats-filter-registration"
|
|
label="{{ __('Filter by registration date') }}"
|
|
wire:model.live="filterByRegistration" />
|
|
|
|
@if ($filterByRegistration)
|
|
<div class="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="block text-sm text-theme-primary mb-1">{{ __('Registered from') }}</label>
|
|
<x-input type="date" class="w-full" wire:model.live="registeredFrom" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm text-theme-primary mb-1">{{ __('Registered until') }}</label>
|
|
<x-input type="date" class="w-full" wire:model.live="registeredTill" />
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Inactivity: not logged in / not traded since --}}
|
|
@php
|
|
$inactivityPeriods = [
|
|
'since_one_year_ago' => __('Since one year ago'),
|
|
'previous_year' => __('Previous year'),
|
|
'previous_quarter' => __('Previous quarter'),
|
|
'previous_month' => __('Previous month'),
|
|
'custom' => __('Custom'),
|
|
'all_time' => __('All time'),
|
|
];
|
|
@endphp
|
|
|
|
<div class="mb-4">
|
|
<x-checkbox id="stats-filter-last-login"
|
|
label="{{ __('Filter by not logged in since') }}"
|
|
wire:model.live="filterByLastLogin" />
|
|
|
|
@if ($filterByLastLogin)
|
|
<div class="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<select wire:model.live="lastLoginPeriod"
|
|
class="block w-full rounded-md border border-theme-border bg-white py-2 pl-3 pr-8 leading-5 shadow-sm sm:text-sm">
|
|
@foreach ($inactivityPeriods as $key => $label)
|
|
<option value="{{ $key }}">{{ $label }}</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
@if ($lastLoginPeriod === 'custom')
|
|
<div>
|
|
<label class="block text-sm text-theme-primary mb-1">{{ __('Not logged in since') }}</label>
|
|
<x-input type="date" class="w-full" wire:model.live="lastLoginCustom" />
|
|
</div>
|
|
@endif
|
|
</div>
|
|
<p class="mt-2 text-xs text-theme-secondary">
|
|
{{ __('Includes profiles that have never logged in. Banks have no login and are left out.') }}
|
|
</p>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<x-checkbox id="stats-filter-last-transaction"
|
|
label="{{ __('Filter by no transaction since') }}"
|
|
wire:model.live="filterByLastTransaction" />
|
|
|
|
@if ($filterByLastTransaction)
|
|
<div class="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<select wire:model.live="lastTransactionPeriod"
|
|
class="block w-full rounded-md border border-theme-border bg-white py-2 pl-3 pr-8 leading-5 shadow-sm sm:text-sm">
|
|
@foreach ($inactivityPeriods as $key => $label)
|
|
<option value="{{ $key }}">{{ $label }}</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
@if ($lastTransactionPeriod === 'custom')
|
|
<div>
|
|
<label class="block text-sm text-theme-primary mb-1">{{ __('No transaction since') }}</label>
|
|
<x-input type="date" class="w-full" wire:model.live="lastTransactionCustom" />
|
|
</div>
|
|
@endif
|
|
</div>
|
|
<p class="mt-2 text-xs text-theme-secondary">
|
|
{{ __('System entries such as migrations and rounding corrections do not count as activity.') }}
|
|
</p>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Counterparties --}}
|
|
<div>
|
|
<x-checkbox id="stats-filter-contacts"
|
|
label="{{ __('Filter by transactions with selected profiles') }}"
|
|
wire:model.live="filterByContacts" />
|
|
|
|
@if ($filterByContacts)
|
|
<p class="mt-2 text-sm text-theme-secondary">
|
|
{{ __('Only include profiles that have transacted with the selected profile(s).') }}
|
|
</p>
|
|
|
|
<div class="mt-3" wire:ignore>
|
|
<livewire:profile.select-multiple-profiles
|
|
wire:key="stats-profiles-selector"
|
|
:label="__('Select profiles')"
|
|
:typesAvailable="[\App\Models\User::class, \App\Models\Organization::class, \App\Models\Bank::class]"
|
|
eventName="statsSelectedProfiles" />
|
|
</div>
|
|
|
|
@if (count($selectedContactProfiles) > 0)
|
|
<div class="mt-3 flex flex-wrap gap-2">
|
|
@foreach ($selectedContactProfiles as $index => $profile)
|
|
<span class="inline-flex items-center gap-2 px-2 py-1 text-xs font-medium rounded-full bg-theme-background border border-theme-border text-theme-primary">
|
|
{{ $profile['name'] }}
|
|
<button type="button" wire:click="removeContactProfile({{ $index }})" class="text-theme-secondary hover:text-theme-primary">
|
|
<x-icon name="x-circle" class="h-4 w-4" />
|
|
</button>
|
|
</span>
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Display options, outside the conditional sections: they change how both
|
|
the stats tables and the network graph are labelled, so they must be
|
|
reachable before either section has been opened — like the filters. --}}
|
|
<div class="mt-6 flex flex-wrap items-center gap-6">
|
|
<div class="flex items-center gap-3"
|
|
title="{{ __('Shows numbered labels instead of names, in the most active profiles table and in the network graph. The same profile gets the same number in both, so a label found in one can be looked up in the other.') }}">
|
|
<x-toggle id="stats-anonymize" name="stats-anonymize" wire:model.live="anonymizeProfiles" />
|
|
<span class="text-sm text-theme-secondary cursor-help">{{ __('Anonymise profile names') }}</span>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3">
|
|
<x-toggle id="stats-decimal" name="stats-decimal" wire:model.live="decimalFormat" />
|
|
<span class="text-sm text-theme-secondary">{{ __('Decimal notation') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
@if (!$hasFilter)
|
|
{{-- Nothing selected yet: don't run a platform-wide query on page load.
|
|
Same structure as the network section below: heading, explanation,
|
|
button — so the two blocks read as equal sections. --}}
|
|
<div class="mt-10">
|
|
<div>
|
|
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">{{ __('Statistics') }}</h3>
|
|
<p class="text-xs text-theme-secondary">{{ __('Statistics are calculated over the profiles matching your selection.') }}</p>
|
|
</div>
|
|
|
|
<div class="mt-3">
|
|
<x-jetstream.secondary-button wire:click="showStats" wire:loading.attr="disabled" wire:target="showStats">
|
|
<span wire:loading.remove wire:target="showStats">{{ __('Show statistics') }}</span>
|
|
<span wire:loading wire:target="showStats">{{ __('Calculating...') }}</span>
|
|
</x-jetstream.secondary-button>
|
|
<span class="ml-3 text-xs text-theme-secondary">
|
|
{{ __('Select a period or a filter first, then calculate the tables and charts.') }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
@else
|
|
{{-- Toolbar. The display toggles used to live here; they now sit with
|
|
the filters, since they apply to the graph as well as the stats
|
|
and must stay reachable when neither section is open. --}}
|
|
<div class="mt-8 flex items-center justify-end">
|
|
<div class="flex items-center gap-3">
|
|
<x-jetstream.secondary-button wire:click="refreshStats" wire:loading.attr="disabled">
|
|
{{ __('Refresh') }}
|
|
</x-jetstream.secondary-button>
|
|
|
|
<x-jetstream.button data-network-pdf-trigger
|
|
x-on:click="window.exportStatsPdf && window.exportStatsPdf()"
|
|
wire:loading.attr="disabled" wire:target="exportPdf">
|
|
<span wire:loading.remove wire:target="exportPdf">{{ __('Export PDF') }}</span>
|
|
<span wire:loading wire:target="exportPdf">{{ __('Preparing...') }}</span>
|
|
</x-jetstream.button>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- What these numbers actually cover --}}
|
|
<div class="mt-6 p-3 bg-theme-surface rounded-md border border-theme-border">
|
|
<div class="flex flex-wrap items-center gap-x-6 gap-y-2">
|
|
<span class="text-xs uppercase tracking-wide text-theme-secondary">{{ __('Showing') }}</span>
|
|
@foreach ($this->activeFilterSummary() as $item)
|
|
<span class="text-sm text-theme-primary">
|
|
<span class="text-theme-secondary">{{ $item['label'] }}:</span>
|
|
<strong>{{ $item['value'] }}</strong>
|
|
</span>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Summary tiles --}}
|
|
{{-- Five across on large screens: with lg:grid-cols-4 the fifth tile
|
|
drops onto a row of its own and reads as an afterthought. --}}
|
|
<div class="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
|
@php
|
|
$filteredByProfile = $filterByContacts && count($selectedContactProfiles) > 0;
|
|
|
|
$tiles = [
|
|
[
|
|
'label' => $this->profilesLabel(),
|
|
'value' => number_format($stats['profileCount'], 0, ',', '.'),
|
|
'hint' => __('How many profiles match your filters. These are the profiles every number on this page is based on.'),
|
|
],
|
|
[
|
|
'label' => __('Incomplete'),
|
|
// The share, not the count: a percentage is what makes
|
|
// the figure comparable between filters and periods.
|
|
'value' => (function () use ($stats) {
|
|
$incomplete = (int) ($stats['online']['incomplete'] ?? 0);
|
|
$total = (int) ($stats['profileCount'] ?? 0);
|
|
|
|
if ($total < 1) {
|
|
return '-';
|
|
}
|
|
|
|
return number_format(100 * $incomplete / $total, 1, ',', '.') . '%';
|
|
})(),
|
|
'hint' => __('Profiles that have not filled in enough of their description, tags, languages or location. Shown as a share of the profiles in this selection.'),
|
|
],
|
|
[
|
|
'label' => __('Transactions'),
|
|
'value' => number_format($stats['economic']['transaction_count'], 0, ',', '.'),
|
|
'hint' => $filteredByProfile
|
|
? __('How many payments were made with the selected profile(s) during the period. Payments between other profiles are not counted.')
|
|
: __('How many payments were made during the period by the profiles in your selection.'),
|
|
],
|
|
[
|
|
'label' => __('Total volume'),
|
|
'value' => $this->formatAmount($stats['economic']['total_volume']),
|
|
'hint' => __('All the time that changed hands, added up. Every payment is counted once, no matter who sent or received it.'),
|
|
],
|
|
[
|
|
'label' => __('Total balance'),
|
|
'value' => $this->formatAmount($stats['balance']),
|
|
'hint' => $filteredByProfile
|
|
? __('What the selected profile(s) hold right now: time earned minus time spent, over their whole history. The period does not change this.')
|
|
: __('What the profiles in your selection hold right now: time earned minus time spent, over their whole history. The period does not change this.'),
|
|
],
|
|
];
|
|
@endphp
|
|
@foreach ($tiles as $tile)
|
|
<div class="p-4 bg-theme-surface rounded-md border border-theme-border cursor-help"
|
|
title="{{ $tile['hint'] }}">
|
|
<div class="text-xs uppercase tracking-wide text-theme-secondary">
|
|
{{ $tile['label'] }}
|
|
</div>
|
|
<div class="mt-1 text-2xl font-semibold text-theme-primary">{{ $tile['value'] }}</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
|
|
{{-- Economic --}}
|
|
<div class="mt-10">
|
|
<div class="cursor-help" title="{{ __('Time traded between profiles. Every payment counts once, whoever sent or received it.') }}">
|
|
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">{{ __('Economic activity') }}</h3>
|
|
<p class="text-xs text-theme-secondary mb-3">{{ $this->sectionScope() }}</p>
|
|
</div>
|
|
|
|
{{-- Only shown when the selection can actually have an "outside";
|
|
see split_is_meaningful in StatsService::economic(). --}}
|
|
@if ($stats['economic']['split_is_meaningful'] ?? false)
|
|
<div class="mb-4 p-3 bg-theme-surface rounded-md border border-theme-border">
|
|
<p class="text-sm text-theme-primary">
|
|
<strong>{{ __('Internal:') }}</strong> {{ $this->formatAmount($stats['economic']['internal_volume']) }}
|
|
·
|
|
<strong>{{ __('External:') }}</strong> {{ $this->formatAmount($stats['economic']['external_volume']) }}
|
|
</p>
|
|
<p class="text-sm text-theme-secondary mt-1">
|
|
{{ __('Internal transactions happen between two profiles inside the selection; external ones have one side outside it.') }}
|
|
</p>
|
|
</div>
|
|
@endif
|
|
|
|
@if ($stats['economic']['by_type']->isNotEmpty())
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-theme-border">
|
|
<thead>
|
|
<tr>
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-theme-secondary uppercase tracking-wider">{{ __('Transaction type') }}</th>
|
|
<th class="px-3 py-3 text-right text-xs font-medium text-theme-secondary uppercase tracking-wider">{{ __('Count') }}</th>
|
|
<th class="px-3 py-3 text-right text-xs font-medium text-theme-secondary uppercase tracking-wider">{{ __('Volume') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-theme-border">
|
|
@foreach ($stats['economic']['by_type'] as $type)
|
|
<tr wire:key="stats-type-{{ $loop->index }}">
|
|
<td class="px-3 py-2 text-sm text-theme-primary">{{ $type['type_name'] }}</td>
|
|
<td class="px-3 py-2 text-sm text-theme-primary text-right">{{ number_format($type['count'], 0, ',', '.') }}</td>
|
|
<td class="px-3 py-2 text-sm text-theme-primary text-right">{{ $this->formatAmount($type['volume']) }}</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
@endif
|
|
|
|
@if (count($stats['economic']['timeline'] ?? []) > 1)
|
|
@php
|
|
// Series are the transaction-type names carried on each row.
|
|
$volumeSeries = array_values(array_diff(
|
|
array_keys($stats['economic']['timeline'][0] ?? []),
|
|
['label', 'ym', 'count', 'volume']
|
|
));
|
|
@endphp
|
|
@php
|
|
// A timeline of all zeros is a real answer, but an empty grid
|
|
// reads as a broken chart — so drop the canvas and say it.
|
|
$hasVolume = collect($stats['economic']['timeline'])
|
|
->sum(fn($row) => $row['volume'] ?? 0) > 0;
|
|
@endphp
|
|
|
|
@if ($hasVolume)
|
|
<div class="mt-6 p-4 bg-theme-background rounded-lg border border-theme-border"
|
|
data-stats-volume-chart="{{ json_encode($stats['economic']['timeline']) }}"
|
|
data-decimal-format="{{ $decimalFormat ? '1' : '0' }}"
|
|
data-currency-symbol="{{ platform_currency_symbol() }}"
|
|
data-currency-position-end="{{ platform_trans('platform_currency_position_end', null, false) ? '1' : '0' }}"
|
|
data-currency-thousand-separator="{{ platform_trans('platform_currency_thousand_separator', null, '') }}"
|
|
data-series-colors="{{ json_encode($this->volumeSeriesColors($volumeSeries)) }}">
|
|
{{-- The sized wrapper is what Chart.js measures (maintainAspectRatio is
|
|
off), so the axis labels get room instead of being clipped. --}}
|
|
<div class="relative aspect-video">
|
|
<canvas id="statsVolumeChart"></canvas>
|
|
</div>
|
|
</div>
|
|
@else
|
|
<p class="mt-6 text-sm text-theme-secondary">
|
|
{{ __('No data available for this chart.') }}
|
|
</p>
|
|
@endif
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Online --}}
|
|
<div class="mt-10">
|
|
<div class="cursor-help" title="{{ $this->isPartnerScoped()
|
|
? __('How many partner profiles are in the selection, how many are still active, and how many signed up each month.')
|
|
: __('How many profiles are in the selection, how many are still active, and how many signed up each month.') }}">
|
|
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">{{ $this->isPartnerScoped() ? __('Partner profiles and activity') : __('Profiles and activity') }}</h3>
|
|
<p class="text-xs text-theme-secondary mb-3">{{ $this->sectionScope() }}</p>
|
|
</div>
|
|
|
|
@php
|
|
// The hint belongs to the whole column, so it goes on every cell
|
|
// as well as the header — a title on <col> is not shown by any
|
|
// browser, so there is no way to declare it once per column.
|
|
$columnHints = [
|
|
'total' => __('Every profile of this type in the current selection, whenever it signed up. The period does not narrow this.'),
|
|
'active' => __('Profiles that have not been marked inactive. Counted as of today, not over the period.'),
|
|
'inactive' => __('Profiles that have been marked inactive, for example after leaving the platform.'),
|
|
'logins' => __('Profiles that signed in at least once in the last 30 days. Each profile counts once, however often it signed in. A dash means this profile type does not record sign-ins.'),
|
|
];
|
|
$columnLabels = [
|
|
'total' => __('Total'),
|
|
'active' => __('Active'),
|
|
'inactive' => __('Inactive'),
|
|
'logins' => __('Recent active < 30d'),
|
|
];
|
|
@endphp
|
|
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-theme-border">
|
|
<thead>
|
|
<tr>
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-theme-secondary uppercase tracking-wider">{{ $this->isPartnerScoped() ? __('Partner profile type') : __('Profile type') }}</th>
|
|
@foreach ($columnLabels as $key => $label)
|
|
<th class="px-3 py-3 text-right text-xs font-medium text-theme-secondary uppercase tracking-wider cursor-help"
|
|
title="{{ $columnHints[$key] }}">{{ $label }}</th>
|
|
@endforeach
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-theme-border">
|
|
@foreach ($stats['online']['by_type'] as $typeName => $row)
|
|
<tr wire:key="stats-online-{{ $typeName }}">
|
|
<td class="px-3 py-2 text-sm text-theme-primary">{{ __($typeName) }}</td>
|
|
<td class="px-3 py-2 text-sm text-theme-primary text-right cursor-help" title="{{ $columnHints['total'] }}">{{ number_format($row['total'], 0, ',', '.') }}</td>
|
|
<td class="px-3 py-2 text-sm text-theme-primary text-right cursor-help" title="{{ $columnHints['active'] }}">{{ number_format($row['active'], 0, ',', '.') }}</td>
|
|
<td class="px-3 py-2 text-sm text-theme-primary text-right cursor-help" title="{{ $columnHints['inactive'] }}">{{ number_format($row['inactive'], 0, ',', '.') }}</td>
|
|
<td class="px-3 py-2 text-sm text-theme-primary text-right cursor-help" title="{{ $columnHints['logins'] }}">{{ $row['recent_logins'] === null ? '-' : number_format($row['recent_logins'], 0, ',', '.') }}</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
@if (count($stats['online']['registrations'] ?? []) > 1)
|
|
@php
|
|
// The table above counts every profile in the selection
|
|
// regardless of when it signed up; the chart counts sign-ups
|
|
// per month. An all-zero timeline is therefore a real answer,
|
|
// but an empty grid reads as a broken chart — so say it.
|
|
$hasRegistrations = collect($stats['online']['registrations'])
|
|
->sum(fn($row) => $row['count'] ?? 0) > 0;
|
|
@endphp
|
|
|
|
<div class="mt-6">
|
|
<h4 class="text-xs font-medium text-theme-secondary uppercase tracking-wider mb-2">
|
|
{{ __('New registrations per month') }}
|
|
</h4>
|
|
|
|
@if ($hasRegistrations)
|
|
{{-- One chart per profile type, each on its own y-axis.
|
|
Users peak around 60 a month where organizations
|
|
peak at 2, so a shared axis flattens the latter to
|
|
nothing. --}}
|
|
@php
|
|
$registrationCharts = [
|
|
[
|
|
'field' => 'User',
|
|
'label' => __('Users'),
|
|
'canvas' => 'statsRegistrationsChart',
|
|
'attr' => 'data-stats-registrations-chart',
|
|
],
|
|
[
|
|
'field' => 'Organization',
|
|
'label' => __('Organizations'),
|
|
'canvas' => 'statsRegistrationsOrgChart',
|
|
'attr' => 'data-stats-registrations-org-chart',
|
|
],
|
|
];
|
|
$registrationRows = collect($stats['online']['registrations']);
|
|
$registrationColors = $this->registrationsSeriesColors();
|
|
@endphp
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
@foreach ($registrationCharts as $chart)
|
|
@php
|
|
$regTotal = $registrationRows->sum(fn($row) => $row[$chart['field']] ?? 0);
|
|
@endphp
|
|
@if ($regTotal > 0)
|
|
<div class="p-4 bg-theme-background rounded-lg border border-theme-border"
|
|
wire:key="stats-reg-chart-{{ $chart['field'] }}">
|
|
<h5 class="text-xs font-medium text-theme-secondary uppercase tracking-wider mb-2">
|
|
{{ $chart['label'] }}
|
|
</h5>
|
|
|
|
<div {{ $chart['attr'] }}="{{ json_encode($registrationRows) }}"
|
|
data-series-labels="{{ json_encode([$chart['field'] => $chart['label']]) }}"
|
|
data-series-colors="{{ json_encode([$chart['field'] => $registrationColors[$chart['field']] ?? null]) }}">
|
|
<div class="relative aspect-video">
|
|
<canvas id="{{ $chart['canvas'] }}"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@else
|
|
<p class="text-sm text-theme-secondary">
|
|
{{ $chart['label'] }}: {{ __('No data available for this chart.') }}
|
|
</p>
|
|
@endif
|
|
@endforeach
|
|
</div>
|
|
@else
|
|
<p class="text-sm text-theme-secondary">
|
|
{{ __('No profile in this selection registered during this period. The totals above cover the whole selection, whenever each profile signed up.') }}
|
|
</p>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Engagement --}}
|
|
<div class="mt-10">
|
|
<div class="cursor-help" title="{{ __('What people did besides paying each other: posts written, calls placed, reactions given and chat messages sent.') }}">
|
|
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">{{ __('Engagement') }}</h3>
|
|
<p class="text-xs text-theme-secondary mb-3">{{ $this->sectionScope() }}</p>
|
|
</div>
|
|
@php
|
|
// One definition drives the tiles and the charts below, so a
|
|
// label and its hint can never drift apart.
|
|
$engagementMetrics = [
|
|
[
|
|
'field' => 'posts',
|
|
'label' => __('Posts'),
|
|
'hint' => __('Articles and other posts published in this period by the profiles in your selection. Edits to an existing post are not counted again.'),
|
|
'canvas' => 'statsEngagementPostsChart',
|
|
'attr' => 'data-stats-engagement-posts-chart',
|
|
],
|
|
[
|
|
'field' => 'calls',
|
|
'label' => __('Calls'),
|
|
'hint' => __('Requests and offers placed in this period. Each call counts once when it is created, however many people respond to it.'),
|
|
'canvas' => 'statsEngagementCallsChart',
|
|
'attr' => 'data-stats-engagement-calls-chart',
|
|
],
|
|
[
|
|
'field' => 'reactions',
|
|
'label' => __('Reactions'),
|
|
'hint' => __('Likes and other reactions given to posts and calls in this period. One profile reacting to several items counts several times.'),
|
|
'canvas' => 'statsEngagementReactionsChart',
|
|
'attr' => 'data-stats-engagement-reactions-chart',
|
|
],
|
|
[
|
|
'field' => 'messages',
|
|
'label' => __('Messages'),
|
|
'hint' => __('Chat messages sent in this period. Every message counts, including several within one conversation.'),
|
|
'canvas' => 'statsEngagementMessagesChart',
|
|
'attr' => 'data-stats-engagement-messages-chart',
|
|
],
|
|
];
|
|
$engagementColors = $this->engagementSeriesColors();
|
|
@endphp
|
|
|
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
@foreach ($engagementMetrics as $metric)
|
|
<div class="p-4 bg-theme-surface rounded-md border border-theme-border cursor-help"
|
|
title="{{ $metric['hint'] }}"
|
|
wire:key="stats-eng-{{ $metric['field'] }}">
|
|
<div class="text-xs uppercase tracking-wide text-theme-secondary">{{ $metric['label'] }}</div>
|
|
<div class="mt-1 text-xl font-semibold text-theme-primary">{{ number_format($stats['engagement'][$metric['field']], 0, ',', '.') }}</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
|
|
@php
|
|
$hasEngagement = collect($stats['engagement']['timeline'] ?? [])
|
|
->sum(fn($row) => ($row['posts'] ?? 0) + ($row['calls'] ?? 0)
|
|
+ ($row['reactions'] ?? 0) + ($row['messages'] ?? 0)) > 0;
|
|
@endphp
|
|
|
|
@if ($hasEngagement)
|
|
{{-- One chart per metric, each on its own y-axis. Reactions peak
|
|
around 3.900 a month where posts peak at 2, so on a shared
|
|
axis the smaller series are well under a pixel. --}}
|
|
<div class="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
@foreach ($engagementMetrics as $metric)
|
|
@php
|
|
$metricRows = collect($stats['engagement']['timeline']);
|
|
$metricTotal = $metricRows->sum(fn($row) => $row[$metric['field']] ?? 0);
|
|
@endphp
|
|
@if ($metricTotal > 0)
|
|
<div class="p-4 bg-theme-background rounded-lg border border-theme-border"
|
|
wire:key="stats-eng-chart-{{ $metric['field'] }}">
|
|
<h4 class="text-xs font-medium text-theme-secondary uppercase tracking-wider mb-2 cursor-help"
|
|
title="{{ $metric['hint'] }}">{{ $metric['label'] }}</h4>
|
|
|
|
<div {{ $metric['attr'] }}="{{ json_encode($metricRows) }}"
|
|
data-series-labels="{{ json_encode([$metric['field'] => $metric['label']]) }}"
|
|
data-series-colors="{{ json_encode([$metric['field'] => $engagementColors[$metric['field']] ?? null]) }}">
|
|
<div class="relative aspect-video">
|
|
<canvas id="{{ $metric['canvas'] }}"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@else
|
|
<p class="text-sm text-theme-secondary">
|
|
{{ $metric['label'] }}: {{ __('No data available for this chart.') }}
|
|
</p>
|
|
@endif
|
|
@endforeach
|
|
</div>
|
|
@else
|
|
<p class="mt-6 text-sm text-theme-secondary">
|
|
{{ __('No data available for this chart.') }}
|
|
</p>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Balance distribution --}}
|
|
@if (($stats['balance_distribution']['profiles_counted'] ?? 0) > 0)
|
|
<div class="mt-10">
|
|
<div class="cursor-help" title="{{ __('How many user and organization profiles sit in each balance range. It shows whether most people are near zero or spread into large credit and debt. Banks are left out because their balance mirrors everyone else\'s.') }}">
|
|
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">{{ __('Balance distribution') }}</h3>
|
|
<p class="text-xs text-theme-secondary mb-3">{{ $this->sectionScope(false) }}</p>
|
|
</div>
|
|
|
|
<p class="text-sm text-theme-secondary mb-3">
|
|
@if ($filterByContacts && count($selectedContactProfiles) > 0)
|
|
{{ __('Current balance of the :count user and organization trading partners of the selected profile(s), in hours. Banks are excluded.', ['count' => $stats['balance_distribution']['profiles_counted']]) }}
|
|
@else
|
|
{{ __('Current balance of the :count user and organization profiles in the selection, in hours. Banks are excluded.', ['count' => $stats['balance_distribution']['profiles_counted']]) }}
|
|
@endif
|
|
</p>
|
|
|
|
@php
|
|
// The chart reads one key per series off each band row, so
|
|
// fold the per-type counts into the bands here.
|
|
$balanceSeries = $stats['balance_distribution']['series'] ?? [];
|
|
$balanceRows = collect($stats['balance_distribution']['bands'])
|
|
->map(function ($band, $i) use ($balanceSeries) {
|
|
foreach ($balanceSeries as $type => $counts) {
|
|
$band[$type] = $counts[$i] ?? 0;
|
|
}
|
|
return $band;
|
|
})
|
|
->values();
|
|
@endphp
|
|
|
|
{{-- One chart per profile type, each with its own y-axis.
|
|
Organizations are far fewer than users, so on a shared axis
|
|
scaled to hundreds of users a bar of 1 organization is
|
|
sub-pixel and reads as missing data. --}}
|
|
@php
|
|
$balanceCharts = [
|
|
[
|
|
'field' => 'User',
|
|
'label' => __('Users'),
|
|
'canvas' => 'statsBalanceChart',
|
|
'attr' => 'data-stats-balance-chart',
|
|
],
|
|
[
|
|
'field' => 'Organization',
|
|
'label' => __('Organizations'),
|
|
'canvas' => 'statsBalanceOrgChart',
|
|
'attr' => 'data-stats-balance-org-chart',
|
|
],
|
|
];
|
|
$balanceColors = $this->balanceSeriesColors();
|
|
@endphp
|
|
|
|
{{-- Stacked, not side by side: at half width the 27 bands are cramped
|
|
and the angled labels crowd. Full width gives each chart
|
|
roughly twice the area. --}}
|
|
<div class="grid grid-cols-1 gap-6">
|
|
@foreach ($balanceCharts as $chart)
|
|
@php
|
|
$total = $balanceRows->sum(fn($row) => $row[$chart['field']] ?? 0);
|
|
|
|
// Trim each axis to its own series: the bands are
|
|
// computed across both types, so a band holding only
|
|
// an organization leaves empty bars on the user
|
|
// chart. One empty band is kept past the last
|
|
// occupied one, to show where the range ends.
|
|
$chartRows = $balanceRows->values();
|
|
$chartLast = $chartRows->count() - 1;
|
|
while (
|
|
$chartLast > 0
|
|
&& ($chartRows[$chartLast][$chart['field']] ?? 0) === 0
|
|
&& ($chartRows[$chartLast - 1][$chart['field']] ?? 0) === 0
|
|
) {
|
|
$chartLast--;
|
|
}
|
|
// Same at the leading end: a chart whose first bar
|
|
// sits in band five should not open with four empty
|
|
// ones.
|
|
$chartFirst = 0;
|
|
while (
|
|
$chartFirst < $chartLast
|
|
&& ($chartRows[$chartFirst][$chart['field']] ?? 0) === 0
|
|
&& ($chartRows[$chartFirst + 1][$chart['field']] ?? 0) === 0
|
|
) {
|
|
$chartFirst++;
|
|
}
|
|
$chartRows = $chartRows->slice($chartFirst, $chartLast - $chartFirst + 1)->values();
|
|
@endphp
|
|
@if ($total > 0)
|
|
<div class="p-4 bg-theme-background rounded-lg border border-theme-border">
|
|
<h4 class="text-xs font-medium text-theme-secondary uppercase tracking-wider mb-2">
|
|
{{ $chart['label'] }}
|
|
</h4>
|
|
|
|
<div {{ $chart['attr'] }}="{{ json_encode($chartRows) }}"
|
|
data-series-labels="{{ json_encode([$chart['field'] => $chart['label']]) }}"
|
|
data-series-colors="{{ json_encode([$chart['field'] => $balanceColors[$chart['field']] ?? null]) }}">
|
|
{{-- Taller than the timelines: the angled
|
|
band labels need the extra room. --}}
|
|
<div class="relative aspect-video">
|
|
<canvas id="{{ $chart['canvas'] }}"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@else
|
|
{{-- No bordered box: an empty frame reads as a chart
|
|
that failed to draw. --}}
|
|
<p class="text-sm text-theme-secondary">
|
|
{{ $chart['label'] }}: {{ __('No profiles of this type in the selection.') }}
|
|
</p>
|
|
@endif
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Distribution --}}
|
|
<div class="mt-10">
|
|
<div class="cursor-help" title="{{ __('The profiles with the highest traded time in the period, split into what they paid and what they received. The combined columns count a payment for both sides.') }}">
|
|
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">{{ $this->isPartnerScoped() ? __('Most active partner profiles') : __('Most active profiles') }}</h3>
|
|
<p class="text-xs text-theme-secondary mb-3">{{ $this->sectionScope() }}</p>
|
|
</div>
|
|
|
|
@if (!empty($stats['distribution']['excluded_profiles']))
|
|
<p class="text-xs text-theme-secondary mb-3">
|
|
{{ __(':profiles excluded from this table.', ['profiles' => implode(', ', $stats['distribution']['excluded_profiles'])]) }}
|
|
</p>
|
|
@endif
|
|
|
|
@if ($stats['distribution']['top_profiles']->isEmpty())
|
|
<p class="text-sm text-theme-secondary">{{ __('No activity in this selection.') }}</p>
|
|
@else
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-theme-border">
|
|
<thead>
|
|
<tr>
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-theme-secondary uppercase tracking-wider">{{ $this->profilesLabel(false) }}</th>
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-theme-secondary uppercase tracking-wider">{{ __('Type') }}</th>
|
|
@php
|
|
$sortable = [
|
|
['key' => 'paid_count', 'label' => __('Paid'), 'hint' => __('Payments sent by this profile.')],
|
|
['key' => 'received_count', 'label' => __('Received'), 'hint' => __('Payments received by this profile.')],
|
|
['key' => 'paid_volume', 'label' => __('Paid volume'), 'hint' => __('Time this profile paid out.')],
|
|
['key' => 'received_volume', 'label' => __('Received volume'), 'hint' => __('Time this profile received.')],
|
|
['key' => 'partners', 'label' => __('Trading partners'), 'hint' => __('Distinct profiles this one traded with. Can be higher than the partner count in the network graph, which leaves out removed profiles.')],
|
|
['key' => 'count', 'label' => __('Transactions'), 'hint' => __('Payments sent and received together. This is what the rows are ranked by.')],
|
|
];
|
|
@endphp
|
|
@foreach ($sortable as $column)
|
|
<th class="px-3 py-3 text-right text-xs font-medium text-theme-secondary uppercase tracking-wider">
|
|
<button type="button"
|
|
wire:click="sortTop('{{ $column['key'] }}')"
|
|
class="inline-flex items-center gap-1 uppercase tracking-wider hover:text-theme-primary {{ $topSort === $column['key'] ? 'text-theme-primary' : '' }}"
|
|
title="{{ $column['hint'] }}">
|
|
<span>{{ $column['label'] }}</span>
|
|
@if ($topSort === $column['key'])
|
|
<span aria-hidden="true">{{ $topDirection === 'desc' ? '▼' : '▲' }}</span>
|
|
@endif
|
|
</button>
|
|
</th>
|
|
@endforeach
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-theme-border">
|
|
@foreach ($stats['distribution']['top_profiles'] as $profile)
|
|
<tr wire:key="stats-top-{{ $profile['type'] }}-{{ $profile['id'] }}">
|
|
<td class="px-3 py-2 text-sm text-theme-primary">{{ $anonymizeProfiles ? $profile['anonymous_name'] : $profile['name'] }}</td>
|
|
<td class="px-3 py-2 text-sm text-theme-secondary">{{ __($profile['type']) }}</td>
|
|
<td class="px-3 py-2 text-sm text-theme-secondary text-right">{{ number_format($profile['paid_count'], 0, ',', '.') }}</td>
|
|
<td class="px-3 py-2 text-sm text-theme-secondary text-right">{{ number_format($profile['received_count'], 0, ',', '.') }}</td>
|
|
<td class="px-3 py-2 text-sm text-theme-secondary text-right">{{ $this->formatAmount($profile['paid_volume']) }}</td>
|
|
<td class="px-3 py-2 text-sm text-theme-secondary text-right">{{ $this->formatAmount($profile['received_volume']) }}</td>
|
|
<td class="px-3 py-2 text-sm text-theme-secondary text-right">{{ number_format($profile['partners'] ?? 0, 0, ',', '.') }}</td>
|
|
<td class="px-3 py-2 text-sm text-theme-primary text-right">{{ number_format($profile['count'], 0, ',', '.') }}</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
@if ($stats['distribution']['by_location']->isNotEmpty())
|
|
<div class="mt-10">
|
|
<div class="cursor-help" title="{{ __('Activity grouped by where the profiles are based, split into what they paid and received. The combined columns count a transaction for both places involved.') }}">
|
|
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">{{ __('Activity by location') }}</h3>
|
|
<p class="text-xs text-theme-secondary mb-3">{{ $this->sectionScope() }}</p>
|
|
</div>
|
|
|
|
@if (!empty($stats['distribution']['excluded_profiles']))
|
|
<p class="text-xs text-theme-secondary mb-3">
|
|
{{ __(':profiles excluded from this table.', ['profiles' => implode(', ', $stats['distribution']['excluded_profiles'])]) }}
|
|
</p>
|
|
@endif
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-theme-border">
|
|
<thead>
|
|
<tr>
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-theme-secondary uppercase tracking-wider">{{ __('Location') }}</th>
|
|
@php
|
|
$locationSortable = [
|
|
['key' => 'paid_count', 'label' => __('Paid'), 'hint' => __('Payments sent by profiles based here.')],
|
|
['key' => 'received_count', 'label' => __('Received'), 'hint' => __('Payments received by profiles based here.')],
|
|
['key' => 'paid_volume', 'label' => __('Paid volume'), 'hint' => __('Time paid out by profiles based here.')],
|
|
['key' => 'received_volume', 'label' => __('Received volume'), 'hint' => __('Time received by profiles based here.')],
|
|
['key' => 'count', 'label' => __('Transactions'), 'hint' => __('Payments sent and received together. This is what the rows are ranked by.')],
|
|
];
|
|
@endphp
|
|
@foreach ($locationSortable as $column)
|
|
<th class="px-3 py-3 text-right text-xs font-medium text-theme-secondary uppercase tracking-wider">
|
|
<button type="button"
|
|
wire:click="sortLocation('{{ $column['key'] }}')"
|
|
class="inline-flex items-center gap-1 uppercase tracking-wider hover:text-theme-primary {{ $locationSort === $column['key'] ? 'text-theme-primary' : '' }}"
|
|
title="{{ $column['hint'] }}">
|
|
<span>{{ $column['label'] }}</span>
|
|
@if ($locationSort === $column['key'])
|
|
<span aria-hidden="true">{{ $locationDirection === 'desc' ? '▼' : '▲' }}</span>
|
|
@endif
|
|
</button>
|
|
</th>
|
|
@endforeach
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-theme-border">
|
|
@foreach ($stats['distribution']['by_location'] as $row)
|
|
<tr wire:key="stats-loc-{{ $loop->index }}">
|
|
<td class="px-3 py-2 text-sm text-theme-primary">
|
|
{{ $row['label'] ?: __('Location not specified') }}
|
|
</td>
|
|
<td class="px-3 py-2 text-sm text-theme-secondary text-right">{{ number_format($row['paid_count'], 0, ',', '.') }}</td>
|
|
<td class="px-3 py-2 text-sm text-theme-secondary text-right">{{ number_format($row['received_count'], 0, ',', '.') }}</td>
|
|
<td class="px-3 py-2 text-sm text-theme-secondary text-right">{{ $this->formatAmount($row['paid_volume']) }}</td>
|
|
<td class="px-3 py-2 text-sm text-theme-secondary text-right">{{ $this->formatAmount($row['received_volume']) }}</td>
|
|
<td class="px-3 py-2 text-sm text-theme-primary text-right">{{ number_format($row['count'], 0, ',', '.') }}</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Profiles marked as inactive, over time --}}
|
|
{{-- Always rendered, like every other section: an empty series is a
|
|
real answer ("nobody is marked inactive"), and a section that
|
|
silently disappears reads as a bug rather than as that answer. --}}
|
|
@php
|
|
$inactiveRows = collect($stats['online']['inactivations'] ?? []);
|
|
// Both series are cumulative, so the last row carries the totals.
|
|
// A chart needs at least two points to draw a line, and is worth
|
|
// drawing when either series has something to say.
|
|
$inactiveTotal = $inactiveRows->count() > 1
|
|
? (int) ($inactiveRows->last()['count'] ?? 0)
|
|
+ (int) ($inactiveRows->last()['incomplete'] ?? 0)
|
|
: 0;
|
|
$inactiveSeriesColors = $this->inactivationsSeriesColors();
|
|
$inactiveColor = $inactiveSeriesColors['count'] ?? null;
|
|
$inactiveBalanceColor = $inactiveSeriesColors['balance'] ?? null;
|
|
$incompleteColor = $inactiveSeriesColors['incomplete'] ?? null;
|
|
@endphp
|
|
|
|
<div class="mt-10">
|
|
<div class="cursor-help" title="{{ __('How many profiles stood marked as inactive at the end of each month, counted cumulatively. Profiles marked as inactive before the period are included in the starting figure, so the line reflects the full history rather than restarting at zero.') }}">
|
|
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">{{ $this->isPartnerScoped() ? __('Partner profiles marked as inactive over time') : __('Profiles marked as inactive over time') }}</h3>
|
|
<p class="text-xs text-theme-secondary">{{ $this->sectionScope() }}</p>
|
|
</div>
|
|
|
|
{{-- One paragraph per charted line, in the order they are drawn, so
|
|
a reader can find the note for the line they are looking at.
|
|
The inactivity rule comes from the platform's own config. --}}
|
|
<div class="mb-3 space-y-1">
|
|
@foreach (\App\Services\StatsService::chartLegendNotes() as $note)
|
|
<p class="text-xs text-theme-secondary">{{ $note }}</p>
|
|
@endforeach
|
|
</div>
|
|
|
|
@if ($inactiveTotal > 0)
|
|
<div class="p-4 bg-theme-background rounded-lg border border-theme-border">
|
|
{{-- The balance line is an amount, so this chart needs
|
|
the currency attributes the volume chart uses —
|
|
without them formatHours() renders a bare number. --}}
|
|
<div data-stats-inactivations-chart="{{ json_encode($inactiveRows) }}"
|
|
data-decimal-format="{{ $decimalFormat ? '1' : '0' }}"
|
|
data-currency-symbol="{{ platform_currency_symbol() }}"
|
|
data-currency-position-end="{{ platform_trans('platform_currency_position_end', null, false) ? '1' : '0' }}"
|
|
data-currency-thousand-separator="{{ platform_trans('platform_currency_thousand_separator', null, '') }}"
|
|
data-series-labels="{{ json_encode([
|
|
'count' => $this->isPartnerScoped() ? __('Partner profiles marked as inactive') : __('Profiles marked as inactive'),
|
|
'balance' => __('Total balance of their accounts'),
|
|
'incomplete' => $this->isPartnerScoped() ? __('Incomplete partner profiles') : __('Incomplete profiles'),
|
|
]) }}"
|
|
data-series-colors="{{ json_encode([
|
|
'count' => $inactiveColor,
|
|
'balance' => $inactiveBalanceColor,
|
|
'incomplete' => $incompleteColor,
|
|
]) }}"
|
|
data-footer-label="{{ __('Marked as inactive this month') }}">
|
|
<div class="relative aspect-video">
|
|
<canvas id="statsInactivationsChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@else
|
|
{{-- No bordered box: an empty frame reads as a chart that
|
|
failed to draw, where this is a real answer. --}}
|
|
<p class="text-sm text-theme-secondary">{{ __('No profiles were marked as inactive in this selection.') }}</p>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Trading network. Deliberately outside the @if (!$hasFilter) branch:
|
|
the graph works independently of "Show statistics" and has its own
|
|
period selector. The other page filters (location, registration,
|
|
profiles) do apply here.
|
|
|
|
data-network-section is the anchor the JS uses to find controls that
|
|
sit above the graph wrapper (search, drag toggle, spread button). --}}
|
|
<div class="mt-10" data-network-section>
|
|
<div class="cursor-help" title="{{ __('Each profile is a dot, each trading relationship a line. Dot size follows the traded volume, line thickness the number of transactions.') }}">
|
|
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">{{ __('Trading network') }}</h3>
|
|
@if ($hasFilter)
|
|
<p class="text-xs text-theme-secondary">{{ $this->sectionScope($networkPeriod === 'inherit') }}</p>
|
|
@endif
|
|
@if ($networkTimelapse && ($lapse = $this->networkTimelapseMonths()))
|
|
{{-- The period dropdown is replaced during a time-lapse, so
|
|
this names what the animation covers instead. --}}
|
|
<p class="text-xs text-theme-secondary">
|
|
{{ __('Time-lapse: :from to :till', [
|
|
'from' => $lapse[0]['label'],
|
|
'till' => $this->timelapseEndDate()?->translatedFormat('j M Y'),
|
|
]) }}
|
|
</p>
|
|
@elseif ($networkPeriod !== 'inherit')
|
|
<p class="text-xs text-theme-secondary">
|
|
{{ __('Period for this graph:') }} {{ $this->networkPeriodLabel() }}
|
|
</p>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="mt-3">
|
|
<x-jetstream.secondary-button wire:click="toggleNetwork" wire:loading.attr="disabled" wire:target="toggleNetwork">
|
|
{{ $showNetwork ? __('Hide network') : __('Show network') }}
|
|
</x-jetstream.secondary-button>
|
|
{{-- Shown only while the request is in flight: the graph pulls in
|
|
~570 kB of lazily loaded JavaScript plus its own aggregation,
|
|
so the wait needs acknowledging rather than looking like
|
|
nothing happened. --}}
|
|
<span class="ml-3 text-xs text-theme-secondary"
|
|
wire:loading wire:target="toggleNetwork">
|
|
{{ __('Loading... this may take a while.') }}
|
|
</span>
|
|
</div>
|
|
|
|
@if ($showNetwork && $network)
|
|
@php
|
|
$ns = $network['stats'];
|
|
$networkPeriods = [
|
|
'inherit' => __('Same period as the page'),
|
|
'since_one_year_ago' => __('Since one year ago'),
|
|
'previous_year' => __('Previous year'),
|
|
'previous_quarter' => __('Previous quarter'),
|
|
'previous_month' => __('Previous month'),
|
|
'all_time' => __('All time'),
|
|
];
|
|
$typeOptions = [
|
|
'User' => __('Users'),
|
|
'Organization' => __('Organizations'),
|
|
'Bank' => __('Banks'),
|
|
];
|
|
$layouts = [
|
|
'force' => __('Force-directed'),
|
|
'circle' => __('Circle'),
|
|
'concentric' => __('Rings by connections'),
|
|
'tree' => __('Tree'),
|
|
'grid' => __('Grid'),
|
|
];
|
|
// From the component, so this list cannot drift from the one
|
|
// the legend and the PDF summary use.
|
|
$views = \App\Http\Livewire\Admin\Stats::networkViewOptions();
|
|
@endphp
|
|
|
|
<div class="mt-4 flex flex-wrap items-center gap-4">
|
|
{{-- Two sliders instead of fixed buttons: degree runs from 1 to
|
|
700, which no fixed set of steps covers. The top stop means
|
|
"no upper bound"; above 50 sit only a handful of hubs, and a
|
|
linear scale up to 700 would cram 99% of the nodes into the
|
|
first percent.
|
|
|
|
wire:model.change rather than .live: .change only fires on
|
|
release, so dragging does not cost a server round trip and a
|
|
new layout per pixel. (.lazy is Livewire 2.) --}}
|
|
@php
|
|
$ceiling = \App\Services\StatsService::DEGREE_CEILING;
|
|
@endphp
|
|
<div class="flex items-center gap-3">
|
|
<span class="text-xs text-theme-secondary whitespace-nowrap">{{ __('Trading partners') }}</span>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<label class="text-xs text-theme-secondary" for="stats-network-min">{{ __('min') }}</label>
|
|
<input id="stats-network-min" type="range" min="0" max="{{ $ceiling }}" step="1"
|
|
wire:model.change="networkMinDegree" class="w-28 accent-theme-brand">
|
|
<span class="text-xs text-theme-primary font-semibold w-6 text-right">{{ $networkMinDegree }}</span>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<label class="text-xs text-theme-secondary" for="stats-network-max">{{ __('max') }}</label>
|
|
<input id="stats-network-max" type="range" min="0" max="{{ $ceiling }}" step="1"
|
|
wire:model.change="networkMaxDegree" class="w-28 accent-theme-brand">
|
|
<span class="text-xs text-theme-primary font-semibold w-8 text-right">
|
|
{{ $networkMaxDegree >= $ceiling ? '>' . $ceiling : $networkMaxDegree }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Circulation only: the other views encode nothing about
|
|
time, so the slider would have no effect there. --}}
|
|
@if ($networkView === 'circulation')
|
|
<div class="flex items-center gap-3"
|
|
title="{{ __('A profile counts as stopped when it has not paid anyone within this period, or when it is flagged inactive.') }}">
|
|
<span class="text-xs text-theme-secondary whitespace-nowrap cursor-help">{{ __('Define \'stopped\' at') }}</span>
|
|
@php $stoppedCeiling = \App\Http\Livewire\Admin\Stats::STOPPED_AFTER_CEILING; @endphp
|
|
<input id="stats-network-stopped" type="range" min="1" max="{{ $stoppedCeiling }}" step="1"
|
|
wire:model.change="networkStoppedAfterMonths" class="w-28 accent-theme-brand">
|
|
<span class="text-xs text-theme-primary font-semibold whitespace-nowrap">
|
|
{{-- ">60 months" rather than "No limit": the top
|
|
stop means anything longer than the ceiling
|
|
counts as stopped, which "no limit" read as
|
|
the opposite of. --}}
|
|
@if ($networkStoppedAfterMonths >= $stoppedCeiling)
|
|
>{{ $stoppedCeiling }} {{ __('months') }}
|
|
@else
|
|
{{ $networkStoppedAfterMonths }} {{ __('months') }}
|
|
@endif
|
|
</span>
|
|
</div>
|
|
@endif
|
|
|
|
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
<x-toggle id="stats-network-hub" name="stats-network-hub" wire:model.live="networkHideHub" />
|
|
<span class="text-xs text-theme-secondary">{{ __('Hide the largest hub') }}</span>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3">
|
|
<span class="text-xs text-theme-secondary">{{ __('Show') }}</span>
|
|
@foreach ($typeOptions as $type => $label)
|
|
<label class="inline-flex items-center gap-1.5 text-xs text-theme-primary cursor-pointer">
|
|
<input type="checkbox" wire:click="toggleNetworkType('{{ $type }}')"
|
|
@checked(in_array($type, $networkTypes, true))
|
|
class="rounded border-theme-border text-theme-brand focus:ring-theme-brand">
|
|
{{ $label }}
|
|
</label>
|
|
@endforeach
|
|
</div>
|
|
|
|
{{-- No wire:model: purely browser behaviour. Through Livewire
|
|
every click would discard the computed layout. --}}
|
|
<label class="inline-flex items-center gap-1.5 text-xs text-theme-primary cursor-pointer">
|
|
<input type="checkbox" checked data-network-drag-neighbours
|
|
class="rounded border-theme-border text-theme-brand focus:ring-theme-brand">
|
|
{{ __('Drag trading partners along') }}
|
|
</label>
|
|
|
|
{{-- These DO go through Livewire: they change which profiles
|
|
the query resolves, so the graph has to be rebuilt. --}}
|
|
<label class="inline-flex items-center gap-1.5 text-xs text-theme-primary cursor-pointer"
|
|
title="{{ __('Profiles closed by their owner. Their hours were transferred out on closure, so those flows only appear here.') }}">
|
|
<input type="checkbox" wire:model.live="networkIncludeRemoved"
|
|
class="rounded border-theme-border text-theme-brand focus:ring-theme-brand">
|
|
{{ __('Include removed profiles') }}
|
|
</label>
|
|
|
|
<label class="inline-flex items-center gap-1.5 text-xs text-theme-primary cursor-pointer"
|
|
title="{{ __('Profiles marked inactive. Drawn with a red outline.') }}">
|
|
<input type="checkbox" wire:model.live="networkMarkInactive"
|
|
class="rounded border-theme-border text-theme-brand focus:ring-theme-brand">
|
|
{{ __('Highlight inactive profiles') }}
|
|
</label>
|
|
|
|
{{-- Time-lapse, with the other toggles that change what is
|
|
shown. Its own settings live in the playback panel inside
|
|
the graph, next to the controls they affect. --}}
|
|
<label class="inline-flex items-center gap-1.5 text-xs text-theme-primary cursor-pointer"
|
|
title="{{ __('Step the graph through one month at a time, so a network that is growing or shrinking can be seen rather than inferred.') }}">
|
|
<input type="checkbox" wire:model.live="networkTimelapse"
|
|
class="rounded border-theme-border text-theme-brand focus:ring-theme-brand">
|
|
{{ __('Time-lapse') }}
|
|
</label>
|
|
|
|
@if ($this->isBankScoped())
|
|
<div class="flex items-center gap-2">
|
|
<x-toggle id="stats-network-edge" name="stats-network-edge" wire:model.live="networkEdge" />
|
|
<span class="text-xs text-theme-secondary">{{ __('Incl. network outside current bank') }}</span>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="mt-3 flex flex-wrap items-center gap-2">
|
|
<span class="text-xs text-theme-secondary">{{ __('Arrangement') }}</span>
|
|
@foreach ($layouts as $key => $label)
|
|
<button type="button" wire:click="setNetworkLayout('{{ $key }}')"
|
|
class="px-2 py-1 text-xs rounded border {{ $networkLayout === $key ? 'border-theme-brand text-theme-primary font-semibold' : 'border-theme-border text-theme-secondary' }}">
|
|
{{ $label }}
|
|
</button>
|
|
@endforeach
|
|
</div>
|
|
|
|
<div class="mt-3 flex flex-wrap items-center gap-2">
|
|
<span class="text-xs text-theme-secondary">{{ __('View') }}</span>
|
|
@foreach ($views as $key => $label)
|
|
<button type="button" wire:click="setNetworkView('{{ $key }}')"
|
|
class="px-2 py-1 text-xs rounded border {{ $networkView === $key ? 'border-theme-brand text-theme-primary font-semibold' : 'border-theme-border text-theme-secondary' }}">
|
|
{{ $label }}
|
|
</button>
|
|
@endforeach
|
|
|
|
</div>
|
|
|
|
<p class="mt-2 text-xs text-theme-secondary">
|
|
@if (($ns['max_degree'] ?? null) !== null && $ns['max_degree'] < \App\Services\StatsService::DEGREE_CEILING)
|
|
{{ __('Showing :shown of :total profiles with :min to :max trading partners.', [
|
|
'shown' => number_format($ns['shown_nodes'], 0, ',', '.'),
|
|
'total' => number_format($ns['total_nodes'], 0, ',', '.'),
|
|
'min' => $ns['min_degree'],
|
|
'max' => $ns['max_degree'],
|
|
]) }}
|
|
{{ __('The busiest profile has :count trading partners.', ['count' => $ns['highest_degree'] ?? 0]) }}
|
|
@else
|
|
{{ __('Showing :shown of :total profiles with at least :min trading partners.', [
|
|
'shown' => number_format($ns['shown_nodes'], 0, ',', '.'),
|
|
'total' => number_format($ns['total_nodes'], 0, ',', '.'),
|
|
'min' => $ns['min_degree'],
|
|
]) }}
|
|
@endif
|
|
</p>
|
|
|
|
{{-- Search field and period selector sit OUTSIDE the empty/non-empty
|
|
guard: with the period selector inside the else branch, the very
|
|
control needed to refill an empty graph would disappear. --}}
|
|
<div class="mt-3 mb-3 flex flex-wrap items-start gap-3">
|
|
@if ($ns['shown_nodes'] >= 2)
|
|
{{-- Searches the drawn graph, not the database. --}}
|
|
<div class="relative max-w-sm flex-1 min-w-[16rem]" data-network-search>
|
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
|
<svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
|
|
</svg>
|
|
</div>
|
|
<input type="search" autocomplete="off" data-network-search-input
|
|
placeholder="{{ __('Search a profile in the graph') }}"
|
|
class="block w-full rounded-md border border-theme-border bg-white py-2 pl-10 pr-3 leading-5 placeholder-gray-400 shadow-sm transition duration-150 ease-in-out focus:border-theme-border focus:placeholder-gray-700 sm:text-sm">
|
|
<div class="absolute z-30 mt-1 w-full rounded-md border border-theme-border bg-white shadow-lg"
|
|
data-network-search-results hidden></div>
|
|
</div>
|
|
@endif
|
|
|
|
@if ($networkTimelapse)
|
|
{{-- While the time-lapse is on, a period means nothing:
|
|
every frame accumulates from the first trade. The one
|
|
free choice is where the animation stops, so the
|
|
period dropdown gives way to that date. Span counts
|
|
back from it. The server clamps it to the range of real
|
|
trade, so an out-of-range pick snaps to the nearest
|
|
valid date rather than drawing an empty graph. --}}
|
|
@php
|
|
[$tradeFirst, $tradeLast] = $this->tradeRange();
|
|
@endphp
|
|
{{-- The same flatpickr as the page's own period filter:
|
|
stored Y-m-d, shown d-m-Y.
|
|
|
|
wire:ignore, like that one: flatpickr builds its own
|
|
visible input next to this one, and a Livewire morph
|
|
would otherwise strip it. The binding still works
|
|
through it - flatpickr fires both 'change' and 'input'
|
|
on the underlying input when a date is picked.
|
|
|
|
min/max stop an out-of-range pick at the source. That
|
|
matters here in particular: under wire:ignore the
|
|
picker could not show the server snapping a bad date
|
|
back into range.
|
|
|
|
Initialised by stats-network.js, because the package
|
|
only sets up pickers present at page load and this one
|
|
appears when the time-lapse is switched on. --}}
|
|
<div class="min-w-[13rem]"
|
|
title="{{ __('The date the time-lapse ends on. Span counts back from here.') }}">
|
|
<div wire:ignore data-network-timelapse-end>
|
|
<x-flatpickr
|
|
id="network-timelapse-end"
|
|
dateFormat="Y-m-d"
|
|
altFormat="d-m-Y"
|
|
:value="$networkTimelapseEnd"
|
|
:min-date="$tradeFirst?->toDateString()"
|
|
:max-date="$tradeLast?->toDateString()"
|
|
wire:model.live="networkTimelapseEnd"
|
|
placeholder="{{ __('Select end date') }}"
|
|
class="block w-full border-gray-300 rounded-md shadow-sm placeholder-theme-light"
|
|
/>
|
|
</div>
|
|
<p class="mt-1 text-xs text-theme-secondary">{{ __('Time-lapse ends on this date.') }}</p>
|
|
</div>
|
|
@else
|
|
{{-- Overrides the period for this graph only. --}}
|
|
<div class="min-w-[13rem]">
|
|
<select wire:model.live="networkPeriod"
|
|
class="block w-full rounded-md border border-theme-border bg-white py-2 pl-3 pr-8 leading-5 shadow-sm transition duration-150 ease-in-out focus:border-theme-border sm:text-sm">
|
|
@foreach ($networkPeriods as $key => $label)
|
|
<option value="{{ $key }}">{{ $label }}</option>
|
|
@endforeach
|
|
</select>
|
|
@if ($networkPeriod !== 'inherit')
|
|
<p class="mt-1 text-xs text-theme-secondary">{{ __('Applies to this graph only.') }}</p>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Vector export of the graph alone. No wire:click: a Livewire
|
|
round trip would rebuild the graph and discard the very
|
|
view the user is asking to export. --}}
|
|
<button type="button" data-network-svg
|
|
data-filename="trading-network"
|
|
title="{{ __('Download the graph exactly as shown, as an SVG vector file with editable text.') }}"
|
|
class="inline-flex items-center gap-1.5 self-start rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-primary shadow-sm hover:bg-theme-brand hover:text-white focus:outline-none focus:ring-2 focus:ring-theme-brand">
|
|
<x-icon name="arrow-down-tray" class="h-4 w-4" />
|
|
{{ __('SVG') }}
|
|
</button>
|
|
</div>
|
|
|
|
@if ($ns['shown_nodes'] < 2)
|
|
{{-- Two different causes with different remedies: the threshold
|
|
filters too strictly, or the selection holds no mutual trade
|
|
at all. "Lower the threshold" is useless when it is already
|
|
at 1. --}}
|
|
<p class="mt-3 text-sm text-theme-secondary">
|
|
@if ($ns['total_nodes'] > 0)
|
|
{{ __('Too little mutual trading in this selection to draw a network. Lower the minimum number of trading partners.') }}
|
|
@elseif ($filterByLastTransaction && $networkPeriod !== 'inherit')
|
|
{{-- This combination contradicts itself: profiles with
|
|
no transaction since X cannot, by definition, have
|
|
traded within a period inside X. --}}
|
|
{{ __('This graph period falls inside the inactivity filter, so these profiles have no transactions here by definition. Choose a longer graph period, or switch off "no transaction since".') }}
|
|
@else
|
|
{{ __('The profiles in this selection did not trade with each other, so there is no network to draw. Widen the filters or the period.') }}
|
|
@endif
|
|
</p>
|
|
@else
|
|
<div class="mt-3 p-4 bg-theme-background rounded-lg border border-theme-border"
|
|
data-stats-network="{{ json_encode($network) }}"
|
|
data-network-meta="{{ json_encode((function () use ($network) {
|
|
$l = $this->networkLegend($network);
|
|
|
|
return [
|
|
'caption' => $l['view_caption'] ?? '',
|
|
// The legend's explanation line, first in the SVG
|
|
// legend as it is on the page and in the PDF.
|
|
'hint' => !empty($l['view_hint'])
|
|
? ($l['view_label'] ?? '') . ': ' . $l['view_hint']
|
|
: null,
|
|
// Top right of the SVG export, like the posters.
|
|
'logo' => \App\Http\Livewire\Admin\Stats::networkExportLogo(),
|
|
'period' => \App\Http\Livewire\Admin\Stats::exportPeriodLabel(
|
|
$this->effectiveNetworkPeriodLabel(),
|
|
$network['stats']['last_date'] ?? null
|
|
),
|
|
|
|
'colorMeaning' => __('Colour') . ': ' . ($l['color_meaning'] ?? ''),
|
|
'colors' => $l['colors'] ?? [],
|
|
'sizeLabel' => __('Dot size'),
|
|
'sizeMeaning' => $l['size_meaning'] ?? '',
|
|
'widthLabel' => __('Line thickness'),
|
|
'widthMeaning' => $l['width_meaning'] ?? '',
|
|
// Only the currency-flow view populates these; the
|
|
// SVG legend was the one surface that never showed them.
|
|
'directions' => $l['directions'] ?? [],
|
|
'nodeStyles' => $l['node_styles'] ?? [],
|
|
'settings' => collect($this->networkSettingsSummary())
|
|
->map(fn ($r) => $r['label'] . ': ' . $r['value'])
|
|
->implode(' | '),
|
|
];
|
|
})()) }}"
|
|
data-hide-hub="{{ $networkHideHub ? '1' : '0' }}"
|
|
data-network-anonymize="{{ $anonymizeProfiles ? '1' : '0' }}"
|
|
data-network-mark-inactive="{{ $networkMarkInactive ? '1' : '0' }}"
|
|
data-network-timelapse="{{ $networkTimelapse ? '1' : '0' }}"
|
|
data-network-timelapse-months="{{ $networkTimelapse ? json_encode($this->networkTimelapseMonths()) : '[]' }}"
|
|
{{-- Changes what every frame contains without changing the
|
|
month list, so it needs its own place in the rebuild
|
|
signature. --}}
|
|
data-network-timelapse-cumulative="{{ $networkTimelapseCumulative ? '1' : '0' }}"
|
|
data-network-timelapse-step="{{ $networkTimelapseStep }}"
|
|
data-network-pseudonyms="{{ json_encode([
|
|
'User' => __('User'),
|
|
'Organization' => __('Organization'),
|
|
]) }}"
|
|
data-network-stopped-after="{{ $networkStoppedAfterMonths >= \App\Http\Livewire\Admin\Stats::STOPPED_AFTER_CEILING ? '' : $networkStoppedAfterMonths * 30 }}"
|
|
{{-- The platform's own currency, so amounts render as the rest
|
|
of the site does rather than with a hardcoded suffix. --}}
|
|
data-currency="{{ json_encode([
|
|
'symbol' => platform_currency_symbol(),
|
|
'atEnd' => (bool) platform_trans('platform_currency_position_end', null, false),
|
|
'thousands' => platform_trans('platform_currency_thousand_separator', null, ''),
|
|
]) }}"
|
|
data-cluster-too-large="{{ __('Too many profiles to group into clusters (:count, limit :limit). Narrow the period or raise the minimum trading partners.') }}"
|
|
data-network-view="{{ $networkView }}"
|
|
data-network-layout="{{ $networkLayout }}"
|
|
data-network-labels="{{ json_encode([
|
|
'partners' => __('Trading partners'),
|
|
'hidden' => __('not shown'),
|
|
'drawFailed' => __('The network could not be drawn.'),
|
|
'loading' => __('Loading the network...'),
|
|
'preparing' => __('Preparing :count profiles...'),
|
|
'arranging' => __('Arranging :count profiles...'),
|
|
'clustering' => __('Finding clusters in :count profiles, this can take a moment...'),
|
|
'drawEmpty' => __('No network to draw for this selection.'),
|
|
'timelapseLoading' => __('Loading the time-lapse...'),
|
|
'timelapseFailed' => __('The time-lapse could not be loaded.'),
|
|
'timelapseEmpty' => __('Nothing to animate for this selection.'),
|
|
'layoutFallback' => __('The usual arrangement failed, so a simpler one is shown.'),
|
|
'timelapsePlay' => __('Play'),
|
|
'timelapsePause' => __('Pause'),
|
|
'paid' => __('Paid'),
|
|
'received' => __('Received'),
|
|
'total' => __('Total traded'),
|
|
'flowRole' => __('Hours'),
|
|
'stale' => __('Stagnant here'),
|
|
'flow' => [
|
|
'circulating' => __('Circulating'),
|
|
'deadEnd' => __('Dead end'),
|
|
'source' => __('Source'),
|
|
'isolated' => __('No flow'),
|
|
],
|
|
'noMatch' => __('No profile found in this graph'),
|
|
'noMatchHint' => __('Profiles below the minimum trading partners, or of a type that is not ticked, are not drawn in the graph.'),
|
|
'bank' => __('Bank network'),
|
|
'cluster' => __('Cluster'),
|
|
'profiles' => __('profiles'),
|
|
'more' => __('+ :count more'),
|
|
]) }}"
|
|
wire:key="stats-network-{{ $networkMinDegree }}-{{ $networkMaxDegree }}-{{ $networkHideHub ? 1 : 0 }}-{{ $networkView }}-{{ $networkEdge ? 1 : 0 }}-{{ implode(',', $networkTypes) }}-{{ $networkLayout }}-{{ $networkPeriod }}-{{ $networkIncludeRemoved ? 1 : 0 }}-{{ $networkMarkInactive ? 1 : 0 }}-{{ $networkStoppedAfterMonths }}">
|
|
|
|
{{-- Info text left, spread button right, on one row. The
|
|
paragraphs are hidden until they have something to say,
|
|
so the button keeps its place through min-h and
|
|
items-start rather than depending on their height. --}}
|
|
{{-- Full screen shows this box: the header row and the graph.
|
|
In full screen the header floats over the top of the
|
|
canvas, so hiding it never resizes the graph; it and the
|
|
playback panel fade out after 2s without movement and come
|
|
back on any pointer movement, tap or drag (stats-network.js). --}}
|
|
<div class="group relative [&:fullscreen]:bg-theme-surface" data-network-screen>
|
|
<div class="flex items-start justify-between gap-3 mb-2 min-h-[1.75rem]
|
|
group-fullscreen:absolute group-fullscreen:inset-x-0 group-fullscreen:top-0 group-fullscreen:z-30 group-fullscreen:m-0 group-fullscreen:px-4 group-fullscreen:pt-3 group-fullscreen:pb-2 group-fullscreen:bg-theme-surface/90 group-fullscreen:backdrop-blur-sm
|
|
transition-opacity duration-300 group-[.controls-hidden]:opacity-0 group-[.controls-hidden]:pointer-events-none"
|
|
data-network-header>
|
|
<div class="flex-1">
|
|
{{-- The question this view answers, above the runtime notes and
|
|
sharing their left margin. --}}
|
|
<p class="text-xs font-medium text-theme-primary mb-1">
|
|
{{ \App\Http\Livewire\Admin\Stats::networkViewCaption($networkView) }}
|
|
</p>
|
|
|
|
{{-- Filled by the circulation classifier, which runs
|
|
in the browser on the visible set - the server
|
|
cannot know the counts.
|
|
|
|
NOT hidden when empty, and given a fixed
|
|
height: during a time-lapse this line changes
|
|
on every frame, and letting it collapse
|
|
resized the graph container underneath it
|
|
frame by frame. It always has something to
|
|
say - at minimum how much is drawn. --}}
|
|
<p class="text-xs text-theme-secondary mb-1 min-h-[1rem]" data-network-stagnant
|
|
data-drawn-template="{{ __('Showing :nodes profiles and :edges connections.') }}"
|
|
data-template="{{ __('Of these, :count are not passing hours on (:never never have), holding :hours in total.') }}"></p>
|
|
|
|
{{-- Below the subtext, in the warning colour: the graph is
|
|
drawn but one view could not be computed.
|
|
Reserves its line for the same reason. --}}
|
|
<p class="text-xs text-yellow-600 mb-1 min-h-[1rem]" data-network-warning>
|
|
<span data-network-warning-text></span>
|
|
</p>
|
|
|
|
<p class="text-xs text-theme-primary" data-network-focus hidden
|
|
data-template="{{ __('Showing :name and its :count trading partners. Click the dot again, or the background, to show everything.') }}"></p>
|
|
|
|
<p class="text-xs text-theme-secondary" data-network-note hidden
|
|
data-template="{{ __(':name is hidden: with :count trading partners it would flatten the rest of the graph.') }}"></p>
|
|
</div>
|
|
|
|
<div class="shrink-0 flex items-center gap-1">
|
|
<button type="button" data-network-shrink
|
|
title="{{ __('Pull closer together') }}"
|
|
aria-label="{{ __('Pull closer together') }}"
|
|
class="inline-flex items-center justify-center rounded-md border border-theme-border bg-theme-surface p-1.5 text-theme-primary hover:bg-theme-brand hover:text-white focus:outline-none focus:ring-2 focus:ring-theme-brand">
|
|
<x-icon name="arrows-pointing-in" class="h-4 w-4" />
|
|
</button>
|
|
|
|
<button type="button" data-network-spread
|
|
title="{{ __('Spread out more') }}"
|
|
aria-label="{{ __('Spread out more') }}"
|
|
class="inline-flex items-center justify-center rounded-md border border-theme-border bg-theme-surface p-1.5 text-theme-primary hover:bg-theme-brand hover:text-white focus:outline-none focus:ring-2 focus:ring-theme-brand">
|
|
<x-icon name="arrows-pointing-out" class="h-4 w-4" />
|
|
</button>
|
|
|
|
{{-- Full screen for the graph's own box only (canvas and
|
|
playback), not the page. Esc leaves it. Hidden by the
|
|
script where the browser cannot do it (iPhone). --}}
|
|
<button type="button" data-network-fullscreen
|
|
title="{{ __('Full screen') }}"
|
|
aria-label="{{ __('Full screen') }}"
|
|
data-label-enter="{{ __('Full screen') }}"
|
|
data-label-exit="{{ __('Exit full screen (Esc)') }}"
|
|
class="inline-flex items-center justify-center rounded-md border border-theme-border bg-theme-surface p-1.5 text-theme-primary hover:bg-theme-brand hover:text-white focus:outline-none focus:ring-2 focus:ring-theme-brand">
|
|
{{-- Four corner brackets pointing out... --}}
|
|
<svg class="h-4 w-4" data-fullscreen-enter viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"/>
|
|
</svg>
|
|
{{-- ...and pointing in, to leave. --}}
|
|
<svg class="h-4 w-4 hidden" data-fullscreen-exit viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M9 4v5H4M20 9h-5V4M15 20v-5h5M4 15h5v5"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- wire:ignore keeps the canvas out of Livewire's morph, so
|
|
the zoom, pan and any focused node survive a re-render
|
|
(exporting a PDF goes through the server and would
|
|
otherwise reset the view). The wrapper's data-*
|
|
attributes above are still updated, so changing a
|
|
setting rebuilds the graph as normal. --}}
|
|
{{-- The placeholder is rendered inside the host so it is
|
|
visible the moment the element exists. Cytoscape empties
|
|
the host when it mounts, which removes it - no JS needed
|
|
to hide it, and nothing to leave behind on failure. --}}
|
|
{{-- data-network-frame: what full screen shows. The browser
|
|
sizes it to the screen; its background must be set, or the
|
|
black full-screen backdrop shows through the canvas. --}}
|
|
<div class="relative w-full h-[560px] group-fullscreen:h-full" data-network-frame>
|
|
<div class="absolute inset-0" wire:ignore data-network-host>
|
|
<div class="flex h-full items-center justify-center" data-network-placeholder>
|
|
<span class="text-sm text-theme-secondary">{{ __('Drawing the network...') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
@if ($networkTimelapse)
|
|
{{-- Playback sits INSIDE the graph, bottom left:
|
|
it steers what the canvas shows, so it belongs
|
|
with the picture rather than up among the
|
|
filters that rebuild it.
|
|
|
|
Outside wire:ignore deliberately - Livewire
|
|
must keep this markup in step when the span or
|
|
window changes - but absolutely positioned over
|
|
the host, which is the element Cytoscape owns
|
|
and empties on mount.
|
|
|
|
Driven entirely by JS: a Livewire round trip
|
|
per frame would rebuild the graph and throw
|
|
away the union layout the animation needs. --}}
|
|
<div class="absolute bottom-3 left-3 z-10 flex flex-wrap items-center gap-x-3 gap-y-1.5
|
|
max-w-[calc(100%-1.5rem)]
|
|
rounded-md border border-theme-border bg-theme-surface/90
|
|
px-2 py-1.5 shadow-sm backdrop-blur-sm
|
|
transition-opacity duration-300 group-[.controls-hidden]:opacity-0 group-[.controls-hidden]:pointer-events-none"
|
|
data-network-timelapse-controls>
|
|
{{-- One wrapping row, playback first: the
|
|
settings follow the speed slider and drop
|
|
to a second line only when the panel runs
|
|
out of width. max-w keeps it inside the
|
|
graph rather than spilling past the edge. --}}
|
|
|
|
{{-- wire:ignore around everything the JS drives
|
|
- play, scrubber, month label, speed. They
|
|
sit outside the graph's own wire:ignore, so
|
|
any Livewire update that did not change the
|
|
graph put back the server's version: the
|
|
spinner reappeared, the scrubber jumped to 0
|
|
and the speed slider to its default, while
|
|
the player ran on and nothing restored them.
|
|
The JS now sets the busy state itself when a
|
|
rebuild starts. `contents` keeps the panel's
|
|
single wrapping row. Accumulate and Span stay
|
|
outside: those are Livewire settings. --}}
|
|
<div class="contents" wire:ignore data-timelapse-playback>
|
|
|
|
{{-- Both icons are rendered and one is hidden,
|
|
rather than having the JS write icon markup:
|
|
the SVG stays in Blade where the rest of the
|
|
icons live. The label travels on aria-label
|
|
and title, which the JS swaps with the
|
|
icons. --}}
|
|
<div class="flex items-center gap-2">
|
|
{{-- Starts disabled, with a spinner: the
|
|
frames are still being built when this
|
|
first renders, and a Play button that
|
|
looks ready but does nothing is worse
|
|
than one that says it is busy. The JS
|
|
enables it once the graph is drawn. --}}
|
|
<button type="button" data-timelapse-play disabled
|
|
title="{{ __('Loading the time-lapse...') }}"
|
|
aria-label="{{ __('Loading the time-lapse...') }}"
|
|
class="inline-flex items-center justify-center rounded-md border border-theme-border bg-theme-surface p-1.5 text-theme-primary hover:bg-theme-brand hover:text-white focus:outline-none focus:ring-2 focus:ring-theme-brand disabled:cursor-wait disabled:opacity-60 disabled:hover:bg-theme-surface disabled:hover:text-theme-primary">
|
|
<x-icon name="play" class="h-4 w-4 hidden" data-timelapse-icon-play />
|
|
<x-icon name="pause" class="h-4 w-4 hidden" data-timelapse-icon-pause />
|
|
<svg class="h-4 w-4 animate-spin" data-timelapse-icon-busy
|
|
viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10"
|
|
stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor"
|
|
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
|
|
</svg>
|
|
</button>
|
|
|
|
<input type="range" data-timelapse-scrubber min="0" max="0" step="1" value="0"
|
|
class="w-32 accent-theme-brand">
|
|
|
|
{{-- Fixed width for the longest label ("30 Sep 2026" on
|
|
weekly steps) and never wrapped or squeezed, so the
|
|
controls beside it do not shift as the date changes. --}}
|
|
<span class="text-xs text-theme-primary font-semibold w-24 shrink-0 whitespace-nowrap tabular-nums text-right"
|
|
data-timelapse-year></span>
|
|
</div>
|
|
|
|
{{-- A SPEED, so slow is on the left and fast
|
|
on the right. 0-100 on a log scale from 0.5
|
|
to 30 months a second - see speedFromSlider()
|
|
in stats-network.js. 36 is ~2.2 months a
|
|
second, the pace the slider used to default
|
|
to. --}}
|
|
<label class="inline-flex items-center gap-1.5 text-xs text-theme-secondary"
|
|
title="{{ __('Months shown per second. Slide right to play faster.') }}">
|
|
{{ __('Speed') }}
|
|
<input type="range" data-timelapse-speed
|
|
min="0" max="100" step="1" value="36"
|
|
class="w-20 accent-theme-brand">
|
|
<span class="w-10 text-right" data-timelapse-speed-value>2.2/s</span>
|
|
</label>
|
|
</div>
|
|
|
|
{{-- Off, each frame holds only its own month's
|
|
trade: a window exactly one frame long. Goes
|
|
through Livewire, because it changes what
|
|
the server puts in every frame. --}}
|
|
<label class="inline-flex items-center gap-1.5 text-xs text-theme-primary cursor-pointer"
|
|
title="{{ __('On: each frame shows all trade up to that month, so the network builds up. Off: each frame shows only that month\'s trade.') }}">
|
|
<input type="checkbox" wire:model.live="networkTimelapseCumulative"
|
|
class="rounded border-theme-border text-theme-brand focus:ring-theme-brand">
|
|
{{ __('Accumulate') }}
|
|
</label>
|
|
|
|
{{-- Step: how far each frame moves on. The layers
|
|
stay months either way; weekly only moves
|
|
them in smaller, smoother steps. --}}
|
|
<label class="inline-flex items-center gap-1.5 text-xs text-theme-primary"
|
|
title="{{ __('How far each frame moves on. The layers stay months either way: a weekly step only moves them more smoothly.') }}">
|
|
{{ __('Step') }}
|
|
<select wire:model.live="networkTimelapseStep"
|
|
class="rounded-md border-theme-border bg-theme-surface py-0.5 pl-1.5 pr-6 text-xs text-theme-primary focus:border-theme-brand focus:ring-theme-brand">
|
|
<option value="month">{{ __('Month') }}</option>
|
|
<option value="week">{{ __('Week') }}</option>
|
|
</select>
|
|
</label>
|
|
|
|
{{-- Span: how far back the animation runs.
|
|
Every frame accumulates, so this only sets
|
|
where it starts, not what each frame
|
|
contains. --}}
|
|
<label class="inline-flex items-center gap-1.5 text-xs text-theme-primary"
|
|
title="{{ __('How far back the animation runs.') }}">
|
|
{{ __('Span') }}
|
|
<select wire:model.live="networkTimelapseSpan"
|
|
class="rounded-md border-theme-border bg-theme-surface py-0.5 pl-1.5 pr-6 text-xs text-theme-primary focus:border-theme-brand focus:ring-theme-brand">
|
|
<option value="12">{{ __(':count year', ['count' => 1]) }}</option>
|
|
<option value="24">{{ __(':count years', ['count' => 2]) }}</option>
|
|
<option value="36">{{ __(':count years', ['count' => 3]) }}</option>
|
|
<option value="60">{{ __(':count years', ['count' => 5]) }}</option>
|
|
<option value="120">{{ __(':count years', ['count' => 10]) }}</option>
|
|
<option value="0">{{ __('All time') }}</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>{{-- data-network-screen --}}
|
|
</div>
|
|
|
|
@php
|
|
$legend = $this->networkLegend($network);
|
|
@endphp
|
|
<div class="mt-3 p-3 bg-theme-surface rounded-md border border-theme-border">
|
|
<div class="text-xs uppercase tracking-wide text-theme-secondary mb-2">{{ __('Legend') }}</div>
|
|
|
|
{{-- What the current view encodes. Volume and flow differ
|
|
in both dot size and line meaning, which the graph
|
|
alone does not reveal. --}}
|
|
@if (!empty($legend['view_hint']))
|
|
<p class="text-xs text-theme-primary mb-3">
|
|
<span class="font-semibold">{{ $legend['view_label'] ?? '' }}:</span>
|
|
{{ $legend['view_hint'] }}
|
|
</p>
|
|
@endif
|
|
|
|
<div class="flex flex-wrap gap-x-8 gap-y-3">
|
|
<div>
|
|
<div class="text-xs text-theme-secondary mb-1">{{ __('Colour') }}: {{ $legend['color_meaning'] }}</div>
|
|
|
|
{{-- Clusters are computed in the browser, so their
|
|
colours and sizes are filled in by JS. --}}
|
|
@if ($networkView === 'cluster')
|
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1"
|
|
data-network-cluster-legend hidden></div>
|
|
@endif
|
|
|
|
@if (!empty($legend['colors']))
|
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
|
|
@foreach ($legend['colors'] as $item)
|
|
<span class="inline-flex items-center gap-1.5 text-xs text-theme-primary">
|
|
<span class="inline-block w-3 h-3 rounded-full"
|
|
style="background-color: {{ $item['color'] }};{{ !empty($item['dashed']) ? ' border: 2px dashed #64748b;' : '' }}"></span>
|
|
{{ $item['label'] }}
|
|
</span>
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
<div>
|
|
<div class="text-xs text-theme-secondary mb-1">{{ __('Dot size') }}: {{ $legend['size_meaning'] }}</div>
|
|
<div class="flex items-center gap-2 text-xs text-theme-secondary">
|
|
<span class="inline-block rounded-full bg-theme-primary" style="width: 6px; height: 6px;"></span>
|
|
<span class="inline-block rounded-full bg-theme-primary" style="width: 11px; height: 11px;"></span>
|
|
<span class="inline-block rounded-full bg-theme-primary" style="width: 17px; height: 17px;"></span>
|
|
<span class="ml-1">{{ __('small to large') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div class="text-xs text-theme-secondary mb-1">{{ __('Line thickness') }}: {{ $legend['width_meaning'] }}</div>
|
|
<div class="flex items-center gap-2 text-xs text-theme-secondary">
|
|
<span class="inline-block bg-theme-primary" style="width: 22px; height: 1px;"></span>
|
|
<span class="inline-block bg-theme-primary" style="width: 22px; height: 3px;"></span>
|
|
<span class="inline-block bg-theme-primary" style="width: 22px; height: 6px;"></span>
|
|
<span class="ml-1">{{ __('few to many') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Node outline styles, when a toggle draws them. --}}
|
|
@if (!empty($legend['node_styles']))
|
|
<div>
|
|
<div class="text-xs text-theme-secondary mb-1">{{ __('Profile state') }}</div>
|
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
|
|
@foreach ($legend['node_styles'] as $style)
|
|
<span class="inline-flex items-center gap-1.5 text-xs text-theme-primary">
|
|
<span class="inline-block w-3 h-3 rounded-full"
|
|
style="border: 2px {{ $style['dashed'] ? 'dashed' : 'solid' }} {{ $style['color'] }};{{ $style['hollow'] ? '' : ' background-color: #94a3b8;' }}"></span>
|
|
{{ $style['label'] }}
|
|
</span>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
@if (!empty($legend['directions']))
|
|
<div>
|
|
<div class="text-xs text-theme-secondary mb-1">{{ __('Transaction type') }}</div>
|
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
|
|
@foreach ($legend['directions'] as $dir)
|
|
<span class="inline-flex items-center gap-1.5 text-xs text-theme-primary">
|
|
{{-- Dashed for bookkeeping edges, matching the graph. --}}
|
|
@if (!empty($dir['dashed']))
|
|
<span class="inline-block" style="width: 22px; height: 0; border-top: 3px dashed {{ $dir['color'] }};"></span>
|
|
@else
|
|
<span class="inline-block" style="width: 22px; height: 3px; background-color: {{ $dir['color'] }};"></span>
|
|
@endif
|
|
{{ $dir['label'] }}
|
|
</span>
|
|
@endforeach
|
|
</div>
|
|
<div class="mt-1 text-xs text-theme-secondary">
|
|
{{ __('An arrow runs from payer to receiver, coloured by transaction type. A pair that traded two types gets an arrow for each, fanned out so they do not overlap.') }}
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
<p class="mt-2 text-xs text-theme-secondary">
|
|
{{ __('Hover over a dot to see its name and its trading partners.') }}
|
|
{{ __('Click a dot to show only that profile and the ones it trades with.') }}
|
|
{{ __('Drag a dot to spread the graph out; its trading partners move along unless you switch that off.') }}
|
|
{{ __('Use the search box to find a profile in this graph.') }}
|
|
</p>
|
|
</div>
|
|
@endif
|
|
@endif
|
|
</div>
|
|
|
|
</div>
|