Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

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\BudgetAllocation;
  10. use App\Models\Accounting\BudgetItem;
  11. use App\Utilities\Currency\CurrencyConverter;
  12. use Awcodes\TableRepeater\Header;
  13. use Filament\Forms;
  14. use Filament\Forms\Form;
  15. use Filament\Resources\Resource;
  16. use Filament\Support\Enums\Alignment;
  17. use Filament\Support\Enums\MaxWidth;
  18. use Filament\Support\RawJs;
  19. use Filament\Tables;
  20. use Filament\Tables\Table;
  21. use Illuminate\Support\Carbon;
  22. class BudgetResource extends Resource
  23. {
  24. protected static ?string $model = Budget::class;
  25. protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
  26. protected static ?string $recordTitleAttribute = 'name';
  27. protected static bool $isGloballySearchable = false;
  28. public static function form(Form $form): Form
  29. {
  30. return $form
  31. ->schema([
  32. Forms\Components\Section::make('Budget Details')
  33. ->columns()
  34. ->schema([
  35. Forms\Components\TextInput::make('name')
  36. ->required()
  37. ->maxLength(255),
  38. Forms\Components\Select::make('interval_type')
  39. ->label('Budget Interval')
  40. ->options(BudgetIntervalType::class)
  41. ->default(BudgetIntervalType::Month->value)
  42. ->required()
  43. ->live(),
  44. Forms\Components\DatePicker::make('start_date')
  45. ->required()
  46. ->default(company_today()->startOfYear())
  47. ->live(),
  48. Forms\Components\DatePicker::make('end_date')
  49. ->required()
  50. ->default(company_today()->endOfYear())
  51. ->live()
  52. ->disabled(static fn (Forms\Get $get) => blank($get('start_date')))
  53. ->minDate(fn (Forms\Get $get) => match (BudgetIntervalType::parse($get('interval_type'))) {
  54. BudgetIntervalType::Month => Carbon::parse($get('start_date'))->addMonth(),
  55. BudgetIntervalType::Quarter => Carbon::parse($get('start_date'))->addQuarter(),
  56. BudgetIntervalType::Year => Carbon::parse($get('start_date'))->addYear(),
  57. default => Carbon::parse($get('start_date'))->addDay(),
  58. })
  59. ->maxDate(fn (Forms\Get $get) => Carbon::parse($get('start_date'))->endOfYear()),
  60. Forms\Components\Textarea::make('notes')
  61. ->columnSpanFull(),
  62. ]),
  63. // Forms\Components\Section::make('Budget Items')
  64. // ->headerActions([
  65. // Forms\Components\Actions\Action::make('addAccounts')
  66. // ->label('Add Accounts')
  67. // ->icon('heroicon-m-plus')
  68. // ->outlined()
  69. // ->color('primary')
  70. // ->form(fn (Forms\Get $get) => [
  71. // Forms\Components\Select::make('selected_accounts')
  72. // ->label('Choose Accounts to Add')
  73. // ->options(function () use ($get) {
  74. // $existingAccounts = collect($get('budgetItems'))->pluck('account_id')->toArray();
  75. //
  76. // return Account::query()
  77. // ->budgetable()
  78. // ->whereNotIn('id', $existingAccounts) // Prevent duplicate selections
  79. // ->pluck('name', 'id');
  80. // })
  81. // ->searchable()
  82. // ->multiple()
  83. // ->hint('Select the accounts you want to add to this budget'),
  84. // ])
  85. // ->action(static fn (Forms\Set $set, Forms\Get $get, array $data) => self::addSelectedAccounts($set, $get, $data)),
  86. //
  87. // Forms\Components\Actions\Action::make('addAllAccounts')
  88. // ->label('Add All Accounts')
  89. // ->icon('heroicon-m-folder-plus')
  90. // ->outlined()
  91. // ->color('primary')
  92. // ->action(static fn (Forms\Set $set, Forms\Get $get) => self::addAllAccounts($set, $get))
  93. // ->hidden(static fn (Forms\Get $get) => filled($get('budgetItems'))),
  94. //
  95. // Forms\Components\Actions\Action::make('increaseAllocations')
  96. // ->label('Increase Allocations')
  97. // ->icon('heroicon-m-arrow-up')
  98. // ->outlined()
  99. // ->color('success')
  100. // ->form(fn (Forms\Get $get) => [
  101. // Forms\Components\Select::make('increase_type')
  102. // ->label('Increase Type')
  103. // ->options([
  104. // 'percentage' => 'Percentage (%)',
  105. // 'fixed' => 'Fixed Amount',
  106. // ])
  107. // ->default('percentage')
  108. // ->live()
  109. // ->required(),
  110. //
  111. // Forms\Components\TextInput::make('percentage')
  112. // ->label('Increase by %')
  113. // ->numeric()
  114. // ->suffix('%')
  115. // ->required()
  116. // ->hidden(fn (Forms\Get $get) => $get('increase_type') !== 'percentage'),
  117. //
  118. // Forms\Components\TextInput::make('fixed_amount')
  119. // ->label('Increase by Fixed Amount')
  120. // ->numeric()
  121. // ->suffix('USD')
  122. // ->required()
  123. // ->hidden(fn (Forms\Get $get) => $get('increase_type') !== 'fixed'),
  124. //
  125. // Forms\Components\Select::make('apply_to_accounts')
  126. // ->label('Apply to Accounts')
  127. // ->options(function () use ($get) {
  128. // $budgetItems = $get('budgetItems') ?? [];
  129. // $accountIds = collect($budgetItems)
  130. // ->pluck('account_id')
  131. // ->filter()
  132. // ->unique()
  133. // ->toArray();
  134. //
  135. // return Account::query()
  136. // ->whereIn('id', $accountIds)
  137. // ->pluck('name', 'id')
  138. // ->toArray();
  139. // })
  140. // ->searchable()
  141. // ->multiple()
  142. // ->hint('Leave blank to apply to all accounts'),
  143. //
  144. // Forms\Components\Select::make('apply_to_periods')
  145. // ->label('Apply to Periods')
  146. // ->options(static function () use ($get) {
  147. // $startDate = $get('start_date');
  148. // $endDate = $get('end_date');
  149. // $intervalType = $get('interval_type');
  150. //
  151. // if (blank($startDate) || blank($endDate) || blank($intervalType)) {
  152. // return [];
  153. // }
  154. //
  155. // $labels = self::generateFormattedLabels($startDate, $endDate, $intervalType);
  156. //
  157. // return array_combine($labels, $labels);
  158. // })
  159. // ->searchable()
  160. // ->multiple()
  161. // ->hint('Leave blank to apply to all periods'),
  162. // ])
  163. // ->action(static fn (Forms\Set $set, Forms\Get $get, array $data) => self::increaseAllocations($set, $get, $data))
  164. // ->visible(static fn (Forms\Get $get) => filled($get('budgetItems'))),
  165. // ])
  166. // ->schema([
  167. // Forms\Components\Repeater::make('budgetItems')
  168. // ->columns(4)
  169. // ->hiddenLabel()
  170. // ->schema([
  171. // Forms\Components\Select::make('account_id')
  172. // ->label('Account')
  173. // ->options(Account::query()
  174. // ->budgetable()
  175. // ->pluck('name', 'id'))
  176. // ->searchable()
  177. // ->disableOptionsWhenSelectedInSiblingRepeaterItems()
  178. // ->columnSpan(1)
  179. // ->required(),
  180. //
  181. // Forms\Components\TextInput::make('total_amount')
  182. // ->label('Total Amount')
  183. // ->numeric()
  184. // ->columnSpan(1)
  185. // ->suffixAction(
  186. // Forms\Components\Actions\Action::make('disperse')
  187. // ->label('Disperse')
  188. // ->icon('heroicon-m-bars-arrow-down')
  189. // ->color('primary')
  190. // ->action(static fn (Forms\Set $set, Forms\Get $get, $state) => self::disperseTotalAmount($set, $get, $state))
  191. // ),
  192. //
  193. // CustomSection::make('Budget Allocations')
  194. // ->contained(false)
  195. // ->columns(4)
  196. // ->schema(static fn (Forms\Get $get) => self::getAllocationFields($get('../../start_date'), $get('../../end_date'), $get('../../interval_type'))),
  197. // ])
  198. // ->defaultItems(0)
  199. // ->addActionLabel('Add Budget Item'),
  200. // ]),
  201. ]);
  202. }
  203. public static function table(Table $table): Table
  204. {
  205. return $table
  206. ->columns([
  207. Tables\Columns\TextColumn::make('name')
  208. ->sortable()
  209. ->searchable(),
  210. Tables\Columns\TextColumn::make('status')
  211. ->label('Status')
  212. ->sortable()
  213. ->badge(),
  214. Tables\Columns\TextColumn::make('interval_type')
  215. ->label('Interval')
  216. ->sortable()
  217. ->badge(),
  218. Tables\Columns\TextColumn::make('start_date')
  219. ->label('Start Date')
  220. ->date()
  221. ->sortable(),
  222. Tables\Columns\TextColumn::make('end_date')
  223. ->label('End Date')
  224. ->date()
  225. ->sortable(),
  226. ])
  227. ->filters([
  228. //
  229. ])
  230. ->actions([
  231. Tables\Actions\ActionGroup::make([
  232. Tables\Actions\ViewAction::make(),
  233. Tables\Actions\EditAction::make('editAllocations')
  234. ->name('editAllocations')
  235. ->url(null)
  236. ->label('Edit Allocations')
  237. ->icon('heroicon-o-table-cells')
  238. ->modalWidth(MaxWidth::Screen)
  239. ->modalHeading('Edit Budget Allocations')
  240. ->modalDescription('Update the allocations for this budget')
  241. ->slideOver()
  242. ->form(function (Budget $record) {
  243. $periods = $record->getPeriods();
  244. $headers = [
  245. Header::make('Account')
  246. ->label('Account')
  247. ->width('200px'),
  248. Header::make('total')
  249. ->label('Total')
  250. ->width('120px')
  251. ->align(Alignment::Right),
  252. Header::make('action')
  253. ->label('')
  254. ->width('40px')
  255. ->align(Alignment::Center),
  256. ];
  257. foreach ($periods as $period) {
  258. $headers[] = Header::make($period->period)
  259. ->label($period->period)
  260. ->width('120px')
  261. ->align(Alignment::Right);
  262. }
  263. return [
  264. CustomTableRepeater::make('budgetItems')
  265. ->relationship()
  266. ->hiddenLabel()
  267. ->headers($headers)
  268. ->schema([
  269. Forms\Components\Placeholder::make('account')
  270. ->hiddenLabel()
  271. ->content(fn (BudgetItem $record) => $record->account->name ?? ''),
  272. Forms\Components\TextInput::make('total')
  273. ->hiddenLabel()
  274. ->mask(RawJs::make('$money($input)'))
  275. ->stripCharacters(',')
  276. ->numeric()
  277. ->afterStateHydrated(function ($component, $state, BudgetItem $record) use ($periods) {
  278. $total = 0;
  279. // Calculate the total for this budget item across all periods
  280. foreach ($periods as $period) {
  281. $allocation = $record->allocations->firstWhere('period', $period->period);
  282. $total += $allocation ? $allocation->getRawOriginal('amount') : 0;
  283. }
  284. $component->state(CurrencyConverter::convertCentsToFormatSimple($total));
  285. })
  286. ->dehydrated(false),
  287. Forms\Components\Actions::make([
  288. Forms\Components\Actions\Action::make('disperse')
  289. ->label('Disperse')
  290. ->icon('heroicon-m-chevron-double-right')
  291. ->color('primary')
  292. ->iconButton()
  293. ->action(function (Forms\Set $set, Forms\Get $get, BudgetItem $record, $livewire) use ($periods) {
  294. $total = CurrencyConverter::convertToCents($get('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->period}", $formattedAmount);
  305. }
  306. }),
  307. ]),
  308. // Create a field for each period
  309. ...collect($periods)->map(function (BudgetAllocation $period) {
  310. return Forms\Components\TextInput::make("allocations.{$period->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->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. }