Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

BudgetResource.php 28KB

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