990 lines
60 KiB
PHP
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)
|
|
>{{ $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>
|