Files

390 lines
18 KiB
PHP

@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']) }} &middot;
{{ __('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') }} &mdash; {{ __('Generated on') }} {{ $generatedAt }}
</div>
</body>
</html>