Ver código fonte

Merge pull request #183 from andrewdwallo/development-3.x

Batch Payment Processing
3.x
Andrew Wallo 4 meses atrás
pai
commit
61f8b1d99b
Nenhuma conta vinculada ao e-mail do autor do commit
21 arquivos alterados com 1257 adições e 385 exclusões
  1. 17
    0
      app/Enums/Accounting/BillStatus.php
  2. 18
    0
      app/Enums/Accounting/InvoiceStatus.php
  3. 8
    6
      app/Filament/Company/Resources/Accounting/BudgetResource/RelationManagers/BudgetItemsRelationManager.php
  4. 106
    144
      app/Filament/Company/Resources/Purchases/BillResource.php
  5. 3
    0
      app/Filament/Company/Resources/Purchases/BillResource/Pages/ListBills.php
  6. 379
    0
      app/Filament/Company/Resources/Purchases/BillResource/Pages/PayBills.php
  7. 1
    0
      app/Filament/Company/Resources/Purchases/BillResource/RelationManagers/PaymentsRelationManager.php
  8. 11
    188
      app/Filament/Company/Resources/Sales/InvoiceResource.php
  9. 5
    2
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ListInvoices.php
  10. 518
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/RecordPayments.php
  11. 4
    6
      app/Filament/Company/Resources/Sales/InvoiceResource/RelationManagers/PaymentsRelationManager.php
  12. 39
    0
      app/Filament/Tables/Columns/CustomTextInputColumn.php
  13. 0
    25
      app/Filament/Tables/Columns/DeferredTextInputColumn.php
  14. 12
    6
      app/Models/Accounting/Invoice.php
  15. 20
    0
      app/Models/Accounting/Transaction.php
  16. 1
    1
      app/Models/Setting/DocumentDefault.php
  17. 6
    1
      app/Providers/Filament/CompanyPanelProvider.php
  18. 2
    2
      resources/css/filament/company/theme.css
  19. 32
    0
      resources/views/filament/company/resources/purchases/bill-resource/pages/pay-bills.blade.php
  20. 30
    0
      resources/views/filament/company/resources/sales/invoice-resource/pages/record-payments.blade.php
  21. 45
    4
      resources/views/filament/tables/columns/custom-text-input-column.blade.php

+ 17
- 0
app/Enums/Accounting/BillStatus.php Ver arquivo

@@ -36,4 +36,21 @@ enum BillStatus: string implements HasColor, HasLabel
36 36
             self::Open,
37 37
         ];
38 38
     }
39
+
40
+    public static function unpaidStatuses(): array
41
+    {
42
+        return [
43
+            self::Open,
44
+            self::Partial,
45
+            self::Overdue,
46
+        ];
47
+    }
48
+
49
+    public static function getUnpaidOptions(): array
50
+    {
51
+        return collect(self::cases())
52
+            ->filter(fn (self $case) => in_array($case, self::unpaidStatuses()))
53
+            ->mapWithKeys(fn (self $case) => [$case->value => $case->getLabel()])
54
+            ->toArray();
55
+    }
39 56
 }

+ 18
- 0
app/Enums/Accounting/InvoiceStatus.php Ver arquivo

@@ -46,4 +46,22 @@ enum InvoiceStatus: string implements HasColor, HasLabel
46 46
             self::Unsent,
47 47
         ];
48 48
     }
49
+
50
+    public static function unpaidStatuses(): array
51
+    {
52
+        return [
53
+            self::Unsent,
54
+            self::Sent,
55
+            self::Viewed,
56
+            self::Partial,
57
+            self::Overdue,
58
+        ];
59
+    }
60
+
61
+    public static function getUnpaidOptions(): array
62
+    {
63
+        return collect(self::unpaidStatuses())
64
+            ->mapWithKeys(fn (self $case) => [$case->value => $case->getLabel()])
65
+            ->toArray();
66
+    }
49 67
 }

+ 8
- 6
app/Filament/Company/Resources/Accounting/BudgetResource/RelationManagers/BudgetItemsRelationManager.php Ver arquivo

@@ -2,7 +2,7 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Accounting\BudgetResource\RelationManagers;
4 4
 
5
-use App\Filament\Tables\Columns\DeferredTextInputColumn;
5
+use App\Filament\Tables\Columns\CustomTextInputColumn;
6 6
 use App\Models\Accounting\Budget;
7 7
 use App\Models\Accounting\BudgetAllocation;
8 8
 use App\Models\Accounting\BudgetItem;
@@ -144,7 +144,7 @@ class BudgetItemsRelationManager extends RelationManager
144 144
                     ->titlePrefixedWithLabel(false)
145 145
                     ->collapsible(),
146 146
             ])
147
-            ->recordClasses(['budget-items-relation-manager'])
147
+            ->recordClasses(['is-spreadsheet'])
148 148
             ->defaultGroup('account.category')
149 149
             ->headerActions([
150 150
                 Action::make('saveBatchChanges')
@@ -157,7 +157,7 @@ class BudgetItemsRelationManager extends RelationManager
157 157
                     ->label('Account')
158 158
                     ->limit(30)
159 159
                     ->searchable(),
160
-                DeferredTextInputColumn::make(self::TOTAL_COLUMN)
160
+                CustomTextInputColumn::make(self::TOTAL_COLUMN)
161 161
                     ->label('Total')
162 162
                     ->alignRight()
163 163
                     ->mask(RawJs::make('$money($input)'))
@@ -173,7 +173,8 @@ class BudgetItemsRelationManager extends RelationManager
173 173
 
174 174
                         return CurrencyConverter::convertCentsToFormatSimple($total);
175 175
                     })
176
-                    ->batchMode()
176
+                    ->deferred()
177
+                    ->navigable()
177 178
                     ->summarize(
178 179
                         Summarizer::make()
179 180
                             ->using(function (\Illuminate\Database\Query\Builder $query) {
@@ -258,10 +259,11 @@ class BudgetItemsRelationManager extends RelationManager
258 259
                 ...$allocationPeriods->map(function (BudgetAllocation $period) {
259 260
                     $alias = $period->start_date->format('Y_m_d');
260 261
 
261
-                    return DeferredTextInputColumn::make($alias)
262
+                    return CustomTextInputColumn::make($alias)
262 263
                         ->label($period->period)
263 264
                         ->alignRight()
264
-                        ->batchMode()
265
+                        ->deferred()
266
+                        ->navigable()
265 267
                         ->mask(RawJs::make('$money($input)'))
266 268
                         ->getStateUsing(function ($record) use ($alias) {
267 269
                             $key = "{$record->getKey()}.{$alias}";

+ 106
- 144
app/Filament/Company/Resources/Purchases/BillResource.php Ver arquivo

@@ -34,15 +34,12 @@ use Awcodes\TableRepeater\Header;
34 34
 use Closure;
35 35
 use Filament\Forms;
36 36
 use Filament\Forms\Form;
37
-use Filament\Notifications\Notification;
38 37
 use Filament\Resources\Resource;
39
-use Filament\Support\Enums\Alignment;
40 38
 use Filament\Support\Enums\MaxWidth;
41 39
 use Filament\Tables;
42 40
 use Filament\Tables\Table;
43 41
 use Guava\FilamentClusters\Forms\Cluster;
44 42
 use Illuminate\Database\Eloquent\Builder;
45
-use Illuminate\Database\Eloquent\Collection;
46 43
 use Illuminate\Support\Carbon;
47 44
 use Illuminate\Support\Facades\Auth;
48 45
 
@@ -440,11 +437,9 @@ class BillResource extends Resource
440 437
                         Bill::getReplicateAction(Tables\Actions\ReplicateAction::class),
441 438
                         Tables\Actions\Action::make('recordPayment')
442 439
                             ->label('Record payment')
443
-                            ->stickyModalHeader()
444
-                            ->stickyModalFooter()
445
-                            ->modalFooterActionsAlignment(Alignment::End)
440
+                            ->slideOver()
446 441
                             ->modalWidth(MaxWidth::TwoExtraLarge)
447
-                            ->icon('heroicon-o-credit-card')
442
+                            ->icon('heroicon-m-credit-card')
448 443
                             ->visible(function (Bill $record) {
449 444
                                 return $record->canRecordPayment();
450 445
                             })
@@ -459,54 +454,122 @@ class BillResource extends Resource
459 454
                             ->form([
460 455
                                 Forms\Components\DatePicker::make('posted_at')
461 456
                                     ->label('Date'),
462
-                                Forms\Components\TextInput::make('amount')
463
-                                    ->label('Amount')
464
-                                    ->required()
465
-                                    ->money(fn (Bill $record) => $record->currency_code)
466
-                                    ->live(onBlur: true)
467
-                                    ->helperText(function (Bill $record, $state) {
457
+                                Forms\Components\Grid::make()
458
+                                    ->schema([
459
+                                        Forms\Components\Select::make('bank_account_id')
460
+                                            ->label('Account')
461
+                                            ->required()
462
+                                            ->live()
463
+                                            ->options(function () {
464
+                                                return BankAccount::query()
465
+                                                    ->join('accounts', 'bank_accounts.account_id', '=', 'accounts.id')
466
+                                                    ->select(['bank_accounts.id', 'accounts.name', 'accounts.currency_code'])
467
+                                                    ->get()
468
+                                                    ->mapWithKeys(function ($account) {
469
+                                                        $label = $account->name;
470
+                                                        if ($account->currency_code) {
471
+                                                            $label .= " ({$account->currency_code})";
472
+                                                        }
473
+
474
+                                                        return [$account->id => $label];
475
+                                                    })
476
+                                                    ->toArray();
477
+                                            })
478
+                                            ->searchable(),
479
+                                        Forms\Components\TextInput::make('amount')
480
+                                            ->label('Amount')
481
+                                            ->required()
482
+                                            ->money(fn (Bill $record) => $record->currency_code)
483
+                                            ->live(onBlur: true)
484
+                                            ->helperText(function (Bill $record, $state) {
485
+                                                $billCurrency = $record->currency_code;
486
+
487
+                                                if (! CurrencyConverter::isValidAmount($state, 'USD')) {
488
+                                                    return null;
489
+                                                }
490
+
491
+                                                $amountDue = $record->amount_due;
492
+
493
+                                                $amount = CurrencyConverter::convertToCents($state, 'USD');
494
+
495
+                                                if ($amount <= 0) {
496
+                                                    return 'Please enter a valid positive amount';
497
+                                                }
498
+
499
+                                                $newAmountDue = $amountDue - $amount;
500
+
501
+                                                return match (true) {
502
+                                                    $newAmountDue > 0 => 'Amount due after payment will be ' . CurrencyConverter::formatCentsToMoney($newAmountDue, $billCurrency),
503
+                                                    $newAmountDue === 0 => 'Bill will be fully paid',
504
+                                                    default => 'Amount exceeds bill total by ' . CurrencyConverter::formatCentsToMoney(abs($newAmountDue), $billCurrency),
505
+                                                };
506
+                                            })
507
+                                            ->rules([
508
+                                                static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
509
+                                                    if (! CurrencyConverter::isValidAmount($value, 'USD')) {
510
+                                                        $fail('Please enter a valid amount');
511
+                                                    }
512
+                                                },
513
+                                            ]),
514
+                                    ])->columns(2),
515
+                                Forms\Components\Placeholder::make('currency_conversion')
516
+                                    ->label('Currency Conversion')
517
+                                    ->content(function (Forms\Get $get, Bill $record) {
518
+                                        $amount = $get('amount');
519
+                                        $bankAccountId = $get('bank_account_id');
520
+
468 521
                                         $billCurrency = $record->currency_code;
469
-                                        if (! CurrencyConverter::isValidAmount($state, 'USD')) {
522
+
523
+                                        if (empty($amount) || empty($bankAccountId) || ! CurrencyConverter::isValidAmount($amount, 'USD')) {
524
+                                            return null;
525
+                                        }
526
+
527
+                                        $bankAccount = BankAccount::with('account')->find($bankAccountId);
528
+                                        if (! $bankAccount) {
470 529
                                             return null;
471 530
                                         }
472 531
 
473
-                                        $amountDue = $record->amount_due;
474
-                                        $amount = CurrencyConverter::convertToCents($state, 'USD');
532
+                                        $bankCurrency = $bankAccount->account->currency_code ?? CurrencyAccessor::getDefaultCurrency();
475 533
 
476
-                                        if ($amount <= 0) {
477
-                                            return 'Please enter a valid positive amount';
534
+                                        // If currencies are the same, no conversion needed
535
+                                        if ($billCurrency === $bankCurrency) {
536
+                                            return null;
478 537
                                         }
479 538
 
480
-                                        $newAmountDue = $amountDue - $amount;
539
+                                        // Convert amount from bill currency to bank currency
540
+                                        $amountInBillCurrencyCents = CurrencyConverter::convertToCents($amount, 'USD');
541
+                                        $amountInBankCurrencyCents = CurrencyConverter::convertBalance(
542
+                                            $amountInBillCurrencyCents,
543
+                                            $billCurrency,
544
+                                            $bankCurrency
545
+                                        );
546
+
547
+                                        $formattedBankAmount = CurrencyConverter::formatCentsToMoney($amountInBankCurrencyCents, $bankCurrency);
481 548
 
482
-                                        return match (true) {
483
-                                            $newAmountDue > 0 => 'Amount due after payment will be ' . CurrencyConverter::formatCentsToMoney($newAmountDue, $billCurrency),
484
-                                            $newAmountDue === 0 => 'Bill will be fully paid',
485
-                                            default => 'Amount exceeds bill total by ' . CurrencyConverter::formatCentsToMoney(abs($newAmountDue), $billCurrency),
486
-                                        };
549
+                                        return "Payment will be recorded as {$formattedBankAmount} in the bank account's currency ({$bankCurrency}).";
487 550
                                     })
488
-                                    ->rules([
489
-                                        static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
490
-                                            if (! CurrencyConverter::isValidAmount($value, 'USD')) {
491
-                                                $fail('Please enter a valid amount');
492
-                                            }
493
-                                        },
494
-                                    ]),
551
+                                    ->hidden(function (Forms\Get $get, Bill $record) {
552
+                                        $bankAccountId = $get('bank_account_id');
553
+                                        if (empty($bankAccountId)) {
554
+                                            return true;
555
+                                        }
556
+
557
+                                        $billCurrency = $record->currency_code;
558
+
559
+                                        $bankAccount = BankAccount::with('account')->find($bankAccountId);
560
+                                        if (! $bankAccount) {
561
+                                            return true;
562
+                                        }
563
+
564
+                                        $bankCurrency = $bankAccount->account->currency_code ?? CurrencyAccessor::getDefaultCurrency();
565
+
566
+                                        // Hide if currencies are the same
567
+                                        return $billCurrency === $bankCurrency;
568
+                                    }),
495 569
                                 Forms\Components\Select::make('payment_method')
496 570
                                     ->label('Payment method')
497 571
                                     ->required()
498 572
                                     ->options(PaymentMethod::class),
499
-                                Forms\Components\Select::make('bank_account_id')
500
-                                    ->label('Account')
501
-                                    ->required()
502
-                                    ->options(function () {
503
-                                        return BankAccount::query()
504
-                                            ->join('accounts', 'bank_accounts.account_id', '=', 'accounts.id')
505
-                                            ->select(['bank_accounts.id', 'accounts.name'])
506
-                                            ->pluck('accounts.name', 'bank_accounts.id')
507
-                                            ->toArray();
508
-                                    })
509
-                                    ->searchable(),
510 573
                                 Forms\Components\Textarea::make('notes')
511 574
                                     ->label('Notes'),
512 575
                             ])
@@ -558,108 +621,6 @@ class BillResource extends Resource
558 621
                             'created_at',
559 622
                             'updated_at',
560 623
                         ]),
561
-                    Tables\Actions\BulkAction::make('recordPayments')
562
-                        ->label('Record payments')
563
-                        ->icon('heroicon-o-credit-card')
564
-                        ->stickyModalHeader()
565
-                        ->stickyModalFooter()
566
-                        ->modalFooterActionsAlignment(Alignment::End)
567
-                        ->modalWidth(MaxWidth::TwoExtraLarge)
568
-                        ->databaseTransaction()
569
-                        ->successNotificationTitle('Payments recorded')
570
-                        ->failureNotificationTitle('Failed to record payments')
571
-                        ->deselectRecordsAfterCompletion()
572
-                        ->beforeFormFilled(function (Collection $records, Tables\Actions\BulkAction $action) {
573
-                            $isInvalid = $records->contains(fn (Bill $bill) => ! $bill->canRecordPayment());
574
-
575
-                            if ($isInvalid) {
576
-                                Notification::make()
577
-                                    ->title('Payment recording failed')
578
-                                    ->body('Bills that are either paid, voided, or are in a foreign currency cannot be processed through bulk payments. Please adjust your selection and try again.')
579
-                                    ->persistent()
580
-                                    ->danger()
581
-                                    ->send();
582
-
583
-                                $action->cancel(true);
584
-                            }
585
-                        })
586
-                        ->mountUsing(function (Collection $records, Form $form) {
587
-                            $totalAmountDue = $records->sum('amount_due');
588
-
589
-                            $form->fill([
590
-                                'posted_at' => now(),
591
-                                'amount' => $totalAmountDue,
592
-                            ]);
593
-                        })
594
-                        ->form([
595
-                            Forms\Components\DatePicker::make('posted_at')
596
-                                ->label('Date'),
597
-                            Forms\Components\TextInput::make('amount')
598
-                                ->label('Amount')
599
-                                ->required()
600
-                                ->money()
601
-                                ->rules([
602
-                                    static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
603
-                                        if (! CurrencyConverter::isValidAmount($value)) {
604
-                                            $fail('Please enter a valid amount');
605
-                                        }
606
-                                    },
607
-                                ]),
608
-                            Forms\Components\Select::make('payment_method')
609
-                                ->label('Payment method')
610
-                                ->required()
611
-                                ->options(PaymentMethod::class),
612
-                            Forms\Components\Select::make('bank_account_id')
613
-                                ->label('Account')
614
-                                ->required()
615
-                                ->options(function () {
616
-                                    return BankAccount::query()
617
-                                        ->join('accounts', 'bank_accounts.account_id', '=', 'accounts.id')
618
-                                        ->select(['bank_accounts.id', 'accounts.name'])
619
-                                        ->pluck('accounts.name', 'bank_accounts.id')
620
-                                        ->toArray();
621
-                                })
622
-                                ->searchable(),
623
-                            Forms\Components\Textarea::make('notes')
624
-                                ->label('Notes'),
625
-                        ])
626
-                        ->before(function (Collection $records, Tables\Actions\BulkAction $action, array $data) {
627
-                            $totalPaymentAmount = $data['amount'] ?? 0;
628
-                            $totalAmountDue = $records->sum('amount_due');
629
-
630
-                            if ($totalPaymentAmount > $totalAmountDue) {
631
-                                $formattedTotalAmountDue = CurrencyConverter::formatCentsToMoney($totalAmountDue);
632
-
633
-                                Notification::make()
634
-                                    ->title('Excess payment amount')
635
-                                    ->body("The payment amount exceeds the total amount due of {$formattedTotalAmountDue}. Please adjust the payment amount and try again.")
636
-                                    ->persistent()
637
-                                    ->warning()
638
-                                    ->send();
639
-
640
-                                $action->halt(true);
641
-                            }
642
-                        })
643
-                        ->action(function (Collection $records, Tables\Actions\BulkAction $action, array $data) {
644
-                            $totalPaymentAmount = $data['amount'] ?? 0;
645
-                            $remainingAmount = $totalPaymentAmount;
646
-
647
-                            $records->each(function (Bill $record) use (&$remainingAmount, $data) {
648
-                                $amountDue = $record->amount_due;
649
-
650
-                                if ($amountDue <= 0 || $remainingAmount <= 0) {
651
-                                    return;
652
-                                }
653
-
654
-                                $paymentAmount = min($amountDue, $remainingAmount);
655
-                                $data['amount'] = $paymentAmount;
656
-
657
-                                $record->recordPayment($data);
658
-                                $remainingAmount -= $paymentAmount;
659
-                            });
660
-
661
-                            $action->success();
662
-                        }),
663 624
                 ]),
664 625
             ]);
665 626
     }
@@ -668,6 +629,7 @@ class BillResource extends Resource
668 629
     {
669 630
         return [
670 631
             'index' => Pages\ListBills::route('/'),
632
+            'pay-bills' => Pages\PayBills::route('/pay-bills'),
671 633
             'create' => Pages\CreateBill::route('/create'),
672 634
             'view' => Pages\ViewBill::route('/{record}'),
673 635
             'edit' => Pages\EditBill::route('/{record}/edit'),

+ 3
- 0
app/Filament/Company/Resources/Purchases/BillResource/Pages/ListBills.php Ver arquivo

@@ -20,6 +20,9 @@ class ListBills extends ListRecords
20 20
     protected function getHeaderActions(): array
21 21
     {
22 22
         return [
23
+            Actions\Action::make('payBills')
24
+                ->outlined()
25
+                ->url(PayBills::getUrl()),
23 26
             Actions\CreateAction::make(),
24 27
         ];
25 28
     }

+ 379
- 0
app/Filament/Company/Resources/Purchases/BillResource/Pages/PayBills.php Ver arquivo

@@ -0,0 +1,379 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\BillResource\Pages;
4
+
5
+use App\Enums\Accounting\BillStatus;
6
+use App\Enums\Accounting\PaymentMethod;
7
+use App\Filament\Company\Resources\Purchases\BillResource;
8
+use App\Filament\Tables\Columns\CustomTextInputColumn;
9
+use App\Models\Accounting\Bill;
10
+use App\Models\Accounting\Transaction;
11
+use App\Models\Banking\BankAccount;
12
+use App\Models\Common\Vendor;
13
+use App\Models\Setting\Currency;
14
+use App\Utilities\Currency\CurrencyAccessor;
15
+use App\Utilities\Currency\CurrencyConverter;
16
+use Filament\Actions;
17
+use Filament\Forms;
18
+use Filament\Forms\Form;
19
+use Filament\Notifications\Notification;
20
+use Filament\Resources\Pages\ListRecords;
21
+use Filament\Support\RawJs;
22
+use Filament\Tables;
23
+use Filament\Tables\Columns\Summarizers\Summarizer;
24
+use Filament\Tables\Columns\TextColumn;
25
+use Filament\Tables\Table;
26
+use Illuminate\Contracts\Support\Htmlable;
27
+use Illuminate\Database\Eloquent\Collection;
28
+use Illuminate\Database\Query\Builder;
29
+use Illuminate\Support\Str;
30
+use Livewire\Attributes\Computed;
31
+
32
+/**
33
+ * @property Form $form
34
+ */
35
+class PayBills extends ListRecords
36
+{
37
+    protected static string $resource = BillResource::class;
38
+
39
+    protected static string $view = 'filament.company.resources.purchases.bill-resource.pages.pay-bills';
40
+
41
+    public array $paymentAmounts = [];
42
+
43
+    public ?array $data = [];
44
+
45
+    public function getBreadcrumb(): ?string
46
+    {
47
+        return 'Pay';
48
+    }
49
+
50
+    public function getTitle(): string | Htmlable
51
+    {
52
+        return 'Pay Bills';
53
+    }
54
+
55
+    public function mount(): void
56
+    {
57
+        parent::mount();
58
+
59
+        $this->form->fill();
60
+
61
+        $this->reset('tableFilters');
62
+    }
63
+
64
+    protected function getHeaderActions(): array
65
+    {
66
+        return [
67
+            Actions\Action::make('processPayments')
68
+                ->color('primary')
69
+                ->requiresConfirmation()
70
+                ->modalHeading('Confirm payments')
71
+                ->modalDescription(function () {
72
+                    $billCount = collect($this->paymentAmounts)->filter(fn ($amount) => $amount > 0)->count();
73
+                    $totalAmount = array_sum($this->paymentAmounts);
74
+                    $currencyCode = $this->getTableFilterState('currency_code')['value'];
75
+                    $totalFormatted = CurrencyConverter::formatCentsToMoney($totalAmount, $currencyCode, true);
76
+
77
+                    return "You are about to pay {$billCount} " . Str::plural('bill', $billCount) . " for a total of {$totalFormatted}. This action cannot be undone.";
78
+                })
79
+                ->action(function () {
80
+                    $data = $this->data;
81
+                    $tableRecords = $this->getTableRecords();
82
+                    $paidCount = 0;
83
+                    $totalPaid = 0;
84
+
85
+                    /** @var Bill $bill */
86
+                    foreach ($tableRecords as $bill) {
87
+                        if (! $bill->canRecordPayment()) {
88
+                            continue;
89
+                        }
90
+
91
+                        // Get the payment amount from our component state
92
+                        $paymentAmount = $this->getPaymentAmount($bill);
93
+
94
+                        if ($paymentAmount <= 0) {
95
+                            continue;
96
+                        }
97
+
98
+                        $paymentData = [
99
+                            'posted_at' => $data['posted_at'],
100
+                            'payment_method' => $data['payment_method'],
101
+                            'bank_account_id' => $data['bank_account_id'],
102
+                            'amount' => $paymentAmount,
103
+                        ];
104
+
105
+                        $bill->recordPayment($paymentData);
106
+                        $paidCount++;
107
+                        $totalPaid += $paymentAmount;
108
+                    }
109
+
110
+                    $currencyCode = $this->getTableFilterState('currency_code')['value'];
111
+                    $totalFormatted = CurrencyConverter::formatCentsToMoney($totalPaid, $currencyCode, true);
112
+
113
+                    Notification::make()
114
+                        ->title('Bills paid successfully')
115
+                        ->body("Paid {$paidCount} " . Str::plural('bill', $paidCount) . " for a total of {$totalFormatted}")
116
+                        ->success()
117
+                        ->send();
118
+
119
+                    $this->reset('paymentAmounts');
120
+
121
+                    $this->resetTable();
122
+                }),
123
+        ];
124
+    }
125
+
126
+    /**
127
+     * @return array<int | string, string | Form>
128
+     */
129
+    protected function getForms(): array
130
+    {
131
+        return [
132
+            'form',
133
+        ];
134
+    }
135
+
136
+    public function form(Form $form): Form
137
+    {
138
+        return $form
139
+            ->live()
140
+            ->schema([
141
+                Forms\Components\Grid::make(3)
142
+                    ->schema([
143
+                        Forms\Components\Select::make('bank_account_id')
144
+                            ->label('Account')
145
+                            ->options(static function () {
146
+                                return Transaction::getBankAccountOptionsFlat();
147
+                            })
148
+                            ->default(fn () => BankAccount::where('enabled', true)->first()?->id)
149
+                            ->selectablePlaceholder(false)
150
+                            ->searchable()
151
+                            ->softRequired(),
152
+                        Forms\Components\DatePicker::make('posted_at')
153
+                            ->label('Date')
154
+                            ->default(now())
155
+                            ->softRequired(),
156
+                        Forms\Components\Select::make('payment_method')
157
+                            ->label('Payment method')
158
+                            ->selectablePlaceholder(false)
159
+                            ->options(PaymentMethod::class)
160
+                            ->default(PaymentMethod::BankPayment)
161
+                            ->softRequired(),
162
+                    ]),
163
+            ])->statePath('data');
164
+    }
165
+
166
+    public function table(Table $table): Table
167
+    {
168
+        return $table
169
+            ->query(
170
+                Bill::query()
171
+                    ->with(['vendor'])
172
+                    ->unpaid()
173
+            )
174
+            ->recordClasses(['is-spreadsheet'])
175
+            ->defaultSort('due_date')
176
+            ->paginated(false)
177
+            ->columns([
178
+                TextColumn::make('vendor.name')
179
+                    ->label('Vendor')
180
+                    ->sortable(),
181
+                TextColumn::make('bill_number')
182
+                    ->label('Bill number')
183
+                    ->sortable(),
184
+                TextColumn::make('due_date')
185
+                    ->label('Due date')
186
+                    ->defaultDateFormat()
187
+                    ->sortable(),
188
+                Tables\Columns\TextColumn::make('status')
189
+                    ->badge()
190
+                    ->sortable(),
191
+                TextColumn::make('amount_due')
192
+                    ->label('Amount due')
193
+                    ->currency(static fn (Bill $record) => $record->currency_code)
194
+                    ->alignEnd()
195
+                    ->sortable()
196
+                    ->summarize([
197
+                        Summarizer::make()
198
+                            ->using(function (Builder $query) {
199
+                                $totalAmountDue = $query->sum('amount_due');
200
+                                $bankAccountCurrency = $this->getSelectedBankAccount()->account->currency_code;
201
+                                $activeCurrency = $this->getTableFilterState('currency_code')['value'] ?? $bankAccountCurrency;
202
+
203
+                                if ($activeCurrency !== $bankAccountCurrency) {
204
+                                    $totalAmountDue = CurrencyConverter::convertBalance($totalAmountDue, $activeCurrency, $bankAccountCurrency);
205
+                                }
206
+
207
+                                return CurrencyConverter::formatCentsToMoney($totalAmountDue, $bankAccountCurrency, true);
208
+                            }),
209
+                        Summarizer::make()
210
+                            ->using(function (Builder $query) {
211
+                                $totalAmountDue = $query->sum('amount_due');
212
+                                $currencyCode = $this->getTableFilterState('currency_code')['value'];
213
+
214
+                                return CurrencyConverter::formatCentsToMoney($totalAmountDue, $currencyCode, true);
215
+                            })
216
+                            ->visible(function () {
217
+                                $activeCurrency = $this->getTableFilterState('currency_code')['value'] ?? null;
218
+                                $bankAccountCurrency = $this->getSelectedBankAccount()->account->currency_code;
219
+
220
+                                return $activeCurrency && $activeCurrency !== $bankAccountCurrency;
221
+                            }),
222
+                    ]),
223
+                Tables\Columns\IconColumn::make('applyFullAmountAction')
224
+                    ->icon('heroicon-m-chevron-double-right')
225
+                    ->color('primary')
226
+                    ->label('')
227
+                    ->default('')
228
+                    ->alignCenter()
229
+                    ->width('3rem')
230
+                    ->tooltip('Apply full amount')
231
+                    ->action(
232
+                        Tables\Actions\Action::make('applyFullPayment')
233
+                            ->action(function (Bill $record) {
234
+                                $this->paymentAmounts[$record->id] = $record->amount_due;
235
+                            }),
236
+                    ),
237
+                CustomTextInputColumn::make('payment_amount')
238
+                    ->label('Payment amount')
239
+                    ->alignEnd()
240
+                    ->navigable()
241
+                    ->mask(RawJs::make('$money($input)'))
242
+                    ->updateStateUsing(function (Bill $record, $state) {
243
+                        if (! CurrencyConverter::isValidAmount($state, 'USD')) {
244
+                            $this->paymentAmounts[$record->id] = 0;
245
+
246
+                            return '0.00';
247
+                        }
248
+
249
+                        $paymentCents = CurrencyConverter::convertToCents($state, 'USD');
250
+
251
+                        if ($paymentCents > $record->amount_due) {
252
+                            $paymentCents = $record->amount_due;
253
+                        }
254
+
255
+                        $this->paymentAmounts[$record->id] = $paymentCents;
256
+
257
+                        return $state;
258
+                    })
259
+                    ->getStateUsing(function (Bill $record) {
260
+                        $paymentAmount = $this->paymentAmounts[$record->id] ?? 0;
261
+
262
+                        return CurrencyConverter::convertCentsToFormatSimple($paymentAmount, 'USD');
263
+                    })
264
+                    ->summarize([
265
+                        Summarizer::make()
266
+                            ->using(function () {
267
+                                $total = array_sum($this->paymentAmounts);
268
+                                $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
269
+                                $activeCurrency = $this->getTableFilterState('currency_code')['value'] ?? $defaultCurrency;
270
+
271
+                                if ($activeCurrency !== $defaultCurrency) {
272
+                                    $total = CurrencyConverter::convertBalance($total, $activeCurrency, $defaultCurrency);
273
+                                }
274
+
275
+                                return CurrencyConverter::formatCentsToMoney($total, $defaultCurrency, true);
276
+                            }),
277
+                        Summarizer::make()
278
+                            ->using(fn () => $this->totalPaymentAmount)
279
+                            ->visible(function () {
280
+                                $activeCurrency = $this->getTableFilterState('currency_code')['value'] ?? null;
281
+                                $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
282
+
283
+                                return $activeCurrency && $activeCurrency !== $defaultCurrency;
284
+                            }),
285
+                    ]),
286
+            ])
287
+            ->bulkActions([
288
+                Tables\Actions\BulkAction::make('applyFullAmounts')
289
+                    ->label('Apply full amounts')
290
+                    ->icon('heroicon-o-banknotes')
291
+                    ->color('primary')
292
+                    ->deselectRecordsAfterCompletion()
293
+                    ->action(function (Collection $records) {
294
+                        $records->each(function (Bill $bill) {
295
+                            $this->paymentAmounts[$bill->id] = $bill->amount_due;
296
+                        });
297
+                    }),
298
+                Tables\Actions\BulkAction::make('clearAmounts')
299
+                    ->label('Clear amounts')
300
+                    ->icon('heroicon-o-x-mark')
301
+                    ->color('gray')
302
+                    ->deselectRecordsAfterCompletion()
303
+                    ->action(function (Collection $records) {
304
+                        $records->each(function (Bill $bill) {
305
+                            $this->paymentAmounts[$bill->id] = 0;
306
+                        });
307
+                    }),
308
+            ])
309
+            ->filters([
310
+                Tables\Filters\SelectFilter::make('currency_code')
311
+                    ->label('Currency')
312
+                    ->selectablePlaceholder(false)
313
+                    ->default(CurrencyAccessor::getDefaultCurrency())
314
+                    ->options(Currency::query()->pluck('name', 'code')->toArray())
315
+                    ->searchable()
316
+                    ->resetState([
317
+                        'value' => CurrencyAccessor::getDefaultCurrency(),
318
+                    ])
319
+                    ->indicateUsing(function (Tables\Filters\SelectFilter $filter, array $state) {
320
+                        if (blank($state['value'] ?? null)) {
321
+                            return [];
322
+                        }
323
+
324
+                        $label = collect($filter->getOptions())
325
+                            ->mapWithKeys(fn (string | array $label, string $value): array => is_array($label) ? $label : [$value => $label])
326
+                            ->get($state['value']);
327
+
328
+                        if (blank($label)) {
329
+                            return [];
330
+                        }
331
+
332
+                        $indicator = $filter->getLabel();
333
+
334
+                        return Tables\Filters\Indicator::make("{$indicator}: {$label}")->removable(false);
335
+                    }),
336
+                Tables\Filters\SelectFilter::make('vendor_id')
337
+                    ->label('Vendor')
338
+                    ->options(fn () => Vendor::query()->pluck('name', 'id')->toArray())
339
+                    ->searchable(),
340
+                Tables\Filters\SelectFilter::make('status')
341
+                    ->multiple()
342
+                    ->options(BillStatus::getUnpaidOptions()),
343
+            ]);
344
+    }
345
+
346
+    protected function getPaymentAmount(Bill $record): int
347
+    {
348
+        return $this->paymentAmounts[$record->id] ?? 0;
349
+    }
350
+
351
+    #[Computed]
352
+    public function totalPaymentAmount(): string
353
+    {
354
+        $total = array_sum($this->paymentAmounts);
355
+
356
+        $currencyCode = $this->getTableFilterState('currency_code')['value'];
357
+
358
+        return CurrencyConverter::formatCentsToMoney($total, $currencyCode, true);
359
+    }
360
+
361
+    public function getSelectedBankAccount(): BankAccount
362
+    {
363
+        $bankAccountId = $this->data['bank_account_id'];
364
+
365
+        $bankAccount = BankAccount::find($bankAccountId);
366
+
367
+        return $bankAccount ?: BankAccount::where('enabled', true)->first();
368
+    }
369
+
370
+    protected function handleTableFilterUpdates(): void
371
+    {
372
+        parent::handleTableFilterUpdates();
373
+
374
+        $visibleBillIds = $this->getTableRecords()->pluck('id')->toArray();
375
+        $visibleBillKeys = array_flip($visibleBillIds);
376
+
377
+        $this->paymentAmounts = array_intersect_key($this->paymentAmounts, $visibleBillKeys);
378
+    }
379
+}

+ 1
- 0
app/Filament/Company/Resources/Purchases/BillResource/RelationManagers/PaymentsRelationManager.php Ver arquivo

@@ -216,6 +216,7 @@ class PaymentsRelationManager extends RelationManager
216 216
                 Tables\Actions\CreateAction::make()
217 217
                     ->label('Record payment')
218 218
                     ->modalHeading(fn (Tables\Actions\CreateAction $action) => $action->getLabel())
219
+                    ->slideOver()
219 220
                     ->modalWidth(MaxWidth::TwoExtraLarge)
220 221
                     ->visible(function () {
221 222
                         return $this->getOwnerRecord()->canRecordPayment();

+ 11
- 188
app/Filament/Company/Resources/Sales/InvoiceResource.php Ver arquivo

@@ -2,14 +2,12 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Sales;
4 4
 
5
-use App\Collections\Accounting\DocumentCollection;
6 5
 use App\Enums\Accounting\AdjustmentCategory;
7 6
 use App\Enums\Accounting\AdjustmentStatus;
8 7
 use App\Enums\Accounting\AdjustmentType;
9 8
 use App\Enums\Accounting\DocumentDiscountMethod;
10 9
 use App\Enums\Accounting\DocumentType;
11 10
 use App\Enums\Accounting\InvoiceStatus;
12
-use App\Enums\Accounting\PaymentMethod;
13 11
 use App\Enums\Setting\PaymentTerms;
14 12
 use App\Filament\Company\Resources\Sales\ClientResource\RelationManagers\InvoicesRelationManager;
15 13
 use App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
@@ -28,19 +26,16 @@ use App\Filament\Tables\Filters\DateRangeFilter;
28 26
 use App\Models\Accounting\Adjustment;
29 27
 use App\Models\Accounting\DocumentLineItem;
30 28
 use App\Models\Accounting\Invoice;
31
-use App\Models\Banking\BankAccount;
32 29
 use App\Models\Common\Client;
33 30
 use App\Models\Common\Offering;
34 31
 use App\Utilities\Currency\CurrencyAccessor;
35 32
 use App\Utilities\Currency\CurrencyConverter;
36 33
 use App\Utilities\RateCalculator;
37 34
 use Awcodes\TableRepeater\Header;
38
-use Closure;
39 35
 use Filament\Forms;
40 36
 use Filament\Forms\Form;
41 37
 use Filament\Notifications\Notification;
42 38
 use Filament\Resources\Resource;
43
-use Filament\Support\Enums\Alignment;
44 39
 use Filament\Support\Enums\MaxWidth;
45 40
 use Filament\Tables;
46 41
 use Filament\Tables\Table;
@@ -492,87 +487,19 @@ class InvoiceResource extends Resource
492 487
                         Invoice::getApproveDraftAction(Tables\Actions\Action::class),
493 488
                         Invoice::getMarkAsSentAction(Tables\Actions\Action::class),
494 489
                         Tables\Actions\Action::make('recordPayment')
495
-                            ->label(fn (Invoice $record) => $record->status === InvoiceStatus::Overpaid ? 'Refund Overpayment' : 'Record Payment')
496
-                            ->stickyModalHeader()
497
-                            ->stickyModalFooter()
498
-                            ->modalFooterActionsAlignment(Alignment::End)
499
-                            ->modalWidth(MaxWidth::TwoExtraLarge)
500
-                            ->icon('heroicon-o-credit-card')
490
+                            ->label('Record Payment')
491
+                            ->icon('heroicon-m-credit-card')
501 492
                             ->visible(function (Invoice $record) {
502 493
                                 return $record->canRecordPayment();
503 494
                             })
504
-                            ->mountUsing(function (Invoice $record, Form $form) {
505
-                                $form->fill([
506
-                                    'posted_at' => now(),
507
-                                    'amount' => $record->status === InvoiceStatus::Overpaid ? ltrim($record->amount_due, '-') : $record->amount_due,
508
-                                ]);
509
-                            })
510
-                            ->databaseTransaction()
511
-                            ->successNotificationTitle('Payment recorded')
512
-                            ->form([
513
-                                Forms\Components\DatePicker::make('posted_at')
514
-                                    ->label('Date'),
515
-                                Forms\Components\TextInput::make('amount')
516
-                                    ->label('Amount')
517
-                                    ->required()
518
-                                    ->money(fn (Invoice $record) => $record->currency_code)
519
-                                    ->live(onBlur: true)
520
-                                    ->helperText(function (Invoice $record, $state) {
521
-                                        $invoiceCurrency = $record->currency_code;
522
-                                        if (! CurrencyConverter::isValidAmount($state, 'USD')) {
523
-                                            return null;
524
-                                        }
525
-
526
-                                        $amountDue = $record->amount_due;
527
-
528
-                                        $amount = CurrencyConverter::convertToCents($state, 'USD');
529
-
530
-                                        if ($amount <= 0) {
531
-                                            return 'Please enter a valid positive amount';
532
-                                        }
533
-
534
-                                        if ($record->status === InvoiceStatus::Overpaid) {
535
-                                            $newAmountDue = $amountDue + $amount;
536
-                                        } else {
537
-                                            $newAmountDue = $amountDue - $amount;
538
-                                        }
539
-
540
-                                        return match (true) {
541
-                                            $newAmountDue > 0 => 'Amount due after payment will be ' . CurrencyConverter::formatCentsToMoney($newAmountDue, $invoiceCurrency),
542
-                                            $newAmountDue === 0 => 'Invoice will be fully paid',
543
-                                            default => 'Invoice will be overpaid by ' . CurrencyConverter::formatCentsToMoney(abs($newAmountDue), $invoiceCurrency),
544
-                                        };
545
-                                    })
546
-                                    ->rules([
547
-                                        static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
548
-                                            if (! CurrencyConverter::isValidAmount($value, 'USD')) {
549
-                                                $fail('Please enter a valid amount');
550
-                                            }
551
-                                        },
552
-                                    ]),
553
-                                Forms\Components\Select::make('payment_method')
554
-                                    ->label('Payment method')
555
-                                    ->required()
556
-                                    ->options(PaymentMethod::class),
557
-                                Forms\Components\Select::make('bank_account_id')
558
-                                    ->label('Account')
559
-                                    ->required()
560
-                                    ->options(function () {
561
-                                        return BankAccount::query()
562
-                                            ->join('accounts', 'bank_accounts.account_id', '=', 'accounts.id')
563
-                                            ->select(['bank_accounts.id', 'accounts.name'])
564
-                                            ->pluck('accounts.name', 'bank_accounts.id')
565
-                                            ->toArray();
566
-                                    })
567
-                                    ->searchable(),
568
-                                Forms\Components\Textarea::make('notes')
569
-                                    ->label('Notes'),
570
-                            ])
571
-                            ->action(function (Invoice $record, Tables\Actions\Action $action, array $data) {
572
-                                $record->recordPayment($data);
573
-
574
-                                $action->success();
575
-                            }),
495
+                            ->url(fn (Invoice $record) => Pages\RecordPayments::getUrl([
496
+                                'tableFilters' => [
497
+                                    'client_id' => ['value' => $record->client_id],
498
+                                    'currency_code' => ['value' => $record->currency_code],
499
+                                ],
500
+                                'invoiceId' => $record->id,
501
+                            ]))
502
+                            ->openUrlInNewTab(false),
576 503
                     ])->dropdown(false),
577 504
                     Tables\Actions\DeleteAction::make(),
578 505
                 ]),
@@ -670,111 +597,6 @@ class InvoiceResource extends Resource
670 597
                                 $record->markAsSent();
671 598
                             });
672 599
 
673
-                            $action->success();
674
-                        }),
675
-                    Tables\Actions\BulkAction::make('recordPayments')
676
-                        ->label('Record payments')
677
-                        ->icon('heroicon-o-credit-card')
678
-                        ->stickyModalHeader()
679
-                        ->stickyModalFooter()
680
-                        ->modalFooterActionsAlignment(Alignment::End)
681
-                        ->modalWidth(MaxWidth::TwoExtraLarge)
682
-                        ->databaseTransaction()
683
-                        ->successNotificationTitle('Payments recorded')
684
-                        ->failureNotificationTitle('Failed to Record Payments')
685
-                        ->deselectRecordsAfterCompletion()
686
-                        ->beforeFormFilled(function (Collection $records, Tables\Actions\BulkAction $action) {
687
-                            $isInvalid = $records->contains(fn (Invoice $record) => ! $record->canBulkRecordPayment());
688
-
689
-                            if ($isInvalid) {
690
-                                Notification::make()
691
-                                    ->title('Payment recording failed')
692
-                                    ->body('Invoices that are either draft, paid, overpaid, voided, or are in a foreign currency cannot be processed through bulk payments. Please adjust your selection and try again.')
693
-                                    ->persistent()
694
-                                    ->danger()
695
-                                    ->send();
696
-
697
-                                $action->cancel(true);
698
-                            }
699
-                        })
700
-                        ->mountUsing(function (DocumentCollection $records, Form $form) {
701
-                            $totalAmountDue = $records->sum('amount_due');
702
-
703
-                            $form->fill([
704
-                                'posted_at' => now(),
705
-                                'amount' => $totalAmountDue,
706
-                            ]);
707
-                        })
708
-                        ->form([
709
-                            Forms\Components\DatePicker::make('posted_at')
710
-                                ->label('Date'),
711
-                            Forms\Components\TextInput::make('amount')
712
-                                ->label('Amount')
713
-                                ->required()
714
-                                ->money()
715
-                                ->rules([
716
-                                    static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
717
-                                        if (! CurrencyConverter::isValidAmount($value)) {
718
-                                            $fail('Please enter a valid amount');
719
-                                        }
720
-                                    },
721
-                                ]),
722
-                            Forms\Components\Select::make('payment_method')
723
-                                ->label('Payment method')
724
-                                ->required()
725
-                                ->options(PaymentMethod::class),
726
-                            Forms\Components\Select::make('bank_account_id')
727
-                                ->label('Account')
728
-                                ->required()
729
-                                ->options(function () {
730
-                                    return BankAccount::query()
731
-                                        ->join('accounts', 'bank_accounts.account_id', '=', 'accounts.id')
732
-                                        ->select(['bank_accounts.id', 'accounts.name'])
733
-                                        ->pluck('accounts.name', 'bank_accounts.id')
734
-                                        ->toArray();
735
-                                })
736
-                                ->searchable(),
737
-                            Forms\Components\Textarea::make('notes')
738
-                                ->label('Notes'),
739
-                        ])
740
-                        ->before(function (DocumentCollection $records, Tables\Actions\BulkAction $action, array $data) {
741
-                            $totalPaymentAmount = $data['amount'] ?? 0;
742
-                            $totalAmountDue = $records->sum('amount_due');
743
-
744
-                            if ($totalPaymentAmount > $totalAmountDue) {
745
-                                $formattedTotalAmountDue = CurrencyConverter::formatCentsToMoney($totalAmountDue);
746
-
747
-                                Notification::make()
748
-                                    ->title('Excess payment amount')
749
-                                    ->body("The payment amount exceeds the total amount due of {$formattedTotalAmountDue}. Please adjust the payment amount and try again.")
750
-                                    ->persistent()
751
-                                    ->warning()
752
-                                    ->send();
753
-
754
-                                $action->halt(true);
755
-                            }
756
-                        })
757
-                        ->action(function (DocumentCollection $records, Tables\Actions\BulkAction $action, array $data) {
758
-                            $totalPaymentAmount = $data['amount'] ?? 0;
759
-
760
-                            $remainingAmount = $totalPaymentAmount;
761
-
762
-                            $records->each(function (Invoice $record) use (&$remainingAmount, $data) {
763
-                                $amountDue = $record->amount_due;
764
-
765
-                                if ($amountDue <= 0 || $remainingAmount <= 0) {
766
-                                    return;
767
-                                }
768
-
769
-                                $paymentAmount = min($amountDue, $remainingAmount);
770
-
771
-                                $data['amount'] = $paymentAmount;
772
-
773
-                                $record->recordPayment($data);
774
-
775
-                                $remainingAmount -= $paymentAmount;
776
-                            });
777
-
778 600
                             $action->success();
779 601
                         }),
780 602
                 ]),
@@ -785,6 +607,7 @@ class InvoiceResource extends Resource
785 607
     {
786 608
         return [
787 609
             'index' => Pages\ListInvoices::route('/'),
610
+            'record-payments' => Pages\RecordPayments::route('/record-payments'),
788 611
             'create' => Pages\CreateInvoice::route('/create'),
789 612
             'view' => Pages\ViewInvoice::route('/{record}'),
790 613
             'edit' => Pages\EditInvoice::route('/{record}/edit'),

+ 5
- 2
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ListInvoices.php Ver arquivo

@@ -7,8 +7,8 @@ use App\Enums\Accounting\InvoiceStatus;
7 7
 use App\Filament\Company\Resources\Sales\InvoiceResource;
8 8
 use App\Filament\Company\Resources\Sales\InvoiceResource\Widgets;
9 9
 use App\Filament\Company\Resources\Sales\RecurringInvoiceResource\Pages\ViewRecurringInvoice;
10
+use App\Filament\Infolists\Components\BannerEntry;
10 11
 use App\Models\Accounting\RecurringInvoice;
11
-use CodeWithDennis\SimpleAlert\Components\Infolists\SimpleAlert;
12 12
 use Filament\Actions;
13 13
 use Filament\Infolists\Components\Actions\Action;
14 14
 use Filament\Infolists\Infolist;
@@ -37,7 +37,7 @@ class ListInvoices extends ListRecords
37 37
     {
38 38
         return $infolist
39 39
             ->schema([
40
-                SimpleAlert::make('recurringInvoiceFilter')
40
+                BannerEntry::make('recurringInvoiceFilter')
41 41
                     ->info()
42 42
                     ->title(function () {
43 43
                         if (empty($this->recurringInvoice)) {
@@ -79,6 +79,9 @@ class ListInvoices extends ListRecords
79 79
     protected function getHeaderActions(): array
80 80
     {
81 81
         return [
82
+            Actions\Action::make('recordPayments')
83
+                ->outlined()
84
+                ->url(RecordPayments::getUrl()),
82 85
             Actions\CreateAction::make(),
83 86
         ];
84 87
     }

+ 518
- 0
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/RecordPayments.php Ver arquivo

@@ -0,0 +1,518 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
4
+
5
+use App\Enums\Accounting\InvoiceStatus;
6
+use App\Enums\Accounting\PaymentMethod;
7
+use App\Filament\Company\Resources\Sales\InvoiceResource;
8
+use App\Filament\Tables\Columns\CustomTextInputColumn;
9
+use App\Models\Accounting\Invoice;
10
+use App\Models\Accounting\Transaction;
11
+use App\Models\Banking\BankAccount;
12
+use App\Models\Common\Client;
13
+use App\Models\Setting\Currency;
14
+use App\Utilities\Currency\CurrencyAccessor;
15
+use App\Utilities\Currency\CurrencyConverter;
16
+use Filament\Actions;
17
+use Filament\Forms;
18
+use Filament\Forms\Form;
19
+use Filament\Notifications\Notification;
20
+use Filament\Resources\Pages\ListRecords;
21
+use Filament\Support\Enums\MaxWidth;
22
+use Filament\Support\RawJs;
23
+use Filament\Tables;
24
+use Filament\Tables\Columns\Summarizers\Summarizer;
25
+use Filament\Tables\Columns\TextColumn;
26
+use Filament\Tables\Table;
27
+use Illuminate\Contracts\Support\Htmlable;
28
+use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
29
+use Illuminate\Database\Eloquent\Collection;
30
+use Illuminate\Database\Query\Builder;
31
+use Illuminate\Support\Str;
32
+use Livewire\Attributes\Computed;
33
+use Livewire\Attributes\Url;
34
+
35
+/**
36
+ * @property Form $form
37
+ */
38
+class RecordPayments extends ListRecords
39
+{
40
+    protected static string $resource = InvoiceResource::class;
41
+
42
+    protected static string $view = 'filament.company.resources.sales.invoice-resource.pages.record-payments';
43
+
44
+    public array $paymentAmounts = [];
45
+
46
+    public ?array $data = [];
47
+
48
+    public ?int $allocationAmount = null;
49
+
50
+    #[Url(except: '')]
51
+    public string $invoiceId = '';
52
+
53
+    public function getBreadcrumb(): ?string
54
+    {
55
+        return 'Record Payments';
56
+    }
57
+
58
+    public function getTitle(): string | Htmlable
59
+    {
60
+        return 'Record Payments';
61
+    }
62
+
63
+    public function getMaxContentWidth(): MaxWidth | string | null
64
+    {
65
+        return 'max-w-8xl';
66
+    }
67
+
68
+    public function mount(): void
69
+    {
70
+        parent::mount();
71
+
72
+        $preservedClientId = $this->tableFilters['client_id']['value'] ?? null;
73
+        $preservedCurrencyCode = $this->tableFilters['currency_code']['value'] ?? CurrencyAccessor::getDefaultCurrency();
74
+
75
+        $this->tableFilters = [
76
+            'client_id' => $preservedClientId ? ['value' => $preservedClientId] : [],
77
+            'currency_code' => ['value' => $preservedCurrencyCode],
78
+        ];
79
+
80
+        if ($invoiceId = (int) $this->invoiceId) {
81
+            $invoice = Invoice::find($invoiceId);
82
+            if ($invoice && $invoice->client_id == $preservedClientId) {
83
+                $this->paymentAmounts[$invoiceId] = $invoice->amount_due;
84
+            }
85
+        }
86
+
87
+        $this->form->fill();
88
+    }
89
+
90
+    protected function getHeaderActions(): array
91
+    {
92
+        return [
93
+            Actions\Action::make('processPayments')
94
+                ->color('primary')
95
+                ->requiresConfirmation()
96
+                ->modalHeading('Confirm payments')
97
+                ->modalDescription(function () {
98
+                    $invoiceCount = collect($this->paymentAmounts)->filter(fn ($amount) => $amount > 0)->count();
99
+                    $totalAmount = array_sum($this->paymentAmounts);
100
+                    $currencyCode = $this->getTableFilterState('currency_code')['value'];
101
+                    $totalFormatted = CurrencyConverter::formatCentsToMoney($totalAmount, $currencyCode, true);
102
+
103
+                    return "You are about to pay {$invoiceCount} " . Str::plural('invoice', $invoiceCount) . " for a total of {$totalFormatted}. This action cannot be undone.";
104
+                })
105
+                ->action(function () {
106
+                    $data = $this->data;
107
+                    $tableRecords = $this->getTableRecords();
108
+                    $paidCount = 0;
109
+                    $totalPaid = 0;
110
+
111
+                    /** @var Invoice $invoice */
112
+                    foreach ($tableRecords as $invoice) {
113
+                        if (! $invoice->canRecordPayment()) {
114
+                            continue;
115
+                        }
116
+
117
+                        // Get the payment amount from our component state
118
+                        $paymentAmount = $this->getPaymentAmount($invoice);
119
+
120
+                        if ($paymentAmount <= 0) {
121
+                            continue;
122
+                        }
123
+
124
+                        $paymentData = [
125
+                            'posted_at' => $data['posted_at'],
126
+                            'payment_method' => $data['payment_method'],
127
+                            'bank_account_id' => $data['bank_account_id'],
128
+                            'amount' => $paymentAmount,
129
+                        ];
130
+
131
+                        $invoice->recordPayment($paymentData);
132
+                        $paidCount++;
133
+                        $totalPaid += $paymentAmount;
134
+                    }
135
+
136
+                    $currencyCode = $this->getTableFilterState('currency_code')['value'];
137
+                    $totalFormatted = CurrencyConverter::formatCentsToMoney($totalPaid, $currencyCode, true);
138
+
139
+                    Notification::make()
140
+                        ->title('Payments recorded successfully')
141
+                        ->body("Recorded {$paidCount} " . Str::plural('payment', $paidCount) . " for a total of {$totalFormatted}")
142
+                        ->success()
143
+                        ->send();
144
+
145
+                    $this->reset('paymentAmounts', 'allocationAmount');
146
+
147
+                    $this->resetTable();
148
+                }),
149
+        ];
150
+    }
151
+
152
+    protected function allocateOldestFirst(Collection $invoices, int $amountInCents): void
153
+    {
154
+        $remainingAmount = $amountInCents;
155
+
156
+        $sortedInvoices = $invoices->sortBy('due_date');
157
+
158
+        foreach ($sortedInvoices as $invoice) {
159
+            if ($remainingAmount <= 0) {
160
+                break;
161
+            }
162
+
163
+            $amountDue = $invoice->amount_due;
164
+            $allocation = min($remainingAmount, $amountDue);
165
+
166
+            $this->paymentAmounts[$invoice->id] = $allocation;
167
+            $remainingAmount -= $allocation;
168
+        }
169
+    }
170
+
171
+    protected function hasSelectedClient(): bool
172
+    {
173
+        return ! empty($this->getTableFilterState('client_id')['value']);
174
+    }
175
+
176
+    /**
177
+     * @return array<int | string, string | Form>
178
+     */
179
+    protected function getForms(): array
180
+    {
181
+        return [
182
+            'form',
183
+        ];
184
+    }
185
+
186
+    public function form(Form $form): Form
187
+    {
188
+        return $form
189
+            ->live()
190
+            ->schema([
191
+                Forms\Components\Grid::make(2) // Changed from 3 to 4
192
+                    ->schema([
193
+                        Forms\Components\Select::make('bank_account_id')
194
+                            ->label('Account')
195
+                            ->options(static function () {
196
+                                return Transaction::getBankAccountOptionsFlat();
197
+                            })
198
+                            ->default(fn () => BankAccount::where('enabled', true)->first()?->id)
199
+                            ->selectablePlaceholder(false)
200
+                            ->searchable()
201
+                            ->softRequired(),
202
+                        Forms\Components\DatePicker::make('posted_at')
203
+                            ->label('Date')
204
+                            ->default(now())
205
+                            ->softRequired(),
206
+                        Forms\Components\Select::make('payment_method')
207
+                            ->label('Payment method')
208
+                            ->selectablePlaceholder(false)
209
+                            ->options(PaymentMethod::class)
210
+                            ->default(PaymentMethod::BankPayment)
211
+                            ->softRequired(),
212
+                        Forms\Components\TextInput::make('allocation_amount')
213
+                            ->label('Allocate Payment Amount')
214
+                            ->default(array_sum($this->paymentAmounts))
215
+                            ->money($this->getTableFilterState('currency_code')['value'])
216
+                            ->extraAlpineAttributes([
217
+                                'x-on:keydown.enter.prevent' => '$refs.allocate.click()',
218
+                            ])
219
+                            ->suffixAction(
220
+                                Forms\Components\Actions\Action::make('allocate')
221
+                                    ->icon('heroicon-m-calculator')
222
+                                    ->extraAttributes([
223
+                                        'x-ref' => 'allocate',
224
+                                    ])
225
+                                    ->action(function ($state) {
226
+                                        $this->allocationAmount = CurrencyConverter::convertToCents($state, 'USD');
227
+                                        if ($this->allocationAmount && $this->hasSelectedClient()) {
228
+                                            $this->allocateOldestFirst($this->getTableRecords(), $this->allocationAmount);
229
+                                        }
230
+                                    }),
231
+                            ),
232
+                    ]),
233
+            ])->statePath('data');
234
+    }
235
+
236
+    public function table(Table $table): Table
237
+    {
238
+        return $table
239
+            ->query(
240
+                Invoice::query()
241
+                    ->with(['client'])
242
+                    ->unpaid()
243
+            )
244
+            ->recordClasses(['is-spreadsheet'])
245
+            ->defaultSort('due_date')
246
+            ->paginated(false)
247
+            ->emptyStateHeading('No client selected')
248
+            ->emptyStateDescription('Select a client from the filters above to view and process invoice payments.')
249
+            ->columns([
250
+                TextColumn::make('client.name')
251
+                    ->label('Client')
252
+                    ->sortable(),
253
+                TextColumn::make('invoice_number')
254
+                    ->label('Invoice number')
255
+                    ->sortable(),
256
+                TextColumn::make('due_date')
257
+                    ->label('Due date')
258
+                    ->defaultDateFormat()
259
+                    ->sortable(),
260
+                Tables\Columns\TextColumn::make('status')
261
+                    ->badge()
262
+                    ->sortable(),
263
+                TextColumn::make('amount_due')
264
+                    ->label('Amount due')
265
+                    ->currency(static fn (Invoice $record) => $record->currency_code)
266
+                    ->alignEnd()
267
+                    ->sortable()
268
+                    ->summarize([
269
+                        Summarizer::make()
270
+                            ->using(function (Builder $query) {
271
+                                $totalAmountDue = $query->sum('amount_due');
272
+                                $bankAccountCurrency = $this->getSelectedBankAccount()->account->currency_code;
273
+                                $activeCurrency = $this->getTableFilterState('currency_code')['value'] ?? $bankAccountCurrency;
274
+
275
+                                if ($activeCurrency !== $bankAccountCurrency) {
276
+                                    $totalAmountDue = CurrencyConverter::convertBalance($totalAmountDue, $activeCurrency, $bankAccountCurrency);
277
+                                }
278
+
279
+                                return CurrencyConverter::formatCentsToMoney($totalAmountDue, $bankAccountCurrency, true);
280
+                            }),
281
+                        Summarizer::make()
282
+                            ->using(function (Builder $query) {
283
+                                $totalAmountDue = $query->sum('amount_due');
284
+                                $currencyCode = $this->getTableFilterState('currency_code')['value'];
285
+
286
+                                return CurrencyConverter::formatCentsToMoney($totalAmountDue, $currencyCode, true);
287
+                            })
288
+                            ->visible(function () {
289
+                                $activeCurrency = $this->getTableFilterState('currency_code')['value'] ?? null;
290
+                                $bankAccountCurrency = $this->getSelectedBankAccount()->account->currency_code;
291
+
292
+                                return $activeCurrency && $activeCurrency !== $bankAccountCurrency;
293
+                            }),
294
+                    ]),
295
+                Tables\Columns\IconColumn::make('applyFullAmountAction')
296
+                    ->icon('heroicon-m-chevron-double-right')
297
+                    ->color('primary')
298
+                    ->label('')
299
+                    ->default('')
300
+                    ->alignCenter()
301
+                    ->width('3rem')
302
+                    ->tooltip('Apply full amount')
303
+                    ->action(
304
+                        Tables\Actions\Action::make('applyFullPayment')
305
+                            ->action(function (Invoice $record) {
306
+                                $this->paymentAmounts[$record->id] = $record->amount_due;
307
+                            }),
308
+                    ),
309
+                CustomTextInputColumn::make('payment_amount')
310
+                    ->label('Payment amount')
311
+                    ->alignEnd()
312
+                    ->navigable()
313
+                    ->mask(RawJs::make('$money($input)'))
314
+                    ->updateStateUsing(function (Invoice $record, $state) {
315
+                        if (! CurrencyConverter::isValidAmount($state, 'USD')) {
316
+                            $this->paymentAmounts[$record->id] = 0;
317
+
318
+                            return '0.00';
319
+                        }
320
+
321
+                        $paymentCents = CurrencyConverter::convertToCents($state, 'USD');
322
+
323
+                        if ($paymentCents > $record->amount_due) {
324
+                            $paymentCents = $record->amount_due;
325
+                        }
326
+
327
+                        $this->paymentAmounts[$record->id] = $paymentCents;
328
+
329
+                        return $state;
330
+                    })
331
+                    ->getStateUsing(function (Invoice $record) {
332
+                        $paymentAmount = $this->paymentAmounts[$record->id] ?? 0;
333
+
334
+                        return CurrencyConverter::convertCentsToFormatSimple($paymentAmount, 'USD');
335
+                    })
336
+                    ->summarize([
337
+                        Summarizer::make()
338
+                            ->using(function () {
339
+                                $total = array_sum($this->paymentAmounts);
340
+                                $bankAccountCurrency = $this->getSelectedBankAccount()->account->currency_code;
341
+                                $activeCurrency = $this->getTableFilterState('currency_code')['value'] ?? $bankAccountCurrency;
342
+
343
+                                if ($activeCurrency !== $bankAccountCurrency) {
344
+                                    $total = CurrencyConverter::convertBalance($total, $activeCurrency, $bankAccountCurrency);
345
+                                }
346
+
347
+                                return CurrencyConverter::formatCentsToMoney($total, $bankAccountCurrency, true);
348
+                            }),
349
+                        Summarizer::make()
350
+                            ->using(fn () => $this->totalPaymentAmount)
351
+                            ->visible(function () {
352
+                                $activeCurrency = $this->getTableFilterState('currency_code')['value'] ?? null;
353
+                                $bankAccountCurrency = $this->getSelectedBankAccount()->account->currency_code;
354
+
355
+                                return $activeCurrency && $activeCurrency !== $bankAccountCurrency;
356
+                            }),
357
+                    ]),
358
+            ])
359
+            ->bulkActions([
360
+                Tables\Actions\BulkAction::make('applyFullAmounts')
361
+                    ->label('Apply full amounts')
362
+                    ->icon('heroicon-o-banknotes')
363
+                    ->color('primary')
364
+                    ->deselectRecordsAfterCompletion()
365
+                    ->action(function (Collection $records) {
366
+                        $records->each(function (Invoice $invoice) {
367
+                            $this->paymentAmounts[$invoice->id] = $invoice->amount_due;
368
+                        });
369
+                    }),
370
+                Tables\Actions\BulkAction::make('clearAmounts')
371
+                    ->label('Clear amounts')
372
+                    ->icon('heroicon-o-x-mark')
373
+                    ->color('gray')
374
+                    ->deselectRecordsAfterCompletion()
375
+                    ->action(function (Collection $records) {
376
+                        $records->each(function (Invoice $invoice) {
377
+                            $this->paymentAmounts[$invoice->id] = 0;
378
+                        });
379
+                    }),
380
+            ])
381
+            ->filters([
382
+                Tables\Filters\SelectFilter::make('currency_code')
383
+                    ->label('Currency')
384
+                    ->selectablePlaceholder(false)
385
+                    ->default(CurrencyAccessor::getDefaultCurrency())
386
+                    ->options(Currency::query()->pluck('name', 'code')->toArray())
387
+                    ->searchable()
388
+                    ->resetState([
389
+                        'value' => CurrencyAccessor::getDefaultCurrency(),
390
+                    ])
391
+                    ->indicateUsing(function (Tables\Filters\SelectFilter $filter, array $state) {
392
+                        if (blank($state['value'] ?? null)) {
393
+                            return [];
394
+                        }
395
+
396
+                        $label = collect($filter->getOptions())
397
+                            ->mapWithKeys(fn (string | array $label, string $value): array => is_array($label) ? $label : [$value => $label])
398
+                            ->get($state['value']);
399
+
400
+                        if (blank($label)) {
401
+                            return [];
402
+                        }
403
+
404
+                        $indicator = $filter->getLabel();
405
+
406
+                        return Tables\Filters\Indicator::make("{$indicator}: {$label}")->removable(false);
407
+                    }),
408
+                Tables\Filters\SelectFilter::make('client_id')
409
+                    ->label('Client')
410
+                    ->selectablePlaceholder(false)
411
+                    ->options(fn () => Client::query()->pluck('name', 'id')->toArray())
412
+                    ->searchable()
413
+                    ->query(function (EloquentBuilder $query, array $data) {
414
+                        if (blank($data['value'] ?? null)) {
415
+                            return $query->whereRaw('1 = 0'); // No results if no client is selected
416
+                        }
417
+
418
+                        return $query->where('client_id', $data['value']);
419
+                    }),
420
+                Tables\Filters\Filter::make('invoice_lookup')
421
+                    ->label('Find Invoice')
422
+                    ->form([
423
+                        Forms\Components\TextInput::make('invoice_number')
424
+                            ->label('Invoice Number')
425
+                            ->placeholder('Enter invoice number')
426
+                            ->suffixAction(
427
+                                Forms\Components\Actions\Action::make('findInvoice')
428
+                                    ->icon('heroicon-m-magnifying-glass')
429
+                                    ->keyBindings(['enter'])
430
+                                    ->action(function ($state, Forms\Set $set) {
431
+                                        if (blank($state)) {
432
+                                            return;
433
+                                        }
434
+
435
+                                        $invoice = Invoice::byNumber($state)
436
+                                            ->unpaid()
437
+                                            ->first();
438
+
439
+                                        if ($invoice) {
440
+                                            $set('tableFilters.client_id.value', $invoice->client_id, true);
441
+                                            $this->paymentAmounts[$invoice->id] = $invoice->amount_due;
442
+
443
+                                            Notification::make()
444
+                                                ->title('Invoice found')
445
+                                                ->body("Found invoice {$invoice->invoice_number} for {$invoice->client->name}")
446
+                                                ->success()
447
+                                                ->send();
448
+                                        } else {
449
+                                            Notification::make()
450
+                                                ->title('Invoice not found')
451
+                                                ->body("No unpaid invoice found with number: {$state}")
452
+                                                ->warning()
453
+                                                ->send();
454
+                                        }
455
+                                    })
456
+                            ),
457
+                    ])
458
+                    ->query(null)
459
+                    ->indicateUsing(null),
460
+                Tables\Filters\SelectFilter::make('status')
461
+                    ->multiple()
462
+                    ->options(InvoiceStatus::getUnpaidOptions()),
463
+            ]);
464
+    }
465
+
466
+    protected function getPaymentAmount(Invoice $record): int
467
+    {
468
+        return $this->paymentAmounts[$record->id] ?? 0;
469
+    }
470
+
471
+    #[Computed]
472
+    public function totalPaymentAmount(): string
473
+    {
474
+        $total = array_sum($this->paymentAmounts);
475
+
476
+        $currencyCode = $this->getTableFilterState('currency_code')['value'];
477
+
478
+        return CurrencyConverter::formatCentsToMoney($total, $currencyCode, true);
479
+    }
480
+
481
+    public function getSelectedBankAccount(): BankAccount
482
+    {
483
+        $bankAccountId = $this->data['bank_account_id'];
484
+
485
+        $bankAccount = BankAccount::find($bankAccountId);
486
+
487
+        return $bankAccount ?: BankAccount::where('enabled', true)->first();
488
+    }
489
+
490
+    public function resetTableFiltersForm(): void
491
+    {
492
+        parent::resetTableFiltersForm();
493
+
494
+        $this->invoiceId = '';
495
+        $this->paymentAmounts = [];
496
+        $this->allocationAmount = null;
497
+    }
498
+
499
+    public function removeTableFilters(): void
500
+    {
501
+        parent::removeTableFilters();
502
+
503
+        $this->invoiceId = '';
504
+        $this->paymentAmounts = [];
505
+        $this->allocationAmount = null;
506
+    }
507
+
508
+    protected function handleTableFilterUpdates(): void
509
+    {
510
+        parent::handleTableFilterUpdates();
511
+
512
+        $visibleInvoiceIds = $this->getTableRecords()->pluck('id')->toArray();
513
+        $visibleInvoiceKeys = array_flip($visibleInvoiceIds);
514
+
515
+        $this->paymentAmounts = array_intersect_key($this->paymentAmounts, $visibleInvoiceKeys);
516
+        $this->allocationAmount = null;
517
+    }
518
+}

+ 4
- 6
app/Filament/Company/Resources/Sales/InvoiceResource/RelationManagers/PaymentsRelationManager.php Ver arquivo

@@ -115,11 +115,8 @@ class PaymentsRelationManager extends RelationManager
115 115
                                 };
116 116
                             })
117 117
                             ->rules([
118
-                                static fn (RelationManager $livewire): Closure => static function (string $attribute, $value, Closure $fail) use ($livewire) {
119
-                                    /** @var Invoice $invoice */
120
-                                    $invoice = $livewire->getOwnerRecord();
121
-
122
-                                    if (! CurrencyConverter::isValidAmount($value, $invoice->currency_code)) {
118
+                                static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
119
+                                    if (! CurrencyConverter::isValidAmount($value, 'USD')) {
123 120
                                         $fail('Please enter a valid amount');
124 121
                                     }
125 122
                                 },
@@ -232,6 +229,7 @@ class PaymentsRelationManager extends RelationManager
232 229
                 Tables\Actions\CreateAction::make()
233 230
                     ->label(fn () => $this->getOwnerRecord()->status === InvoiceStatus::Overpaid ? 'Refund Overpayment' : 'Record Payment')
234 231
                     ->modalHeading(fn (Tables\Actions\CreateAction $action) => $action->getLabel())
232
+                    ->slideOver()
235 233
                     ->modalWidth(MaxWidth::TwoExtraLarge)
236 234
                     ->visible(function () {
237 235
                         return $this->getOwnerRecord()->canRecordPayment();
@@ -240,7 +238,7 @@ class PaymentsRelationManager extends RelationManager
240 238
                         $record = $this->getOwnerRecord();
241 239
                         $form->fill([
242 240
                             'posted_at' => now(),
243
-                            'amount' => $record->status === InvoiceStatus::Overpaid ? ltrim($record->amount_due, '-') : $record->amount_due,
241
+                            'amount' => abs($record->amount_due),
244 242
                         ]);
245 243
                     })
246 244
                     ->databaseTransaction()

+ 39
- 0
app/Filament/Tables/Columns/CustomTextInputColumn.php Ver arquivo

@@ -0,0 +1,39 @@
1
+<?php
2
+
3
+namespace App\Filament\Tables\Columns;
4
+
5
+use Closure;
6
+use Filament\Tables\Columns\TextInputColumn;
7
+
8
+class CustomTextInputColumn extends TextInputColumn
9
+{
10
+    protected string $view = 'filament.tables.columns.custom-text-input-column';
11
+
12
+    protected bool | Closure $isDeferred = false;
13
+
14
+    protected bool | Closure $isNavigable = false;
15
+
16
+    public function deferred(bool | Closure $condition = true): static
17
+    {
18
+        $this->isDeferred = $condition;
19
+
20
+        return $this;
21
+    }
22
+
23
+    public function navigable(bool | Closure $condition = true): static
24
+    {
25
+        $this->isNavigable = $condition;
26
+
27
+        return $this;
28
+    }
29
+
30
+    public function isDeferred(): bool
31
+    {
32
+        return (bool) $this->evaluate($this->isDeferred);
33
+    }
34
+
35
+    public function isNavigable(): bool
36
+    {
37
+        return (bool) $this->evaluate($this->isNavigable);
38
+    }
39
+}

+ 0
- 25
app/Filament/Tables/Columns/DeferredTextInputColumn.php Ver arquivo

@@ -1,25 +0,0 @@
1
-<?php
2
-
3
-namespace App\Filament\Tables\Columns;
4
-
5
-use Closure;
6
-use Filament\Tables\Columns\TextInputColumn;
7
-
8
-class DeferredTextInputColumn extends TextInputColumn
9
-{
10
-    protected string $view = 'filament.tables.columns.deferred-text-input-column';
11
-
12
-    protected bool | Closure $batchMode = false;
13
-
14
-    public function batchMode(bool | Closure $condition = true): static
15
-    {
16
-        $this->batchMode = $condition;
17
-
18
-        return $this;
19
-    }
20
-
21
-    public function getBatchMode(): bool
22
-    {
23
-        return $this->evaluate($this->batchMode);
24
-    }
25
-}

+ 12
- 6
app/Models/Accounting/Invoice.php Ver arquivo

@@ -183,12 +183,18 @@ class Invoice extends Document
183 183
 
184 184
     public function scopeUnpaid(Builder $query): Builder
185 185
     {
186
-        return $query->whereNotIn('status', [
187
-            InvoiceStatus::Paid,
188
-            InvoiceStatus::Void,
189
-            InvoiceStatus::Draft,
190
-            InvoiceStatus::Overpaid,
191
-        ]);
186
+        return $query->whereIn('status', InvoiceStatus::unpaidStatuses());
187
+    }
188
+
189
+    // TODO: Consider storing the numeric part of the invoice number separately
190
+    public function scopeByNumber(Builder $query, string $number): Builder
191
+    {
192
+        $invoicePrefix = DocumentDefault::invoice()->first()->number_prefix ?? '';
193
+
194
+        return $query->where(function ($q) use ($number, $invoicePrefix) {
195
+            $q->where('invoice_number', $number)
196
+                ->orWhere('invoice_number', $invoicePrefix . $number);
197
+        });
192 198
     }
193 199
 
194 200
     public function scopeOverdue(Builder $query): Builder

+ 20
- 0
app/Models/Accounting/Transaction.php Ver arquivo

@@ -113,6 +113,26 @@ class Transaction extends Model
113 113
         }
114 114
     }
115 115
 
116
+    public static function getBankAccountOptionsFlat(?int $excludedAccountId = null, ?int $currentBankAccountId = null, bool $excludeArchived = true): array
117
+    {
118
+        return BankAccount::query()
119
+            ->whereHas('account', function (Builder $query) use ($excludeArchived) {
120
+                if ($excludeArchived) {
121
+                    $query->where('archived', false);
122
+                }
123
+            })
124
+            ->with(['account' => function ($query) use ($excludeArchived) {
125
+                if ($excludeArchived) {
126
+                    $query->where('archived', false);
127
+                }
128
+            }])
129
+            ->when($excludedAccountId, fn (Builder $query) => $query->where('account_id', '!=', $excludedAccountId))
130
+            ->when($currentBankAccountId, fn (Builder $query) => $query->orWhere('id', $currentBankAccountId))
131
+            ->get()
132
+            ->pluck('account.name', 'id')
133
+            ->toArray();
134
+    }
135
+
116 136
     public static function getBankAccountOptions(?int $excludedAccountId = null, ?int $currentBankAccountId = null, bool $excludeArchived = true): array
117 137
     {
118 138
         return BankAccount::query()

+ 1
- 1
app/Models/Setting/DocumentDefault.php Ver arquivo

@@ -75,7 +75,7 @@ class DocumentDefault extends Model
75 75
 
76 76
     public function scopeType(Builder $query, string | DocumentType $type): Builder
77 77
     {
78
-        return $query->where($this->qualifyColumn('type'), $type);
78
+        return $query->where('type', $type);
79 79
     }
80 80
 
81 81
     public function scopeInvoice(Builder $query): Builder

+ 6
- 1
app/Providers/Filament/CompanyPanelProvider.php Ver arquivo

@@ -284,7 +284,12 @@ class CompanyPanelProvider extends PanelProvider
284 284
             $table
285 285
                 ->paginationPageOptions([5, 10, 25, 50, 100])
286 286
                 ->filtersFormWidth(MaxWidth::Small)
287
-                ->filtersTriggerAction(fn (Tables\Actions\Action $action) => $action->slideOver());
287
+                ->filtersTriggerAction(
288
+                    fn (Tables\Actions\Action $action) => $action
289
+                        ->button()
290
+                        ->label('Filters')
291
+                        ->slideOver()
292
+                );
288 293
         });
289 294
 
290 295
         Tables\Columns\TextColumn::configureUsing(function (Tables\Columns\TextColumn $column): void {

+ 2
- 2
resources/css/filament/company/theme.css Ver arquivo

@@ -66,7 +66,7 @@
66 66
     z-index: -1;
67 67
 }
68 68
 
69
-.fi-ta-table:has(.budget-items-relation-manager) {
69
+.fi-ta-table:has(.is-spreadsheet) {
70 70
     .fi-ta-row {
71 71
         @apply divide-x divide-gray-200 dark:divide-gray-700;
72 72
     }
@@ -102,7 +102,7 @@
102 102
         }
103 103
     }
104 104
 
105
-    .fi-ta-cell:focus-within {
105
+    .fi-ta-cell:has(.fi-ta-text-input):focus-within {
106 106
         outline: 2px solid #2563eb;
107 107
         outline-offset: -2px;
108 108
         z-index: 1;

+ 32
- 0
resources/views/filament/company/resources/purchases/bill-resource/pages/pay-bills.blade.php Ver arquivo

@@ -0,0 +1,32 @@
1
+<x-filament-panels::page
2
+    @class([
3
+        'fi-resource-list-records-page',
4
+        'fi-resource-' . str_replace('/', '-', $this->getResource()::getSlug()),
5
+    ])
6
+>
7
+    <div class="flex flex-col gap-y-6">
8
+        <x-filament::section>
9
+            <div class="flex items-center justify-between">
10
+                <div>
11
+                    {{ $this->form }}
12
+                </div>
13
+                <div class="text-right">
14
+                    <div class="text-xs font-medium text-gray-500 dark:text-gray-400">
15
+                        Total Payment Amount
16
+                    </div>
17
+                    <div class="text-3xl font-bold text-gray-900 dark:text-white tabular-nums">
18
+                        {{ $this->totalPaymentAmount }}
19
+                    </div>
20
+                </div>
21
+            </div>
22
+        </x-filament::section>
23
+
24
+        <x-filament-panels::resources.tabs />
25
+
26
+        {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::RESOURCE_PAGES_LIST_RECORDS_TABLE_BEFORE, scopes: $this->getRenderHookScopes()) }}
27
+
28
+        {{ $this->table }}
29
+
30
+        {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::RESOURCE_PAGES_LIST_RECORDS_TABLE_AFTER, scopes: $this->getRenderHookScopes()) }}
31
+    </div>
32
+</x-filament-panels::page>

+ 30
- 0
resources/views/filament/company/resources/sales/invoice-resource/pages/record-payments.blade.php Ver arquivo

@@ -0,0 +1,30 @@
1
+<x-filament-panels::page
2
+    @class([
3
+        'fi-resource-list-records-page',
4
+        'fi-resource-' . str_replace('/', '-', $this->getResource()::getSlug()),
5
+    ])
6
+>
7
+    <div class="flex flex-col gap-y-6">
8
+        <x-filament::section>
9
+            <div class="flex items-start justify-between">
10
+                <div>
11
+                    {{ $this->form }}
12
+                </div>
13
+                <div class="text-right">
14
+                    <div class="text-xs font-medium text-gray-500 dark:text-gray-400">
15
+                        Total Payment Amount
16
+                    </div>
17
+                    <div class="text-3xl font-bold text-gray-900 dark:text-white tabular-nums">{{ $this->totalPaymentAmount }}</div>
18
+                </div>
19
+            </div>
20
+        </x-filament::section>
21
+
22
+        <x-filament-panels::resources.tabs />
23
+
24
+        {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::RESOURCE_PAGES_LIST_RECORDS_TABLE_BEFORE, scopes: $this->getRenderHookScopes()) }}
25
+
26
+        {{ $this->table }}
27
+
28
+        {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::RESOURCE_PAGES_LIST_RECORDS_TABLE_AFTER, scopes: $this->getRenderHookScopes()) }}
29
+    </div>
30
+</x-filament-panels::page>

resources/views/filament/tables/columns/deferred-text-input-column.blade.php → resources/views/filament/tables/columns/custom-text-input-column.blade.php Ver arquivo

@@ -4,7 +4,8 @@
4 4
     $isDisabled = $isDisabled();
5 5
     $state = $getState();
6 6
     $mask = $getMask();
7
-    $batchMode = $getBatchMode();
7
+    $isDeferred = $isDeferred();
8
+    $isNavigable = $isNavigable();
8 9
 
9 10
     $alignment = $getAlignment() ?? Alignment::Start;
10 11
 
@@ -32,6 +33,42 @@
32 33
         recordKey: @js($recordKey),
33 34
 
34 35
         state: @js($state),
36
+
37
+        navigateToRow(direction) {
38
+            const currentRow = $el.closest('tr');
39
+            const currentCell = $el.closest('td');
40
+            const currentColumnIndex = Array.from(currentRow.children).indexOf(currentCell);
41
+
42
+            const targetRow = direction === 'next'
43
+                ? currentRow.nextElementSibling
44
+                : currentRow.previousElementSibling;
45
+
46
+            if (targetRow && targetRow.children[currentColumnIndex]) {
47
+                const targetInput = targetRow.children[currentColumnIndex].querySelector('input[x-model=\'state\']');
48
+                if (targetInput) {
49
+                    targetInput.focus();
50
+                    targetInput.select();
51
+                }
52
+            }
53
+        },
54
+
55
+        navigateToColumn(direction) {
56
+            const currentCell = $el.closest('td');
57
+            const currentRow = $el.closest('tr');
58
+            const currentColumnIndex = Array.from(currentRow.children).indexOf(currentCell);
59
+
60
+            const targetCell = direction === 'next'
61
+                ? currentRow.children[currentColumnIndex + 1]
62
+                : currentRow.children[currentColumnIndex - 1];
63
+
64
+            if (targetCell) {
65
+                const targetInput = targetCell.querySelector('input[x-model=\'state\']');
66
+                if (targetInput) {
67
+                    targetInput.focus();
68
+                    targetInput.select();
69
+                }
70
+            }
71
+        }
35 72
     }"
36 73
     x-init="
37 74
         () => {
@@ -105,7 +142,7 @@
105 142
                 \Filament\Support\prepare_inherited_attributes(
106 143
                     $getExtraInputAttributeBag()
107 144
                         ->merge([
108
-                            'x-on:change' . ($type === 'number' ? '.debounce.1s' : null) => $batchMode ? '
145
+                            'x-on:change' . ($type === 'number' ? '.debounce.1s' : null) => $isDeferred ? '
109 146
                                 $wire.handleBatchColumnChanged({
110 147
                                     name: name,
111 148
                                     recordKey: recordKey,
@@ -128,7 +165,7 @@
128 165
 
129 166
                                 isLoading = false
130 167
                             ',
131
-                            'x-on:keydown.enter' => $batchMode ? '
168
+                            'x-on:keydown.enter' => $isDeferred ? '
132 169
                                 $wire.handleBatchColumnChanged({
133 170
                                     name: name,
134 171
                                     recordKey: recordKey,
@@ -138,7 +175,11 @@
138 175
                                 $nextTick(() => {
139 176
                                     $wire.saveBatchChanges();
140 177
                                 });
141
-                            ' : null,
178
+                            ' : ($isNavigable ? 'navigateToRow(\'next\')' : null),
179
+                            'x-on:keydown.arrow-down.prevent' => $isNavigable ? 'navigateToRow(\'next\')' : null,
180
+                            'x-on:keydown.arrow-up.prevent' => $isNavigable ? 'navigateToRow(\'prev\')' : null,
181
+                            'x-on:keydown.arrow-left.prevent' => $isNavigable ? 'navigateToColumn(\'prev\')' : null,
182
+                            'x-on:keydown.arrow-right.prevent' => $isNavigable ? 'navigateToColumn(\'next\')' : null,
142 183
                             'x-mask' . ($mask instanceof \Filament\Support\RawJs ? ':dynamic' : '') => filled($mask) ? $mask : null,
143 184
                         ])
144 185
                         ->class([

Carregando…
Cancelar
Salvar