You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

BudgetItemsRelationManager.php 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. <?php
  2. namespace App\Filament\Company\Resources\Accounting\BudgetResource\RelationManagers;
  3. use App\Filament\Tables\Columns\DeferredTextInputColumn;
  4. use App\Models\Accounting\Budget;
  5. use App\Models\Accounting\BudgetAllocation;
  6. use App\Models\Accounting\BudgetItem;
  7. use App\Utilities\Currency\CurrencyConverter;
  8. use Filament\Notifications\Notification;
  9. use Filament\Resources\RelationManagers\RelationManager;
  10. use Filament\Support\RawJs;
  11. use Filament\Tables\Actions\Action;
  12. use Filament\Tables\Actions\BulkAction;
  13. use Filament\Tables\Columns\IconColumn;
  14. use Filament\Tables\Columns\Summarizers\Summarizer;
  15. use Filament\Tables\Columns\TextColumn;
  16. use Filament\Tables\Grouping\Group;
  17. use Filament\Tables\Table;
  18. use Illuminate\Database\Eloquent\Builder;
  19. use Illuminate\Database\Eloquent\Collection;
  20. use Illuminate\Support\Str;
  21. class BudgetItemsRelationManager extends RelationManager
  22. {
  23. protected static string $relationship = 'budgetItems';
  24. protected static bool $isLazy = false;
  25. protected const AMOUNT_PREFIX = 'amount_';
  26. protected const TOTAL_COLUMN = 'total';
  27. public array $batchChanges = [];
  28. /**
  29. * Generate a consistent key for the budget item and period
  30. */
  31. protected static function generatePeriodKey(int $recordId, string $period): string
  32. {
  33. return "{$recordId}." . self::AMOUNT_PREFIX . Str::snake($period);
  34. }
  35. /**
  36. * Generate a consistent key for the budget item's total
  37. */
  38. protected static function generateTotalKey(int $recordId): string
  39. {
  40. return "{$recordId}." . self::TOTAL_COLUMN;
  41. }
  42. /**
  43. * Extract the period from a column name
  44. */
  45. protected static function extractPeriodFromColumn(string $columnName): ?string
  46. {
  47. if (preg_match('/' . self::AMOUNT_PREFIX . '(.+)/', $columnName, $matches)) {
  48. return str_replace('_', ' ', $matches[1] ?? '');
  49. }
  50. return null;
  51. }
  52. public function handleBatchColumnChanged($data): void
  53. {
  54. $key = "{$data['recordKey']}.{$data['name']}";
  55. $this->batchChanges[$key] = $data['value'];
  56. }
  57. public function saveBatchChanges(): void
  58. {
  59. foreach ($this->batchChanges as $key => $value) {
  60. [$recordKey, $column] = explode('.', $key, 2);
  61. $period = self::extractPeriodFromColumn($column);
  62. if (! $period) {
  63. continue;
  64. }
  65. $record = BudgetItem::find($recordKey);
  66. if (! $record) {
  67. continue;
  68. }
  69. $allocation = $record->allocations->firstWhere('period', $period);
  70. if ($allocation) {
  71. $allocation->update(['amount' => $value]);
  72. } else {
  73. $record->allocations()->create([
  74. 'period' => $period,
  75. 'amount' => $value,
  76. ]);
  77. }
  78. }
  79. $this->batchChanges = [];
  80. Notification::make()
  81. ->title('Budget allocations updated')
  82. ->success()
  83. ->send();
  84. }
  85. protected function calculateTotalSum(array $budgetItemIds): int
  86. {
  87. $periods = BudgetAllocation::whereIn('budget_item_id', $budgetItemIds)
  88. ->pluck('period')
  89. ->unique()
  90. ->values()
  91. ->toArray();
  92. $total = 0;
  93. foreach ($periods as $period) {
  94. $total += $this->calculatePeriodSum($budgetItemIds, $period);
  95. }
  96. return $total;
  97. }
  98. protected function calculatePeriodSum(array $budgetItemIds, string $period): int
  99. {
  100. $dbTotal = BudgetAllocation::whereIn('budget_item_id', $budgetItemIds)
  101. ->where('period', $period)
  102. ->sum('amount');
  103. $batchTotal = 0;
  104. foreach ($budgetItemIds as $itemId) {
  105. $key = self::generatePeriodKey($itemId, $period);
  106. if (isset($this->batchChanges[$key])) {
  107. $batchValue = CurrencyConverter::convertToCents($this->batchChanges[$key]);
  108. $existingAmount = BudgetAllocation::where('budget_item_id', $itemId)
  109. ->where('period', $period)
  110. ->first()
  111. ?->getRawOriginal('amount') ?? 0;
  112. $batchTotal += ($batchValue - $existingAmount);
  113. }
  114. }
  115. return $dbTotal + $batchTotal;
  116. }
  117. public function table(Table $table): Table
  118. {
  119. /** @var Budget $budget */
  120. $budget = $this->getOwnerRecord();
  121. $periods = $budget->getPeriods();
  122. return $table
  123. ->recordTitleAttribute('account_id')
  124. ->paginated(false)
  125. ->heading(null)
  126. ->modifyQueryUsing(function (Builder $query) use ($periods) {
  127. $query->select('budget_items.*')
  128. ->leftJoin('budget_allocations', 'budget_allocations.budget_item_id', '=', 'budget_items.id');
  129. foreach ($periods as $period) {
  130. $alias = self::AMOUNT_PREFIX . Str::snake($period);
  131. $query->selectRaw(
  132. "SUM(CASE WHEN budget_allocations.period = ? THEN budget_allocations.amount ELSE 0 END) as {$alias}",
  133. [$period]
  134. );
  135. }
  136. return $query->groupBy('budget_items.id');
  137. })
  138. ->groups([
  139. Group::make('account.category')
  140. ->titlePrefixedWithLabel(false)
  141. ->collapsible(),
  142. ])
  143. ->recordClasses(['budget-items-relation-manager'])
  144. ->defaultGroup('account.category')
  145. ->headerActions([
  146. Action::make('saveBatchChanges')
  147. ->label('Save all changes')
  148. ->action('saveBatchChanges')
  149. ->color('primary'),
  150. ])
  151. ->columns([
  152. TextColumn::make('account.name')
  153. ->label('Account')
  154. ->limit(30)
  155. ->searchable(),
  156. DeferredTextInputColumn::make(self::TOTAL_COLUMN)
  157. ->label('Total')
  158. ->alignRight()
  159. ->mask(RawJs::make('$money($input)'))
  160. ->getStateUsing(function (BudgetItem $record) {
  161. $key = self::generateTotalKey($record->getKey());
  162. if (isset($this->batchChanges[$key])) {
  163. return $this->batchChanges[$key];
  164. }
  165. $total = $record->allocations->sum(
  166. fn (BudgetAllocation $allocation) => $allocation->getRawOriginal('amount')
  167. );
  168. return CurrencyConverter::convertCentsToFormatSimple($total);
  169. })
  170. ->batchMode()
  171. ->summarize(
  172. Summarizer::make()
  173. ->using(function (\Illuminate\Database\Query\Builder $query) {
  174. $budgetItemIds = $query->pluck('id')->toArray();
  175. $total = $this->calculateTotalSum($budgetItemIds);
  176. return CurrencyConverter::convertCentsToFormatSimple($total);
  177. })
  178. ),
  179. IconColumn::make('disperseAction')
  180. ->icon('heroicon-m-chevron-double-right')
  181. ->color('primary')
  182. ->label('')
  183. ->default('')
  184. ->tooltip('Disperse total across periods')
  185. ->action(
  186. Action::make('disperse')
  187. ->label('Disperse')
  188. ->action(function (BudgetItem $record) use ($periods) {
  189. if (empty($periods)) {
  190. return;
  191. }
  192. $totalKey = self::generateTotalKey($record->getKey());
  193. $totalAmount = $this->batchChanges[$totalKey] ?? null;
  194. if (isset($totalAmount)) {
  195. $totalCents = CurrencyConverter::convertToCents($totalAmount);
  196. } else {
  197. $totalCents = $record->allocations->sum(function (BudgetAllocation $budgetAllocation) {
  198. return $budgetAllocation->getRawOriginal('amount');
  199. });
  200. }
  201. $numPeriods = count($periods);
  202. if ($numPeriods === 0) {
  203. return;
  204. }
  205. if ($totalCents <= 0) {
  206. foreach ($periods as $period) {
  207. $periodKey = self::generatePeriodKey($record->getKey(), $period);
  208. $this->batchChanges[$periodKey] = CurrencyConverter::convertCentsToFormatSimple(0);
  209. }
  210. return;
  211. }
  212. $baseAmount = floor($totalCents / $numPeriods);
  213. $remainder = $totalCents - ($baseAmount * $numPeriods);
  214. foreach ($periods as $index => $period) {
  215. $amount = $baseAmount + ($index === 0 ? $remainder : 0);
  216. $formattedAmount = CurrencyConverter::convertCentsToFormatSimple($amount);
  217. $periodKey = self::generatePeriodKey($record->getKey(), $period);
  218. $this->batchChanges[$periodKey] = $formattedAmount;
  219. }
  220. }),
  221. ),
  222. ...array_map(function (string $period) {
  223. $alias = self::AMOUNT_PREFIX . Str::snake($period);
  224. return DeferredTextInputColumn::make($alias)
  225. ->label($period)
  226. ->alignRight()
  227. ->batchMode()
  228. ->mask(RawJs::make('$money($input)'))
  229. ->getStateUsing(function ($record) use ($alias) {
  230. $key = "{$record->getKey()}.{$alias}";
  231. return $this->batchChanges[$key] ?? CurrencyConverter::convertCentsToFormatSimple($record->{$alias} ?? 0);
  232. })
  233. ->summarize(
  234. Summarizer::make()
  235. ->using(function (\Illuminate\Database\Query\Builder $query) use ($period) {
  236. $budgetItemIds = $query->pluck('id')->toArray();
  237. $total = $this->calculatePeriodSum($budgetItemIds, $period);
  238. return CurrencyConverter::convertCentsToFormatSimple($total);
  239. })
  240. );
  241. }, $periods),
  242. ])
  243. ->bulkActions([
  244. BulkAction::make('clearAllocations')
  245. ->label('Clear Allocations')
  246. ->icon('heroicon-o-trash')
  247. ->color('danger')
  248. ->requiresConfirmation()
  249. ->deselectRecordsAfterCompletion()
  250. ->action(function (Collection $records) use ($periods) {
  251. foreach ($records as $record) {
  252. foreach ($periods as $period) {
  253. $periodKey = self::generatePeriodKey($record->getKey(), $period);
  254. $this->batchChanges[$periodKey] = CurrencyConverter::convertCentsToFormatSimple(0);
  255. }
  256. }
  257. }),
  258. ]);
  259. }
  260. }