Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,389 @@
|
||||
@php
|
||||
$fmt = function ($minutes) use ($decimalFormat) {
|
||||
if ($decimalFormat) {
|
||||
$negative = $minutes < 0;
|
||||
return ($negative ? '-' : '') . number_format(abs($minutes) / 60, 2, ',', '.') . ' h.';
|
||||
}
|
||||
return tbFormat($minutes);
|
||||
};
|
||||
$num = fn($value) => number_format($value, 0, ',', '.');
|
||||
|
||||
$logoPath = theme_email_logo_file();
|
||||
$logo = file_exists($logoPath)
|
||||
? 'data:image/png;base64,' . base64_encode(file_get_contents($logoPath))
|
||||
: null;
|
||||
@endphp
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ $title['header'] }}</title>
|
||||
<style>
|
||||
/* Matches the per-profile report's page inset: that template has no
|
||||
@page rule and relies on a 20px body margin plus an 80px header
|
||||
offset, which at 150 dpi lands far wider than the 25/30 used here
|
||||
before. Expressed as @page so every page gets it, not just the
|
||||
first. */
|
||||
@page { margin: 70px 60px; }
|
||||
body { font-family: 'DejaVu Sans', sans-serif; font-size: 8.5px; color: #222; margin: 0; }
|
||||
.header { border-bottom: 2px solid #222; padding-bottom: 10px; margin-bottom: 22px; }
|
||||
.header .platform { font-size: 9px; text-transform: uppercase; letter-spacing: 1px; color: #666; margin-bottom: 4px; }
|
||||
.header h1 { font-size: 20px; margin: 0 0 4px 0; }
|
||||
.header .sub { font-size: 10px; color: #666; }
|
||||
/* Applied filters, listed under the title: a printed report is read
|
||||
away from the page that produced it, so it has to say what it covers. */
|
||||
.header .filters { margin-top: 10px; font-size: 9px; color: #222; width: auto; }
|
||||
.header .filters td { border: none; padding: 0 22px 0 0; vertical-align: top; }
|
||||
.header .filters .key { font-size: 8px; text-transform: uppercase; letter-spacing: 0.5px; color: #777; }
|
||||
.logo { height: 34px; }
|
||||
/* padding-top rather than margin-top: dompdf collapses a margin against
|
||||
a preceding table, and every section here ends in one, so the gap
|
||||
between sections vanished. Padding is not collapsed. */
|
||||
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; margin: 0 0 8px 0; padding-top: 18px; border-bottom: 1px solid #ccc; padding-bottom: 5px; }
|
||||
/* The first section follows the header rule, which already spaces it. */
|
||||
h2.first { padding-top: 0; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { text-align: left; font-size: 8px; text-transform: uppercase; color: #666; border-bottom: 1px solid #ccc; padding: 2.5px 3px; }
|
||||
td { padding: 2px 3px; border-bottom: 1px solid #eee; }
|
||||
.right { text-align: right; }
|
||||
.tiles { margin-bottom: 14px; }
|
||||
.tiles td { border: none; padding: 0 6px 0 0; width: 25%; }
|
||||
.tile { border: 1px solid #ddd; padding: 8px; }
|
||||
.tile .label { font-size: 8px; text-transform: uppercase; color: #666; }
|
||||
.tile .value { font-size: 13px; font-weight: bold; margin-top: 2px; }
|
||||
/* dompdf honours page-break-before on a block element. */
|
||||
.page-break { page-break-before: always; }
|
||||
.chart-label { font-size: 9px; text-transform: uppercase; letter-spacing: 0.5px; color: #666; margin-bottom: 2px; }
|
||||
.note { color: #666; font-size: 8.5px; margin: 5px 0 10px 0; }
|
||||
/* Heights come from the bitmaps themselves now: chartToPng() renders
|
||||
each chart at the size its slot displays, so height:auto preserves
|
||||
the aspect ratio instead of squashing the plot. */
|
||||
.chart { width: 100%; height: auto; border: 1px solid #ccc; margin-top: 6px; }
|
||||
.chart-pair { width: 100%; height: auto; border: 1px solid #ccc; margin-top: 2px; }
|
||||
.footer { margin-top: 14px; border-top: 1px solid #ccc; padding-top: 5px; font-size: 7px; color: #888; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<table class="header" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td>
|
||||
<div class="platform">{{ platform_name() }}</div>
|
||||
<h1>{{ $title['header'] }}</h1>
|
||||
@if (!empty($appliedFilters))
|
||||
{{-- A table, not inline spans: dompdf ignores flex and gap, so
|
||||
cells are the only reliable way to separate these. --}}
|
||||
<table class="filters" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
@foreach ($appliedFilters as $item)
|
||||
<td>
|
||||
<span class="key">{{ $item['label'] }}</span><br>
|
||||
<strong>{{ $item['value'] }}</strong>
|
||||
</td>
|
||||
@endforeach
|
||||
</tr>
|
||||
</table>
|
||||
@else
|
||||
<div class="sub">{{ $title['sub'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
@if ($logo)
|
||||
<td class="right" style="border:none;"><img src="{{ $logo }}" class="logo" alt=""></td>
|
||||
@endif
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table class="tiles" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td><div class="tile"><div class="label">{{ ($partnerScoped ?? false) ? __('Partner profiles') : __('Profiles') }}</div><div class="value">{{ $num($stats['profileCount']) }}</div></div></td>
|
||||
<td><div class="tile"><div class="label">{{ __('Transactions') }}</div><div class="value">{{ $num($stats['economic']['transaction_count']) }}</div></div></td>
|
||||
<td><div class="tile"><div class="label">{{ __('Total volume') }}</div><div class="value">{{ $fmt($stats['economic']['total_volume']) }}</div></div></td>
|
||||
<td><div class="tile"><div class="label">{{ __('Total balance') }}</div><div class="value">{{ $fmt($stats['balance']) }}</div></div></td>
|
||||
</tr>
|
||||
{{-- On its own row: a fifth column would squeeze the four above it, and
|
||||
dompdf gives the cells a fixed share of the page width. --}}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="tile">
|
||||
<div class="label">{{ __('Incomplete') }}</div>
|
||||
<div class="value">
|
||||
@php
|
||||
$incompleteCount = (int) ($stats['online']['incomplete'] ?? 0);
|
||||
$profileTotal = (int) ($stats['profileCount'] ?? 0);
|
||||
@endphp
|
||||
{{ $profileTotal > 0 ? number_format(100 * $incompleteCount / $profileTotal, 1, ',', '.') . '%' : '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td colspan="3"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2 class="first">{{ __('Economic activity') }}</h2>
|
||||
<div class="note">
|
||||
{{ __('Internal:') }} {{ $fmt($stats['economic']['internal_volume']) }} ·
|
||||
{{ __('External:') }} {{ $fmt($stats['economic']['external_volume']) }}
|
||||
</div>
|
||||
|
||||
@if ($stats['economic']['by_type']->isNotEmpty())
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ __('Transaction type') }}</th>
|
||||
<th class="right">{{ __('Count') }}</th>
|
||||
<th class="right">{{ __('Volume') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($stats['economic']['by_type'] as $type)
|
||||
<tr>
|
||||
<td>{{ $type['type_name'] }}</td>
|
||||
<td class="right">{{ $num($type['count']) }}</td>
|
||||
<td class="right">{{ $fmt($type['volume']) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@endif
|
||||
|
||||
@if (!empty($charts['volume']))
|
||||
<img src="{{ $charts['volume'] }}" class="chart" alt="">
|
||||
@endif
|
||||
|
||||
<h2>{{ ($partnerScoped ?? false) ? __('Partner profiles and activity') : __('Profiles and activity') }}</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ ($partnerScoped ?? false) ? __('Partner profile type') : __('Profile type') }}</th>
|
||||
<th class="right">{{ __('Total') }}</th>
|
||||
<th class="right">{{ __('Active') }}</th>
|
||||
<th class="right">{{ __('Inactive') }}</th>
|
||||
<th class="right">{{ __('Recent active < 30d') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($stats['online']['by_type'] as $typeName => $row)
|
||||
<tr>
|
||||
<td>{{ __($typeName) }}</td>
|
||||
<td class="right">{{ $num($row['total']) }}</td>
|
||||
<td class="right">{{ $num($row['active']) }}</td>
|
||||
<td class="right">{{ $num($row['inactive']) }}</td>
|
||||
<td class="right">{{ $row['recent_logins'] === null ? '-' : $num($row['recent_logins']) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{-- One chart per profile type, two to a row — same split as the screen. --}}
|
||||
@php
|
||||
$registrationCharts = collect([
|
||||
['key' => 'registrations', 'label' => __('Users')],
|
||||
['key' => 'registrationsOrg', 'label' => __('Organizations')],
|
||||
])->filter(fn($c) => !empty($charts[$c['key']]))->values();
|
||||
@endphp
|
||||
|
||||
@if ($registrationCharts->isNotEmpty())
|
||||
<table class="tiles" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
@foreach ($registrationCharts as $chart)
|
||||
<td style="width: 50%; vertical-align: top; padding: 0 6px 10px 0;">
|
||||
<div class="chart-label">{{ $chart['label'] }}</div>
|
||||
<img src="{{ $charts[$chart['key']] }}" class="chart-pair" alt="{{ $chart['label'] }}">
|
||||
</td>
|
||||
@endforeach
|
||||
@if ($registrationCharts->count() === 1)
|
||||
<td style="width: 50%;"></td>
|
||||
@endif
|
||||
</tr>
|
||||
</table>
|
||||
@endif
|
||||
|
||||
<h2 class="page-break">{{ __('Engagement') }}</h2>
|
||||
<table class="tiles" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td><div class="tile"><div class="label">{{ __('Posts') }}</div><div class="value">{{ $num($stats['engagement']['posts']) }}</div></div></td>
|
||||
<td><div class="tile"><div class="label">{{ __('Calls') }}</div><div class="value">{{ $num($stats['engagement']['calls']) }}</div></div></td>
|
||||
<td><div class="tile"><div class="label">{{ __('Reactions') }}</div><div class="value">{{ $num($stats['engagement']['reactions']) }}</div></div></td>
|
||||
<td><div class="tile"><div class="label">{{ __('Messages') }}</div><div class="value">{{ $num($stats['engagement']['messages']) }}</div></div></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{{-- One chart per metric, two per row: reactions run in the thousands where
|
||||
posts run in single digits, so a shared axis hides the smaller series. --}}
|
||||
@php
|
||||
$engagementCharts = collect([
|
||||
['key' => 'engagementPosts', 'label' => __('Posts')],
|
||||
['key' => 'engagementCalls', 'label' => __('Calls')],
|
||||
['key' => 'engagementReactions', 'label' => __('Reactions')],
|
||||
['key' => 'engagementMessages', 'label' => __('Messages')],
|
||||
])->filter(fn($c) => !empty($charts[$c['key']]))->values();
|
||||
@endphp
|
||||
|
||||
@if ($engagementCharts->isNotEmpty())
|
||||
<table class="tiles" cellpadding="0" cellspacing="0">
|
||||
@foreach ($engagementCharts->chunk(2) as $row)
|
||||
<tr>
|
||||
@foreach ($row as $chart)
|
||||
<td style="width: 50%; vertical-align: top; padding: 0 6px 10px 0;">
|
||||
<div class="chart-label">{{ $chart['label'] }}</div>
|
||||
<img src="{{ $charts[$chart['key']] }}" class="chart-pair" alt="{{ $chart['label'] }}">
|
||||
</td>
|
||||
@endforeach
|
||||
@if ($row->count() === 1)
|
||||
<td style="width: 50%;"></td>
|
||||
@endif
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
@endif
|
||||
|
||||
{{-- Balance distribution: one chart per profile type, same split as the
|
||||
screen. Banks are excluded server-side. --}}
|
||||
@php
|
||||
$balanceCharts = collect([
|
||||
['key' => 'balance', 'label' => __('Users')],
|
||||
['key' => 'balanceOrg', 'label' => __('Organizations')],
|
||||
])->filter(fn($c) => !empty($charts[$c['key']]))->values();
|
||||
@endphp
|
||||
|
||||
@if ($balanceCharts->isNotEmpty())
|
||||
<h2>{{ __('Balance distribution') }}</h2>
|
||||
@if (!empty($stats['balance_distribution']['profiles_counted']))
|
||||
<p class="note">
|
||||
{{ __('Current balance of the :count user and organization profiles in the selection, in hours. Banks are excluded.', ['count' => $stats['balance_distribution']['profiles_counted']]) }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
{{-- One per row, full width: these histograms carry ~27 banded labels,
|
||||
which are unreadable at half the page width. --}}
|
||||
@foreach ($balanceCharts as $chart)
|
||||
<div style="margin-bottom: 12px;">
|
||||
<div class="chart-label">{{ $chart['label'] }}</div>
|
||||
<img src="{{ $charts[$chart['key']] }}" class="chart" alt="{{ $chart['label'] }}">
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
{{-- Trading network, captured exactly as rendered on screen. --}}
|
||||
@if (!empty($charts['network']))
|
||||
<h2 class="page-break">{{ __('Trading network') }}</h2>
|
||||
|
||||
{{-- The question this view answers, matching the caption shown above the
|
||||
graph on screen so the export reads the same way. --}}
|
||||
@if (!empty($networkLegend['view_caption']))
|
||||
<p style="font-size: 10px; font-weight: bold; margin: 2px 0 0 0;">
|
||||
{{ $networkLegend['view_caption'] }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
{{-- Counts from the classifier, which runs in the browser: captured at
|
||||
export time and carried through the session, since the server cannot
|
||||
recompute them. --}}
|
||||
@if (!empty($networkSubtext))
|
||||
<p style="font-size: 9px; color: #555; margin: 2px 0 0 0;">{{ $networkSubtext }}</p>
|
||||
@endif
|
||||
|
||||
@if (!empty($networkSettings))
|
||||
{{-- A printed graph carries no controls, so name the settings that
|
||||
produced it. --}}
|
||||
<table class="filters" cellpadding="0" cellspacing="0" style="margin-bottom: 8px;">
|
||||
<tr>
|
||||
@foreach ($networkSettings as $item)
|
||||
<td>
|
||||
<span class="key">{{ $item['label'] }}</span><br>
|
||||
<strong>{{ $item['value'] }}</strong>
|
||||
</td>
|
||||
@endforeach
|
||||
</tr>
|
||||
</table>
|
||||
@endif
|
||||
|
||||
<img src="{{ $charts['network'] }}" class="chart" alt="{{ __('Trading network') }}">
|
||||
|
||||
@include('partials.network-legend-pdf')
|
||||
@endif
|
||||
|
||||
@if ($stats['distribution']['top_profiles']->isNotEmpty())
|
||||
<h2 class="page-break">{{ ($partnerScoped ?? false) ? __('Most active partner profiles') : __('Most active profiles') }}</h2>
|
||||
@if (!empty($stats['distribution']['excluded_profiles']))
|
||||
<p class="note">{{ __(':profiles excluded from this table.', ['profiles' => implode(', ', $stats['distribution']['excluded_profiles'])]) }}</p>
|
||||
@endif
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ ($partnerScoped ?? false) ? __('Partner profile') : __('Profile') }}</th>
|
||||
<th>{{ __('Type') }}</th>
|
||||
<th class="right">{{ __('Paid') }}</th>
|
||||
<th class="right">{{ __('Received') }}</th>
|
||||
<th class="right">{{ __('Paid volume') }}</th>
|
||||
<th class="right">{{ __('Received volume') }}</th>
|
||||
<th class="right">{{ __('Trading partners') }}</th>
|
||||
<th class="right">{{ __('Transactions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($stats['distribution']['top_profiles'] as $profile)
|
||||
<tr>
|
||||
<td>{{ ($anonymize ?? false) ? $profile['anonymous_name'] : $profile['name'] }}</td>
|
||||
<td>{{ __($profile['type']) }}</td>
|
||||
<td class="right">{{ $num($profile['paid_count']) }}</td>
|
||||
<td class="right">{{ $num($profile['received_count']) }}</td>
|
||||
<td class="right">{{ $fmt($profile['paid_volume']) }}</td>
|
||||
<td class="right">{{ $fmt($profile['received_volume']) }}</td>
|
||||
<td class="right">{{ $num($profile['partners'] ?? 0) }}</td>
|
||||
<td class="right">{{ $num($profile['count']) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@endif
|
||||
|
||||
@if ($stats['distribution']['by_location']->isNotEmpty())
|
||||
<h2>{{ __('Activity by location') }}</h2>
|
||||
@if (!empty($stats['distribution']['excluded_profiles']))
|
||||
<p class="note">{{ __(':profiles excluded from this table.', ['profiles' => implode(', ', $stats['distribution']['excluded_profiles'])]) }}</p>
|
||||
@endif
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ __('Location') }}</th>
|
||||
<th class="right">{{ __('Paid') }}</th>
|
||||
<th class="right">{{ __('Received') }}</th>
|
||||
<th class="right">{{ __('Paid volume') }}</th>
|
||||
<th class="right">{{ __('Received volume') }}</th>
|
||||
<th class="right">{{ __('Transactions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($stats['distribution']['by_location'] as $row)
|
||||
<tr>
|
||||
<td>{{ $row['label'] ?: __('Location not specified') }}</td>
|
||||
<td class="right">{{ $num($row['paid_count']) }}</td>
|
||||
<td class="right">{{ $num($row['received_count']) }}</td>
|
||||
<td class="right">{{ $fmt($row['paid_volume']) }}</td>
|
||||
<td class="right">{{ $fmt($row['received_volume']) }}</td>
|
||||
<td class="right">{{ $num($row['count']) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@endif
|
||||
|
||||
{{-- Profiles marked as inactive: cumulative, so it sits alone at full width. --}}
|
||||
@if (!empty($charts['inactivations']))
|
||||
<h2>{{ ($partnerScoped ?? false) ? __('Partner profiles marked as inactive over time') : __('Profiles marked as inactive over time') }}</h2>
|
||||
<p class="note">
|
||||
{{ __('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.') }}
|
||||
</p>
|
||||
@foreach (\App\Services\StatsService::chartLegendNotes() as $note)
|
||||
<p class="note" style="margin: 0 0 3px 0;">{{ $note }}</p>
|
||||
@endforeach
|
||||
<img src="{{ $charts['inactivations'] }}" class="chart" alt="{{ __('Profiles marked as inactive over time') }}">
|
||||
@endif
|
||||
|
||||
<div class="footer">
|
||||
{{ config('app.name') }} — {{ __('Generated on') }} {{ $generatedAt }}
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
{{ __('Statistics') }}
|
||||
</x-slot>
|
||||
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-theme-background overflow-hidden shadow-xl sm:rounded-lg">
|
||||
<div class="p-6 sm:px-12 bg-theme-background border-b border-theme-border">
|
||||
|
||||
@livewire('select-period', ['label' => __('Select Period'), 'allowAllTime' => true])
|
||||
|
||||
@livewire('admin.stats')
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
Reference in new issue
Block a user