Bladeren bron

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

Development 3.x
3.x
Andrew Wallo 10 maanden geleden
bovenliggende
commit
3cf19e27a5
No account linked to committer's email address
100 gewijzigde bestanden met toevoegingen van 5667 en 879 verwijderingen
  1. 41
    15
      app/Casts/TransactionAmountCast.php
  2. 11
    0
      app/Concerns/RedirectToViewPage.php
  3. 46
    0
      app/Enums/Accounting/AdjustmentStatus.php
  4. 39
    0
      app/Enums/Accounting/BudgetIntervalType.php
  5. 32
    0
      app/Enums/Accounting/BudgetSourceType.php
  6. 37
    0
      app/Enums/Accounting/BudgetStatus.php
  7. 1
    1
      app/Filament/Company/Clusters/Settings/Pages/CompanyDefault.php
  8. 1
    1
      app/Filament/Company/Clusters/Settings/Pages/CompanyProfile.php
  9. 1
    1
      app/Filament/Company/Clusters/Settings/Pages/Localization.php
  10. 250
    8
      app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource.php
  11. 3
    0
      app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource/Pages/CreateAdjustment.php
  12. 3
    0
      app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource/Pages/EditAdjustment.php
  13. 3
    0
      app/Filament/Company/Clusters/Settings/Resources/DocumentDefaultResource/Pages/EditDocumentDefault.php
  14. 141
    24
      app/Filament/Company/Pages/Accounting/AccountChart.php
  15. 4
    29
      app/Filament/Company/Pages/Accounting/Transactions.php
  16. 542
    0
      app/Filament/Company/Resources/Accounting/BudgetResource.php
  17. 634
    0
      app/Filament/Company/Resources/Accounting/BudgetResource/Pages/CreateBudget.php
  18. 19
    0
      app/Filament/Company/Resources/Accounting/BudgetResource/Pages/ListBudgets.php
  19. 43
    0
      app/Filament/Company/Resources/Accounting/BudgetResource/Pages/ViewBudget.php
  20. 299
    0
      app/Filament/Company/Resources/Accounting/BudgetResource/RelationManagers/BudgetItemsRelationManager.php
  21. 3
    5
      app/Filament/Company/Resources/Banking/AccountResource/Pages/CreateAccount.php
  22. 3
    5
      app/Filament/Company/Resources/Banking/AccountResource/Pages/EditAccount.php
  23. 49
    32
      app/Filament/Company/Resources/Common/OfferingResource.php
  24. 66
    26
      app/Filament/Company/Resources/Purchases/BillResource.php
  25. 2
    2
      app/Filament/Company/Resources/Purchases/BillResource/Pages/CreateBill.php
  26. 2
    5
      app/Filament/Company/Resources/Purchases/BillResource/Pages/EditBill.php
  27. 9
    2
      app/Filament/Company/Resources/Purchases/BillResource/Pages/ViewBill.php
  28. 119
    42
      app/Filament/Company/Resources/Purchases/BillResource/RelationManagers/PaymentsRelationManager.php
  29. 43
    23
      app/Filament/Company/Resources/Purchases/BillResource/Widgets/BillOverview.php
  30. 2
    2
      app/Filament/Company/Resources/Purchases/VendorResource/Pages/EditVendor.php
  31. 1
    1
      app/Filament/Company/Resources/Purchases/VendorResource/Pages/ViewVendor.php
  32. 1
    1
      app/Filament/Company/Resources/Sales/ClientResource.php
  33. 2
    0
      app/Filament/Company/Resources/Sales/ClientResource/Pages/CreateClient.php
  34. 2
    2
      app/Filament/Company/Resources/Sales/ClientResource/Pages/EditClient.php
  35. 1
    1
      app/Filament/Company/Resources/Sales/ClientResource/Pages/ViewClient.php
  36. 57
    14
      app/Filament/Company/Resources/Sales/EstimateResource.php
  37. 2
    5
      app/Filament/Company/Resources/Sales/EstimateResource/Pages/EditEstimate.php
  38. 27
    0
      app/Filament/Company/Resources/Sales/EstimateResource/Pages/ViewEstimate.php
  39. 45
    12
      app/Filament/Company/Resources/Sales/EstimateResource/Widgets/EstimateOverview.php
  40. 69
    28
      app/Filament/Company/Resources/Sales/InvoiceResource.php
  41. 2
    2
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/CreateInvoice.php
  42. 2
    7
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/EditInvoice.php
  43. 34
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ViewInvoice.php
  44. 123
    40
      app/Filament/Company/Resources/Sales/InvoiceResource/RelationManagers/PaymentsRelationManager.php
  45. 34
    7
      app/Filament/Company/Resources/Sales/InvoiceResource/Widgets/InvoiceOverview.php
  46. 57
    14
      app/Filament/Company/Resources/Sales/RecurringInvoiceResource.php
  47. 2
    5
      app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/EditRecurringInvoice.php
  48. 27
    1
      app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/ViewRecurringInvoice.php
  49. 167
    0
      app/Filament/Forms/Components/CreateAccountSelect.php
  50. 222
    0
      app/Filament/Forms/Components/CreateAdjustmentSelect.php
  51. 38
    0
      app/Filament/Forms/Components/CustomTableRepeater.php
  52. 50
    0
      app/Filament/Forms/Components/LinearWizard.php
  53. 25
    0
      app/Filament/Tables/Columns/DeferredTextInputColumn.php
  54. 1
    1
      app/Listeners/CreateConnectedAccount.php
  55. 2
    1
      app/Listeners/SyncWithCompanyDefaults.php
  56. 7
    6
      app/Livewire/Company/Service/ConnectedAccount/ListInstitutions.php
  57. 36
    12
      app/Models/Accounting/Account.php
  58. 122
    2
      app/Models/Accounting/Adjustment.php
  59. 8
    2
      app/Models/Accounting/Bill.php
  60. 329
    0
      app/Models/Accounting/Budget.php
  61. 43
    0
      app/Models/Accounting/BudgetAllocation.php
  62. 40
    0
      app/Models/Accounting/BudgetItem.php
  63. 9
    0
      app/Models/Accounting/Document.php
  64. 24
    5
      app/Models/Accounting/Estimate.php
  65. 70
    4
      app/Models/Accounting/Invoice.php
  66. 24
    5
      app/Models/Accounting/RecurringInvoice.php
  67. 2
    0
      app/Models/Accounting/Transaction.php
  68. 7
    0
      app/Models/Common/Offering.php
  69. 15
    0
      app/Models/Company.php
  70. 11
    12
      app/Observers/AccountObserver.php
  71. 45
    1
      app/Observers/AdjustmentObserver.php
  72. 11
    1
      app/Observers/EstimateObserver.php
  73. 32
    0
      app/Observers/TransactionObserver.php
  74. 70
    0
      app/Policies/AdjustmentPolicy.php
  75. 5
    5
      app/Policies/EstimatePolicy.php
  76. 9
    4
      app/Providers/Filament/CompanyPanelProvider.php
  77. 4
    4
      app/Providers/MacroServiceProvider.php
  78. 1
    1
      app/Services/AccountService.php
  79. 32
    9
      app/Services/CompanySettingsService.php
  80. 4
    1
      app/Services/TransactionService.php
  81. 3
    8
      app/Utilities/Currency/CurrencyAccessor.php
  82. 1
    1
      app/Utilities/Currency/CurrencyConverter.php
  83. 4
    2
      app/ValueObjects/Money.php
  84. 2
    2
      composer.json
  85. 450
    289
      composer.lock
  86. 332
    0
      config/debugbar.php
  87. 23
    0
      database/factories/Accounting/BudgetAllocationFactory.php
  88. 23
    0
      database/factories/Accounting/BudgetFactory.php
  89. 23
    0
      database/factories/Accounting/BudgetItemFactory.php
  90. 1
    0
      database/migrations/2024_01_01_234943_create_transactions_table.php
  91. 5
    0
      database/migrations/2024_11_14_230753_create_adjustments_table.php
  92. 43
    0
      database/migrations/2025_03_15_191245_create_budgets_table.php
  93. 32
    0
      database/migrations/2025_03_15_191321_create_budget_items_table.php
  94. 34
    0
      database/migrations/2025_03_15_203454_create_budget_allocations_table.php
  95. 88
    88
      package-lock.json
  96. 0
    4
      resources/css/filament/company/custom-section.css
  97. 141
    31
      resources/css/filament/company/form-fields.css
  98. 12
    10
      resources/css/filament/company/modal.css
  99. 49
    5
      resources/css/filament/company/theme.css
  100. 0
    0
      resources/data/lang/en.json

+ 41
- 15
app/Casts/TransactionAmountCast.php Bestand weergeven

@@ -11,12 +11,42 @@ use UnexpectedValueException;
11 11
 
12 12
 class TransactionAmountCast implements CastsAttributes
13 13
 {
14
-    private array $currencyCache = [];
14
+    /**
15
+     * Static cache to persist across instances
16
+     */
17
+    private static array $currencyCache = [];
18
+
19
+    /**
20
+     * Eagerly load all required bank accounts at once if needed
21
+     */
22
+    private function loadMissingBankAccounts(array $ids): void
23
+    {
24
+        $missingIds = array_filter($ids, static fn ($id) => ! isset(self::$currencyCache[$id]) && $id !== null);
25
+
26
+        if (empty($missingIds)) {
27
+            return;
28
+        }
29
+
30
+        /** @var BankAccount[] $accounts */
31
+        $accounts = BankAccount::with('account')
32
+            ->whereIn('id', $missingIds)
33
+            ->get();
34
+
35
+        foreach ($accounts as $account) {
36
+            self::$currencyCache[$account->id] = $account->account->currency_code ?? CurrencyAccessor::getDefaultCurrency();
37
+        }
38
+    }
15 39
 
16 40
     public function get(Model $model, string $key, mixed $value, array $attributes): string
17 41
     {
18 42
         // Attempt to retrieve the currency code from the related bankAccount->account model
19
-        $currencyCode = $this->getCurrencyCodeFromBankAccountId($attributes['bank_account_id'] ?? null);
43
+        $bankAccountId = $attributes['bank_account_id'] ?? null;
44
+
45
+        if ($bankAccountId !== null && ! isset(self::$currencyCache[$bankAccountId])) {
46
+            $this->loadMissingBankAccounts([$bankAccountId]);
47
+        }
48
+
49
+        $currencyCode = $this->getCurrencyCodeFromBankAccountId($bankAccountId);
20 50
 
21 51
         if ($value !== null) {
22 52
             return CurrencyConverter::prepareForMutator($value, $currencyCode);
@@ -30,7 +60,13 @@ class TransactionAmountCast implements CastsAttributes
30 60
      */
31 61
     public function set(Model $model, string $key, mixed $value, array $attributes): int
32 62
     {
33
-        $currencyCode = $this->getCurrencyCodeFromBankAccountId($attributes['bank_account_id'] ?? null);
63
+        $bankAccountId = $attributes['bank_account_id'] ?? null;
64
+
65
+        if ($bankAccountId !== null && ! isset(self::$currencyCache[$bankAccountId])) {
66
+            $this->loadMissingBankAccounts([$bankAccountId]);
67
+        }
68
+
69
+        $currencyCode = $this->getCurrencyCodeFromBankAccountId($bankAccountId);
34 70
 
35 71
         if (is_numeric($value)) {
36 72
             $value = (string) $value;
@@ -42,8 +78,7 @@ class TransactionAmountCast implements CastsAttributes
42 78
     }
43 79
 
44 80
     /**
45
-     * Using this is necessary because the relationship is not always loaded into memory when the cast is called
46
-     * Instead of using: $model->bankAccount->account->currency_code directly, find the bank account and get the currency code
81
+     * Get currency code from the cache or use default
47 82
      */
48 83
     private function getCurrencyCodeFromBankAccountId(?int $bankAccountId): string
49 84
     {
@@ -51,15 +86,6 @@ class TransactionAmountCast implements CastsAttributes
51 86
             return CurrencyAccessor::getDefaultCurrency();
52 87
         }
53 88
 
54
-        if (isset($this->currencyCache[$bankAccountId])) {
55
-            return $this->currencyCache[$bankAccountId];
56
-        }
57
-
58
-        $bankAccount = BankAccount::find($bankAccountId);
59
-
60
-        $currencyCode = $bankAccount?->account?->currency_code ?? CurrencyAccessor::getDefaultCurrency();
61
-        $this->currencyCache[$bankAccountId] = $currencyCode;
62
-
63
-        return $currencyCode;
89
+        return self::$currencyCache[$bankAccountId] ?? CurrencyAccessor::getDefaultCurrency();
64 90
     }
65 91
 }

+ 11
- 0
app/Concerns/RedirectToViewPage.php Bestand weergeven

@@ -0,0 +1,11 @@
1
+<?php
2
+
3
+namespace App\Concerns;
4
+
5
+trait RedirectToViewPage
6
+{
7
+    protected function getRedirectUrl(): string
8
+    {
9
+        return $this->getResource()::getUrl('view', ['record' => $this->record]);
10
+    }
11
+}

+ 46
- 0
app/Enums/Accounting/AdjustmentStatus.php Bestand weergeven

@@ -0,0 +1,46 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use Filament\Support\Contracts\HasColor;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum AdjustmentStatus: string implements HasColor, HasLabel
9
+{
10
+    case Active = 'active';
11
+    case Upcoming = 'upcoming';
12
+    case Expired = 'expired';
13
+    case Paused = 'paused';
14
+    case Archived = 'archived';
15
+
16
+    public function getLabel(): ?string
17
+    {
18
+        return $this->name;
19
+    }
20
+
21
+    public function getColor(): string | array | null
22
+    {
23
+        return match ($this) {
24
+            self::Active => 'primary',
25
+            self::Upcoming, self::Paused => 'warning',
26
+            self::Expired => 'danger',
27
+            self::Archived => 'gray',
28
+        };
29
+    }
30
+
31
+    /**
32
+     * Check if the status is set manually (not calculated from dates)
33
+     */
34
+    public function isManualStatus(): bool
35
+    {
36
+        return in_array($this, [self::Paused, self::Archived]);
37
+    }
38
+
39
+    /**
40
+     * Check if the status is system-calculated based on dates
41
+     */
42
+    public function isSystemStatus(): bool
43
+    {
44
+        return in_array($this, [self::Active, self::Upcoming, self::Expired]);
45
+    }
46
+}

+ 39
- 0
app/Enums/Accounting/BudgetIntervalType.php Bestand weergeven

@@ -0,0 +1,39 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum BudgetIntervalType: string implements HasLabel
9
+{
10
+    use ParsesEnum;
11
+
12
+    case Month = 'month';
13
+    case Quarter = 'quarter';
14
+    case Year = 'year';
15
+
16
+    public function getLabel(): ?string
17
+    {
18
+        return match ($this) {
19
+            self::Month => 'Monthly',
20
+            self::Quarter => 'Quarterly',
21
+            self::Year => 'Yearly',
22
+        };
23
+    }
24
+
25
+    public function isMonth(): bool
26
+    {
27
+        return $this === self::Month;
28
+    }
29
+
30
+    public function isQuarter(): bool
31
+    {
32
+        return $this === self::Quarter;
33
+    }
34
+
35
+    public function isYear(): bool
36
+    {
37
+        return $this === self::Year;
38
+    }
39
+}

+ 32
- 0
app/Enums/Accounting/BudgetSourceType.php Bestand weergeven

@@ -0,0 +1,32 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum BudgetSourceType: string implements HasLabel
9
+{
10
+    use ParsesEnum;
11
+
12
+    case Budget = 'budget';
13
+    case Actuals = 'actuals';
14
+
15
+    public function getLabel(): string
16
+    {
17
+        return match ($this) {
18
+            self::Budget => 'Copy from a previous budget',
19
+            self::Actuals => 'Use historical actuals',
20
+        };
21
+    }
22
+
23
+    public function isBudget(): bool
24
+    {
25
+        return $this === self::Budget;
26
+    }
27
+
28
+    public function isActuals(): bool
29
+    {
30
+        return $this === self::Actuals;
31
+    }
32
+}

+ 37
- 0
app/Enums/Accounting/BudgetStatus.php Bestand weergeven

@@ -0,0 +1,37 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use Filament\Support\Contracts\HasColor;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum BudgetStatus: string implements HasColor, HasLabel
9
+{
10
+    case Draft = 'draft';
11
+    case Active = 'active';
12
+    case Closed = 'closed';
13
+
14
+    public function getLabel(): ?string
15
+    {
16
+        return $this->name;
17
+    }
18
+
19
+    public function getColor(): string | array | null
20
+    {
21
+        return match ($this) {
22
+            self::Draft => 'gray',
23
+            self::Active => 'success',
24
+            self::Closed => 'warning',
25
+        };
26
+    }
27
+
28
+    public function isEditable(): bool
29
+    {
30
+        return in_array($this, [self::Draft, self::Active]);
31
+    }
32
+
33
+    public static function editableStatuses(): array
34
+    {
35
+        return [self::Draft, self::Active];
36
+    }
37
+}

+ 1
- 1
app/Filament/Company/Clusters/Settings/Pages/CompanyDefault.php Bestand weergeven

@@ -65,7 +65,7 @@ class CompanyDefault extends Page
65 65
     public function mount(): void
66 66
     {
67 67
         $this->record = CompanyDefaultModel::firstOrNew([
68
-            'company_id' => auth()->user()->currentCompany->id,
68
+            'company_id' => auth()->user()->current_company_id,
69 69
         ]);
70 70
 
71 71
         abort_unless(static::canView($this->record), 404);

+ 1
- 1
app/Filament/Company/Clusters/Settings/Pages/CompanyProfile.php Bestand weergeven

@@ -68,7 +68,7 @@ class CompanyProfile extends Page
68 68
     public function mount(): void
69 69
     {
70 70
         $this->record = CompanyProfileModel::firstOrNew([
71
-            'company_id' => auth()->user()->currentCompany->id,
71
+            'company_id' => auth()->user()->current_company_id,
72 72
         ]);
73 73
 
74 74
         abort_unless(static::canView($this->record), 404);

+ 1
- 1
app/Filament/Company/Clusters/Settings/Pages/Localization.php Bestand weergeven

@@ -69,7 +69,7 @@ class Localization extends Page
69 69
     public function mount(): void
70 70
     {
71 71
         $this->record = LocalizationModel::firstOrNew([
72
-            'company_id' => auth()->user()->currentCompany->id,
72
+            'company_id' => auth()->user()->current_company_id,
73 73
         ]);
74 74
 
75 75
         abort_unless(static::canView($this->record), 404);

+ 250
- 8
app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource.php Bestand weergeven

@@ -5,16 +5,20 @@ namespace App\Filament\Company\Clusters\Settings\Resources;
5 5
 use App\Enums\Accounting\AdjustmentCategory;
6 6
 use App\Enums\Accounting\AdjustmentComputation;
7 7
 use App\Enums\Accounting\AdjustmentScope;
8
+use App\Enums\Accounting\AdjustmentStatus;
8 9
 use App\Enums\Accounting\AdjustmentType;
9 10
 use App\Filament\Company\Clusters\Settings;
10 11
 use App\Filament\Company\Clusters\Settings\Resources\AdjustmentResource\Pages;
11 12
 use App\Models\Accounting\Adjustment;
12 13
 use Filament\Forms;
13 14
 use Filament\Forms\Form;
15
+use Filament\Notifications\Notification;
14 16
 use Filament\Resources\Resource;
15 17
 use Filament\Tables;
18
+use Filament\Tables\Filters\Indicator;
16 19
 use Filament\Tables\Table;
17
-use Wallo\FilamentSelectify\Components\ToggleButton;
20
+use Illuminate\Database\Eloquent\Builder;
21
+use Illuminate\Database\Eloquent\Collection;
18 22
 
19 23
 class AdjustmentResource extends Resource
20 24
 {
@@ -33,8 +37,7 @@ class AdjustmentResource extends Resource
33 37
                             ->required()
34 38
                             ->maxLength(255),
35 39
                         Forms\Components\Textarea::make('description')
36
-                            ->label('Description')
37
-                            ->autosize(),
40
+                            ->label('Description'),
38 41
                     ]),
39 42
                 Forms\Components\Section::make('Configuration')
40 43
                     ->schema([
@@ -50,9 +53,10 @@ class AdjustmentResource extends Resource
50 53
                             ->default(AdjustmentType::Sales)
51 54
                             ->live()
52 55
                             ->required(),
53
-                        ToggleButton::make('recoverable')
56
+                        Forms\Components\Checkbox::make('recoverable')
54 57
                             ->label('Recoverable')
55 58
                             ->default(false)
59
+                            ->helperText('When enabled, tax is tracked separately as claimable from the government. Non-recoverable taxes are treated as part of the expense.')
56 60
                             ->visible(fn (Forms\Get $get) => AdjustmentCategory::parse($get('category'))->isTax() && AdjustmentType::parse($get('type'))->isPurchase()),
57 61
                     ])
58 62
                     ->columns()
@@ -77,7 +81,8 @@ class AdjustmentResource extends Resource
77 81
                 Forms\Components\Section::make('Dates')
78 82
                     ->schema([
79 83
                         Forms\Components\DateTimePicker::make('start_date'),
80
-                        Forms\Components\DateTimePicker::make('end_date'),
84
+                        Forms\Components\DateTimePicker::make('end_date')
85
+                            ->after('start_date'),
81 86
                     ])
82 87
                     ->columns()
83 88
                     ->visible(fn (Forms\Get $get) => AdjustmentCategory::parse($get('category'))->isDiscount()),
@@ -91,6 +96,8 @@ class AdjustmentResource extends Resource
91 96
                 Tables\Columns\TextColumn::make('name')
92 97
                     ->label('Name')
93 98
                     ->sortable(),
99
+                Tables\Columns\TextColumn::make('status')
100
+                    ->badge(),
94 101
                 Tables\Columns\TextColumn::make('category')
95 102
                     ->searchable(),
96 103
                 Tables\Columns\TextColumn::make('type')
@@ -100,15 +107,250 @@ class AdjustmentResource extends Resource
100 107
                     ->rate(static fn (Adjustment $record) => $record->computation->value)
101 108
                     ->searchable()
102 109
                     ->sortable(),
110
+                Tables\Columns\TextColumn::make('paused_until')
111
+                    ->label('Auto-Resume Date')
112
+                    ->dateTime()
113
+                    ->sortable()
114
+                    ->toggleable(isToggledHiddenByDefault: true),
115
+                Tables\Columns\TextColumn::make('start_date')
116
+                    ->dateTime()
117
+                    ->sortable()
118
+                    ->toggleable(isToggledHiddenByDefault: true),
119
+                Tables\Columns\TextColumn::make('end_date')
120
+                    ->dateTime()
121
+                    ->sortable()
122
+                    ->toggleable(isToggledHiddenByDefault: true),
103 123
             ])
104 124
             ->filters([
105
-                //
125
+                Tables\Filters\SelectFilter::make('status')
126
+                    ->label('Status')
127
+                    ->native(false)
128
+                    ->default('unarchived')
129
+                    ->options(
130
+                        collect(AdjustmentStatus::cases())
131
+                            ->mapWithKeys(fn (AdjustmentStatus $status) => [$status->value => $status->getLabel()])
132
+                            ->merge([
133
+                                'unarchived' => 'Unarchived',
134
+                            ])
135
+                            ->toArray()
136
+                    )
137
+                    ->indicateUsing(function (Tables\Filters\SelectFilter $filter, array $state) {
138
+                        if (blank($state['value'] ?? null)) {
139
+                            return [];
140
+                        }
141
+
142
+                        $label = collect($filter->getOptions())
143
+                            ->mapWithKeys(fn (string | array $label, string $value): array => is_array($label) ? $label : [$value => $label])
144
+                            ->get($state['value']);
145
+
146
+                        if (blank($label)) {
147
+                            return [];
148
+                        }
149
+
150
+                        $indicator = $filter->getIndicator();
151
+
152
+                        if (! $indicator instanceof Indicator) {
153
+                            if ($state['value'] === 'unarchived') {
154
+                                $indicator = $label;
155
+                            } else {
156
+                                $indicator = Indicator::make("{$indicator}: {$label}");
157
+                            }
158
+                        }
159
+
160
+                        return [$indicator];
161
+                    })
162
+                    ->query(function (Builder $query, array $data): Builder {
163
+                        if (blank($data['value'] ?? null)) {
164
+                            return $query;
165
+                        }
166
+
167
+                        if ($data['value'] !== 'unarchived') {
168
+                            return $query->where('status', $data['value']);
169
+                        } else {
170
+                            return $query->where('status', '!=', AdjustmentStatus::Archived->value);
171
+                        }
172
+                    }),
173
+                Tables\Filters\SelectFilter::make('category')
174
+                    ->label('Category')
175
+                    ->native(false)
176
+                    ->options(AdjustmentCategory::class),
177
+                Tables\Filters\SelectFilter::make('type')
178
+                    ->label('Type')
179
+                    ->native(false)
180
+                    ->options(AdjustmentType::class),
181
+                Tables\Filters\SelectFilter::make('computation')
182
+                    ->label('Computation')
183
+                    ->native(false)
184
+                    ->options(AdjustmentComputation::class),
106 185
             ])
107 186
             ->actions([
108
-                Tables\Actions\EditAction::make(),
187
+                Tables\Actions\ActionGroup::make([
188
+                    Tables\Actions\EditAction::make(),
189
+                    Tables\Actions\Action::make('pause')
190
+                        ->label('Pause')
191
+                        ->icon('heroicon-m-pause')
192
+                        ->form([
193
+                            Forms\Components\DateTimePicker::make('paused_until')
194
+                                ->label('Auto-resume date')
195
+                                ->helperText('When should this adjustment automatically resume? Leave empty to keep paused indefinitely.')
196
+                                ->after('now'),
197
+                            Forms\Components\Textarea::make('status_reason')
198
+                                ->label('Reason for pausing')
199
+                                ->maxLength(255),
200
+                        ])
201
+                        ->databaseTransaction()
202
+                        ->successNotificationTitle('Adjustment paused')
203
+                        ->failureNotificationTitle('Failed to pause adjustment')
204
+                        ->visible(fn (Adjustment $record) => $record->canBePaused())
205
+                        ->action(function (Adjustment $record, array $data, Tables\Actions\Action $action) {
206
+                            $pausedUntil = $data['paused_until'] ?? null;
207
+                            $reason = $data['status_reason'] ?? null;
208
+                            $record->pause($reason, $pausedUntil);
209
+
210
+                            $action->success();
211
+                        }),
212
+                    Tables\Actions\Action::make('resume')
213
+                        ->label('Resume')
214
+                        ->icon('heroicon-m-play')
215
+                        ->requiresConfirmation()
216
+                        ->databaseTransaction()
217
+                        ->successNotificationTitle('Adjustment resumed')
218
+                        ->failureNotificationTitle('Failed to resume adjustment')
219
+                        ->visible(fn (Adjustment $record) => $record->canBeResumed())
220
+                        ->action(function (Adjustment $record, Tables\Actions\Action $action) {
221
+                            $record->resume();
222
+
223
+                            $action->success();
224
+                        }),
225
+                    Tables\Actions\Action::make('archive')
226
+                        ->label('Archive')
227
+                        ->icon('heroicon-m-archive-box')
228
+                        ->color('danger')
229
+                        ->form([
230
+                            Forms\Components\Textarea::make('status_reason')
231
+                                ->label('Reason for archiving')
232
+                                ->maxLength(255),
233
+                        ])
234
+                        ->databaseTransaction()
235
+                        ->successNotificationTitle('Adjustment archived')
236
+                        ->failureNotificationTitle('Failed to archive adjustment')
237
+                        ->visible(fn (Adjustment $record) => $record->canBeArchived())
238
+                        ->action(function (Adjustment $record, array $data, Tables\Actions\Action $action) {
239
+                            $reason = $data['status_reason'] ?? null;
240
+                            $record->archive($reason);
241
+
242
+                            $action->success();
243
+                        }),
244
+                ]),
109 245
             ])
110 246
             ->bulkActions([
111
-                //
247
+                Tables\Actions\BulkActionGroup::make([
248
+                    Tables\Actions\BulkAction::make('pause')
249
+                        ->label('Pause')
250
+                        ->icon('heroicon-m-pause')
251
+                        ->form([
252
+                            Forms\Components\DateTimePicker::make('paused_until')
253
+                                ->label('Auto-resume date')
254
+                                ->helperText('When should these adjustments automatically resume? Leave empty to keep paused indefinitely.')
255
+                                ->after('now'),
256
+                            Forms\Components\Textarea::make('status_reason')
257
+                                ->label('Reason for pausing')
258
+                                ->maxLength(255),
259
+                        ])
260
+                        ->databaseTransaction()
261
+                        ->successNotificationTitle('Adjustments paused')
262
+                        ->failureNotificationTitle('Failed to pause adjustments')
263
+                        ->beforeFormFilled(function (Collection $records, Tables\Actions\BulkAction $action) {
264
+                            $isInvalid = $records->contains(fn (Adjustment $record) => ! $record->canBePaused());
265
+
266
+                            if ($isInvalid) {
267
+                                Notification::make()
268
+                                    ->title('Pause failed')
269
+                                    ->body('Only adjustments that are currently active can be paused. Please adjust your selection and try again.')
270
+                                    ->persistent()
271
+                                    ->danger()
272
+                                    ->send();
273
+
274
+                                $action->cancel(true);
275
+                            }
276
+                        })
277
+                        ->deselectRecordsAfterCompletion()
278
+                        ->action(function (Collection $records, array $data, Tables\Actions\BulkAction $action) {
279
+                            $pausedUntil = $data['paused_until'] ?? null;
280
+                            $reason = $data['status_reason'] ?? null;
281
+
282
+                            $records->each(function (Adjustment $record) use ($reason, $pausedUntil) {
283
+                                $record->pause($reason, $pausedUntil);
284
+                            });
285
+
286
+                            $action->success();
287
+                        }),
288
+                    Tables\Actions\BulkAction::make('resume')
289
+                        ->label('Resume')
290
+                        ->icon('heroicon-m-play')
291
+                        ->databaseTransaction()
292
+                        ->requiresConfirmation()
293
+                        ->successNotificationTitle('Adjustments resumed')
294
+                        ->failureNotificationTitle('Failed to resume adjustments')
295
+                        ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
296
+                            $isInvalid = $records->contains(fn (Adjustment $record) => ! $record->canBeResumed());
297
+
298
+                            if ($isInvalid) {
299
+                                Notification::make()
300
+                                    ->title('Resume failed')
301
+                                    ->body('Only adjustments that are currently paused can be resumed. Please adjust your selection and try again.')
302
+                                    ->persistent()
303
+                                    ->danger()
304
+                                    ->send();
305
+
306
+                                $action->cancel(true);
307
+                            }
308
+                        })
309
+                        ->deselectRecordsAfterCompletion()
310
+                        ->action(function (Collection $records, Tables\Actions\BulkAction $action) {
311
+                            $records->each(function (Adjustment $record) {
312
+                                $record->resume();
313
+                            });
314
+
315
+                            $action->success();
316
+                        }),
317
+                    Tables\Actions\BulkAction::make('archive')
318
+                        ->label('Archive')
319
+                        ->icon('heroicon-m-archive-box')
320
+                        ->color('danger')
321
+                        ->form([
322
+                            Forms\Components\Textarea::make('status_reason')
323
+                                ->label('Reason for archiving')
324
+                                ->maxLength(255),
325
+                        ])
326
+                        ->databaseTransaction()
327
+                        ->successNotificationTitle('Adjustments archived')
328
+                        ->failureNotificationTitle('Failed to archive adjustments')
329
+                        ->beforeFormFilled(function (Collection $records, Tables\Actions\BulkAction $action) {
330
+                            $isInvalid = $records->contains(fn (Adjustment $record) => ! $record->canBeArchived());
331
+
332
+                            if ($isInvalid) {
333
+                                Notification::make()
334
+                                    ->title('Archive failed')
335
+                                    ->body('Only adjustments that are currently active or paused can be archived. Please adjust your selection and try again.')
336
+                                    ->persistent()
337
+                                    ->danger()
338
+                                    ->send();
339
+
340
+                                $action->cancel(true);
341
+                            }
342
+                        })
343
+                        ->deselectRecordsAfterCompletion()
344
+                        ->action(function (Collection $records, array $data, Tables\Actions\BulkAction $action) {
345
+                            $reason = $data['status_reason'] ?? null;
346
+
347
+                            $records->each(function (Adjustment $record) use ($reason) {
348
+                                $record->archive($reason);
349
+                            });
350
+
351
+                            $action->success();
352
+                        }),
353
+                ]),
112 354
             ]);
113 355
     }
114 356
 

+ 3
- 0
app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource/Pages/CreateAdjustment.php Bestand weergeven

@@ -2,10 +2,13 @@
2 2
 
3 3
 namespace App\Filament\Company\Clusters\Settings\Resources\AdjustmentResource\Pages;
4 4
 
5
+use App\Concerns\RedirectToListPage;
5 6
 use App\Filament\Company\Clusters\Settings\Resources\AdjustmentResource;
6 7
 use Filament\Resources\Pages\CreateRecord;
7 8
 
8 9
 class CreateAdjustment extends CreateRecord
9 10
 {
11
+    use RedirectToListPage;
12
+
10 13
     protected static string $resource = AdjustmentResource::class;
11 14
 }

+ 3
- 0
app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource/Pages/EditAdjustment.php Bestand weergeven

@@ -2,11 +2,14 @@
2 2
 
3 3
 namespace App\Filament\Company\Clusters\Settings\Resources\AdjustmentResource\Pages;
4 4
 
5
+use App\Concerns\RedirectToListPage;
5 6
 use App\Filament\Company\Clusters\Settings\Resources\AdjustmentResource;
6 7
 use Filament\Resources\Pages\EditRecord;
7 8
 
8 9
 class EditAdjustment extends EditRecord
9 10
 {
11
+    use RedirectToListPage;
12
+
10 13
     protected static string $resource = AdjustmentResource::class;
11 14
 
12 15
     protected function getHeaderActions(): array

+ 3
- 0
app/Filament/Company/Clusters/Settings/Resources/DocumentDefaultResource/Pages/EditDocumentDefault.php Bestand weergeven

@@ -2,12 +2,15 @@
2 2
 
3 3
 namespace App\Filament\Company\Clusters\Settings\Resources\DocumentDefaultResource\Pages;
4 4
 
5
+use App\Concerns\RedirectToListPage;
5 6
 use App\Filament\Company\Clusters\Settings\Resources\DocumentDefaultResource;
6 7
 use Filament\Resources\Pages\EditRecord;
7 8
 use Illuminate\Contracts\Support\Htmlable;
8 9
 
9 10
 class EditDocumentDefault extends EditRecord
10 11
 {
12
+    use RedirectToListPage;
13
+
11 14
     protected static string $resource = DocumentDefaultResource::class;
12 15
 
13 16
     public function getRecordTitle(): string | Htmlable

+ 141
- 24
app/Filament/Company/Pages/Accounting/AccountChart.php Bestand weergeven

@@ -3,15 +3,17 @@
3 3
 namespace App\Filament\Company\Pages\Accounting;
4 4
 
5 5
 use App\Enums\Accounting\AccountCategory;
6
+use App\Enums\Banking\BankAccountType;
7
+use App\Filament\Forms\Components\CreateCurrencySelect;
6 8
 use App\Models\Accounting\Account;
7 9
 use App\Models\Accounting\AccountSubtype;
8 10
 use App\Utilities\Accounting\AccountCode;
9
-use App\Utilities\Currency\CurrencyAccessor;
10 11
 use Filament\Actions\Action;
11 12
 use Filament\Actions\CreateAction;
12 13
 use Filament\Actions\EditAction;
13 14
 use Filament\Forms\Components\Checkbox;
14 15
 use Filament\Forms\Components\Component;
16
+use Filament\Forms\Components\Group;
15 17
 use Filament\Forms\Components\Select;
16 18
 use Filament\Forms\Components\Textarea;
17 19
 use Filament\Forms\Components\TextInput;
@@ -21,6 +23,8 @@ use Filament\Forms\Set;
21 23
 use Filament\Pages\Page;
22 24
 use Filament\Support\Enums\MaxWidth;
23 25
 use Illuminate\Support\Collection;
26
+use Illuminate\Support\Facades\Auth;
27
+use Illuminate\Validation\Rules\Unique;
24 28
 use Livewire\Attributes\Computed;
25 29
 use Livewire\Attributes\Url;
26 30
 
@@ -39,6 +43,7 @@ class AccountChart extends Page
39 43
     {
40 44
         $action
41 45
             ->modal()
46
+            ->slideOver()
42 47
             ->modalWidth(MaxWidth::TwoExtraLarge);
43 48
     }
44 49
 
@@ -46,8 +51,9 @@ class AccountChart extends Page
46 51
     public function categories(): Collection
47 52
     {
48 53
         return AccountSubtype::withCount('accounts')
49
-            ->with('accounts')
50
-            ->with('accounts.adjustment')
54
+            ->with(['accounts' => function ($query) {
55
+                $query->withLastTransactionDate()->with('adjustment');
56
+            }])
51 57
             ->get()
52 58
             ->groupBy('category');
53 59
     }
@@ -94,6 +100,7 @@ class AccountChart extends Page
94 100
                 $this->getTypeFormComponent($useActiveTab),
95 101
                 $this->getCodeFormComponent(),
96 102
                 $this->getNameFormComponent(),
103
+                ...$this->getBankAccountFormComponents(),
97 104
                 $this->getCurrencyFormComponent(),
98 105
                 $this->getDescriptionFormComponent(),
99 106
                 $this->getArchiveFormComponent(),
@@ -106,15 +113,18 @@ class AccountChart extends Page
106 113
             ->label('Type')
107 114
             ->required()
108 115
             ->live()
109
-            ->disabled(static function (string $operation): bool {
110
-                return $operation === 'edit';
111
-            })
116
+            ->disabledOn('edit')
117
+            ->searchable()
112 118
             ->options($this->getChartSubtypeOptions($useActiveTab))
113 119
             ->afterStateUpdated(static function (?string $state, Set $set): void {
114 120
                 if ($state) {
115 121
                     $accountSubtype = AccountSubtype::find($state);
116 122
                     $generatedCode = AccountCode::generate($accountSubtype);
117 123
                     $set('code', $generatedCode);
124
+
125
+                    $set('is_bank_account', false);
126
+                    $set('bankAccount.type', null);
127
+                    $set('bankAccount.number', null);
118 128
                 }
119 129
             });
120 130
     }
@@ -124,11 +134,124 @@ class AccountChart extends Page
124 134
         return TextInput::make('code')
125 135
             ->label('Code')
126 136
             ->required()
137
+            ->hiddenOn('edit')
127 138
             ->validationAttribute('account code')
128 139
             ->unique(table: Account::class, column: 'code', ignoreRecord: true)
129 140
             ->validateAccountCode(static fn (Get $get) => $get('subtype_id'));
130 141
     }
131 142
 
143
+    protected function getBankAccountFormComponents(): array
144
+    {
145
+        return [
146
+            Checkbox::make('is_bank_account')
147
+                ->live()
148
+                ->visible(function (Get $get, string $operation) {
149
+                    if ($operation === 'edit') {
150
+                        return false;
151
+                    }
152
+
153
+                    $subtype = $get('subtype_id');
154
+                    if (empty($subtype)) {
155
+                        return false;
156
+                    }
157
+
158
+                    $accountSubtype = AccountSubtype::find($subtype);
159
+
160
+                    if (! $accountSubtype) {
161
+                        return false;
162
+                    }
163
+
164
+                    return in_array($accountSubtype->category, [
165
+                        AccountCategory::Asset,
166
+                        AccountCategory::Liability,
167
+                    ]) && $accountSubtype->multi_currency;
168
+                })
169
+                ->afterStateUpdated(static function ($state, Get $get, Set $set) {
170
+                    if ($state) {
171
+                        $subtypeId = $get('subtype_id');
172
+
173
+                        if (empty($subtypeId)) {
174
+                            return;
175
+                        }
176
+
177
+                        $subtype = AccountSubtype::find($subtypeId);
178
+
179
+                        if (! $subtype) {
180
+                            return;
181
+                        }
182
+
183
+                        // Set default bank account type based on account category
184
+                        if ($subtype->category === AccountCategory::Asset) {
185
+                            $set('bankAccount.type', BankAccountType::Depository->value);
186
+                        } elseif ($subtype->category === AccountCategory::Liability) {
187
+                            $set('bankAccount.type', BankAccountType::Credit->value);
188
+                        }
189
+                    } else {
190
+                        // Clear bank account fields
191
+                        $set('bankAccount.type', null);
192
+                        $set('bankAccount.number', null);
193
+                    }
194
+                }),
195
+            Group::make()
196
+                ->relationship('bankAccount')
197
+                ->schema([
198
+                    Select::make('type')
199
+                        ->label('Bank account type')
200
+                        ->options(function (Get $get) {
201
+                            $subtype = $get('../subtype_id');
202
+
203
+                            if (empty($subtype)) {
204
+                                return [];
205
+                            }
206
+
207
+                            $accountSubtype = AccountSubtype::find($subtype);
208
+
209
+                            if (! $accountSubtype) {
210
+                                return [];
211
+                            }
212
+
213
+                            if ($accountSubtype->category === AccountCategory::Asset) {
214
+                                return [
215
+                                    BankAccountType::Depository->value => BankAccountType::Depository->getLabel(),
216
+                                    BankAccountType::Investment->value => BankAccountType::Investment->getLabel(),
217
+                                ];
218
+                            } elseif ($accountSubtype->category === AccountCategory::Liability) {
219
+                                return [
220
+                                    BankAccountType::Credit->value => BankAccountType::Credit->getLabel(),
221
+                                    BankAccountType::Loan->value => BankAccountType::Loan->getLabel(),
222
+                                ];
223
+                            }
224
+
225
+                            return [];
226
+                        })
227
+                        ->searchable()
228
+                        ->columnSpan(1)
229
+                        ->disabledOn('edit')
230
+                        ->required(),
231
+                    TextInput::make('number')
232
+                        ->label('Bank account number')
233
+                        ->unique(ignoreRecord: true, modifyRuleUsing: static function (Unique $rule, $state) {
234
+                            $companyId = Auth::user()->currentCompany->id;
235
+
236
+                            return $rule->where('company_id', $companyId)->where('number', $state);
237
+                        })
238
+                        ->maxLength(20)
239
+                        ->validationAttribute('account number'),
240
+                ])
241
+                ->visible(static function (Get $get, ?Account $record, string $operation) {
242
+                    if ($operation === 'create') {
243
+                        return (bool) $get('is_bank_account');
244
+                    }
245
+
246
+                    if ($operation === 'edit' && $record) {
247
+                        return (bool) $record->bankAccount;
248
+                    }
249
+
250
+                    return false;
251
+                }),
252
+        ];
253
+    }
254
+
132 255
     protected function getNameFormComponent(): Component
133 256
     {
134 257
         return TextInput::make('name')
@@ -136,38 +259,32 @@ class AccountChart extends Page
136 259
             ->required();
137 260
     }
138 261
 
139
-    protected function getCurrencyFormComponent()
262
+    protected function getCurrencyFormComponent(): Component
140 263
     {
141
-        return Select::make('currency_code')
142
-            ->localizeLabel('Currency')
143
-            ->relationship('currency', 'name')
144
-            ->default(CurrencyAccessor::getDefaultCurrency())
145
-            ->preload()
146
-            ->searchable()
147
-            ->disabled(static function (string $operation): bool {
148
-                return $operation === 'edit';
149
-            })
264
+        return CreateCurrencySelect::make('currency_code')
265
+            ->disabledOn('edit')
266
+            ->required(false)
267
+            ->requiredIfAccepted('is_bank_account')
268
+            ->validationMessages([
269
+                'required_if_accepted' => 'The currency is required for bank accounts.',
270
+            ])
150 271
             ->visible(function (Get $get): bool {
151 272
                 return filled($get('subtype_id')) && AccountSubtype::find($get('subtype_id'))->multi_currency;
152
-            })
153
-            ->live();
273
+            });
154 274
     }
155 275
 
156 276
     protected function getDescriptionFormComponent(): Component
157 277
     {
158 278
         return Textarea::make('description')
159
-            ->label('Description')
160
-            ->autosize();
279
+            ->label('Description');
161 280
     }
162 281
 
163 282
     protected function getArchiveFormComponent(): Component
164 283
     {
165 284
         return Checkbox::make('archived')
166 285
             ->label('Archive account')
167
-            ->helperText('Archived accounts will not be available for selection in transactions.')
168
-            ->hidden(static function (string $operation): bool {
169
-                return $operation === 'create';
170
-            });
286
+            ->helperText('Archived accounts will not be available for selection in transactions, offerings, or other new records.')
287
+            ->hiddenOn('create');
171 288
     }
172 289
 
173 290
     private function getChartSubtypeOptions($useActiveTab = true): array

+ 4
- 29
app/Filament/Company/Pages/Accounting/Transactions.php Bestand weergeven

@@ -36,7 +36,6 @@ use Filament\Forms\Get;
36 36
 use Filament\Forms\Set;
37 37
 use Filament\Pages\Page;
38 38
 use Filament\Support\Colors\Color;
39
-use Filament\Support\Enums\Alignment;
40 39
 use Filament\Support\Enums\FontWeight;
41 40
 use Filament\Support\Enums\IconPosition;
42 41
 use Filament\Support\Enums\IconSize;
@@ -111,6 +110,9 @@ class Transactions extends Page implements HasTable
111 110
                     ->label('Add journal transaction')
112 111
                     ->fillForm(fn (): array => $this->getFormDefaultsForType(TransactionType::Journal))
113 112
                     ->modalWidth(MaxWidth::Screen)
113
+                    ->extraModalWindowAttributes([
114
+                        'class' => 'journal-transaction-modal',
115
+                    ])
114 116
                     ->model(static::getModel())
115 117
                     ->form(fn (Form $form) => $this->journalTransactionForm($form))
116 118
                     ->modalSubmitAction(fn (Actions\StaticAction $action) => $action->disabled(! $this->isJournalEntryBalanced()))
@@ -343,34 +345,7 @@ class Transactions extends Page implements HasTable
343 345
                 $filters['posted_at'],
344 346
                 $filters['updated_at'],
345 347
             ])
346
-            ->deferFilters()
347
-            ->deferLoading()
348 348
             ->filtersFormWidth(MaxWidth::ThreeExtraLarge)
349
-            ->filtersTriggerAction(
350
-                fn (Tables\Actions\Action $action) => $action
351
-                    ->slideOver()
352
-                    ->modalFooterActionsAlignment(Alignment::End)
353
-                    ->modalCancelAction(false)
354
-                    ->extraModalFooterActions(function (Table $table) use ($action) {
355
-                        return [
356
-                            $table->getFiltersApplyAction()
357
-                                ->close(),
358
-                            Actions\StaticAction::make('cancel')
359
-                                ->label($action->getModalCancelActionLabel())
360
-                                ->button()
361
-                                ->close()
362
-                                ->color('gray'),
363
-                            Tables\Actions\Action::make('resetFilters')
364
-                                ->label(__('Clear all'))
365
-                                ->color('primary')
366
-                                ->link()
367
-                                ->extraAttributes([
368
-                                    'class' => 'me-auto',
369
-                                ])
370
-                                ->action('resetTableFiltersForm'),
371
-                        ];
372
-                    })
373
-            )
374 349
             ->actions([
375 350
                 Tables\Actions\Action::make('markAsReviewed')
376 351
                     ->label('Mark as reviewed')
@@ -623,7 +598,7 @@ class Transactions extends Page implements HasTable
623 598
                 ->label('Type')
624 599
                 ->options(JournalEntryType::class)
625 600
                 ->live()
626
-                ->afterStateUpdated(function (Get $get, Set $set, ?string $state, ?string $old) {
601
+                ->afterStateUpdated(function (Get $get, Set $set, $state, $old) {
627 602
                     $this->adjustJournalEntryAmountsForTypeChange(JournalEntryType::parse($state), JournalEntryType::parse($old), $get('amount'));
628 603
                 })
629 604
                 ->softRequired(),

+ 542
- 0
app/Filament/Company/Resources/Accounting/BudgetResource.php Bestand weergeven

@@ -0,0 +1,542 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Accounting;
4
+
5
+use App\Enums\Accounting\BudgetIntervalType;
6
+use App\Filament\Company\Resources\Accounting\BudgetResource\Pages;
7
+use App\Filament\Forms\Components\CustomSection;
8
+use App\Filament\Forms\Components\CustomTableRepeater;
9
+use App\Models\Accounting\Account;
10
+use App\Models\Accounting\Budget;
11
+use App\Models\Accounting\BudgetAllocation;
12
+use App\Models\Accounting\BudgetItem;
13
+use App\Utilities\Currency\CurrencyConverter;
14
+use Awcodes\TableRepeater\Header;
15
+use Filament\Forms;
16
+use Filament\Forms\Form;
17
+use Filament\Resources\Resource;
18
+use Filament\Support\Enums\Alignment;
19
+use Filament\Support\Enums\MaxWidth;
20
+use Filament\Support\RawJs;
21
+use Filament\Tables;
22
+use Filament\Tables\Table;
23
+use Illuminate\Support\Carbon;
24
+
25
+class BudgetResource extends Resource
26
+{
27
+    protected static ?string $model = Budget::class;
28
+
29
+    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
30
+
31
+    protected static ?string $recordTitleAttribute = 'name';
32
+
33
+    protected static bool $isGloballySearchable = false;
34
+
35
+    public static function form(Form $form): Form
36
+    {
37
+        return $form
38
+            ->schema([
39
+                Forms\Components\Section::make('Budget Details')
40
+                    ->columns()
41
+                    ->schema([
42
+                        Forms\Components\TextInput::make('name')
43
+                            ->required()
44
+                            ->maxLength(255),
45
+                        Forms\Components\Select::make('interval_type')
46
+                            ->label('Budget Interval')
47
+                            ->options(BudgetIntervalType::class)
48
+                            ->default(BudgetIntervalType::Month->value)
49
+                            ->required()
50
+                            ->live(),
51
+                        Forms\Components\DatePicker::make('start_date')
52
+                            ->required()
53
+                            ->default(now()->startOfYear())
54
+                            ->live(),
55
+                        Forms\Components\DatePicker::make('end_date')
56
+                            ->required()
57
+                            ->default(now()->endOfYear())
58
+                            ->live()
59
+                            ->disabled(static fn (Forms\Get $get) => blank($get('start_date')))
60
+                            ->minDate(fn (Forms\Get $get) => match (BudgetIntervalType::parse($get('interval_type'))) {
61
+                                BudgetIntervalType::Month => Carbon::parse($get('start_date'))->addMonth(),
62
+                                BudgetIntervalType::Quarter => Carbon::parse($get('start_date'))->addQuarter(),
63
+                                BudgetIntervalType::Year => Carbon::parse($get('start_date'))->addYear(),
64
+                                default => Carbon::parse($get('start_date'))->addDay(),
65
+                            })
66
+                            ->maxDate(fn (Forms\Get $get) => Carbon::parse($get('start_date'))->endOfYear()),
67
+                        Forms\Components\Textarea::make('notes')
68
+                            ->columnSpanFull(),
69
+                    ]),
70
+
71
+                //                Forms\Components\Section::make('Budget Items')
72
+                //                    ->headerActions([
73
+                //                        Forms\Components\Actions\Action::make('addAccounts')
74
+                //                            ->label('Add Accounts')
75
+                //                            ->icon('heroicon-m-plus')
76
+                //                            ->outlined()
77
+                //                            ->color('primary')
78
+                //                            ->form(fn (Forms\Get $get) => [
79
+                //                                Forms\Components\Select::make('selected_accounts')
80
+                //                                    ->label('Choose Accounts to Add')
81
+                //                                    ->options(function () use ($get) {
82
+                //                                        $existingAccounts = collect($get('budgetItems'))->pluck('account_id')->toArray();
83
+                //
84
+                //                                        return Account::query()
85
+                //                                            ->budgetable()
86
+                //                                            ->whereNotIn('id', $existingAccounts) // Prevent duplicate selections
87
+                //                                            ->pluck('name', 'id');
88
+                //                                    })
89
+                //                                    ->searchable()
90
+                //                                    ->multiple()
91
+                //                                    ->hint('Select the accounts you want to add to this budget'),
92
+                //                            ])
93
+                //                            ->action(static fn (Forms\Set $set, Forms\Get $get, array $data) => self::addSelectedAccounts($set, $get, $data)),
94
+                //
95
+                //                        Forms\Components\Actions\Action::make('addAllAccounts')
96
+                //                            ->label('Add All Accounts')
97
+                //                            ->icon('heroicon-m-folder-plus')
98
+                //                            ->outlined()
99
+                //                            ->color('primary')
100
+                //                            ->action(static fn (Forms\Set $set, Forms\Get $get) => self::addAllAccounts($set, $get))
101
+                //                            ->hidden(static fn (Forms\Get $get) => filled($get('budgetItems'))),
102
+                //
103
+                //                        Forms\Components\Actions\Action::make('increaseAllocations')
104
+                //                            ->label('Increase Allocations')
105
+                //                            ->icon('heroicon-m-arrow-up')
106
+                //                            ->outlined()
107
+                //                            ->color('success')
108
+                //                            ->form(fn (Forms\Get $get) => [
109
+                //                                Forms\Components\Select::make('increase_type')
110
+                //                                    ->label('Increase Type')
111
+                //                                    ->options([
112
+                //                                        'percentage' => 'Percentage (%)',
113
+                //                                        'fixed' => 'Fixed Amount',
114
+                //                                    ])
115
+                //                                    ->default('percentage')
116
+                //                                    ->live()
117
+                //                                    ->required(),
118
+                //
119
+                //                                Forms\Components\TextInput::make('percentage')
120
+                //                                    ->label('Increase by %')
121
+                //                                    ->numeric()
122
+                //                                    ->suffix('%')
123
+                //                                    ->required()
124
+                //                                    ->hidden(fn (Forms\Get $get) => $get('increase_type') !== 'percentage'),
125
+                //
126
+                //                                Forms\Components\TextInput::make('fixed_amount')
127
+                //                                    ->label('Increase by Fixed Amount')
128
+                //                                    ->numeric()
129
+                //                                    ->suffix('USD')
130
+                //                                    ->required()
131
+                //                                    ->hidden(fn (Forms\Get $get) => $get('increase_type') !== 'fixed'),
132
+                //
133
+                //                                Forms\Components\Select::make('apply_to_accounts')
134
+                //                                    ->label('Apply to Accounts')
135
+                //                                    ->options(function () use ($get) {
136
+                //                                        $budgetItems = $get('budgetItems') ?? [];
137
+                //                                        $accountIds = collect($budgetItems)
138
+                //                                            ->pluck('account_id')
139
+                //                                            ->filter()
140
+                //                                            ->unique()
141
+                //                                            ->toArray();
142
+                //
143
+                //                                        return Account::query()
144
+                //                                            ->whereIn('id', $accountIds)
145
+                //                                            ->pluck('name', 'id')
146
+                //                                            ->toArray();
147
+                //                                    })
148
+                //                                    ->searchable()
149
+                //                                    ->multiple()
150
+                //                                    ->hint('Leave blank to apply to all accounts'),
151
+                //
152
+                //                                Forms\Components\Select::make('apply_to_periods')
153
+                //                                    ->label('Apply to Periods')
154
+                //                                    ->options(static function () use ($get) {
155
+                //                                        $startDate = $get('start_date');
156
+                //                                        $endDate = $get('end_date');
157
+                //                                        $intervalType = $get('interval_type');
158
+                //
159
+                //                                        if (blank($startDate) || blank($endDate) || blank($intervalType)) {
160
+                //                                            return [];
161
+                //                                        }
162
+                //
163
+                //                                        $labels = self::generateFormattedLabels($startDate, $endDate, $intervalType);
164
+                //
165
+                //                                        return array_combine($labels, $labels);
166
+                //                                    })
167
+                //                                    ->searchable()
168
+                //                                    ->multiple()
169
+                //                                    ->hint('Leave blank to apply to all periods'),
170
+                //                            ])
171
+                //                            ->action(static fn (Forms\Set $set, Forms\Get $get, array $data) => self::increaseAllocations($set, $get, $data))
172
+                //                            ->visible(static fn (Forms\Get $get) => filled($get('budgetItems'))),
173
+                //                    ])
174
+                //                    ->schema([
175
+                //                        Forms\Components\Repeater::make('budgetItems')
176
+                //                            ->columns(4)
177
+                //                            ->hiddenLabel()
178
+                //                            ->schema([
179
+                //                                Forms\Components\Select::make('account_id')
180
+                //                                    ->label('Account')
181
+                //                                    ->options(Account::query()
182
+                //                                        ->budgetable()
183
+                //                                        ->pluck('name', 'id'))
184
+                //                                    ->searchable()
185
+                //                                    ->disableOptionsWhenSelectedInSiblingRepeaterItems()
186
+                //                                    ->columnSpan(1)
187
+                //                                    ->required(),
188
+                //
189
+                //                                Forms\Components\TextInput::make('total_amount')
190
+                //                                    ->label('Total Amount')
191
+                //                                    ->numeric()
192
+                //                                    ->columnSpan(1)
193
+                //                                    ->suffixAction(
194
+                //                                        Forms\Components\Actions\Action::make('disperse')
195
+                //                                            ->label('Disperse')
196
+                //                                            ->icon('heroicon-m-bars-arrow-down')
197
+                //                                            ->color('primary')
198
+                //                                            ->action(static fn (Forms\Set $set, Forms\Get $get, $state) => self::disperseTotalAmount($set, $get, $state))
199
+                //                                    ),
200
+                //
201
+                //                                CustomSection::make('Budget Allocations')
202
+                //                                    ->contained(false)
203
+                //                                    ->columns(4)
204
+                //                                    ->schema(static fn (Forms\Get $get) => self::getAllocationFields($get('../../start_date'), $get('../../end_date'), $get('../../interval_type'))),
205
+                //                            ])
206
+                //                            ->defaultItems(0)
207
+                //                            ->addActionLabel('Add Budget Item'),
208
+                //                    ]),
209
+            ]);
210
+    }
211
+
212
+    public static function table(Table $table): Table
213
+    {
214
+        return $table
215
+            ->columns([
216
+                Tables\Columns\TextColumn::make('name')
217
+                    ->sortable()
218
+                    ->searchable(),
219
+
220
+                Tables\Columns\TextColumn::make('status')
221
+                    ->label('Status')
222
+                    ->sortable()
223
+                    ->badge(),
224
+
225
+                Tables\Columns\TextColumn::make('interval_type')
226
+                    ->label('Interval')
227
+                    ->sortable()
228
+                    ->badge(),
229
+
230
+                Tables\Columns\TextColumn::make('start_date')
231
+                    ->label('Start Date')
232
+                    ->date()
233
+                    ->sortable(),
234
+
235
+                Tables\Columns\TextColumn::make('end_date')
236
+                    ->label('End Date')
237
+                    ->date()
238
+                    ->sortable(),
239
+            ])
240
+            ->filters([
241
+                //
242
+            ])
243
+            ->actions([
244
+                Tables\Actions\ActionGroup::make([
245
+                    Tables\Actions\ViewAction::make(),
246
+                    Tables\Actions\EditAction::make('editAllocations')
247
+                        ->name('editAllocations')
248
+                        ->url(null)
249
+                        ->label('Edit Allocations')
250
+                        ->icon('heroicon-o-table-cells')
251
+                        ->modalWidth(MaxWidth::Screen)
252
+                        ->modalHeading('Edit Budget Allocations')
253
+                        ->modalDescription('Update the allocations for this budget')
254
+                        ->slideOver()
255
+                        ->form(function (Budget $record) {
256
+                            $periods = $record->getPeriods();
257
+
258
+                            $headers = [
259
+                                Header::make('Account')
260
+                                    ->label('Account')
261
+                                    ->width('200px'),
262
+                                Header::make('total')
263
+                                    ->label('Total')
264
+                                    ->width('120px')
265
+                                    ->align(Alignment::Right),
266
+                                Header::make('action')
267
+                                    ->label('')
268
+                                    ->width('40px')
269
+                                    ->align(Alignment::Center),
270
+                            ];
271
+
272
+                            foreach ($periods as $period) {
273
+                                $headers[] = Header::make($period->period)
274
+                                    ->label($period->period)
275
+                                    ->width('120px')
276
+                                    ->align(Alignment::Right);
277
+                            }
278
+
279
+                            return [
280
+                                CustomTableRepeater::make('budgetItems')
281
+                                    ->relationship()
282
+                                    ->hiddenLabel()
283
+                                    ->headers($headers)
284
+                                    ->schema([
285
+                                        Forms\Components\Placeholder::make('account')
286
+                                            ->hiddenLabel()
287
+                                            ->content(fn (BudgetItem $record) => $record->account->name ?? ''),
288
+
289
+                                        Forms\Components\TextInput::make('total')
290
+                                            ->hiddenLabel()
291
+                                            ->mask(RawJs::make('$money($input)'))
292
+                                            ->stripCharacters(',')
293
+                                            ->numeric()
294
+                                            ->afterStateHydrated(function ($component, $state, BudgetItem $record) use ($periods) {
295
+                                                $total = 0;
296
+                                                // Calculate the total for this budget item across all periods
297
+                                                foreach ($periods as $period) {
298
+                                                    $allocation = $record->allocations->firstWhere('period', $period->period);
299
+                                                    $total += $allocation ? $allocation->getRawOriginal('amount') : 0;
300
+                                                }
301
+                                                $component->state(CurrencyConverter::convertCentsToFormatSimple($total));
302
+                                            })
303
+                                            ->dehydrated(false),
304
+
305
+                                        Forms\Components\Actions::make([
306
+                                            Forms\Components\Actions\Action::make('disperse')
307
+                                                ->label('Disperse')
308
+                                                ->icon('heroicon-m-chevron-double-right')
309
+                                                ->color('primary')
310
+                                                ->iconButton()
311
+                                                ->action(function (Forms\Set $set, Forms\Get $get, BudgetItem $record, $livewire) use ($periods) {
312
+                                                    $total = CurrencyConverter::convertToCents($get('total'));
313
+                                                    $numPeriods = count($periods);
314
+
315
+                                                    if ($numPeriods === 0) {
316
+                                                        return;
317
+                                                    }
318
+
319
+                                                    $baseAmount = floor($total / $numPeriods);
320
+                                                    $remainder = $total - ($baseAmount * $numPeriods);
321
+
322
+                                                    foreach ($periods as $index => $period) {
323
+                                                        $amount = $baseAmount + ($index === 0 ? $remainder : 0);
324
+                                                        $formattedAmount = CurrencyConverter::convertCentsToFormatSimple($amount);
325
+                                                        $set("allocations.{$period->period}", $formattedAmount);
326
+                                                    }
327
+                                                }),
328
+                                        ]),
329
+
330
+                                        // Create a field for each period
331
+                                        ...collect($periods)->map(function (BudgetAllocation $period) {
332
+                                            return Forms\Components\TextInput::make("allocations.{$period->period}")
333
+                                                ->mask(RawJs::make('$money($input)'))
334
+                                                ->stripCharacters(',')
335
+                                                ->numeric()
336
+                                                ->afterStateHydrated(function ($component, $state, BudgetItem $record) use ($period) {
337
+                                                    // Find the allocation for this period
338
+                                                    $allocation = $record->allocations->firstWhere('period', $period->period);
339
+                                                    $component->state($allocation ? $allocation->amount : 0);
340
+                                                })
341
+                                                ->dehydrated(false); // We'll handle saving manually
342
+                                        })->toArray(),
343
+                                    ])
344
+                                    ->spreadsheet()
345
+                                    ->itemLabel(fn (BudgetItem $record) => $record->account->name ?? 'Budget Item')
346
+                                    ->deletable(false)
347
+                                    ->reorderable(false)
348
+                                    ->addable(false) // Don't allow adding new budget items
349
+                                    ->columnSpanFull(),
350
+                            ];
351
+                        }),
352
+                ]),
353
+            ])
354
+            ->bulkActions([
355
+                Tables\Actions\BulkActionGroup::make([
356
+                    Tables\Actions\DeleteBulkAction::make(),
357
+                ]),
358
+            ]);
359
+    }
360
+
361
+    private static function addAllAccounts(Forms\Set $set, Forms\Get $get): void
362
+    {
363
+        $accounts = Account::query()
364
+            ->budgetable()
365
+            ->pluck('id');
366
+
367
+        $budgetItems = $accounts->map(static fn ($accountId) => [
368
+            'account_id' => $accountId,
369
+            'total_amount' => 0, // Default to 0 until the user inputs amounts
370
+            'amounts' => self::generateDefaultAllocations($get('start_date'), $get('end_date'), $get('interval_type')),
371
+        ])->toArray();
372
+
373
+        $set('budgetItems', $budgetItems);
374
+    }
375
+
376
+    private static function addSelectedAccounts(Forms\Set $set, Forms\Get $get, array $data): void
377
+    {
378
+        $selectedAccountIds = $data['selected_accounts'] ?? [];
379
+
380
+        if (empty($selectedAccountIds)) {
381
+            return; // No accounts selected, do nothing.
382
+        }
383
+
384
+        $existingAccountIds = collect($get('budgetItems'))
385
+            ->pluck('account_id')
386
+            ->unique()
387
+            ->filter()
388
+            ->toArray();
389
+
390
+        // Only add accounts that aren't already in the budget items
391
+        $newAccounts = array_diff($selectedAccountIds, $existingAccountIds);
392
+
393
+        $newBudgetItems = collect($newAccounts)->map(static fn ($accountId) => [
394
+            'account_id' => $accountId,
395
+            'total_amount' => 0,
396
+            'amounts' => self::generateDefaultAllocations($get('start_date'), $get('end_date'), $get('interval_type')),
397
+        ])->toArray();
398
+
399
+        // Merge new budget items with existing ones
400
+        $set('budgetItems', array_merge($get('budgetItems') ?? [], $newBudgetItems));
401
+    }
402
+
403
+    private static function generateDefaultAllocations(?string $startDate, ?string $endDate, ?string $intervalType): array
404
+    {
405
+        if (! $startDate || ! $endDate || ! $intervalType) {
406
+            return [];
407
+        }
408
+
409
+        $labels = self::generateFormattedLabels($startDate, $endDate, $intervalType);
410
+
411
+        return collect($labels)->mapWithKeys(static fn ($label) => [$label => 0])->toArray();
412
+    }
413
+
414
+    private static function increaseAllocations(Forms\Set $set, Forms\Get $get, array $data): void
415
+    {
416
+        $increaseType = $data['increase_type']; // 'percentage' or 'fixed'
417
+        $percentage = $data['percentage'] ?? 0;
418
+        $fixedAmount = $data['fixed_amount'] ?? 0;
419
+
420
+        $selectedAccounts = $data['apply_to_accounts'] ?? []; // Selected account IDs
421
+        $selectedPeriods = $data['apply_to_periods'] ?? []; // Selected period labels
422
+
423
+        $budgetItems = $get('budgetItems') ?? [];
424
+
425
+        foreach ($budgetItems as $index => $budgetItem) {
426
+            // Skip if this account isn't selected (unless all accounts are being updated)
427
+            if (! empty($selectedAccounts) && ! in_array($budgetItem['account_id'], $selectedAccounts)) {
428
+                continue;
429
+            }
430
+
431
+            if (empty($budgetItem['amounts'])) {
432
+                continue; // Skip if no allocations exist
433
+            }
434
+
435
+            $updatedAmounts = $budgetItem['amounts']; // Clone existing amounts
436
+            foreach ($updatedAmounts as $label => $amount) {
437
+                // Skip if this period isn't selected (unless all periods are being updated)
438
+                if (! empty($selectedPeriods) && ! in_array($label, $selectedPeriods)) {
439
+                    continue;
440
+                }
441
+
442
+                // Apply increase based on selected type
443
+                $updatedAmounts[$label] = match ($increaseType) {
444
+                    'percentage' => round($amount * (1 + $percentage / 100), 2),
445
+                    'fixed' => round($amount + $fixedAmount, 2),
446
+                    default => $amount,
447
+                };
448
+            }
449
+
450
+            $set("budgetItems.{$index}.amounts", $updatedAmounts);
451
+            $set("budgetItems.{$index}.total_amount", round(array_sum($updatedAmounts), 2));
452
+        }
453
+    }
454
+
455
+    private static function disperseTotalAmount(Forms\Set $set, Forms\Get $get, float $totalAmount): void
456
+    {
457
+        $startDate = $get('../../start_date');
458
+        $endDate = $get('../../end_date');
459
+        $intervalType = $get('../../interval_type');
460
+
461
+        if (! $startDate || ! $endDate || ! $intervalType || $totalAmount <= 0) {
462
+            return;
463
+        }
464
+
465
+        $labels = self::generateFormattedLabels($startDate, $endDate, $intervalType);
466
+        $numPeriods = count($labels);
467
+
468
+        if ($numPeriods === 0) {
469
+            return;
470
+        }
471
+
472
+        $baseAmount = floor($totalAmount / $numPeriods);
473
+        $remainder = $totalAmount - ($baseAmount * $numPeriods);
474
+
475
+        foreach ($labels as $index => $label) {
476
+            $amount = $baseAmount + ($index === 0 ? $remainder : 0);
477
+            $set("amounts.{$label}", $amount);
478
+        }
479
+    }
480
+
481
+    private static function generateFormattedLabels(string $startDate, string $endDate, string $intervalType): array
482
+    {
483
+        $start = Carbon::parse($startDate);
484
+        $end = Carbon::parse($endDate);
485
+        $intervalTypeEnum = BudgetIntervalType::parse($intervalType);
486
+        $labels = [];
487
+
488
+        while ($start->lte($end)) {
489
+            $labels[] = match ($intervalTypeEnum) {
490
+                BudgetIntervalType::Month => $start->format('M'), // Example: Jan, Feb, Mar
491
+                BudgetIntervalType::Quarter => 'Q' . $start->quarter, // Example: Q1, Q2, Q3
492
+                BudgetIntervalType::Year => (string) $start->year, // Example: 2024, 2025
493
+                default => '',
494
+            };
495
+
496
+            match ($intervalTypeEnum) {
497
+                BudgetIntervalType::Month => $start->addMonth(),
498
+                BudgetIntervalType::Quarter => $start->addQuarter(),
499
+                BudgetIntervalType::Year => $start->addYear(),
500
+                default => null,
501
+            };
502
+        }
503
+
504
+        return $labels;
505
+    }
506
+
507
+    private static function getAllocationFields(?string $startDate, ?string $endDate, ?string $intervalType): array
508
+    {
509
+        if (! $startDate || ! $endDate || ! $intervalType) {
510
+            return [];
511
+        }
512
+
513
+        $fields = [];
514
+
515
+        $labels = self::generateFormattedLabels($startDate, $endDate, $intervalType);
516
+
517
+        foreach ($labels as $label) {
518
+            $fields[] = Forms\Components\TextInput::make("amounts.{$label}")
519
+                ->label($label)
520
+                ->numeric()
521
+                ->required();
522
+        }
523
+
524
+        return $fields;
525
+    }
526
+
527
+    public static function getRelations(): array
528
+    {
529
+        return [
530
+            //
531
+        ];
532
+    }
533
+
534
+    public static function getPages(): array
535
+    {
536
+        return [
537
+            'index' => Pages\ListBudgets::route('/'),
538
+            'create' => Pages\CreateBudget::route('/create'),
539
+            'view' => Pages\ViewBudget::route('/{record}'),
540
+        ];
541
+    }
542
+}

+ 634
- 0
app/Filament/Company/Resources/Accounting/BudgetResource/Pages/CreateBudget.php Bestand weergeven

@@ -0,0 +1,634 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Accounting\BudgetResource\Pages;
4
+
5
+use App\Enums\Accounting\BudgetIntervalType;
6
+use App\Enums\Accounting\BudgetSourceType;
7
+use App\Facades\Accounting;
8
+use App\Filament\Company\Resources\Accounting\BudgetResource;
9
+use App\Filament\Forms\Components\CustomSection;
10
+use App\Models\Accounting\Account;
11
+use App\Models\Accounting\Budget;
12
+use App\Models\Accounting\BudgetAllocation;
13
+use App\Models\Accounting\BudgetItem;
14
+use App\Utilities\Currency\CurrencyConverter;
15
+use Filament\Forms;
16
+use Filament\Forms\Components\Actions\Action;
17
+use Filament\Forms\Components\Wizard\Step;
18
+use Filament\Resources\Pages\CreateRecord;
19
+use Illuminate\Database\Eloquent\Builder;
20
+use Illuminate\Database\Eloquent\Model;
21
+use Illuminate\Support\Carbon;
22
+use Illuminate\Support\Collection;
23
+
24
+class CreateBudget extends CreateRecord
25
+{
26
+    use CreateRecord\Concerns\HasWizard;
27
+
28
+    protected static string $resource = BudgetResource::class;
29
+
30
+    // Add computed properties
31
+    public function getBudgetableAccounts(): Collection
32
+    {
33
+        return $this->getAccountsCache('budgetable', function () {
34
+            return Account::query()->budgetable()->get();
35
+        });
36
+    }
37
+
38
+    public function getAccountsWithActuals(): Collection
39
+    {
40
+        $fiscalYear = $this->data['source_fiscal_year'] ?? null;
41
+
42
+        if (blank($fiscalYear)) {
43
+            return collect();
44
+        }
45
+
46
+        return $this->getAccountsCache("actuals_{$fiscalYear}", function () use ($fiscalYear) {
47
+            return Account::query()
48
+                ->budgetable()
49
+                ->whereHas('journalEntries.transaction', function (Builder $query) use ($fiscalYear) {
50
+                    $query->whereYear('posted_at', $fiscalYear);
51
+                })
52
+                ->get();
53
+        });
54
+    }
55
+
56
+    public function getAccountsWithoutActuals(): Collection
57
+    {
58
+        $fiscalYear = $this->data['source_fiscal_year'] ?? null;
59
+
60
+        if (blank($fiscalYear)) {
61
+            return collect();
62
+        }
63
+
64
+        $budgetableAccounts = $this->getBudgetableAccounts();
65
+        $accountsWithActuals = $this->getAccountsWithActuals();
66
+
67
+        return $budgetableAccounts->whereNotIn('id', $accountsWithActuals->pluck('id'));
68
+    }
69
+
70
+    public function getAccountBalances(): Collection
71
+    {
72
+        $fiscalYear = $this->data['source_fiscal_year'] ?? null;
73
+
74
+        if (blank($fiscalYear)) {
75
+            return collect();
76
+        }
77
+
78
+        return $this->getAccountsCache("balances_{$fiscalYear}", function () use ($fiscalYear) {
79
+            $fiscalYearStart = Carbon::create($fiscalYear, 1, 1)->startOfYear();
80
+            $fiscalYearEnd = $fiscalYearStart->copy()->endOfYear();
81
+
82
+            return Accounting::getAccountBalances(
83
+                $fiscalYearStart->toDateString(),
84
+                $fiscalYearEnd->toDateString(),
85
+                $this->getBudgetableAccounts()->pluck('id')->toArray()
86
+            )->get();
87
+        });
88
+    }
89
+
90
+    // Cache helper to avoid duplicate queries
91
+    private array $accountsCache = [];
92
+
93
+    private function getAccountsCache(string $key, callable $callback): Collection
94
+    {
95
+        if (! isset($this->accountsCache[$key])) {
96
+            $this->accountsCache[$key] = $callback();
97
+        }
98
+
99
+        return $this->accountsCache[$key];
100
+    }
101
+
102
+    public function getSteps(): array
103
+    {
104
+        return [
105
+            Step::make('General Information')
106
+                ->icon('heroicon-o-document-text')
107
+                ->columns(2)
108
+                ->schema([
109
+                    Forms\Components\TextInput::make('name')
110
+                        ->required()
111
+                        ->maxLength(255),
112
+                    Forms\Components\Select::make('interval_type')
113
+                        ->label('Budget Interval')
114
+                        ->options(BudgetIntervalType::class)
115
+                        ->default(BudgetIntervalType::Month->value)
116
+                        ->required()
117
+                        ->live(),
118
+                    Forms\Components\DatePicker::make('start_date')
119
+                        ->required()
120
+                        ->default(now()->startOfYear())
121
+                        ->live(),
122
+                    Forms\Components\DatePicker::make('end_date')
123
+                        ->required()
124
+                        ->default(now()->endOfYear())
125
+                        ->live()
126
+                        ->disabled(static fn (Forms\Get $get) => blank($get('start_date')))
127
+                        ->minDate(fn (Forms\Get $get) => match (BudgetIntervalType::parse($get('interval_type'))) {
128
+                            BudgetIntervalType::Month => Carbon::parse($get('start_date'))->addMonth(),
129
+                            BudgetIntervalType::Quarter => Carbon::parse($get('start_date'))->addQuarter(),
130
+                            BudgetIntervalType::Year => Carbon::parse($get('start_date'))->addYear(),
131
+                            default => Carbon::parse($get('start_date'))->addDay(),
132
+                        })
133
+                        ->maxDate(fn (Forms\Get $get) => Carbon::parse($get('start_date'))->endOfYear()),
134
+                ]),
135
+
136
+            Step::make('Budget Setup & Settings')
137
+                ->icon('heroicon-o-cog-6-tooth')
138
+                ->schema([
139
+                    // Prefill configuration
140
+                    Forms\Components\Toggle::make('prefill_data')
141
+                        ->label('Prefill Data')
142
+                        ->helperText('Enable this option to prefill the budget with historical data')
143
+                        ->default(false)
144
+                        ->live(),
145
+
146
+                    Forms\Components\Grid::make(1)
147
+                        ->schema([
148
+                            Forms\Components\Select::make('source_type')
149
+                                ->label('Prefill Method')
150
+                                ->options(BudgetSourceType::class)
151
+                                ->live()
152
+                                ->required(),
153
+
154
+                            // If user selects to copy a previous budget
155
+                            Forms\Components\Select::make('source_budget_id')
156
+                                ->label('Source Budget')
157
+                                ->options(fn () => Budget::query()
158
+                                    ->orderByDesc('end_date')
159
+                                    ->pluck('name', 'id'))
160
+                                ->searchable()
161
+                                ->required()
162
+                                ->visible(fn (Forms\Get $get) => BudgetSourceType::parse($get('source_type'))?->isBudget()),
163
+
164
+                            // If user selects to use historical actuals
165
+                            Forms\Components\Select::make('source_fiscal_year')
166
+                                ->label('Fiscal Year')
167
+                                ->options(function () {
168
+                                    $options = [];
169
+                                    $company = auth()->user()->currentCompany;
170
+                                    $earliestDate = Carbon::parse(Accounting::getEarliestTransactionDate());
171
+                                    $fiscalYearStartCurrent = Carbon::parse($company->locale->fiscalYearStartDate());
172
+
173
+                                    for ($year = $fiscalYearStartCurrent->year; $year >= $earliestDate->year; $year--) {
174
+                                        $options[$year] = $year;
175
+                                    }
176
+
177
+                                    return $options;
178
+                                })
179
+                                ->required()
180
+                                ->live()
181
+                                ->afterStateUpdated(function (Forms\Set $set) {
182
+                                    // Clear the cache when the fiscal year changes
183
+                                    $this->accountsCache = [];
184
+
185
+                                    // Get all accounts without actuals
186
+                                    $accountIdsWithoutActuals = $this->getAccountsWithoutActuals()->pluck('id')->toArray();
187
+
188
+                                    // Set exclude_accounts_without_actuals to true by default
189
+                                    $set('exclude_accounts_without_actuals', true);
190
+
191
+                                    // Update the selected_accounts field to exclude accounts without actuals
192
+                                    $set('selected_accounts', $accountIdsWithoutActuals);
193
+                                })
194
+                                ->visible(fn (Forms\Get $get) => BudgetSourceType::parse($get('source_type'))?->isActuals()),
195
+                        ])->visible(fn (Forms\Get $get) => $get('prefill_data') === true),
196
+
197
+                    CustomSection::make('Account Selection')
198
+                        ->contained(false)
199
+                        ->schema([
200
+                            Forms\Components\Checkbox::make('exclude_accounts_without_actuals')
201
+                                ->label('Exclude all accounts without actuals')
202
+                                ->helperText(function () {
203
+                                    $count = $this->getAccountsWithoutActuals()->count();
204
+
205
+                                    return "Will exclude {$count} accounts without transaction data in the selected fiscal year";
206
+                                })
207
+                                ->default(true)
208
+                                ->live()
209
+                                ->afterStateUpdated(function (Forms\Set $set, $state) {
210
+                                    if ($state) {
211
+                                        // When checked, select all accounts without actuals
212
+                                        $accountsWithoutActuals = $this->getAccountsWithoutActuals()->pluck('id')->toArray();
213
+                                        $set('selected_accounts', $accountsWithoutActuals);
214
+                                    } else {
215
+                                        // When unchecked, clear the selection
216
+                                        $set('selected_accounts', []);
217
+                                    }
218
+                                }),
219
+
220
+                            Forms\Components\CheckboxList::make('selected_accounts')
221
+                                ->label('Select Accounts to Exclude')
222
+                                ->options(function () {
223
+                                    // Get all budgetable accounts
224
+                                    return $this->getBudgetableAccounts()->pluck('name', 'id')->toArray();
225
+                                })
226
+                                ->descriptions(function (Forms\Components\CheckboxList $component) {
227
+                                    $fiscalYear = $this->data['source_fiscal_year'] ?? null;
228
+
229
+                                    if (blank($fiscalYear)) {
230
+                                        return [];
231
+                                    }
232
+
233
+                                    $accountIds = array_keys($component->getOptions());
234
+                                    $descriptions = [];
235
+
236
+                                    if (empty($accountIds)) {
237
+                                        return [];
238
+                                    }
239
+
240
+                                    // Get account balances
241
+                                    $accountBalances = $this->getAccountBalances()->keyBy('id');
242
+
243
+                                    // Get accounts with actuals
244
+                                    $accountsWithActuals = $this->getAccountsWithActuals()->pluck('id')->toArray();
245
+
246
+                                    // Process all accounts
247
+                                    foreach ($accountIds as $accountId) {
248
+                                        $balance = $accountBalances[$accountId] ?? null;
249
+                                        $hasActuals = in_array($accountId, $accountsWithActuals);
250
+
251
+                                        if ($balance && $hasActuals) {
252
+                                            // Calculate net movement
253
+                                            $netMovement = Accounting::calculateNetMovementByCategory(
254
+                                                $balance->category,
255
+                                                $balance->total_debit ?? 0,
256
+                                                $balance->total_credit ?? 0
257
+                                            );
258
+
259
+                                            // Format the amount for display
260
+                                            $formattedAmount = CurrencyConverter::formatCentsToMoney($netMovement);
261
+                                            $descriptions[$accountId] = "{$formattedAmount} in {$fiscalYear}";
262
+                                        } else {
263
+                                            $descriptions[$accountId] = "No transactions in {$fiscalYear}";
264
+                                        }
265
+                                    }
266
+
267
+                                    return $descriptions;
268
+                                })
269
+                                ->columns(2) // Display in two columns
270
+                                ->searchable() // Allow searching for accounts
271
+                                ->bulkToggleable() // Enable "Select All" / "Deselect All"
272
+                                ->selectAllAction(fn (Action $action) => $action->label('Exclude all accounts'))
273
+                                ->deselectAllAction(fn (Action $action) => $action->label('Include all accounts'))
274
+                                ->afterStateUpdated(function (Forms\Set $set, $state) {
275
+                                    // Get all accounts without actuals
276
+                                    $accountsWithoutActuals = $this->getAccountsWithoutActuals()->pluck('id')->toArray();
277
+
278
+                                    // Check if all accounts without actuals are in the selected accounts
279
+                                    $allAccountsWithoutActualsSelected = empty(array_diff($accountsWithoutActuals, $state));
280
+
281
+                                    // Update the exclude_accounts_without_actuals checkbox state
282
+                                    $set('exclude_accounts_without_actuals', $allAccountsWithoutActualsSelected);
283
+                                }),
284
+                        ])
285
+                        ->visible(function (Forms\Get $get) {
286
+                            // Only show when using actuals with valid fiscal year AND accounts without transactions exist
287
+                            $prefillSourceType = BudgetSourceType::parse($get('source_type'));
288
+
289
+                            if ($prefillSourceType !== BudgetSourceType::Actuals || blank($get('source_fiscal_year'))) {
290
+                                return false;
291
+                            }
292
+
293
+                            return $this->getAccountsWithoutActuals()->isNotEmpty();
294
+                        }),
295
+
296
+                    Forms\Components\Textarea::make('notes')
297
+                        ->label('Notes')
298
+                        ->columnSpanFull(),
299
+                ]),
300
+        ];
301
+    }
302
+
303
+    protected function handleRecordCreation(array $data): Model
304
+    {
305
+        /** @var Budget $budget */
306
+        $budget = Budget::create([
307
+            'source_budget_id' => $data['source_budget_id'] ?? null,
308
+            'source_fiscal_year' => $data['source_fiscal_year'] ?? null,
309
+            'source_type' => $data['source_type'] ?? null,
310
+            'name' => $data['name'],
311
+            'interval_type' => $data['interval_type'],
312
+            'start_date' => $data['start_date'],
313
+            'end_date' => $data['end_date'],
314
+            'notes' => $data['notes'] ?? null,
315
+        ]);
316
+
317
+        $selectedAccounts = $data['selected_accounts'] ?? [];
318
+
319
+        $accountsToInclude = Account::query()
320
+            ->budgetable()
321
+            ->whereNotIn('id', $selectedAccounts)
322
+            ->get();
323
+
324
+        foreach ($accountsToInclude as $account) {
325
+            /** @var BudgetItem $budgetItem */
326
+            $budgetItem = $budget->budgetItems()->create([
327
+                'account_id' => $account->id,
328
+            ]);
329
+
330
+            $allocationStart = Carbon::parse($data['start_date']);
331
+            $budgetEndDate = Carbon::parse($data['end_date']);
332
+
333
+            // Determine amounts based on the prefill method
334
+            $amounts = match ($data['source_type'] ?? null) {
335
+                'actuals' => $this->getAmountsFromActuals($account, $data['source_fiscal_year'], BudgetIntervalType::parse($data['interval_type'])),
336
+                'previous_budget' => $this->getAmountsFromPreviousBudget($account, $data['source_budget_id'], BudgetIntervalType::parse($data['interval_type'])),
337
+                default => $this->generateZeroAmounts($data['start_date'], $data['end_date'], BudgetIntervalType::parse($data['interval_type'])),
338
+            };
339
+
340
+            if (empty($amounts)) {
341
+                $amounts = $this->generateZeroAmounts(
342
+                    $data['start_date'],
343
+                    $data['end_date'],
344
+                    BudgetIntervalType::parse($data['interval_type'])
345
+                );
346
+            }
347
+
348
+            foreach ($amounts as $periodLabel => $amount) {
349
+                if ($allocationStart->gt($budgetEndDate)) {
350
+                    break;
351
+                }
352
+
353
+                $allocationEnd = self::calculateEndDate($allocationStart, BudgetIntervalType::parse($data['interval_type']));
354
+
355
+                if ($allocationEnd->gt($budgetEndDate)) {
356
+                    $allocationEnd = $budgetEndDate->copy();
357
+                }
358
+
359
+                $budgetItem->allocations()->create([
360
+                    'period' => $periodLabel,
361
+                    'interval_type' => $data['interval_type'],
362
+                    'start_date' => $allocationStart->toDateString(),
363
+                    'end_date' => $allocationEnd->toDateString(),
364
+                    'amount' => CurrencyConverter::convertCentsToFloat($amount),
365
+                ]);
366
+
367
+                $allocationStart = $allocationEnd->addDay();
368
+            }
369
+        }
370
+
371
+        return $budget;
372
+    }
373
+
374
+    private function getAmountsFromActuals(Account $account, int $fiscalYear, BudgetIntervalType $intervalType): array
375
+    {
376
+        $amounts = [];
377
+
378
+        // Get the start and end date of the budget being created
379
+        $budgetStartDate = Carbon::parse($this->data['start_date']);
380
+        $budgetEndDate = Carbon::parse($this->data['end_date']);
381
+
382
+        // Map to equivalent dates in the reference fiscal year
383
+        $referenceStartDate = Carbon::create($fiscalYear, $budgetStartDate->month, $budgetStartDate->day);
384
+        $referenceEndDate = Carbon::create($fiscalYear, $budgetEndDate->month, $budgetEndDate->day);
385
+
386
+        // Handle year boundary case (if budget crosses year boundary)
387
+        if ($budgetStartDate->month > $budgetEndDate->month ||
388
+            ($budgetStartDate->month === $budgetEndDate->month && $budgetStartDate->day > $budgetEndDate->day)) {
389
+            $referenceEndDate->year++;
390
+        }
391
+
392
+        if ($intervalType->isMonth()) {
393
+            // Process month by month within the reference period
394
+            $currentDate = $referenceStartDate->copy()->startOfMonth();
395
+            $lastMonth = $referenceEndDate->copy()->startOfMonth();
396
+
397
+            while ($currentDate->lte($lastMonth)) {
398
+                $periodStart = $currentDate->copy()->startOfMonth();
399
+                $periodEnd = $currentDate->copy()->endOfMonth();
400
+                $periodLabel = $this->determinePeriod($periodStart, $intervalType);
401
+
402
+                $netMovement = Accounting::getNetMovement(
403
+                    $account,
404
+                    $periodStart->toDateString(),
405
+                    $periodEnd->toDateString()
406
+                );
407
+
408
+                $amounts[$periodLabel] = $netMovement->getAmount();
409
+
410
+                $currentDate->addMonth();
411
+            }
412
+        } elseif ($intervalType->isQuarter()) {
413
+            // Process quarter by quarter within the reference period
414
+            $currentDate = $referenceStartDate->copy()->startOfQuarter();
415
+            $lastQuarter = $referenceEndDate->copy()->startOfQuarter();
416
+
417
+            while ($currentDate->lte($lastQuarter)) {
418
+                $periodStart = $currentDate->copy()->startOfQuarter();
419
+                $periodEnd = $currentDate->copy()->endOfQuarter();
420
+                $periodLabel = $this->determinePeriod($periodStart, $intervalType);
421
+
422
+                $netMovement = Accounting::getNetMovement(
423
+                    $account,
424
+                    $periodStart->toDateString(),
425
+                    $periodEnd->toDateString()
426
+                );
427
+
428
+                $amounts[$periodLabel] = $netMovement->getAmount();
429
+
430
+                $currentDate->addQuarter();
431
+            }
432
+        } else {
433
+            // For yearly intervals
434
+            $periodStart = $referenceStartDate->copy()->startOfYear();
435
+            $periodEnd = $referenceEndDate->copy()->endOfYear();
436
+            $periodLabel = $this->determinePeriod($periodStart, $intervalType);
437
+
438
+            $netMovement = Accounting::getNetMovement(
439
+                $account,
440
+                $periodStart->toDateString(),
441
+                $periodEnd->toDateString()
442
+            );
443
+
444
+            $amounts[$periodLabel] = $netMovement->getAmount();
445
+        }
446
+
447
+        return $amounts;
448
+    }
449
+
450
+    private function distributeAmountAcrossPeriods(int $totalAmountInCents, Carbon $startDate, Carbon $endDate, BudgetIntervalType $intervalType): array
451
+    {
452
+        $amounts = [];
453
+        $periods = [];
454
+
455
+        // Generate period labels based on interval type
456
+        $currentPeriod = $startDate->copy();
457
+        while ($currentPeriod->lte($endDate)) {
458
+            $periods[] = $this->determinePeriod($currentPeriod, $intervalType);
459
+            $currentPeriod->addUnit($intervalType->value);
460
+        }
461
+
462
+        // Evenly distribute total amount across periods
463
+        $periodCount = count($periods);
464
+
465
+        if ($periodCount === 0) {
466
+            return $amounts;
467
+        }
468
+
469
+        $baseAmount = intdiv($totalAmountInCents, $periodCount); // Floor division to get the base amount in cents
470
+        $remainder = $totalAmountInCents % $periodCount; // Remaining cents to distribute
471
+
472
+        foreach ($periods as $index => $period) {
473
+            $amounts[$period] = $baseAmount + ($index < $remainder ? 1 : 0); // Distribute remainder cents evenly
474
+        }
475
+
476
+        return $amounts;
477
+    }
478
+
479
+    private function getAmountsFromPreviousBudget(Account $account, int $sourceBudgetId, BudgetIntervalType $intervalType): array
480
+    {
481
+        $amounts = [];
482
+
483
+        // Get the budget being created start and end dates
484
+        $newBudgetStartDate = Carbon::parse($this->data['start_date']);
485
+        $newBudgetEndDate = Carbon::parse($this->data['end_date']);
486
+
487
+        // Get source budget's date information
488
+        $sourceBudget = Budget::findOrFail($sourceBudgetId);
489
+        $sourceBudgetType = $sourceBudget->interval_type;
490
+
491
+        // Retrieve all previous allocations for this account
492
+        $previousAllocations = BudgetAllocation::query()
493
+            ->whereHas(
494
+                'budgetItem',
495
+                fn ($query) => $query->where('account_id', $account->id)
496
+                    ->where('budget_id', $sourceBudgetId)
497
+            )
498
+            ->orderBy('start_date')
499
+            ->get();
500
+
501
+        if ($previousAllocations->isEmpty()) {
502
+            return $this->generateZeroAmounts(
503
+                $this->data['start_date'],
504
+                $this->data['end_date'],
505
+                $intervalType
506
+            );
507
+        }
508
+
509
+        // Map previous budget periods to current budget periods
510
+        if ($intervalType === $sourceBudgetType) {
511
+            // Same interval type: direct mapping of equivalent periods
512
+            foreach ($previousAllocations as $allocation) {
513
+                $allocationDate = Carbon::parse($allocation->start_date);
514
+
515
+                // Create an equivalent date in the new budget's time range
516
+                $equivalentMonth = $allocationDate->month;
517
+                $equivalentDay = $allocationDate->day;
518
+                $equivalentYear = $newBudgetStartDate->year;
519
+
520
+                // Adjust year if the budget spans multiple years
521
+                if ($newBudgetStartDate->month > $newBudgetEndDate->month &&
522
+                    $equivalentMonth < $newBudgetStartDate->month) {
523
+                    $equivalentYear++;
524
+                }
525
+
526
+                $equivalentDate = Carbon::create($equivalentYear, $equivalentMonth, $equivalentDay);
527
+
528
+                // Only include if the date falls within our new budget period
529
+                if ($equivalentDate->between($newBudgetStartDate, $newBudgetEndDate)) {
530
+                    $periodLabel = $this->determinePeriod($equivalentDate, $intervalType);
531
+                    $amounts[$periodLabel] = $allocation->getRawOriginal('amount');
532
+                }
533
+            }
534
+        } else {
535
+            // Handle conversion between different interval types
536
+            $newBudgetPeriods = $this->generateZeroAmounts(
537
+                $this->data['start_date'],
538
+                $this->data['end_date'],
539
+                $intervalType
540
+            );
541
+
542
+            // Fill with zeros initially
543
+            $amounts = array_fill_keys(array_keys($newBudgetPeriods), 0);
544
+
545
+            // Group previous allocations by their date range
546
+            $allocationsByRange = [];
547
+            foreach ($previousAllocations as $allocation) {
548
+                $allocationsByRange[] = [
549
+                    'start' => Carbon::parse($allocation->start_date),
550
+                    'end' => Carbon::parse($allocation->end_date),
551
+                    'amount' => $allocation->getRawOriginal('amount'),
552
+                ];
553
+            }
554
+
555
+            // Create new allocations based on interval type
556
+            $currentDate = Carbon::parse($this->data['start_date']);
557
+            $endDate = Carbon::parse($this->data['end_date']);
558
+
559
+            while ($currentDate->lte($endDate)) {
560
+                $periodStart = $currentDate->copy();
561
+                $periodEnd = self::calculateEndDate($currentDate, $intervalType);
562
+
563
+                if ($periodEnd->gt($endDate)) {
564
+                    $periodEnd = $endDate->copy();
565
+                }
566
+
567
+                $periodLabel = $this->determinePeriod($periodStart, $intervalType);
568
+
569
+                // Calculate the proportional amount from the source budget
570
+                $weightedAmount = 0;
571
+
572
+                foreach ($allocationsByRange as $allocation) {
573
+                    // Find overlapping days between new period and source allocation
574
+                    $overlapStart = max($periodStart, $allocation['start']);
575
+                    $overlapEnd = min($periodEnd, $allocation['end']);
576
+
577
+                    if ($overlapStart <= $overlapEnd) {
578
+                        // Calculate overlapping days
579
+                        $overlapDays = $overlapStart->diffInDays($overlapEnd) + 1;
580
+                        $allocationTotalDays = $allocation['start']->diffInDays($allocation['end']) + 1;
581
+
582
+                        // Calculate proportional amount based on days
583
+                        $proportion = $overlapDays / $allocationTotalDays;
584
+                        $proportionalAmount = (int) ($allocation['amount'] * $proportion);
585
+
586
+                        $weightedAmount += $proportionalAmount;
587
+                    }
588
+                }
589
+
590
+                // Assign the calculated amount to the period
591
+                if (array_key_exists($periodLabel, $amounts)) {
592
+                    $amounts[$periodLabel] = $weightedAmount;
593
+                }
594
+
595
+                // Move to the next period
596
+                $currentDate = $periodEnd->copy()->addDay();
597
+            }
598
+        }
599
+
600
+        return $amounts;
601
+    }
602
+
603
+    private function generateZeroAmounts(string $startDate, string $endDate, BudgetIntervalType $intervalType): array
604
+    {
605
+        $amounts = [];
606
+
607
+        $currentPeriod = Carbon::parse($startDate);
608
+        while ($currentPeriod->lte(Carbon::parse($endDate))) {
609
+            $period = $this->determinePeriod($currentPeriod, $intervalType);
610
+            $amounts[$period] = 0;
611
+            $currentPeriod->addUnit($intervalType->value);
612
+        }
613
+
614
+        return $amounts;
615
+    }
616
+
617
+    private function determinePeriod(Carbon $date, BudgetIntervalType $intervalType): string
618
+    {
619
+        return match ($intervalType) {
620
+            BudgetIntervalType::Month => $date->format('M Y'),
621
+            BudgetIntervalType::Quarter => 'Q' . $date->quarter . ' ' . $date->year,
622
+            BudgetIntervalType::Year => (string) $date->year,
623
+        };
624
+    }
625
+
626
+    private static function calculateEndDate(Carbon $startDate, BudgetIntervalType $intervalType): Carbon
627
+    {
628
+        return match ($intervalType) {
629
+            BudgetIntervalType::Month => $startDate->copy()->endOfMonth(),
630
+            BudgetIntervalType::Quarter => $startDate->copy()->endOfQuarter(),
631
+            BudgetIntervalType::Year => $startDate->copy()->endOfYear(),
632
+        };
633
+    }
634
+}

+ 19
- 0
app/Filament/Company/Resources/Accounting/BudgetResource/Pages/ListBudgets.php Bestand weergeven

@@ -0,0 +1,19 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Accounting\BudgetResource\Pages;
4
+
5
+use App\Filament\Company\Resources\Accounting\BudgetResource;
6
+use Filament\Actions;
7
+use Filament\Resources\Pages\ListRecords;
8
+
9
+class ListBudgets extends ListRecords
10
+{
11
+    protected static string $resource = BudgetResource::class;
12
+
13
+    protected function getHeaderActions(): array
14
+    {
15
+        return [
16
+            Actions\CreateAction::make(),
17
+        ];
18
+    }
19
+}

+ 43
- 0
app/Filament/Company/Resources/Accounting/BudgetResource/Pages/ViewBudget.php Bestand weergeven

@@ -0,0 +1,43 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Accounting\BudgetResource\Pages;
4
+
5
+use App\Filament\Company\Resources\Accounting\BudgetResource;
6
+use Filament\Forms\Form;
7
+use Filament\Infolists\Infolist;
8
+use Filament\Resources\Pages\ViewRecord;
9
+use Filament\Support\Enums\MaxWidth;
10
+
11
+class ViewBudget extends ViewRecord
12
+{
13
+    protected static string $resource = BudgetResource::class;
14
+
15
+    public function getMaxContentWidth(): MaxWidth | string | null
16
+    {
17
+        return '8xl';
18
+    }
19
+
20
+    protected function getHeaderActions(): array
21
+    {
22
+        return [
23
+            //
24
+        ];
25
+    }
26
+
27
+    protected function getAllRelationManagers(): array
28
+    {
29
+        return [
30
+            BudgetResource\RelationManagers\BudgetItemsRelationManager::class,
31
+        ];
32
+    }
33
+
34
+    public function form(Form $form): Form
35
+    {
36
+        return $form->schema([]);
37
+    }
38
+
39
+    public function infolist(Infolist $infolist): Infolist
40
+    {
41
+        return $infolist->schema([]);
42
+    }
43
+}

+ 299
- 0
app/Filament/Company/Resources/Accounting/BudgetResource/RelationManagers/BudgetItemsRelationManager.php Bestand weergeven

@@ -0,0 +1,299 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Accounting\BudgetResource\RelationManagers;
4
+
5
+use App\Filament\Tables\Columns\DeferredTextInputColumn;
6
+use App\Models\Accounting\Budget;
7
+use App\Models\Accounting\BudgetAllocation;
8
+use App\Models\Accounting\BudgetItem;
9
+use App\Utilities\Currency\CurrencyConverter;
10
+use Filament\Notifications\Notification;
11
+use Filament\Resources\RelationManagers\RelationManager;
12
+use Filament\Support\RawJs;
13
+use Filament\Tables\Actions\Action;
14
+use Filament\Tables\Actions\BulkAction;
15
+use Filament\Tables\Columns\IconColumn;
16
+use Filament\Tables\Columns\Summarizers\Summarizer;
17
+use Filament\Tables\Columns\TextColumn;
18
+use Filament\Tables\Grouping\Group;
19
+use Filament\Tables\Table;
20
+use Illuminate\Database\Eloquent\Builder;
21
+use Illuminate\Database\Eloquent\Collection;
22
+use Illuminate\Support\Carbon;
23
+use Illuminate\Support\Facades\DB;
24
+use stdClass;
25
+
26
+class BudgetItemsRelationManager extends RelationManager
27
+{
28
+    protected static string $relationship = 'budgetItems';
29
+
30
+    protected static bool $isLazy = false;
31
+
32
+    protected const TOTAL_COLUMN = 'total';
33
+
34
+    public array $batchChanges = [];
35
+
36
+    /**
37
+     * Generate a consistent key for the budget item and period
38
+     */
39
+    protected static function generatePeriodKey(int $recordId, string | Carbon $startDate): string
40
+    {
41
+        $formattedDate = $startDate instanceof Carbon
42
+            ? $startDate->format('Y_m_d')
43
+            : Carbon::parse($startDate)->format('Y_m_d');
44
+
45
+        return "{$recordId}.{$formattedDate}";
46
+    }
47
+
48
+    /**
49
+     * Generate a consistent key for the budget item's total
50
+     */
51
+    protected static function generateTotalKey(int $recordId): string
52
+    {
53
+        return "{$recordId}." . self::TOTAL_COLUMN;
54
+    }
55
+
56
+    public function handleBatchColumnChanged($data): void
57
+    {
58
+        $key = "{$data['recordKey']}.{$data['name']}";
59
+        $this->batchChanges[$key] = $data['value'];
60
+    }
61
+
62
+    public function saveBatchChanges(): void
63
+    {
64
+        foreach ($this->batchChanges as $key => $value) {
65
+            [$recordKey, $column] = explode('.', $key, 2);
66
+
67
+            try {
68
+                $startDate = Carbon::createFromFormat('Y_m_d', $column);
69
+            } catch (\Exception) {
70
+                continue;
71
+            }
72
+
73
+            $record = BudgetItem::find($recordKey);
74
+            if (! $record) {
75
+                continue;
76
+            }
77
+
78
+            $allocation = $record->allocations()
79
+                ->whereDate('start_date', $startDate)
80
+                ->first();
81
+
82
+            $allocation?->update(['amount' => $value]);
83
+        }
84
+
85
+        $this->batchChanges = [];
86
+
87
+        Notification::make()
88
+            ->title('Budget allocations updated')
89
+            ->success()
90
+            ->send();
91
+    }
92
+
93
+    protected function calculatePeriodSum(array $budgetItemIds, string | Carbon $startDate): int
94
+    {
95
+        $allocations = DB::table('budget_allocations')
96
+            ->whereIn('budget_item_id', $budgetItemIds)
97
+            ->whereDate('start_date', $startDate)
98
+            ->pluck('amount', 'budget_item_id');
99
+
100
+        $dbTotal = $allocations->sum();
101
+
102
+        $batchTotal = 0;
103
+
104
+        foreach ($budgetItemIds as $itemId) {
105
+            $key = self::generatePeriodKey($itemId, $startDate);
106
+
107
+            if (isset($this->batchChanges[$key])) {
108
+                $batchValue = CurrencyConverter::convertToCents($this->batchChanges[$key]);
109
+                $existingAmount = $allocations[$itemId] ?? 0;
110
+
111
+                $batchTotal += ($batchValue - $existingAmount);
112
+            }
113
+        }
114
+
115
+        return $dbTotal + $batchTotal;
116
+    }
117
+
118
+    public function table(Table $table): Table
119
+    {
120
+        /** @var Budget $budget */
121
+        $budget = $this->getOwnerRecord();
122
+        $allocationPeriods = $budget->getPeriods();
123
+
124
+        return $table
125
+            ->recordTitleAttribute('account_id')
126
+            ->paginated(false)
127
+            ->heading(null)
128
+            ->modifyQueryUsing(function (Builder $query) use ($allocationPeriods) {
129
+                $query->select('budget_items.*')
130
+                    ->leftJoin('budget_allocations', 'budget_allocations.budget_item_id', '=', 'budget_items.id');
131
+
132
+                foreach ($allocationPeriods as $period) {
133
+                    $alias = $period->start_date->format('Y_m_d');
134
+                    $query->selectRaw(
135
+                        "SUM(CASE WHEN budget_allocations.start_date = ? THEN budget_allocations.amount ELSE 0 END) as {$alias}",
136
+                        [$period->start_date->toDateString()]
137
+                    );
138
+                }
139
+
140
+                return $query->groupBy('budget_items.id');
141
+            })
142
+            ->groups([
143
+                Group::make('account.category')
144
+                    ->titlePrefixedWithLabel(false)
145
+                    ->collapsible(),
146
+            ])
147
+            ->recordClasses(['budget-items-relation-manager'])
148
+            ->defaultGroup('account.category')
149
+            ->headerActions([
150
+                Action::make('saveBatchChanges')
151
+                    ->label('Save all changes')
152
+                    ->action('saveBatchChanges')
153
+                    ->color('primary'),
154
+            ])
155
+            ->columns([
156
+                TextColumn::make('account.name')
157
+                    ->label('Account')
158
+                    ->limit(30)
159
+                    ->searchable(),
160
+                DeferredTextInputColumn::make(self::TOTAL_COLUMN)
161
+                    ->label('Total')
162
+                    ->alignRight()
163
+                    ->mask(RawJs::make('$money($input)'))
164
+                    ->getStateUsing(function (BudgetItem $record) {
165
+                        $key = self::generateTotalKey($record->getKey());
166
+                        if (isset($this->batchChanges[$key])) {
167
+                            return $this->batchChanges[$key];
168
+                        }
169
+
170
+                        $total = $record->allocations->sum(
171
+                            fn (BudgetAllocation $allocation) => $allocation->getRawOriginal('amount')
172
+                        );
173
+
174
+                        return CurrencyConverter::convertCentsToFormatSimple($total);
175
+                    })
176
+                    ->batchMode()
177
+                    ->summarize(
178
+                        Summarizer::make()
179
+                            ->using(function (\Illuminate\Database\Query\Builder $query) {
180
+                                $allocations = $query
181
+                                    ->leftJoin('budget_allocations', 'budget_allocations.budget_item_id', '=', 'budget_items.id')
182
+                                    ->select('budget_allocations.budget_item_id', 'budget_allocations.start_date', 'budget_allocations.amount')
183
+                                    ->get();
184
+
185
+                                $allocationsByDate = $allocations->groupBy('start_date');
186
+
187
+                                $total = 0;
188
+
189
+                                /** @var \Illuminate\Support\Collection<string, \Illuminate\Support\Collection<int, stdClass>> $allocationsByDate */
190
+                                foreach ($allocationsByDate as $startDate => $group) {
191
+                                    $dbTotal = $group->sum('amount');
192
+                                    $amounts = $group->pluck('amount', 'budget_item_id');
193
+                                    $batchTotal = 0;
194
+
195
+                                    foreach ($amounts as $itemId => $existingAmount) {
196
+                                        $key = self::generatePeriodKey($itemId, $startDate);
197
+
198
+                                        if (isset($this->batchChanges[$key])) {
199
+                                            $batchValue = CurrencyConverter::convertToCents($this->batchChanges[$key]);
200
+                                            $batchTotal += ($batchValue - $existingAmount);
201
+                                        }
202
+                                    }
203
+
204
+                                    $total += $dbTotal + $batchTotal;
205
+                                }
206
+
207
+                                return CurrencyConverter::convertCentsToFormatSimple($total);
208
+                            })
209
+                    ),
210
+                IconColumn::make('disperseAction')
211
+                    ->icon('heroicon-m-chevron-double-right')
212
+                    ->color('primary')
213
+                    ->label('')
214
+                    ->default('')
215
+                    ->tooltip('Disperse total across periods')
216
+                    ->action(
217
+                        Action::make('disperse')
218
+                            ->label('Disperse')
219
+                            ->action(function (BudgetItem $record) use ($allocationPeriods) {
220
+                                if (empty($allocationPeriods)) {
221
+                                    return;
222
+                                }
223
+
224
+                                $totalKey = self::generateTotalKey($record->getKey());
225
+                                $totalAmount = $this->batchChanges[$totalKey] ?? null;
226
+
227
+                                if (isset($totalAmount)) {
228
+                                    $totalCents = CurrencyConverter::convertToCents($totalAmount);
229
+                                } else {
230
+                                    $totalCents = $record->allocations->sum(function (BudgetAllocation $budgetAllocation) {
231
+                                        return $budgetAllocation->getRawOriginal('amount');
232
+                                    });
233
+                                }
234
+
235
+                                if ($totalCents <= 0) {
236
+                                    foreach ($allocationPeriods as $period) {
237
+                                        $periodKey = self::generatePeriodKey($record->getKey(), $period->start_date);
238
+                                        $this->batchChanges[$periodKey] = CurrencyConverter::convertCentsToFormatSimple(0);
239
+                                    }
240
+
241
+                                    return;
242
+                                }
243
+
244
+                                $numPeriods = count($allocationPeriods);
245
+
246
+                                $baseAmount = floor($totalCents / $numPeriods);
247
+                                $remainder = $totalCents - ($baseAmount * $numPeriods);
248
+
249
+                                foreach ($allocationPeriods as $index => $period) {
250
+                                    $amount = $baseAmount + ($index === 0 ? $remainder : 0);
251
+                                    $formattedAmount = CurrencyConverter::convertCentsToFormatSimple($amount);
252
+
253
+                                    $periodKey = self::generatePeriodKey($record->getKey(), $period->start_date);
254
+                                    $this->batchChanges[$periodKey] = $formattedAmount;
255
+                                }
256
+                            }),
257
+                    ),
258
+                ...$allocationPeriods->map(function (BudgetAllocation $period) {
259
+                    $alias = $period->start_date->format('Y_m_d');
260
+
261
+                    return DeferredTextInputColumn::make($alias)
262
+                        ->label($period->period)
263
+                        ->alignRight()
264
+                        ->batchMode()
265
+                        ->mask(RawJs::make('$money($input)'))
266
+                        ->getStateUsing(function ($record) use ($alias) {
267
+                            $key = "{$record->getKey()}.{$alias}";
268
+
269
+                            return $this->batchChanges[$key] ?? CurrencyConverter::convertCentsToFormatSimple($record->{$alias} ?? 0);
270
+                        })
271
+                        ->summarize(
272
+                            Summarizer::make()
273
+                                ->using(function (\Illuminate\Database\Query\Builder $query) use ($period) {
274
+                                    $budgetItemIds = $query->pluck('id')->toArray();
275
+                                    $total = $this->calculatePeriodSum($budgetItemIds, $period->start_date);
276
+
277
+                                    return CurrencyConverter::convertCentsToFormatSimple($total);
278
+                                })
279
+                        );
280
+                })->toArray(),
281
+            ])
282
+            ->bulkActions([
283
+                BulkAction::make('clearAllocations')
284
+                    ->label('Clear Allocations')
285
+                    ->icon('heroicon-o-trash')
286
+                    ->color('danger')
287
+                    ->requiresConfirmation()
288
+                    ->deselectRecordsAfterCompletion()
289
+                    ->action(function (Collection $records) use ($allocationPeriods) {
290
+                        foreach ($records as $record) {
291
+                            foreach ($allocationPeriods as $period) {
292
+                                $periodKey = self::generatePeriodKey($record->getKey(), $period->start_date);
293
+                                $this->batchChanges[$periodKey] = CurrencyConverter::convertCentsToFormatSimple(0);
294
+                            }
295
+                        }
296
+                    }),
297
+            ]);
298
+    }
299
+}

+ 3
- 5
app/Filament/Company/Resources/Banking/AccountResource/Pages/CreateAccount.php Bestand weergeven

@@ -2,17 +2,15 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Banking\AccountResource\Pages;
4 4
 
5
+use App\Concerns\RedirectToListPage;
5 6
 use App\Filament\Company\Resources\Banking\AccountResource;
6 7
 use Filament\Resources\Pages\CreateRecord;
7 8
 
8 9
 class CreateAccount extends CreateRecord
9 10
 {
10
-    protected static string $resource = AccountResource::class;
11
+    use RedirectToListPage;
11 12
 
12
-    protected function getRedirectUrl(): string
13
-    {
14
-        return $this->getResource()::getUrl('index');
15
-    }
13
+    protected static string $resource = AccountResource::class;
16 14
 
17 15
     protected function mutateFormDataBeforeCreate(array $data): array
18 16
     {

+ 3
- 5
app/Filament/Company/Resources/Banking/AccountResource/Pages/EditAccount.php Bestand weergeven

@@ -2,12 +2,15 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Banking\AccountResource\Pages;
4 4
 
5
+use App\Concerns\RedirectToListPage;
5 6
 use App\Filament\Company\Resources\Banking\AccountResource;
6 7
 use Filament\Actions;
7 8
 use Filament\Resources\Pages\EditRecord;
8 9
 
9 10
 class EditAccount extends EditRecord
10 11
 {
12
+    use RedirectToListPage;
13
+
11 14
     protected static string $resource = AccountResource::class;
12 15
 
13 16
     protected function getHeaderActions(): array
@@ -17,11 +20,6 @@ class EditAccount extends EditRecord
17 20
         ];
18 21
     }
19 22
 
20
-    protected function getRedirectUrl(): string
21
-    {
22
-        return $this->getResource()::getUrl('index');
23
-    }
24
-
25 23
     protected function mutateFormDataBeforeSave(array $data): array
26 24
     {
27 25
         $data['enabled'] = (bool) ($data['enabled'] ?? false);

+ 49
- 32
app/Filament/Company/Resources/Common/OfferingResource.php Bestand weergeven

@@ -4,9 +4,13 @@ namespace App\Filament\Company\Resources\Common;
4 4
 
5 5
 use App\Enums\Accounting\AccountCategory;
6 6
 use App\Enums\Accounting\AccountType;
7
+use App\Enums\Accounting\AdjustmentCategory;
8
+use App\Enums\Accounting\AdjustmentType;
7 9
 use App\Enums\Common\OfferingType;
8 10
 use App\Filament\Company\Resources\Common\OfferingResource\Pages;
9
-use App\Models\Accounting\Account;
11
+use App\Filament\Forms\Components\Banner;
12
+use App\Filament\Forms\Components\CreateAccountSelect;
13
+use App\Filament\Forms\Components\CreateAdjustmentSelect;
10 14
 use App\Models\Common\Offering;
11 15
 use App\Utilities\Currency\CurrencyAccessor;
12 16
 use Filament\Forms;
@@ -15,6 +19,7 @@ use Filament\Resources\Resource;
15 19
 use Filament\Tables;
16 20
 use Filament\Tables\Table;
17 21
 use Illuminate\Database\Eloquent\Builder;
22
+use Illuminate\Support\HtmlString;
18 23
 use Illuminate\Support\Str;
19 24
 use JaOcero\RadioDeck\Forms\Components\RadioDeck;
20 25
 
@@ -28,6 +33,29 @@ class OfferingResource extends Resource
28 33
     {
29 34
         return $form
30 35
             ->schema([
36
+                Banner::make('inactiveAdjustments')
37
+                    ->label('Inactive adjustments')
38
+                    ->warning()
39
+                    ->icon('heroicon-o-exclamation-triangle')
40
+                    ->visible(fn (Offering $record) => $record->hasInactiveAdjustments())
41
+                    ->columnSpanFull()
42
+                    ->description(function (Offering $record) {
43
+                        $inactiveAdjustments = collect();
44
+
45
+                        foreach ($record->adjustments as $adjustment) {
46
+                            if ($adjustment->isInactive() && $inactiveAdjustments->doesntContain($adjustment->name)) {
47
+                                $inactiveAdjustments->push($adjustment->name);
48
+                            }
49
+                        }
50
+
51
+                        $adjustmentsList = $inactiveAdjustments->map(static function ($name) {
52
+                            return "<span class='font-medium'>{$name}</span>";
53
+                        })->join(', ');
54
+
55
+                        $output = "<p class='text-sm'>This offering contains inactive adjustments that need to be addressed: {$adjustmentsList}</p>";
56
+
57
+                        return new HtmlString($output);
58
+                    }),
31 59
                 Forms\Components\Section::make('General')
32 60
                     ->schema([
33 61
                         RadioDeck::make('type')
@@ -65,63 +93,52 @@ class OfferingResource extends Resource
65 93
                 // Sellable Section
66 94
                 Forms\Components\Section::make('Sale Information')
67 95
                     ->schema([
68
-                        Forms\Components\Select::make('income_account_id')
96
+                        CreateAccountSelect::make('income_account_id')
69 97
                             ->label('Income account')
70
-                            ->options(Account::query()
71
-                                ->where('category', AccountCategory::Revenue)
72
-                                ->where('type', AccountType::OperatingRevenue)
73
-                                ->pluck('name', 'id')
74
-                                ->toArray())
75
-                            ->searchable()
76
-                            ->preload()
98
+                            ->category(AccountCategory::Revenue)
99
+                            ->type(AccountType::OperatingRevenue)
77 100
                             ->required()
78 101
                             ->validationMessages([
79 102
                                 'required' => 'The income account is required for sellable offerings.',
80 103
                             ]),
81
-                        Forms\Components\Select::make('salesTaxes')
104
+                        CreateAdjustmentSelect::make('salesTaxes')
82 105
                             ->label('Sales tax')
83
-                            ->relationship('salesTaxes', 'name')
84
-                            ->preload()
106
+                            ->category(AdjustmentCategory::Tax)
107
+                            ->type(AdjustmentType::Sales)
85 108
                             ->multiple(),
86
-                        Forms\Components\Select::make('salesDiscounts')
109
+                        CreateAdjustmentSelect::make('salesDiscounts')
87 110
                             ->label('Sales discount')
88
-                            ->relationship('salesDiscounts', 'name')
89
-                            ->preload()
111
+                            ->category(AdjustmentCategory::Discount)
112
+                            ->type(AdjustmentType::Sales)
90 113
                             ->multiple(),
91 114
                     ])
92 115
                     ->columns()
93
-                    ->visible(fn (Forms\Get $get) => in_array('Sellable', $get('attributes') ?? [])),
116
+                    ->visible(static fn (Forms\Get $get) => in_array('Sellable', $get('attributes') ?? [])),
94 117
 
95 118
                 // Purchasable Section
96 119
                 Forms\Components\Section::make('Purchase Information')
97 120
                     ->schema([
98
-                        Forms\Components\Select::make('expense_account_id')
121
+                        CreateAccountSelect::make('expense_account_id')
99 122
                             ->label('Expense account')
100
-                            ->options(Account::query()
101
-                                ->where('category', AccountCategory::Expense)
102
-                                ->where('type', AccountType::OperatingExpense)
103
-                                ->orderBy('name')
104
-                                ->pluck('name', 'id')
105
-                                ->toArray())
106
-                            ->searchable()
107
-                            ->preload()
123
+                            ->category(AccountCategory::Expense)
124
+                            ->type(AccountType::OperatingExpense)
108 125
                             ->required()
109 126
                             ->validationMessages([
110 127
                                 'required' => 'The expense account is required for purchasable offerings.',
111 128
                             ]),
112
-                        Forms\Components\Select::make('purchaseTaxes')
129
+                        CreateAdjustmentSelect::make('purchaseTaxes')
113 130
                             ->label('Purchase tax')
114
-                            ->relationship('purchaseTaxes', 'name')
115
-                            ->preload()
131
+                            ->category(AdjustmentCategory::Tax)
132
+                            ->type(AdjustmentType::Purchase)
116 133
                             ->multiple(),
117
-                        Forms\Components\Select::make('purchaseDiscounts')
134
+                        CreateAdjustmentSelect::make('purchaseDiscounts')
118 135
                             ->label('Purchase discount')
119
-                            ->relationship('purchaseDiscounts', 'name')
120
-                            ->preload()
136
+                            ->category(AdjustmentCategory::Discount)
137
+                            ->type(AdjustmentType::Purchase)
121 138
                             ->multiple(),
122 139
                     ])
123 140
                     ->columns()
124
-                    ->visible(fn (Forms\Get $get) => in_array('Purchasable', $get('attributes') ?? [])),
141
+                    ->visible(static fn (Forms\Get $get) => in_array('Purchasable', $get('attributes') ?? [])),
125 142
             ])->columns();
126 143
     }
127 144
 

+ 66
- 26
app/Filament/Company/Resources/Purchases/BillResource.php Bestand weergeven

@@ -2,12 +2,16 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Purchases;
4 4
 
5
+use App\Enums\Accounting\AdjustmentCategory;
6
+use App\Enums\Accounting\AdjustmentStatus;
7
+use App\Enums\Accounting\AdjustmentType;
5 8
 use App\Enums\Accounting\BillStatus;
6 9
 use App\Enums\Accounting\DocumentDiscountMethod;
7 10
 use App\Enums\Accounting\DocumentType;
8 11
 use App\Enums\Accounting\PaymentMethod;
9 12
 use App\Filament\Company\Resources\Purchases\BillResource\Pages;
10 13
 use App\Filament\Company\Resources\Purchases\VendorResource\RelationManagers\BillsRelationManager;
14
+use App\Filament\Forms\Components\CreateAdjustmentSelect;
11 15
 use App\Filament\Forms\Components\CreateCurrencySelect;
12 16
 use App\Filament\Forms\Components\DocumentTotals;
13 17
 use App\Filament\Tables\Actions\ReplicateBulkAction;
@@ -15,6 +19,7 @@ use App\Filament\Tables\Columns;
15 19
 use App\Filament\Tables\Filters\DateRangeFilter;
16 20
 use App\Models\Accounting\Adjustment;
17 21
 use App\Models\Accounting\Bill;
22
+use App\Models\Accounting\DocumentLineItem;
18 23
 use App\Models\Banking\BankAccount;
19 24
 use App\Models\Common\Offering;
20 25
 use App\Models\Common\Vendor;
@@ -117,17 +122,17 @@ class BillResource extends Resource
117 122
                                     Header::make($settings->resolveColumnLabel('item_name', 'Items'))
118 123
                                         ->width($hasDiscounts ? '15%' : '20%'),
119 124
                                     Header::make('Description')
120
-                                        ->width($hasDiscounts ? '25%' : '30%'),
125
+                                        ->width($hasDiscounts ? '15%' : '20%'),
121 126
                                     Header::make($settings->resolveColumnLabel('unit_name', 'Quantity'))
122 127
                                         ->width('10%'),
123 128
                                     Header::make($settings->resolveColumnLabel('price_name', 'Price'))
124 129
                                         ->width('10%'),
125 130
                                     Header::make('Taxes')
126
-                                        ->width($hasDiscounts ? '15%' : '20%'),
131
+                                        ->width($hasDiscounts ? '20%' : '30%'),
127 132
                                 ];
128 133
 
129 134
                                 if ($hasDiscounts) {
130
-                                    $headers[] = Header::make('Discounts')->width('15%');
135
+                                    $headers[] = Header::make('Discounts')->width('20%');
131 136
                                 }
132 137
 
133 138
                                 $headers[] = Header::make($settings->resolveColumnLabel('amount_name', 'Amount'))
@@ -144,9 +149,40 @@ class BillResource extends Resource
144 149
                                     ->searchable()
145 150
                                     ->required()
146 151
                                     ->live()
147
-                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
152
+                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state, ?DocumentLineItem $record) {
148 153
                                         $offeringId = $state;
149
-                                        $offeringRecord = Offering::with(['purchaseTaxes', 'purchaseDiscounts'])->find($offeringId);
154
+                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
155
+                                        $isPerLineItem = $discountMethod->isPerLineItem();
156
+
157
+                                        $existingTaxIds = [];
158
+                                        $existingDiscountIds = [];
159
+
160
+                                        if ($record) {
161
+                                            $existingTaxIds = $record->purchaseTaxes()->pluck('adjustments.id')->toArray();
162
+                                            if ($isPerLineItem) {
163
+                                                $existingDiscountIds = $record->purchaseDiscounts()->pluck('adjustments.id')->toArray();
164
+                                            }
165
+                                        }
166
+
167
+                                        $with = [
168
+                                            'purchaseTaxes' => static function ($query) use ($existingTaxIds) {
169
+                                                $query->where(static function ($query) use ($existingTaxIds) {
170
+                                                    $query->where('status', AdjustmentStatus::Active)
171
+                                                        ->orWhereIn('adjustments.id', $existingTaxIds);
172
+                                                });
173
+                                            },
174
+                                        ];
175
+
176
+                                        if ($isPerLineItem) {
177
+                                            $with['purchaseDiscounts'] = static function ($query) use ($existingDiscountIds) {
178
+                                                $query->where(static function ($query) use ($existingDiscountIds) {
179
+                                                    $query->where('status', AdjustmentStatus::Active)
180
+                                                        ->orWhereIn('adjustments.id', $existingDiscountIds);
181
+                                                });
182
+                                            };
183
+                                        }
184
+
185
+                                        $offeringRecord = Offering::with($with)->find($offeringId);
150 186
 
151 187
                                         if (! $offeringRecord) {
152 188
                                             return;
@@ -158,8 +194,7 @@ class BillResource extends Resource
158 194
                                         $set('unit_price', $unitPrice);
159 195
                                         $set('purchaseTaxes', $offeringRecord->purchaseTaxes->pluck('id')->toArray());
160 196
 
161
-                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
162
-                                        if ($discountMethod->isPerLineItem()) {
197
+                                        if ($isPerLineItem) {
163 198
                                             $set('purchaseDiscounts', $offeringRecord->purchaseDiscounts->pluck('id')->toArray());
164 199
                                         }
165 200
                                     }),
@@ -177,21 +212,24 @@ class BillResource extends Resource
177 212
                                     ->live()
178 213
                                     ->maxValue(9999999999.99)
179 214
                                     ->default(0),
180
-                                Forms\Components\Select::make('purchaseTaxes')
215
+                                CreateAdjustmentSelect::make('purchaseTaxes')
181 216
                                     ->label('Taxes')
182
-                                    ->relationship('purchaseTaxes', 'name')
217
+                                    ->category(AdjustmentCategory::Tax)
218
+                                    ->type(AdjustmentType::Purchase)
219
+                                    ->adjustmentsRelationship('purchaseTaxes')
183 220
                                     ->saveRelationshipsUsing(null)
184 221
                                     ->dehydrated(true)
185 222
                                     ->preload()
186 223
                                     ->multiple()
187 224
                                     ->live()
188 225
                                     ->searchable(),
189
-                                Forms\Components\Select::make('purchaseDiscounts')
226
+                                CreateAdjustmentSelect::make('purchaseDiscounts')
190 227
                                     ->label('Discounts')
191
-                                    ->relationship('purchaseDiscounts', 'name')
228
+                                    ->category(AdjustmentCategory::Discount)
229
+                                    ->type(AdjustmentType::Purchase)
230
+                                    ->adjustmentsRelationship('purchaseDiscounts')
192 231
                                     ->saveRelationshipsUsing(null)
193 232
                                     ->dehydrated(true)
194
-                                    ->preload()
195 233
                                     ->multiple()
196 234
                                     ->live()
197 235
                                     ->hidden(function (Forms\Get $get) {
@@ -288,7 +326,8 @@ class BillResource extends Resource
288 326
                 Tables\Filters\SelectFilter::make('vendor')
289 327
                     ->relationship('vendor', 'name')
290 328
                     ->searchable()
291
-                    ->preload(),
329
+                    ->preload()
330
+                    ->hiddenOn(BillsRelationManager::class),
292 331
                 Tables\Filters\SelectFilter::make('status')
293 332
                     ->options(BillStatus::class)
294 333
                     ->native(false),
@@ -374,9 +413,13 @@ class BillResource extends Resource
374 413
                                 Forms\Components\Select::make('bank_account_id')
375 414
                                     ->label('Account')
376 415
                                     ->required()
377
-                                    ->options(BankAccount::query()
378
-                                        ->get()
379
-                                        ->pluck('account.name', 'id'))
416
+                                    ->options(function () {
417
+                                        return BankAccount::query()
418
+                                            ->join('accounts', 'bank_accounts.account_id', '=', 'accounts.id')
419
+                                            ->select(['bank_accounts.id', 'accounts.name'])
420
+                                            ->pluck('accounts.name', 'bank_accounts.id')
421
+                                            ->toArray();
422
+                                    })
380 423
                                     ->searchable(),
381 424
                                 Forms\Components\Textarea::make('notes')
382 425
                                     ->label('Notes'),
@@ -483,9 +526,13 @@ class BillResource extends Resource
483 526
                             Forms\Components\Select::make('bank_account_id')
484 527
                                 ->label('Account')
485 528
                                 ->required()
486
-                                ->options(BankAccount::query()
487
-                                    ->get()
488
-                                    ->pluck('account.name', 'id'))
529
+                                ->options(function () {
530
+                                    return BankAccount::query()
531
+                                        ->join('accounts', 'bank_accounts.account_id', '=', 'accounts.id')
532
+                                        ->select(['bank_accounts.id', 'accounts.name'])
533
+                                        ->pluck('accounts.name', 'bank_accounts.id')
534
+                                        ->toArray();
535
+                                })
489 536
                                 ->searchable(),
490 537
                             Forms\Components\Textarea::make('notes')
491 538
                                 ->label('Notes'),
@@ -531,13 +578,6 @@ class BillResource extends Resource
531 578
             ]);
532 579
     }
533 580
 
534
-    public static function getRelations(): array
535
-    {
536
-        return [
537
-            BillResource\RelationManagers\PaymentsRelationManager::class,
538
-        ];
539
-    }
540
-
541 581
     public static function getPages(): array
542 582
     {
543 583
         return [

+ 2
- 2
app/Filament/Company/Resources/Purchases/BillResource/Pages/CreateBill.php Bestand weergeven

@@ -3,7 +3,7 @@
3 3
 namespace App\Filament\Company\Resources\Purchases\BillResource\Pages;
4 4
 
5 5
 use App\Concerns\ManagesLineItems;
6
-use App\Concerns\RedirectToListPage;
6
+use App\Concerns\RedirectToViewPage;
7 7
 use App\Filament\Company\Resources\Purchases\BillResource;
8 8
 use App\Models\Accounting\Bill;
9 9
 use App\Models\Common\Vendor;
@@ -15,7 +15,7 @@ use Livewire\Attributes\Url;
15 15
 class CreateBill extends CreateRecord
16 16
 {
17 17
     use ManagesLineItems;
18
-    use RedirectToListPage;
18
+    use RedirectToViewPage;
19 19
 
20 20
     protected static string $resource = BillResource::class;
21 21
 

+ 2
- 5
app/Filament/Company/Resources/Purchases/BillResource/Pages/EditBill.php Bestand weergeven

@@ -3,6 +3,7 @@
3 3
 namespace App\Filament\Company\Resources\Purchases\BillResource\Pages;
4 4
 
5 5
 use App\Concerns\ManagesLineItems;
6
+use App\Concerns\RedirectToViewPage;
6 7
 use App\Filament\Company\Resources\Purchases\BillResource;
7 8
 use App\Models\Accounting\Bill;
8 9
 use Filament\Actions;
@@ -13,6 +14,7 @@ use Illuminate\Database\Eloquent\Model;
13 14
 class EditBill extends EditRecord
14 15
 {
15 16
     use ManagesLineItems;
17
+    use RedirectToViewPage;
16 18
 
17 19
     protected static string $resource = BillResource::class;
18 20
 
@@ -28,11 +30,6 @@ class EditBill extends EditRecord
28 30
         return MaxWidth::Full;
29 31
     }
30 32
 
31
-    protected function getRedirectUrl(): string
32
-    {
33
-        return $this->getResource()::getUrl('view', ['record' => $this->record]);
34
-    }
35
-
36 33
     protected function handleRecordUpdate(Model $record, array $data): Model
37 34
     {
38 35
         /** @var Bill $record */

+ 9
- 2
app/Filament/Company/Resources/Purchases/BillResource/Pages/ViewBill.php Bestand weergeven

@@ -62,10 +62,10 @@ class ViewBill extends ViewRecord
62 62
                             ->url(static fn (Bill $record) => VendorResource::getUrl('edit', ['record' => $record->vendor_id])),
63 63
                         TextEntry::make('total')
64 64
                             ->label('Total')
65
-                            ->money(),
65
+                            ->currency(fn (Bill $record) => $record->currency_code),
66 66
                         TextEntry::make('amount_due')
67 67
                             ->label('Amount due')
68
-                            ->money(),
68
+                            ->currency(fn (Bill $record) => $record->currency_code),
69 69
                         TextEntry::make('date')
70 70
                             ->label('Date')
71 71
                             ->date(),
@@ -79,4 +79,11 @@ class ViewBill extends ViewRecord
79 79
                     ]),
80 80
             ]);
81 81
     }
82
+
83
+    protected function getAllRelationManagers(): array
84
+    {
85
+        return [
86
+            BillResource\RelationManagers\PaymentsRelationManager::class,
87
+        ];
88
+    }
82 89
 }

+ 119
- 42
app/Filament/Company/Resources/Purchases/BillResource/RelationManagers/PaymentsRelationManager.php Bestand weergeven

@@ -4,7 +4,6 @@ namespace App\Filament\Company\Resources\Purchases\BillResource\RelationManagers
4 4
 
5 5
 use App\Enums\Accounting\PaymentMethod;
6 6
 use App\Enums\Accounting\TransactionType;
7
-use App\Filament\Company\Resources\Purchases\BillResource\Pages\ViewBill;
8 7
 use App\Models\Accounting\Bill;
9 8
 use App\Models\Accounting\Transaction;
10 9
 use App\Models\Banking\BankAccount;
@@ -19,7 +18,6 @@ use Filament\Support\Enums\FontWeight;
19 18
 use Filament\Support\Enums\MaxWidth;
20 19
 use Filament\Tables;
21 20
 use Filament\Tables\Table;
22
-use Illuminate\Database\Eloquent\Model;
23 21
 
24 22
 class PaymentsRelationManager extends RelationManager
25 23
 {
@@ -36,11 +34,6 @@ class PaymentsRelationManager extends RelationManager
36 34
         return false;
37 35
     }
38 36
 
39
-    public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
40
-    {
41
-        return $pageClass === ViewBill::class;
42
-    }
43
-
44 37
     public function form(Form $form): Form
45 38
     {
46 39
         return $form
@@ -48,55 +41,139 @@ class PaymentsRelationManager extends RelationManager
48 41
             ->schema([
49 42
                 Forms\Components\DatePicker::make('posted_at')
50 43
                     ->label('Date'),
51
-                Forms\Components\TextInput::make('amount')
52
-                    ->label('Amount')
53
-                    ->required()
54
-                    ->money()
55
-                    ->live(onBlur: true)
56
-                    ->helperText(function (RelationManager $livewire, $state, ?Transaction $record) {
57
-                        if (! CurrencyConverter::isValidAmount($state)) {
44
+                Forms\Components\Grid::make()
45
+                    ->schema([
46
+                        Forms\Components\Select::make('bank_account_id')
47
+                            ->label('Account')
48
+                            ->required()
49
+                            ->live()
50
+                            ->options(function () {
51
+                                return BankAccount::query()
52
+                                    ->join('accounts', 'bank_accounts.account_id', '=', 'accounts.id')
53
+                                    ->select(['bank_accounts.id', 'accounts.name', 'accounts.currency_code'])
54
+                                    ->get()
55
+                                    ->mapWithKeys(function ($account) {
56
+                                        $label = $account->name;
57
+                                        if ($account->currency_code) {
58
+                                            $label .= " ({$account->currency_code})";
59
+                                        }
60
+
61
+                                        return [$account->id => $label];
62
+                                    })
63
+                                    ->toArray();
64
+                            })
65
+                            ->searchable(),
66
+                        Forms\Components\TextInput::make('amount')
67
+                            ->label('Amount')
68
+                            ->required()
69
+                            ->money(function (RelationManager $livewire) {
70
+                                /** @var Bill $bill */
71
+                                $bill = $livewire->getOwnerRecord();
72
+
73
+                                return $bill->currency_code;
74
+                            })
75
+                            ->live(onBlur: true)
76
+                            ->helperText(function (RelationManager $livewire, $state, ?Transaction $record) {
77
+                                /** @var Bill $ownerRecord */
78
+                                $ownerRecord = $livewire->getOwnerRecord();
79
+
80
+                                $billCurrency = $ownerRecord->currency_code;
81
+
82
+                                if (! CurrencyConverter::isValidAmount($state, $billCurrency)) {
83
+                                    return null;
84
+                                }
85
+
86
+                                $amountDue = $ownerRecord->getRawOriginal('amount_due');
87
+
88
+                                $amount = CurrencyConverter::convertToCents($state, $billCurrency);
89
+
90
+                                if ($amount <= 0) {
91
+                                    return 'Please enter a valid positive amount';
92
+                                }
93
+
94
+                                $currentPaymentAmount = $record?->getRawOriginal('amount') ?? 0;
95
+
96
+                                $newAmountDue = $amountDue - $amount + $currentPaymentAmount;
97
+
98
+                                return match (true) {
99
+                                    $newAmountDue > 0 => 'Amount due after payment will be ' . CurrencyConverter::formatCentsToMoney($newAmountDue, $billCurrency),
100
+                                    $newAmountDue === 0 => 'Bill will be fully paid',
101
+                                    default => 'Amount exceeds bill total by ' . CurrencyConverter::formatCentsToMoney(abs($newAmountDue), $billCurrency),
102
+                                };
103
+                            })
104
+                            ->rules([
105
+                                static fn (RelationManager $livewire): Closure => static function (string $attribute, $value, Closure $fail) use ($livewire) {
106
+                                    /** @var Bill $bill */
107
+                                    $bill = $livewire->getOwnerRecord();
108
+
109
+                                    if (! CurrencyConverter::isValidAmount($value, $bill->currency_code)) {
110
+                                        $fail('Please enter a valid amount');
111
+                                    }
112
+                                },
113
+                            ]),
114
+                    ])->columns(2),
115
+                Forms\Components\Placeholder::make('currency_conversion')
116
+                    ->label('Currency Conversion')
117
+                    ->content(function (Forms\Get $get, RelationManager $livewire) {
118
+                        $amount = $get('amount');
119
+                        $bankAccountId = $get('bank_account_id');
120
+
121
+                        /** @var Bill $bill */
122
+                        $bill = $livewire->getOwnerRecord();
123
+                        $billCurrency = $bill->currency_code;
124
+
125
+                        if (empty($amount) || empty($bankAccountId) || ! CurrencyConverter::isValidAmount($amount, $billCurrency)) {
58 126
                             return null;
59 127
                         }
60 128
 
61
-                        /** @var Bill $ownerRecord */
62
-                        $ownerRecord = $livewire->getOwnerRecord();
63
-
64
-                        $amountDue = $ownerRecord->getRawOriginal('amount_due');
129
+                        $bankAccount = BankAccount::with('account')->find($bankAccountId);
130
+                        if (! $bankAccount) {
131
+                            return null;
132
+                        }
65 133
 
66
-                        $amount = CurrencyConverter::convertToCents($state);
134
+                        $bankCurrency = $bankAccount->account->currency_code ?? CurrencyAccessor::getDefaultCurrency();
67 135
 
68
-                        if ($amount <= 0) {
69
-                            return 'Please enter a valid positive amount';
136
+                        // If currencies are the same, no conversion needed
137
+                        if ($billCurrency === $bankCurrency) {
138
+                            return null;
70 139
                         }
71 140
 
72
-                        $currentPaymentAmount = $record?->getRawOriginal('amount') ?? 0;
141
+                        // Convert amount from bill currency to bank currency
142
+                        $amountInBillCurrencyCents = CurrencyConverter::convertToCents($amount, $billCurrency);
143
+                        $amountInBankCurrencyCents = CurrencyConverter::convertBalance(
144
+                            $amountInBillCurrencyCents,
145
+                            $billCurrency,
146
+                            $bankCurrency
147
+                        );
73 148
 
74
-                        $newAmountDue = $amountDue - $amount + $currentPaymentAmount;
149
+                        $formattedBankAmount = CurrencyConverter::formatCentsToMoney($amountInBankCurrencyCents, $bankCurrency);
75 150
 
76
-                        return match (true) {
77
-                            $newAmountDue > 0 => 'Amount due after payment will be ' . CurrencyConverter::formatCentsToMoney($newAmountDue),
78
-                            $newAmountDue === 0 => 'Bill will be fully paid',
79
-                            default => 'Amount exceeds bill total by ' . CurrencyConverter::formatCentsToMoney(abs($newAmountDue)),
80
-                        };
151
+                        return "Payment will be recorded as {$formattedBankAmount} in the bank account's currency ({$bankCurrency}).";
81 152
                     })
82
-                    ->rules([
83
-                        static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
84
-                            if (! CurrencyConverter::isValidAmount($value)) {
85
-                                $fail('Please enter a valid amount');
86
-                            }
87
-                        },
88
-                    ]),
153
+                    ->hidden(function (Forms\Get $get, RelationManager $livewire) {
154
+                        $bankAccountId = $get('bank_account_id');
155
+                        if (empty($bankAccountId)) {
156
+                            return true;
157
+                        }
158
+
159
+                        /** @var Bill $bill */
160
+                        $bill = $livewire->getOwnerRecord();
161
+                        $billCurrency = $bill->currency_code;
162
+
163
+                        $bankAccount = BankAccount::with('account')->find($bankAccountId);
164
+                        if (! $bankAccount) {
165
+                            return true;
166
+                        }
167
+
168
+                        $bankCurrency = $bankAccount->account->currency_code ?? CurrencyAccessor::getDefaultCurrency();
169
+
170
+                        // Hide if currencies are the same
171
+                        return $billCurrency === $bankCurrency;
172
+                    }),
89 173
                 Forms\Components\Select::make('payment_method')
90 174
                     ->label('Payment method')
91 175
                     ->required()
92 176
                     ->options(PaymentMethod::class),
93
-                Forms\Components\Select::make('bank_account_id')
94
-                    ->label('Account')
95
-                    ->required()
96
-                    ->options(BankAccount::query()
97
-                        ->get()
98
-                        ->pluck('account.name', 'id'))
99
-                    ->searchable(),
100 177
                 Forms\Components\Textarea::make('notes')
101 178
                     ->label('Notes'),
102 179
             ]);

+ 43
- 23
app/Filament/Company/Resources/Purchases/BillResource/Widgets/BillOverview.php Bestand weergeven

@@ -21,8 +21,47 @@ class BillOverview extends EnhancedStatsOverviewWidget
21 21
 
22 22
     protected function getStats(): array
23 23
     {
24
+        $activeTab = $this->activeTab;
25
+
26
+        $averagePaymentTimeFormatted = '-';
27
+        $averagePaymentTimeSuffix = null;
28
+        $lastMonthTotal = '-';
29
+        $lastMonthTotalSuffix = null;
30
+
31
+        if ($activeTab !== 'unpaid') {
32
+            $averagePaymentTime = $this->getPageTableQuery()
33
+                ->whereNotNull('paid_at')
34
+                ->selectRaw('AVG(TIMESTAMPDIFF(DAY, date, paid_at)) as avg_days')
35
+                ->value('avg_days');
36
+
37
+            $averagePaymentTimeFormatted = Number::format($averagePaymentTime ?? 0, maxPrecision: 1);
38
+            $averagePaymentTimeSuffix = 'days';
39
+
40
+            $lastMonthPaid = $this->getPageTableQuery()
41
+                ->whereBetween('date', [
42
+                    today()->subMonth()->startOfMonth(),
43
+                    today()->subMonth()->endOfMonth(),
44
+                ])
45
+                ->get()
46
+                ->sumMoneyInDefaultCurrency('amount_paid');
47
+
48
+            $lastMonthTotal = CurrencyConverter::formatCentsToMoney($lastMonthPaid);
49
+            $lastMonthTotalSuffix = CurrencyAccessor::getDefaultCurrency();
50
+        }
51
+
52
+        if ($activeTab === 'paid') {
53
+            return [
54
+                EnhancedStatsOverviewWidget\EnhancedStat::make('Total To Pay', '-'),
55
+                EnhancedStatsOverviewWidget\EnhancedStat::make('Due Within 7 Days', '-'),
56
+                EnhancedStatsOverviewWidget\EnhancedStat::make('Average Payment Time', $averagePaymentTimeFormatted)
57
+                    ->suffix($averagePaymentTimeSuffix),
58
+                EnhancedStatsOverviewWidget\EnhancedStat::make('Paid Last Month', $lastMonthTotal)
59
+                    ->suffix($lastMonthTotalSuffix),
60
+            ];
61
+        }
62
+
24 63
         $unpaidBills = $this->getPageTableQuery()
25
-            ->whereIn('status', [BillStatus::Open, BillStatus::Partial, BillStatus::Overdue]);
64
+            ->unpaid();
26 65
 
27 66
         $amountToPay = $unpaidBills->get()->sumMoneyInDefaultCurrency('amount_due');
28 67
 
@@ -38,35 +77,16 @@ class BillOverview extends EnhancedStatsOverviewWidget
38 77
             ->get()
39 78
             ->sumMoneyInDefaultCurrency('amount_due');
40 79
 
41
-        $averagePaymentTime = $this->getPageTableQuery()
42
-            ->whereNotNull('paid_at')
43
-            ->selectRaw('AVG(TIMESTAMPDIFF(DAY, date, paid_at)) as avg_days')
44
-            ->value('avg_days');
45
-
46
-        $averagePaymentTimeFormatted = Number::format($averagePaymentTime ?? 0, maxPrecision: 1);
47
-
48
-        $lastMonthTotal = $this->getPageTableQuery()
49
-            ->where('status', BillStatus::Paid)
50
-            ->whereBetween('date', [
51
-                today()->subMonth()->startOfMonth(),
52
-                today()->subMonth()->endOfMonth(),
53
-            ])
54
-            ->get()
55
-            ->sumMoneyInDefaultCurrency('amount_paid');
56
-
57 80
         return [
58 81
             EnhancedStatsOverviewWidget\EnhancedStat::make('Total To Pay', CurrencyConverter::formatCentsToMoney($amountToPay))
59 82
                 ->suffix(CurrencyAccessor::getDefaultCurrency())
60 83
                 ->description('Includes ' . CurrencyConverter::formatCentsToMoney($amountOverdue) . ' overdue'),
61
-
62 84
             EnhancedStatsOverviewWidget\EnhancedStat::make('Due Within 7 Days', CurrencyConverter::formatCentsToMoney($amountDueWithin7Days))
63 85
                 ->suffix(CurrencyAccessor::getDefaultCurrency()),
64
-
65 86
             EnhancedStatsOverviewWidget\EnhancedStat::make('Average Payment Time', $averagePaymentTimeFormatted)
66
-                ->suffix('days'),
67
-
68
-            EnhancedStatsOverviewWidget\EnhancedStat::make('Paid Last Month', CurrencyConverter::formatCentsToMoney($lastMonthTotal))
69
-                ->suffix(CurrencyAccessor::getDefaultCurrency()),
87
+                ->suffix($averagePaymentTimeSuffix),
88
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Paid Last Month', $lastMonthTotal)
89
+                ->suffix($lastMonthTotalSuffix),
70 90
         ];
71 91
     }
72 92
 }

+ 2
- 2
app/Filament/Company/Resources/Purchases/VendorResource/Pages/EditVendor.php Bestand weergeven

@@ -2,7 +2,7 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Purchases\VendorResource\Pages;
4 4
 
5
-use App\Concerns\RedirectToListPage;
5
+use App\Concerns\RedirectToViewPage;
6 6
 use App\Filament\Company\Resources\Purchases\VendorResource;
7 7
 use Filament\Actions;
8 8
 use Filament\Resources\Pages\EditRecord;
@@ -10,7 +10,7 @@ use Filament\Support\Enums\MaxWidth;
10 10
 
11 11
 class EditVendor extends EditRecord
12 12
 {
13
-    use RedirectToListPage;
13
+    use RedirectToViewPage;
14 14
 
15 15
     protected static string $resource = VendorResource::class;
16 16
 

+ 1
- 1
app/Filament/Company/Resources/Purchases/VendorResource/Pages/ViewVendor.php Bestand weergeven

@@ -20,7 +20,7 @@ class ViewVendor extends ViewRecord
20 20
 {
21 21
     protected static string $resource = VendorResource::class;
22 22
 
23
-    public function getRelationManagers(): array
23
+    protected function getAllRelationManagers(): array
24 24
     {
25 25
         return [
26 26
             RelationManagers\BillsRelationManager::class,

+ 1
- 1
app/Filament/Company/Resources/Sales/ClientResource.php Bestand weergeven

@@ -225,7 +225,7 @@ class ClientResource extends Resource
225 225
                                         $fieldsToSync = [
226 226
                                             'address_line_1',
227 227
                                             'address_line_2',
228
-                                            'country',
228
+                                            'country_code',
229 229
                                             'state_id',
230 230
                                             'city',
231 231
                                             'postal_code',

+ 2
- 0
app/Filament/Company/Resources/Sales/ClientResource/Pages/CreateClient.php Bestand weergeven

@@ -5,6 +5,7 @@ namespace App\Filament\Company\Resources\Sales\ClientResource\Pages;
5 5
 use App\Concerns\RedirectToListPage;
6 6
 use App\Enums\Common\AddressType;
7 7
 use App\Filament\Company\Resources\Sales\ClientResource;
8
+use App\Models\Common\Address;
8 9
 use App\Models\Common\Client;
9 10
 use Filament\Resources\Pages\CreateRecord;
10 11
 use Filament\Support\Enums\MaxWidth;
@@ -27,6 +28,7 @@ class CreateClient extends CreateRecord
27 28
         $record = parent::handleRecordCreation($data);
28 29
 
29 30
         // Create billing address first
31
+        /** @var Address $billingAddress */
30 32
         $billingAddress = $record->addresses()->create([
31 33
             ...$data['billingAddress'],
32 34
             'type' => AddressType::Billing,

+ 2
- 2
app/Filament/Company/Resources/Sales/ClientResource/Pages/EditClient.php Bestand weergeven

@@ -2,7 +2,7 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Sales\ClientResource\Pages;
4 4
 
5
-use App\Concerns\RedirectToListPage;
5
+use App\Concerns\RedirectToViewPage;
6 6
 use App\Filament\Company\Resources\Sales\ClientResource;
7 7
 use App\Models\Common\Client;
8 8
 use Filament\Actions;
@@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Model;
12 12
 
13 13
 class EditClient extends EditRecord
14 14
 {
15
-    use RedirectToListPage;
15
+    use RedirectToViewPage;
16 16
 
17 17
     protected static string $resource = ClientResource::class;
18 18
 

+ 1
- 1
app/Filament/Company/Resources/Sales/ClientResource/Pages/ViewClient.php Bestand weergeven

@@ -23,7 +23,7 @@ class ViewClient extends ViewRecord
23 23
 {
24 24
     protected static string $resource = ClientResource::class;
25 25
 
26
-    public function getRelationManagers(): array
26
+    protected function getAllRelationManagers(): array
27 27
     {
28 28
         return [
29 29
             RelationManagers\InvoicesRelationManager::class,

+ 57
- 14
app/Filament/Company/Resources/Sales/EstimateResource.php Bestand weergeven

@@ -2,11 +2,16 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Sales;
4 4
 
5
+use App\Enums\Accounting\AdjustmentCategory;
6
+use App\Enums\Accounting\AdjustmentStatus;
7
+use App\Enums\Accounting\AdjustmentType;
5 8
 use App\Enums\Accounting\DocumentDiscountMethod;
6 9
 use App\Enums\Accounting\DocumentType;
7 10
 use App\Enums\Accounting\EstimateStatus;
11
+use App\Filament\Company\Resources\Sales\ClientResource\RelationManagers\EstimatesRelationManager;
8 12
 use App\Filament\Company\Resources\Sales\EstimateResource\Pages;
9 13
 use App\Filament\Company\Resources\Sales\EstimateResource\Widgets;
14
+use App\Filament\Forms\Components\CreateAdjustmentSelect;
10 15
 use App\Filament\Forms\Components\CreateCurrencySelect;
11 16
 use App\Filament\Forms\Components\DocumentFooterSection;
12 17
 use App\Filament\Forms\Components\DocumentHeaderSection;
@@ -15,6 +20,7 @@ use App\Filament\Tables\Actions\ReplicateBulkAction;
15 20
 use App\Filament\Tables\Columns;
16 21
 use App\Filament\Tables\Filters\DateRangeFilter;
17 22
 use App\Models\Accounting\Adjustment;
23
+use App\Models\Accounting\DocumentLineItem;
18 24
 use App\Models\Accounting\Estimate;
19 25
 use App\Models\Common\Client;
20 26
 use App\Models\Common\Offering;
@@ -123,17 +129,17 @@ class EstimateResource extends Resource
123 129
                                     Header::make($settings->resolveColumnLabel('item_name', 'Items'))
124 130
                                         ->width($hasDiscounts ? '15%' : '20%'),
125 131
                                     Header::make('Description')
126
-                                        ->width($hasDiscounts ? '25%' : '30%'),
132
+                                        ->width($hasDiscounts ? '15%' : '20%'),
127 133
                                     Header::make($settings->resolveColumnLabel('unit_name', 'Quantity'))
128 134
                                         ->width('10%'),
129 135
                                     Header::make($settings->resolveColumnLabel('price_name', 'Price'))
130 136
                                         ->width('10%'),
131 137
                                     Header::make('Taxes')
132
-                                        ->width($hasDiscounts ? '15%' : '20%'),
138
+                                        ->width($hasDiscounts ? '20%' : '30%'),
133 139
                                 ];
134 140
 
135 141
                                 if ($hasDiscounts) {
136
-                                    $headers[] = Header::make('Discounts')->width('15%');
142
+                                    $headers[] = Header::make('Discounts')->width('20%');
137 143
                                 }
138 144
 
139 145
                                 $headers[] = Header::make($settings->resolveColumnLabel('amount_name', 'Amount'))
@@ -149,9 +155,40 @@ class EstimateResource extends Resource
149 155
                                     ->searchable()
150 156
                                     ->required()
151 157
                                     ->live()
152
-                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
158
+                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state, ?DocumentLineItem $record) {
153 159
                                         $offeringId = $state;
154
-                                        $offeringRecord = Offering::with(['salesTaxes', 'salesDiscounts'])->find($offeringId);
160
+                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
161
+                                        $isPerLineItem = $discountMethod->isPerLineItem();
162
+
163
+                                        $existingTaxIds = [];
164
+                                        $existingDiscountIds = [];
165
+
166
+                                        if ($record) {
167
+                                            $existingTaxIds = $record->salesTaxes()->pluck('adjustments.id')->toArray();
168
+                                            if ($isPerLineItem) {
169
+                                                $existingDiscountIds = $record->salesDiscounts()->pluck('adjustments.id')->toArray();
170
+                                            }
171
+                                        }
172
+
173
+                                        $with = [
174
+                                            'salesTaxes' => static function ($query) use ($existingTaxIds) {
175
+                                                $query->where(static function ($query) use ($existingTaxIds) {
176
+                                                    $query->where('status', AdjustmentStatus::Active)
177
+                                                        ->orWhereIn('adjustments.id', $existingTaxIds);
178
+                                                });
179
+                                            },
180
+                                        ];
181
+
182
+                                        if ($isPerLineItem) {
183
+                                            $with['salesDiscounts'] = static function ($query) use ($existingDiscountIds) {
184
+                                                $query->where(static function ($query) use ($existingDiscountIds) {
185
+                                                    $query->where('status', AdjustmentStatus::Active)
186
+                                                        ->orWhereIn('adjustments.id', $existingDiscountIds);
187
+                                                });
188
+                                            };
189
+                                        }
190
+
191
+                                        $offeringRecord = Offering::with($with)->find($offeringId);
155 192
 
156 193
                                         if (! $offeringRecord) {
157 194
                                             return;
@@ -163,8 +200,7 @@ class EstimateResource extends Resource
163 200
                                         $set('unit_price', $unitPrice);
164 201
                                         $set('salesTaxes', $offeringRecord->salesTaxes->pluck('id')->toArray());
165 202
 
166
-                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
167
-                                        if ($discountMethod->isPerLineItem()) {
203
+                                        if ($isPerLineItem) {
168 204
                                             $set('salesDiscounts', $offeringRecord->salesDiscounts->pluck('id')->toArray());
169 205
                                         }
170 206
                                     }),
@@ -181,19 +217,24 @@ class EstimateResource extends Resource
181 217
                                     ->live()
182 218
                                     ->maxValue(9999999999.99)
183 219
                                     ->default(0),
184
-                                Forms\Components\Select::make('salesTaxes')
185
-                                    ->relationship('salesTaxes', 'name')
220
+                                CreateAdjustmentSelect::make('salesTaxes')
221
+                                    ->label('Taxes')
222
+                                    ->category(AdjustmentCategory::Tax)
223
+                                    ->type(AdjustmentType::Sales)
224
+                                    ->adjustmentsRelationship('salesTaxes')
186 225
                                     ->saveRelationshipsUsing(null)
187 226
                                     ->dehydrated(true)
188 227
                                     ->preload()
189 228
                                     ->multiple()
190 229
                                     ->live()
191 230
                                     ->searchable(),
192
-                                Forms\Components\Select::make('salesDiscounts')
193
-                                    ->relationship('salesDiscounts', 'name')
231
+                                CreateAdjustmentSelect::make('salesDiscounts')
232
+                                    ->label('Discounts')
233
+                                    ->category(AdjustmentCategory::Discount)
234
+                                    ->type(AdjustmentType::Sales)
235
+                                    ->adjustmentsRelationship('salesDiscounts')
194 236
                                     ->saveRelationshipsUsing(null)
195 237
                                     ->dehydrated(true)
196
-                                    ->preload()
197 238
                                     ->multiple()
198 239
                                     ->live()
199 240
                                     ->hidden(function (Forms\Get $get) {
@@ -275,7 +316,8 @@ class EstimateResource extends Resource
275 316
                     ->sortable(),
276 317
                 Tables\Columns\TextColumn::make('client.name')
277 318
                     ->sortable()
278
-                    ->searchable(),
319
+                    ->searchable()
320
+                    ->hiddenOn(EstimatesRelationManager::class),
279 321
                 Tables\Columns\TextColumn::make('total')
280 322
                     ->currencyWithConversion(static fn (Estimate $record) => $record->currency_code)
281 323
                     ->sortable()
@@ -285,7 +327,8 @@ class EstimateResource extends Resource
285 327
                 Tables\Filters\SelectFilter::make('client')
286 328
                     ->relationship('client', 'name')
287 329
                     ->searchable()
288
-                    ->preload(),
330
+                    ->preload()
331
+                    ->hiddenOn(EstimatesRelationManager::class),
289 332
                 Tables\Filters\SelectFilter::make('status')
290 333
                     ->options(EstimateStatus::class)
291 334
                     ->native(false),

+ 2
- 5
app/Filament/Company/Resources/Sales/EstimateResource/Pages/EditEstimate.php Bestand weergeven

@@ -3,6 +3,7 @@
3 3
 namespace App\Filament\Company\Resources\Sales\EstimateResource\Pages;
4 4
 
5 5
 use App\Concerns\ManagesLineItems;
6
+use App\Concerns\RedirectToViewPage;
6 7
 use App\Filament\Company\Resources\Sales\EstimateResource;
7 8
 use App\Models\Accounting\Estimate;
8 9
 use Filament\Actions;
@@ -13,6 +14,7 @@ use Illuminate\Database\Eloquent\Model;
13 14
 class EditEstimate extends EditRecord
14 15
 {
15 16
     use ManagesLineItems;
17
+    use RedirectToViewPage;
16 18
 
17 19
     protected static string $resource = EstimateResource::class;
18 20
 
@@ -28,11 +30,6 @@ class EditEstimate extends EditRecord
28 30
         return MaxWidth::Full;
29 31
     }
30 32
 
31
-    protected function getRedirectUrl(): string
32
-    {
33
-        return $this->getResource()::getUrl('view', ['record' => $this->record]);
34
-    }
35
-
36 33
     protected function handleRecordUpdate(Model $record, array $data): Model
37 34
     {
38 35
         /** @var Estimate $record */

+ 27
- 0
app/Filament/Company/Resources/Sales/EstimateResource/Pages/ViewEstimate.php Bestand weergeven

@@ -5,6 +5,7 @@ namespace App\Filament\Company\Resources\Sales\EstimateResource\Pages;
5 5
 use App\Enums\Accounting\DocumentType;
6 6
 use App\Filament\Company\Resources\Sales\ClientResource;
7 7
 use App\Filament\Company\Resources\Sales\EstimateResource;
8
+use App\Filament\Infolists\Components\BannerEntry;
8 9
 use App\Filament\Infolists\Components\DocumentPreview;
9 10
 use App\Models\Accounting\Estimate;
10 11
 use Filament\Actions;
@@ -17,6 +18,7 @@ use Filament\Support\Enums\FontWeight;
17 18
 use Filament\Support\Enums\IconPosition;
18 19
 use Filament\Support\Enums\IconSize;
19 20
 use Filament\Support\Enums\MaxWidth;
21
+use Illuminate\Support\HtmlString;
20 22
 
21 23
 class ViewEstimate extends ViewRecord
22 24
 {
@@ -63,6 +65,31 @@ class ViewEstimate extends ViewRecord
63 65
     {
64 66
         return $infolist
65 67
             ->schema([
68
+                BannerEntry::make('inactiveAdjustments')
69
+                    ->label('Inactive adjustments')
70
+                    ->warning()
71
+                    ->icon('heroicon-o-exclamation-triangle')
72
+                    ->visible(fn (Estimate $record) => $record->hasInactiveAdjustments() && $record->canBeApproved())
73
+                    ->columnSpanFull()
74
+                    ->description(function (Estimate $record) {
75
+                        $inactiveAdjustments = collect();
76
+
77
+                        foreach ($record->lineItems as $lineItem) {
78
+                            foreach ($lineItem->adjustments as $adjustment) {
79
+                                if ($adjustment->isInactive() && $inactiveAdjustments->doesntContain($adjustment->name)) {
80
+                                    $inactiveAdjustments->push($adjustment->name);
81
+                                }
82
+                            }
83
+                        }
84
+
85
+                        $adjustmentsList = $inactiveAdjustments->map(static function ($name) {
86
+                            return "<span class='font-medium'>{$name}</span>";
87
+                        })->join(', ');
88
+
89
+                        $output = "<p class='text-sm'>This estimate contains inactive adjustments that need to be addressed before approval: {$adjustmentsList}</p>";
90
+
91
+                        return new HtmlString($output);
92
+                    }),
66 93
                 Section::make('Estimate Details')
67 94
                     ->columns(4)
68 95
                     ->schema([

+ 45
- 12
app/Filament/Company/Resources/Sales/EstimateResource/Widgets/EstimateOverview.php Bestand weergeven

@@ -21,6 +21,26 @@ class EstimateOverview extends EnhancedStatsOverviewWidget
21 21
 
22 22
     protected function getStats(): array
23 23
     {
24
+        $activeTab = $this->activeTab;
25
+
26
+        if ($activeTab === 'draft') {
27
+            $draftEstimates = $this->getPageTableQuery();
28
+            $totalDraftCount = $draftEstimates->count();
29
+            $totalDraftAmount = $draftEstimates->get()->sumMoneyInDefaultCurrency('total');
30
+
31
+            $averageDraftTotal = $totalDraftCount > 0
32
+                ? (int) round($totalDraftAmount / $totalDraftCount)
33
+                : 0;
34
+
35
+            return [
36
+                EnhancedStatsOverviewWidget\EnhancedStat::make('Active Estimates', '-'),
37
+                EnhancedStatsOverviewWidget\EnhancedStat::make('Accepted Estimates', '-'),
38
+                EnhancedStatsOverviewWidget\EnhancedStat::make('Converted Estimates', '-'),
39
+                EnhancedStatsOverviewWidget\EnhancedStat::make('Average Estimate Total', CurrencyConverter::formatCentsToMoney($averageDraftTotal))
40
+                    ->suffix(CurrencyAccessor::getDefaultCurrency()),
41
+            ];
42
+        }
43
+
24 44
         $activeEstimates = $this->getPageTableQuery()->active();
25 45
 
26 46
         $totalActiveCount = $activeEstimates->count();
@@ -36,20 +56,32 @@ class EstimateOverview extends EnhancedStatsOverviewWidget
36 56
             ->where('status', EstimateStatus::Converted);
37 57
 
38 58
         $totalConvertedCount = $convertedEstimates->count();
39
-        $totalEstimatesCount = $this->getPageTableQuery()->count();
40 59
 
41
-        $percentConverted = $totalEstimatesCount > 0
42
-            ? Number::percentage(($totalConvertedCount / $totalEstimatesCount) * 100, maxPrecision: 1)
43
-            : Number::percentage(0, maxPrecision: 1);
60
+        $validEstimates = $this->getPageTableQuery()
61
+            ->whereNotIn('status', [
62
+                EstimateStatus::Draft,
63
+            ]);
44 64
 
45
-        $totalEstimateAmount = $this->getPageTableQuery()
46
-            ->get()
47
-            ->sumMoneyInDefaultCurrency('total');
65
+        $totalValidEstimatesCount = $validEstimates->count();
66
+        $totalValidEstimateAmount = $validEstimates->get()->sumMoneyInDefaultCurrency('total');
48 67
 
49
-        $averageEstimateTotal = $totalEstimatesCount > 0
50
-            ? (int) round($totalEstimateAmount / $totalEstimatesCount)
68
+        $averageEstimateTotal = $totalValidEstimatesCount > 0
69
+            ? (int) round($totalValidEstimateAmount / $totalValidEstimatesCount)
51 70
             : 0;
52 71
 
72
+        $percentConverted = '-';
73
+        $percentConvertedSuffix = null;
74
+        $percentConvertedDescription = null;
75
+
76
+        if ($activeTab !== 'active') {
77
+            $percentConverted = $totalValidEstimatesCount > 0
78
+                ? Number::percentage(($totalConvertedCount / $totalValidEstimatesCount) * 100, maxPrecision: 1)
79
+                : Number::percentage(0, maxPrecision: 1);
80
+
81
+            $percentConvertedSuffix = 'converted';
82
+            $percentConvertedDescription = $totalConvertedCount . ' converted';
83
+        }
84
+
53 85
         return [
54 86
             EnhancedStatsOverviewWidget\EnhancedStat::make('Active Estimates', CurrencyConverter::formatCentsToMoney($totalActiveAmount))
55 87
                 ->suffix(CurrencyAccessor::getDefaultCurrency())
@@ -60,11 +92,12 @@ class EstimateOverview extends EnhancedStatsOverviewWidget
60 92
                 ->description($totalAcceptedCount . ' accepted'),
61 93
 
62 94
             EnhancedStatsOverviewWidget\EnhancedStat::make('Converted Estimates', $percentConverted)
63
-                ->suffix('converted')
64
-                ->description($totalConvertedCount . ' converted'),
95
+                ->suffix($percentConvertedSuffix)
96
+                ->description($percentConvertedDescription),
65 97
 
66 98
             EnhancedStatsOverviewWidget\EnhancedStat::make('Average Estimate Total', CurrencyConverter::formatCentsToMoney($averageEstimateTotal))
67
-                ->suffix(CurrencyAccessor::getDefaultCurrency()),
99
+                ->suffix(CurrencyAccessor::getDefaultCurrency())
100
+                ->description($activeTab === 'all' ? 'Excludes draft estimates' : null),
68 101
         ];
69 102
     }
70 103
 }

+ 69
- 28
app/Filament/Company/Resources/Sales/InvoiceResource.php Bestand weergeven

@@ -3,14 +3,17 @@
3 3
 namespace App\Filament\Company\Resources\Sales;
4 4
 
5 5
 use App\Collections\Accounting\DocumentCollection;
6
+use App\Enums\Accounting\AdjustmentCategory;
7
+use App\Enums\Accounting\AdjustmentStatus;
8
+use App\Enums\Accounting\AdjustmentType;
6 9
 use App\Enums\Accounting\DocumentDiscountMethod;
7 10
 use App\Enums\Accounting\DocumentType;
8 11
 use App\Enums\Accounting\InvoiceStatus;
9 12
 use App\Enums\Accounting\PaymentMethod;
10 13
 use App\Filament\Company\Resources\Sales\ClientResource\RelationManagers\InvoicesRelationManager;
11 14
 use App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
12
-use App\Filament\Company\Resources\Sales\InvoiceResource\RelationManagers;
13 15
 use App\Filament\Company\Resources\Sales\InvoiceResource\Widgets;
16
+use App\Filament\Forms\Components\CreateAdjustmentSelect;
14 17
 use App\Filament\Forms\Components\CreateCurrencySelect;
15 18
 use App\Filament\Forms\Components\DocumentFooterSection;
16 19
 use App\Filament\Forms\Components\DocumentHeaderSection;
@@ -19,6 +22,7 @@ use App\Filament\Tables\Actions\ReplicateBulkAction;
19 22
 use App\Filament\Tables\Columns;
20 23
 use App\Filament\Tables\Filters\DateRangeFilter;
21 24
 use App\Models\Accounting\Adjustment;
25
+use App\Models\Accounting\DocumentLineItem;
22 26
 use App\Models\Accounting\Invoice;
23 27
 use App\Models\Banking\BankAccount;
24 28
 use App\Models\Common\Client;
@@ -137,17 +141,17 @@ class InvoiceResource extends Resource
137 141
                                     Header::make($settings->resolveColumnLabel('item_name', 'Items'))
138 142
                                         ->width($hasDiscounts ? '15%' : '20%'),
139 143
                                     Header::make('Description')
140
-                                        ->width($hasDiscounts ? '25%' : '30%'),
144
+                                        ->width($hasDiscounts ? '15%' : '20%'),
141 145
                                     Header::make($settings->resolveColumnLabel('unit_name', 'Quantity'))
142 146
                                         ->width('10%'),
143 147
                                     Header::make($settings->resolveColumnLabel('price_name', 'Price'))
144 148
                                         ->width('10%'),
145 149
                                     Header::make('Taxes')
146
-                                        ->width($hasDiscounts ? '15%' : '20%'),
150
+                                        ->width($hasDiscounts ? '20%' : '30%'),
147 151
                                 ];
148 152
 
149 153
                                 if ($hasDiscounts) {
150
-                                    $headers[] = Header::make('Discounts')->width('15%');
154
+                                    $headers[] = Header::make('Discounts')->width('20%');
151 155
                                 }
152 156
 
153 157
                                 $headers[] = Header::make($settings->resolveColumnLabel('amount_name', 'Amount'))
@@ -163,9 +167,40 @@ class InvoiceResource extends Resource
163 167
                                     ->searchable()
164 168
                                     ->required()
165 169
                                     ->live()
166
-                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
170
+                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state, ?DocumentLineItem $record) {
167 171
                                         $offeringId = $state;
168
-                                        $offeringRecord = Offering::with(['salesTaxes', 'salesDiscounts'])->find($offeringId);
172
+                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
173
+                                        $isPerLineItem = $discountMethod->isPerLineItem();
174
+
175
+                                        $existingTaxIds = [];
176
+                                        $existingDiscountIds = [];
177
+
178
+                                        if ($record) {
179
+                                            $existingTaxIds = $record->salesTaxes()->pluck('adjustments.id')->toArray();
180
+                                            if ($isPerLineItem) {
181
+                                                $existingDiscountIds = $record->salesDiscounts()->pluck('adjustments.id')->toArray();
182
+                                            }
183
+                                        }
184
+
185
+                                        $with = [
186
+                                            'salesTaxes' => static function ($query) use ($existingTaxIds) {
187
+                                                $query->where(static function ($query) use ($existingTaxIds) {
188
+                                                    $query->where('status', AdjustmentStatus::Active)
189
+                                                        ->orWhereIn('adjustments.id', $existingTaxIds);
190
+                                                });
191
+                                            },
192
+                                        ];
193
+
194
+                                        if ($isPerLineItem) {
195
+                                            $with['salesDiscounts'] = static function ($query) use ($existingDiscountIds) {
196
+                                                $query->where(static function ($query) use ($existingDiscountIds) {
197
+                                                    $query->where('status', AdjustmentStatus::Active)
198
+                                                        ->orWhereIn('adjustments.id', $existingDiscountIds);
199
+                                                });
200
+                                            };
201
+                                        }
202
+
203
+                                        $offeringRecord = Offering::with($with)->find($offeringId);
169 204
 
170 205
                                         if (! $offeringRecord) {
171 206
                                             return;
@@ -177,8 +212,7 @@ class InvoiceResource extends Resource
177 212
                                         $set('unit_price', $unitPrice);
178 213
                                         $set('salesTaxes', $offeringRecord->salesTaxes->pluck('id')->toArray());
179 214
 
180
-                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
181
-                                        if ($discountMethod->isPerLineItem()) {
215
+                                        if ($isPerLineItem) {
182 216
                                             $set('salesDiscounts', $offeringRecord->salesDiscounts->pluck('id')->toArray());
183 217
                                         }
184 218
                                     }),
@@ -195,19 +229,24 @@ class InvoiceResource extends Resource
195 229
                                     ->live()
196 230
                                     ->maxValue(9999999999.99)
197 231
                                     ->default(0),
198
-                                Forms\Components\Select::make('salesTaxes')
199
-                                    ->relationship('salesTaxes', 'name')
232
+                                CreateAdjustmentSelect::make('salesTaxes')
233
+                                    ->label('Taxes')
234
+                                    ->category(AdjustmentCategory::Tax)
235
+                                    ->type(AdjustmentType::Sales)
236
+                                    ->adjustmentsRelationship('salesTaxes')
200 237
                                     ->saveRelationshipsUsing(null)
201 238
                                     ->dehydrated(true)
202 239
                                     ->preload()
203 240
                                     ->multiple()
204 241
                                     ->live()
205 242
                                     ->searchable(),
206
-                                Forms\Components\Select::make('salesDiscounts')
207
-                                    ->relationship('salesDiscounts', 'name')
243
+                                CreateAdjustmentSelect::make('salesDiscounts')
244
+                                    ->label('Discounts')
245
+                                    ->category(AdjustmentCategory::Discount)
246
+                                    ->type(AdjustmentType::Sales)
247
+                                    ->adjustmentsRelationship('salesDiscounts')
208 248
                                     ->saveRelationshipsUsing(null)
209 249
                                     ->dehydrated(true)
210
-                                    ->preload()
211 250
                                     ->multiple()
212 251
                                     ->live()
213 252
                                     ->hidden(function (Forms\Get $get) {
@@ -328,10 +367,11 @@ class InvoiceResource extends Resource
328 367
                 Tables\Filters\SelectFilter::make('client')
329 368
                     ->relationship('client', 'name')
330 369
                     ->searchable()
331
-                    ->preload(),
370
+                    ->preload()
371
+                    ->hiddenOn(InvoicesRelationManager::class),
332 372
                 Tables\Filters\SelectFilter::make('status')
333 373
                     ->options(InvoiceStatus::class)
334
-                    ->native(false),
374
+                    ->multiple(),
335 375
                 Tables\Filters\TernaryFilter::make('has_payments')
336 376
                     ->label('Has payments')
337 377
                     ->queries(
@@ -439,9 +479,13 @@ class InvoiceResource extends Resource
439 479
                                 Forms\Components\Select::make('bank_account_id')
440 480
                                     ->label('Account')
441 481
                                     ->required()
442
-                                    ->options(BankAccount::query()
443
-                                        ->get()
444
-                                        ->pluck('account.name', 'id'))
482
+                                    ->options(function () {
483
+                                        return BankAccount::query()
484
+                                            ->join('accounts', 'bank_accounts.account_id', '=', 'accounts.id')
485
+                                            ->select(['bank_accounts.id', 'accounts.name'])
486
+                                            ->pluck('accounts.name', 'bank_accounts.id')
487
+                                            ->toArray();
488
+                                    })
445 489
                                     ->searchable(),
446 490
                                 Forms\Components\Textarea::make('notes')
447 491
                                     ->label('Notes'),
@@ -605,9 +649,13 @@ class InvoiceResource extends Resource
605 649
                             Forms\Components\Select::make('bank_account_id')
606 650
                                 ->label('Account')
607 651
                                 ->required()
608
-                                ->options(BankAccount::query()
609
-                                    ->get()
610
-                                    ->pluck('account.name', 'id'))
652
+                                ->options(function () {
653
+                                    return BankAccount::query()
654
+                                        ->join('accounts', 'bank_accounts.account_id', '=', 'accounts.id')
655
+                                        ->select(['bank_accounts.id', 'accounts.name'])
656
+                                        ->pluck('accounts.name', 'bank_accounts.id')
657
+                                        ->toArray();
658
+                                })
611 659
                                 ->searchable(),
612 660
                             Forms\Components\Textarea::make('notes')
613 661
                                 ->label('Notes'),
@@ -656,13 +704,6 @@ class InvoiceResource extends Resource
656 704
             ]);
657 705
     }
658 706
 
659
-    public static function getRelations(): array
660
-    {
661
-        return [
662
-            RelationManagers\PaymentsRelationManager::class,
663
-        ];
664
-    }
665
-
666 707
     public static function getPages(): array
667 708
     {
668 709
         return [

+ 2
- 2
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/CreateInvoice.php Bestand weergeven

@@ -3,7 +3,7 @@
3 3
 namespace App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
4 4
 
5 5
 use App\Concerns\ManagesLineItems;
6
-use App\Concerns\RedirectToListPage;
6
+use App\Concerns\RedirectToViewPage;
7 7
 use App\Filament\Company\Resources\Sales\InvoiceResource;
8 8
 use App\Models\Accounting\Invoice;
9 9
 use App\Models\Common\Client;
@@ -15,7 +15,7 @@ use Livewire\Attributes\Url;
15 15
 class CreateInvoice extends CreateRecord
16 16
 {
17 17
     use ManagesLineItems;
18
-    use RedirectToListPage;
18
+    use RedirectToViewPage;
19 19
 
20 20
     protected static string $resource = InvoiceResource::class;
21 21
 

+ 2
- 7
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/EditInvoice.php Bestand weergeven

@@ -3,7 +3,7 @@
3 3
 namespace App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
4 4
 
5 5
 use App\Concerns\ManagesLineItems;
6
-use App\Concerns\RedirectToListPage;
6
+use App\Concerns\RedirectToViewPage;
7 7
 use App\Filament\Company\Resources\Sales\InvoiceResource;
8 8
 use App\Models\Accounting\Invoice;
9 9
 use Filament\Actions;
@@ -14,7 +14,7 @@ use Illuminate\Database\Eloquent\Model;
14 14
 class EditInvoice extends EditRecord
15 15
 {
16 16
     use ManagesLineItems;
17
-    use RedirectToListPage;
17
+    use RedirectToViewPage;
18 18
 
19 19
     protected static string $resource = InvoiceResource::class;
20 20
 
@@ -30,11 +30,6 @@ class EditInvoice extends EditRecord
30 30
         return MaxWidth::Full;
31 31
     }
32 32
 
33
-    protected function getRedirectUrl(): string
34
-    {
35
-        return $this->getResource()::getUrl('view', ['record' => $this->record]);
36
-    }
37
-
38 33
     protected function handleRecordUpdate(Model $record, array $data): Model
39 34
     {
40 35
         /** @var Invoice $record */

+ 34
- 0
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ViewInvoice.php Bestand weergeven

@@ -5,6 +5,7 @@ namespace App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
5 5
 use App\Enums\Accounting\DocumentType;
6 6
 use App\Filament\Company\Resources\Sales\ClientResource;
7 7
 use App\Filament\Company\Resources\Sales\InvoiceResource;
8
+use App\Filament\Infolists\Components\BannerEntry;
8 9
 use App\Filament\Infolists\Components\DocumentPreview;
9 10
 use App\Models\Accounting\Invoice;
10 11
 use Filament\Actions;
@@ -17,6 +18,7 @@ use Filament\Support\Enums\FontWeight;
17 18
 use Filament\Support\Enums\IconPosition;
18 19
 use Filament\Support\Enums\IconSize;
19 20
 use Filament\Support\Enums\MaxWidth;
21
+use Illuminate\Support\HtmlString;
20 22
 
21 23
 class ViewInvoice extends ViewRecord
22 24
 {
@@ -60,6 +62,31 @@ class ViewInvoice extends ViewRecord
60 62
     {
61 63
         return $infolist
62 64
             ->schema([
65
+                BannerEntry::make('inactiveAdjustments')
66
+                    ->label('Inactive adjustments')
67
+                    ->warning()
68
+                    ->icon('heroicon-o-exclamation-triangle')
69
+                    ->visible(fn (Invoice $record) => $record->hasInactiveAdjustments() && $record->canBeApproved())
70
+                    ->columnSpanFull()
71
+                    ->description(function (Invoice $record) {
72
+                        $inactiveAdjustments = collect();
73
+
74
+                        foreach ($record->lineItems as $lineItem) {
75
+                            foreach ($lineItem->adjustments as $adjustment) {
76
+                                if ($adjustment->isInactive() && $inactiveAdjustments->doesntContain($adjustment->name)) {
77
+                                    $inactiveAdjustments->push($adjustment->name);
78
+                                }
79
+                            }
80
+                        }
81
+
82
+                        $adjustmentsList = $inactiveAdjustments->map(static function ($name) {
83
+                            return "<span class='font-medium'>{$name}</span>";
84
+                        })->join(', ');
85
+
86
+                        $output = "<p class='text-sm'>This invoice contains inactive adjustments that need to be addressed before approval: {$adjustmentsList}</p>";
87
+
88
+                        return new HtmlString($output);
89
+                    }),
63 90
                 Section::make('Invoice Details')
64 91
                     ->columns(4)
65 92
                     ->schema([
@@ -98,4 +125,11 @@ class ViewInvoice extends ViewRecord
98 125
                     ]),
99 126
             ]);
100 127
     }
128
+
129
+    protected function getAllRelationManagers(): array
130
+    {
131
+        return [
132
+            InvoiceResource\RelationManagers\PaymentsRelationManager::class,
133
+        ];
134
+    }
101 135
 }

+ 123
- 40
app/Filament/Company/Resources/Sales/InvoiceResource/RelationManagers/PaymentsRelationManager.php Bestand weergeven

@@ -5,7 +5,6 @@ namespace App\Filament\Company\Resources\Sales\InvoiceResource\RelationManagers;
5 5
 use App\Enums\Accounting\InvoiceStatus;
6 6
 use App\Enums\Accounting\PaymentMethod;
7 7
 use App\Enums\Accounting\TransactionType;
8
-use App\Filament\Company\Resources\Sales\InvoiceResource\Pages\ViewInvoice;
9 8
 use App\Models\Accounting\Invoice;
10 9
 use App\Models\Accounting\Transaction;
11 10
 use App\Models\Banking\BankAccount;
@@ -41,7 +40,7 @@ class PaymentsRelationManager extends RelationManager
41 40
 
42 41
     public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
43 42
     {
44
-        return $ownerRecord->status !== InvoiceStatus::Draft && $pageClass === ViewInvoice::class;
43
+        return $ownerRecord->status !== InvoiceStatus::Draft;
45 44
     }
46 45
 
47 46
     public function form(Form $form): Form
@@ -51,59 +50,143 @@ class PaymentsRelationManager extends RelationManager
51 50
             ->schema([
52 51
                 Forms\Components\DatePicker::make('posted_at')
53 52
                     ->label('Date'),
54
-                Forms\Components\TextInput::make('amount')
55
-                    ->label('Amount')
56
-                    ->required()
57
-                    ->money()
58
-                    ->live(onBlur: true)
59
-                    ->helperText(function (RelationManager $livewire, $state, ?Transaction $record) {
60
-                        if (! CurrencyConverter::isValidAmount($state)) {
53
+                Forms\Components\Grid::make()
54
+                    ->schema([
55
+                        Forms\Components\Select::make('bank_account_id')
56
+                            ->label('Account')
57
+                            ->required()
58
+                            ->live()
59
+                            ->options(function () {
60
+                                return BankAccount::query()
61
+                                    ->join('accounts', 'bank_accounts.account_id', '=', 'accounts.id')
62
+                                    ->select(['bank_accounts.id', 'accounts.name', 'accounts.currency_code'])
63
+                                    ->get()
64
+                                    ->mapWithKeys(function ($account) {
65
+                                        $label = $account->name;
66
+                                        if ($account->currency_code) {
67
+                                            $label .= " ({$account->currency_code})";
68
+                                        }
69
+
70
+                                        return [$account->id => $label];
71
+                                    })
72
+                                    ->toArray();
73
+                            })
74
+                            ->searchable(),
75
+                        Forms\Components\TextInput::make('amount')
76
+                            ->label('Amount')
77
+                            ->required()
78
+                            ->money(function (RelationManager $livewire) {
79
+                                /** @var Invoice $invoice */
80
+                                $invoice = $livewire->getOwnerRecord();
81
+
82
+                                return $invoice->currency_code;
83
+                            })
84
+                            ->live(onBlur: true)
85
+                            ->helperText(function (RelationManager $livewire, $state, ?Transaction $record) {
86
+                                /** @var Invoice $ownerRecord */
87
+                                $ownerRecord = $livewire->getOwnerRecord();
88
+
89
+                                $invoiceCurrency = $ownerRecord->currency_code;
90
+
91
+                                if (! CurrencyConverter::isValidAmount($state, $invoiceCurrency)) {
92
+                                    return null;
93
+                                }
94
+
95
+                                $amountDue = $ownerRecord->getRawOriginal('amount_due');
96
+
97
+                                $amount = CurrencyConverter::convertToCents($state, $invoiceCurrency);
98
+
99
+                                if ($amount <= 0) {
100
+                                    return 'Please enter a valid positive amount';
101
+                                }
102
+
103
+                                $currentPaymentAmount = $record?->getRawOriginal('amount') ?? 0;
104
+
105
+                                if ($ownerRecord->status === InvoiceStatus::Overpaid) {
106
+                                    $newAmountDue = $amountDue + $amount - $currentPaymentAmount;
107
+                                } else {
108
+                                    $newAmountDue = $amountDue - $amount + $currentPaymentAmount;
109
+                                }
110
+
111
+                                return match (true) {
112
+                                    $newAmountDue > 0 => 'Amount due after payment will be ' . CurrencyConverter::formatCentsToMoney($newAmountDue, $invoiceCurrency),
113
+                                    $newAmountDue === 0 => 'Invoice will be fully paid',
114
+                                    default => 'Invoice will be overpaid by ' . CurrencyConverter::formatCentsToMoney(abs($newAmountDue), $invoiceCurrency),
115
+                                };
116
+                            })
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)) {
123
+                                        $fail('Please enter a valid amount');
124
+                                    }
125
+                                },
126
+                            ]),
127
+                    ])->columns(2),
128
+                Forms\Components\Placeholder::make('currency_conversion')
129
+                    ->label('Currency Conversion')
130
+                    ->content(function (Forms\Get $get, RelationManager $livewire) {
131
+                        $amount = $get('amount');
132
+                        $bankAccountId = $get('bank_account_id');
133
+
134
+                        /** @var Invoice $invoice */
135
+                        $invoice = $livewire->getOwnerRecord();
136
+                        $invoiceCurrency = $invoice->currency_code;
137
+
138
+                        if (empty($amount) || empty($bankAccountId) || ! CurrencyConverter::isValidAmount($amount, $invoiceCurrency)) {
139
+                            return null;
140
+                        }
141
+
142
+                        $bankAccount = BankAccount::with('account')->find($bankAccountId);
143
+                        if (! $bankAccount) {
61 144
                             return null;
62 145
                         }
63 146
 
64
-                        /** @var Invoice $ownerRecord */
65
-                        $ownerRecord = $livewire->getOwnerRecord();
147
+                        $bankCurrency = $bankAccount->account->currency_code ?? CurrencyAccessor::getDefaultCurrency();
148
+
149
+                        // If currencies are the same, no conversion needed
150
+                        if ($invoiceCurrency === $bankCurrency) {
151
+                            return null;
152
+                        }
66 153
 
67
-                        $amountDue = $ownerRecord->getRawOriginal('amount_due');
154
+                        // Convert amount from invoice currency to bank currency
155
+                        $amountInInvoiceCurrencyCents = CurrencyConverter::convertToCents($amount, $invoiceCurrency);
156
+                        $amountInBankCurrencyCents = CurrencyConverter::convertBalance(
157
+                            $amountInInvoiceCurrencyCents,
158
+                            $invoiceCurrency,
159
+                            $bankCurrency
160
+                        );
68 161
 
69
-                        $amount = CurrencyConverter::convertToCents($state);
162
+                        $formattedBankAmount = CurrencyConverter::formatCentsToMoney($amountInBankCurrencyCents, $bankCurrency);
70 163
 
71
-                        if ($amount <= 0) {
72
-                            return 'Please enter a valid positive amount';
164
+                        return "Payment will be recorded as {$formattedBankAmount} in the bank account's currency ({$bankCurrency}).";
165
+                    })
166
+                    ->hidden(function (Forms\Get $get, RelationManager $livewire) {
167
+                        $bankAccountId = $get('bank_account_id');
168
+                        if (empty($bankAccountId)) {
169
+                            return true;
73 170
                         }
74 171
 
75
-                        $currentPaymentAmount = $record?->getRawOriginal('amount') ?? 0;
172
+                        /** @var Invoice $invoice */
173
+                        $invoice = $livewire->getOwnerRecord();
174
+                        $invoiceCurrency = $invoice->currency_code;
76 175
 
77
-                        if ($ownerRecord->status === InvoiceStatus::Overpaid) {
78
-                            $newAmountDue = $amountDue + $amount - $currentPaymentAmount;
79
-                        } else {
80
-                            $newAmountDue = $amountDue - $amount + $currentPaymentAmount;
176
+                        $bankAccount = BankAccount::with('account')->find($bankAccountId);
177
+                        if (! $bankAccount) {
178
+                            return true;
81 179
                         }
82 180
 
83
-                        return match (true) {
84
-                            $newAmountDue > 0 => 'Amount due after payment will be ' . CurrencyConverter::formatCentsToMoney($newAmountDue),
85
-                            $newAmountDue === 0 => 'Invoice will be fully paid',
86
-                            default => 'Invoice will be overpaid by ' . CurrencyConverter::formatCentsToMoney(abs($newAmountDue)),
87
-                        };
88
-                    })
89
-                    ->rules([
90
-                        static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
91
-                            if (! CurrencyConverter::isValidAmount($value)) {
92
-                                $fail('Please enter a valid amount');
93
-                            }
94
-                        },
95
-                    ]),
181
+                        $bankCurrency = $bankAccount->account->currency_code ?? CurrencyAccessor::getDefaultCurrency();
182
+
183
+                        // Hide if currencies are the same
184
+                        return $invoiceCurrency === $bankCurrency;
185
+                    }),
96 186
                 Forms\Components\Select::make('payment_method')
97 187
                     ->label('Payment method')
98 188
                     ->required()
99 189
                     ->options(PaymentMethod::class),
100
-                Forms\Components\Select::make('bank_account_id')
101
-                    ->label('Account')
102
-                    ->required()
103
-                    ->options(BankAccount::query()
104
-                        ->get()
105
-                        ->pluck('account.name', 'id'))
106
-                    ->searchable(),
107 190
                 Forms\Components\Textarea::make('notes')
108 191
                     ->label('Notes'),
109 192
             ]);

+ 34
- 7
app/Filament/Company/Resources/Sales/InvoiceResource/Widgets/InvoiceOverview.php Bestand weergeven

@@ -21,6 +21,26 @@ class InvoiceOverview extends EnhancedStatsOverviewWidget
21 21
 
22 22
     protected function getStats(): array
23 23
     {
24
+        $activeTab = $this->activeTab;
25
+
26
+        if ($activeTab === 'draft') {
27
+            $draftInvoices = $this->getPageTableQuery();
28
+            $totalDraftCount = $draftInvoices->count();
29
+            $totalDraftAmount = $draftInvoices->get()->sumMoneyInDefaultCurrency('total');
30
+
31
+            $averageDraftTotal = $totalDraftCount > 0
32
+                ? (int) round($totalDraftAmount / $totalDraftCount)
33
+                : 0;
34
+
35
+            return [
36
+                EnhancedStatsOverviewWidget\EnhancedStat::make('Total Unpaid', '-'),
37
+                EnhancedStatsOverviewWidget\EnhancedStat::make('Due Within 30 Days', '-'),
38
+                EnhancedStatsOverviewWidget\EnhancedStat::make('Average Payment Time', '-'),
39
+                EnhancedStatsOverviewWidget\EnhancedStat::make('Average Invoice Total', CurrencyConverter::formatCentsToMoney($averageDraftTotal))
40
+                    ->suffix(CurrencyAccessor::getDefaultCurrency()),
41
+            ];
42
+        }
43
+
24 44
         $unpaidInvoices = $this->getPageTableQuery()->unpaid();
25 45
 
26 46
         $amountUnpaid = $unpaidInvoices->get()->sumMoneyInDefaultCurrency('amount_due');
@@ -51,12 +71,18 @@ class InvoiceOverview extends EnhancedStatsOverviewWidget
51 71
             ? (int) round($totalValidInvoiceAmount / $totalValidInvoiceCount)
52 72
             : 0;
53 73
 
54
-        $averagePaymentTime = $this->getPageTableQuery()
55
-            ->whereNotNull('paid_at')
56
-            ->selectRaw('AVG(TIMESTAMPDIFF(DAY, approved_at, paid_at)) as avg_days')
57
-            ->value('avg_days');
74
+        $averagePaymentTimeFormatted = '-';
75
+        $averagePaymentTimeSuffix = null;
58 76
 
59
-        $averagePaymentTimeFormatted = Number::format($averagePaymentTime ?? 0, maxPrecision: 1);
77
+        if ($activeTab !== 'unpaid') {
78
+            $averagePaymentTime = $this->getPageTableQuery()
79
+                ->whereNotNull('paid_at')
80
+                ->selectRaw('AVG(TIMESTAMPDIFF(DAY, approved_at, paid_at)) as avg_days')
81
+                ->value('avg_days');
82
+
83
+            $averagePaymentTimeFormatted = Number::format($averagePaymentTime ?? 0, maxPrecision: 1);
84
+            $averagePaymentTimeSuffix = 'days';
85
+        }
60 86
 
61 87
         return [
62 88
             EnhancedStatsOverviewWidget\EnhancedStat::make('Total Unpaid', CurrencyConverter::formatCentsToMoney($amountUnpaid))
@@ -65,9 +91,10 @@ class InvoiceOverview extends EnhancedStatsOverviewWidget
65 91
             EnhancedStatsOverviewWidget\EnhancedStat::make('Due Within 30 Days', CurrencyConverter::formatCentsToMoney($amountDueWithin30Days))
66 92
                 ->suffix(CurrencyAccessor::getDefaultCurrency()),
67 93
             EnhancedStatsOverviewWidget\EnhancedStat::make('Average Payment Time', $averagePaymentTimeFormatted)
68
-                ->suffix('days'),
94
+                ->suffix($averagePaymentTimeSuffix),
69 95
             EnhancedStatsOverviewWidget\EnhancedStat::make('Average Invoice Total', CurrencyConverter::formatCentsToMoney($averageInvoiceTotal))
70
-                ->suffix(CurrencyAccessor::getDefaultCurrency()),
96
+                ->suffix(CurrencyAccessor::getDefaultCurrency())
97
+                ->description($activeTab === 'all' ? 'Excludes draft and voided invoices' : null),
71 98
         ];
72 99
     }
73 100
 }

+ 57
- 14
app/Filament/Company/Resources/Sales/RecurringInvoiceResource.php Bestand weergeven

@@ -2,17 +2,23 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Sales;
4 4
 
5
+use App\Enums\Accounting\AdjustmentCategory;
6
+use App\Enums\Accounting\AdjustmentStatus;
7
+use App\Enums\Accounting\AdjustmentType;
5 8
 use App\Enums\Accounting\DocumentDiscountMethod;
6 9
 use App\Enums\Accounting\DocumentType;
7 10
 use App\Enums\Accounting\RecurringInvoiceStatus;
8 11
 use App\Enums\Setting\PaymentTerms;
12
+use App\Filament\Company\Resources\Sales\ClientResource\RelationManagers\RecurringInvoicesRelationManager;
9 13
 use App\Filament\Company\Resources\Sales\RecurringInvoiceResource\Pages;
14
+use App\Filament\Forms\Components\CreateAdjustmentSelect;
10 15
 use App\Filament\Forms\Components\CreateCurrencySelect;
11 16
 use App\Filament\Forms\Components\DocumentFooterSection;
12 17
 use App\Filament\Forms\Components\DocumentHeaderSection;
13 18
 use App\Filament\Forms\Components\DocumentTotals;
14 19
 use App\Filament\Tables\Columns;
15 20
 use App\Models\Accounting\Adjustment;
21
+use App\Models\Accounting\DocumentLineItem;
16 22
 use App\Models\Accounting\RecurringInvoice;
17 23
 use App\Models\Common\Client;
18 24
 use App\Models\Common\Offering;
@@ -107,17 +113,17 @@ class RecurringInvoiceResource extends Resource
107 113
                                     Header::make($settings->resolveColumnLabel('item_name', 'Items'))
108 114
                                         ->width($hasDiscounts ? '15%' : '20%'),
109 115
                                     Header::make('Description')
110
-                                        ->width($hasDiscounts ? '25%' : '30%'),
116
+                                        ->width($hasDiscounts ? '15%' : '20%'),
111 117
                                     Header::make($settings->resolveColumnLabel('unit_name', 'Quantity'))
112 118
                                         ->width('10%'),
113 119
                                     Header::make($settings->resolveColumnLabel('price_name', 'Price'))
114 120
                                         ->width('10%'),
115 121
                                     Header::make('Taxes')
116
-                                        ->width($hasDiscounts ? '15%' : '20%'),
122
+                                        ->width($hasDiscounts ? '20%' : '30%'),
117 123
                                 ];
118 124
 
119 125
                                 if ($hasDiscounts) {
120
-                                    $headers[] = Header::make('Discounts')->width('15%');
126
+                                    $headers[] = Header::make('Discounts')->width('20%');
121 127
                                 }
122 128
 
123 129
                                 $headers[] = Header::make($settings->resolveColumnLabel('amount_name', 'Amount'))
@@ -133,9 +139,40 @@ class RecurringInvoiceResource extends Resource
133 139
                                     ->searchable()
134 140
                                     ->required()
135 141
                                     ->live()
136
-                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
142
+                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state, ?DocumentLineItem $record) {
137 143
                                         $offeringId = $state;
138
-                                        $offeringRecord = Offering::with(['salesTaxes', 'salesDiscounts'])->find($offeringId);
144
+                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
145
+                                        $isPerLineItem = $discountMethod->isPerLineItem();
146
+
147
+                                        $existingTaxIds = [];
148
+                                        $existingDiscountIds = [];
149
+
150
+                                        if ($record) {
151
+                                            $existingTaxIds = $record->salesTaxes()->pluck('adjustments.id')->toArray();
152
+                                            if ($isPerLineItem) {
153
+                                                $existingDiscountIds = $record->salesDiscounts()->pluck('adjustments.id')->toArray();
154
+                                            }
155
+                                        }
156
+
157
+                                        $with = [
158
+                                            'salesTaxes' => static function ($query) use ($existingTaxIds) {
159
+                                                $query->where(static function ($query) use ($existingTaxIds) {
160
+                                                    $query->where('status', AdjustmentStatus::Active)
161
+                                                        ->orWhereIn('adjustments.id', $existingTaxIds);
162
+                                                });
163
+                                            },
164
+                                        ];
165
+
166
+                                        if ($isPerLineItem) {
167
+                                            $with['salesDiscounts'] = static function ($query) use ($existingDiscountIds) {
168
+                                                $query->where(static function ($query) use ($existingDiscountIds) {
169
+                                                    $query->where('status', AdjustmentStatus::Active)
170
+                                                        ->orWhereIn('adjustments.id', $existingDiscountIds);
171
+                                                });
172
+                                            };
173
+                                        }
174
+
175
+                                        $offeringRecord = Offering::with($with)->find($offeringId);
139 176
 
140 177
                                         if (! $offeringRecord) {
141 178
                                             return;
@@ -147,8 +184,7 @@ class RecurringInvoiceResource extends Resource
147 184
                                         $set('unit_price', $unitPrice);
148 185
                                         $set('salesTaxes', $offeringRecord->salesTaxes->pluck('id')->toArray());
149 186
 
150
-                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
151
-                                        if ($discountMethod->isPerLineItem()) {
187
+                                        if ($isPerLineItem) {
152 188
                                             $set('salesDiscounts', $offeringRecord->salesDiscounts->pluck('id')->toArray());
153 189
                                         }
154 190
                                     }),
@@ -165,19 +201,24 @@ class RecurringInvoiceResource extends Resource
165 201
                                     ->live()
166 202
                                     ->maxValue(9999999999.99)
167 203
                                     ->default(0),
168
-                                Forms\Components\Select::make('salesTaxes')
169
-                                    ->relationship('salesTaxes', 'name')
204
+                                CreateAdjustmentSelect::make('salesTaxes')
205
+                                    ->label('Taxes')
206
+                                    ->category(AdjustmentCategory::Tax)
207
+                                    ->type(AdjustmentType::Sales)
208
+                                    ->adjustmentsRelationship('salesTaxes')
170 209
                                     ->saveRelationshipsUsing(null)
171 210
                                     ->dehydrated(true)
172 211
                                     ->preload()
173 212
                                     ->multiple()
174 213
                                     ->live()
175 214
                                     ->searchable(),
176
-                                Forms\Components\Select::make('salesDiscounts')
177
-                                    ->relationship('salesDiscounts', 'name')
215
+                                CreateAdjustmentSelect::make('salesDiscounts')
216
+                                    ->label('Discounts')
217
+                                    ->category(AdjustmentCategory::Discount)
218
+                                    ->type(AdjustmentType::Sales)
219
+                                    ->adjustmentsRelationship('salesDiscounts')
178 220
                                     ->saveRelationshipsUsing(null)
179 221
                                     ->dehydrated(true)
180
-                                    ->preload()
181 222
                                     ->multiple()
182 223
                                     ->live()
183 224
                                     ->hidden(function (Forms\Get $get) {
@@ -248,7 +289,8 @@ class RecurringInvoiceResource extends Resource
248 289
                     ->searchable(),
249 290
                 Tables\Columns\TextColumn::make('client.name')
250 291
                     ->sortable()
251
-                    ->searchable(),
292
+                    ->searchable()
293
+                    ->hiddenOn(RecurringInvoicesRelationManager::class),
252 294
                 Tables\Columns\TextColumn::make('schedule')
253 295
                     ->label('Schedule')
254 296
                     ->getStateUsing(function (RecurringInvoice $record) {
@@ -287,7 +329,8 @@ class RecurringInvoiceResource extends Resource
287 329
                 Tables\Filters\SelectFilter::make('client')
288 330
                     ->relationship('client', 'name')
289 331
                     ->searchable()
290
-                    ->preload(),
332
+                    ->preload()
333
+                    ->hiddenOn(RecurringInvoicesRelationManager::class),
291 334
                 Tables\Filters\SelectFilter::make('status')
292 335
                     ->options(RecurringInvoiceStatus::class)
293 336
                     ->native(false),

+ 2
- 5
app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/EditRecurringInvoice.php Bestand weergeven

@@ -3,6 +3,7 @@
3 3
 namespace App\Filament\Company\Resources\Sales\RecurringInvoiceResource\Pages;
4 4
 
5 5
 use App\Concerns\ManagesLineItems;
6
+use App\Concerns\RedirectToViewPage;
6 7
 use App\Filament\Company\Resources\Sales\RecurringInvoiceResource;
7 8
 use App\Models\Accounting\Estimate;
8 9
 use Filament\Actions;
@@ -13,6 +14,7 @@ use Illuminate\Database\Eloquent\Model;
13 14
 class EditRecurringInvoice extends EditRecord
14 15
 {
15 16
     use ManagesLineItems;
17
+    use RedirectToViewPage;
16 18
 
17 19
     protected static string $resource = RecurringInvoiceResource::class;
18 20
 
@@ -28,11 +30,6 @@ class EditRecurringInvoice extends EditRecord
28 30
         return MaxWidth::Full;
29 31
     }
30 32
 
31
-    protected function getRedirectUrl(): string
32
-    {
33
-        return $this->getResource()::getUrl('view', ['record' => $this->record]);
34
-    }
35
-
36 33
     protected function handleRecordUpdate(Model $record, array $data): Model
37 34
     {
38 35
         /** @var Estimate $record */

+ 27
- 1
app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/ViewRecurringInvoice.php Bestand weergeven

@@ -20,6 +20,7 @@ use Filament\Support\Enums\FontWeight;
20 20
 use Filament\Support\Enums\IconPosition;
21 21
 use Filament\Support\Enums\IconSize;
22 22
 use Filament\Support\Enums\MaxWidth;
23
+use Illuminate\Support\HtmlString;
23 24
 use Illuminate\Support\Str;
24 25
 
25 26
 class ViewRecurringInvoice extends ViewRecord
@@ -59,6 +60,31 @@ class ViewRecurringInvoice extends ViewRecord
59 60
     {
60 61
         return $infolist
61 62
             ->schema([
63
+                BannerEntry::make('inactiveAdjustments')
64
+                    ->label('Inactive adjustments')
65
+                    ->warning()
66
+                    ->icon('heroicon-o-exclamation-triangle')
67
+                    ->visible(fn (RecurringInvoice $record) => $record->hasInactiveAdjustments() && $record->canBeApproved())
68
+                    ->columnSpanFull()
69
+                    ->description(function (RecurringInvoice $record) {
70
+                        $inactiveAdjustments = collect();
71
+
72
+                        foreach ($record->lineItems as $lineItem) {
73
+                            foreach ($lineItem->adjustments as $adjustment) {
74
+                                if ($adjustment->isInactive() && $inactiveAdjustments->doesntContain($adjustment->name)) {
75
+                                    $inactiveAdjustments->push($adjustment->name);
76
+                                }
77
+                            }
78
+                        }
79
+
80
+                        $adjustmentsList = $inactiveAdjustments->map(static function ($name) {
81
+                            return "<span class='font-medium'>{$name}</span>";
82
+                        })->join(', ');
83
+
84
+                        $output = "<p class='text-sm'>This recurring invoice contains inactive adjustments that need to be addressed before approval: {$adjustmentsList}</p>";
85
+
86
+                        return new HtmlString($output);
87
+                    }),
62 88
                 BannerEntry::make('scheduleIsNotSet')
63 89
                     ->info()
64 90
                     ->title('Schedule not set')
@@ -73,7 +99,7 @@ class ViewRecurringInvoice extends ViewRecord
73 99
                     ->info()
74 100
                     ->title('Ready to Approve')
75 101
                     ->description('This recurring invoice is ready for approval. Review the details, and approve it when you’re ready to start generating invoices.')
76
-                    ->visible(fn (RecurringInvoice $record) => $record->canBeApproved())
102
+                    ->visible(fn (RecurringInvoice $record) => $record->canBeApproved() && ! $record->hasInactiveAdjustments())
77 103
                     ->columnSpanFull()
78 104
                     ->actions([
79 105
                         RecurringInvoice::getApproveDraftAction(Action::class)

+ 167
- 0
app/Filament/Forms/Components/CreateAccountSelect.php Bestand weergeven

@@ -0,0 +1,167 @@
1
+<?php
2
+
3
+namespace App\Filament\Forms\Components;
4
+
5
+use App\Enums\Accounting\AccountCategory;
6
+use App\Enums\Accounting\AccountType;
7
+use App\Models\Accounting\Account;
8
+use App\Models\Accounting\AccountSubtype;
9
+use App\Utilities\Accounting\AccountCode;
10
+use Filament\Forms\Components\Actions\Action;
11
+use Filament\Forms\Components\Select;
12
+use Filament\Forms\Components\Textarea;
13
+use Filament\Forms\Components\TextInput;
14
+use Filament\Forms\Get;
15
+use Filament\Forms\Set;
16
+use Filament\Support\Enums\MaxWidth;
17
+use Illuminate\Support\Collection;
18
+use Illuminate\Support\Facades\DB;
19
+
20
+class CreateAccountSelect extends Select
21
+{
22
+    protected ?AccountCategory $category = null;
23
+
24
+    protected ?AccountType $type = null;
25
+
26
+    protected bool $includeArchived = false;
27
+
28
+    public function category(AccountCategory $category): static
29
+    {
30
+        $this->category = $category;
31
+
32
+        return $this;
33
+    }
34
+
35
+    public function type(AccountType $type): static
36
+    {
37
+        $this->type = $type;
38
+
39
+        return $this;
40
+    }
41
+
42
+    public function includeArchived(bool $includeArchived = true): static
43
+    {
44
+        $this->includeArchived = $includeArchived;
45
+
46
+        return $this;
47
+    }
48
+
49
+    public function getCategory(): ?AccountCategory
50
+    {
51
+        return $this->category;
52
+    }
53
+
54
+    public function getType(): ?AccountType
55
+    {
56
+        return $this->type;
57
+    }
58
+
59
+    public function includesArchived(): bool
60
+    {
61
+        return $this->includeArchived;
62
+    }
63
+
64
+    protected function setUp(): void
65
+    {
66
+        parent::setUp();
67
+
68
+        $this
69
+            ->searchable()
70
+            ->live()
71
+            ->createOptionForm($this->createAccountForm())
72
+            ->createOptionAction(fn (Action $action) => $this->createAccountAction($action));
73
+
74
+        $this->options(function () {
75
+            $query = Account::query();
76
+
77
+            if ($this->getCategory()) {
78
+                $query->where('category', $this->getCategory());
79
+            }
80
+
81
+            if ($this->getType()) {
82
+                $query->where('type', $this->getType());
83
+            }
84
+
85
+            if (! $this->includesArchived()) {
86
+                $query->where('archived', false);
87
+            }
88
+
89
+            return $query->orderBy('name')
90
+                ->pluck('name', 'id')
91
+                ->toArray();
92
+        });
93
+
94
+        $this->createOptionUsing(static function (array $data) {
95
+            return DB::transaction(static function () use ($data) {
96
+                $account = Account::create([
97
+                    'name' => $data['name'],
98
+                    'code' => $data['code'],
99
+                    'description' => $data['description'] ?? null,
100
+                    'subtype_id' => $data['subtype_id'],
101
+                ]);
102
+
103
+                return $account->getKey();
104
+            });
105
+        });
106
+    }
107
+
108
+    protected function createAccountForm(): array
109
+    {
110
+        return [
111
+            Select::make('subtype_id')
112
+                ->label('Type')
113
+                ->required()
114
+                ->live()
115
+                ->searchable()
116
+                ->options(function () {
117
+                    $query = AccountSubtype::query()->orderBy('name');
118
+
119
+                    if ($this->getCategory()) {
120
+                        $query->where('category', $this->getCategory());
121
+                    }
122
+
123
+                    if ($this->getType()) {
124
+                        $query->where('type', $this->getType());
125
+
126
+                        return $query->pluck('name', 'id')
127
+                            ->toArray();
128
+                    } else {
129
+                        return $query->get()
130
+                            ->groupBy(fn (AccountSubtype $subtype) => $subtype->type->getLabel())
131
+                            ->map(fn (Collection $subtypes, string $type) => $subtypes->mapWithKeys(static fn (AccountSubtype $subtype) => [$subtype->id => $subtype->name]))
132
+                            ->toArray();
133
+                    }
134
+                })
135
+                ->afterStateUpdated(function (string $state, Set $set) {
136
+                    if ($state) {
137
+                        $accountSubtype = AccountSubtype::find($state);
138
+                        $generatedCode = AccountCode::generate($accountSubtype);
139
+                        $set('code', $generatedCode);
140
+                    }
141
+                }),
142
+
143
+            TextInput::make('code')
144
+                ->label('Code')
145
+                ->required()
146
+                ->validationAttribute('account code')
147
+                ->unique(table: Account::class, column: 'code')
148
+                ->validateAccountCode(static fn (Get $get) => $get('subtype_id')),
149
+
150
+            TextInput::make('name')
151
+                ->label('Name')
152
+                ->required(),
153
+
154
+            Textarea::make('description')
155
+                ->label('Description'),
156
+        ];
157
+    }
158
+
159
+    protected function createAccountAction(Action $action): Action
160
+    {
161
+        return $action
162
+            ->label('Create Account')
163
+            ->slideOver()
164
+            ->modalWidth(MaxWidth::Large)
165
+            ->modalHeading('Create a new account');
166
+    }
167
+}

+ 222
- 0
app/Filament/Forms/Components/CreateAdjustmentSelect.php Bestand weergeven

@@ -0,0 +1,222 @@
1
+<?php
2
+
3
+namespace App\Filament\Forms\Components;
4
+
5
+use App\Enums\Accounting\AdjustmentCategory;
6
+use App\Enums\Accounting\AdjustmentComputation;
7
+use App\Enums\Accounting\AdjustmentScope;
8
+use App\Enums\Accounting\AdjustmentStatus;
9
+use App\Enums\Accounting\AdjustmentType;
10
+use App\Models\Accounting\Adjustment;
11
+use Filament\Forms\Components\Actions\Action;
12
+use Filament\Forms\Components\Checkbox;
13
+use Filament\Forms\Components\DateTimePicker;
14
+use Filament\Forms\Components\Group;
15
+use Filament\Forms\Components\Select;
16
+use Filament\Forms\Components\Textarea;
17
+use Filament\Forms\Components\TextInput;
18
+use Filament\Forms\Get;
19
+use Filament\Support\Enums\MaxWidth;
20
+use Illuminate\Database\Eloquent\Builder;
21
+use Illuminate\Database\Eloquent\Model;
22
+use Illuminate\Support\Facades\DB;
23
+
24
+class CreateAdjustmentSelect extends Select
25
+{
26
+    protected ?AdjustmentCategory $category = null;
27
+
28
+    protected ?AdjustmentType $type = null;
29
+
30
+    protected bool $includeInactive = false;
31
+
32
+    protected string $adjustmentsRelationship = 'adjustments';
33
+
34
+    public function category(AdjustmentCategory $category): static
35
+    {
36
+        $this->category = $category;
37
+
38
+        return $this;
39
+    }
40
+
41
+    public function type(AdjustmentType $type): static
42
+    {
43
+        $this->type = $type;
44
+
45
+        return $this;
46
+    }
47
+
48
+    public function includeInactive(bool $includeInactive = true): static
49
+    {
50
+        $this->includeInactive = $includeInactive;
51
+
52
+        return $this;
53
+    }
54
+
55
+    public function adjustmentsRelationship(string $relationship): static
56
+    {
57
+        $this->adjustmentsRelationship = $relationship;
58
+
59
+        return $this;
60
+    }
61
+
62
+    public function getCategory(): ?AdjustmentCategory
63
+    {
64
+        return $this->category;
65
+    }
66
+
67
+    public function getType(): ?AdjustmentType
68
+    {
69
+        return $this->type;
70
+    }
71
+
72
+    public function includesInactive(): bool
73
+    {
74
+        return $this->includeInactive;
75
+    }
76
+
77
+    public function getAdjustmentsRelationship(): string
78
+    {
79
+        return $this->adjustmentsRelationship;
80
+    }
81
+
82
+    protected function setUp(): void
83
+    {
84
+        parent::setUp();
85
+
86
+        $this
87
+            ->searchable()
88
+            ->preload()
89
+            ->createOptionForm($this->createAdjustmentForm())
90
+            ->createOptionAction(fn (Action $action) => $this->createAdjustmentAction($action));
91
+
92
+        $this->relationship(
93
+            name: $this->getAdjustmentsRelationship(),
94
+            titleAttribute: 'name',
95
+            modifyQueryUsing: function (Builder $query, ?Model $record) {
96
+                if ($this->getCategory()) {
97
+                    $query->where('category', $this->getCategory());
98
+                }
99
+
100
+                if ($this->getType()) {
101
+                    $query->where('type', $this->getType());
102
+                }
103
+
104
+                if (! $this->includesInactive()) {
105
+                    $existingAdjustmentIds = $record?->{$this->getAdjustmentsRelationship()}()
106
+                        ->pluck('adjustments.id')
107
+                        ->toArray() ?? [];
108
+
109
+                    $query->where(function ($query) use ($existingAdjustmentIds) {
110
+                        $query->where('status', AdjustmentStatus::Active)
111
+                            ->orWhereIn('adjustments.id', $existingAdjustmentIds);
112
+                    });
113
+                }
114
+
115
+                return $query->orderBy('name');
116
+            },
117
+        );
118
+
119
+        $this->createOptionUsing(static function (array $data, CreateAdjustmentSelect $component) {
120
+            return DB::transaction(static function () use ($data, $component) {
121
+                $category = $data['category'] ?? $component->getCategory();
122
+                $type = $data['type'] ?? $component->getType();
123
+
124
+                $adjustment = Adjustment::create([
125
+                    'name' => $data['name'],
126
+                    'description' => $data['description'] ?? null,
127
+                    'category' => $category,
128
+                    'type' => $type,
129
+                    'computation' => $data['computation'],
130
+                    'rate' => $data['rate'],
131
+                    'scope' => $data['scope'] ?? null,
132
+                    'recoverable' => $data['recoverable'] ?? false,
133
+                    'start_date' => $data['start_date'] ?? null,
134
+                    'end_date' => $data['end_date'] ?? null,
135
+                ]);
136
+
137
+                return $adjustment->getKey();
138
+            });
139
+        });
140
+    }
141
+
142
+    protected function createAdjustmentForm(): array
143
+    {
144
+        return [
145
+            TextInput::make('name')
146
+                ->label('Name')
147
+                ->required()
148
+                ->maxLength(255),
149
+
150
+            Textarea::make('description')
151
+                ->label('Description'),
152
+
153
+            Select::make('category')
154
+                ->label('Category')
155
+                ->options(AdjustmentCategory::class)
156
+                ->default(AdjustmentCategory::Tax)
157
+                ->hidden(fn () => (bool) $this->getCategory())
158
+                ->live()
159
+                ->required(),
160
+
161
+            Select::make('type')
162
+                ->label('Type')
163
+                ->options(AdjustmentType::class)
164
+                ->default(AdjustmentType::Sales)
165
+                ->hidden(fn () => (bool) $this->getType())
166
+                ->live()
167
+                ->required(),
168
+
169
+            Select::make('computation')
170
+                ->label('Computation')
171
+                ->options(AdjustmentComputation::class)
172
+                ->default(AdjustmentComputation::Percentage)
173
+                ->live()
174
+                ->required(),
175
+
176
+            TextInput::make('rate')
177
+                ->label('Rate')
178
+                ->rate(static fn (Get $get) => $get('computation'))
179
+                ->required(),
180
+
181
+            Select::make('scope')
182
+                ->label('Scope')
183
+                ->options(AdjustmentScope::class),
184
+
185
+            Checkbox::make('recoverable')
186
+                ->label('Recoverable')
187
+                ->default(false)
188
+                ->helperText('When enabled, tax is tracked separately as claimable from the government. Non-recoverable taxes are treated as part of the expense.')
189
+                ->visible(function (Get $get) {
190
+                    $category = $this->getCategory() ?? AdjustmentCategory::parse($get('category'));
191
+                    $type = $this->getType() ?? AdjustmentType::parse($get('type'));
192
+
193
+                    return $category->isTax() && $type->isPurchase();
194
+                }),
195
+
196
+            Group::make()
197
+                ->schema([
198
+                    DateTimePicker::make('start_date'),
199
+                    DateTimePicker::make('end_date')
200
+                        ->after('start_date'),
201
+                ])
202
+                ->visible(function (Get $get) {
203
+                    $category = $this->getCategory() ?? AdjustmentCategory::parse($get('category'));
204
+
205
+                    return $category->isDiscount();
206
+                }),
207
+        ];
208
+    }
209
+
210
+    protected function createAdjustmentAction(Action $action): Action
211
+    {
212
+        $categoryLabel = $this->getCategory()?->getLabel() ?? 'Adjustment';
213
+        $typeLabel = $this->getType()?->getLabel() ?? '';
214
+        $label = trim($typeLabel . ' ' . $categoryLabel);
215
+
216
+        return $action
217
+            ->label('Create ' . $label)
218
+            ->slideOver()
219
+            ->modalWidth(MaxWidth::ExtraLarge)
220
+            ->modalHeading('Create a new ' . strtolower($label));
221
+    }
222
+}

+ 38
- 0
app/Filament/Forms/Components/CustomTableRepeater.php Bestand weergeven

@@ -0,0 +1,38 @@
1
+<?php
2
+
3
+namespace App\Filament\Forms\Components;
4
+
5
+use Awcodes\TableRepeater\Components\TableRepeater;
6
+use Closure;
7
+
8
+class CustomTableRepeater extends TableRepeater
9
+{
10
+    protected bool | Closure | null $spreadsheet = null;
11
+
12
+    public function spreadsheet(bool | Closure $condition = true): static
13
+    {
14
+        $this->spreadsheet = $condition;
15
+
16
+        return $this;
17
+    }
18
+
19
+    public function isSpreadsheet(): bool
20
+    {
21
+        return $this->evaluate($this->spreadsheet) ?? false;
22
+    }
23
+
24
+    protected function setUp(): void
25
+    {
26
+        parent::setUp();
27
+
28
+        $this->extraAttributes(function (): array {
29
+            $attributes = [];
30
+
31
+            if ($this->isSpreadsheet()) {
32
+                $attributes['class'] = 'is-spreadsheet';
33
+            }
34
+
35
+            return $attributes;
36
+        });
37
+    }
38
+}

+ 50
- 0
app/Filament/Forms/Components/LinearWizard.php Bestand weergeven

@@ -0,0 +1,50 @@
1
+<?php
2
+
3
+namespace App\Filament\Forms\Components;
4
+
5
+use Filament\Forms\Components\Wizard;
6
+
7
+class LinearWizard extends Wizard
8
+{
9
+    protected string $view = 'filament.forms.components.linear-wizard';
10
+
11
+    protected bool $hideStepTabs = false;
12
+
13
+    protected ?string $currentStepDescription = null;
14
+
15
+    /**
16
+     * Hide the step tabs at the top of the wizard
17
+     */
18
+    public function hideStepTabs(bool $condition = true): static
19
+    {
20
+        $this->hideStepTabs = $condition;
21
+
22
+        return $this;
23
+    }
24
+
25
+    /**
26
+     * Add a description for the current step
27
+     */
28
+    public function currentStepDescription(?string $description): static
29
+    {
30
+        $this->currentStepDescription = $description;
31
+
32
+        return $this;
33
+    }
34
+
35
+    /**
36
+     * Get whether the step tabs should be hidden
37
+     */
38
+    public function areStepTabsHidden(): bool
39
+    {
40
+        return $this->hideStepTabs;
41
+    }
42
+
43
+    /**
44
+     * Get the description for the current step
45
+     */
46
+    public function getCurrentStepDescription(): ?string
47
+    {
48
+        return $this->currentStepDescription;
49
+    }
50
+}

+ 25
- 0
app/Filament/Tables/Columns/DeferredTextInputColumn.php Bestand weergeven

@@ -0,0 +1,25 @@
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
+}

+ 1
- 1
app/Listeners/CreateConnectedAccount.php Bestand weergeven

@@ -57,7 +57,7 @@ class CreateConnectedAccount
57 57
 
58 58
     public function processConnectedBankAccount($plaidAccount, Company $company, Institution $institution, $authResponse, $accessToken): void
59 59
     {
60
-        $identifierHash = md5($institution->external_institution_id . $plaidAccount->name . $plaidAccount->mask);
60
+        $identifierHash = md5($company->id . $institution->external_institution_id . $plaidAccount->name . $plaidAccount->mask);
61 61
 
62 62
         $company->connectedBankAccounts()->updateOrCreate([
63 63
             'identifier' => $identifierHash,

+ 2
- 1
app/Listeners/SyncWithCompanyDefaults.php Bestand weergeven

@@ -34,7 +34,7 @@ class SyncWithCompanyDefaults
34 34
             return;
35 35
         }
36 36
 
37
-        $companyId = auth()->user()->currentCompany->id;
37
+        $companyId = auth()->user()->current_company_id;
38 38
 
39 39
         if (! $companyId) {
40 40
             return;
@@ -47,6 +47,7 @@ class SyncWithCompanyDefaults
47 47
     {
48 48
         $modelName = class_basename($model);
49 49
 
50
+        /** @var CompanyDefault $default */
50 51
         $default = CompanyDefault::firstOrNew([
51 52
             'company_id' => $companyId,
52 53
         ]);

+ 7
- 6
app/Livewire/Company/Service/ConnectedAccount/ListInstitutions.php Bestand weergeven

@@ -108,14 +108,15 @@ class ListInstitutions extends Component implements HasActions, HasForms
108 108
         $options = ['new' => 'New Account'];
109 109
 
110 110
         if ($institutionId) {
111
-            $options += BankAccount::query()
112
-                ->where('company_id', $this->user->currentCompany->id)
113
-                ->where('institution_id', $institutionId)
111
+            $accountOptions = BankAccount::query()
112
+                ->join('accounts', 'bank_accounts.account_id', '=', 'accounts.id')
113
+                ->where('bank_accounts.institution_id', $institutionId)
114 114
                 ->whereDoesntHave('connectedBankAccount')
115
-                ->with('account')
116
-                ->get()
117
-                ->pluck('account.name', 'id')
115
+                ->select(['bank_accounts.id', 'accounts.name'])
116
+                ->pluck('accounts.name', 'bank_accounts.id')
118 117
                 ->toArray();
118
+
119
+            $options += $accountOptions;
119 120
         }
120 121
 
121 122
         return $options;

+ 36
- 12
app/Models/Accounting/Account.php Bestand weergeven

@@ -10,8 +10,10 @@ use App\Facades\Accounting;
10 10
 use App\Models\Banking\BankAccount;
11 11
 use App\Models\Setting\Currency;
12 12
 use App\Observers\AccountObserver;
13
+use App\Utilities\Currency\CurrencyAccessor;
13 14
 use Database\Factories\Accounting\AccountFactory;
14 15
 use Illuminate\Database\Eloquent\Attributes\ObservedBy;
16
+use Illuminate\Database\Eloquent\Builder;
15 17
 use Illuminate\Database\Eloquent\Casts\Attribute;
16 18
 use Illuminate\Database\Eloquent\Factories\Factory;
17 19
 use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -19,7 +21,7 @@ use Illuminate\Database\Eloquent\Model;
19 21
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
20 22
 use Illuminate\Database\Eloquent\Relations\HasMany;
21 23
 use Illuminate\Database\Eloquent\Relations\HasOne;
22
-use Illuminate\Support\Carbon;
24
+use Illuminate\Support\Facades\DB;
23 25
 
24 26
 #[ObservedBy(AccountObserver::class)]
25 27
 class Account extends Model
@@ -84,17 +86,39 @@ class Account extends Model
84 86
         return $this->hasOne(Adjustment::class, 'account_id');
85 87
     }
86 88
 
87
-    public function getLastTransactionDate(): ?string
88
-    {
89
-        $lastJournalEntryTransaction = $this->journalEntries()
90
-            ->join('transactions', 'journal_entries.transaction_id', '=', 'transactions.id')
91
-            ->max('transactions.posted_at');
92
-
93
-        if ($lastJournalEntryTransaction) {
94
-            return Carbon::parse($lastJournalEntryTransaction)->format('F j, Y');
95
-        }
96
-
97
-        return null;
89
+    public function scopeBudgetable(Builder $query): Builder
90
+    {
91
+        return $query->whereIn('category', [
92
+            AccountCategory::Revenue,
93
+            AccountCategory::Expense,
94
+        ])
95
+            ->whereNotIn('type', [
96
+                AccountType::ContraRevenue,
97
+                AccountType::ContraExpense,
98
+                AccountType::UncategorizedRevenue,
99
+                AccountType::UncategorizedExpense,
100
+            ])
101
+            ->whereDoesntHave('subtype', function (Builder $query) {
102
+                $query->whereIn('name', [
103
+                    'Receivables',
104
+                    'Input Tax Recoverable',
105
+                ]);
106
+            })
107
+            ->whereNotIn('name', [
108
+                'Gain on Foreign Exchange',
109
+                'Loss on Foreign Exchange',
110
+            ])
111
+            ->where('currency_code', CurrencyAccessor::getDefaultCurrency());
112
+    }
113
+
114
+    public function scopeWithLastTransactionDate(Builder $query): Builder
115
+    {
116
+        return $query->addSelect([
117
+            'last_transaction_date' => JournalEntry::select(DB::raw('MAX(transactions.posted_at)'))
118
+                ->join('transactions', 'journal_entries.transaction_id', '=', 'transactions.id')
119
+                ->whereColumn('journal_entries.account_id', 'accounts.id')
120
+                ->limit(1),
121
+        ]);
98 122
     }
99 123
 
100 124
     protected function endingBalance(): Attribute

+ 122
- 2
app/Models/Accounting/Adjustment.php Bestand weergeven

@@ -8,6 +8,7 @@ use App\Concerns\CompanyOwned;
8 8
 use App\Enums\Accounting\AdjustmentCategory;
9 9
 use App\Enums\Accounting\AdjustmentComputation;
10 10
 use App\Enums\Accounting\AdjustmentScope;
11
+use App\Enums\Accounting\AdjustmentStatus;
11 12
 use App\Enums\Accounting\AdjustmentType;
12 13
 use App\Models\Common\Offering;
13 14
 use App\Observers\AdjustmentObserver;
@@ -26,12 +27,12 @@ class Adjustment extends Model
26 27
     use CompanyOwned;
27 28
     use HasFactory;
28 29
 
29
-    protected $table = 'adjustments';
30
-
31 30
     protected $fillable = [
32 31
         'company_id',
33 32
         'account_id',
34 33
         'name',
34
+        'status',
35
+        'status_reason',
35 36
         'description',
36 37
         'category',
37 38
         'type',
@@ -41,11 +42,15 @@ class Adjustment extends Model
41 42
         'scope',
42 43
         'start_date',
43 44
         'end_date',
45
+        'paused_at',
46
+        'paused_until',
47
+        'archived_at',
44 48
         'created_by',
45 49
         'updated_by',
46 50
     ];
47 51
 
48 52
     protected $casts = [
53
+        'status' => AdjustmentStatus::class,
49 54
         'category' => AdjustmentCategory::class,
50 55
         'type' => AdjustmentType::class,
51 56
         'recoverable' => 'boolean',
@@ -54,6 +59,9 @@ class Adjustment extends Model
54 59
         'scope' => AdjustmentScope::class,
55 60
         'start_date' => 'datetime',
56 61
         'end_date' => 'datetime',
62
+        'paused_at' => 'datetime',
63
+        'paused_until' => 'datetime',
64
+        'archived_at' => 'datetime',
57 65
     ];
58 66
 
59 67
     public function account(): BelongsTo
@@ -91,6 +99,118 @@ class Adjustment extends Model
91 99
         return $this->category->isDiscount() && $this->type->isPurchase();
92 100
     }
93 101
 
102
+    public function isActive(): bool
103
+    {
104
+        return $this->status === AdjustmentStatus::Active;
105
+    }
106
+
107
+    public function isInactive(): bool
108
+    {
109
+        return ! $this->isActive();
110
+    }
111
+
112
+    public function canBePaused(): bool
113
+    {
114
+        return $this->status === AdjustmentStatus::Active;
115
+    }
116
+
117
+    public function canBeResumed(): bool
118
+    {
119
+        return $this->status === AdjustmentStatus::Paused;
120
+    }
121
+
122
+    public function canBeArchived(): bool
123
+    {
124
+        return $this->status !== AdjustmentStatus::Archived;
125
+    }
126
+
127
+    public function calculateNaturalStatus(): AdjustmentStatus
128
+    {
129
+        if ($this->start_date?->isFuture()) {
130
+            return AdjustmentStatus::Upcoming;
131
+        }
132
+
133
+        if ($this->end_date?->isPast()) {
134
+            return AdjustmentStatus::Expired;
135
+        }
136
+
137
+        return AdjustmentStatus::Active;
138
+    }
139
+
140
+    public function pause(?string $reason = null, ?\DateTime $untilDate = null): bool
141
+    {
142
+        if (! $this->canBePaused()) {
143
+            return false;
144
+        }
145
+
146
+        return $this->update([
147
+            'paused_at' => now(),
148
+            'paused_until' => $untilDate,
149
+            'status' => AdjustmentStatus::Paused,
150
+            'status_reason' => $reason,
151
+        ]);
152
+    }
153
+
154
+    public function resume(): bool
155
+    {
156
+        if (! $this->canBeResumed()) {
157
+            return false;
158
+        }
159
+
160
+        return $this->update([
161
+            'paused_at' => null,
162
+            'paused_until' => null,
163
+            'status_reason' => null,
164
+            'status' => $this->calculateNaturalStatus(),
165
+        ]);
166
+    }
167
+
168
+    public function archive(?string $reason = null): bool
169
+    {
170
+        if (! $this->canBeArchived()) {
171
+            return false;
172
+        }
173
+
174
+        return $this->update([
175
+            'archived_at' => now(),
176
+            'status' => AdjustmentStatus::Archived,
177
+            'status_reason' => $reason,
178
+        ]);
179
+    }
180
+
181
+    public function shouldAutoResume(): bool
182
+    {
183
+        return $this->status === AdjustmentStatus::Paused &&
184
+            $this->paused_until !== null &&
185
+            $this->paused_until->isPast();
186
+    }
187
+
188
+    public function refreshStatus(): bool
189
+    {
190
+        // Don't automatically change archived or paused status
191
+        if ($this->status === AdjustmentStatus::Archived ||
192
+            ($this->status === AdjustmentStatus::Paused && ! $this->shouldAutoResume())) {
193
+            return false;
194
+        }
195
+
196
+        // Check if a paused adjustment should be auto-resumed
197
+        if ($this->shouldAutoResume()) {
198
+            return $this->resume();
199
+        }
200
+
201
+        // Calculate natural status based on dates
202
+        $naturalStatus = $this->calculateNaturalStatus();
203
+
204
+        // Only update if the status would change
205
+        if ($this->status !== $naturalStatus) {
206
+            return $this->update([
207
+                'status' => $naturalStatus,
208
+            ]);
209
+        }
210
+
211
+        return false;
212
+    }
213
+
94 214
     protected static function newFactory(): Factory
95 215
     {
96 216
         return AdjustmentFactory::new();

+ 8
- 2
app/Models/Accounting/Bill.php Bestand weergeven

@@ -164,7 +164,7 @@ class Bill extends Document
164 164
         return ! in_array($this->status, [
165 165
             BillStatus::Paid,
166 166
             BillStatus::Void,
167
-        ]) && $this->currency_code === CurrencyAccessor::getDefaultCurrency();
167
+        ]);
168 168
     }
169 169
 
170 170
     public function hasPayments(): bool
@@ -227,8 +227,10 @@ class Bill extends Document
227 227
         $billCurrency = $this->currency_code;
228 228
         $requiresConversion = $billCurrency !== $bankAccountCurrency;
229 229
 
230
+        // Store the original payment amount in bill currency before any conversion
231
+        $amountInBillCurrencyCents = CurrencyConverter::convertToCents($data['amount'], $billCurrency);
232
+
230 233
         if ($requiresConversion) {
231
-            $amountInBillCurrencyCents = CurrencyConverter::convertToCents($data['amount'], $billCurrency);
232 234
             $amountInBankCurrencyCents = CurrencyConverter::convertBalance(
233 235
                 $amountInBillCurrencyCents,
234 236
                 $billCurrency,
@@ -254,6 +256,10 @@ class Bill extends Document
254 256
             'account_id' => Account::getAccountsPayableAccount()->id,
255 257
             'description' => $transactionDescription,
256 258
             'notes' => $data['notes'] ?? null,
259
+            'meta' => [
260
+                'original_document_currency' => $billCurrency,
261
+                'amount_in_document_currency_cents' => $amountInBillCurrencyCents,
262
+            ],
257 263
         ]);
258 264
     }
259 265
 

+ 329
- 0
app/Models/Accounting/Budget.php Bestand weergeven

@@ -0,0 +1,329 @@
1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use App\Concerns\Blamable;
6
+use App\Concerns\CompanyOwned;
7
+use App\Enums\Accounting\BudgetIntervalType;
8
+use App\Enums\Accounting\BudgetSourceType;
9
+use App\Enums\Accounting\BudgetStatus;
10
+use App\Filament\Company\Resources\Accounting\BudgetResource;
11
+use App\Models\User;
12
+use Filament\Actions\Action;
13
+use Filament\Actions\MountableAction;
14
+use Filament\Actions\ReplicateAction;
15
+use Illuminate\Database\Eloquent\Builder;
16
+use Illuminate\Database\Eloquent\Casts\Attribute;
17
+use Illuminate\Database\Eloquent\Factories\HasFactory;
18
+use Illuminate\Database\Eloquent\Model;
19
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
20
+use Illuminate\Database\Eloquent\Relations\HasMany;
21
+use Illuminate\Database\Eloquent\Relations\HasManyThrough;
22
+use Illuminate\Support\Carbon;
23
+use Illuminate\Support\Collection;
24
+
25
+class Budget extends Model
26
+{
27
+    use Blamable;
28
+    use CompanyOwned;
29
+    use HasFactory;
30
+
31
+    protected $fillable = [
32
+        'company_id',
33
+        'source_budget_id',
34
+        'source_fiscal_year',
35
+        'source_type',
36
+        'name',
37
+        'start_date',
38
+        'end_date',
39
+        'status', // draft, active, closed
40
+        'interval_type', // day, week, month, quarter, year
41
+        'notes',
42
+        'approved_at',
43
+        'approved_by_id',
44
+        'closed_at',
45
+        'created_by',
46
+        'updated_by',
47
+    ];
48
+
49
+    protected $casts = [
50
+        'source_fiscal_year' => 'integer',
51
+        'source_type' => BudgetSourceType::class,
52
+        'start_date' => 'date',
53
+        'end_date' => 'date',
54
+        'status' => BudgetStatus::class,
55
+        'interval_type' => BudgetIntervalType::class,
56
+        'approved_at' => 'datetime',
57
+        'closed_at' => 'datetime',
58
+    ];
59
+
60
+    public function sourceBudget(): BelongsTo
61
+    {
62
+        return $this->belongsTo(self::class, 'source_budget_id');
63
+    }
64
+
65
+    public function derivedBudgets(): HasMany
66
+    {
67
+        return $this->hasMany(self::class, 'source_budget_id');
68
+    }
69
+
70
+    public function approvedBy(): BelongsTo
71
+    {
72
+        return $this->belongsTo(User::class, 'approved_by_id');
73
+    }
74
+
75
+    public function budgetItems(): HasMany
76
+    {
77
+        return $this->hasMany(BudgetItem::class);
78
+    }
79
+
80
+    public function allocations(): HasManyThrough
81
+    {
82
+        return $this->hasManyThrough(BudgetAllocation::class, BudgetItem::class);
83
+    }
84
+
85
+    public function getPeriods(): Collection
86
+    {
87
+        return $this->allocations()
88
+            ->select(['period', 'start_date'])
89
+            ->distinct()
90
+            ->orderBy('start_date')
91
+            ->get();
92
+    }
93
+
94
+    public function isDraft(): bool
95
+    {
96
+        return $this->status === BudgetStatus::Draft;
97
+    }
98
+
99
+    public function isActive(): bool
100
+    {
101
+        return $this->status === BudgetStatus::Active;
102
+    }
103
+
104
+    public function isClosed(): bool
105
+    {
106
+        return $this->status === BudgetStatus::Closed;
107
+    }
108
+
109
+    public function wasApproved(): bool
110
+    {
111
+        return $this->approved_at !== null;
112
+    }
113
+
114
+    public function wasClosed(): bool
115
+    {
116
+        return $this->closed_at !== null;
117
+    }
118
+
119
+    public function canBeApproved(): bool
120
+    {
121
+        return $this->isDraft() && ! $this->wasApproved();
122
+    }
123
+
124
+    public function canBeClosed(): bool
125
+    {
126
+        return $this->isActive() && ! $this->wasClosed();
127
+    }
128
+
129
+    public function hasItems(): bool
130
+    {
131
+        return $this->budgetItems()->exists();
132
+    }
133
+
134
+    public function hasAllocations(): bool
135
+    {
136
+        return $this->allocations()->exists();
137
+    }
138
+
139
+    public function scopeDraft(Builder $query): Builder
140
+    {
141
+        return $query->where('status', BudgetStatus::Draft);
142
+    }
143
+
144
+    public function scopeActive(Builder $query): Builder
145
+    {
146
+        return $query->where('status', BudgetStatus::Active);
147
+    }
148
+
149
+    public function scopeClosed(Builder $query): Builder
150
+    {
151
+        return $query->where('status', BudgetStatus::Closed);
152
+    }
153
+
154
+    public function scopeCurrentlyActive(Builder $query): Builder
155
+    {
156
+        return $query->active()
157
+            ->where('start_date', '<=', now())
158
+            ->where('end_date', '>=', now());
159
+    }
160
+
161
+    protected function isCurrentlyInPeriod(): Attribute
162
+    {
163
+        return Attribute::get(function () {
164
+            return now()->between($this->start_date, $this->end_date);
165
+        });
166
+    }
167
+
168
+    /**
169
+     * Approve a draft budget
170
+     */
171
+    public function approveDraft(?Carbon $approvedAt = null): void
172
+    {
173
+        if (! $this->canBeApproved()) {
174
+            throw new \RuntimeException('Budget cannot be approved.');
175
+        }
176
+
177
+        $approvedAt ??= now();
178
+
179
+        $this->update([
180
+            'status' => BudgetStatus::Active,
181
+            'approved_at' => $approvedAt,
182
+        ]);
183
+    }
184
+
185
+    /**
186
+     * Close an active budget
187
+     */
188
+    public function close(?Carbon $closedAt = null): void
189
+    {
190
+        if (! $this->canBeClosed()) {
191
+            throw new \RuntimeException('Budget cannot be closed.');
192
+        }
193
+
194
+        $closedAt ??= now();
195
+
196
+        $this->update([
197
+            'status' => BudgetStatus::Closed,
198
+            'closed_at' => $closedAt,
199
+        ]);
200
+    }
201
+
202
+    /**
203
+     * Reopen a closed budget
204
+     */
205
+    public function reopen(): void
206
+    {
207
+        if (! $this->isClosed()) {
208
+            throw new \RuntimeException('Only closed budgets can be reopened.');
209
+        }
210
+
211
+        $this->update([
212
+            'status' => BudgetStatus::Active,
213
+            'closed_at' => null,
214
+        ]);
215
+    }
216
+
217
+    /**
218
+     * Get Action for approving a draft budget
219
+     */
220
+    public static function getApproveDraftAction(string $action = Action::class): MountableAction
221
+    {
222
+        return $action::make('approveDraft')
223
+            ->label('Approve')
224
+            ->icon('heroicon-m-check-circle')
225
+            ->visible(function (self $record) {
226
+                return $record->canBeApproved();
227
+            })
228
+            ->databaseTransaction()
229
+            ->successNotificationTitle('Budget approved')
230
+            ->action(function (self $record, MountableAction $action) {
231
+                $record->approveDraft();
232
+                $action->success();
233
+            });
234
+    }
235
+
236
+    /**
237
+     * Get Action for closing an active budget
238
+     */
239
+    public static function getCloseAction(string $action = Action::class): MountableAction
240
+    {
241
+        return $action::make('close')
242
+            ->label('Close')
243
+            ->icon('heroicon-m-lock-closed')
244
+            ->color('warning')
245
+            ->visible(function (self $record) {
246
+                return $record->canBeClosed();
247
+            })
248
+            ->requiresConfirmation()
249
+            ->databaseTransaction()
250
+            ->successNotificationTitle('Budget closed')
251
+            ->action(function (self $record, MountableAction $action) {
252
+                $record->close();
253
+                $action->success();
254
+            });
255
+    }
256
+
257
+    /**
258
+     * Get Action for reopening a closed budget
259
+     */
260
+    public static function getReopenAction(string $action = Action::class): MountableAction
261
+    {
262
+        return $action::make('reopen')
263
+            ->label('Reopen')
264
+            ->icon('heroicon-m-lock-open')
265
+            ->visible(function (self $record) {
266
+                return $record->isClosed();
267
+            })
268
+            ->requiresConfirmation()
269
+            ->databaseTransaction()
270
+            ->successNotificationTitle('Budget reopened')
271
+            ->action(function (self $record, MountableAction $action) {
272
+                $record->reopen();
273
+                $action->success();
274
+            });
275
+    }
276
+
277
+    /**
278
+     * Get Action for duplicating a budget
279
+     */
280
+    public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
281
+    {
282
+        return $action::make()
283
+            ->excludeAttributes([
284
+                'status',
285
+                'approved_at',
286
+                'closed_at',
287
+                'created_by',
288
+                'updated_by',
289
+                'created_at',
290
+                'updated_at',
291
+            ])
292
+            ->modal(false)
293
+            ->beforeReplicaSaved(function (self $original, self $replica) {
294
+                $replica->status = BudgetStatus::Draft;
295
+                $replica->name = $replica->name . ' (Copy)';
296
+            })
297
+            ->databaseTransaction()
298
+            ->after(function (self $original, self $replica) {
299
+                // Clone budget items and their allocations
300
+                $original->budgetItems->each(function (BudgetItem $item) use ($replica) {
301
+                    $newItem = $item->replicate([
302
+                        'budget_id',
303
+                        'created_by',
304
+                        'updated_by',
305
+                        'created_at',
306
+                        'updated_at',
307
+                    ]);
308
+
309
+                    $newItem->budget_id = $replica->id;
310
+                    $newItem->save();
311
+
312
+                    // Clone the allocations for this budget item
313
+                    $item->allocations->each(function (BudgetAllocation $allocation) use ($newItem) {
314
+                        $newAllocation = $allocation->replicate([
315
+                            'budget_item_id',
316
+                            'created_at',
317
+                            'updated_at',
318
+                        ]);
319
+
320
+                        $newAllocation->budget_item_id = $newItem->id;
321
+                        $newAllocation->save();
322
+                    });
323
+                });
324
+            })
325
+            ->successRedirectUrl(static function (self $replica) {
326
+                return BudgetResource::getUrl('edit', ['record' => $replica]);
327
+            });
328
+    }
329
+}

+ 43
- 0
app/Models/Accounting/BudgetAllocation.php Bestand weergeven

@@ -0,0 +1,43 @@
1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use App\Casts\MoneyCast;
6
+use App\Concerns\CompanyOwned;
7
+use App\Enums\Accounting\BudgetIntervalType;
8
+use Illuminate\Database\Eloquent\Factories\HasFactory;
9
+use Illuminate\Database\Eloquent\Model;
10
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
11
+
12
+class BudgetAllocation extends Model
13
+{
14
+    use CompanyOwned;
15
+    use HasFactory;
16
+
17
+    protected $fillable = [
18
+        'company_id',
19
+        'budget_item_id',
20
+        'period',
21
+        'interval_type',
22
+        'start_date',
23
+        'end_date',
24
+        'amount',
25
+    ];
26
+
27
+    protected $casts = [
28
+        'start_date' => 'date',
29
+        'end_date' => 'date',
30
+        'interval_type' => BudgetIntervalType::class,
31
+        'amount' => MoneyCast::class,
32
+    ];
33
+
34
+    public function budgetItem(): BelongsTo
35
+    {
36
+        return $this->belongsTo(BudgetItem::class);
37
+    }
38
+
39
+    public function isCurrentPeriod(): bool
40
+    {
41
+        return now()->between($this->start_date, $this->end_date);
42
+    }
43
+}

+ 40
- 0
app/Models/Accounting/BudgetItem.php Bestand weergeven

@@ -0,0 +1,40 @@
1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use App\Concerns\Blamable;
6
+use App\Concerns\CompanyOwned;
7
+use Illuminate\Database\Eloquent\Factories\HasFactory;
8
+use Illuminate\Database\Eloquent\Model;
9
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
10
+use Illuminate\Database\Eloquent\Relations\HasMany;
11
+
12
+class BudgetItem extends Model
13
+{
14
+    use Blamable;
15
+    use CompanyOwned;
16
+    use HasFactory;
17
+
18
+    protected $fillable = [
19
+        'company_id',
20
+        'budget_id',
21
+        'account_id',
22
+        'created_by',
23
+        'updated_by',
24
+    ];
25
+
26
+    public function budget(): BelongsTo
27
+    {
28
+        return $this->belongsTo(Budget::class);
29
+    }
30
+
31
+    public function account(): BelongsTo
32
+    {
33
+        return $this->belongsTo(Account::class);
34
+    }
35
+
36
+    public function allocations(): HasMany
37
+    {
38
+        return $this->hasMany(BudgetAllocation::class);
39
+    }
40
+}

+ 9
- 0
app/Models/Accounting/Document.php Bestand weergeven

@@ -35,6 +35,15 @@ abstract class Document extends Model
35 35
         return $this->lineItems()->exists();
36 36
     }
37 37
 
38
+    public function hasInactiveAdjustments(): bool
39
+    {
40
+        return $this->lineItems->contains(function (DocumentLineItem $lineItem) {
41
+            return $lineItem->adjustments->contains(function (Adjustment $adjustment) {
42
+                return $adjustment->isInactive();
43
+            });
44
+        });
45
+    }
46
+
38 47
     public static function getPrintDocumentAction(string $action = Action::class): MountableAction
39 48
     {
40 49
         return $action::make('printPdf')

+ 24
- 5
app/Models/Accounting/Estimate.php Bestand weergeven

@@ -19,6 +19,7 @@ use App\Observers\EstimateObserver;
19 19
 use Filament\Actions\Action;
20 20
 use Filament\Actions\MountableAction;
21 21
 use Filament\Actions\ReplicateAction;
22
+use Filament\Notifications\Notification;
22 23
 use Illuminate\Database\Eloquent\Attributes\CollectedBy;
23 24
 use Illuminate\Database\Eloquent\Attributes\ObservedBy;
24 25
 use Illuminate\Database\Eloquent\Builder;
@@ -27,6 +28,7 @@ use Illuminate\Database\Eloquent\Model;
27 28
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
28 29
 use Illuminate\Database\Eloquent\Relations\HasOne;
29 30
 use Illuminate\Support\Carbon;
31
+use Livewire\Component;
30 32
 
31 33
 #[CollectedBy(DocumentCollection::class)]
32 34
 #[ObservedBy(EstimateObserver::class)]
@@ -124,7 +126,7 @@ class Estimate extends Document
124 126
     protected function isCurrentlyExpired(): Attribute
125 127
     {
126 128
         return Attribute::get(function () {
127
-            return $this->expiration_date?->isBefore(today()) && $this->canBeExpired();
129
+            return $this->expiration_date?->isBefore(today());
128 130
         });
129 131
     }
130 132
 
@@ -170,6 +172,7 @@ class Estimate extends Document
170 172
             EstimateStatus::Accepted,
171 173
             EstimateStatus::Declined,
172 174
             EstimateStatus::Converted,
175
+            EstimateStatus::Expired,
173 176
         ]);
174 177
     }
175 178
 
@@ -265,12 +268,28 @@ class Estimate extends Document
265 268
             ->visible(function (self $record) {
266 269
                 return $record->canBeApproved();
267 270
             })
271
+            ->requiresConfirmation()
268 272
             ->databaseTransaction()
269 273
             ->successNotificationTitle('Estimate approved')
270
-            ->action(function (self $record, MountableAction $action) {
271
-                $record->approveDraft();
272
-
273
-                $action->success();
274
+            ->action(function (self $record, MountableAction $action, Component $livewire) {
275
+                if ($record->hasInactiveAdjustments()) {
276
+                    $isViewPage = $livewire instanceof EstimateResource\Pages\ViewEstimate;
277
+
278
+                    if (! $isViewPage) {
279
+                        redirect(EstimateResource\Pages\ViewEstimate::getUrl(['record' => $record->id]));
280
+                    } else {
281
+                        Notification::make()
282
+                            ->warning()
283
+                            ->title('Cannot approve estimate')
284
+                            ->body('This estimate has inactive adjustments that must be addressed first.')
285
+                            ->persistent()
286
+                            ->send();
287
+                    }
288
+                } else {
289
+                    $record->approveDraft();
290
+
291
+                    $action->success();
292
+                }
274 293
             });
275 294
     }
276 295
 

+ 70
- 4
app/Models/Accounting/Invoice.php Bestand weergeven

@@ -22,6 +22,9 @@ use App\Utilities\Currency\CurrencyConverter;
22 22
 use Filament\Actions\Action;
23 23
 use Filament\Actions\MountableAction;
24 24
 use Filament\Actions\ReplicateAction;
25
+use Filament\Actions\StaticAction;
26
+use Filament\Notifications\Notification;
27
+use Filament\Support\Enums\Alignment;
25 28
 use Illuminate\Database\Eloquent\Attributes\CollectedBy;
26 29
 use Illuminate\Database\Eloquent\Attributes\ObservedBy;
27 30
 use Illuminate\Database\Eloquent\Builder;
@@ -31,6 +34,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
31 34
 use Illuminate\Database\Eloquent\Relations\MorphMany;
32 35
 use Illuminate\Database\Eloquent\Relations\MorphOne;
33 36
 use Illuminate\Support\Carbon;
37
+use Illuminate\Support\HtmlString;
38
+use Livewire\Component;
34 39
 
35 40
 #[CollectedBy(DocumentCollection::class)]
36 41
 #[ObservedBy(InvoiceObserver::class)]
@@ -306,8 +311,10 @@ class Invoice extends Document
306 311
         $invoiceCurrency = $this->currency_code;
307 312
         $requiresConversion = $invoiceCurrency !== $bankAccountCurrency;
308 313
 
314
+        // Store the original payment amount in invoice currency before any conversion
315
+        $amountInInvoiceCurrencyCents = CurrencyConverter::convertToCents($data['amount'], $invoiceCurrency);
316
+
309 317
         if ($requiresConversion) {
310
-            $amountInInvoiceCurrencyCents = CurrencyConverter::convertToCents($data['amount'], $invoiceCurrency);
311 318
             $amountInBankCurrencyCents = CurrencyConverter::convertBalance(
312 319
                 $amountInInvoiceCurrencyCents,
313 320
                 $invoiceCurrency,
@@ -333,6 +340,10 @@ class Invoice extends Document
333 340
             'account_id' => Account::getAccountsReceivableAccount()->id,
334 341
             'description' => $transactionDescription,
335 342
             'notes' => $data['notes'] ?? null,
343
+            'meta' => [
344
+                'original_document_currency' => $invoiceCurrency,
345
+                'amount_in_document_currency_cents' => $amountInInvoiceCurrencyCents,
346
+            ],
336 347
         ]);
337 348
     }
338 349
 
@@ -458,6 +469,45 @@ class Invoice extends Document
458 469
         return CurrencyConverter::convertCentsToFormatSimple($convertedCents);
459 470
     }
460 471
 
472
+    // TODO: Potentially handle this another way
473
+    public static function getBlockedApproveAction(string $action = Action::class): MountableAction
474
+    {
475
+        return $action::make('blockedApprove')
476
+            ->label('Approve')
477
+            ->icon('heroicon-m-check-circle')
478
+            ->visible(fn (self $record) => $record->canBeApproved() && $record->hasInactiveAdjustments())
479
+            ->requiresConfirmation()
480
+            ->modalAlignment(Alignment::Start)
481
+            ->modalIconColor('danger')
482
+            ->modalDescription(function (self $record) {
483
+                $inactiveAdjustments = collect();
484
+
485
+                foreach ($record->lineItems as $lineItem) {
486
+                    foreach ($lineItem->adjustments as $adjustment) {
487
+                        if ($adjustment->isInactive() && $inactiveAdjustments->doesntContain($adjustment->name)) {
488
+                            $inactiveAdjustments->push($adjustment->name);
489
+                        }
490
+                    }
491
+                }
492
+
493
+                $output = "<p class='text-sm mb-4'>This invoice contains inactive adjustments that need to be addressed before approval:</p>";
494
+                $output .= "<ul role='list' class='list-disc list-inside space-y-1 text-sm'>";
495
+
496
+                foreach ($inactiveAdjustments as $name) {
497
+                    $output .= "<li class='py-1'><span class='font-medium'>{$name}</span></li>";
498
+                }
499
+
500
+                $output .= '</ul>';
501
+                $output .= "<p class='text-sm mt-4'>Please update these adjustments before approving the invoice.</p>";
502
+
503
+                return new HtmlString($output);
504
+            })
505
+            ->modalSubmitAction(function (StaticAction $action, self $record) {
506
+                $action->label('Edit Invoice')
507
+                    ->url(InvoiceResource\Pages\EditInvoice::getUrl(['record' => $record->id]));
508
+            });
509
+    }
510
+
461 511
     public static function getApproveDraftAction(string $action = Action::class): MountableAction
462 512
     {
463 513
         return $action::make('approveDraft')
@@ -466,12 +516,28 @@ class Invoice extends Document
466 516
             ->visible(function (self $record) {
467 517
                 return $record->canBeApproved();
468 518
             })
519
+            ->requiresConfirmation()
469 520
             ->databaseTransaction()
470 521
             ->successNotificationTitle('Invoice approved')
471
-            ->action(function (self $record, MountableAction $action) {
472
-                $record->approveDraft();
522
+            ->action(function (self $record, MountableAction $action, Component $livewire) {
523
+                if ($record->hasInactiveAdjustments()) {
524
+                    $isViewPage = $livewire instanceof InvoiceResource\Pages\ViewInvoice;
525
+
526
+                    if (! $isViewPage) {
527
+                        redirect(InvoiceResource\Pages\ViewInvoice::getUrl(['record' => $record->id]));
528
+                    } else {
529
+                        Notification::make()
530
+                            ->warning()
531
+                            ->title('Cannot approve invoice')
532
+                            ->body('This invoice has inactive adjustments that must be addressed first.')
533
+                            ->persistent()
534
+                            ->send();
535
+                    }
536
+                } else {
537
+                    $record->approveDraft();
473 538
 
474
-                $action->success();
539
+                    $action->success();
540
+                }
475 541
             });
476 542
     }
477 543
 

+ 24
- 5
app/Models/Accounting/RecurringInvoice.php Bestand weergeven

@@ -17,6 +17,7 @@ use App\Enums\Accounting\InvoiceStatus;
17 17
 use App\Enums\Accounting\Month;
18 18
 use App\Enums\Accounting\RecurringInvoiceStatus;
19 19
 use App\Enums\Setting\PaymentTerms;
20
+use App\Filament\Company\Resources\Sales\RecurringInvoiceResource\Pages\ViewRecurringInvoice;
20 21
 use App\Filament\Forms\Components\Banner;
21 22
 use App\Filament\Forms\Components\CustomSection;
22 23
 use App\Models\Common\Client;
@@ -28,6 +29,7 @@ use Filament\Actions\Action;
28 29
 use Filament\Actions\MountableAction;
29 30
 use Filament\Forms;
30 31
 use Filament\Forms\Form;
32
+use Filament\Notifications\Notification;
31 33
 use Guava\FilamentClusters\Forms\Cluster;
32 34
 use Illuminate\Database\Eloquent\Attributes\CollectedBy;
33 35
 use Illuminate\Database\Eloquent\Attributes\ObservedBy;
@@ -35,6 +37,7 @@ use Illuminate\Database\Eloquent\Model;
35 37
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
36 38
 use Illuminate\Database\Eloquent\Relations\HasMany;
37 39
 use Illuminate\Support\Carbon;
40
+use Livewire\Component;
38 41
 
39 42
 #[CollectedBy(DocumentCollection::class)]
40 43
 #[ObservedBy(RecurringInvoiceObserver::class)]
@@ -557,16 +560,32 @@ class RecurringInvoice extends Document
557 560
     {
558 561
         return $action::make('approveDraft')
559 562
             ->label('Approve')
560
-            ->icon('heroicon-o-check-circle')
563
+            ->icon('heroicon-m-check-circle')
561 564
             ->visible(function (self $record) {
562 565
                 return $record->canBeApproved();
563 566
             })
567
+            ->requiresConfirmation()
564 568
             ->databaseTransaction()
565 569
             ->successNotificationTitle('Recurring invoice approved')
566
-            ->action(function (self $record, MountableAction $action) {
567
-                $record->approveDraft();
568
-
569
-                $action->success();
570
+            ->action(function (self $record, MountableAction $action, Component $livewire) {
571
+                if ($record->hasInactiveAdjustments()) {
572
+                    $isViewPage = $livewire instanceof ViewRecurringInvoice;
573
+
574
+                    if (! $isViewPage) {
575
+                        redirect(ViewRecurringInvoice::getUrl(['record' => $record->id]));
576
+                    } else {
577
+                        Notification::make()
578
+                            ->warning()
579
+                            ->title('Cannot approve recurring invoice')
580
+                            ->body('This recurring invoice has inactive adjustments that must be addressed first.')
581
+                            ->persistent()
582
+                            ->send();
583
+                    }
584
+                } else {
585
+                    $record->approveDraft();
586
+
587
+                    $action->success();
588
+                }
570 589
             });
571 590
     }
572 591
 

+ 2
- 0
app/Models/Accounting/Transaction.php Bestand weergeven

@@ -45,6 +45,7 @@ class Transaction extends Model
45 45
         'posted_at',
46 46
         'created_by',
47 47
         'updated_by',
48
+        'meta',
48 49
     ];
49 50
 
50 51
     protected $casts = [
@@ -54,6 +55,7 @@ class Transaction extends Model
54 55
         'pending' => 'boolean',
55 56
         'reviewed' => 'boolean',
56 57
         'posted_at' => 'date',
58
+        'meta' => 'array',
57 59
     ];
58 60
 
59 61
     public function account(): BelongsTo

+ 7
- 0
app/Models/Common/Offering.php Bestand weergeven

@@ -111,4 +111,11 @@ class Offering extends Model
111 111
     {
112 112
         return $this->adjustments()->where('category', AdjustmentCategory::Discount)->where('type', AdjustmentType::Purchase);
113 113
     }
114
+
115
+    public function hasInactiveAdjustments(): bool
116
+    {
117
+        return $this->adjustments->contains(function (Adjustment $adjustment) {
118
+            return $adjustment->isInactive();
119
+        });
120
+    }
114 121
 }

+ 15
- 0
app/Models/Company.php Bestand weergeven

@@ -93,6 +93,21 @@ class Company extends FilamentCompaniesCompany implements HasAvatar
93 93
         return $this->hasMany(Accounting\Bill::class, 'company_id');
94 94
     }
95 95
 
96
+    public function budgets(): HasMany
97
+    {
98
+        return $this->hasMany(Accounting\Budget::class, 'company_id');
99
+    }
100
+
101
+    public function budgetItems(): HasMany
102
+    {
103
+        return $this->hasMany(Accounting\BudgetItem::class, 'company_id');
104
+    }
105
+
106
+    public function budgetAllocations(): HasMany
107
+    {
108
+        return $this->hasMany(Accounting\BudgetAllocation::class, 'company_id');
109
+    }
110
+
96 111
     public function accountSubtypes(): HasMany
97 112
     {
98 113
         return $this->hasMany(AccountSubtype::class, 'company_id');

+ 11
- 12
app/Observers/AccountObserver.php Bestand weergeven

@@ -5,7 +5,6 @@ namespace App\Observers;
5 5
 use App\Enums\Accounting\AccountCategory;
6 6
 use App\Enums\Accounting\AccountType;
7 7
 use App\Models\Accounting\Account;
8
-use App\Models\Accounting\AccountSubtype;
9 8
 use App\Utilities\Accounting\AccountCode;
10 9
 use App\Utilities\Currency\CurrencyAccessor;
11 10
 
@@ -13,33 +12,33 @@ class AccountObserver
13 12
 {
14 13
     public function creating(Account $account): void
15 14
     {
16
-        $this->setCategoryAndType($account, true);
17
-        $this->setCurrency($account);
15
+        $this->setCategoryAndType($account);
16
+        $this->ensureDefaultCurrency($account);
18 17
     }
19 18
 
20 19
     public function updating(Account $account): void
21 20
     {
22 21
         if ($account->isDirty('subtype_id')) {
23
-            $this->setCategoryAndType($account, false);
22
+            $this->setCategoryAndType($account);
24 23
         }
24
+
25
+        $this->ensureDefaultCurrency($account);
25 26
     }
26 27
 
27
-    private function setCategoryAndType(Account $account, bool $isCreating): void
28
+    private function setCategoryAndType(Account $account): void
28 29
     {
29
-        $subtype = $account->subtype_id ? AccountSubtype::find($account->subtype_id) : null;
30
-
31
-        if ($subtype) {
30
+        if ($subtype = $account->subtype) {
32 31
             $account->category = $subtype->category;
33 32
             $account->type = $subtype->type;
34
-        } elseif ($isCreating) {
33
+        } else {
35 34
             $account->category = AccountCategory::Asset;
36 35
             $account->type = AccountType::CurrentAsset;
37 36
         }
38 37
     }
39 38
 
40
-    private function setCurrency(Account $account): void
39
+    private function ensureDefaultCurrency(Account $account): void
41 40
     {
42
-        if ($account->currency_code === null && $account->subtype->multi_currency === false) {
41
+        if (! $account->currency_code) {
43 42
             $account->currency_code = CurrencyAccessor::getDefaultCurrency();
44 43
         }
45 44
     }
@@ -58,7 +57,7 @@ class AccountObserver
58 57
     {
59 58
         if (! $account->code) {
60 59
             $this->setAccountCode($account);
61
-            $account->save();
60
+            $account->saveQuietly();
62 61
         }
63 62
     }
64 63
 }

+ 45
- 1
app/Observers/AdjustmentObserver.php Bestand weergeven

@@ -2,6 +2,7 @@
2 2
 
3 3
 namespace App\Observers;
4 4
 
5
+use App\Enums\Accounting\AdjustmentStatus;
5 6
 use App\Models\Accounting\Account;
6 7
 use App\Models\Accounting\Adjustment;
7 8
 
@@ -9,7 +10,7 @@ class AdjustmentObserver
9 10
 {
10 11
     public function creating(Adjustment $adjustment): void
11 12
     {
12
-        if ($adjustment->account_id === null && ! $adjustment->isNonRecoverablePurchaseTax()) {
13
+        if (! $adjustment->account_id && ! $adjustment->isNonRecoverablePurchaseTax()) {
13 14
             $account = null;
14 15
 
15 16
             if ($adjustment->isSalesTax()) {
@@ -37,4 +38,47 @@ class AdjustmentObserver
37 38
             ]);
38 39
         }
39 40
     }
41
+
42
+    public function saved(Adjustment $adjustment): void
43
+    {
44
+        if ($adjustment->wasChanged('status') || $adjustment->wasRecentlyCreated) {
45
+            if ($adjustment->isInactive()) {
46
+                $adjustment->account?->update([
47
+                    'archived' => true,
48
+                ]);
49
+            } else {
50
+                $adjustment->account?->update([
51
+                    'archived' => false,
52
+                ]);
53
+            }
54
+        }
55
+    }
56
+
57
+    /**
58
+     * Handle the Adjustment "saving" event.
59
+     */
60
+    public function saving(Adjustment $adjustment): void
61
+    {
62
+        // Handle dates changes affecting status
63
+        // Only if the status isn't being explicitly changed and not in a manual state
64
+        if ($adjustment->isDirty(['start_date', 'end_date']) &&
65
+            ! $adjustment->isDirty('status') &&
66
+            ! in_array($adjustment->status, [AdjustmentStatus::Archived, AdjustmentStatus::Paused])) {
67
+
68
+            $adjustment->status = $adjustment->calculateNaturalStatus();
69
+        }
70
+
71
+        // Handle auto-resume for paused adjustments with a paused_until date
72
+        if ($adjustment->shouldAutoResume() && ! $adjustment->isDirty('status')) {
73
+            $adjustment->status = $adjustment->calculateNaturalStatus();
74
+            $adjustment->paused_at = null;
75
+            $adjustment->paused_until = null;
76
+            $adjustment->status_reason = null;
77
+        }
78
+
79
+        // Ensure consistency between paused status and paused_at field
80
+        if ($adjustment->status === AdjustmentStatus::Paused && ! $adjustment->paused_at) {
81
+            $adjustment->paused_at = now();
82
+        }
83
+    }
40 84
 }

+ 11
- 1
app/Observers/EstimateObserver.php Bestand weergeven

@@ -11,7 +11,17 @@ class EstimateObserver
11 11
 {
12 12
     public function saving(Estimate $estimate): void
13 13
     {
14
-        if ($estimate->approved_at && $estimate->is_currently_expired) {
14
+        if (! $estimate->wasApproved()) {
15
+            return;
16
+        }
17
+
18
+        if ($estimate->isDirty('expiration_date') && $estimate->status === EstimateStatus::Expired && ! $estimate->is_currently_expired) {
19
+            $estimate->status = $estimate->hasBeenSent() ? EstimateStatus::Sent : EstimateStatus::Unsent;
20
+
21
+            return;
22
+        }
23
+
24
+        if ($estimate->is_currently_expired && $estimate->canBeExpired()) {
15 25
             $estimate->status = EstimateStatus::Expired;
16 26
         }
17 27
     }

+ 32
- 0
app/Observers/TransactionObserver.php Bestand weergeven

@@ -113,6 +113,16 @@ class TransactionObserver
113 113
             ->when($excludedTransaction, fn (Builder $query) => $query->whereKeyNot($excludedTransaction->getKey()))
114 114
             ->get()
115 115
             ->sum(function (Transaction $transaction) use ($invoiceCurrency) {
116
+                // If the transaction has stored the original invoice amount in metadata, use that
117
+                if (! empty($transaction->meta) &&
118
+                    isset($transaction->meta['original_document_currency']) &&
119
+                    $transaction->meta['original_document_currency'] === $invoiceCurrency &&
120
+                    isset($transaction->meta['amount_in_document_currency_cents'])) {
121
+
122
+                    return (int) $transaction->meta['amount_in_document_currency_cents'];
123
+                }
124
+
125
+                // Fall back to conversion if metadata is not available
116 126
                 $bankAccountCurrency = $transaction->bankAccount->account->currency_code;
117 127
                 $amountCents = (int) $transaction->getRawOriginal('amount');
118 128
 
@@ -123,6 +133,16 @@ class TransactionObserver
123 133
             ->when($excludedTransaction, fn (Builder $query) => $query->whereKeyNot($excludedTransaction->getKey()))
124 134
             ->get()
125 135
             ->sum(function (Transaction $transaction) use ($invoiceCurrency) {
136
+                // If the transaction has stored the original invoice amount in metadata, use that
137
+                if (! empty($transaction->meta) &&
138
+                    isset($transaction->meta['original_document_currency']) &&
139
+                    $transaction->meta['original_document_currency'] === $invoiceCurrency &&
140
+                    isset($transaction->meta['amount_in_document_currency_cents'])) {
141
+
142
+                    return (int) $transaction->meta['amount_in_document_currency_cents'];
143
+                }
144
+
145
+                // Fall back to conversion if metadata is not available
126 146
                 $bankAccountCurrency = $transaction->bankAccount->account->currency_code;
127 147
                 $amountCents = (int) $transaction->getRawOriginal('amount');
128 148
 
@@ -136,6 +156,7 @@ class TransactionObserver
136 156
         $newStatus = match (true) {
137 157
             $totalPaidInInvoiceCurrencyCents > $invoiceTotalInInvoiceCurrencyCents => InvoiceStatus::Overpaid,
138 158
             $totalPaidInInvoiceCurrencyCents === $invoiceTotalInInvoiceCurrencyCents => InvoiceStatus::Paid,
159
+            $totalPaidInInvoiceCurrencyCents === 0 => $invoice->last_sent_at ? InvoiceStatus::Sent : InvoiceStatus::Unsent,
139 160
             default => InvoiceStatus::Partial,
140 161
         };
141 162
 
@@ -166,6 +187,16 @@ class TransactionObserver
166 187
             ->when($excludedTransaction, fn (Builder $query) => $query->whereKeyNot($excludedTransaction->getKey()))
167 188
             ->get()
168 189
             ->sum(function (Transaction $transaction) use ($billCurrency) {
190
+                // If the transaction has stored the original bill amount in metadata, use that
191
+                if (! empty($transaction->meta) &&
192
+                    isset($transaction->meta['original_document_currency']) &&
193
+                    $transaction->meta['original_document_currency'] === $billCurrency &&
194
+                    isset($transaction->meta['amount_in_document_currency_cents'])) {
195
+
196
+                    return (int) $transaction->meta['amount_in_document_currency_cents'];
197
+                }
198
+
199
+                // Fall back to conversion if metadata is not available
169 200
                 $bankAccountCurrency = $transaction->bankAccount->account->currency_code;
170 201
                 $amountCents = (int) $transaction->getRawOriginal('amount');
171 202
 
@@ -178,6 +209,7 @@ class TransactionObserver
178 209
 
179 210
         $newStatus = match (true) {
180 211
             $totalPaidInBillCurrencyCents >= $billTotalInBillCurrencyCents => BillStatus::Paid,
212
+            $totalPaidInBillCurrencyCents === 0 => BillStatus::Open,
181 213
             default => BillStatus::Partial,
182 214
         };
183 215
 

+ 70
- 0
app/Policies/AdjustmentPolicy.php Bestand weergeven

@@ -0,0 +1,70 @@
1
+<?php
2
+
3
+namespace App\Policies;
4
+
5
+use App\Enums\Accounting\AdjustmentStatus;
6
+use App\Models\Accounting\Adjustment;
7
+use App\Models\User;
8
+
9
+class AdjustmentPolicy
10
+{
11
+    /**
12
+     * Determine whether the user can view any models.
13
+     */
14
+    public function viewAny(User $user): bool
15
+    {
16
+        return true;
17
+    }
18
+
19
+    /**
20
+     * Determine whether the user can view the model.
21
+     */
22
+    public function view(User $user, Adjustment $adjustment): bool
23
+    {
24
+        return true;
25
+    }
26
+
27
+    /**
28
+     * Determine whether the user can create models.
29
+     */
30
+    public function create(User $user): bool
31
+    {
32
+        return true;
33
+    }
34
+
35
+    /**
36
+     * Determine whether the user can update the model.
37
+     */
38
+    public function update(User $user, Adjustment $adjustment): bool
39
+    {
40
+        if ($adjustment->status === AdjustmentStatus::Archived) {
41
+            return false;
42
+        }
43
+
44
+        return true;
45
+    }
46
+
47
+    /**
48
+     * Determine whether the user can delete the model.
49
+     */
50
+    public function delete(User $user, Adjustment $adjustment): bool
51
+    {
52
+        return false;
53
+    }
54
+
55
+    /**
56
+     * Determine whether the user can restore the model.
57
+     */
58
+    public function restore(User $user, Adjustment $adjustment): bool
59
+    {
60
+        return true;
61
+    }
62
+
63
+    /**
64
+     * Determine whether the user can permanently delete the model.
65
+     */
66
+    public function forceDelete(User $user, Adjustment $adjustment): bool
67
+    {
68
+        return true;
69
+    }
70
+}

+ 5
- 5
app/Policies/EstimatePolicy.php Bestand weergeven

@@ -20,7 +20,7 @@ class EstimatePolicy
20 20
      */
21 21
     public function view(User $user, Estimate $estimate): bool
22 22
     {
23
-        return $user->belongsToCompany($estimate->company);
23
+        return true;
24 24
     }
25 25
 
26 26
     /**
@@ -40,7 +40,7 @@ class EstimatePolicy
40 40
             return false;
41 41
         }
42 42
 
43
-        return $user->belongsToCompany($estimate->company);
43
+        return true;
44 44
     }
45 45
 
46 46
     /**
@@ -48,7 +48,7 @@ class EstimatePolicy
48 48
      */
49 49
     public function delete(User $user, Estimate $estimate): bool
50 50
     {
51
-        return $user->belongsToCompany($estimate->company);
51
+        return true;
52 52
     }
53 53
 
54 54
     /**
@@ -56,7 +56,7 @@ class EstimatePolicy
56 56
      */
57 57
     public function restore(User $user, Estimate $estimate): bool
58 58
     {
59
-        return $user->belongsToCompany($estimate->company);
59
+        return true;
60 60
     }
61 61
 
62 62
     /**
@@ -64,6 +64,6 @@ class EstimatePolicy
64 64
      */
65 65
     public function forceDelete(User $user, Estimate $estimate): bool
66 66
     {
67
-        return $user->belongsToCompany($estimate->company);
67
+        return true;
68 68
     }
69 69
 }

+ 9
- 4
app/Providers/Filament/CompanyPanelProvider.php Bestand weergeven

@@ -25,6 +25,7 @@ use App\Filament\Company\Pages\ManageCompany;
25 25
 use App\Filament\Company\Pages\Reports;
26 26
 use App\Filament\Company\Pages\Service\ConnectedAccount;
27 27
 use App\Filament\Company\Pages\Service\LiveCurrency;
28
+use App\Filament\Company\Resources\Accounting\BudgetResource;
28 29
 use App\Filament\Company\Resources\Banking\AccountResource;
29 30
 use App\Filament\Company\Resources\Common\OfferingResource;
30 31
 use App\Filament\Company\Resources\Purchases\BillResource;
@@ -39,6 +40,7 @@ use App\Http\Middleware\ConfigureCurrentCompany;
39 40
 use App\Livewire\UpdatePassword;
40 41
 use App\Livewire\UpdateProfileInformation;
41 42
 use App\Models\Company;
43
+use App\Services\CompanySettingsService;
42 44
 use App\Support\FilamentComponentConfigurator;
43 45
 use Exception;
44 46
 use Filament\Actions;
@@ -133,10 +135,10 @@ class CompanyPanelProvider extends PanelProvider
133 135
                             ->label('Sales')
134 136
                             ->icon('heroicon-o-currency-dollar')
135 137
                             ->items([
138
+                                ...ClientResource::getNavigationItems(),
139
+                                ...EstimateResource::getNavigationItems(),
136 140
                                 ...InvoiceResource::getNavigationItems(),
137 141
                                 ...RecurringInvoiceResource::getNavigationItems(),
138
-                                ...EstimateResource::getNavigationItems(),
139
-                                ...ClientResource::getNavigationItems(),
140 142
                             ]),
141 143
                         NavigationGroup::make('Purchases')
142 144
                             ->label('Purchases')
@@ -150,6 +152,7 @@ class CompanyPanelProvider extends PanelProvider
150 152
                             ->icon('heroicon-o-clipboard-document-list')
151 153
                             ->extraSidebarAttributes(['class' => 'es-sidebar-group'])
152 154
                             ->items([
155
+                                // ...BudgetResource::getNavigationItems(),
153 156
                                 ...AccountChart::getNavigationItems(),
154 157
                                 ...Transactions::getNavigationItems(),
155 158
                             ]),
@@ -272,11 +275,13 @@ class CompanyPanelProvider extends PanelProvider
272 275
         });
273 276
 
274 277
         Tables\Table::configureUsing(static function (Tables\Table $table): void {
278
+            $table::$defaultDateDisplayFormat = CompanySettingsService::getDefaultDateFormat(session('current_company_id') ?? auth()->user()->current_company_id);
279
+
275 280
             $table
276 281
                 ->paginationPageOptions([5, 10, 25, 50, 100])
277 282
                 ->filtersFormWidth(MaxWidth::Small)
278 283
                 ->filtersTriggerAction(fn (Tables\Actions\Action $action) => $action->slideOver());
279
-        }, isImportant: true);
284
+        });
280 285
 
281 286
         Tables\Columns\TextColumn::configureUsing(function (Tables\Columns\TextColumn $column): void {
282 287
             $column->placeholder('–');
@@ -294,7 +299,7 @@ class CompanyPanelProvider extends PanelProvider
294 299
             $select
295 300
                 ->native(false)
296 301
                 ->selectablePlaceholder($isSelectable);
297
-        }, isImportant: true);
302
+        });
298 303
     }
299 304
 
300 305
     protected function hasRequiredRule(Select $component): bool

+ 4
- 4
app/Providers/MacroServiceProvider.php Bestand weergeven

@@ -8,6 +8,7 @@ use App\Enums\Accounting\AdjustmentComputation;
8 8
 use App\Enums\Setting\DateFormat;
9 9
 use App\Models\Accounting\AccountSubtype;
10 10
 use App\Models\Setting\Localization;
11
+use App\Services\CompanySettingsService;
11 12
 use App\Utilities\Accounting\AccountCode;
12 13
 use App\Utilities\Currency\CurrencyAccessor;
13 14
 use App\Utilities\Currency\CurrencyConverter;
@@ -433,10 +434,9 @@ class MacroServiceProvider extends ServiceProvider
433 434
         });
434 435
 
435 436
         Carbon::macro('toDefaultDateFormat', function () {
436
-            $localization = Localization::firstOrFail();
437
-
438
-            $dateFormat = $localization->date_format->value ?? DateFormat::DEFAULT;
439
-            $timezone = $localization->timezone ?? Carbon::now()->timezoneName;
437
+            $companyId = auth()->user()?->current_company_id;
438
+            $dateFormat = CompanySettingsService::getDefaultDateFormat($companyId);
439
+            $timezone = CompanySettingsService::getDefaultTimezone($companyId);
440 440
 
441 441
             return $this->setTimezone($timezone)->format($dateFormat);
442 442
         });

+ 1
- 1
app/Services/AccountService.php Bestand weergeven

@@ -78,7 +78,7 @@ class AccountService
78 78
         return new Money($endingBalance, $account->currency_code);
79 79
     }
80 80
 
81
-    private function calculateNetMovementByCategory(AccountCategory $category, int $debitBalance, int $creditBalance): int
81
+    public function calculateNetMovementByCategory(AccountCategory $category, int $debitBalance, int $creditBalance): int
82 82
     {
83 83
         if ($category->isNormalDebitBalance()) {
84 84
             return $debitBalance - $creditBalance;

+ 32
- 9
app/Services/CompanySettingsService.php Bestand weergeven

@@ -5,35 +5,58 @@ namespace App\Services;
5 5
 use App\Enums\Setting\DateFormat;
6 6
 use App\Enums\Setting\WeekStart;
7 7
 use App\Models\Company;
8
+use App\Models\Setting\Currency;
8 9
 use Illuminate\Support\Facades\Cache;
9 10
 
10 11
 class CompanySettingsService
11 12
 {
12
-    public static function getSettings(int $companyId): array
13
+    protected static array $requestCache = [];
14
+
15
+    public static function getSettings(?int $companyId = null): array
13 16
     {
17
+        if (! $companyId) {
18
+            return self::getDefaultSettings();
19
+        }
20
+
21
+        if (isset(self::$requestCache[$companyId])) {
22
+            return self::$requestCache[$companyId];
23
+        }
24
+
14 25
         $cacheKey = "company_settings_{$companyId}";
15 26
 
16
-        return Cache::rememberForever($cacheKey, function () use ($companyId) {
27
+        $settings = Cache::rememberForever($cacheKey, function () use ($companyId) {
17 28
             $company = Company::with(['locale'])->find($companyId);
18 29
 
19 30
             if (! $company) {
20 31
                 return self::getDefaultSettings();
21 32
             }
22 33
 
34
+            $defaultCurrency = Currency::query()
35
+                ->where('company_id', $companyId)
36
+                ->where('enabled', true)
37
+                ->value('code') ?? 'USD';
38
+
23 39
             return [
24 40
                 'default_language' => $company->locale->language ?? config('transmatic.source_locale'),
25 41
                 'default_timezone' => $company->locale->timezone ?? config('app.timezone'),
26
-                'default_currency' => $company->currency_code ?? 'USD',
42
+                'default_currency' => $defaultCurrency,
27 43
                 'default_date_format' => $company->locale->date_format->value ?? DateFormat::DEFAULT,
28 44
                 'default_week_start' => $company->locale->week_start->value ?? WeekStart::DEFAULT,
29 45
             ];
30 46
         });
47
+
48
+        self::$requestCache[$companyId] = $settings;
49
+
50
+        return $settings;
31 51
     }
32 52
 
33 53
     public static function invalidateSettings(int $companyId): void
34 54
     {
35 55
         $cacheKey = "company_settings_{$companyId}";
56
+
36 57
         Cache::forget($cacheKey);
58
+
59
+        unset(self::$requestCache[$companyId]);
37 60
     }
38 61
 
39 62
     public static function getDefaultSettings(): array
@@ -47,34 +70,34 @@ class CompanySettingsService
47 70
         ];
48 71
     }
49 72
 
50
-    public static function getSpecificSetting(int $companyId, string $key, $default = null)
73
+    public static function getSpecificSetting(?int $companyId, string $key, $default = null)
51 74
     {
52 75
         $settings = self::getSettings($companyId);
53 76
 
54 77
         return $settings[$key] ?? $default;
55 78
     }
56 79
 
57
-    public static function getDefaultLanguage(int $companyId): string
80
+    public static function getDefaultLanguage(?int $companyId = null): string
58 81
     {
59 82
         return self::getSpecificSetting($companyId, 'default_language', config('transmatic.source_locale'));
60 83
     }
61 84
 
62
-    public static function getDefaultTimezone(int $companyId): string
85
+    public static function getDefaultTimezone(?int $companyId = null): string
63 86
     {
64 87
         return self::getSpecificSetting($companyId, 'default_timezone', config('app.timezone'));
65 88
     }
66 89
 
67
-    public static function getDefaultCurrency(int $companyId): string
90
+    public static function getDefaultCurrency(?int $companyId = null): string
68 91
     {
69 92
         return self::getSpecificSetting($companyId, 'default_currency', 'USD');
70 93
     }
71 94
 
72
-    public static function getDefaultDateFormat(int $companyId): string
95
+    public static function getDefaultDateFormat(?int $companyId = null): string
73 96
     {
74 97
         return self::getSpecificSetting($companyId, 'default_date_format', DateFormat::DEFAULT);
75 98
     }
76 99
 
77
-    public static function getDefaultWeekStart(int $companyId): string
100
+    public static function getDefaultWeekStart(?int $companyId = null): string
78 101
     {
79 102
         return self::getSpecificSetting($companyId, 'default_week_start', WeekStart::DEFAULT);
80 103
     }

+ 4
- 1
app/Services/TransactionService.php Bestand weergeven

@@ -46,7 +46,10 @@ class TransactionService
46 46
     {
47 47
         $transactionType = $startingBalance >= 0 ? TransactionType::Deposit : TransactionType::Withdrawal;
48 48
         $accountName = $startingBalance >= 0 ? "Owner's Investment" : "Owner's Drawings";
49
-        $chartAccount = $account->where('category', AccountCategory::Equity)->where('name', $accountName)->first();
49
+        $chartAccount = $company->accounts()
50
+            ->where('category', AccountCategory::Equity)
51
+            ->where('name', $accountName)
52
+            ->firstOrFail();
50 53
 
51 54
         $postedAt = Carbon::parse($startDate)->subDay()->toDateTimeString();
52 55
 

+ 3
- 8
app/Utilities/Currency/CurrencyAccessor.php Bestand weergeven

@@ -5,7 +5,7 @@ namespace App\Utilities\Currency;
5 5
 use Akaunting\Money\Currency as ISOCurrencies;
6 6
 use App\Facades\Forex;
7 7
 use App\Models\Setting\Currency;
8
-use Illuminate\Support\Facades\Cache;
8
+use App\Services\CompanySettingsService;
9 9
 
10 10
 class CurrencyAccessor
11 11
 {
@@ -53,17 +53,12 @@ class CurrencyAccessor
53 53
 
54 54
     public static function getDefaultCurrency(): ?string
55 55
     {
56
-        $companyId = auth()->user()?->currentCompany?->id;
57
-        $cacheKey = "default_currency_{$companyId}";
56
+        $companyId = auth()->user()?->current_company_id;
58 57
 
59 58
         if ($companyId === null) {
60 59
             return 'USD';
61 60
         }
62 61
 
63
-        return Cache::rememberForever($cacheKey, function () {
64
-            return Currency::query()
65
-                ->where('enabled', true)
66
-                ->value('code');
67
-        });
62
+        return CompanySettingsService::getDefaultCurrency($companyId);
68 63
     }
69 64
 }

+ 1
- 1
app/Utilities/Currency/CurrencyConverter.php Bestand weergeven

@@ -32,7 +32,7 @@ class CurrencyConverter
32 32
 
33 33
     public static function prepareForAccessor(string $amount, string $currency): int
34 34
     {
35
-        return money($amount, $currency, true)->getAmount();
35
+        return self::convertToCents($amount, $currency);
36 36
     }
37 37
 
38 38
     public static function convertCentsToFormatSimple(int $amount, ?string $currency = null): string

+ 4
- 2
app/ValueObjects/Money.php Bestand weergeven

@@ -11,8 +11,10 @@ class Money
11 11
 
12 12
     public function __construct(
13 13
         private readonly int $amount,
14
-        private readonly string $currencyCode
15
-    ) {}
14
+        private ?string $currencyCode,
15
+    ) {
16
+        $this->currencyCode = $currencyCode ?: CurrencyAccessor::getDefaultCurrency();
17
+    }
16 18
 
17 19
     public function getAmount(): int
18 20
     {

+ 2
- 2
composer.json Bestand weergeven

@@ -30,6 +30,7 @@
30 30
         "symfony/intl": "^6.3"
31 31
     },
32 32
     "require-dev": {
33
+        "barryvdh/laravel-debugbar": "^3.15",
33 34
         "fakerphp/faker": "^1.23",
34 35
         "laravel/pint": "^1.13",
35 36
         "laravel/sail": "^1.26",
@@ -62,8 +63,7 @@
62 63
             "@php artisan filament:upgrade"
63 64
         ],
64 65
         "post-update-cmd": [
65
-            "@php artisan vendor:publish --tag=laravel-assets --ansi --force",
66
-            "npm up && npm run build || echo \"Skipping npm update and build (npm not available)\""
66
+            "@php artisan vendor:publish --tag=laravel-assets --ansi --force"
67 67
         ],
68 68
         "post-root-package-install": [
69 69
             "@php -r \"file_exists('.env') || copy('.env.example', '.env');\""

+ 450
- 289
composer.lock
Diff onderdrukt omdat het te groot bestand
Bestand weergeven


+ 332
- 0
config/debugbar.php Bestand weergeven

@@ -0,0 +1,332 @@
1
+<?php
2
+
3
+return [
4
+
5
+    /*
6
+     |--------------------------------------------------------------------------
7
+     | Debugbar Settings
8
+     |--------------------------------------------------------------------------
9
+     |
10
+     | Debugbar is enabled by default, when debug is set to true in app.php.
11
+     | You can override the value by setting enable to true or false instead of null.
12
+     |
13
+     | You can provide an array of URI's that must be ignored (eg. 'api/*')
14
+     |
15
+     */
16
+
17
+    'enabled' => env('DEBUGBAR_ENABLED', false),
18
+    'hide_empty_tabs' => true, // Hide tabs until they have content
19
+    'except' => [
20
+        'telescope*',
21
+        'horizon*',
22
+    ],
23
+
24
+    /*
25
+     |--------------------------------------------------------------------------
26
+     | Storage settings
27
+     |--------------------------------------------------------------------------
28
+     |
29
+     | Debugbar stores data for session/ajax requests.
30
+     | You can disable this, so the debugbar stores data in headers/session,
31
+     | but this can cause problems with large data collectors.
32
+     | By default, file storage (in the storage folder) is used. Redis and PDO
33
+     | can also be used. For PDO, run the package migrations first.
34
+     |
35
+     | Warning: Enabling storage.open will allow everyone to access previous
36
+     | request, do not enable open storage in publicly available environments!
37
+     | Specify a callback if you want to limit based on IP or authentication.
38
+     | Leaving it to null will allow localhost only.
39
+     */
40
+    'storage' => [
41
+        'enabled' => true,
42
+        'open' => env('DEBUGBAR_OPEN_STORAGE'), // bool/callback.
43
+        'driver' => 'file', // redis, file, pdo, socket, custom
44
+        'path' => storage_path('debugbar'), // For file driver
45
+        'connection' => null,   // Leave null for default connection (Redis/PDO)
46
+        'provider' => '', // Instance of StorageInterface for custom driver
47
+        'hostname' => '127.0.0.1', // Hostname to use with the "socket" driver
48
+        'port' => 2304, // Port to use with the "socket" driver
49
+    ],
50
+
51
+    /*
52
+    |--------------------------------------------------------------------------
53
+    | Editor
54
+    |--------------------------------------------------------------------------
55
+    |
56
+    | Choose your preferred editor to use when clicking file name.
57
+    |
58
+    | Supported: "phpstorm", "vscode", "vscode-insiders", "vscode-remote",
59
+    |            "vscode-insiders-remote", "vscodium", "textmate", "emacs",
60
+    |            "sublime", "atom", "nova", "macvim", "idea", "netbeans",
61
+    |            "xdebug", "espresso"
62
+    |
63
+    */
64
+
65
+    'editor' => env('DEBUGBAR_EDITOR') ?: env('IGNITION_EDITOR', 'phpstorm'),
66
+
67
+    /*
68
+    |--------------------------------------------------------------------------
69
+    | Remote Path Mapping
70
+    |--------------------------------------------------------------------------
71
+    |
72
+    | If you are using a remote dev server, like Laravel Homestead, Docker, or
73
+    | even a remote VPS, it will be necessary to specify your path mapping.
74
+    |
75
+    | Leaving one, or both of these, empty or null will not trigger the remote
76
+    | URL changes and Debugbar will treat your editor links as local files.
77
+    |
78
+    | "remote_sites_path" is an absolute base path for your sites or projects
79
+    | in Homestead, Vagrant, Docker, or another remote development server.
80
+    |
81
+    | Example value: "/home/vagrant/Code"
82
+    |
83
+    | "local_sites_path" is an absolute base path for your sites or projects
84
+    | on your local computer where your IDE or code editor is running on.
85
+    |
86
+    | Example values: "/Users/<name>/Code", "C:\Users\<name>\Documents\Code"
87
+    |
88
+    */
89
+
90
+    'remote_sites_path' => env('DEBUGBAR_REMOTE_SITES_PATH'),
91
+    'local_sites_path' => env('DEBUGBAR_LOCAL_SITES_PATH', env('IGNITION_LOCAL_SITES_PATH')),
92
+
93
+    /*
94
+     |--------------------------------------------------------------------------
95
+     | Vendors
96
+     |--------------------------------------------------------------------------
97
+     |
98
+     | Vendor files are included by default, but can be set to false.
99
+     | This can also be set to 'js' or 'css', to only include javascript or css vendor files.
100
+     | Vendor files are for css: font-awesome (including fonts) and highlight.js (css files)
101
+     | and for js: jquery and highlight.js
102
+     | So if you want syntax highlighting, set it to true.
103
+     | jQuery is set to not conflict with existing jQuery scripts.
104
+     |
105
+     */
106
+
107
+    'include_vendors' => true,
108
+
109
+    /*
110
+     |--------------------------------------------------------------------------
111
+     | Capture Ajax Requests
112
+     |--------------------------------------------------------------------------
113
+     |
114
+     | The Debugbar can capture Ajax requests and display them. If you don't want this (ie. because of errors),
115
+     | you can use this option to disable sending the data through the headers.
116
+     |
117
+     | Optionally, you can also send ServerTiming headers on ajax requests for the Chrome DevTools.
118
+     |
119
+     | Note for your request to be identified as ajax requests they must either send the header
120
+     | X-Requested-With with the value XMLHttpRequest (most JS libraries send this), or have application/json as a Accept header.
121
+     |
122
+     | By default `ajax_handler_auto_show` is set to true allowing ajax requests to be shown automatically in the Debugbar.
123
+     | Changing `ajax_handler_auto_show` to false will prevent the Debugbar from reloading.
124
+     |
125
+     | You can defer loading the dataset, so it will be loaded with ajax after the request is done. (Experimental)
126
+     */
127
+
128
+    'capture_ajax' => true,
129
+    'add_ajax_timing' => false,
130
+    'ajax_handler_auto_show' => false,
131
+    'ajax_handler_enable_tab' => true,
132
+    'defer_datasets' => false,
133
+    /*
134
+     |--------------------------------------------------------------------------
135
+     | Custom Error Handler for Deprecated warnings
136
+     |--------------------------------------------------------------------------
137
+     |
138
+     | When enabled, the Debugbar shows deprecated warnings for Symfony components
139
+     | in the Messages tab.
140
+     |
141
+     */
142
+    'error_handler' => false,
143
+
144
+    /*
145
+     |--------------------------------------------------------------------------
146
+     | Clockwork integration
147
+     |--------------------------------------------------------------------------
148
+     |
149
+     | The Debugbar can emulate the Clockwork headers, so you can use the Chrome
150
+     | Extension, without the server-side code. It uses Debugbar collectors instead.
151
+     |
152
+     */
153
+    'clockwork' => false,
154
+
155
+    /*
156
+     |--------------------------------------------------------------------------
157
+     | DataCollectors
158
+     |--------------------------------------------------------------------------
159
+     |
160
+     | Enable/disable DataCollectors
161
+     |
162
+     */
163
+
164
+    'collectors' => [
165
+        'phpinfo' => false,  // Php version
166
+        'messages' => true,  // Messages
167
+        'time' => true,  // Time Datalogger
168
+        'memory' => true,  // Memory usage
169
+        'exceptions' => true,  // Exception displayer
170
+        'log' => true,  // Logs from Monolog (merged in messages if enabled)
171
+        'db' => true,  // Show database (PDO) queries and bindings
172
+        'views' => true,  // Views with their data
173
+        'route' => false,  // Current route information
174
+        'auth' => false, // Display Laravel authentication status
175
+        'gate' => true,  // Display Laravel Gate checks
176
+        'session' => false,  // Display session data
177
+        'symfony_request' => true,  // Only one can be enabled..
178
+        'mail' => true,  // Catch mail messages
179
+        'laravel' => true, // Laravel version and environment
180
+        'events' => false, // All events fired
181
+        'default_request' => false, // Regular or special Symfony request logger
182
+        'logs' => false, // Add the latest log messages
183
+        'files' => false, // Show the included files
184
+        'config' => false, // Display config settings
185
+        'cache' => false, // Display cache events
186
+        'models' => true,  // Display models
187
+        'livewire' => true,  // Display Livewire (when available)
188
+        'jobs' => false, // Display dispatched jobs
189
+        'pennant' => false, // Display Pennant feature flags
190
+    ],
191
+
192
+    /*
193
+     |--------------------------------------------------------------------------
194
+     | Extra options
195
+     |--------------------------------------------------------------------------
196
+     |
197
+     | Configure some DataCollectors
198
+     |
199
+     */
200
+
201
+    'options' => [
202
+        'time' => [
203
+            'memory_usage' => false,  // Calculated by subtracting memory start and end, it may be inaccurate
204
+        ],
205
+        'messages' => [
206
+            'trace' => true,   // Trace the origin of the debug message
207
+        ],
208
+        'memory' => [
209
+            'reset_peak' => false,     // run memory_reset_peak_usage before collecting
210
+            'with_baseline' => false,  // Set boot memory usage as memory peak baseline
211
+            'precision' => 0,          // Memory rounding precision
212
+        ],
213
+        'auth' => [
214
+            'show_name' => true,   // Also show the users name/email in the debugbar
215
+            'show_guards' => true, // Show the guards that are used
216
+        ],
217
+        'db' => [
218
+            'with_params' => true,   // Render SQL with the parameters substituted
219
+            'exclude_paths' => [       // Paths to exclude entirely from the collector
220
+                //                'vendor/laravel/framework/src/Illuminate/Session', // Exclude sessions queries
221
+            ],
222
+            'backtrace' => true,   // Use a backtrace to find the origin of the query in your files.
223
+            'backtrace_exclude_paths' => [],   // Paths to exclude from backtrace. (in addition to defaults)
224
+            'timeline' => false,  // Add the queries to the timeline
225
+            'duration_background' => true,   // Show shaded background on each query relative to how long it took to execute.
226
+            'explain' => [                 // Show EXPLAIN output on queries
227
+                'enabled' => false,
228
+            ],
229
+            'hints' => false,   // Show hints for common mistakes
230
+            'show_copy' => true,    // Show copy button next to the query,
231
+            'slow_threshold' => false,   // Only track queries that last longer than this time in ms
232
+            'memory_usage' => false,   // Show queries memory usage
233
+            'soft_limit' => 100,      // After the soft limit, no parameters/backtrace are captured
234
+            'hard_limit' => 500,      // After the hard limit, queries are ignored
235
+        ],
236
+        'mail' => [
237
+            'timeline' => true,  // Add mails to the timeline
238
+            'show_body' => true,
239
+        ],
240
+        'views' => [
241
+            'timeline' => true,    // Add the views to the timeline
242
+            'data' => false,        // True for all data, 'keys' for only names, false for no parameters.
243
+            'group' => 50,          // Group duplicate views. Pass value to auto-group, or true/false to force
244
+            'exclude_paths' => [    // Add the paths which you don't want to appear in the views
245
+                'vendor/filament',   // Exclude Filament components by default
246
+            ],
247
+        ],
248
+        'route' => [
249
+            'label' => true,  // Show complete route on bar
250
+        ],
251
+        'session' => [
252
+            'hiddens' => [], // Hides sensitive values using array paths
253
+        ],
254
+        'symfony_request' => [
255
+            'label' => true,  // Show route on bar
256
+            'hiddens' => [], // Hides sensitive values using array paths, example: request_request.password
257
+        ],
258
+        'events' => [
259
+            'data' => false, // Collect events data, listeners
260
+        ],
261
+        'logs' => [
262
+            'file' => null,
263
+        ],
264
+        'cache' => [
265
+            'values' => true, // Collect cache values
266
+        ],
267
+    ],
268
+
269
+    /*
270
+     |--------------------------------------------------------------------------
271
+     | Inject Debugbar in Response
272
+     |--------------------------------------------------------------------------
273
+     |
274
+     | Usually, the debugbar is added just before </body>, by listening to the
275
+     | Response after the App is done. If you disable this, you have to add them
276
+     | in your template yourself. See http://phpdebugbar.com/docs/rendering.html
277
+     |
278
+     */
279
+
280
+    'inject' => true,
281
+
282
+    /*
283
+     |--------------------------------------------------------------------------
284
+     | Debugbar route prefix
285
+     |--------------------------------------------------------------------------
286
+     |
287
+     | Sometimes you want to set route prefix to be used by Debugbar to load
288
+     | its resources from. Usually the need comes from misconfigured web server or
289
+     | from trying to overcome bugs like this: http://trac.nginx.org/nginx/ticket/97
290
+     |
291
+     */
292
+    'route_prefix' => '_debugbar',
293
+
294
+    /*
295
+     |--------------------------------------------------------------------------
296
+     | Debugbar route middleware
297
+     |--------------------------------------------------------------------------
298
+     |
299
+     | Additional middleware to run on the Debugbar routes
300
+     */
301
+    'route_middleware' => [],
302
+
303
+    /*
304
+     |--------------------------------------------------------------------------
305
+     | Debugbar route domain
306
+     |--------------------------------------------------------------------------
307
+     |
308
+     | By default Debugbar route served from the same domain that request served.
309
+     | To override default domain, specify it as a non-empty value.
310
+     */
311
+    'route_domain' => null,
312
+
313
+    /*
314
+     |--------------------------------------------------------------------------
315
+     | Debugbar theme
316
+     |--------------------------------------------------------------------------
317
+     |
318
+     | Switches between light and dark theme. If set to auto it will respect system preferences
319
+     | Possible values: auto, light, dark
320
+     */
321
+    'theme' => env('DEBUGBAR_THEME', 'auto'),
322
+
323
+    /*
324
+     |--------------------------------------------------------------------------
325
+     | Backtrace stack limit
326
+     |--------------------------------------------------------------------------
327
+     |
328
+     | By default, the Debugbar limits the number of frames returned by the 'debug_backtrace()' function.
329
+     | If you need larger stacktraces, you can increase this number. Setting it to 0 will result in no limit.
330
+     */
331
+    'debug_backtrace_limit' => 50,
332
+];

+ 23
- 0
database/factories/Accounting/BudgetAllocationFactory.php Bestand weergeven

@@ -0,0 +1,23 @@
1
+<?php
2
+
3
+namespace Database\Factories\Accounting;
4
+
5
+use Illuminate\Database\Eloquent\Factories\Factory;
6
+
7
+/**
8
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Accounting\BudgetAllocation>
9
+ */
10
+class BudgetAllocationFactory extends Factory
11
+{
12
+    /**
13
+     * Define the model's default state.
14
+     *
15
+     * @return array<string, mixed>
16
+     */
17
+    public function definition(): array
18
+    {
19
+        return [
20
+            //
21
+        ];
22
+    }
23
+}

+ 23
- 0
database/factories/Accounting/BudgetFactory.php Bestand weergeven

@@ -0,0 +1,23 @@
1
+<?php
2
+
3
+namespace Database\Factories\Accounting;
4
+
5
+use Illuminate\Database\Eloquent\Factories\Factory;
6
+
7
+/**
8
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Accounting\Budget>
9
+ */
10
+class BudgetFactory extends Factory
11
+{
12
+    /**
13
+     * Define the model's default state.
14
+     *
15
+     * @return array<string, mixed>
16
+     */
17
+    public function definition(): array
18
+    {
19
+        return [
20
+            //
21
+        ];
22
+    }
23
+}

+ 23
- 0
database/factories/Accounting/BudgetItemFactory.php Bestand weergeven

@@ -0,0 +1,23 @@
1
+<?php
2
+
3
+namespace Database\Factories\Accounting;
4
+
5
+use Illuminate\Database\Eloquent\Factories\Factory;
6
+
7
+/**
8
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Accounting\BudgetItem>
9
+ */
10
+class BudgetItemFactory extends Factory
11
+{
12
+    /**
13
+     * Define the model's default state.
14
+     *
15
+     * @return array<string, mixed>
16
+     */
17
+    public function definition(): array
18
+    {
19
+        return [
20
+            //
21
+        ];
22
+    }
23
+}

+ 1
- 0
database/migrations/2024_01_01_234943_create_transactions_table.php Bestand weergeven

@@ -27,6 +27,7 @@ return new class extends Migration
27 27
             $table->text('notes')->nullable();
28 28
             $table->string('reference')->nullable();
29 29
             $table->bigInteger('amount')->default(0);
30
+            $table->json('meta')->nullable();
30 31
             $table->boolean('pending')->default(false);
31 32
             $table->boolean('reviewed')->default(false);
32 33
             $table->dateTime('posted_at');

+ 5
- 0
database/migrations/2024_11_14_230753_create_adjustments_table.php Bestand weergeven

@@ -16,6 +16,8 @@ return new class extends Migration
16 16
             $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17 17
             $table->foreignId('account_id')->nullable()->constrained('accounts')->nullOnDelete();
18 18
             $table->string('name')->nullable();
19
+            $table->string('status')->default('active');
20
+            $table->text('status_reason')->nullable();
19 21
             $table->text('description')->nullable();
20 22
             $table->string('category')->default('tax');
21 23
             $table->string('type')->default('sales');
@@ -25,6 +27,9 @@ return new class extends Migration
25 27
             $table->string('scope')->nullable();
26 28
             $table->dateTime('start_date')->nullable();
27 29
             $table->dateTime('end_date')->nullable();
30
+            $table->timestamp('paused_at')->nullable();
31
+            $table->timestamp('paused_until')->nullable();
32
+            $table->timestamp('archived_at')->nullable();
28 33
             $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
29 34
             $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
30 35
             $table->timestamps();

+ 43
- 0
database/migrations/2025_03_15_191245_create_budgets_table.php Bestand weergeven

@@ -0,0 +1,43 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    /**
10
+     * Run the migrations.
11
+     */
12
+    public function up(): void
13
+    {
14
+        Schema::create('budgets', function (Blueprint $table) {
15
+            $table->id();
16
+            $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17
+            $table->foreignId('source_budget_id')->nullable()->constrained('budgets')->nullOnDelete();
18
+            // Source fiscal year
19
+            $table->year('source_fiscal_year')->nullable();
20
+            $table->string('source_type')->nullable(); // budget, actuals
21
+            $table->string('name');
22
+            $table->date('start_date');
23
+            $table->date('end_date');
24
+            $table->string('status')->default('draft'); // draft, active, closed
25
+            $table->string('interval_type')->default('month'); // day, week, month, quarter, year
26
+            $table->text('notes')->nullable();
27
+            $table->timestamp('approved_at')->nullable();
28
+            $table->foreignId('approved_by_id')->nullable()->constrained('users')->nullOnDelete();
29
+            $table->timestamp('closed_at')->nullable();
30
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
31
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
32
+            $table->timestamps();
33
+        });
34
+    }
35
+
36
+    /**
37
+     * Reverse the migrations.
38
+     */
39
+    public function down(): void
40
+    {
41
+        Schema::dropIfExists('budgets');
42
+    }
43
+};

+ 32
- 0
database/migrations/2025_03_15_191321_create_budget_items_table.php Bestand weergeven

@@ -0,0 +1,32 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    /**
10
+     * Run the migrations.
11
+     */
12
+    public function up(): void
13
+    {
14
+        Schema::create('budget_items', function (Blueprint $table) {
15
+            $table->id();
16
+            $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17
+            $table->foreignId('budget_id')->constrained()->cascadeOnDelete();
18
+            $table->foreignId('account_id')->constrained('accounts')->cascadeOnDelete();
19
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
20
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
21
+            $table->timestamps();
22
+        });
23
+    }
24
+
25
+    /**
26
+     * Reverse the migrations.
27
+     */
28
+    public function down(): void
29
+    {
30
+        Schema::dropIfExists('budget_items');
31
+    }
32
+};

+ 34
- 0
database/migrations/2025_03_15_203454_create_budget_allocations_table.php Bestand weergeven

@@ -0,0 +1,34 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    /**
10
+     * Run the migrations.
11
+     */
12
+    public function up(): void
13
+    {
14
+        Schema::create('budget_allocations', function (Blueprint $table) {
15
+            $table->id();
16
+            $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17
+            $table->foreignId('budget_item_id')->constrained()->cascadeOnDelete();
18
+            $table->string('period'); // e.g., 'Jan 2024', 'Q1 2024', '2024'
19
+            $table->string('interval_type'); // 'month', 'quarter', 'year'
20
+            $table->date('start_date'); // Period start
21
+            $table->date('end_date'); // Period end
22
+            $table->bigInteger('amount')->default(0); // Stored in cents
23
+            $table->timestamps();
24
+        });
25
+    }
26
+
27
+    /**
28
+     * Reverse the migrations.
29
+     */
30
+    public function down(): void
31
+    {
32
+        Schema::dropIfExists('budget_allocations');
33
+    }
34
+};

+ 88
- 88
package-lock.json Bestand weergeven

@@ -575,9 +575,9 @@
575 575
             }
576 576
         },
577 577
         "node_modules/@rollup/rollup-android-arm-eabi": {
578
-            "version": "4.35.0",
579
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.35.0.tgz",
580
-            "integrity": "sha512-uYQ2WfPaqz5QtVgMxfN6NpLD+no0MYHDBywl7itPYd3K5TjjSghNKmX8ic9S8NU8w81NVhJv/XojcHptRly7qQ==",
578
+            "version": "4.36.0",
579
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.36.0.tgz",
580
+            "integrity": "sha512-jgrXjjcEwN6XpZXL0HUeOVGfjXhPyxAbbhD0BlXUB+abTOpbPiN5Wb3kOT7yb+uEtATNYF5x5gIfwutmuBA26w==",
581 581
             "cpu": [
582 582
                 "arm"
583 583
             ],
@@ -589,9 +589,9 @@
589 589
             ]
590 590
         },
591 591
         "node_modules/@rollup/rollup-android-arm64": {
592
-            "version": "4.35.0",
593
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.35.0.tgz",
594
-            "integrity": "sha512-FtKddj9XZudurLhdJnBl9fl6BwCJ3ky8riCXjEw3/UIbjmIY58ppWwPEvU3fNu+W7FUsAsB1CdH+7EQE6CXAPA==",
592
+            "version": "4.36.0",
593
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.36.0.tgz",
594
+            "integrity": "sha512-NyfuLvdPdNUfUNeYKUwPwKsE5SXa2J6bCt2LdB/N+AxShnkpiczi3tcLJrm5mA+eqpy0HmaIY9F6XCa32N5yzg==",
595 595
             "cpu": [
596 596
                 "arm64"
597 597
             ],
@@ -603,9 +603,9 @@
603 603
             ]
604 604
         },
605 605
         "node_modules/@rollup/rollup-darwin-arm64": {
606
-            "version": "4.35.0",
607
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.35.0.tgz",
608
-            "integrity": "sha512-Uk+GjOJR6CY844/q6r5DR/6lkPFOw0hjfOIzVx22THJXMxktXG6CbejseJFznU8vHcEBLpiXKY3/6xc+cBm65Q==",
606
+            "version": "4.36.0",
607
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.36.0.tgz",
608
+            "integrity": "sha512-JQ1Jk5G4bGrD4pWJQzWsD8I1n1mgPXq33+/vP4sk8j/z/C2siRuxZtaUA7yMTf71TCZTZl/4e1bfzwUmFb3+rw==",
609 609
             "cpu": [
610 610
                 "arm64"
611 611
             ],
@@ -617,9 +617,9 @@
617 617
             ]
618 618
         },
619 619
         "node_modules/@rollup/rollup-darwin-x64": {
620
-            "version": "4.35.0",
621
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.35.0.tgz",
622
-            "integrity": "sha512-3IrHjfAS6Vkp+5bISNQnPogRAW5GAV1n+bNCrDwXmfMHbPl5EhTmWtfmwlJxFRUCBZ+tZ/OxDyU08aF6NI/N5Q==",
620
+            "version": "4.36.0",
621
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.36.0.tgz",
622
+            "integrity": "sha512-6c6wMZa1lrtiRsbDziCmjE53YbTkxMYhhnWnSW8R/yqsM7a6mSJ3uAVT0t8Y/DGt7gxUWYuFM4bwWk9XCJrFKA==",
623 623
             "cpu": [
624 624
                 "x64"
625 625
             ],
@@ -631,9 +631,9 @@
631 631
             ]
632 632
         },
633 633
         "node_modules/@rollup/rollup-freebsd-arm64": {
634
-            "version": "4.35.0",
635
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.35.0.tgz",
636
-            "integrity": "sha512-sxjoD/6F9cDLSELuLNnY0fOrM9WA0KrM0vWm57XhrIMf5FGiN8D0l7fn+bpUeBSU7dCgPV2oX4zHAsAXyHFGcQ==",
634
+            "version": "4.36.0",
635
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.36.0.tgz",
636
+            "integrity": "sha512-KXVsijKeJXOl8QzXTsA+sHVDsFOmMCdBRgFmBb+mfEb/7geR7+C8ypAml4fquUt14ZyVXaw2o1FWhqAfOvA4sg==",
637 637
             "cpu": [
638 638
                 "arm64"
639 639
             ],
@@ -645,9 +645,9 @@
645 645
             ]
646 646
         },
647 647
         "node_modules/@rollup/rollup-freebsd-x64": {
648
-            "version": "4.35.0",
649
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.35.0.tgz",
650
-            "integrity": "sha512-2mpHCeRuD1u/2kruUiHSsnjWtHjqVbzhBkNVQ1aVD63CcexKVcQGwJ2g5VphOd84GvxfSvnnlEyBtQCE5hxVVw==",
648
+            "version": "4.36.0",
649
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.36.0.tgz",
650
+            "integrity": "sha512-dVeWq1ebbvByI+ndz4IJcD4a09RJgRYmLccwlQ8bPd4olz3Y213uf1iwvc7ZaxNn2ab7bjc08PrtBgMu6nb4pQ==",
651 651
             "cpu": [
652 652
                 "x64"
653 653
             ],
@@ -659,9 +659,9 @@
659 659
             ]
660 660
         },
661 661
         "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
662
-            "version": "4.35.0",
663
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.35.0.tgz",
664
-            "integrity": "sha512-mrA0v3QMy6ZSvEuLs0dMxcO2LnaCONs1Z73GUDBHWbY8tFFocM6yl7YyMu7rz4zS81NDSqhrUuolyZXGi8TEqg==",
662
+            "version": "4.36.0",
663
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.36.0.tgz",
664
+            "integrity": "sha512-bvXVU42mOVcF4le6XSjscdXjqx8okv4n5vmwgzcmtvFdifQ5U4dXFYaCB87namDRKlUL9ybVtLQ9ztnawaSzvg==",
665 665
             "cpu": [
666 666
                 "arm"
667 667
             ],
@@ -673,9 +673,9 @@
673 673
             ]
674 674
         },
675 675
         "node_modules/@rollup/rollup-linux-arm-musleabihf": {
676
-            "version": "4.35.0",
677
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.35.0.tgz",
678
-            "integrity": "sha512-DnYhhzcvTAKNexIql8pFajr0PiDGrIsBYPRvCKlA5ixSS3uwo/CWNZxB09jhIapEIg945KOzcYEAGGSmTSpk7A==",
676
+            "version": "4.36.0",
677
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.36.0.tgz",
678
+            "integrity": "sha512-JFIQrDJYrxOnyDQGYkqnNBtjDwTgbasdbUiQvcU8JmGDfValfH1lNpng+4FWlhaVIR4KPkeddYjsVVbmJYvDcg==",
679 679
             "cpu": [
680 680
                 "arm"
681 681
             ],
@@ -687,9 +687,9 @@
687 687
             ]
688 688
         },
689 689
         "node_modules/@rollup/rollup-linux-arm64-gnu": {
690
-            "version": "4.35.0",
691
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.35.0.tgz",
692
-            "integrity": "sha512-uagpnH2M2g2b5iLsCTZ35CL1FgyuzzJQ8L9VtlJ+FckBXroTwNOaD0z0/UF+k5K3aNQjbm8LIVpxykUOQt1m/A==",
690
+            "version": "4.36.0",
691
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.36.0.tgz",
692
+            "integrity": "sha512-KqjYVh3oM1bj//5X7k79PSCZ6CvaVzb7Qs7VMWS+SlWB5M8p3FqufLP9VNp4CazJ0CsPDLwVD9r3vX7Ci4J56A==",
693 693
             "cpu": [
694 694
                 "arm64"
695 695
             ],
@@ -701,9 +701,9 @@
701 701
             ]
702 702
         },
703 703
         "node_modules/@rollup/rollup-linux-arm64-musl": {
704
-            "version": "4.35.0",
705
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.35.0.tgz",
706
-            "integrity": "sha512-XQxVOCd6VJeHQA/7YcqyV0/88N6ysSVzRjJ9I9UA/xXpEsjvAgDTgH3wQYz5bmr7SPtVK2TsP2fQ2N9L4ukoUg==",
704
+            "version": "4.36.0",
705
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.36.0.tgz",
706
+            "integrity": "sha512-QiGnhScND+mAAtfHqeT+cB1S9yFnNQ/EwCg5yE3MzoaZZnIV0RV9O5alJAoJKX/sBONVKeZdMfO8QSaWEygMhw==",
707 707
             "cpu": [
708 708
                 "arm64"
709 709
             ],
@@ -715,9 +715,9 @@
715 715
             ]
716 716
         },
717 717
         "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
718
-            "version": "4.35.0",
719
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.35.0.tgz",
720
-            "integrity": "sha512-5pMT5PzfgwcXEwOaSrqVsz/LvjDZt+vQ8RT/70yhPU06PTuq8WaHhfT1LW+cdD7mW6i/J5/XIkX/1tCAkh1W6g==",
718
+            "version": "4.36.0",
719
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.36.0.tgz",
720
+            "integrity": "sha512-1ZPyEDWF8phd4FQtTzMh8FQwqzvIjLsl6/84gzUxnMNFBtExBtpL51H67mV9xipuxl1AEAerRBgBwFNpkw8+Lg==",
721 721
             "cpu": [
722 722
                 "loong64"
723 723
             ],
@@ -729,9 +729,9 @@
729 729
             ]
730 730
         },
731 731
         "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
732
-            "version": "4.35.0",
733
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.35.0.tgz",
734
-            "integrity": "sha512-c+zkcvbhbXF98f4CtEIP1EBA/lCic5xB0lToneZYvMeKu5Kamq3O8gqrxiYYLzlZH6E3Aq+TSW86E4ay8iD8EA==",
732
+            "version": "4.36.0",
733
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.36.0.tgz",
734
+            "integrity": "sha512-VMPMEIUpPFKpPI9GZMhJrtu8rxnp6mJR3ZzQPykq4xc2GmdHj3Q4cA+7avMyegXy4n1v+Qynr9fR88BmyO74tg==",
735 735
             "cpu": [
736 736
                 "ppc64"
737 737
             ],
@@ -743,9 +743,9 @@
743 743
             ]
744 744
         },
745 745
         "node_modules/@rollup/rollup-linux-riscv64-gnu": {
746
-            "version": "4.35.0",
747
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.35.0.tgz",
748
-            "integrity": "sha512-s91fuAHdOwH/Tad2tzTtPX7UZyytHIRR6V4+2IGlV0Cej5rkG0R61SX4l4y9sh0JBibMiploZx3oHKPnQBKe4g==",
746
+            "version": "4.36.0",
747
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.36.0.tgz",
748
+            "integrity": "sha512-ttE6ayb/kHwNRJGYLpuAvB7SMtOeQnVXEIpMtAvx3kepFQeowVED0n1K9nAdraHUPJ5hydEMxBpIR7o4nrm8uA==",
749 749
             "cpu": [
750 750
                 "riscv64"
751 751
             ],
@@ -757,9 +757,9 @@
757 757
             ]
758 758
         },
759 759
         "node_modules/@rollup/rollup-linux-s390x-gnu": {
760
-            "version": "4.35.0",
761
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.35.0.tgz",
762
-            "integrity": "sha512-hQRkPQPLYJZYGP+Hj4fR9dDBMIM7zrzJDWFEMPdTnTy95Ljnv0/4w/ixFw3pTBMEuuEuoqtBINYND4M7ujcuQw==",
760
+            "version": "4.36.0",
761
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.36.0.tgz",
762
+            "integrity": "sha512-4a5gf2jpS0AIe7uBjxDeUMNcFmaRTbNv7NxI5xOCs4lhzsVyGR/0qBXduPnoWf6dGC365saTiwag8hP1imTgag==",
763 763
             "cpu": [
764 764
                 "s390x"
765 765
             ],
@@ -771,9 +771,9 @@
771 771
             ]
772 772
         },
773 773
         "node_modules/@rollup/rollup-linux-x64-gnu": {
774
-            "version": "4.35.0",
775
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.35.0.tgz",
776
-            "integrity": "sha512-Pim1T8rXOri+0HmV4CdKSGrqcBWX0d1HoPnQ0uw0bdp1aP5SdQVNBy8LjYncvnLgu3fnnCt17xjWGd4cqh8/hA==",
774
+            "version": "4.36.0",
775
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.36.0.tgz",
776
+            "integrity": "sha512-5KtoW8UWmwFKQ96aQL3LlRXX16IMwyzMq/jSSVIIyAANiE1doaQsx/KRyhAvpHlPjPiSU/AYX/8m+lQ9VToxFQ==",
777 777
             "cpu": [
778 778
                 "x64"
779 779
             ],
@@ -785,9 +785,9 @@
785 785
             ]
786 786
         },
787 787
         "node_modules/@rollup/rollup-linux-x64-musl": {
788
-            "version": "4.35.0",
789
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.35.0.tgz",
790
-            "integrity": "sha512-QysqXzYiDvQWfUiTm8XmJNO2zm9yC9P/2Gkrwg2dH9cxotQzunBHYr6jk4SujCTqnfGxduOmQcI7c2ryuW8XVg==",
788
+            "version": "4.36.0",
789
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.36.0.tgz",
790
+            "integrity": "sha512-sycrYZPrv2ag4OCvaN5js+f01eoZ2U+RmT5as8vhxiFz+kxwlHrsxOwKPSA8WyS+Wc6Epid9QeI/IkQ9NkgYyQ==",
791 791
             "cpu": [
792 792
                 "x64"
793 793
             ],
@@ -799,9 +799,9 @@
799 799
             ]
800 800
         },
801 801
         "node_modules/@rollup/rollup-win32-arm64-msvc": {
802
-            "version": "4.35.0",
803
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.35.0.tgz",
804
-            "integrity": "sha512-OUOlGqPkVJCdJETKOCEf1mw848ZyJ5w50/rZ/3IBQVdLfR5jk/6Sr5m3iO2tdPgwo0x7VcncYuOvMhBWZq8ayg==",
802
+            "version": "4.36.0",
803
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.36.0.tgz",
804
+            "integrity": "sha512-qbqt4N7tokFwwSVlWDsjfoHgviS3n/vZ8LK0h1uLG9TYIRuUTJC88E1xb3LM2iqZ/WTqNQjYrtmtGmrmmawB6A==",
805 805
             "cpu": [
806 806
                 "arm64"
807 807
             ],
@@ -813,9 +813,9 @@
813 813
             ]
814 814
         },
815 815
         "node_modules/@rollup/rollup-win32-ia32-msvc": {
816
-            "version": "4.35.0",
817
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.35.0.tgz",
818
-            "integrity": "sha512-2/lsgejMrtwQe44glq7AFFHLfJBPafpsTa6JvP2NGef/ifOa4KBoglVf7AKN7EV9o32evBPRqfg96fEHzWo5kw==",
816
+            "version": "4.36.0",
817
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.36.0.tgz",
818
+            "integrity": "sha512-t+RY0JuRamIocMuQcfwYSOkmdX9dtkr1PbhKW42AMvaDQa+jOdpUYysroTF/nuPpAaQMWp7ye+ndlmmthieJrQ==",
819 819
             "cpu": [
820 820
                 "ia32"
821 821
             ],
@@ -827,9 +827,9 @@
827 827
             ]
828 828
         },
829 829
         "node_modules/@rollup/rollup-win32-x64-msvc": {
830
-            "version": "4.35.0",
831
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.35.0.tgz",
832
-            "integrity": "sha512-PIQeY5XDkrOysbQblSW7v3l1MDZzkTEzAfTPkj5VAu3FW8fS4ynyLg2sINp0fp3SjZ8xkRYpLqoKcYqAkhU1dw==",
830
+            "version": "4.36.0",
831
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.36.0.tgz",
832
+            "integrity": "sha512-aRXd7tRZkWLqGbChgcMMDEHjOKudo1kChb1Jt1IfR8cY/KIpgNviLeJy5FUb9IpSuQj8dU2fAYNMPW/hLKOSTw==",
833 833
             "cpu": [
834 834
                 "x64"
835 835
             ],
@@ -1088,9 +1088,9 @@
1088 1088
             }
1089 1089
         },
1090 1090
         "node_modules/caniuse-lite": {
1091
-            "version": "1.0.30001704",
1092
-            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001704.tgz",
1093
-            "integrity": "sha512-+L2IgBbV6gXB4ETf0keSvLr7JUrRVbIaB/lrQ1+z8mRcQiisG5k+lG6O4n6Y5q6f5EuNfaYXKgymucphlEXQew==",
1091
+            "version": "1.0.30001706",
1092
+            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001706.tgz",
1093
+            "integrity": "sha512-3ZczoTApMAZwPKYWmwVbQMFpXBDds3/0VciVoUwPUbldlYyVLmRVuRs/PcUZtHpbLRpzzDvrvnFuREsGt6lUug==",
1094 1094
             "dev": true,
1095 1095
             "funding": [
1096 1096
                 {
@@ -1264,9 +1264,9 @@
1264 1264
             "license": "MIT"
1265 1265
         },
1266 1266
         "node_modules/electron-to-chromium": {
1267
-            "version": "1.5.119",
1268
-            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.119.tgz",
1269
-            "integrity": "sha512-Ku4NMzUjz3e3Vweh7PhApPrZSS4fyiCIbcIrG9eKrriYVLmbMepETR/v6SU7xPm98QTqMSYiCwfO89QNjXLkbQ==",
1267
+            "version": "1.5.120",
1268
+            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.120.tgz",
1269
+            "integrity": "sha512-oTUp3gfX1gZI+xfD2djr2rzQdHCwHzPQrrK0CD7WpTdF0nPdQ/INcRVjWgLdCT4a9W3jFObR9DAfsuyFQnI8CQ==",
1270 1270
             "dev": true,
1271 1271
             "license": "ISC"
1272 1272
         },
@@ -1930,9 +1930,9 @@
1930 1930
             }
1931 1931
         },
1932 1932
         "node_modules/nanoid": {
1933
-            "version": "3.3.9",
1934
-            "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.9.tgz",
1935
-            "integrity": "sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg==",
1933
+            "version": "3.3.11",
1934
+            "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
1935
+            "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
1936 1936
             "dev": true,
1937 1937
             "funding": [
1938 1938
                 {
@@ -2412,9 +2412,9 @@
2412 2412
             }
2413 2413
         },
2414 2414
         "node_modules/rollup": {
2415
-            "version": "4.35.0",
2416
-            "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.35.0.tgz",
2417
-            "integrity": "sha512-kg6oI4g+vc41vePJyO6dHt/yl0Rz3Thv0kJeVQ3D1kS3E5XSuKbPc29G4IpT/Kv1KQwgHVcN+HtyS+HYLNSvQg==",
2415
+            "version": "4.36.0",
2416
+            "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.36.0.tgz",
2417
+            "integrity": "sha512-zwATAXNQxUcd40zgtQG0ZafcRK4g004WtEl7kbuhTWPvf07PsfohXl39jVUvPF7jvNAIkKPQ2XrsDlWuxBd++Q==",
2418 2418
             "dev": true,
2419 2419
             "license": "MIT",
2420 2420
             "dependencies": {
@@ -2428,25 +2428,25 @@
2428 2428
                 "npm": ">=8.0.0"
2429 2429
             },
2430 2430
             "optionalDependencies": {
2431
-                "@rollup/rollup-android-arm-eabi": "4.35.0",
2432
-                "@rollup/rollup-android-arm64": "4.35.0",
2433
-                "@rollup/rollup-darwin-arm64": "4.35.0",
2434
-                "@rollup/rollup-darwin-x64": "4.35.0",
2435
-                "@rollup/rollup-freebsd-arm64": "4.35.0",
2436
-                "@rollup/rollup-freebsd-x64": "4.35.0",
2437
-                "@rollup/rollup-linux-arm-gnueabihf": "4.35.0",
2438
-                "@rollup/rollup-linux-arm-musleabihf": "4.35.0",
2439
-                "@rollup/rollup-linux-arm64-gnu": "4.35.0",
2440
-                "@rollup/rollup-linux-arm64-musl": "4.35.0",
2441
-                "@rollup/rollup-linux-loongarch64-gnu": "4.35.0",
2442
-                "@rollup/rollup-linux-powerpc64le-gnu": "4.35.0",
2443
-                "@rollup/rollup-linux-riscv64-gnu": "4.35.0",
2444
-                "@rollup/rollup-linux-s390x-gnu": "4.35.0",
2445
-                "@rollup/rollup-linux-x64-gnu": "4.35.0",
2446
-                "@rollup/rollup-linux-x64-musl": "4.35.0",
2447
-                "@rollup/rollup-win32-arm64-msvc": "4.35.0",
2448
-                "@rollup/rollup-win32-ia32-msvc": "4.35.0",
2449
-                "@rollup/rollup-win32-x64-msvc": "4.35.0",
2431
+                "@rollup/rollup-android-arm-eabi": "4.36.0",
2432
+                "@rollup/rollup-android-arm64": "4.36.0",
2433
+                "@rollup/rollup-darwin-arm64": "4.36.0",
2434
+                "@rollup/rollup-darwin-x64": "4.36.0",
2435
+                "@rollup/rollup-freebsd-arm64": "4.36.0",
2436
+                "@rollup/rollup-freebsd-x64": "4.36.0",
2437
+                "@rollup/rollup-linux-arm-gnueabihf": "4.36.0",
2438
+                "@rollup/rollup-linux-arm-musleabihf": "4.36.0",
2439
+                "@rollup/rollup-linux-arm64-gnu": "4.36.0",
2440
+                "@rollup/rollup-linux-arm64-musl": "4.36.0",
2441
+                "@rollup/rollup-linux-loongarch64-gnu": "4.36.0",
2442
+                "@rollup/rollup-linux-powerpc64le-gnu": "4.36.0",
2443
+                "@rollup/rollup-linux-riscv64-gnu": "4.36.0",
2444
+                "@rollup/rollup-linux-s390x-gnu": "4.36.0",
2445
+                "@rollup/rollup-linux-x64-gnu": "4.36.0",
2446
+                "@rollup/rollup-linux-x64-musl": "4.36.0",
2447
+                "@rollup/rollup-win32-arm64-msvc": "4.36.0",
2448
+                "@rollup/rollup-win32-ia32-msvc": "4.36.0",
2449
+                "@rollup/rollup-win32-x64-msvc": "4.36.0",
2450 2450
                 "fsevents": "~2.3.2"
2451 2451
             }
2452 2452
         },

+ 0
- 4
resources/css/filament/company/custom-section.css Bestand weergeven

@@ -48,10 +48,6 @@
48 48
     &.fi-section-not-contained:not(.fi-aside) {
49 49
         @apply grid gap-y-4;
50 50
 
51
-        & .fi-section-header {
52
-            @apply py-2;
53
-        }
54
-
55 51
         & .fi-section-content-ctn {
56 52
             @apply grid gap-y-4;
57 53
         }

+ 141
- 31
resources/css/filament/company/form-fields.css Bestand weergeven

@@ -29,10 +29,6 @@
29 29
 }
30 30
 
31 31
 /* Choices.js select field overrides */
32
-.choices__list.choices__list--single {
33
-    @apply w-full;
34
-}
35
-
36 32
 .choices:focus-visible {
37 33
     outline: none;
38 34
 }
@@ -41,52 +37,166 @@
41 37
     @apply text-gray-900 dark:text-white font-semibold;
42 38
 }
43 39
 
44
-.choices[data-type="select-one"] .choices__inner {
45
-    line-height: 1.5;
46
-    display: flex;
47
-    align-items: center;
48
-    min-height: 2.25rem;
49
-    box-sizing: border-box;
50
-}
51
-
52 40
 .choices:not(.is-disabled) .choices__item {
53 41
     cursor: pointer;
54 42
 }
55 43
 
56 44
 /* Table Repeater Styles */
57
-.table-repeater-container {
58
-    @apply rounded-none ring-0;
59
-}
45
+:not(.is-spreadsheet) {
46
+    .table-repeater-container {
47
+        @apply rounded-none ring-0;
48
+    }
49
+
50
+    .table-repeater-component {
51
+        @apply space-y-10;
52
+    }
60 53
 
61
-.table-repeater-component {
62
-    @apply space-y-10;
54
+    .table-repeater-component ul {
55
+        @apply justify-start;
56
+    }
57
+
58
+    .table-repeater-row {
59
+        @apply divide-x-0 !important;
60
+    }
61
+
62
+    .table-repeater-column {
63
+        @apply py-2 !important;
64
+    }
65
+
66
+    .table-repeater-header {
67
+        @apply rounded-t-none !important;
68
+    }
69
+
70
+    .table-repeater-rows-wrapper {
71
+        @apply divide-gray-300 last:border-b last:border-gray-300 dark:divide-white/20 dark:last:border-white/20;
72
+    }
73
+
74
+    .table-repeater-header tr {
75
+        @apply divide-x-0 text-base sm:text-sm sm:leading-6 !important;
76
+    }
77
+
78
+    .table-repeater-header-column {
79
+        @apply ps-3 pe-3 font-semibold bg-gray-200 dark:bg-gray-800 rounded-none !important;
80
+    }
63 81
 }
64 82
 
65
-.table-repeater-component ul {
66
-    @apply justify-start;
83
+/* Excel/Spreadsheet styling */
84
+.is-spreadsheet {
85
+    .table-repeater-container {
86
+        overflow-x: auto;
87
+        max-width: 100%;
88
+        -webkit-overflow-scrolling: touch;
89
+    }
90
+
91
+    .table-repeater-container:has(.choices.is-open) {
92
+        overflow: visible;
93
+    }
94
+
95
+    .table-repeater-container table {
96
+        min-width: 100%;
97
+        width: max-content;
98
+    }
99
+
100
+    .table-repeater-container {
101
+        border: 1px solid #e5e7eb !important;
102
+        border-radius: 0 !important;
103
+        @apply ring-0 !important;
104
+    }
105
+
106
+    .table-repeater-header {
107
+        background-color: #f8f9fa !important;
108
+    }
109
+
110
+    .table-repeater-header-column {
111
+        border: 1px solid #e5e7eb !important;
112
+        background-color: #f8f9fa !important;
113
+        font-weight: 600 !important;
114
+        padding: 8px 12px !important;
115
+    }
116
+
117
+    .table-repeater-column {
118
+        border: 1px solid #e5e7eb !important;
119
+        padding: 8px 12px !important;
120
+    }
121
+
122
+    .table-repeater-column input {
123
+        text-align: right !important;
124
+    }
125
+
126
+    .fi-input-wrapper,
127
+    .fi-input {
128
+        padding: 0 !important;
129
+    }
130
+
131
+    .fi-input-wrp,
132
+    .fi-fo-file-upload .filepond--root {
133
+        @apply ring-0 bg-transparent shadow-none rounded-none !important;
134
+    }
135
+
136
+    .fi-input-wrp input {
137
+        @apply bg-transparent !important;
138
+    }
139
+
140
+    .table-repeater-column:focus-within {
141
+        outline: 2px solid #2563eb !important;
142
+        outline-offset: -2px !important;
143
+        z-index: 1 !important;
144
+    }
145
+
146
+    input:focus,
147
+    select:focus,
148
+    textarea:focus {
149
+        @apply ring-0 shadow-none !important;
150
+        outline: none !important;
151
+    }
152
+
153
+    .table-repeater-row:nth-child(even) {
154
+        background-color: #f9fafb;
155
+    }
156
+
157
+    .fi-fo-field-wrp:has(.fi-fo-checkbox-list),
158
+    .fi-fo-field-wrp:has(.fi-checkbox-input),
159
+    .fi-fo-field-wrp:has(.fi-fo-radio) {
160
+        @apply py-2 px-3 !important;
161
+    }
162
+
163
+    .fi-fo-field-wrp:has(.fi-fo-toggle) {
164
+        @apply inline-block mt-1 !important;
165
+    }
67 166
 }
68 167
 
69
-.table-repeater-row {
70
-    @apply divide-x-0 !important;
168
+/* Responsive behavior */
169
+@media (max-width: theme('screens.sm')) {
170
+    .table-repeater-component.break-point-sm .table-repeater-container {
171
+        overflow-x: visible;
172
+    }
71 173
 }
72 174
 
73
-.table-repeater-column {
74
-    @apply py-2 !important;
175
+@media (max-width: theme('screens.md')) {
176
+    .table-repeater-component.break-point-md .table-repeater-container {
177
+        overflow-x: visible;
178
+    }
75 179
 }
76 180
 
77
-.table-repeater-header {
78
-    @apply rounded-t-none !important;
181
+@media (max-width: theme('screens.lg')) {
182
+    .table-repeater-component.break-point-lg .table-repeater-container {
183
+        overflow-x: visible;
184
+    }
79 185
 }
80 186
 
81
-.table-repeater-rows-wrapper {
82
-    @apply divide-gray-300 last:border-b last:border-gray-300 dark:divide-white/20 dark:last:border-white/20;
187
+@media (max-width: theme('screens.xl')) {
188
+    .table-repeater-component.break-point-xl .table-repeater-container {
189
+        overflow-x: visible;
190
+    }
83 191
 }
84 192
 
85
-.table-repeater-header tr {
86
-    @apply divide-x-0 text-base sm:text-sm sm:leading-6 !important;
193
+@media (max-width: theme('screens.2xl')) {
194
+    .table-repeater-component.break-point-2xl .table-repeater-container {
195
+        overflow-x: visible;
196
+    }
87 197
 }
88 198
 
89
-.table-repeater-header-column {
90
-    @apply ps-3 pe-3 font-semibold bg-gray-200 dark:bg-gray-800 rounded-none !important;
199
+.is-spreadsheet .table-repeater-column .fi-input-wrp-suffix {
200
+    padding-right: 0 !important;
91 201
 }
92 202
 

+ 12
- 10
resources/css/filament/company/modal.css Bestand weergeven

@@ -1,18 +1,20 @@
1 1
 /* Journal Entry Modal Styles */
2 2
 .fi-modal.fi-width-screen {
3
-    .fi-modal-header {
4
-        @apply xl:px-80;
3
+    .fi-modal-window.journal-transaction-modal {
4
+        .fi-modal-header {
5
+            @apply xl:px-80;
5 6
 
6
-        .absolute.end-4.top-4 {
7
-            @apply xl:end-80;
7
+            .absolute.end-4.top-4 {
8
+                @apply xl:end-80;
9
+            }
8 10
         }
9
-    }
10 11
 
11
-    .fi-modal-content {
12
-        @apply xl:px-80;
13
-    }
12
+        .fi-modal-content {
13
+            @apply xl:px-80;
14
+        }
14 15
 
15
-    .fi-modal-footer {
16
-        @apply xl:px-80;
16
+        .fi-modal-footer {
17
+            @apply xl:px-80;
18
+        }
17 19
     }
18 20
 }

+ 49
- 5
resources/css/filament/company/theme.css Bestand weergeven

@@ -28,11 +28,7 @@
28 28
 
29 29
 /* End Alignment sortable column enhancement */
30 30
 .fi-ta-header-cell:has(button.justify-end > svg.fi-ta-header-cell-sort-icon) {
31
-    padding-right: 0.25rem;
32
-}
33
-
34
-.fi-ta-cell:has(.fi-ta-col-wrp > .justify-end) {
35
-    padding-right: 1rem;
31
+    padding-right: 0;
36 32
 }
37 33
 
38 34
 :not(.dark) .fi-body {
@@ -57,3 +53,51 @@
57 53
     pointer-events: none;
58 54
     z-index: -1;
59 55
 }
56
+
57
+.fi-ta-table:has(.budget-items-relation-manager) {
58
+    .fi-ta-row {
59
+        @apply divide-x divide-gray-200 dark:divide-gray-700;
60
+    }
61
+
62
+    .fi-ta-summary-row-heading {
63
+        @apply px-3 py-3 !important;
64
+    }
65
+
66
+    .fi-ta-text-summary {
67
+        @apply px-3 py-3 !important;
68
+
69
+        > span {
70
+            @apply font-medium text-gray-950 dark:text-white;
71
+        }
72
+    }
73
+
74
+    .fi-ta-text {
75
+        @apply px-3 py-0 !important;
76
+    }
77
+
78
+    .fi-ta-text-input {
79
+        @apply px-0 py-0 !important;
80
+    }
81
+
82
+    .fi-ta-icon {
83
+        @apply px-3 py-0 !important;
84
+    }
85
+
86
+    .fi-ta-text-input {
87
+        .fi-input-wrp,
88
+        .fi-input {
89
+            @apply ring-0 bg-transparent shadow-none rounded-none !important;
90
+        }
91
+    }
92
+
93
+    .fi-ta-cell:focus-within {
94
+        outline: 2px solid #2563eb;
95
+        outline-offset: -2px;
96
+        z-index: 1;
97
+    }
98
+
99
+    .fi-ta-col-wrp button:focus {
100
+        outline: none !important;
101
+        box-shadow: none !important;
102
+    }
103
+}

+ 0
- 0
resources/data/lang/en.json Bestand weergeven


Some files were not shown because too many files changed in this diff

Laden…
Annuleren
Opslaan