'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); } }