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

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