|
@@ -5,55 +5,97 @@ namespace App\Filament\Company\Resources\Accounting\BudgetResource\Pages;
|
5
|
5
|
use App\Enums\Accounting\BudgetIntervalType;
|
6
|
6
|
use App\Facades\Accounting;
|
7
|
7
|
use App\Filament\Company\Resources\Accounting\BudgetResource;
|
8
|
|
-use App\Filament\Forms\Components\LinearWizard;
|
|
8
|
+use App\Filament\Forms\Components\CustomSection;
|
9
|
9
|
use App\Models\Accounting\Account;
|
10
|
10
|
use App\Models\Accounting\Budget;
|
11
|
11
|
use App\Models\Accounting\BudgetAllocation;
|
12
|
12
|
use App\Models\Accounting\BudgetItem;
|
13
|
13
|
use App\Utilities\Currency\CurrencyConverter;
|
14
|
|
-use Filament\Actions\ActionGroup;
|
15
|
14
|
use Filament\Forms;
|
16
|
15
|
use Filament\Forms\Components\Actions\Action;
|
17
|
16
|
use Filament\Forms\Components\Wizard\Step;
|
18
|
|
-use Filament\Forms\Form;
|
19
|
17
|
use Filament\Resources\Pages\CreateRecord;
|
20
|
18
|
use Illuminate\Database\Eloquent\Builder;
|
21
|
19
|
use Illuminate\Database\Eloquent\Model;
|
22
|
20
|
use Illuminate\Support\Carbon;
|
|
21
|
+use Illuminate\Support\Collection;
|
23
|
22
|
|
24
|
23
|
class CreateBudget extends CreateRecord
|
25
|
24
|
{
|
|
25
|
+ use CreateRecord\Concerns\HasWizard;
|
|
26
|
+
|
26
|
27
|
protected static string $resource = BudgetResource::class;
|
27
|
28
|
|
28
|
|
- public function getStartStep(): int
|
|
29
|
+ // Add computed properties
|
|
30
|
+ public function getBudgetableAccounts(): Collection
|
29
|
31
|
{
|
30
|
|
- return 1;
|
|
32
|
+ return $this->getAccountsCache('budgetable', function () {
|
|
33
|
+ return Account::query()->budgetable()->get();
|
|
34
|
+ });
|
31
|
35
|
}
|
32
|
36
|
|
33
|
|
- public function form(Form $form): Form
|
|
37
|
+ public function getAccountsWithActuals(): Collection
|
34
|
38
|
{
|
35
|
|
- return parent::form($form)
|
36
|
|
- ->schema([
|
37
|
|
- LinearWizard::make($this->getSteps())
|
38
|
|
- ->startOnStep($this->getStartStep())
|
39
|
|
- ->cancelAction($this->getCancelFormAction())
|
40
|
|
- ->submitAction($this->getSubmitFormAction()->label('Next'))
|
41
|
|
- ->skippable($this->hasSkippableSteps()),
|
42
|
|
- ])
|
43
|
|
- ->columns(null);
|
|
39
|
+ $fiscalYear = $this->data['actuals_fiscal_year'] ?? null;
|
|
40
|
+
|
|
41
|
+ if (blank($fiscalYear)) {
|
|
42
|
+ return collect();
|
|
43
|
+ }
|
|
44
|
+
|
|
45
|
+ return $this->getAccountsCache("actuals_{$fiscalYear}", function () use ($fiscalYear) {
|
|
46
|
+ return Account::query()
|
|
47
|
+ ->budgetable()
|
|
48
|
+ ->whereHas('journalEntries.transaction', function (Builder $query) use ($fiscalYear) {
|
|
49
|
+ $query->whereYear('posted_at', $fiscalYear);
|
|
50
|
+ })
|
|
51
|
+ ->get();
|
|
52
|
+ });
|
44
|
53
|
}
|
45
|
54
|
|
46
|
|
- /**
|
47
|
|
- * @return array<\Filament\Actions\Action | ActionGroup>
|
48
|
|
- */
|
49
|
|
- public function getFormActions(): array
|
|
55
|
+ public function getAccountsWithoutActuals(): Collection
|
50
|
56
|
{
|
51
|
|
- return [];
|
|
57
|
+ $fiscalYear = $this->data['actuals_fiscal_year'] ?? null;
|
|
58
|
+
|
|
59
|
+ if (blank($fiscalYear)) {
|
|
60
|
+ return collect();
|
|
61
|
+ }
|
|
62
|
+
|
|
63
|
+ $budgetableAccounts = $this->getBudgetableAccounts();
|
|
64
|
+ $accountsWithActuals = $this->getAccountsWithActuals();
|
|
65
|
+
|
|
66
|
+ return $budgetableAccounts->whereNotIn('id', $accountsWithActuals->pluck('id'));
|
52
|
67
|
}
|
53
|
68
|
|
54
|
|
- protected function hasSkippableSteps(): bool
|
|
69
|
+ public function getAccountBalances(): Collection
|
55
|
70
|
{
|
56
|
|
- return false;
|
|
71
|
+ $fiscalYear = $this->data['actuals_fiscal_year'] ?? null;
|
|
72
|
+
|
|
73
|
+ if (blank($fiscalYear)) {
|
|
74
|
+ return collect();
|
|
75
|
+ }
|
|
76
|
+
|
|
77
|
+ return $this->getAccountsCache("balances_{$fiscalYear}", function () use ($fiscalYear) {
|
|
78
|
+ $fiscalYearStart = Carbon::create($fiscalYear, 1, 1)->startOfYear();
|
|
79
|
+ $fiscalYearEnd = $fiscalYearStart->copy()->endOfYear();
|
|
80
|
+
|
|
81
|
+ return Accounting::getAccountBalances(
|
|
82
|
+ $fiscalYearStart->toDateString(),
|
|
83
|
+ $fiscalYearEnd->toDateString(),
|
|
84
|
+ $this->getBudgetableAccounts()->pluck('id')->toArray()
|
|
85
|
+ )->get();
|
|
86
|
+ });
|
|
87
|
+ }
|
|
88
|
+
|
|
89
|
+ // Cache helper to avoid duplicate queries
|
|
90
|
+ private array $accountsCache = [];
|
|
91
|
+
|
|
92
|
+ private function getAccountsCache(string $key, callable $callback): Collection
|
|
93
|
+ {
|
|
94
|
+ if (! isset($this->accountsCache[$key])) {
|
|
95
|
+ $this->accountsCache[$key] = $callback();
|
|
96
|
+ }
|
|
97
|
+
|
|
98
|
+ return $this->accountsCache[$key];
|
57
|
99
|
}
|
58
|
100
|
|
59
|
101
|
public function getSteps(): array
|
|
@@ -138,65 +180,125 @@ class CreateBudget extends CreateRecord
|
138
|
180
|
})
|
139
|
181
|
->required()
|
140
|
182
|
->live()
|
|
183
|
+ ->afterStateUpdated(function (Forms\Set $set) {
|
|
184
|
+ // Clear the cache when the fiscal year changes
|
|
185
|
+ $this->accountsCache = [];
|
|
186
|
+
|
|
187
|
+ // Get all accounts without actuals
|
|
188
|
+ $accountIdsWithoutActuals = $this->getAccountsWithoutActuals()->pluck('id')->toArray();
|
|
189
|
+
|
|
190
|
+ // Set exclude_accounts_without_actuals to true by default
|
|
191
|
+ $set('exclude_accounts_without_actuals', true);
|
|
192
|
+
|
|
193
|
+ // Update the selected_accounts field to exclude accounts without actuals
|
|
194
|
+ $set('selected_accounts', $accountIdsWithoutActuals);
|
|
195
|
+ })
|
141
|
196
|
->visible(fn (Forms\Get $get) => $get('prefill_method') === 'actuals'),
|
142
|
197
|
])->visible(fn (Forms\Get $get) => $get('prefill_data') === true),
|
143
|
198
|
|
|
199
|
+ CustomSection::make('Account Selection')
|
|
200
|
+ ->contained(false)
|
|
201
|
+ ->schema([
|
|
202
|
+ Forms\Components\Checkbox::make('exclude_accounts_without_actuals')
|
|
203
|
+ ->label('Exclude all accounts without actuals')
|
|
204
|
+ ->helperText(function () {
|
|
205
|
+ $count = $this->getAccountsWithoutActuals()->count();
|
|
206
|
+
|
|
207
|
+ return "Will exclude {$count} accounts without transaction data in the selected fiscal year";
|
|
208
|
+ })
|
|
209
|
+ ->default(true)
|
|
210
|
+ ->live()
|
|
211
|
+ ->afterStateUpdated(function (Forms\Set $set, $state) {
|
|
212
|
+ if ($state) {
|
|
213
|
+ // When checked, select all accounts without actuals
|
|
214
|
+ $accountsWithoutActuals = $this->getAccountsWithoutActuals()->pluck('id')->toArray();
|
|
215
|
+ $set('selected_accounts', $accountsWithoutActuals);
|
|
216
|
+ } else {
|
|
217
|
+ // When unchecked, clear the selection
|
|
218
|
+ $set('selected_accounts', []);
|
|
219
|
+ }
|
|
220
|
+ }),
|
|
221
|
+
|
|
222
|
+ Forms\Components\CheckboxList::make('selected_accounts')
|
|
223
|
+ ->label('Select Accounts to Exclude')
|
|
224
|
+ ->options(function () {
|
|
225
|
+ // Get all budgetable accounts
|
|
226
|
+ return $this->getBudgetableAccounts()->pluck('name', 'id')->toArray();
|
|
227
|
+ })
|
|
228
|
+ ->descriptions(function (Forms\Components\CheckboxList $component) {
|
|
229
|
+ $fiscalYear = $this->data['actuals_fiscal_year'] ?? null;
|
|
230
|
+
|
|
231
|
+ if (blank($fiscalYear)) {
|
|
232
|
+ return [];
|
|
233
|
+ }
|
|
234
|
+
|
|
235
|
+ $accountIds = array_keys($component->getOptions());
|
|
236
|
+ $descriptions = [];
|
|
237
|
+
|
|
238
|
+ if (empty($accountIds)) {
|
|
239
|
+ return [];
|
|
240
|
+ }
|
|
241
|
+
|
|
242
|
+ // Get account balances
|
|
243
|
+ $accountBalances = $this->getAccountBalances()->keyBy('id');
|
|
244
|
+
|
|
245
|
+ // Get accounts with actuals
|
|
246
|
+ $accountsWithActuals = $this->getAccountsWithActuals()->pluck('id')->toArray();
|
|
247
|
+
|
|
248
|
+ // Process all accounts
|
|
249
|
+ foreach ($accountIds as $accountId) {
|
|
250
|
+ $balance = $accountBalances[$accountId] ?? null;
|
|
251
|
+ $hasActuals = in_array($accountId, $accountsWithActuals);
|
|
252
|
+
|
|
253
|
+ if ($balance && $hasActuals) {
|
|
254
|
+ // Calculate net movement
|
|
255
|
+ $netMovement = Accounting::calculateNetMovementByCategory(
|
|
256
|
+ $balance->category,
|
|
257
|
+ $balance->total_debit ?? 0,
|
|
258
|
+ $balance->total_credit ?? 0
|
|
259
|
+ );
|
|
260
|
+
|
|
261
|
+ // Format the amount for display
|
|
262
|
+ $formattedAmount = CurrencyConverter::formatCentsToMoney($netMovement);
|
|
263
|
+ $descriptions[$accountId] = "{$formattedAmount} in {$fiscalYear}";
|
|
264
|
+ } else {
|
|
265
|
+ $descriptions[$accountId] = "No transactions in {$fiscalYear}";
|
|
266
|
+ }
|
|
267
|
+ }
|
|
268
|
+
|
|
269
|
+ return $descriptions;
|
|
270
|
+ })
|
|
271
|
+ ->columns(2) // Display in two columns
|
|
272
|
+ ->searchable() // Allow searching for accounts
|
|
273
|
+ ->bulkToggleable() // Enable "Select All" / "Deselect All"
|
|
274
|
+ ->selectAllAction(fn (Action $action) => $action->label('Exclude all accounts'))
|
|
275
|
+ ->deselectAllAction(fn (Action $action) => $action->label('Include all accounts'))
|
|
276
|
+ ->afterStateUpdated(function (Forms\Set $set, $state) {
|
|
277
|
+ // Get all accounts without actuals
|
|
278
|
+ $accountsWithoutActuals = $this->getAccountsWithoutActuals()->pluck('id')->toArray();
|
|
279
|
+
|
|
280
|
+ // Check if all accounts without actuals are in the selected accounts
|
|
281
|
+ $allAccountsWithoutActualsSelected = empty(array_diff($accountsWithoutActuals, $state));
|
|
282
|
+
|
|
283
|
+ // Update the exclude_accounts_without_actuals checkbox state
|
|
284
|
+ $set('exclude_accounts_without_actuals', $allAccountsWithoutActualsSelected);
|
|
285
|
+ }),
|
|
286
|
+ ])
|
|
287
|
+ ->visible(function () {
|
|
288
|
+ // Only show when using actuals with valid fiscal year AND accounts without transactions exist
|
|
289
|
+ $prefillMethod = $this->data['prefill_method'] ?? null;
|
|
290
|
+
|
|
291
|
+ if ($prefillMethod !== 'actuals' || blank($this->data['actuals_fiscal_year'] ?? null)) {
|
|
292
|
+ return false;
|
|
293
|
+ }
|
|
294
|
+
|
|
295
|
+ return $this->getAccountsWithoutActuals()->isNotEmpty();
|
|
296
|
+ }),
|
|
297
|
+
|
144
|
298
|
Forms\Components\Textarea::make('notes')
|
145
|
299
|
->label('Notes')
|
146
|
300
|
->columnSpanFull(),
|
147
|
301
|
]),
|
148
|
|
-
|
149
|
|
- Step::make('Modify Budget Structure')
|
150
|
|
- ->icon('heroicon-o-adjustments-horizontal')
|
151
|
|
- ->schema([
|
152
|
|
- Forms\Components\CheckboxList::make('selected_accounts')
|
153
|
|
- ->label('Select Accounts to Exclude')
|
154
|
|
- ->options(function (Forms\Get $get) {
|
155
|
|
- $fiscalYear = $get('actuals_fiscal_year');
|
156
|
|
-
|
157
|
|
- // Get all budgetable accounts
|
158
|
|
- $allAccounts = Account::query()->budgetable()->pluck('name', 'id')->toArray();
|
159
|
|
-
|
160
|
|
- // Get accounts that have actuals for the selected fiscal year
|
161
|
|
- $accountsWithActuals = Account::query()
|
162
|
|
- ->budgetable()
|
163
|
|
- ->whereHas('journalEntries.transaction', function (Builder $query) use ($fiscalYear) {
|
164
|
|
- $query->whereYear('posted_at', $fiscalYear);
|
165
|
|
- })
|
166
|
|
- ->pluck('name', 'id')
|
167
|
|
- ->toArray();
|
168
|
|
-
|
169
|
|
- return $allAccounts + $accountsWithActuals; // Merge both sets
|
170
|
|
- })
|
171
|
|
- ->columns(2) // Display in two columns
|
172
|
|
- ->searchable() // Allow searching for accounts
|
173
|
|
- ->bulkToggleable() // Enable "Select All" / "Deselect All"
|
174
|
|
- ->selectAllAction(
|
175
|
|
- fn (Action $action, Forms\Get $get) => $action
|
176
|
|
- ->label('Remove all items without past actuals (' .
|
177
|
|
- Account::query()->budgetable()->whereDoesntHave('journalEntries.transaction', function (Builder $query) use ($get) {
|
178
|
|
- $query->whereYear('posted_at', $get('actuals_fiscal_year'));
|
179
|
|
- })->count() . ' lines)')
|
180
|
|
- )
|
181
|
|
- ->disableOptionWhen(fn (string $value, Forms\Get $get) => in_array(
|
182
|
|
- $value,
|
183
|
|
- Account::query()->budgetable()->whereHas('journalEntries.transaction', function (Builder $query) use ($get) {
|
184
|
|
- $query->whereYear('posted_at', Carbon::parse($get('actuals_fiscal_year'))->year);
|
185
|
|
- })->pluck('id')->toArray()
|
186
|
|
- ))
|
187
|
|
- ->visible(fn (Forms\Get $get) => $get('prefill_method') === 'actuals'),
|
188
|
|
- ])
|
189
|
|
- ->visible(function (Forms\Get $get) {
|
190
|
|
- $prefillMethod = $get('prefill_method');
|
191
|
|
-
|
192
|
|
- if ($prefillMethod !== 'actuals' || blank($get('actuals_fiscal_year'))) {
|
193
|
|
- return false;
|
194
|
|
- }
|
195
|
|
-
|
196
|
|
- return Account::query()->budgetable()->whereDoesntHave('journalEntries.transaction', function (Builder $query) use ($get) {
|
197
|
|
- $query->whereYear('posted_at', $get('actuals_fiscal_year'));
|
198
|
|
- })->exists();
|
199
|
|
- }),
|
200
|
302
|
];
|
201
|
303
|
}
|
202
|
304
|
|