您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

BudgetResource.php 20KB

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