Files
timebank-cc-public/app/Http/Livewire/SingleReport.php
Ronald Huynen 2547717edb Initial commit
2026-03-23 21:37:59 +01:00

1073 lines
38 KiB
PHP
Raw Permalink 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\Traits\AccountInfoTrait;
use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
use Livewire\Component;
class SingleReport extends Component
{
use AccountInfoTrait;
public $reportData;
public $fromAccountId;
public $fromDate;
public $toDate;
public $isOrganization = false;
public $decimalFormat = false;
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) {
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;
}
// 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;
}
}
$returnRatios = [];
foreach ($profileRatios as $profileKey => $amounts) {
if ($amounts['given'] > 0) {
$ratio = ($amounts['received'] / $amounts['given']) * 100;
$returnRatios[] = $ratio;
}
}
$averageRatio = count($returnRatios) > 0 ? array_sum($returnRatios) / count($returnRatios) : 0;
return $averageRatio;
}
/**
* 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,
];
}
// 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,
];
}
$activeProfile = getActiveProfile();
if (!$activeProfile || !method_exists($activeProfile, 'accounts')) {
return [
'transaction_count' => 0,
'unique_profiles' => 0,
'profiles_by_type' => collect(),
'average_return_ratio' => 0,
];
}
$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;
}
// 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;
}
}
// Calculate individual return ratios and compute average
$returnRatios = [];
foreach ($profileRatios as $profileKey => $amounts) {
if ($amounts['given'] > 0) {
// Return ratio = (hours received back / hours given) × 100
$ratio = ($amounts['received'] / $amounts['given']) * 100;
$returnRatios[] = $ratio;
}
}
// Average return ratio across all profiles
$averageReturnRatio = count($returnRatios) > 0 ? array_sum($returnRatios) / count($returnRatios) : 0;
return [
'transaction_count' => $transactionCount,
'unique_profiles' => $uniqueProfilesData->count(),
'profiles_by_type' => $profilesByType,
'average_return_ratio' => $averageReturnRatio,
];
}
/**
* 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,
]);
}
/**
* Export report as PDF with both chart images
*/
public function exportPdfWithCharts($returnRatioChartImage = null, $accountBalancesChartImage = null, $fromDate = null, $toDate = null, $decimalFormat = 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 = [];
// Process Return Ratio Chart image
if ($returnRatioChartImage) {
$returnRatioChartImageId = uniqid('return_ratio_chart_', true);
$tempImagePath = storage_path('app/temp/' . $returnRatioChartImageId . '.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(',', $returnRatioChartImage)[1] ?? $returnRatioChartImage;
$imageData = base64_decode($base64Data);
file_put_contents($tempImagePath, $imageData);
$chartImageIds['return_ratio_chart_id'] = $returnRatioChartImageId;
}
// Process Account Balances Chart image
if ($accountBalancesChartImage) {
$accountBalancesChartImageId = uniqid('account_balances_chart_', true);
$tempImagePath = storage_path('app/temp/' . $accountBalancesChartImageId . '.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(',', $accountBalancesChartImage)[1] ?? $accountBalancesChartImage;
$imageData = base64_decode($base64Data);
file_put_contents($tempImagePath, $imageData);
$chartImageIds['account_balances_chart_id'] = $accountBalancesChartImageId;
}
// Store chart image IDs in session
session(['chart_image_ids' => $chartImageIds]);
// 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);
}
}