1073 lines
38 KiB
PHP
1073 lines
38 KiB
PHP
<?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);
|
||
}
|
||
} |