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 25KB

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