|
@@ -63,12 +63,8 @@ class Transactions extends Page implements HasTable
|
63
|
63
|
|
64
|
64
|
protected static ?string $model = Transaction::class;
|
65
|
65
|
|
66
|
|
- protected static ?string $navigationParentItem = 'Chart of Accounts';
|
67
|
|
-
|
68
|
66
|
protected static ?string $navigationGroup = 'Accounting';
|
69
|
67
|
|
70
|
|
- public ?string $bankAccountIdFiltered = 'all';
|
71
|
|
-
|
72
|
68
|
public string $fiscalYearStartDate = '';
|
73
|
69
|
|
74
|
70
|
public string $fiscalYearEndDate = '';
|
|
@@ -96,6 +92,15 @@ class Transactions extends Page implements HasTable
|
96
|
92
|
return [
|
97
|
93
|
$this->buildTransactionAction('addIncome', 'Add Income', TransactionType::Deposit),
|
98
|
94
|
$this->buildTransactionAction('addExpense', 'Add Expense', TransactionType::Withdrawal),
|
|
95
|
+ Actions\CreateAction::make('addTransfer')
|
|
96
|
+ ->label('Add Transfer')
|
|
97
|
+ ->modalHeading('Add Transfer')
|
|
98
|
+ ->modalWidth(MaxWidth::ThreeExtraLarge)
|
|
99
|
+ ->model(static::getModel())
|
|
100
|
+ ->fillForm(fn (): array => $this->getFormDefaultsForType(TransactionType::Transfer))
|
|
101
|
+ ->form(fn (Form $form) => $this->transferForm($form))
|
|
102
|
+ ->button()
|
|
103
|
+ ->outlined(),
|
99
|
104
|
Actions\ActionGroup::make([
|
100
|
105
|
Actions\CreateAction::make('addJournalTransaction')
|
101
|
106
|
->label('Add Journal Transaction')
|
|
@@ -124,21 +129,57 @@ class Transactions extends Page implements HasTable
|
124
|
129
|
];
|
125
|
130
|
}
|
126
|
131
|
|
127
|
|
- public function form(Form $form): Form
|
|
132
|
+ public function transferForm(Form $form): Form
|
128
|
133
|
{
|
129
|
134
|
return $form
|
130
|
135
|
->schema([
|
131
|
|
- Forms\Components\Select::make('bankAccountIdFiltered')
|
|
136
|
+ Forms\Components\DatePicker::make('posted_at')
|
|
137
|
+ ->label('Date')
|
|
138
|
+ ->required(),
|
|
139
|
+ Forms\Components\TextInput::make('description')
|
|
140
|
+ ->label('Description'),
|
|
141
|
+ Forms\Components\Select::make('bank_account_id')
|
|
142
|
+ ->label('From Account')
|
|
143
|
+ ->options(fn (Get $get, ?Transaction $transaction) => $this->getBankAccountOptions(excludedAccountId: $get('account_id'), currentBankAccountId: $transaction?->bank_account_id))
|
132
|
144
|
->live()
|
133
|
|
- ->allowHtml()
|
134
|
|
- ->hiddenLabel()
|
135
|
|
- ->columnSpan(2)
|
136
|
|
- ->label('Account')
|
137
|
|
- ->selectablePlaceholder(false)
|
138
|
|
- ->extraAttributes(['wire:key' => Str::random()])
|
139
|
|
- ->options(fn () => $this->getBankAccountOptions(true, true)),
|
|
145
|
+ ->searchable()
|
|
146
|
+ ->afterStateUpdated(function (Set $set, $state, $old, Get $get) {
|
|
147
|
+ $amount = CurrencyConverter::convertAndSet(
|
|
148
|
+ BankAccount::find($state)->account->currency_code,
|
|
149
|
+ BankAccount::find($old)->account->currency_code ?? CurrencyAccessor::getDefaultCurrency(),
|
|
150
|
+ $get('amount')
|
|
151
|
+ );
|
|
152
|
+
|
|
153
|
+ if ($amount !== null) {
|
|
154
|
+ $set('amount', $amount);
|
|
155
|
+ }
|
|
156
|
+ })
|
|
157
|
+ ->required(),
|
|
158
|
+ Forms\Components\Select::make('type')
|
|
159
|
+ ->label('Type')
|
|
160
|
+ ->options([
|
|
161
|
+ TransactionType::Transfer->value => TransactionType::Transfer->getLabel(),
|
|
162
|
+ ])
|
|
163
|
+ ->disabled()
|
|
164
|
+ ->dehydrated()
|
|
165
|
+ ->required(),
|
|
166
|
+ Forms\Components\TextInput::make('amount')
|
|
167
|
+ ->label('Amount')
|
|
168
|
+ ->money(static fn (Forms\Get $get) => BankAccount::find($get('bank_account_id'))?->account?->currency_code ?? CurrencyAccessor::getDefaultCurrency())
|
|
169
|
+ ->required(),
|
|
170
|
+ Forms\Components\Select::make('account_id')
|
|
171
|
+ ->label('To Account')
|
|
172
|
+ ->live()
|
|
173
|
+ ->options(fn (Get $get, ?Transaction $transaction) => $this->getBankAccountAccountOptions(excludedBankAccountId: $get('bank_account_id'), currentAccountId: $transaction?->account_id))
|
|
174
|
+ ->searchable()
|
|
175
|
+ ->required(),
|
|
176
|
+ Forms\Components\Textarea::make('notes')
|
|
177
|
+ ->label('Notes')
|
|
178
|
+ ->autosize()
|
|
179
|
+ ->rows(10)
|
|
180
|
+ ->columnSpanFull(),
|
140
|
181
|
])
|
141
|
|
- ->columns(7);
|
|
182
|
+ ->columns();
|
142
|
183
|
}
|
143
|
184
|
|
144
|
185
|
public function transactionForm(Form $form): Form
|
|
@@ -222,16 +263,16 @@ class Transactions extends Page implements HasTable
|
222
|
263
|
'bankAccount.account',
|
223
|
264
|
'journalEntries.account',
|
224
|
265
|
]);
|
225
|
|
-
|
226
|
|
- if ($this->bankAccountIdFiltered !== 'all') {
|
227
|
|
- $query->where('bank_account_id', $this->bankAccountIdFiltered);
|
228
|
|
- }
|
229
|
266
|
})
|
230
|
267
|
->columns([
|
231
|
268
|
Tables\Columns\TextColumn::make('posted_at')
|
232
|
269
|
->label('Date')
|
233
|
270
|
->sortable()
|
234
|
271
|
->defaultDateFormat(),
|
|
272
|
+ Tables\Columns\TextColumn::make('type')
|
|
273
|
+ ->label('Type')
|
|
274
|
+ ->sortable()
|
|
275
|
+ ->toggleable(isToggledHiddenByDefault: true),
|
235
|
276
|
Tables\Columns\TextColumn::make('description')
|
236
|
277
|
->label('Description')
|
237
|
278
|
->limit(30)
|
|
@@ -241,6 +282,7 @@ class Transactions extends Page implements HasTable
|
241
|
282
|
->toggleable(),
|
242
|
283
|
Tables\Columns\TextColumn::make('account.name')
|
243
|
284
|
->label('Category')
|
|
285
|
+ ->prefix(static fn (Transaction $transaction) => $transaction->type->isTransfer() ? 'Transfer to ' : null)
|
244
|
286
|
->toggleable()
|
245
|
287
|
->state(static fn (Transaction $transaction) => $transaction->account->name ?? 'Journal Entry'),
|
246
|
288
|
Tables\Columns\TextColumn::make('amount')
|
|
@@ -253,68 +295,51 @@ class Transactions extends Page implements HasTable
|
253
|
295
|
default => null,
|
254
|
296
|
}
|
255
|
297
|
)
|
|
298
|
+ ->sortable()
|
256
|
299
|
->currency(static fn (Transaction $transaction) => $transaction->bankAccount->account->currency_code ?? CurrencyAccessor::getDefaultCurrency(), true),
|
257
|
300
|
])
|
258
|
301
|
->recordClasses(static fn (Transaction $transaction) => $transaction->reviewed ? 'bg-primary-300/10' : null)
|
259
|
302
|
->defaultSort('posted_at', 'desc')
|
260
|
303
|
->filters([
|
261
|
|
- Tables\Filters\Filter::make('filters')
|
262
|
|
- ->columnSpanFull()
|
263
|
|
- ->form([
|
264
|
|
- Grid::make()
|
265
|
|
- ->schema([
|
266
|
|
- Select::make('account_id')
|
267
|
|
- ->label('Category')
|
268
|
|
- ->options(fn () => $this->getChartAccountOptions(nominalAccountsOnly: true))
|
269
|
|
- ->multiple()
|
270
|
|
- ->searchable(),
|
271
|
|
- Select::make('reviewed')
|
272
|
|
- ->label('Status')
|
273
|
|
- ->native(false)
|
274
|
|
- ->options([
|
275
|
|
- '1' => 'Reviewed',
|
276
|
|
- '0' => 'Not Reviewed',
|
277
|
|
- ]),
|
278
|
|
- Select::make('type')
|
279
|
|
- ->label('Type')
|
280
|
|
- ->options(TransactionType::class)
|
281
|
|
- ->multiple(),
|
282
|
|
- ])
|
283
|
|
- ->extraAttributes([
|
284
|
|
- 'class' => 'border-b border-gray-200 dark:border-white/10 pb-8',
|
285
|
|
- ]),
|
286
|
|
- ])->query(function (Builder $query, array $data): Builder {
|
287
|
|
- if (filled($data['reviewed'])) {
|
288
|
|
- $reviewedStatus = $data['reviewed'] === '1';
|
289
|
|
- $query->where('reviewed', $reviewedStatus);
|
290
|
|
- }
|
291
|
|
-
|
292
|
|
- $query
|
293
|
|
- ->when($data['account_id'], fn (Builder $query, $accountIds) => $query->whereIn('account_id', $accountIds))
|
294
|
|
- ->when($data['type'], fn (Builder $query, $types) => $query->whereIn('type', $types));
|
295
|
|
-
|
296
|
|
- return $query;
|
297
|
|
- })
|
298
|
|
- ->indicateUsing(function (array $data): array {
|
299
|
|
- $indicators = [];
|
300
|
|
-
|
301
|
|
- $this->addIndicatorForSingleSelection($data, 'reviewed', $data['reviewed'] === '1' ? 'Reviewed' : 'Not Reviewed', $indicators);
|
302
|
|
- $this->addMultipleSelectionIndicator($data, 'account_id', fn ($accountId) => Account::find($accountId)->name, 'account_id', $indicators);
|
303
|
|
- $this->addMultipleSelectionIndicator($data, 'type', fn ($type) => TransactionType::parse($type)->getLabel(), 'type', $indicators);
|
304
|
|
-
|
305
|
|
- return $indicators;
|
306
|
|
- }),
|
|
304
|
+ Tables\Filters\SelectFilter::make('bank_account_id')
|
|
305
|
+ ->label('Account')
|
|
306
|
+ ->searchable()
|
|
307
|
+ ->options(fn () => $this->getBankAccountOptions(false)),
|
|
308
|
+ Tables\Filters\SelectFilter::make('account_id')
|
|
309
|
+ ->label('Category')
|
|
310
|
+ ->multiple()
|
|
311
|
+ ->options(fn () => $this->getChartAccountOptions(nominalAccountsOnly: false)),
|
|
312
|
+ Tables\Filters\TernaryFilter::make('reviewed')
|
|
313
|
+ ->label('Status')
|
|
314
|
+ ->native(false)
|
|
315
|
+ ->trueLabel('Reviewed')
|
|
316
|
+ ->falseLabel('Not Reviewed'),
|
|
317
|
+ Tables\Filters\SelectFilter::make('type')
|
|
318
|
+ ->label('Type')
|
|
319
|
+ ->native(false)
|
|
320
|
+ ->options(TransactionType::class),
|
307
|
321
|
$this->buildDateRangeFilter('posted_at', 'Posted', true),
|
308
|
322
|
$this->buildDateRangeFilter('updated_at', 'Last Modified'),
|
309
|
323
|
], layout: Tables\Enums\FiltersLayout::Modal)
|
|
324
|
+ ->filtersFormSchema(fn (array $filters): array => [
|
|
325
|
+ Grid::make()
|
|
326
|
+ ->schema([
|
|
327
|
+ $filters['bank_account_id'],
|
|
328
|
+ $filters['account_id'],
|
|
329
|
+ $filters['reviewed'],
|
|
330
|
+ $filters['type'],
|
|
331
|
+ ])
|
|
332
|
+ ->columnSpanFull()
|
|
333
|
+ ->extraAttributes(['class' => 'border-b border-gray-200 dark:border-white/10 pb-8']),
|
|
334
|
+ $filters['posted_at'],
|
|
335
|
+ $filters['updated_at'],
|
|
336
|
+ ])
|
310
|
337
|
->deferFilters()
|
311
|
338
|
->deferLoading()
|
312
|
|
- ->filtersFormColumns(2)
|
|
339
|
+ ->filtersFormWidth(MaxWidth::ThreeExtraLarge)
|
313
|
340
|
->filtersTriggerAction(
|
314
|
341
|
fn (Tables\Actions\Action $action) => $action
|
315
|
|
- ->stickyModalHeader()
|
316
|
|
- ->stickyModalFooter()
|
317
|
|
- ->modalWidth(MaxWidth::ThreeExtraLarge)
|
|
342
|
+ ->slideOver()
|
318
|
343
|
->modalFooterActionsAlignment(Alignment::End)
|
319
|
344
|
->modalCancelAction(false)
|
320
|
345
|
->extraModalFooterActions(function (Table $table) use ($action) {
|
|
@@ -360,7 +385,13 @@ class Transactions extends Page implements HasTable
|
360
|
385
|
->modalHeading('Edit Transaction')
|
361
|
386
|
->modalWidth(MaxWidth::ThreeExtraLarge)
|
362
|
387
|
->form(fn (Form $form) => $this->transactionForm($form))
|
363
|
|
- ->hidden(static fn (Transaction $transaction) => $transaction->type->isJournal()),
|
|
388
|
+ ->visible(static fn (Transaction $transaction) => $transaction->type->isStandard()),
|
|
389
|
+ Tables\Actions\EditAction::make('updateTransfer')
|
|
390
|
+ ->label('Edit Transfer')
|
|
391
|
+ ->modalHeading('Edit Transfer')
|
|
392
|
+ ->modalWidth(MaxWidth::ThreeExtraLarge)
|
|
393
|
+ ->form(fn (Form $form) => $this->transferForm($form))
|
|
394
|
+ ->visible(static fn (Transaction $transaction) => $transaction->type->isTransfer()),
|
364
|
395
|
Tables\Actions\EditAction::make('updateJournalTransaction')
|
365
|
396
|
->label('Edit Journal Transaction')
|
366
|
397
|
->modalHeading('Journal Entry')
|
|
@@ -392,9 +423,7 @@ class Transactions extends Page implements HasTable
|
392
|
423
|
])->save();
|
393
|
424
|
});
|
394
|
425
|
}),
|
395
|
|
- ])
|
396
|
|
- ->dropdownPlacement('bottom-start')
|
397
|
|
- ->dropdownWidth('max-w-fit'),
|
|
426
|
+ ]),
|
398
|
427
|
])
|
399
|
428
|
->bulkActions([
|
400
|
429
|
Tables\Actions\BulkActionGroup::make([
|
|
@@ -434,7 +463,7 @@ class Transactions extends Page implements HasTable
|
434
|
463
|
];
|
435
|
464
|
|
436
|
465
|
return match ($type) {
|
437
|
|
- TransactionType::Deposit, TransactionType::Withdrawal => array_merge($commonDefaults, $this->transactionDefaults($type)),
|
|
466
|
+ TransactionType::Deposit, TransactionType::Withdrawal, TransactionType::Transfer => array_merge($commonDefaults, $this->transactionDefaults($type)),
|
438
|
467
|
TransactionType::Journal => array_merge($commonDefaults, $this->journalEntryDefaults()),
|
439
|
468
|
};
|
440
|
469
|
}
|
|
@@ -464,7 +493,7 @@ class Transactions extends Page implements HasTable
|
464
|
493
|
'type' => $type,
|
465
|
494
|
'bank_account_id' => BankAccount::where('enabled', true)->first()?->id,
|
466
|
495
|
'amount' => '0.00',
|
467
|
|
- 'account_id' => static::getUncategorizedAccountByType($type)?->id,
|
|
496
|
+ 'account_id' => ! $type->isTransfer() ? static::getUncategorizedAccountByType($type)->id : null,
|
468
|
497
|
];
|
469
|
498
|
}
|
470
|
499
|
|
|
@@ -739,6 +768,46 @@ class Transactions extends Page implements HasTable
|
739
|
768
|
return 'uncategorized';
|
740
|
769
|
}
|
741
|
770
|
|
|
771
|
+ protected function getBankAccountOptions(?int $excludedAccountId = null, ?int $currentBankAccountId = null): array
|
|
772
|
+ {
|
|
773
|
+ return BankAccount::join('accounts', 'accounts.bank_account_id', '=', 'bank_accounts.id')
|
|
774
|
+ ->where('accounts.archived', false)
|
|
775
|
+ ->select(['bank_accounts.id', 'accounts.name', 'accounts.subtype_id'])
|
|
776
|
+ ->with(['account.subtype' => static function ($query) {
|
|
777
|
+ $query->select(['id', 'name']);
|
|
778
|
+ }])
|
|
779
|
+ ->when($excludedAccountId, function (Builder $query) use ($excludedAccountId) {
|
|
780
|
+ $query->whereNot('accounts.id', $excludedAccountId);
|
|
781
|
+ })
|
|
782
|
+ ->when($currentBankAccountId, function (Builder $query) use ($currentBankAccountId) {
|
|
783
|
+ // Ensure the current bank account is included even if archived
|
|
784
|
+ $query->orWhere('bank_accounts.id', $currentBankAccountId);
|
|
785
|
+ })
|
|
786
|
+ ->get()
|
|
787
|
+ ->groupBy('account.subtype.name')
|
|
788
|
+ ->map(fn (Collection $bankAccounts, string $subtype) => $bankAccounts->pluck('account.name', 'id'))
|
|
789
|
+ ->toArray();
|
|
790
|
+ }
|
|
791
|
+
|
|
792
|
+ protected function getBankAccountAccountOptions(?int $excludedBankAccountId = null, ?int $currentAccountId = null): array
|
|
793
|
+ {
|
|
794
|
+ return Account::query()
|
|
795
|
+ ->whereHas('bankAccount', function (Builder $query) use ($excludedBankAccountId) {
|
|
796
|
+ // Exclude the specific bank account if provided
|
|
797
|
+ if ($excludedBankAccountId) {
|
|
798
|
+ $query->whereNot('id', $excludedBankAccountId);
|
|
799
|
+ }
|
|
800
|
+ })
|
|
801
|
+ ->where(function (Builder $query) use ($currentAccountId) {
|
|
802
|
+ $query->where('archived', false)
|
|
803
|
+ ->orWhere('id', $currentAccountId);
|
|
804
|
+ })
|
|
805
|
+ ->get()
|
|
806
|
+ ->groupBy(fn (Account $account) => $account->category->getPluralLabel())
|
|
807
|
+ ->map(fn (Collection $accounts, string $category) => $accounts->pluck('name', 'id'))
|
|
808
|
+ ->toArray();
|
|
809
|
+ }
|
|
810
|
+
|
742
|
811
|
protected function getChartAccountOptions(?TransactionType $type = null, ?bool $nominalAccountsOnly = null, ?int $currentAccountId = null): array
|
743
|
812
|
{
|
744
|
813
|
$nominalAccountsOnly ??= false;
|
|
@@ -762,46 +831,6 @@ class Transactions extends Page implements HasTable
|
762
|
831
|
->toArray();
|
763
|
832
|
}
|
764
|
833
|
|
765
|
|
- protected function getBankAccountOptions(?bool $onlyWithTransactions = null, ?bool $isFilter = null, ?int $currentBankAccountId = null): array
|
766
|
|
- {
|
767
|
|
- $isFilter ??= false;
|
768
|
|
- $onlyWithTransactions ??= false;
|
769
|
|
-
|
770
|
|
- $options = $isFilter ? [
|
771
|
|
- '' => ['all' => "All Accounts <span class='float-right'>{$this->getBalanceForAllAccounts()}</span>"],
|
772
|
|
- ] : [];
|
773
|
|
-
|
774
|
|
- $bankAccountOptions = BankAccount::with('account.subtype')
|
775
|
|
- ->whereHas('account', function (Builder $query) use ($isFilter, $currentBankAccountId) {
|
776
|
|
- if ($isFilter === false) {
|
777
|
|
- $query->where('archived', false);
|
778
|
|
- }
|
779
|
|
-
|
780
|
|
- if ($currentBankAccountId) {
|
781
|
|
- $query->orWhereHas('bankAccount', function (Builder $query) use ($currentBankAccountId) {
|
782
|
|
- $query->where('id', $currentBankAccountId);
|
783
|
|
- });
|
784
|
|
- }
|
785
|
|
- })
|
786
|
|
- ->when($onlyWithTransactions, fn (Builder $query) => $query->has('transactions'))
|
787
|
|
- ->get()
|
788
|
|
- ->groupBy('account.subtype.name')
|
789
|
|
- ->mapWithKeys(function (Collection $bankAccounts, string $subtype) use ($isFilter) {
|
790
|
|
- return [$subtype => $bankAccounts->mapWithKeys(static function (BankAccount $bankAccount) use ($isFilter) {
|
791
|
|
- $label = $bankAccount->account->name;
|
792
|
|
- if ($isFilter) {
|
793
|
|
- $balance = $bankAccount->account->ending_balance->convert()->formatWithCode(true);
|
794
|
|
- $label .= "<span class='float-right'>{$balance}</span>";
|
795
|
|
- }
|
796
|
|
-
|
797
|
|
- return [$bankAccount->id => $label];
|
798
|
|
- })];
|
799
|
|
- })
|
800
|
|
- ->toArray();
|
801
|
|
-
|
802
|
|
- return array_merge($options, $bankAccountOptions);
|
803
|
|
- }
|
804
|
|
-
|
805
|
834
|
protected function getBalanceForAllAccounts(): string
|
806
|
835
|
{
|
807
|
836
|
return Accounting::getTotalBalanceForAllBankAccounts($this->fiscalYearStartDate, $this->fiscalYearEndDate)->format();
|