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.

BudgetResource.php 28KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. <?php
  2. namespace App\Filament\Company\Resources\Accounting;
  3. use App\Enums\Accounting\BudgetIntervalType;
  4. use App\Filament\Company\Resources\Accounting\BudgetResource\Pages;
  5. use App\Filament\Forms\Components\CustomSection;
  6. use App\Filament\Forms\Components\CustomTableRepeater;
  7. use App\Models\Accounting\Account;
  8. use App\Models\Accounting\Budget;
  9. use App\Models\Accounting\BudgetItem;
  10. use App\Utilities\Currency\CurrencyConverter;
  11. use Awcodes\TableRepeater\Header;
  12. use Filament\Forms;
  13. use Filament\Forms\Form;
  14. use Filament\Resources\Resource;
  15. use Filament\Support\Enums\Alignment;
  16. use Filament\Support\Enums\MaxWidth;
  17. use Filament\Support\RawJs;
  18. use Filament\Tables;
  19. use Filament\Tables\Table;
  20. use Illuminate\Support\Carbon;
  21. class BudgetResource extends Resource
  22. {
  23. protected static ?string $model = Budget::class;
  24. protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
  25. public static function form(Form $form): Form
  26. {
  27. return $form
  28. ->schema([
  29. Forms\Components\Section::make('Budget Details')
  30. ->columns()
  31. ->schema([
  32. Forms\Components\TextInput::make('name')
  33. ->required()
  34. ->maxLength(255),
  35. Forms\Components\Select::make('interval_type')
  36. ->label('Budget Interval')
  37. ->options(BudgetIntervalType::class)
  38. ->default(BudgetIntervalType::Month->value)
  39. ->required()
  40. ->live(),
  41. Forms\Components\DatePicker::make('start_date')
  42. ->required()
  43. ->default(now()->startOfYear())
  44. ->live(),
  45. Forms\Components\DatePicker::make('end_date')
  46. ->required()
  47. ->default(now()->endOfYear())
  48. ->live()
  49. ->disabled(static fn (Forms\Get $get) => blank($get('start_date')))
  50. ->minDate(fn (Forms\Get $get) => match (BudgetIntervalType::parse($get('interval_type'))) {
  51. BudgetIntervalType::Month => Carbon::parse($get('start_date'))->addMonth(),
  52. BudgetIntervalType::Quarter => Carbon::parse($get('start_date'))->addQuarter(),
  53. BudgetIntervalType::Year => Carbon::parse($get('start_date'))->addYear(),
  54. default => Carbon::parse($get('start_date'))->addDay(),
  55. })
  56. ->maxDate(fn (Forms\Get $get) => Carbon::parse($get('start_date'))->endOfYear()),
  57. Forms\Components\Textarea::make('notes')
  58. ->columnSpanFull(),
  59. ]),
  60. // Forms\Components\Section::make('Budget Items')
  61. // ->headerActions([
  62. // Forms\Components\Actions\Action::make('addAccounts')
  63. // ->label('Add Accounts')
  64. // ->icon('heroicon-m-plus')
  65. // ->outlined()
  66. // ->color('primary')
  67. // ->form(fn (Forms\Get $get) => [
  68. // Forms\Components\Select::make('selected_accounts')
  69. // ->label('Choose Accounts to Add')
  70. // ->options(function () use ($get) {
  71. // $existingAccounts = collect($get('budgetItems'))->pluck('account_id')->toArray();
  72. //
  73. // return Account::query()
  74. // ->budgetable()
  75. // ->whereNotIn('id', $existingAccounts) // Prevent duplicate selections
  76. // ->pluck('name', 'id');
  77. // })
  78. // ->searchable()
  79. // ->multiple()
  80. // ->hint('Select the accounts you want to add to this budget'),
  81. // ])
  82. // ->action(static fn (Forms\Set $set, Forms\Get $get, array $data) => self::addSelectedAccounts($set, $get, $data)),
  83. //
  84. // Forms\Components\Actions\Action::make('addAllAccounts')
  85. // ->label('Add All Accounts')
  86. // ->icon('heroicon-m-folder-plus')
  87. // ->outlined()
  88. // ->color('primary')
  89. // ->action(static fn (Forms\Set $set, Forms\Get $get) => self::addAllAccounts($set, $get))
  90. // ->hidden(static fn (Forms\Get $get) => filled($get('budgetItems'))),
  91. //
  92. // Forms\Components\Actions\Action::make('increaseAllocations')
  93. // ->label('Increase Allocations')
  94. // ->icon('heroicon-m-arrow-up')
  95. // ->outlined()
  96. // ->color('success')
  97. // ->form(fn (Forms\Get $get) => [
  98. // Forms\Components\Select::make('increase_type')
  99. // ->label('Increase Type')
  100. // ->options([
  101. // 'percentage' => 'Percentage (%)',
  102. // 'fixed' => 'Fixed Amount',
  103. // ])
  104. // ->default('percentage')
  105. // ->live()
  106. // ->required(),
  107. //
  108. // Forms\Components\TextInput::make('percentage')
  109. // ->label('Increase by %')
  110. // ->numeric()
  111. // ->suffix('%')
  112. // ->required()
  113. // ->hidden(fn (Forms\Get $get) => $get('increase_type') !== 'percentage'),
  114. //
  115. // Forms\Components\TextInput::make('fixed_amount')
  116. // ->label('Increase by Fixed Amount')
  117. // ->numeric()
  118. // ->suffix('USD')
  119. // ->required()
  120. // ->hidden(fn (Forms\Get $get) => $get('increase_type') !== 'fixed'),
  121. //
  122. // Forms\Components\Select::make('apply_to_accounts')
  123. // ->label('Apply to Accounts')
  124. // ->options(function () use ($get) {
  125. // $budgetItems = $get('budgetItems') ?? [];
  126. // $accountIds = collect($budgetItems)
  127. // ->pluck('account_id')
  128. // ->filter()
  129. // ->unique()
  130. // ->toArray();
  131. //
  132. // return Account::query()
  133. // ->whereIn('id', $accountIds)
  134. // ->pluck('name', 'id')
  135. // ->toArray();
  136. // })
  137. // ->searchable()
  138. // ->multiple()
  139. // ->hint('Leave blank to apply to all accounts'),
  140. //
  141. // Forms\Components\Select::make('apply_to_periods')
  142. // ->label('Apply to Periods')
  143. // ->options(static function () use ($get) {
  144. // $startDate = $get('start_date');
  145. // $endDate = $get('end_date');
  146. // $intervalType = $get('interval_type');
  147. //
  148. // if (blank($startDate) || blank($endDate) || blank($intervalType)) {
  149. // return [];
  150. // }
  151. //
  152. // $labels = self::generateFormattedLabels($startDate, $endDate, $intervalType);
  153. //
  154. // return array_combine($labels, $labels);
  155. // })
  156. // ->searchable()
  157. // ->multiple()
  158. // ->hint('Leave blank to apply to all periods'),
  159. // ])
  160. // ->action(static fn (Forms\Set $set, Forms\Get $get, array $data) => self::increaseAllocations($set, $get, $data))
  161. // ->visible(static fn (Forms\Get $get) => filled($get('budgetItems'))),
  162. // ])
  163. // ->schema([
  164. // Forms\Components\Repeater::make('budgetItems')
  165. // ->columns(4)
  166. // ->hiddenLabel()
  167. // ->schema([
  168. // Forms\Components\Select::make('account_id')
  169. // ->label('Account')
  170. // ->options(Account::query()
  171. // ->budgetable()
  172. // ->pluck('name', 'id'))
  173. // ->searchable()
  174. // ->disableOptionsWhenSelectedInSiblingRepeaterItems()
  175. // ->columnSpan(1)
  176. // ->required(),
  177. //
  178. // Forms\Components\TextInput::make('total_amount')
  179. // ->label('Total Amount')
  180. // ->numeric()
  181. // ->columnSpan(1)
  182. // ->suffixAction(
  183. // Forms\Components\Actions\Action::make('disperse')
  184. // ->label('Disperse')
  185. // ->icon('heroicon-m-bars-arrow-down')
  186. // ->color('primary')
  187. // ->action(static fn (Forms\Set $set, Forms\Get $get, $state) => self::disperseTotalAmount($set, $get, $state))
  188. // ),
  189. //
  190. // CustomSection::make('Budget Allocations')
  191. // ->contained(false)
  192. // ->columns(4)
  193. // ->schema(static fn (Forms\Get $get) => self::getAllocationFields($get('../../start_date'), $get('../../end_date'), $get('../../interval_type'))),
  194. // ])
  195. // ->defaultItems(0)
  196. // ->addActionLabel('Add Budget Item'),
  197. // ]),
  198. ]);
  199. }
  200. public static function table(Table $table): Table
  201. {
  202. return $table
  203. ->columns([
  204. Tables\Columns\TextColumn::make('name')
  205. ->sortable()
  206. ->searchable(),
  207. Tables\Columns\TextColumn::make('status')
  208. ->label('Status')
  209. ->sortable()
  210. ->badge(),
  211. Tables\Columns\TextColumn::make('interval_type')
  212. ->label('Interval')
  213. ->sortable()
  214. ->badge(),
  215. Tables\Columns\TextColumn::make('start_date')
  216. ->label('Start Date')
  217. ->date()
  218. ->sortable(),
  219. Tables\Columns\TextColumn::make('end_date')
  220. ->label('End Date')
  221. ->date()
  222. ->sortable(),
  223. ])
  224. ->filters([
  225. //
  226. ])
  227. ->actions([
  228. Tables\Actions\ActionGroup::make([
  229. Tables\Actions\ViewAction::make(),
  230. Tables\Actions\EditAction::make(),
  231. Tables\Actions\EditAction::make('editAllocations')
  232. ->name('editAllocations')
  233. ->url(null)
  234. ->label('Edit Allocations')
  235. ->icon('heroicon-o-table-cells')
  236. ->modalWidth(MaxWidth::Screen)
  237. ->modalHeading('Edit Budget Allocations')
  238. ->modalDescription('Update the allocations for this budget')
  239. ->slideOver()
  240. ->form(function (Budget $record) {
  241. $periods = $record->getPeriods();
  242. $headers = [
  243. Header::make('Account')
  244. ->label('Account')
  245. ->width('200px'),
  246. Header::make('total')
  247. ->label('Total')
  248. ->width('120px')
  249. ->align(Alignment::Right),
  250. Header::make('action')
  251. ->label('')
  252. ->width('40px')
  253. ->align(Alignment::Center),
  254. ];
  255. foreach ($periods as $period) {
  256. $headers[] = Header::make($period)
  257. ->label($period)
  258. ->width('120px')
  259. ->align(Alignment::Right);
  260. }
  261. return [
  262. CustomTableRepeater::make('budgetItems')
  263. ->relationship()
  264. ->hiddenLabel()
  265. ->headers($headers)
  266. ->schema([
  267. Forms\Components\Placeholder::make('account')
  268. ->hiddenLabel()
  269. ->content(fn (BudgetItem $record) => $record->account->name ?? ''),
  270. Forms\Components\TextInput::make('total')
  271. ->hiddenLabel()
  272. ->mask(RawJs::make('$money($input)'))
  273. ->stripCharacters(',')
  274. ->numeric()
  275. ->afterStateHydrated(function ($component, $state, BudgetItem $record) use ($periods) {
  276. $total = 0;
  277. // Calculate the total for this budget item across all periods
  278. foreach ($periods as $period) {
  279. $allocation = $record->allocations->firstWhere('period', $period);
  280. $total += $allocation ? $allocation->amount : 0;
  281. }
  282. $component->state($total);
  283. })
  284. ->dehydrated(false),
  285. Forms\Components\Actions::make([
  286. Forms\Components\Actions\Action::make('disperse')
  287. ->label('Disperse')
  288. ->icon('heroicon-m-chevron-double-right')
  289. ->color('primary')
  290. ->iconButton()
  291. ->action(function (Forms\Set $set, Forms\Get $get, BudgetItem $record, $livewire) use ($periods) {
  292. $total = CurrencyConverter::convertToCents($get('total'));
  293. ray($total);
  294. $numPeriods = count($periods);
  295. if ($numPeriods === 0) {
  296. return;
  297. }
  298. $baseAmount = floor($total / $numPeriods);
  299. $remainder = $total - ($baseAmount * $numPeriods);
  300. foreach ($periods as $index => $period) {
  301. $amount = $baseAmount + ($index === 0 ? $remainder : 0);
  302. $formattedAmount = CurrencyConverter::convertCentsToFormatSimple($amount);
  303. $set("allocations.{$period}", $formattedAmount);
  304. }
  305. }),
  306. ]),
  307. // Create a field for each period
  308. ...collect($periods)->map(function ($period) {
  309. return Forms\Components\TextInput::make("allocations.{$period}")
  310. ->mask(RawJs::make('$money($input)'))
  311. ->stripCharacters(',')
  312. ->numeric()
  313. ->afterStateHydrated(function ($component, $state, BudgetItem $record) use ($period) {
  314. // Find the allocation for this period
  315. $allocation = $record->allocations->firstWhere('period', $period);
  316. $component->state($allocation ? $allocation->amount : 0);
  317. })
  318. ->dehydrated(false); // We'll handle saving manually
  319. })->toArray(),
  320. ])
  321. ->spreadsheet()
  322. ->itemLabel(fn (BudgetItem $record) => $record->account->name ?? 'Budget Item')
  323. ->deletable(false)
  324. ->reorderable(false)
  325. ->addable(false) // Don't allow adding new budget items
  326. ->columnSpanFull(),
  327. ];
  328. }),
  329. ]),
  330. ])
  331. ->bulkActions([
  332. Tables\Actions\BulkActionGroup::make([
  333. Tables\Actions\DeleteBulkAction::make(),
  334. ]),
  335. ]);
  336. }
  337. private static function addAllAccounts(Forms\Set $set, Forms\Get $get): void
  338. {
  339. $accounts = Account::query()
  340. ->budgetable()
  341. ->pluck('id');
  342. $budgetItems = $accounts->map(static fn ($accountId) => [
  343. 'account_id' => $accountId,
  344. 'total_amount' => 0, // Default to 0 until the user inputs amounts
  345. 'amounts' => self::generateDefaultAllocations($get('start_date'), $get('end_date'), $get('interval_type')),
  346. ])->toArray();
  347. $set('budgetItems', $budgetItems);
  348. }
  349. private static function addSelectedAccounts(Forms\Set $set, Forms\Get $get, array $data): void
  350. {
  351. $selectedAccountIds = $data['selected_accounts'] ?? [];
  352. if (empty($selectedAccountIds)) {
  353. return; // No accounts selected, do nothing.
  354. }
  355. $existingAccountIds = collect($get('budgetItems'))
  356. ->pluck('account_id')
  357. ->unique()
  358. ->filter()
  359. ->toArray();
  360. // Only add accounts that aren't already in the budget items
  361. $newAccounts = array_diff($selectedAccountIds, $existingAccountIds);
  362. $newBudgetItems = collect($newAccounts)->map(static fn ($accountId) => [
  363. 'account_id' => $accountId,
  364. 'total_amount' => 0,
  365. 'amounts' => self::generateDefaultAllocations($get('start_date'), $get('end_date'), $get('interval_type')),
  366. ])->toArray();
  367. // Merge new budget items with existing ones
  368. $set('budgetItems', array_merge($get('budgetItems') ?? [], $newBudgetItems));
  369. }
  370. private static function generateDefaultAllocations(?string $startDate, ?string $endDate, ?string $intervalType): array
  371. {
  372. if (! $startDate || ! $endDate || ! $intervalType) {
  373. return [];
  374. }
  375. $labels = self::generateFormattedLabels($startDate, $endDate, $intervalType);
  376. return collect($labels)->mapWithKeys(static fn ($label) => [$label => 0])->toArray();
  377. }
  378. private static function increaseAllocations(Forms\Set $set, Forms\Get $get, array $data): void
  379. {
  380. $increaseType = $data['increase_type']; // 'percentage' or 'fixed'
  381. $percentage = $data['percentage'] ?? 0;
  382. $fixedAmount = $data['fixed_amount'] ?? 0;
  383. $selectedAccounts = $data['apply_to_accounts'] ?? []; // Selected account IDs
  384. $selectedPeriods = $data['apply_to_periods'] ?? []; // Selected period labels
  385. $budgetItems = $get('budgetItems') ?? [];
  386. foreach ($budgetItems as $index => $budgetItem) {
  387. // Skip if this account isn't selected (unless all accounts are being updated)
  388. if (! empty($selectedAccounts) && ! in_array($budgetItem['account_id'], $selectedAccounts)) {
  389. continue;
  390. }
  391. if (empty($budgetItem['amounts'])) {
  392. continue; // Skip if no allocations exist
  393. }
  394. $updatedAmounts = $budgetItem['amounts']; // Clone existing amounts
  395. foreach ($updatedAmounts as $label => $amount) {
  396. // Skip if this period isn't selected (unless all periods are being updated)
  397. if (! empty($selectedPeriods) && ! in_array($label, $selectedPeriods)) {
  398. continue;
  399. }
  400. // Apply increase based on selected type
  401. $updatedAmounts[$label] = match ($increaseType) {
  402. 'percentage' => round($amount * (1 + $percentage / 100), 2),
  403. 'fixed' => round($amount + $fixedAmount, 2),
  404. default => $amount,
  405. };
  406. }
  407. $set("budgetItems.{$index}.amounts", $updatedAmounts);
  408. $set("budgetItems.{$index}.total_amount", round(array_sum($updatedAmounts), 2));
  409. }
  410. }
  411. private static function disperseTotalAmount(Forms\Set $set, Forms\Get $get, float $totalAmount): void
  412. {
  413. $startDate = $get('../../start_date');
  414. $endDate = $get('../../end_date');
  415. $intervalType = $get('../../interval_type');
  416. if (! $startDate || ! $endDate || ! $intervalType || $totalAmount <= 0) {
  417. return;
  418. }
  419. $labels = self::generateFormattedLabels($startDate, $endDate, $intervalType);
  420. $numPeriods = count($labels);
  421. if ($numPeriods === 0) {
  422. return;
  423. }
  424. $baseAmount = floor($totalAmount / $numPeriods);
  425. $remainder = $totalAmount - ($baseAmount * $numPeriods);
  426. foreach ($labels as $index => $label) {
  427. $amount = $baseAmount + ($index === 0 ? $remainder : 0);
  428. $set("amounts.{$label}", $amount);
  429. }
  430. }
  431. private static function generateFormattedLabels(string $startDate, string $endDate, string $intervalType): array
  432. {
  433. $start = Carbon::parse($startDate);
  434. $end = Carbon::parse($endDate);
  435. $intervalTypeEnum = BudgetIntervalType::parse($intervalType);
  436. $labels = [];
  437. while ($start->lte($end)) {
  438. $labels[] = match ($intervalTypeEnum) {
  439. BudgetIntervalType::Month => $start->format('M'), // Example: Jan, Feb, Mar
  440. BudgetIntervalType::Quarter => 'Q' . $start->quarter, // Example: Q1, Q2, Q3
  441. BudgetIntervalType::Year => (string) $start->year, // Example: 2024, 2025
  442. default => '',
  443. };
  444. match ($intervalTypeEnum) {
  445. BudgetIntervalType::Month => $start->addMonth(),
  446. BudgetIntervalType::Quarter => $start->addQuarter(),
  447. BudgetIntervalType::Year => $start->addYear(),
  448. default => null,
  449. };
  450. }
  451. return $labels;
  452. }
  453. private static function getAllocationFields(?string $startDate, ?string $endDate, ?string $intervalType): array
  454. {
  455. if (! $startDate || ! $endDate || ! $intervalType) {
  456. return [];
  457. }
  458. $fields = [];
  459. $labels = self::generateFormattedLabels($startDate, $endDate, $intervalType);
  460. foreach ($labels as $label) {
  461. $fields[] = Forms\Components\TextInput::make("amounts.{$label}")
  462. ->label($label)
  463. ->numeric()
  464. ->required();
  465. }
  466. return $fields;
  467. }
  468. public static function getRelations(): array
  469. {
  470. return [
  471. //
  472. ];
  473. }
  474. public static function getPages(): array
  475. {
  476. return [
  477. 'index' => Pages\ListBudgets::route('/'),
  478. 'create' => Pages\CreateBudget::route('/create'),
  479. 'view' => Pages\ViewBudget::route('/{record}'),
  480. 'edit' => Pages\EditBudget::route('/{record}/edit'),
  481. 'allocations' => Pages\ManageAllocations::route('/{record}/allocations'),
  482. ];
  483. }
  484. }