Files

990 lines
60 KiB
PHP

<div class="relative mb-12 mt-0 w-full min-w-full leading-normal" id="single-report-component">
<!-- Decimal format toggle -->
@if($accountsData && $accountsData->count() > 0)
<div class="mb-6 flex items-center gap-3">
<x-toggle wire:model.live="decimalFormat" id="decimalFormatReport" name="decimalFormatReport" />
<span class="text-xs text-theme-light">{{ __('Show in decimals') }}</span>
</div>
@endif
<!-- Export buttons - always visible but disabled when no period selected -->
<div class="mb-8 mt-0 flex w-full justify-end">
<div class="space-x-2">
@if($this->fromDate && $this->toDate && $accountsData && $accountsData->count() > 0)
<x-jetstream.secondary-button wire:click="$dispatch('export-pdf-with-charts')" wire:loading.attr="disabled"
data-network-pdf-trigger>
<span wire:loading.remove wire:target="handleExportPdfWithCharts">
<x-icon class="mr-1 h-4 w-4" name="arrow-down-tray" />
{{ __('PDF') }}
</span>
<span wire:loading wire:target="handleExportPdfWithCharts">
<x-icon class="mr-1 h-4 w-4 animate-spin" name="arrow-path" />
{{ __('Loading...') }}
</span>
</x-jetstream.secondary-button>
@else
<x-jetstream.secondary-button disabled class="cursor-not-allowed opacity-50">
<x-icon class="mr-1 h-4 w-4" name="arrow-down-tray" />
{{ __('PDF') }}
</x-jetstream.secondary-button>
@endif
</div>
</div>
@if($accountsData && $accountsData->count() > 0)
<!-- Account Balances Table -->
<h3 class="text-lg font-medium text-theme-primary">
{{ __('Account Balance') }}
</h3>
<p class="text-sm text-theme-secondary mt-2">
{{ __('Balance overview of your accounts') }}
</p>
<div class="relative mb-24 w-full min-w-full leading-normal">
<table class="w-full" id="account-balances">
<thead>
<tr>
<th class="border-b border-theme-primary py-6 text-left">
<span class="px-0 py-2 text-sm font-normal text-theme-secondary">
{{ __('Account Name') }}
</span>
</th>
<th class="border-b border-theme-primary py-6 text-right">
<span class="px-0 py-2 text-sm font-normal text-theme-secondary">
{{ __('Start Balance') }}
</span>
</th>
<th class="border-b border-theme-primary py-6 text-right">
<span class="px-0 py-2 text-sm font-normal text-theme-secondary">
{{ __('End Balance') }}
</span>
</th>
<th class="border-b border-theme-primary py-6 text-right">
<span class="px-0 py-2 text-sm font-normal text-theme-secondary">
{{ __('Difference') }}
</span>
</th>
</tr>
</thead>
<tbody>
@foreach($accountsData as $account)
<tr>
<td class="border-b border-theme-primary bg-theme-background px-2 py-2 text-sm">
<div class="whitespace-no-wrap text-theme-primary font-medium">
{{ $account['name'] }}
</div>
</td>
<td class="border-b border-theme-primary bg-theme-background px-2 py-2 text-right text-sm">
<span class="whitespace-no-wrap text-theme-primary">
{{ $account['start_balance_formatted'] }}
</span>
</td>
<td class="border-b border-theme-primary bg-theme-background px-2 py-2 text-right text-sm">
<span class="whitespace-no-wrap text-theme-primary">
{{ $account['end_balance_formatted'] }}
</span>
</td>
<td class="border-b border-theme-primary bg-theme-background px-2 py-2 text-right text-sm">
<span class="whitespace-no-wrap @if($account['difference'] >= 0) text-theme-primary @else text-red-700 @endif">
{{ $account['difference_formatted'] }}@if($account['difference'] > 0) +@endif
</span>
</td>
</tr>
@endforeach
<!-- Totals Row -->
<tr class="bg-gray-100">
<td class="border-b border-theme-primary bg-gray-100 px-2 py-3 text-sm">
<div class="whitespace-no-wrap text-theme-primary font-bold">
{{ __('TOTALS') }}
</div>
</td>
<td class="border-b border-theme-primary bg-gray-100 px-2 py-3 text-right text-sm">
<span class="whitespace-no-wrap text-theme-primary font-bold">
{{ $this->formatAmount($accountsData->sum('start_balance')) }}
</span>
</td>
<td class="border-b border-theme-primary bg-gray-100 px-2 py-3 text-right text-sm">
<span class="whitespace-no-wrap text-theme-primary font-bold">
{{ $this->formatAmount($accountsData->sum('end_balance')) }}
</span>
</td>
<td class="border-b border-theme-primary bg-gray-100 px-2 py-3 text-right text-sm">
@php $totalDifference = $accountsData->sum('difference'); @endphp
<span class="whitespace-no-wrap font-bold @if($totalDifference >= 0) text-theme-primary @else text-red-700 @endif">
{{ $this->formatAmount($totalDifference) }}@if($totalDifference > 0) +@endif
</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Transaction Types Breakdown -->
@if($transactionTypesData && $transactionTypesData->count() > 0)
<div class="mb-6">
<h3 class="text-lg font-medium text-theme-primary">
{{ __('Transacion types') }}
</h3>
<p class="text-sm text-theme-secondary mt-2">
{{ __('Overview of the different transaction purposes') }}
</p>
</div>
<div class="relative mb-24 mt-12 w-full min-w-full leading-normal">
<table class="w-full text-left" id="transaction-types">
<thead>
<tr>
<th class="border-b border-theme-primary py-6">
<span class="px-0 py-2 text-sm font-normal text-theme-secondary">
{{ __('Transaction Type') }}
</span>
</th>
<th class="border-b border-theme-primary py-6 text-right">
<span class="px-0 py-2 text-sm font-normal text-theme-secondary">
{{ __('Credit') }}
</span>
</th>
<th class="border-b border-theme-primary py-6 text-right">
<span class="px-0 py-2 text-sm font-normal text-theme-secondary">
{{ __('Debit') }}
</span>
</th>
<th class="border-b border-theme-primary py-6 text-right">
<span class="px-0 py-2 text-sm font-normal text-theme-secondary">
{{ __('Total') }}
</span>
</th>
</tr>
</thead>
<tbody>
@foreach($transactionTypesData as $transactionType)
<tr>
<td class="border-b border-theme-primary bg-theme-background px-2 py-2 text-sm">
<div class="whitespace-no-wrap text-theme-primary">
{{ $transactionType['type_name'] }}
</div>
</td>
<td class="border-b border-theme-primary bg-theme-background px-2 py-2 text-right text-sm">
<span class="whitespace-no-wrap text-theme-primary">
{{ $transactionType['incoming_formatted'] }}
</span>
</td>
<td class="border-b border-theme-primary bg-theme-background px-2 py-2 text-right text-sm">
<span class="whitespace-no-wrap text-red-700">
{{ $transactionType['outgoing_formatted'] }}
</span>
</td>
<td class="border-b border-theme-primary bg-theme-background px-2 py-2 text-right text-sm">
<span class="whitespace-no-wrap @if($transactionType['net'] >= 0) text-theme-primary @else text-red-700 @endif">
{{ $transactionType['net_formatted'] }}
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
<!-- Account Balances Chart -->
@if($accountBalancesTimelineData && count($accountBalancesTimelineData) > 4)
<div class="relative mb-24 mt-12 w-full min-w-full leading-normal">
<div class="mb-6">
<h3 class="text-lg font-medium text-theme-primary">
{{ __('Account Balance Over Time') }}
</h3>
<p class="text-sm text-theme-secondary mt-2">
{{ __('Track your account balance over time') }}
</p>
</div>
<div class="bg-white rounded-lg shadow-sm border border-theme-primary p-6"
data-balance-chart-data="{{ json_encode($accountBalancesTimelineData) }}"
data-decimal-format="{{ $decimalFormat ? '1' : '0' }}"
data-date-range="{{ $this->fromDate }} to {{ $this->toDate }}">
{{-- 16:9, like every chart on the statistics page. The
sized wrapper is what Chart.js measures. --}}
<div class="relative aspect-video">
<canvas id="accountBalancesChart"></canvas>
</div>
</div>
</div>
@endif
<!-- Balance Distribution of trading partners (organizations and banks,
and only above the partner threshold — see
SingleReport::balanceDistribution()). Counts per band only: no
partner is named, listed or otherwise identifiable here. -->
@if($balanceDistribution && ($balanceDistribution['withheld'] ?? false))
{{-- Too few trading partners: with only a handful of profiles per
band, a reader who knows who this profile deals with could read
an individual's balance off the chart. --}}
<div class="relative mb-24 mt-12 w-full min-w-full leading-normal">
<div class="mb-6">
<h3 class="text-lg font-medium text-theme-primary">
{{ __('Balance distribution of your trading partners') }}
</h3>
</div>
<p class="text-sm text-theme-secondary">
{{ __('Not shown: you have :count trading partners in this period, and at least :minimum are needed to keep individual balances private.', [
'count' => $balanceDistribution['profiles_counted'],
'minimum' => $balanceDistribution['minimum'],
]) }}
</p>
</div>
@elseif($balanceDistribution)
@php
$bdRows = collect($balanceDistribution['bands'])
->map(function ($band, $i) use ($balanceDistribution) {
foreach ($balanceDistribution['series'] as $type => $counts) {
$band[$type] = $counts[$i] ?? 0;
}
return $band;
})
->values();
@endphp
<div class="relative mb-24 mt-12 w-full min-w-full leading-normal">
<div class="mb-6">
<h3 class="text-lg font-medium text-theme-primary">
{{ __('Balance distribution of your trading partners') }}
</h3>
<p class="text-sm text-theme-secondary mt-2">
{{ __('How the balances of your :count trading partners are spread, in hours.', ['count' => $balanceDistribution['profiles_counted']]) }}
</p>
</div>
{{-- One chart per profile type, each with its own y-axis, as on
the platform statistics page. Organizations are far fewer
than users, so on a shared axis scaled to hundreds of users
a bar of one organization is sub-pixel and reads as missing
data. --}}
@php
$bdCharts = [
[
'field' => 'User',
'label' => __('Users'),
'canvas' => 'reportBalanceDistributionChart',
'attr' => 'data-report-balance-distribution',
],
[
'field' => 'Organization',
'label' => __('Organizations'),
'canvas' => 'reportBalanceDistributionOrgChart',
'attr' => 'data-report-balance-distribution-org',
],
];
@endphp
{{-- Stacked so each chart gets the full width; at half width the
banded axis is too cramped to read. --}}
<div class="grid grid-cols-1 gap-6">
@foreach ($bdCharts as $chart)
@php
$bdTotal = $bdRows->sum(fn($row) => $row[$chart['field']] ?? 0);
// Trim each axis to its own series. The bands are
// computed across both types together, so a band
// holding only an organization leaves a run of empty
// bars on the user chart — and vice versa. Keep one
// empty band past the last occupied one, as the
// platform statistics page does.
$bdSeriesRows = $bdRows->values();
$bdLast = $bdSeriesRows->count() - 1;
while (
$bdLast > 0
&& ($bdSeriesRows[$bdLast][$chart['field']] ?? 0) === 0
&& ($bdSeriesRows[$bdLast - 1][$chart['field']] ?? 0) === 0
) {
$bdLast--;
}
// Same at the leading end: a chart whose first bar
// sits in band five should not open with four empty
// ones.
$bdFirst = 0;
while (
$bdFirst < $bdLast
&& ($bdSeriesRows[$bdFirst][$chart['field']] ?? 0) === 0
&& ($bdSeriesRows[$bdFirst + 1][$chart['field']] ?? 0) === 0
) {
$bdFirst++;
}
$bdSeriesRows = $bdSeriesRows->slice($bdFirst, $bdLast - $bdFirst + 1)->values();
@endphp
@if ($bdTotal > 0)
<div class="bg-white rounded-lg shadow-sm border border-theme-primary p-6">
<h4 class="text-xs font-medium text-theme-secondary uppercase tracking-wider mb-2">
{{ $chart['label'] }}
</h4>
<div {{ $chart['attr'] }}="{{ json_encode($bdSeriesRows) }}"
data-series-labels="{{ json_encode([$chart['field'] => $chart['label']]) }}">
<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
<!-- Return Ratio Timeline Chart -->
@if($isOrganization && $returnRatioTimelineData && count($returnRatioTimelineData) > 4)
<!-- Loading indicator positioned above chart -->
<div id="chartLoadingIndicator" class="flex items-center justify-center space-x-2 px-3 py-2 rounded-md mb-4" style="display: flex !important; visibility: visible !important;">
<div class="animate-spin rounded-full h-4 w-4 border-2 border-gray-300 border-t-gray-900"></div>
<span class="text-sm font-bold">
{{ __('Loading chart...') }}
</span>
</div>
<div class="relative mb-24 mt-12 w-full min-w-full leading-normal">
<div class="mb-6">
<h3 class="text-lg font-medium text-theme-primary">
{{ __('Reciprocity Rate Timeline') }}
</h3>
<p class="text-sm text-theme-secondary mt-2">
{{ __('Reciprocity rate: the share of your Hours that were also returned by the same people you helped during this period.') }}
</p>
<p class="text-sm text-theme-secondary mt-1">
{{ __('A high rate means you are part of a more closed exchange network with frequent returning exchange partners, while a low rate suggests you are part of a more open network with many different exchange partners.') }}
</p>
</div>
<div class="bg-white rounded-lg shadow-sm border border-theme-primary p-6"
data-chart-data="{{ json_encode($returnRatioTimelineData) }}"
data-trend-data="{{ json_encode($returnRatioTrendData) }}"
data-date-range="{{ $this->fromDate }} to {{ $this->toDate }}">
{{-- 16:9, like every chart on the statistics page. The
sized wrapper is what Chart.js measures. --}}
<div class="relative aspect-video">
<canvas id="returnRatioChart"></canvas>
</div>
</div>
</div>
@endif
<!-- Period Statistics Table -->
<div class="">
<h3 class="text-lg font-medium text-theme-primary">
{{ __('Transaction Relation Statistics') }}
</h3>
<p class="text-sm text-theme-secondary mt-2">
{{ __('Which types of profiles have you transacted with?') }}
</p>
</div>
<div class="relative mb-12 w-full min-w-full leading-normal">
<table class="w-full" id="period-statistics">
<thead>
<tr>
<th class="border-b border-theme-primary text-left">
<span class="px-0 py-2 text-sm font-normal text-theme-secondary">
{{ __('Period Statistics') }}
</span>
</th>
<th class="border-b border-theme-primary py-6 text-right">
<span class="px-0 py-2 text-sm font-normal text-theme-secondary">
{{ __('Value') }}
</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="border-b border-theme-primary bg-theme-background px-2 py-2 text-sm">
<div class="whitespace-no-wrap text-theme-primary">
{{ __('Number of Transactions') }}
</div>
</td>
<td class="border-b border-theme-primary bg-theme-background px-2 py-2 text-right text-sm">
<span class="whitespace-no-wrap text-theme-primary">
{{ number_format($statisticsData['transaction_count']) }}
</span>
</td>
</tr>
@if($statisticsData['profiles_by_type'] && $statisticsData['profiles_by_type']->count() > 0)
@foreach($statisticsData['profiles_by_type'] as $modelType => $count)
<tr>
<td class="border-b border-theme-primary bg-theme-background px-2 py-2 text-sm">
<div class="whitespace-no-wrap text-theme-primary">
{{ __('Unique ' . class_basename($modelType) . 's') }}
</div>
</td>
<td class="border-b border-theme-primary bg-theme-background px-2 py-2 text-right text-sm">
<span class="whitespace-no-wrap text-theme-primary">
{{ number_format($count) }}
</span>
</td>
</tr>
@endforeach
@endif
<tr>
<td class="border-b border-theme-primary bg-theme-background px-2 py-2 text-sm">
<div class="whitespace-no-wrap text-theme-primary">
{{ __('Total unique profiles') }}
</div>
</td>
<td class="border-b border-theme-primary bg-theme-background px-2 py-2 text-right text-sm">
<span class="whitespace-no-wrap text-theme-primary">
{{ number_format($statisticsData['unique_profiles']) }}
</span>
</td>
</tr>
@if($isOrganization)
<tr>
<td class="border-b border-theme-primary bg-theme-background px-2 py-2 text-sm">
<div class="whitespace-no-wrap text-theme-primary cursor-help"
title="{{ __('Of all the time you gave, how much came back. Partners you gave more time are weighted accordingly.') }}">
{{ __('Average reciprocity rate') }}
</div>
</td>
<td class="border-b border-theme-primary bg-theme-background px-2 py-2 text-right text-sm">
<span class="whitespace-no-wrap text-theme-primary">
{{ number_format($statisticsData['average_return_ratio'], 1) }}%
</span>
</td>
</tr>
{{-- Withheld below the partner threshold: with only a few
partners the median is arithmetically the mean and
reads as a finding when it is not. --}}
@if(!is_null($statisticsData['median_return_ratio'] ?? null))
<tr>
<td class="border-b border-theme-primary bg-theme-background px-2 py-2 text-sm">
<div class="whitespace-no-wrap text-theme-primary cursor-help"
title="{{ __('The typical partnership: half of your partners returned more than this, half returned less. A median far from the average means a few partnerships carry the total.') }}">
{{ __('Median reciprocity rate') }}
</div>
</td>
<td class="border-b border-theme-primary bg-theme-background px-2 py-2 text-right text-sm">
<span class="whitespace-no-wrap text-theme-primary">
{{ number_format($statisticsData['median_return_ratio'], 1) }}%
</span>
</td>
</tr>
@endif
@endif
</tbody>
</table>
</div>
@else
<div class="text-left py-8">
<div class="text-theme-secondary">
{{ __('Please select a period to generate the account balance report') }}
</div>
</div>
@endif
<script>
// Register trigger-chart-export listener once globally (guard against re-registration on Livewire morphs)
if (!window._chartExportListenerRegistered) {
window._chartExportListenerRegistered = true;
function registerChartExportListener() {
Livewire.on('trigger-chart-export', () => {
if (typeof window.exportPdfWithChart === 'function') {
window.exportPdfWithChart();
}
});
}
// Livewire may already be initialized (component renders after livewire:init fires)
if (typeof window.Livewire !== 'undefined') {
registerChartExportListener();
} else {
document.addEventListener('livewire:init', registerChartExportListener);
}
}
</script>
{{-- Trading network. Banks only: it shows who trades with whom among the
bank's own clients. Scoping happens server-side in networkScope(). --}}
@if ($this->showsNetwork())
@php
$network = $this->networkData();
@endphp
<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>
<p class="text-xs text-theme-secondary">
{{ __('Trade between your own clients.') }}
{{ __('Period for this graph:') }} {{ $this->networkPeriodLabel() }}
</p>
</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'];
$ceiling = \App\Services\StatsService::DEGREE_CEILING;
$networkPeriods = [
'inherit' => __('Same period as the report'),
'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 the report's buttons cannot drift
// from the admin stats page.
$views = \App\Http\Livewire\Admin\Stats::networkViewOptions();
@endphp
<div class="mt-4 flex flex-wrap items-center gap-4">
<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="report-network-min">{{ __('min') }}</label>
<input id="report-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="report-network-max">{{ __('max') }}</label>
<input id="report-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>
<div class="flex items-center gap-2">
<x-toggle id="report-network-hub" name="report-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. --}}
<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>
<div class="flex items-center gap-3"
title="{{ __('Shows numbered labels instead of names in the network graph. The same profile gets the same number as on the statistics page.') }}">
<x-toggle id="report-network-anonymize" name="report-network-anonymize"
wire:model.live="anonymizeProfiles" />
<span class="text-xs text-theme-secondary cursor-help">{{ __('Anonymise profile names') }}</span>
</div>
{{-- Same controls as the admin stats page: these change
which profiles the query resolves, so they go through
Livewire and rebuild the graph. --}}
<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>
@if ($networkView === 'circulation')
@php $stoppedCeiling = \App\Http\Livewire\Admin\Stats::STOPPED_AFTER_CEILING; @endphp
<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>
<input id="report-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">
@if ($networkStoppedAfterMonths >= $stoppedCeiling)
&gt;{{ $stoppedCeiling }} {{ __('months') }}
@else
{{ $networkStoppedAfterMonths }} {{ __('months') }}
@endif
</span>
</div>
@endif
<div class="flex items-center gap-2">
<x-toggle id="report-network-edge" name="report-network-edge" wire:model.live="networkEdge" />
<span class="text-xs text-theme-secondary">{{ __('Incl. network outside current bank') }}</span>
</div>
</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">
{{ __('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'],
]) }}
</p>
{{-- Search and period stay outside the empty/non-empty guard, so
an empty graph does not hide the controls that fix it. --}}
<div class="mt-3 mb-3 flex flex-wrap items-start gap-3">
@if ($ns['shown_nodes'] >= 2)
<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 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
<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 sm:text-sm">
@foreach ($networkPeriods as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</select>
</div>
{{-- 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="bank-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)
<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.') }}
@else
{{ __('Your clients did not trade with each other in this period. Choose a longer period, or include the network outside the current bank.') }}
@endif
</p>
@else
<div class="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; see the stats view.
'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'] ?? [],
'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-stopped-after="{{ $networkStoppedAfterMonths >= \App\Http\Livewire\Admin\Stats::STOPPED_AFTER_CEILING ? '' : $networkStoppedAfterMonths * 30 }}"
data-network-pseudonyms="{{ json_encode([
'User' => __('User'),
'Organization' => __('Organization'),
]) }}"
{{-- 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.'),
'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="report-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 their height. --}}
<div class="flex items-start justify-between gap-3 mb-2 min-h-[1.75rem]">
<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 in the browser;
see the stats view. Reserves its line rather
than collapsing: the same JS drives both views,
and a line that disappears resizes the graph
below it. --}}
<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. --}}
<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>
</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. --}}
{{-- See the stats view: the placeholder lives inside
the host, so Cytoscape removes it on mount. --}}
<div class="relative w-full" style="height: 560px;" 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>
</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>
@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>
@if (!empty($legend['styles']))
<div>
<div class="text-xs text-theme-secondary mb-1">{{ __('Node style') }}</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
@foreach ($legend['styles'] as $style)
<span class="inline-flex items-center gap-1.5 text-xs text-theme-primary">
<span class="inline-block w-3.5 h-3.5 rounded-full"
style="background-color: {{ $style['color'] }};{{ $style['dashed'] ? ' border: 2px dashed #64748b;' : ' border: 1px solid rgba(0,0,0,0.25);' }}"></span>
{{ $style['label'] }}
</span>
@endforeach
</div>
</div>
@endif
<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>
@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>
@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>
@endif
</div>