Files
timebank-cc-public/app/Http/Livewire/SingleReport.php
T

1670 lines
62 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Livewire;
use App\Models\Transaction;
use App\Services\Stats\StatsFilter;
use App\Services\StatsService;
use App\Traits\AccountInfoTrait;
use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
use Livewire\Component;
class SingleReport extends Component
{
use AccountInfoTrait;
/**
* Minimum trading partners before a per-partner breakdown is shown.
*
* Two things depend on it, for related reasons. The balance distribution
* chart: with only a handful of profiles per band, a reader who knows who
* the organization deals with could read an individual's balance off it.
* The median reciprocity rate: at two partners it is arithmetically the
* mean of the only two values, and it reads as a finding when it is not.
*/
private const MIN_PARTNERS_FOR_BREAKDOWN = 20;
public $reportData;
public $fromAccountId;
public $fromDate;
public $toDate;
public $isOrganization = false;
public $decimalFormat = false;
// --- Trading network graph (banks only; see networkScope()) ---
/**
* Closed by default: the graph costs an extra aggregation and ~570 kB of
* lazily loaded JavaScript, and most visits to a report are about the
* figures above it.
*/
public bool $showNetwork = false;
/**
* Replace profile names with stable numbers in the network graph.
*
* Same labels as the admin stats page (StatsService::pseudonym), so a
* profile is "User 12" in both. On by default because this report is made
* to be exported and shared: names are revealed deliberately rather than
* by omission.
*/
public bool $anonymizeProfiles = true;
/**
* Include profiles closed by their owner, and highlight those flagged
* inactive. Mirrors the admin stats page; see Admin\Stats for why each
* default is what it is.
*/
public bool $networkIncludeRemoved = true;
public bool $networkMarkInactive = true;
/** Months after which a profile counts as having stopped passing hours on. */
public int $networkStoppedAfterMonths = 25;
/**
* Zero, matching the admin stats page: a threshold silently dropped
* profiles the figures above still counted, and nothing on screen
* explained the difference. The slider raises the floor when a dense
* graph matters more than completeness.
*/
public int $networkMinDegree = 0;
public int $networkMaxDegree = StatsService::DEGREE_CEILING;
/**
* Off, matching the admin stats page: show the whole network unless the
* user asks otherwise. A bank's own graph is scoped to its clients, so the
* hub is less dominant here than on the platform-wide view.
*/
public bool $networkHideHub = false;
/**
* Include trade with profiles outside the bank's client circle.
*
* On by default here, unlike the platform statistics. A bank's clients
* trade mostly outside the circle: on dev, bank 5 shows 2 nodes with
* internal trade only and 55 once edge traffic is included. Outsiders
* always appear without a name, so the privacy boundary is unchanged.
*/
public bool $networkEdge = true;
public string $networkView = 'volume';
public string $networkLayout = 'force';
public array $networkTypes = ['User', 'Organization'];
/**
* Period for the graph alone.
*
* Defaults to all time rather than the report's own period: scoped to one
* bank's clients, a single year leaves most banks with an empty graph
* (bank 8 on dev: 0 nodes over the last year, 47 over all time).
*/
public string $networkPeriod = 'all_time';
protected $listeners = [
'fromAccountId',
'periodSelected' => 'periodSelectedDispatched',
'periodCleared' => 'periodClearedDispatched',
'export-pdf-with-charts' => 'handleExportPdfWithCharts',
];
public function mount($reportData = null)
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
// This prevents unauthorized access to financial report data via session manipulation
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
$this->reportData = $reportData;
$this->isOrganization = $profile instanceof \App\Models\Organization;
// Default to previous year on load (matches SelectPeriod default)
$this->fromDate = Carbon::now()->subYear()->startOfYear()->format('Y-m-d');
$this->toDate = Carbon::now()->subYear()->endOfYear()->format('Y-m-d');
}
public function updatedDecimalFormat()
{
// Trigger a re-render with updated format — no additional action needed,
// render() picks up $decimalFormat automatically.
}
public function formatAmount($minutes)
{
if ($this->decimalFormat) {
$isNegative = $minutes < 0;
$decimal = number_format(abs($minutes) / 60, 2, ',', '.');
return ($isNegative ? '-' : '') . $decimal . ' ' . __('h.');
}
return tbFormat($minutes);
}
public function fromAccountId($selectedAccount)
{
$this->fromAccountId = $selectedAccount['id'];
}
public function periodSelectedDispatched($periodData)
{
$fromDate = $periodData['fromDate'];
$toDate = $periodData['toDate'];
// Validate date range - ensure fromDate is not after toDate
if ($fromDate && $toDate && Carbon::parse($fromDate)->gt(Carbon::parse($toDate))) {
// Invalid date range - swap the dates or use a default range
Log::warning('Invalid date range detected', [
'fromDate' => $fromDate,
'toDate' => $toDate,
'action' => 'swapping_dates'
]);
// Swap the dates to fix the invalid range
$this->fromDate = $toDate;
$this->toDate = $fromDate;
// Dispatch a warning to the frontend
$this->dispatch('show-notification', [
'type' => 'warning',
'message' => __('Date range was invalid (start date after end date). Dates have been corrected.')
]);
} else {
$this->fromDate = $fromDate;
$this->toDate = $toDate;
}
// Generate and dispatch the title
$this->generateAndDispatchTitle($periodData['period'] ?? 'custom');
}
/**
* Handle when period is cleared/deselected
*/
public function periodClearedDispatched()
{
$this->fromDate = null;
$this->toDate = null;
// Clear the title when period is cleared
$this->dispatch('tableTitle', ['header' => '', 'sub' => '']);
}
/**
* Generate period-aware title and dispatch to table-title component
*/
private function generateAndDispatchTitle($periodType)
{
if (!$this->fromDate || !$this->toDate) {
// Clear title when no period is selected
$this->dispatch('tableTitle', ['header' => '', 'sub' => '']);
return;
}
// Check if there's actual data to show
$accountsData = $this->getAccountsWithPeriodBalances();
if (!$accountsData || $accountsData->count() === 0) {
// Clear title when no data is available
$this->dispatch('tableTitle', ['header' => '', 'sub' => '']);
return;
}
$fromDate = Carbon::parse($this->fromDate);
$toDate = Carbon::parse($this->toDate);
// Generate main title based on period type
$mainTitle = $this->generatePeriodTitle($periodType, $fromDate, $toDate);
// Generate date range subtitle
$dateRange = $fromDate->format('d-m-Y') . ' - ' . $toDate->format('d-m-Y');
$title = [
'header' => $mainTitle,
'sub' => $dateRange
];
$this->dispatch('tableTitle', $title);
}
/**
* Generate appropriate title based on period type and dates
*/
private function generatePeriodTitle($periodType, $fromDate, $toDate)
{
switch ($periodType) {
// A rolling twelve months spans two calendar years, so naming one
// of them would be wrong; the date-range subtitle carries the
// detail instead.
case 'since_one_year_ago':
return __('Financial Overview') . ' - ' . __('Since one year ago');
case 'previous_year':
return __('Financial Overview') . ' ' . $fromDate->year;
case 'previous_quarter':
$quarter = $this->getQuarterName($fromDate);
return __('Financial Overview') . ' ' . $quarter . ' ' . $fromDate->year;
case 'previous_month':
return __('Financial Overview') . ' ' . $fromDate->format('F Y');
case 'custom':
default:
// For custom periods, just show "Financial Overview" without date details
return __('Financial Overview');
}
}
/**
* Get quarter name from date
*/
private function getQuarterName($date)
{
$quarter = $date->quarter;
switch ($quarter) {
case 1:
return __('1st Quarter');
case 2:
return __('2nd Quarter');
case 3:
return __('3rd Quarter');
case 4:
return __('4th Quarter');
default:
return __('Quarter') . ' ' . $quarter;
}
}
/**
* Calculate balance for an account BEFORE any transactions on the start date
* This gives the balance at the end of the previous day (start of the period)
*/
private function calculateStartBalance($accountId, $date)
{
if (!$date) {
return 0;
}
$query = Transaction::where(function($q) use ($accountId) {
$q->where('from_account_id', $accountId)
->orWhere('to_account_id', $accountId);
})
->whereDate('created_at', '<', $date);
$balance = $query->selectRaw('SUM(CASE WHEN to_account_id = ? THEN amount ELSE -amount END) as balance', [$accountId])
->value('balance');
return $balance ?? 0;
}
/**
* Calculate balance for an account AFTER all transactions on the end date
* This gives the balance at the end of the target day
*/
private function calculateEndBalance($accountId, $date)
{
if (!$date) {
return 0;
}
$query = Transaction::where(function($q) use ($accountId) {
$q->where('from_account_id', $accountId)
->orWhere('to_account_id', $accountId);
})
->whereDate('created_at', '<=', $date);
$balance = $query->selectRaw('SUM(CASE WHEN to_account_id = ? THEN amount ELSE -amount END) as balance', [$accountId])
->value('balance');
return $balance ?? 0;
}
/**
* Get all accounts for active profile with period balances
*/
public function getAccountsWithPeriodBalances()
{
$activeProfile = getActiveProfile();
if (!$activeProfile || !method_exists($activeProfile, 'accounts')) {
return collect();
}
// Validate date range before processing
if ($this->fromDate && $this->toDate && Carbon::parse($this->fromDate)->gt(Carbon::parse($this->toDate))) {
Log::warning('Invalid date range in balance calculation', [
'fromDate' => $this->fromDate,
'toDate' => $this->toDate
]);
return collect();
}
$accounts = $activeProfile->accounts()->notRemoved()->get();
return $accounts->map(function ($account) {
$startBalance = $this->fromDate ? $this->calculateStartBalance($account->id, $this->fromDate) : 0;
$endBalance = $this->toDate ? $this->calculateEndBalance($account->id, $this->toDate) : $this->getBalance($account->id);
$difference = $endBalance - $startBalance;
return [
'id' => $account->id,
'name' => __('messages.' . $account->name . '_account'),
'start_balance' => $startBalance,
'end_balance' => $endBalance,
'difference' => $difference,
'limit_max' => $account->limit_max,
'start_balance_formatted' => $this->formatAmount($startBalance),
'end_balance_formatted' => $this->formatAmount($endBalance),
'difference_formatted' => $this->formatAmount($difference),
];
});
}
/**
* Calculate transaction type totals for the period
*/
public function calculateTransactionTypeTotals()
{
if (!$this->fromDate || !$this->toDate) {
return collect();
}
// Validate date range before processing
if (Carbon::parse($this->fromDate)->gt(Carbon::parse($this->toDate))) {
Log::warning('Invalid date range in transaction types calculation', [
'fromDate' => $this->fromDate,
'toDate' => $this->toDate
]);
return collect();
}
$activeProfile = getActiveProfile();
if (!$activeProfile || !method_exists($activeProfile, 'accounts')) {
return collect();
}
$accountIds = $activeProfile->accounts()->notRemoved()->pluck('id');
$transactionsQuery = Transaction::with('transactionType')
->where(function ($query) use ($accountIds) {
$query->whereIn('from_account_id', $accountIds)
->orWhereIn('to_account_id', $accountIds);
})
->whereBetween('created_at', [$this->fromDate, $this->toDate]);
$transactions = $transactionsQuery->get();
// Group transactions by type, separating known and unknown types
$knownTypes = collect();
$unknownTransactions = collect();
$groupedTransactions = $transactions->groupBy('transaction_type_id');
foreach ($groupedTransactions as $typeId => $typeTransactions) {
$typeName = $typeTransactions->first()->transactionType->name ?? null;
if ($typeName && $typeName !== 'Unknown') {
// Known transaction type
$incoming = $typeTransactions->whereIn('to_account_id', $accountIds)->sum('amount');
$outgoing = $typeTransactions->whereIn('from_account_id', $accountIds)->sum('amount');
$net = $incoming - $outgoing;
$knownTypes->put($typeId, [
'type_name' => __(ucfirst(strtolower($typeName))),
'incoming' => $incoming,
'outgoing' => $outgoing,
'net' => $net,
'incoming_formatted' => $this->formatAmount($incoming),
'outgoing_formatted' => $this->formatAmount($outgoing),
'net_formatted' => $this->formatAmount($net),
]);
} else {
// Unknown transaction type - add to collection for grouping
$unknownTransactions = $unknownTransactions->concat($typeTransactions);
}
}
// If we have unknown transactions, group them into a single "Unknown" entry
if ($unknownTransactions->count() > 0) {
$incoming = $unknownTransactions->whereIn('to_account_id', $accountIds)->sum('amount');
$outgoing = $unknownTransactions->whereIn('from_account_id', $accountIds)->sum('amount');
$net = $incoming - $outgoing;
$knownTypes->put('unknown', [
'type_name' => __('Unknown'),
'incoming' => $incoming,
'outgoing' => $outgoing,
'net' => $net,
'incoming_formatted' => $this->formatAmount($incoming),
'outgoing_formatted' => $this->formatAmount($outgoing),
'net_formatted' => $this->formatAmount($net),
]);
}
// Only hide the breakdown if there are only unknown types (not if there are no transactions)
if ($knownTypes->count() === 1 && $knownTypes->has('unknown')) {
return collect();
}
return $knownTypes;
}
/**
* Calculate return ratio data for chart visualization over time periods
*/
public function calculateReturnRatioTimeline()
{
if (!$this->fromDate || !$this->toDate) {
return [];
}
$activeProfile = getActiveProfile();
if (!$activeProfile || !method_exists($activeProfile, 'accounts')) {
return [];
}
$accountIds = $activeProfile->accounts()->notRemoved()->pluck('id');
$fromDate = Carbon::parse($this->fromDate);
$toDate = Carbon::parse($this->toDate);
// Always use monthly intervals for consistent chart display
$timelineData = [];
$currentDate = $fromDate->copy()->startOfMonth();
while ($currentDate->lte($toDate)) {
$periodStart = $currentDate->copy();
$periodEnd = $currentDate->copy()->endOfMonth();
// Don't go beyond the selected end date
if ($periodEnd->gt($toDate)) {
$periodEnd = $toDate->copy()->endOfDay();
}
// Don't start before the selected start date
if ($periodStart->lt($fromDate)) {
$periodStart = $fromDate->copy();
}
// Calculate return ratio for this period
$periodRatio = $this->calculateReturnRatioForPeriod($accountIds, $periodStart, $periodEnd);
// Also calculate account balances for this period
$accounts = $activeProfile->accounts()->notRemoved()->get();
$accountBalances = [];
foreach ($accounts as $account) {
$balance = $this->calculateEndBalance($account->id, $periodEnd->format('Y-m-d'));
$accountBalances[] = [
'account_id' => $account->id,
'account_name' => __('messages.' . $account->name . '_account'),
'balance' => $balance, // Keep raw value for chart calculations
'balance_formatted' => tbFormat($balance) // Add formatted version
];
}
$timelineData[] = [
'label' => $periodStart->format('M Y'),
'date' => $periodStart->format('Y-m-d'),
'return_ratio' => $periodRatio,
'accounts' => $accountBalances, // Add account balances data
'translations' => [
'period' => __('Period'),
'return_ratio' => __('Reciprocity Rate')
]
];
// Move to next month
$currentDate->addMonth();
}
// Calculate trend line using linear regression
$trendData = $this->calculateTrendLine($timelineData);
return [
'timeline' => $timelineData,
'trend' => $trendData
];
}
/**
* Calculate trend line using 4 key data points for better trend analysis
*/
private function calculateTrendLine($timelineData)
{
if (count($timelineData) < 4) {
return [];
}
$n = count($timelineData);
// Use 4 key points distributed across the timeline for better trend analysis
$keyPoints = [
['index' => 0, 'value' => $timelineData[0]['return_ratio']],
['index' => floor($n / 3), 'value' => $timelineData[floor($n / 3)]['return_ratio']],
['index' => floor(2 * $n / 3), 'value' => $timelineData[floor(2 * $n / 3)]['return_ratio']],
['index' => $n - 1, 'value' => $timelineData[$n - 1]['return_ratio']]
];
// Calculate linear regression using these 4 points
$sumX = 0;
$sumY = 0;
$sumXY = 0;
$sumXX = 0;
$pointCount = 4;
foreach ($keyPoints as $point) {
$x = $point['index'];
$y = $point['value'];
$sumX += $x;
$sumY += $y;
$sumXY += $x * $y;
$sumXX += $x * $x;
}
// Calculate slope (m) and y-intercept (b) for y = mx + b
$denominator = ($pointCount * $sumXX) - ($sumX * $sumX);
if ($denominator == 0) {
// Avoid division by zero - return flat line at average
$average = $sumY / $pointCount;
$trendData = [];
foreach ($timelineData as $point) {
$trendData[] = [
'label' => $point['label'],
'date' => $point['date'],
'trend_value' => $average
];
}
return $trendData;
}
$slope = (($pointCount * $sumXY) - ($sumX * $sumY)) / $denominator;
$intercept = ($sumY - ($slope * $sumX)) / $pointCount;
// Generate trend line points for all data points
$trendData = [];
foreach ($timelineData as $index => $point) {
$trendValue = ($slope * $index) + $intercept;
$trendData[] = [
'label' => $point['label'],
'date' => $point['date'],
'trend_value' => $trendValue
];
}
return $trendData;
}
/**
* Calculate account balances timeline for chart visualization
*/
public function calculateAccountBalancesTimeline()
{
if (!$this->fromDate || !$this->toDate) {
return [];
}
$activeProfile = getActiveProfile();
if (!$activeProfile || !method_exists($activeProfile, 'accounts')) {
return [];
}
$accounts = $activeProfile->accounts()->notRemoved()->get();
$fromDate = Carbon::parse($this->fromDate);
$toDate = Carbon::parse($this->toDate);
// Always use monthly intervals for consistent chart display
$timelineData = [];
$currentDate = $fromDate->copy()->startOfMonth();
while ($currentDate->lte($toDate)) {
$periodStart = $currentDate->copy();
$periodEnd = $currentDate->copy()->endOfMonth();
// Don't go beyond the selected end date
if ($periodEnd->gt($toDate)) {
$periodEnd = $toDate->copy()->endOfDay();
}
// Don't start before the selected start date
if ($periodStart->lt($fromDate)) {
$periodStart = $fromDate->copy();
}
$monthData = [
'label' => $periodStart->format('M Y'),
'date' => $periodStart->format('Y-m-d'),
'accounts' => [],
'translations' => [
'period' => __('Period'),
'balance' => __('Balance')
]
];
// Calculate balance for each account at the end of this period
foreach ($accounts as $account) {
$balance = $this->calculateEndBalance($account->id, $periodEnd->format('Y-m-d'));
$monthData['accounts'][] = [
'account_id' => $account->id,
'account_name' => __('messages.' . $account->name . '_account'),
'balance' => $balance
];
}
$timelineData[] = $monthData;
// Move to next month
$currentDate->addMonth();
}
return $timelineData;
}
/**
* Calculate return ratio for a specific period
*/
private function calculateReturnRatioForPeriod($accountIds, $startDate, $endDate)
{
$activeProfile = getActiveProfile();
$activeProfileKey = get_class($activeProfile) . ':' . $activeProfile->id;
$accountIdsArray = $accountIds->toArray();
$transactions = Transaction::with(['accountFrom.accountable', 'accountTo.accountable'])
->where(function ($query) use ($accountIds) {
$query->whereIn('from_account_id', $accountIds)
->orWhereIn('to_account_id', $accountIds);
})
->whereBetween('created_at', [$startDate, $endDate])
->get();
$profileRatios = [];
foreach ($transactions as $transaction) {
$fromAccountId = $transaction->from_account_id;
$toAccountId = $transaction->to_account_id;
$amount = $transaction->amount;
$fromProfileKey = null;
$toProfileKey = null;
if ($transaction->accountFrom && $transaction->accountFrom->accountable) {
$fromProfileKey = $transaction->accountFrom->accountable_type . ':' . $transaction->accountFrom->accountable_id;
}
if ($transaction->accountTo && $transaction->accountTo->accountable) {
$toProfileKey = $transaction->accountTo->accountable_type . ':' . $transaction->accountTo->accountable_id;
}
if (!$fromProfileKey || !$toProfileKey) {
continue;
}
// Bank flow is administrative, not reciprocal — see
// isReciprocityPartner().
if (!$this->isReciprocityPartner($fromProfileKey) || !$this->isReciprocityPartner($toProfileKey)) {
continue;
}
// When the active profile sends money to someone else (helping someone)
if (in_array($fromAccountId, $accountIdsArray) && $toProfileKey !== $activeProfileKey) {
if (!isset($profileRatios[$toProfileKey])) {
$profileRatios[$toProfileKey] = ['given' => 0, 'received' => 0];
}
$profileRatios[$toProfileKey]['given'] += $amount;
}
// When the active profile receives money from someone else (being helped back)
elseif (in_array($toAccountId, $accountIdsArray) && $fromProfileKey !== $activeProfileKey) {
if (!isset($profileRatios[$fromProfileKey])) {
$profileRatios[$fromProfileKey] = ['given' => 0, 'received' => 0];
}
$profileRatios[$fromProfileKey]['received'] += $amount;
}
}
return $this->summariseReturnRatios($profileRatios)['weighted'];
}
/**
* Is this profile a reciprocity partner, or administrative flow?
*
* Banks are excluded: reciprocity asks "when I help people, do they help me
* back", and a bank issuing currency, reversing a payment or migrating a
* legacy balance is not reciprocating. Counting one made an organization
* with a single real partner at 43,5% report 104,2%.
*/
private function isReciprocityPartner(?string $profileKey): bool
{
return $profileKey !== null
&& !str_starts_with($profileKey, \App\Models\Bank::class . ':');
}
/**
* Reduce per-partner given/received totals to a weighted rate and a median.
*
* Weighted, not a mean of per-partner percentages: a partner given 6 minutes
* who returned 1.941 scores 32.350%, and an unweighted mean lets that one
* row outvote partners representing hundreds of hours. That is how this
* report came to show 414,5% for an organization whose real return was 93%.
*
* The weighted figure answers the question the chart is asking — of all the
* time given, how much came back — and the median says whether a typical
* partnership looks like that or whether the total is carried by a few.
*
* Partners who were never given anything are excluded: with nothing given,
* there is no return to measure, and they would divide by zero.
*
* @param array<string, array{given: int, received: int}> $profileRatios
* @return array{weighted: float, median: float|null, partner_count: int}
*/
private function summariseReturnRatios(array $profileRatios): array
{
$withGiven = array_filter($profileRatios, fn($a) => ($a['given'] ?? 0) > 0);
if (empty($withGiven)) {
return ['weighted' => 0.0, 'median' => null, 'partner_count' => 0];
}
$totalGiven = array_sum(array_column($withGiven, 'given'));
$totalReceived = array_sum(array_column($withGiven, 'received'));
$percentages = array_map(
fn($a) => ($a['received'] / $a['given']) * 100,
array_values($withGiven)
);
sort($percentages);
$count = count($percentages);
$middle = intdiv($count, 2);
$median = $count % 2
? $percentages[$middle]
: ($percentages[$middle - 1] + $percentages[$middle]) / 2;
return [
'weighted' => $totalGiven > 0 ? ($totalReceived / $totalGiven) * 100 : 0.0,
'median' => $count >= self::MIN_PARTNERS_FOR_BREAKDOWN ? $median : null,
'partner_count' => $count,
];
}
/**
* Calculate period statistics
*/
public function calculatePeriodStatistics()
{
if (!$this->fromDate || !$this->toDate) {
return [
'transaction_count' => 0,
'unique_profiles' => 0,
'profiles_by_type' => collect(),
'average_return_ratio' => 0,
'median_return_ratio' => null,
];
}
// Validate date range before processing
if (Carbon::parse($this->fromDate)->gt(Carbon::parse($this->toDate))) {
Log::warning('Invalid date range in period statistics calculation', [
'fromDate' => $this->fromDate,
'toDate' => $this->toDate
]);
return [
'transaction_count' => 0,
'unique_profiles' => 0,
'profiles_by_type' => collect(),
'average_return_ratio' => 0,
'median_return_ratio' => null,
];
}
$activeProfile = getActiveProfile();
if (!$activeProfile || !method_exists($activeProfile, 'accounts')) {
return [
'transaction_count' => 0,
'unique_profiles' => 0,
'profiles_by_type' => collect(),
'average_return_ratio' => 0,
'median_return_ratio' => null,
];
}
$accountIds = $activeProfile->accounts()->notRemoved()->pluck('id');
$transactionsQuery = Transaction::where(function ($query) use ($accountIds) {
$query->whereIn('from_account_id', $accountIds)
->orWhereIn('to_account_id', $accountIds);
})
->whereBetween('created_at', [$this->fromDate, $this->toDate]);
$transactionCount = $transactionsQuery->count();
// Get unique profiles involved in transactions (excluding the active profile)
$transactions = $transactionsQuery->with(['accountFrom.accountable', 'accountTo.accountable'])->get();
$activeProfileKey = get_class($activeProfile) . ':' . $activeProfile->id;
$uniqueProfiles = collect();
foreach ($transactions as $transaction) {
if ($transaction->accountFrom && $transaction->accountFrom->accountable) {
$profileKey = $transaction->accountFrom->accountable_type . ':' . $transaction->accountFrom->accountable_id;
if ($profileKey !== $activeProfileKey) {
$uniqueProfiles->push([
'key' => $profileKey,
'type' => $transaction->accountFrom->accountable_type
]);
}
}
if ($transaction->accountTo && $transaction->accountTo->accountable) {
$profileKey = $transaction->accountTo->accountable_type . ':' . $transaction->accountTo->accountable_id;
if ($profileKey !== $activeProfileKey) {
$uniqueProfiles->push([
'key' => $profileKey,
'type' => $transaction->accountTo->accountable_type
]);
}
}
}
// Get unique profiles and group by type
$uniqueProfilesData = $uniqueProfiles->unique('key');
$profilesByType = $uniqueProfilesData->groupBy('type')->map(function ($profiles) {
return $profiles->count();
});
// Calculate return ratio: what percentage of timebank hours you get back from people you've helped
// Example: You give 10 hours to someone, they give you 4 hours back = 40% return ratio
// This is averaged across all people you've transacted with during the selected period
$profileRatios = [];
$accountIdsArray = $accountIds->toArray();
// Process each transaction to track giving/receiving amounts per profile
foreach ($transactions as $transaction) {
$fromAccountId = $transaction->from_account_id;
$toAccountId = $transaction->to_account_id;
$amount = $transaction->amount;
// Get unique profile identifiers for both sender and receiver
$fromProfileKey = null;
$toProfileKey = null;
if ($transaction->accountFrom && $transaction->accountFrom->accountable) {
$fromProfileKey = $transaction->accountFrom->accountable_type . ':' . $transaction->accountFrom->accountable_id;
}
if ($transaction->accountTo && $transaction->accountTo->accountable) {
$toProfileKey = $transaction->accountTo->accountable_type . ':' . $transaction->accountTo->accountable_id;
}
// Skip transactions where we can't identify both profiles
if (!$fromProfileKey || !$toProfileKey) {
continue;
}
if (!$this->isReciprocityPartner($fromProfileKey) || !$this->isReciprocityPartner($toProfileKey)) {
continue;
}
// Track transactions between active profile and other profiles
if (in_array($fromAccountId, $accountIdsArray) && $toProfileKey !== $activeProfileKey) {
// Active profile gave hours to another profile - track as "given"
if (!isset($profileRatios[$toProfileKey])) {
$profileRatios[$toProfileKey] = ['given' => 0, 'received' => 0];
}
$profileRatios[$toProfileKey]['given'] += $amount;
} elseif (in_array($toAccountId, $accountIdsArray) && $fromProfileKey !== $activeProfileKey) {
// Active profile received hours from another profile - track as "received"
if (!isset($profileRatios[$fromProfileKey])) {
$profileRatios[$fromProfileKey] = ['given' => 0, 'received' => 0];
}
$profileRatios[$fromProfileKey]['received'] += $amount;
}
}
// Weighted by volume — see summariseReturnRatios() for why a mean of
// per-partner percentages is not usable here.
$summary = $this->summariseReturnRatios($profileRatios);
return [
'transaction_count' => $transactionCount,
'unique_profiles' => $uniqueProfilesData->count(),
'profiles_by_type' => $profilesByType,
'average_return_ratio' => $summary['weighted'],
'median_return_ratio' => $summary['median'],
];
}
/**
* Export report as PDF
*/
public function exportPdf()
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate authorization before export
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
// Build URL with current report parameters
$params = [
'fromDate' => $this->fromDate,
'toDate' => $this->toDate,
'decimal' => $this->decimalFormat ? 1 : 0,
];
$url = route('reports.pdf', $params);
// Use JavaScript to open PDF in new window/tab
$this->dispatch('openPdf', $url);
}
/**
* Handle export PDF with charts request
* This method is triggered by the export button and dispatches a browser event
* to trigger the JavaScript chart export function
*/
public function handleExportPdfWithCharts()
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate authorization before export
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
// Check if any charts will be rendered (need more than 4 data points)
$returnRatioData = $this->calculateReturnRatioTimeline();
$returnRatioTimelineData = $returnRatioData['timeline'] ?? [];
$hasReturnRatioChart = $this->isOrganization && count($returnRatioTimelineData) > 4;
$hasBalancesChart = count($returnRatioTimelineData) > 4; // account balances chart uses same timeline data
Log::info('handleExportPdfWithCharts', [
'isOrganization' => $this->isOrganization,
'timelineDataCount' => count($returnRatioTimelineData),
'hasReturnRatioChart' => $hasReturnRatioChart,
'hasBalancesChart' => $hasBalancesChart,
'fromDate' => $this->fromDate,
'toDate' => $this->toDate,
]);
if ($hasReturnRatioChart || $hasBalancesChart) {
// Dispatch browser event to trigger JavaScript chart export
Log::info('Dispatching trigger-chart-export browser event');
$this->dispatch('trigger-chart-export');
} else {
// No charts available, export regular PDF directly
Log::info('No charts available, calling exportPdf directly');
$this->exportPdf();
}
}
/**
* Export report as PDF with chart image
*/
public function exportPdfWithChart($chartImage, $fromDate = null, $toDate = null)
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate authorization before export
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
// Use passed dates if provided, otherwise use component dates
$actualFromDate = $fromDate ?? $this->fromDate;
$actualToDate = $toDate ?? $this->toDate;
// Generate unique filename for temporary chart image
$chartImageId = uniqid('chart_', true);
$tempImagePath = storage_path('app/temp/' . $chartImageId . '.png');
// Ensure temp directory exists
if (!file_exists(dirname($tempImagePath))) {
mkdir(dirname($tempImagePath), 0755, true);
}
// Extract base64 data and save to temporary file
$base64Data = explode(',', $chartImage)[1] ?? $chartImage;
$imageData = base64_decode($base64Data);
file_put_contents($tempImagePath, $imageData);
// Store only the chart image ID in session (much smaller)
session(['chart_image_id' => $chartImageId]);
// Build URL with current report parameters
$params = [
'fromDate' => $actualFromDate,
'toDate' => $actualToDate,
'with_chart' => 1,
'decimal' => $this->decimalFormat ? 1 : 0,
];
$url = route('reports.pdf', $params);
// Use JavaScript to open PDF in new window/tab
$this->dispatch('openPdf', $url);
}
/**
* Clean UTF-8 encoding for strings
*/
private function cleanUtf8String($string)
{
if (!is_string($string)) {
return $string;
}
// Remove or replace problematic characters
$string = mb_convert_encoding($string, 'UTF-8', 'UTF-8');
$string = preg_replace('/[\x00-\x1F\x7F-\x9F]/u', '', $string);
return $string;
}
/**
* Clean UTF-8 encoding for arrays/collections
*/
private function cleanUtf8Data($data)
{
if (is_string($data)) {
return $this->cleanUtf8String($data);
}
if (is_array($data) || $data instanceof \Illuminate\Support\Collection) {
$cleaned = [];
foreach ($data as $key => $value) {
$cleanedKey = $this->cleanUtf8String($key);
$cleanedValue = $this->cleanUtf8Data($value);
$cleaned[$cleanedKey] = $cleanedValue;
}
return $data instanceof \Illuminate\Support\Collection ? collect($cleaned) : $cleaned;
}
return $data;
}
public function render()
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Re-validate authorization on every render
// This prevents data exposure after session manipulation between interactions
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
// Update title based on current data state
$this->generateAndDispatchTitle('custom');
$returnRatioData = $this->calculateReturnRatioTimeline();
$returnRatioTimelineData = $returnRatioData['timeline'] ?? [];
$returnRatioTrendData = $returnRatioData['trend'] ?? [];
$accountBalancesTimelineData = $returnRatioTimelineData; // Use same data as return ratio chart
return view('livewire.single-report', [
'accountsData' => $this->getAccountsWithPeriodBalances(),
'transactionTypesData' => $this->calculateTransactionTypeTotals(),
'statisticsData' => $this->calculatePeriodStatistics(),
'returnRatioTimelineData' => $returnRatioTimelineData,
'returnRatioTrendData' => $returnRatioTrendData,
'accountBalancesTimelineData' => $accountBalancesTimelineData,
'balanceDistribution' => $this->balanceDistribution($profile),
]);
}
/**
* How a profile's trading partners are spread across balance bands.
*
* Organizations and banks, and only above a threshold of partners: below
* it the bands hold so few profiles that a reader could match a bar to a
* person they know traded with the profile. The chart carries counts per band
* and no identity of any kind — the underlying profiles are never named,
* listed or exposed, only tallied.
*
* @return array{withheld: bool, bands?: array, series?: array, profiles_counted: int, minimum?: int}|null
* null when the profile type has no such section; withheld => true
* when it has one but too few partners to show it.
*/
// ------------------------------------------------------- trading network
/**
* Whether this report may show a network graph.
*
* Banks only. An organization or user report would show the trade of other
* members between themselves, which is not theirs to see; a bank already
* administers its clients' accounts.
*/
public function showsNetwork(): bool
{
return getActiveProfile() instanceof \App\Models\Bank;
}
public function toggleNetwork(): void
{
if (!$this->showsNetwork()) {
return;
}
$this->showNetwork = !$this->showNetwork;
}
public function setNetworkView(string $view): void
{
// Allow-list: the value drives styling in JS, so never take it blindly.
if (in_array($view, ['flow', 'volume', 'cluster', 'region', 'circulation'], true)) {
$this->networkView = $view;
}
}
public function setNetworkLayout(string $layout): void
{
if (in_array($layout, ['force', 'circle', 'concentric', 'tree', 'grid'], true)) {
$this->networkLayout = $layout;
}
}
public function toggleNetworkType(string $type): void
{
if (!in_array($type, ['User', 'Organization', 'Bank'], true)) {
return;
}
if (in_array($type, $this->networkTypes, true)) {
$this->networkTypes = array_values(array_diff($this->networkTypes, [$type]));
} else {
$this->networkTypes[] = $type;
}
}
/**
* Clamp the degree sliders; they must not cross, or the graph goes empty
* without the user seeing why.
*/
public function updatedNetworkMinDegree($value): void
{
$this->networkMinDegree = max(0, min(StatsService::DEGREE_CEILING, (int) $value));
if ($this->networkMinDegree > $this->networkMaxDegree) {
$this->networkMaxDegree = $this->networkMinDegree;
}
}
public function updatedNetworkMaxDegree($value): void
{
$max = max(0, min(StatsService::DEGREE_CEILING, (int) $value));
if ($max < $this->networkMinDegree) {
$this->networkMinDegree = $max;
}
$this->networkMaxDegree = $max;
}
/**
* The bank's own clients, as profile keys.
*
* Determined server-side and never settable from the view: this is the
* boundary that stops a bank seeing names of non-client members. Note that
* bank_user holds the MANAGERS; the clients live in bank_clients.
*/
private function networkScope(): array
{
$profile = getActiveProfile();
if (!$profile instanceof \App\Models\Bank) {
return [];
}
return \DB::table('bank_clients')
->where('bank_id', $profile->id)
->get(['client_type', 'client_id'])
->map(fn($row) => $row->client_type . ':' . $row->client_id)
->all();
}
/** Periods the graph can show, independently of the report's period. */
private function networkFilter(): StatsFilter
{
$from = null;
$till = null;
switch ($this->networkPeriod) {
case 'inherit':
$from = $this->fromDate ? Carbon::parse($this->fromDate) : null;
$till = $this->toDate ? Carbon::parse($this->toDate) : null;
break;
case 'since_one_year_ago':
$from = Carbon::now()->subYear();
$till = Carbon::now();
break;
case 'previous_year':
$from = Carbon::now()->subYear()->startOfYear();
$till = Carbon::now()->subYear()->endOfYear();
break;
case 'previous_quarter':
$from = Carbon::now()->subQuarter()->startOfQuarter();
$till = Carbon::now()->subQuarter()->endOfQuarter();
break;
case 'previous_month':
$from = Carbon::now()->subMonth()->startOfMonth();
$till = Carbon::now()->subMonth()->endOfMonth();
break;
case 'all_time':
default:
break;
}
return new StatsFilter(
periodFrom: $from,
periodTill: $till,
allTime: $this->networkPeriod === 'all_time',
includeRemoved: $this->networkIncludeRemoved,
markInactive: $this->networkMarkInactive,
);
}
/**
* The graph's settings in words, for the PDF.
*
* @return array<array{label:string,value:string}>
*/
public function networkSettingsSummary(): array
{
$views = \App\Http\Livewire\Admin\Stats::networkViewOptions();
$layouts = [
'force' => __('Force-directed'),
'circle' => __('Circle'),
'concentric' => __('Rings by connections'),
'tree' => __('Tree'),
'grid' => __('Grid'),
];
$ceiling = StatsService::DEGREE_CEILING;
$types = array_map(
fn($t) => ['User' => __('Users'), 'Organization' => __('Organizations'), 'Bank' => __('Banks')][$t] ?? $t,
$this->networkTypes
);
$summary = [
['label' => __('View'), 'value' => $views[$this->networkView] ?? $this->networkView],
['label' => __('Arrangement'), 'value' => $layouts[$this->networkLayout] ?? $this->networkLayout],
['label' => __('Trading partners'), 'value' => $this->networkMinDegree . ' - '
. ($this->networkMaxDegree >= $ceiling ? '>' . $ceiling : $this->networkMaxDegree)],
['label' => __('Show'), 'value' => implode(', ', $types) ?: __('None')],
['label' => __('Period'), 'value' => $this->networkPeriodLabel()],
];
if ($this->networkEdge) {
$summary[] = ['label' => __('Incl. network outside current bank'), 'value' => __('Yes')];
}
if ($this->networkView === 'circulation') {
$summary[] = [
'label' => __('Define \'stopped\' at'),
'value' => ($this->networkStoppedAfterMonths >= \App\Http\Livewire\Admin\Stats::STOPPED_AFTER_CEILING
? '>' . \App\Http\Livewire\Admin\Stats::STOPPED_AFTER_CEILING
: $this->networkStoppedAfterMonths) . ' ' . __('months'),
];
}
if ($this->networkHideHub) {
$summary[] = ['label' => __('Hide the largest hub'), 'value' => __('Yes')];
}
if ($this->networkIncludeRemoved) {
$summary[] = ['label' => __('Include removed profiles'), 'value' => __('Yes')];
}
if ($this->networkMarkInactive) {
$summary[] = ['label' => __('Highlight inactive profiles'), 'value' => __('Yes')];
}
// Stated explicitly: a reader of a shared export should not have to
// guess whether "User 12" is a pseudonym or someone's actual name.
if ($this->anonymizeProfiles) {
$summary[] = ['label' => __('Anonymise profile names'), 'value' => __('Yes')];
}
return $summary;
}
/** Readable name of the graph's own period, shown under the heading. */
/**
* The period the graph is actually drawn over, in words.
*
* networkPeriodLabel() only describes the graph's own override and returns
* an empty string on 'inherit'. This resolves that case to the report's own
* period, so an exported figure always states what it covers.
*/
public function effectiveNetworkPeriodLabel(): string
{
if ($this->networkPeriod !== 'inherit') {
return $this->networkPeriodLabel();
}
if ($this->fromDate && $this->toDate) {
return Carbon::parse($this->fromDate)->format('d-m-Y')
. ' – ' . Carbon::parse($this->toDate)->format('d-m-Y');
}
return __('All time');
}
public function networkPeriodLabel(): string
{
return [
'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'),
][$this->networkPeriod] ?? '';
}
/** The graph itself, or null when this report does not show one. */
public function networkData(): ?array
{
if (!$this->showNetwork || !$this->showsNetwork()) {
return null;
}
$scope = $this->networkScope();
if (empty($scope)) {
return null;
}
return (new StatsService($this->networkFilter()))->network(
$this->networkMinDegree,
$scope,
$this->networkEdge,
$this->networkTypes,
$this->networkMaxDegree,
$this->anonymizeProfiles
);
}
/**
* Legend for the graph: what colour, size and line width mean.
*
* Mirrors Admin\Stats::networkLegend(); the colour codes match
* TYPE_COLORS / REGION_COLORS in stats-network.js.
*/
public function networkLegend(array $network): array
{
$typeColors = [
'User' => '#14b8a6',
'Organization' => '#d946ef',
'Bank' => '#f59e0b',
];
$regionColors = ['#3b82f6', '#f97316', '#14b8a6', '#d946ef', '#84cc16',
'#8b5cf6', '#ec4899', '#22c55e', '#0ea5e9', '#eab308'];
$nodes = $network['nodes'] ?? [];
if ($this->networkView === 'region') {
$seen = [];
foreach ($nodes as $node) {
$region = $node['region'] ?? null;
if ($region && !in_array($region, $seen, true)) {
$seen[] = $region;
}
}
$colors = [];
foreach ($seen as $i => $region) {
$colors[] = ['color' => $regionColors[$i % count($regionColors)], 'label' => $region];
}
$colorMeaning = __('Region');
} elseif ($this->networkView === 'circulation') {
// Classified in the browser on the visible set; this only explains
// the palette. Keep in step with FLOW_COLORS in stats-network.js.
// The same three states, labels and colours as the stats page:
// both draw with FLOW_COLORS. An earlier legend here showed a
// four-colour recency ramp from a circulationColor() that the graph
// script never had, so it described colours that were never drawn.
$colors = [
['color' => '#3b82f6', 'label' => __('Passing hours on')],
['color' => '#9333ea', 'label' => __('Stalled: hours are sitting still here')],
['color' => '#22d3ee', 'label' => __('Never passed hours on')],
];
$colorMeaning = __('What the profile does with the hours it sees');
} elseif ($this->networkView === 'cluster') {
$colors = [];
$colorMeaning = __('Cluster of profiles that mainly trade with each other');
} else {
$present = array_unique(array_column($nodes, 'type'));
$labels = ['User' => __('Users'), 'Organization' => __('Organizations'), 'Bank' => __('Banks')];
$colors = [];
foreach ($typeColors as $type => $hex) {
if (in_array($type, $present, true)) {
$colors[] = ['color' => $hex, 'label' => $labels[$type] ?? $type];
}
}
$colorMeaning = __('Profile type');
}
// Node style, as a separate legend block: colour alone does not explain
// the dashed outline that marks a profile from another bank's network.
$styles = [
['color' => $typeColors['User'], 'dashed' => false, 'label' => __('Your own clients')],
];
if (collect($nodes)->contains(fn($n) => $n['outside'] ?? false)) {
$styles[] = ['color' => '#94a3b8', 'dashed' => true, 'label' => __('Another bank network')];
}
return [
'view_label' => \App\Http\Livewire\Admin\Stats::networkViewOptions()[$this->networkView] ?? $this->networkView,
'view_caption' => \App\Http\Livewire\Admin\Stats::networkViewCaption($this->networkView),
'view_hint' => \App\Http\Livewire\Admin\Stats::networkViewHint($this->networkView),
'color_meaning' => $colorMeaning,
'colors' => $colors,
'styles' => $styles,
'size_meaning' => $this->networkView === 'volume'
? __('Total time traded by this profile')
: __('Number of trading partners'),
'width_meaning' => $this->networkView === 'flow'
? __('Time traded between the two profiles')
: __('Number of transactions between the two profiles'),
// One entry per transaction type actually present in this
// graph: each arrow is a single type carrying that type's volume.
// Keep the colours in step with TYPE_EDGE_COLORS in
// stats-network.js.
'directions' => $this->networkView === 'flow'
? collect($network['edges'] ?? [])
->pluck('type_name', 'type_id')
->sortKeys()
->map(fn($name, $id) => [
'color' => [
1 => '#1e293b', 2 => '#4338ca', 3 => '#16a34a',
4 => '#eab308', 5 => '#ff3b00', 6 => '#0891b2',
][$id] ?? '#64748b',
'label' => $name,
])
->values()
->all()
: [],
];
}
public function balanceDistribution($profile): ?array
{
// Organizations and banks: both hold many counterparties, so the bands
// describe a real spread. A user's partner list is small enough that
// the threshold below would hide it anyway.
if (!$profile instanceof \App\Models\Organization && !$profile instanceof \App\Models\Bank) {
return null;
}
$service = new \App\Services\StatsService(new \App\Services\Stats\StatsFilter(
periodFrom: $this->fromDate ? Carbon::parse($this->fromDate) : null,
periodTill: $this->toDate ? Carbon::parse($this->toDate) : null,
counterparties: [[
'type' => get_class($profile),
'id' => $profile->id,
]],
));
$distribution = $service->balanceDistribution();
$partnerCount = $distribution['profiles_counted'] ?? 0;
// Withheld rather than absent: the view says so, because a section that
// simply vanishes reads as a bug. Null stays reserved for "this profile
// type has no such section at all".
if ($partnerCount < self::MIN_PARTNERS_FOR_BREAKDOWN) {
return [
'withheld' => true,
'profiles_counted' => $partnerCount,
'minimum' => self::MIN_PARTNERS_FOR_BREAKDOWN,
];
}
// Counts only. Nothing that could name or order an individual partner
// leaves this method.
return [
'withheld' => false,
'bands' => $distribution['bands'],
'series' => $distribution['series'],
'profiles_counted' => $partnerCount,
];
}
/**
* Export report as PDF with both chart images
*/
/**
* Validate a data-URL chart image and store it under a fresh temp id.
*
* Validation mirrors Reports::decodeChartImage(): strict base64 plus a
* PNG/JPEG magic-byte check, so a crafted payload cannot be written to disk
* and handed to the PDF renderer. The three charts shared two copies of an
* unvalidated version of this before.
*
* @return string|null the temp id, or null when no image was supplied
*/
private function storeChartImage(?string $chartImage, string $prefix): ?string
{
if (!$chartImage) {
return null;
}
$base64Data = explode(',', $chartImage)[1] ?? $chartImage;
$imageData = base64_decode($base64Data, strict: true);
if ($imageData === false) {
abort(422, 'Invalid chart image data');
}
$isPng = str_starts_with($imageData, "\x89PNG");
$isJpeg = str_starts_with($imageData, "\xFF\xD8\xFF");
if (!$isPng && !$isJpeg) {
Log::warning('SingleReport: rejected chart image with invalid magic bytes', [
'profileType' => session('activeProfileType'),
'profileId' => session('activeProfileId'),
]);
abort(422, 'Chart image must be a PNG or JPEG');
}
$imageId = uniqid($prefix, true);
$tempImagePath = storage_path('app/temp/' . $imageId . '.png');
if (!file_exists(dirname($tempImagePath))) {
mkdir(dirname($tempImagePath), 0755, true);
}
file_put_contents($tempImagePath, $imageData);
return $imageId;
}
public function exportPdfWithCharts($returnRatioChartImage = null, $accountBalancesChartImage = null, $fromDate = null, $toDate = null, $decimalFormat = null, $balanceDistributionChartImage = null, $balanceDistributionOrgChartImage = null, $networkChartImage = null)
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate authorization before export
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
// Use passed dates if provided, otherwise use component dates
$actualFromDate = $fromDate ?? $this->fromDate;
$actualToDate = $toDate ?? $this->toDate;
$chartImageIds = array_filter([
'return_ratio_chart_id' => $this->storeChartImage($returnRatioChartImage, 'return_ratio_chart_'),
'account_balances_chart_id' => $this->storeChartImage($accountBalancesChartImage, 'account_balances_chart_'),
'balance_distribution_chart_id' => $this->storeChartImage($balanceDistributionChartImage, 'balance_distribution_chart_'),
'balance_distribution_org_chart_id' => $this->storeChartImage($balanceDistributionOrgChartImage, 'balance_distribution_org_chart_'),
'network_chart_id' => $this->storeChartImage($networkChartImage, 'network_chart_'),
]);
// Store chart image IDs in session
session(['chart_image_ids' => $chartImageIds]);
// The graph's settings travel with it: a printed graph has no controls,
// so the PDF has to say which view produced it.
session(['report_network_settings' => isset($chartImageIds['network_chart_id'])
? $this->networkSettingsSummary()
: null]);
// The legend belongs with the image: without it the colours and sizes
// in a printed graph mean nothing.
$graph = isset($chartImageIds['network_chart_id']) ? $this->networkData() : null;
session(['report_network_legend' => $graph ? $this->networkLegend($graph) : null]);
// Build URL with current report parameters
// Use explicitly passed decimalFormat if provided, otherwise fall back to component state
$useDecimal = $decimalFormat !== null ? (bool) $decimalFormat : $this->decimalFormat;
$params = [
'fromDate' => $actualFromDate,
'toDate' => $actualToDate,
'with_charts' => 1,
'decimal' => $useDecimal ? 1 : 0,
];
$url = route('reports.pdf', $params);
// Use JavaScript to open PDF in new window/tab
$this->dispatch('openPdf', $url);
}
}