Przeglądaj źródła

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

Development 3.x
3.x
Andrew Wallo 1 rok temu
rodzic
commit
daf5315ee4
No account linked to committer's email address
54 zmienionych plików z 2473 dodań i 1469 usunięć
  1. 57
    29
      README.md
  2. 14
    5
      app/Casts/TransactionAmountCast.php
  3. 7
    5
      app/Concerns/Blamable.php
  4. 15
    4
      app/Concerns/CompanyOwned.php
  5. 2
    13
      app/Concerns/HandlesResourceRecordCreation.php
  6. 0
    2
      app/Contracts/AccountHandler.php
  7. 2
    0
      app/DTO/AccountDTO.php
  8. 65
    0
      app/Enums/Accounting/AccountCategory.php
  9. 5
    9
      app/Filament/Company/Clusters/Settings/Pages/CompanyDefault.php
  10. 1
    44
      app/Filament/Company/Clusters/Settings/Resources/CurrencyResource.php
  11. 0
    28
      app/Filament/Company/Clusters/Settings/Resources/CurrencyResource/Pages/CreateCurrency.php
  12. 0
    28
      app/Filament/Company/Clusters/Settings/Resources/CurrencyResource/Pages/EditCurrency.php
  13. 31
    5
      app/Filament/Company/Pages/Accounting/Transactions.php
  14. 179
    0
      app/Filament/Company/Pages/Concerns/HasDeferredFiltersForm.php
  15. 133
    0
      app/Filament/Company/Pages/Concerns/HasFiltersForm.php
  16. 106
    0
      app/Filament/Company/Pages/Concerns/HasToggleTableColumnForm.php
  17. 29
    1
      app/Filament/Company/Pages/Reports.php
  18. 4
    5
      app/Filament/Company/Pages/Reports/AccountBalances.php
  19. 24
    14
      app/Filament/Company/Pages/Reports/AccountTransactions.php
  20. 28
    107
      app/Filament/Company/Pages/Reports/BaseReportPage.php
  21. 83
    0
      app/Filament/Company/Pages/Reports/IncomeStatement.php
  22. 5
    9
      app/Filament/Company/Pages/Reports/TrialBalance.php
  23. 11
    4
      app/Filament/Company/Resources/Banking/AccountResource.php
  24. 21
    0
      app/Filament/Company/Resources/Banking/AccountResource/Pages/CreateAccount.php
  25. 17
    0
      app/Filament/Company/Resources/Banking/AccountResource/Pages/EditAccount.php
  26. 15
    64
      app/Filament/Forms/Components/DateRangeSelect.php
  27. 114
    0
      app/Filament/Tables/Actions/ReplicateBulkAction.php
  28. 35
    26
      app/Listeners/ConfigureCompanyDefault.php
  29. 0
    1
      app/Listeners/SyncAssociatedModels.php
  30. 11
    7
      app/Models/Setting/Localization.php
  31. 2
    1
      app/Observers/TransactionObserver.php
  32. 2
    1
      app/Providers/AppServiceProvider.php
  33. 23
    1
      app/Providers/MacroServiceProvider.php
  34. 2
    4
      app/Repositories/Accounting/JournalEntryRepository.php
  35. 23
    2
      app/Scopes/CurrentCompanyScope.php
  36. 117
    53
      app/Services/AccountService.php
  37. 145
    0
      app/Services/DateRangeService.php
  38. 40
    12
      app/Services/ExportService.php
  39. 238
    142
      app/Services/ReportService.php
  40. 14
    0
      app/Support/Column.php
  41. 2
    0
      app/Transformers/AccountBalanceReportTransformer.php
  42. 132
    0
      app/Transformers/IncomeStatementReportTransformer.php
  43. 2
    0
      app/Transformers/TrialBalanceReportTransformer.php
  44. 383
    642
      composer.lock
  45. 9
    1
      database/factories/UserFactory.php
  46. 147
    143
      package-lock.json
  47. 9
    11
      resources/css/filament/company/theme.css
  48. 5
    1
      resources/views/components/company/reports/account-transactions-report-pdf.blade.php
  49. 4
    4
      resources/views/components/company/reports/report-pdf.blade.php
  50. 21
    3
      resources/views/components/company/tables/reports/detailed-report.blade.php
  51. 14
    7
      resources/views/components/panel-shift-dropdown.blade.php
  52. 14
    9
      resources/views/filament/company/pages/reports/account-transactions.blade.php
  53. 30
    22
      resources/views/filament/company/pages/reports/detailed-report.blade.php
  54. 81
    0
      resources/views/filament/company/pages/reports/income-statement.blade.php

+ 57
- 29
README.md Wyświetl plik

@@ -8,14 +8,14 @@
8 8
 <img width="1920" alt="Screenshot 2024-05-07 at 10 55 56 PM" src="https://github.com/andrewdwallo/erpsaas/assets/104294090/6395030a-6688-4b08-bf6c-b12b5e591b31">
9 9
 
10 10
 
11
-
12 11
 This repo is currently a work in progress — PRs and issues welcome!
13 12
 
14 13
 # Getting started
15 14
 
16 15
 ## Installation
17 16
 
18
-Please check the official laravel installation guide for server requirements before you start. [Official Documentation](https://laravel.com/docs/10.x)
17
+Please check the official laravel installation guide for server requirements before you
18
+start. [Official Documentation](https://laravel.com/docs/10.x)
19 19
 
20 20
 Clone the repository
21 21
 
@@ -77,48 +77,58 @@ Run the database seeder
77 77
 
78 78
     php artisan db:seed
79 79
 
80
-***Note*** : It's recommended to have a clean database before seeding. You can refresh your migrations at any point to clean the database by running the following command
80
+***Note***: It's recommended to have a clean database before seeding. You can reset your database to a clean state at
81
+any point by running the following command:
81 82
 
82
-    php artisan migrate:refresh
83
+    php artisan migrate:fresh
83 84
 
84 85
 ## Generating PDFs for Reports
85 86
 
86
-To generate PDFs for reports, the application uses Laravel Snappy. The Laravel Snappy package is already included in the application, but you need to install Wkhtmltopdf separately.
87
+To generate PDFs for reports, the application uses Laravel Snappy. The Laravel Snappy package is already included in the
88
+application, but you need to install Wkhtmltopdf separately.
87 89
 
88 90
 ### Wkhtmltopdf Installation
89 91
 
90 92
 1. **Download and install Wkhtmltopdf**
91
-   - [Wkhtmltopdf Downloads](https://wkhtmltopdf.org/downloads.html)
92
-   
93
-   - Alternatively, if you are using Homebrew on macOS, you can install it using the following command:
94
-     ```bash
95
-     brew install wkhtmltopdf
96
-     ```
93
+    - [Wkhtmltopdf Downloads](https://wkhtmltopdf.org/downloads.html)
94
+
95
+    - Alternatively, if you are using Homebrew on macOS, you can install it using the following command:
96
+      ```bash
97
+      brew install wkhtmltopdf
98
+      ```
97 99
 
98 100
 2. **Configure the binary paths**
99
-   - If needed, you can change the paths to the Wkhtmltopdf binaries in the Snappy configuration file (`config/snappy.php`).
101
+    - If needed, you can change the paths to the Wkhtmltopdf binaries in the Snappy configuration file (
102
+      `config/snappy.php`).
100 103
 
101
-For detailed installation instructions, refer to the [Laravel Snappy documentation](https://github.com/barryvdh/laravel-snappy).
104
+For detailed installation instructions, refer to
105
+the [Laravel Snappy documentation](https://github.com/barryvdh/laravel-snappy).
102 106
 
103 107
 ## Live Currency
104 108
 
105 109
 ### Overview
106 110
 
107
-This application offers support for real-time currency exchange rates. This feature is disabled by default. To enable it, you must first register for an API key at [ExchangeRate-API](https://www.exchangerate-api.com/). The application uses this service due to its generous provision of up to 1,500 free API calls per month, which should be enough for development and testing purposes.
111
+This application offers support for real-time currency exchange rates. This feature is disabled by default. To enable
112
+it, you must first register for an API key at [ExchangeRate-API](https://www.exchangerate-api.com/). The application
113
+uses this service due to its generous provision of up to 1,500 free API calls per month, which should be enough for
114
+development and testing purposes.
108 115
 
109 116
 **Disclaimer**: There is no affiliation between this application and ExchangeRate-API.
110 117
 
111
-Once you have your API key, you can enable the feature by setting the `CURRENCY_API_KEY` environment variable in your `.env` file.
118
+Once you have your API key, you can enable the feature by setting the `CURRENCY_API_KEY` environment variable in your
119
+`.env` file.
112 120
 
113 121
 ### Initial Setup
114 122
 
115
-After setting your API key in the `.env` file, it is essential to prepare your database to store the currency data. Start by running a fresh database migration:
123
+After setting your API key in the `.env` file, it is essential to prepare your database to store the currency data.
124
+Start by running a fresh database migration:
116 125
 
117 126
 ```bash
118 127
 php artisan migrate:fresh
119 128
 ```
120 129
 
121
-This ensures that your database is in the correct state to store the currency information. Afterward, use the following command to generate and populate the Currency List with supported currencies for the Live Currency page:
130
+This ensures that your database is in the correct state to store the currency information. Afterward, use the following
131
+command to generate and populate the Currency List with supported currencies for the Live Currency page:
122 132
 
123 133
 ```bash
124 134
 php artisan currency:init
@@ -128,7 +138,8 @@ This command fetches and stores the list of currencies supported by your configu
128 138
 
129 139
 ### Configuration
130 140
 
131
-Of course, you may use any service you wish to retrieve currency exchange rates. If you decide to use a different service, you can update the `config/services.php` file with your choice:
141
+Of course, you may use any service you wish to retrieve currency exchange rates. If you decide to use a different
142
+service, you can update the `config/services.php` file with your choice:
132 143
 
133 144
 ```php
134 145
 'currency_api' => [
@@ -141,28 +152,41 @@ Then, adjust the implementation of the `App\Services\CurrencyService` class to u
141 152
 
142 153
 ### Live Currency Page
143 154
 
144
-Once enabled, the "Live Currency" feature provides access to a dedicated page in the application, listing all supported currencies from the configured exchange rate service. Users can view available currencies and update exchange rates for their company's currencies as needed.
155
+Once enabled, the "Live Currency" feature provides access to a dedicated page in the application, listing all supported
156
+currencies from the configured exchange rate service. Users can view available currencies and update exchange rates for
157
+their company's currencies as needed.
145 158
 
146 159
 ### Important Information
147 160
 
148
-- To use the currency exchange rate feature, you must first obtain an API key from a service provider. This application is configured to use a service that offers a free tier suitable for development and testing purposes.
149
-- Your API key is sensitive information and should be kept secret. Do not commit it to your repository or share it with anyone.
150
-- Note that API rate limits may apply depending on the service you choose. Make sure to review the terms for your chosen service.
161
+- To use the currency exchange rate feature, you must first obtain an API key from a service provider. This application
162
+  is configured to use a service that offers a free tier suitable for development and testing purposes.
163
+- Your API key is sensitive information and should be kept secret. Do not commit it to your repository or share it with
164
+  anyone.
165
+- Note that API rate limits may apply depending on the service you choose. Make sure to review the terms for your chosen
166
+  service.
151 167
 
152 168
 ## Automatic Translation
153 169
 
154
-The application now supports automatic translation, leveraging machine translation services provided by AWS, as facilitated by the [andrewdwallo/transmatic](https://github.com/andrewdwallo/transmatic) package. This integration significantly enhances the application's accessibility for a global audience. The application currently offers support for several languages, including English, Arabic, German, Spanish, French, Indonesian, Italian, Dutch, Portuguese, Turkish, and Chinese, with English as the default language.
170
+The application now supports automatic translation, leveraging machine translation services provided by AWS, as
171
+facilitated by the [andrewdwallo/transmatic](https://github.com/andrewdwallo/transmatic) package. This integration
172
+significantly enhances the application's accessibility for a global audience. The application currently offers support
173
+for several languages, including English, Arabic, German, Spanish, French, Indonesian, Italian, Dutch, Portuguese,
174
+Turkish, and Chinese, with English as the default language.
155 175
 
156 176
 ### Configuration & Usage
157 177
 
158 178
 To utilize this feature for additional languages or custom translations:
159
-1. Follow the documentation provided in the [andrewdwallo/transmatic](https://github.com/andrewdwallo/transmatic) package.
179
+
180
+1. Follow the documentation provided in the [andrewdwallo/transmatic](https://github.com/andrewdwallo/transmatic)
181
+   package.
160 182
 2. Configure the package with your preferred translation service credentials.
161 183
 3. Run the translation commands as per the package instructions to generate new translations.
162 184
 
163
-Once you have configured the package, you may update the following method in the `app/Models/Setting/Localization.php` file to generate translations based on the selected language in the application UI:
185
+Once you have configured the package, you may update the following method in the `app/Models/Setting/Localization.php`
186
+file to generate translations based on the selected language in the application UI:
164 187
 
165 188
 Change to the following:
189
+
166 190
 ```php
167 191
 public static function getAllLanguages(): array
168 192
 {
@@ -192,13 +216,17 @@ php artisan queue:work --queue=transactions
192 216
 ## Dependencies
193 217
 
194 218
 - [filamentphp/filament](https://github.com/filamentphp/filament) - A collection of beautiful full-stack components
195
-- [andrewdwallo/filament-companies](https://github.com/andrewdwallo/filament-companies) - A complete authentication system kit based on companies built for Filament
196
-- [andrewdwallo/transmatic](https://github.com/andrewdwallo/transmatic) - A package for automatic translation using machine translation services
197
-- [akaunting/laravel-money](https://github.com/akaunting/laravel-money) - Currency formatting and conversion package for Laravel
219
+- [andrewdwallo/filament-companies](https://github.com/andrewdwallo/filament-companies) - A complete authentication
220
+  system kit based on companies built for Filament
221
+- [andrewdwallo/transmatic](https://github.com/andrewdwallo/transmatic) - A package for automatic translation using
222
+  machine translation services
223
+- [akaunting/laravel-money](https://github.com/akaunting/laravel-money) - Currency formatting and conversion package for
224
+  Laravel
198 225
 - [squirephp/squire](https://github.com/squirephp/squire) - A library of static Eloquent models for common fixture data
199 226
 - [awcodes/filament-table-repeater](https://github.com/awcodes/filament-table-repeater) - A modified version of the Filament Forms Repeater to display it as a table. 
200 227
 
201
-***Note*** : It is recommended to read the documentation for all dependencies to get yourself familiar with how the application works.
228
+***Note*** : It is recommended to read the documentation for all dependencies to get yourself familiar with how the
229
+application works.
202 230
 
203 231
 ## License
204 232
 

+ 14
- 5
app/Casts/TransactionAmountCast.php Wyświetl plik

@@ -11,13 +11,15 @@ use UnexpectedValueException;
11 11
 
12 12
 class TransactionAmountCast implements CastsAttributes
13 13
 {
14
+    private array $currencyCache = [];
15
+
14 16
     public function get(Model $model, string $key, mixed $value, array $attributes): string
15 17
     {
16 18
         // Attempt to retrieve the currency code from the related bankAccount->account model
17
-        $currency_code = $this->getCurrencyCodeFromBankAccountId($attributes['bank_account_id'] ?? null);
19
+        $currencyCode = $this->getCurrencyCodeFromBankAccountId($attributes['bank_account_id'] ?? null);
18 20
 
19 21
         if ($value !== null) {
20
-            return CurrencyConverter::prepareForMutator($value, $currency_code);
22
+            return CurrencyConverter::prepareForMutator($value, $currencyCode);
21 23
         }
22 24
 
23 25
         return '';
@@ -28,7 +30,7 @@ class TransactionAmountCast implements CastsAttributes
28 30
      */
29 31
     public function set(Model $model, string $key, mixed $value, array $attributes): int
30 32
     {
31
-        $currency_code = $this->getCurrencyCodeFromBankAccountId($attributes['bank_account_id'] ?? null);
33
+        $currencyCode = $this->getCurrencyCodeFromBankAccountId($attributes['bank_account_id'] ?? null);
32 34
 
33 35
         if (is_numeric($value)) {
34 36
             $value = (string) $value;
@@ -36,7 +38,7 @@ class TransactionAmountCast implements CastsAttributes
36 38
             throw new UnexpectedValueException('Expected string or numeric value for money cast');
37 39
         }
38 40
 
39
-        return CurrencyConverter::prepareForAccessor($value, $currency_code);
41
+        return CurrencyConverter::prepareForAccessor($value, $currencyCode);
40 42
     }
41 43
 
42 44
     /**
@@ -49,8 +51,15 @@ class TransactionAmountCast implements CastsAttributes
49 51
             return CurrencyAccessor::getDefaultCurrency();
50 52
         }
51 53
 
54
+        if (isset($this->currencyCache[$bankAccountId])) {
55
+            return $this->currencyCache[$bankAccountId];
56
+        }
57
+
52 58
         $bankAccount = BankAccount::find($bankAccountId);
53 59
 
54
-        return $bankAccount?->account?->currency_code ?? CurrencyAccessor::getDefaultCurrency();
60
+        $currencyCode = $bankAccount?->account?->currency_code ?? CurrencyAccessor::getDefaultCurrency();
61
+        $this->currencyCache[$bankAccountId] = $currencyCode;
62
+
63
+        return $currencyCode;
55 64
     }
56 65
 }

+ 7
- 5
app/Concerns/Blamable.php Wyświetl plik

@@ -11,14 +11,16 @@ trait Blamable
11 11
     public static function bootBlamable(): void
12 12
     {
13 13
         static::creating(static function ($model) {
14
-            $auth = Auth::id();
15
-            $model->created_by = $auth;
16
-            $model->updated_by = $auth;
14
+            if (Auth::check() && $authId = Auth::id()) {
15
+                $model->created_by = $model->created_by ?? $authId;
16
+                $model->updated_by = $model->updated_by ?? $authId;
17
+            }
17 18
         });
18 19
 
19 20
         static::updating(static function ($model) {
20
-            $auth = Auth::id();
21
-            $model->updated_by = $auth;
21
+            if (Auth::check() && $authId = Auth::id()) {
22
+                $model->updated_by = $authId;
23
+            }
22 24
         });
23 25
     }
24 26
 

+ 15
- 4
app/Concerns/CompanyOwned.php Wyświetl plik

@@ -15,12 +15,23 @@ trait CompanyOwned
15 15
     {
16 16
         static::creating(static function ($model) {
17 17
             if (empty($model->company_id)) {
18
-                if (Auth::check() && Auth::user()->currentCompany) {
19
-                    $model->company_id = Auth::user()->currentCompany->id;
18
+                $companyId = session('current_company_id');
19
+
20
+                if (! $companyId && Auth::check() && Auth::user()->currentCompany) {
21
+                    $companyId = Auth::user()->currentCompany->id;
22
+                    session(['current_company_id' => $companyId]);
23
+                }
24
+
25
+                if (! $companyId) {
26
+                    $companyId = Auth::user()->currentCompany->id;
27
+                }
28
+
29
+                if ($companyId) {
30
+                    $model->company_id = $companyId;
20 31
                 } else {
21
-                    Log::info('CompanyOwned trait: No company_id set on model ' . get_class($model) . ' ' . $model->id);
32
+                    Log::error('CurrentCompanyScope: No company_id found for user ' . Auth::id());
22 33
 
23
-                    throw new ModelNotFoundException('CompanyOwned trait: No company_id set on model ' . get_class($model) . ' ' . $model->id);
34
+                    throw new ModelNotFoundException('CurrentCompanyScope: No company_id set in the session, user, or database.');
24 35
                 }
25 36
             }
26 37
         });

+ 2
- 13
app/Concerns/HandlesResourceRecordCreation.php Wyświetl plik

@@ -3,7 +3,6 @@
3 3
 namespace App\Concerns;
4 4
 
5 5
 use App\Models\User;
6
-use BackedEnum;
7 6
 use Illuminate\Database\Eloquent\Builder;
8 7
 use Illuminate\Database\Eloquent\Model;
9 8
 
@@ -11,11 +10,8 @@ trait HandlesResourceRecordCreation
11 10
 {
12 11
     protected function handleRecordCreationWithUniqueField(array $data, Model $model, User $user, ?string $uniqueField = null, ?array $evaluatedTypes = null): Model
13 12
     {
14
-        if (is_array($evaluatedTypes)) {
15
-            $evaluatedTypes = $this->ensureCreationEnumValues($evaluatedTypes);
16
-        }
17
-
18
-        if ($uniqueField && ! in_array($data[$uniqueField] ?? '', $evaluatedTypes ?? [], true)) {
13
+        // If evaluatedTypes is provided, ensure the unique field value is within the allowed types
14
+        if ($uniqueField && $evaluatedTypes && ! in_array($data[$uniqueField] ?? '', $evaluatedTypes, true)) {
19 15
             $data['enabled'] = false;
20 16
             $instance = $model->newInstance($data);
21 17
             $instance->save();
@@ -43,13 +39,6 @@ trait HandlesResourceRecordCreation
43 39
         return $instance;
44 40
     }
45 41
 
46
-    private function ensureCreationEnumValues(array $evaluatedTypes): array
47
-    {
48
-        return array_map(static function ($type) {
49
-            return $type instanceof BackedEnum ? $type->value : $type;
50
-        }, $evaluatedTypes);
51
-    }
52
-
53 42
     private function toggleRecords(Builder $query, bool &$shouldBeEnabled): void
54 43
     {
55 44
         if ($shouldBeEnabled) {

+ 0
- 2
app/Contracts/AccountHandler.php Wyświetl plik

@@ -21,7 +21,5 @@ interface AccountHandler
21 21
 
22 22
     public function getTotalBalanceForAllBankAccounts(string $startDate, string $endDate): Money;
23 23
 
24
-    public function getAccountCategoryOrder(): array;
25
-
26 24
     public function getEarliestTransactionDate(): string;
27 25
 }

+ 2
- 0
app/DTO/AccountDTO.php Wyświetl plik

@@ -9,5 +9,7 @@ class AccountDTO
9 9
         public string $accountCode,
10 10
         public ?int $accountId,
11 11
         public AccountBalanceDTO $balance,
12
+        public ?string $startDate,
13
+        public ?string $endDate,
12 14
     ) {}
13 15
 }

+ 65
- 0
app/Enums/Accounting/AccountCategory.php Wyświetl plik

@@ -38,4 +38,69 @@ enum AccountCategory: string implements HasLabel
38 38
 
39 39
         return null;
40 40
     }
41
+
42
+    /**
43
+     * Determines if the account typically has a normal debit balance.
44
+     *
45
+     * In accounting, assets and expenses typically have a normal debit balance.
46
+     * A debit increases the balance of these accounts, while a credit decreases it.
47
+     */
48
+    public function isNormalDebitBalance(): bool
49
+    {
50
+        return in_array($this, [self::Asset, self::Expense], true);
51
+    }
52
+
53
+    /**
54
+     * Determines if the account typically has a normal credit balance.
55
+     *
56
+     * In accounting, liabilities, equity, and revenue typically have a normal credit balance.
57
+     * A credit increases the balance of these accounts, while a debit decreases it.
58
+     */
59
+    public function isNormalCreditBalance(): bool
60
+    {
61
+        return ! $this->isNormalDebitBalance();
62
+    }
63
+
64
+    /**
65
+     * Determines if the account is a nominal account.
66
+     *
67
+     * In accounting, nominal accounts are temporary accounts that are closed at the end of each accounting period,
68
+     * with their net balances transferred to Retained Earnings (a real account).
69
+     */
70
+    public function isNominal(): bool
71
+    {
72
+        return in_array($this, [self::Revenue, self::Expense], true);
73
+    }
74
+
75
+    /**
76
+     * Determines if the account is a real account.
77
+     *
78
+     * In accounting, real accounts are permanent accounts that retain their balances across accounting periods.
79
+     * They are not closed at the end of each accounting period.
80
+     */
81
+    public function isReal(): bool
82
+    {
83
+        return ! $this->isNominal();
84
+    }
85
+
86
+    public function getRelevantBalanceFields(): array
87
+    {
88
+        $commonFields = ['debit_balance', 'credit_balance', 'net_movement'];
89
+
90
+        return match ($this->isReal()) {
91
+            true => [...$commonFields, 'starting_balance', 'ending_balance'],
92
+            false => $commonFields,
93
+        };
94
+    }
95
+
96
+    public static function getOrderedCategories(): array
97
+    {
98
+        return [
99
+            self::Asset,
100
+            self::Liability,
101
+            self::Equity,
102
+            self::Revenue,
103
+            self::Expense,
104
+        ];
105
+    }
41 106
 }

+ 5
- 9
app/Filament/Company/Clusters/Settings/Pages/CompanyDefault.php Wyświetl plik

@@ -6,12 +6,12 @@ use App\Events\CompanyDefaultUpdated;
6 6
 use App\Filament\Company\Clusters\Settings;
7 7
 use App\Models\Banking\BankAccount;
8 8
 use App\Models\Setting\CompanyDefault as CompanyDefaultModel;
9
-use App\Models\Setting\Currency;
10 9
 use App\Models\Setting\Discount;
11 10
 use App\Models\Setting\Tax;
12 11
 use Filament\Actions\Action;
13 12
 use Filament\Actions\ActionGroup;
14 13
 use Filament\Forms\Components\Component;
14
+use Filament\Forms\Components\Placeholder;
15 15
 use Filament\Forms\Components\Section;
16 16
 use Filament\Forms\Components\Select;
17 17
 use Filament\Forms\Form;
@@ -133,14 +133,10 @@ class CompanyDefault extends Page
133 133
                     ->selectablePlaceholder(false)
134 134
                     ->searchable()
135 135
                     ->preload(),
136
-                Select::make('currency_code')
137
-                    ->softRequired()
138
-                    ->localizeLabel('Currency')
139
-                    ->relationship('currency', 'name')
140
-                    ->getOptionLabelFromRecordUsing(static fn (Currency $record) => "{$record->code} {$record->symbol} - {$record->name}")
141
-                    ->saveRelationshipsUsing(null)
142
-                    ->searchable()
143
-                    ->preload(),
136
+                Placeholder::make('currency_code')
137
+                    ->label(translate('Currency'))
138
+                    ->hintIcon('heroicon-o-question-mark-circle', 'You cannot change this after your company has been created. You can still use other currencies for transactions.')
139
+                    ->content(static fn (CompanyDefaultModel $record) => "{$record->currency->code} {$record->currency->symbol} - {$record->currency->name}"),
144 140
             ])->columns();
145 141
     }
146 142
 

+ 1
- 44
app/Filament/Company/Clusters/Settings/Resources/CurrencyResource.php Wyświetl plik

@@ -19,7 +19,6 @@ use Filament\Support\Enums\FontWeight;
19 19
 use Filament\Tables;
20 20
 use Filament\Tables\Table;
21 21
 use Illuminate\Database\Eloquent\Collection;
22
-use Wallo\FilamentSelectify\Components\ToggleButton;
23 22
 
24 23
 class CurrencyResource extends Resource
25 24
 {
@@ -51,7 +50,6 @@ class CurrencyResource extends Resource
51 50
                             ->live()
52 51
                             ->required()
53 52
                             ->localizeLabel()
54
-                            ->hidden(static fn (Forms\Get $get, $state): bool => $get('enabled') && $state !== null)
55 53
                             ->afterStateUpdated(static function (Forms\Set $set, $state) {
56 54
                                 $fields = ['name', 'precision', 'symbol', 'symbol_first', 'decimal_mark', 'thousands_separator'];
57 55
 
@@ -71,12 +69,6 @@ class CurrencyResource extends Resource
71 69
 
72 70
                                 array_walk($fields, static fn ($field) => $set($field, $currencyDetails[$field] ?? null));
73 71
                             }),
74
-                        Forms\Components\TextInput::make('code')
75
-                            ->localizeLabel()
76
-                            ->hidden(static fn (Forms\Get $get): bool => ! ($get('enabled') && $get('code') !== null))
77
-                            ->disabled(static fn (Forms\Get $get): bool => $get('enabled'))
78
-                            ->dehydrated()
79
-                            ->required(),
80 72
                         Forms\Components\TextInput::make('name')
81 73
                             ->localizeLabel()
82 74
                             ->maxLength(50)
@@ -86,8 +78,6 @@ class CurrencyResource extends Resource
86 78
                             ->rule('gt:0')
87 79
                             ->live()
88 80
                             ->localizeLabel()
89
-                            ->disabled(static fn (?CurrencyModel $record): bool => $record?->isEnabled() ?? false)
90
-                            ->dehydrated()
91 81
                             ->required(),
92 82
                         Forms\Components\Select::make('precision')
93 83
                             ->localizeLabel()
@@ -123,36 +113,6 @@ class CurrencyResource extends Resource
123 113
                                 };
124 114
                             })
125 115
                             ->nullable(),
126
-                        ToggleButton::make('enabled')
127
-                            ->localizeLabel('Default')
128
-                            ->onLabel(CurrencyModel::enabledLabel())
129
-                            ->offLabel(CurrencyModel::disabledLabel())
130
-                            ->disabled(static fn (?CurrencyModel $record): bool => $record?->isEnabled() ?? false)
131
-                            ->dehydrated()
132
-                            ->live()
133
-                            ->afterStateUpdated(static function (Forms\Set $set, Forms\Get $get, $state) {
134
-                                $enabledState = (bool) $state;
135
-                                $code = $get('code');
136
-
137
-                                if (! $code) {
138
-                                    return;
139
-                                }
140
-
141
-                                if ($enabledState) {
142
-                                    $set('rate', 1);
143
-
144
-                                    return;
145
-                                }
146
-
147
-                                $forexEnabled = Forex::isEnabled();
148
-                                if ($forexEnabled) {
149
-                                    $defaultCurrencyCode = CurrencyAccessor::getDefaultCurrency();
150
-                                    $exchangeRate = Forex::getCachedExchangeRate($defaultCurrencyCode, $code);
151
-                                    if ($exchangeRate !== null) {
152
-                                        $set('rate', $exchangeRate);
153
-                                    }
154
-                                }
155
-                            }),
156 116
                     ])->columns(),
157 117
             ]);
158 118
     }
@@ -230,10 +190,7 @@ class CurrencyResource extends Resource
230 190
             ])
231 191
             ->checkIfRecordIsSelectableUsing(static function (CurrencyModel $record) {
232 192
                 return $record->isDisabled();
233
-            })
234
-            ->emptyStateActions([
235
-                Tables\Actions\CreateAction::make(),
236
-            ]);
193
+            });
237 194
     }
238 195
 
239 196
     public static function getPages(): array

+ 0
- 28
app/Filament/Company/Clusters/Settings/Resources/CurrencyResource/Pages/CreateCurrency.php Wyświetl plik

@@ -2,43 +2,15 @@
2 2
 
3 3
 namespace App\Filament\Company\Clusters\Settings\Resources\CurrencyResource\Pages;
4 4
 
5
-use App\Concerns\HandlesResourceRecordCreation;
6 5
 use App\Filament\Company\Clusters\Settings\Resources\CurrencyResource;
7
-use App\Models\Setting\Currency;
8 6
 use Filament\Resources\Pages\CreateRecord;
9
-use Filament\Support\Exceptions\Halt;
10
-use Illuminate\Database\Eloquent\Model;
11
-use Illuminate\Support\Facades\Auth;
12 7
 
13 8
 class CreateCurrency extends CreateRecord
14 9
 {
15
-    use HandlesResourceRecordCreation;
16
-
17 10
     protected static string $resource = CurrencyResource::class;
18 11
 
19 12
     protected function getRedirectUrl(): string
20 13
     {
21 14
         return $this->getResource()::getUrl('index');
22 15
     }
23
-
24
-    protected function mutateFormDataBeforeCreate(array $data): array
25
-    {
26
-        $data['enabled'] = (bool) $data['enabled'];
27
-
28
-        return $data;
29
-    }
30
-
31
-    /**
32
-     * @throws Halt
33
-     */
34
-    protected function handleRecordCreation(array $data): Model
35
-    {
36
-        $user = Auth::user();
37
-
38
-        if (! $user) {
39
-            throw new Halt('No authenticated user found');
40
-        }
41
-
42
-        return $this->handleRecordCreationWithUniqueField($data, new Currency, $user);
43
-    }
44 16
 }

+ 0
- 28
app/Filament/Company/Clusters/Settings/Resources/CurrencyResource/Pages/EditCurrency.php Wyświetl plik

@@ -2,19 +2,12 @@
2 2
 
3 3
 namespace App\Filament\Company\Clusters\Settings\Resources\CurrencyResource\Pages;
4 4
 
5
-use App\Concerns\HandlesResourceRecordUpdate;
6 5
 use App\Filament\Company\Clusters\Settings\Resources\CurrencyResource;
7
-use App\Models\Setting\Currency;
8 6
 use Filament\Actions;
9 7
 use Filament\Resources\Pages\EditRecord;
10
-use Filament\Support\Exceptions\Halt;
11
-use Illuminate\Database\Eloquent\Model;
12
-use Illuminate\Support\Facades\Auth;
13 8
 
14 9
 class EditCurrency extends EditRecord
15 10
 {
16
-    use HandlesResourceRecordUpdate;
17
-
18 11
     protected static string $resource = CurrencyResource::class;
19 12
 
20 13
     protected function getHeaderActions(): array
@@ -28,25 +21,4 @@ class EditCurrency extends EditRecord
28 21
     {
29 22
         return $this->getResource()::getUrl('index');
30 23
     }
31
-
32
-    protected function mutateFormDataBeforeSave(array $data): array
33
-    {
34
-        $data['enabled'] = (bool) $data['enabled'];
35
-
36
-        return $data;
37
-    }
38
-
39
-    /**
40
-     * @throws Halt
41
-     */
42
-    protected function handleRecordUpdate(Model | Currency $record, array $data): Model | Currency
43
-    {
44
-        $user = Auth::user();
45
-
46
-        if (! $user) {
47
-            throw new Halt('No authenticated user found');
48
-        }
49
-
50
-        return $this->handleRecordUpdateWithUniqueField($record, $data, $user);
51
-    }
52 24
 }

+ 31
- 5
app/Filament/Company/Pages/Accounting/Transactions.php Wyświetl plik

@@ -10,6 +10,7 @@ use App\Facades\Accounting;
10 10
 use App\Filament\Company\Pages\Service\ConnectedAccount;
11 11
 use App\Filament\Forms\Components\DateRangeSelect;
12 12
 use App\Filament\Forms\Components\JournalEntryRepeater;
13
+use App\Filament\Tables\Actions\ReplicateBulkAction;
13 14
 use App\Models\Accounting\Account;
14 15
 use App\Models\Accounting\JournalEntry;
15 16
 use App\Models\Accounting\Transaction;
@@ -216,6 +217,12 @@ class Transactions extends Page implements HasTable
216 217
         return $table
217 218
             ->query(static::getEloquentQuery())
218 219
             ->modifyQueryUsing(function (Builder $query) {
220
+                $query->with([
221
+                    'account',
222
+                    'bankAccount.account',
223
+                    'journalEntries.account',
224
+                ]);
225
+
219 226
                 if ($this->bankAccountIdFiltered !== 'all') {
220 227
                     $query->where('bank_account_id', $this->bankAccountIdFiltered);
221 228
                 }
@@ -224,7 +231,7 @@ class Transactions extends Page implements HasTable
224 231
                 Tables\Columns\TextColumn::make('posted_at')
225 232
                     ->label('Date')
226 233
                     ->sortable()
227
-                    ->localizeDate(),
234
+                    ->defaultDateFormat(),
228 235
                 Tables\Columns\TextColumn::make('description')
229 236
                     ->label('Description')
230 237
                     ->limit(30)
@@ -373,8 +380,17 @@ class Transactions extends Page implements HasTable
373 380
                     Tables\Actions\ReplicateAction::make()
374 381
                         ->excludeAttributes(['created_by', 'updated_by', 'created_at', 'updated_at'])
375 382
                         ->modal(false)
376
-                        ->beforeReplicaSaved(static function (Transaction $transaction) {
377
-                            $transaction->description = '(Copy of) ' . $transaction->description;
383
+                        ->beforeReplicaSaved(static function (Transaction $replica) {
384
+                            $replica->description = '(Copy of) ' . $replica->description;
385
+                        })
386
+                        ->after(static function (Transaction $original, Transaction $replica) {
387
+                            $original->journalEntries->each(function (JournalEntry $entry) use ($replica) {
388
+                                $entry->replicate([
389
+                                    'transaction_id',
390
+                                ])->fill([
391
+                                    'transaction_id' => $replica->id,
392
+                                ])->save();
393
+                            });
378 394
                         }),
379 395
                 ])
380 396
                     ->dropdownPlacement('bottom-start')
@@ -383,6 +399,18 @@ class Transactions extends Page implements HasTable
383 399
             ->bulkActions([
384 400
                 Tables\Actions\BulkActionGroup::make([
385 401
                     Tables\Actions\DeleteBulkAction::make(),
402
+                    ReplicateBulkAction::make()
403
+                        ->label('Replicate')
404
+                        ->modalWidth(MaxWidth::Large)
405
+                        ->modalDescription('Replicating transactions will also replicate their journal entries. Are you sure you want to proceed?')
406
+                        ->successNotificationTitle('Transactions Replicated Successfully')
407
+                        ->failureNotificationTitle('Failed to Replicate Transactions')
408
+                        ->deselectRecordsAfterCompletion()
409
+                        ->excludeAttributes(['created_by', 'updated_by', 'created_at', 'updated_at'])
410
+                        ->beforeReplicaSaved(static function (Transaction $replica) {
411
+                            $replica->description = '(Copy of) ' . $replica->description;
412
+                        })
413
+                        ->withReplicatedRelationships(['journalEntries']),
386 414
                 ]),
387 415
             ]);
388 416
     }
@@ -630,14 +658,12 @@ class Transactions extends Page implements HasTable
630 658
                             ->endDateField("{$fieldPrefix}_end_date"),
631 659
                         DatePicker::make("{$fieldPrefix}_start_date")
632 660
                             ->label("{$label} From")
633
-                            ->displayFormat('Y-m-d')
634 661
                             ->columnStart(1)
635 662
                             ->afterStateUpdated(static function (Set $set) use ($fieldPrefix) {
636 663
                                 $set("{$fieldPrefix}_date_range", 'Custom');
637 664
                             }),
638 665
                         DatePicker::make("{$fieldPrefix}_end_date")
639 666
                             ->label("{$label} To")
640
-                            ->displayFormat('Y-m-d')
641 667
                             ->afterStateUpdated(static function (Set $set) use ($fieldPrefix) {
642 668
                                 $set("{$fieldPrefix}_date_range", 'Custom');
643 669
                             }),

+ 179
- 0
app/Filament/Company/Pages/Concerns/HasDeferredFiltersForm.php Wyświetl plik

@@ -0,0 +1,179 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Pages\Concerns;
4
+
5
+use Filament\Actions\Action;
6
+use Filament\Forms\Components\DatePicker;
7
+use Filament\Forms\Form;
8
+use Illuminate\Support\Arr;
9
+use Illuminate\Support\Carbon;
10
+
11
+trait HasDeferredFiltersForm
12
+{
13
+    /**
14
+     * @var array<string, mixed> | null
15
+     */
16
+    public ?array $filters = null;
17
+
18
+    /**
19
+     * @var array<string, mixed> | null
20
+     */
21
+    public ?array $deferredFilters = null;
22
+
23
+    public function mountHasDeferredFiltersForm(): void
24
+    {
25
+        $this->initializeDefaultFilters();
26
+
27
+        $this->initializeFilters();
28
+    }
29
+
30
+    protected function initializeDefaultFilters(): void
31
+    {
32
+        //
33
+    }
34
+
35
+    public function initializeFilters(): void
36
+    {
37
+        if (! count($this->filters ?? [])) {
38
+            $this->filters = null;
39
+        }
40
+
41
+        $this->getFiltersForm()->fill($this->filters);
42
+    }
43
+
44
+    protected function getHasDeferredFiltersFormForms(): array
45
+    {
46
+        return [
47
+            'filtersForm' => $this->getFiltersForm(),
48
+        ];
49
+    }
50
+
51
+    public function filtersForm(Form $form): Form
52
+    {
53
+        return $form;
54
+    }
55
+
56
+    public function getFiltersForm(): Form
57
+    {
58
+        return $this->filtersForm($this->makeForm()
59
+            ->statePath('deferredFilters'));
60
+    }
61
+
62
+    public function updatedFilters(): void
63
+    {
64
+        $this->deferredFilters = $this->filters;
65
+    }
66
+
67
+    protected function isValidDate($date): bool
68
+    {
69
+        return strtotime($date) !== false;
70
+    }
71
+
72
+    public function applyFilters(): void
73
+    {
74
+        $this->filters = $this->deferredFilters;
75
+
76
+        $this->loadReportData();
77
+    }
78
+
79
+    public function applyFiltersAction(): Action
80
+    {
81
+        return Action::make('applyFilters')
82
+            ->label('Update Report')
83
+            ->action('applyFilters')
84
+            ->keyBindings(['mod+s'])
85
+            ->button();
86
+    }
87
+
88
+    public function getFilterState(string $name): mixed
89
+    {
90
+        return Arr::get($this->filters, $name);
91
+    }
92
+
93
+    public function setFilterState(string $name, mixed $value): void
94
+    {
95
+        Arr::set($this->filters, $name, $value);
96
+    }
97
+
98
+    public function getDeferredFilterState(string $name): mixed
99
+    {
100
+        return Arr::get($this->deferredFilters, $name);
101
+    }
102
+
103
+    public function setDeferredFilterState(string $name, mixed $value): void
104
+    {
105
+        Arr::set($this->deferredFilters, $name, $value);
106
+    }
107
+
108
+    protected function convertDatesToDateTimeString(array $filters): array
109
+    {
110
+        if (isset($filters['startDate'])) {
111
+            $filters['startDate'] = Carbon::parse($filters['startDate'])->startOfDay()->toDateTimeString();
112
+        }
113
+
114
+        if (isset($filters['endDate'])) {
115
+            $filters['endDate'] = Carbon::parse($filters['endDate'])->endOfDay()->toDateTimeString();
116
+        }
117
+
118
+        return $filters;
119
+    }
120
+
121
+    protected function queryStringHasDeferredFiltersForm(): array
122
+    {
123
+        // Get the filter keys dynamically from the filters form
124
+        $filterKeys = collect($this->getFiltersForm()->getFlatFields())->keys()->toArray();
125
+
126
+        return array_merge(
127
+            $this->generateQueryStrings($filterKeys),
128
+            $this->generateExcludedQueryStrings(),
129
+        );
130
+    }
131
+
132
+    protected function generateQueryStrings(array $filterKeys): array
133
+    {
134
+        $generatedQueryStrings = [];
135
+
136
+        $excludedKeys = $this->excludeQueryStrings();
137
+
138
+        foreach ($filterKeys as $key) {
139
+            if (! in_array($key, $excludedKeys)) {
140
+                $generatedQueryStrings["filters.{$key}"] = [
141
+                    'as' => $key,
142
+                    'keep' => true,
143
+                ];
144
+            }
145
+        }
146
+
147
+        return $generatedQueryStrings;
148
+    }
149
+
150
+    protected function generateExcludedQueryStrings(): array
151
+    {
152
+        $excludedQueryStrings = [];
153
+
154
+        foreach ($this->excludeQueryStrings() as $key) {
155
+            $excludedQueryStrings["filters.{$key}.value"] = null;
156
+        }
157
+
158
+        return $excludedQueryStrings;
159
+    }
160
+
161
+    protected function excludeQueryStrings(): array
162
+    {
163
+        return [
164
+            'dateRange',
165
+        ];
166
+    }
167
+
168
+    public function dehydrateHasDeferredFiltersForm(): void
169
+    {
170
+        $flatFields = $this->getFiltersForm()->getFlatFields();
171
+
172
+        foreach ($this->filters as $key => $value) {
173
+            if (isset($flatFields[$key]) && $flatFields[$key] instanceof DatePicker) {
174
+                // TODO: Submit a PR to Filament to address DatePicker being dehydrated as a datetime string in filters
175
+                $this->filters[$key] = Carbon::parse($value)->toDateString();
176
+            }
177
+        }
178
+    }
179
+}

+ 133
- 0
app/Filament/Company/Pages/Concerns/HasFiltersForm.php Wyświetl plik

@@ -0,0 +1,133 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Pages\Concerns;
4
+
5
+use Filament\Actions\Action;
6
+use Filament\Forms\Form;
7
+use Illuminate\Support\Arr;
8
+use Illuminate\Support\Carbon;
9
+use Livewire\Attributes\Url;
10
+
11
+trait HasFiltersForm
12
+{
13
+    /**
14
+     * @var array<string, mixed> | null
15
+     */
16
+    #[Url(keep: true)]
17
+    public ?array $filters = null;
18
+
19
+    /**
20
+     * @var array<string, mixed> | null
21
+     */
22
+    public ?array $deferredFilters = null;
23
+
24
+    public function mountHasFiltersForm(): void
25
+    {
26
+        if (method_exists($this, 'loadDefaultDateRange')) {
27
+            $this->loadDefaultDateRange();
28
+        }
29
+
30
+        $this->initializeFilters();
31
+    }
32
+
33
+    public function initializeFilters(): void
34
+    {
35
+        if (! count($this->filters ?? [])) {
36
+            $this->filters = null;
37
+        }
38
+
39
+        $this->getFiltersForm()->fill($this->filters ?? []);
40
+
41
+        $this->applyFilters();
42
+    }
43
+
44
+    protected function getForms(): array
45
+    {
46
+        return [
47
+            'toggledTableColumnForm',
48
+            'filtersForm' => $this->getFiltersForm(),
49
+        ];
50
+    }
51
+
52
+    public function filtersForm(Form $form): Form
53
+    {
54
+        return $form;
55
+    }
56
+
57
+    public function getFiltersForm(): Form
58
+    {
59
+        return $this->filtersForm($this->makeForm()
60
+            ->statePath('deferredFilters'));
61
+    }
62
+
63
+    public function updatedFilters(): void
64
+    {
65
+        $this->deferredFilters = $this->filters;
66
+
67
+        $this->handleFilterUpdates();
68
+    }
69
+
70
+    protected function isValidDate($date): bool
71
+    {
72
+        return strtotime($date) !== false;
73
+    }
74
+
75
+    protected function handleFilterUpdates(): void
76
+    {
77
+        //
78
+    }
79
+
80
+    public function applyFilters(): void
81
+    {
82
+        $normalizedFilters = $this->deferredFilters;
83
+
84
+        $this->normalizeFilters($normalizedFilters);
85
+
86
+        $this->filters = $normalizedFilters;
87
+
88
+        $this->handleFilterUpdates();
89
+
90
+        if (method_exists($this, 'loadReportData')) {
91
+            $this->loadReportData();
92
+        }
93
+    }
94
+
95
+    protected function normalizeFilters(array &$filters): void
96
+    {
97
+        foreach ($filters as $name => &$value) {
98
+            if ($name === 'dateRange') {
99
+                unset($filters[$name]);
100
+            } elseif ($this->isValidDate($value)) {
101
+                $filters[$name] = Carbon::parse($value)->toDateString();
102
+            }
103
+        }
104
+    }
105
+
106
+    public function getFiltersApplyAction(): Action
107
+    {
108
+        return Action::make('applyFilters')
109
+            ->label(__('filament-tables::table.filters.actions.apply.label'))
110
+            ->action('applyFilters')
111
+            ->button();
112
+    }
113
+
114
+    public function getFilterState(string $name): mixed
115
+    {
116
+        return Arr::get($this->filters, $name);
117
+    }
118
+
119
+    public function setFilterState(string $name, mixed $value): void
120
+    {
121
+        Arr::set($this->filters, $name, $value);
122
+    }
123
+
124
+    public function getDeferredFilterState(string $name): mixed
125
+    {
126
+        return Arr::get($this->deferredFilters, $name);
127
+    }
128
+
129
+    public function setDeferredFilterState(string $name, mixed $value): void
130
+    {
131
+        Arr::set($this->deferredFilters, $name, $value);
132
+    }
133
+}

+ 106
- 0
app/Filament/Company/Pages/Concerns/HasToggleTableColumnForm.php Wyświetl plik

@@ -0,0 +1,106 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Pages\Concerns;
4
+
5
+use App\Support\Column;
6
+use Filament\Actions\Action;
7
+use Filament\Forms\Components\Checkbox;
8
+use Filament\Forms\Form;
9
+use Filament\Support\Enums\ActionSize;
10
+use Filament\Support\Facades\FilamentIcon;
11
+use Livewire\Attributes\Session;
12
+
13
+trait HasToggleTableColumnForm
14
+{
15
+    #[Session]
16
+    public array $toggledTableColumns = [];
17
+
18
+    public function mountHasToggleTableColumnForm(): void
19
+    {
20
+        $this->loadDefaultTableColumnToggleState();
21
+    }
22
+
23
+    protected function getHasToggleTableColumnFormForms(): array
24
+    {
25
+        return [
26
+            'toggleTableColumnForm' => $this->getToggleTableColumnForm(),
27
+        ];
28
+    }
29
+
30
+    public function getToggleTableColumnForm(): Form
31
+    {
32
+        return $this->toggleTableColumnForm($this->makeForm()
33
+            ->statePath('toggledTableColumns'));
34
+    }
35
+
36
+    public function toggleTableColumnForm(Form $form): Form
37
+    {
38
+        return $form;
39
+    }
40
+
41
+    protected function hasToggleableColumns(): bool
42
+    {
43
+        return ! empty($this->getTableColumnToggleFormSchema());
44
+    }
45
+
46
+    /**
47
+     * @return array<Checkbox>
48
+     */
49
+    protected function getTableColumnToggleFormSchema(): array
50
+    {
51
+        $schema = [];
52
+
53
+        foreach ($this->getTable() as $column) {
54
+            if ($column->isToggleable()) {
55
+                $schema[] = Checkbox::make($column->getName())
56
+                    ->label($column->getLabel());
57
+            }
58
+        }
59
+
60
+        return $schema;
61
+    }
62
+
63
+    public function toggleColumnsAction(): Action
64
+    {
65
+        return Action::make('toggleColumns')
66
+            ->label(__('filament-tables::table.actions.toggle_columns.label'))
67
+            ->iconButton()
68
+            ->size(ActionSize::Large)
69
+            ->icon(FilamentIcon::resolve('tables::actions.toggle-columns') ?? 'heroicon-m-view-columns')
70
+            ->color('gray');
71
+    }
72
+
73
+    protected function loadDefaultTableColumnToggleState(): void
74
+    {
75
+        $tableColumns = $this->getTable();
76
+
77
+        foreach ($tableColumns as $column) {
78
+            $columnName = $column->getName();
79
+
80
+            if (empty($this->toggledTableColumns)) {
81
+                if ($column->isToggleable()) {
82
+                    $this->toggledTableColumns[$columnName] = ! $column->isToggledHiddenByDefault();
83
+                } else {
84
+                    $this->toggledTableColumns[$columnName] = true;
85
+                }
86
+            }
87
+
88
+            // Handle cases where the toggle state needs to be reset
89
+            if (! $column->isToggleable()) {
90
+                $this->toggledTableColumns[$columnName] = true;
91
+            } elseif ($column->isToggleable() && $column->isToggledHiddenByDefault() && isset($this->toggledTableColumns[$columnName]) && $this->toggledTableColumns[$columnName]) {
92
+                $this->toggledTableColumns[$columnName] = false;
93
+            }
94
+        }
95
+    }
96
+
97
+    protected function getToggledColumns(): array
98
+    {
99
+        return array_values(
100
+            array_filter(
101
+                $this->getTable(),
102
+                fn (Column $column) => $this->toggledTableColumns[$column->getName()] ?? false,
103
+            )
104
+        );
105
+    }
106
+}

+ 29
- 1
app/Filament/Company/Pages/Reports.php Wyświetl plik

@@ -4,6 +4,7 @@ namespace App\Filament\Company\Pages;
4 4
 
5 5
 use App\Filament\Company\Pages\Reports\AccountBalances;
6 6
 use App\Filament\Company\Pages\Reports\AccountTransactions;
7
+use App\Filament\Company\Pages\Reports\IncomeStatement;
7 8
 use App\Filament\Company\Pages\Reports\TrialBalance;
8 9
 use App\Infolists\Components\ReportEntry;
9 10
 use Filament\Infolists\Components\Section;
@@ -22,9 +23,36 @@ class Reports extends Page
22 23
         return $infolist
23 24
             ->state([])
24 25
             ->schema([
26
+                Section::make('Financial Statements')
27
+                    ->aside()
28
+                    ->description('Key financial statements that provide a snapshot of your company’s financial health.')
29
+                    ->extraAttributes(['class' => 'es-report-card'])
30
+                    ->schema([
31
+                        ReportEntry::make('income_statement')
32
+                            ->hiddenLabel()
33
+                            ->heading('Income Statement')
34
+                            ->description('Tracks revenue and expenses to show profit or loss over a specific period of time.')
35
+                            ->icon('heroicon-o-chart-bar')
36
+                            ->iconColor(Color::Indigo)
37
+                            ->url(IncomeStatement::getUrl()),
38
+                        ReportEntry::make('balance_sheet')
39
+                            ->hiddenLabel()
40
+                            ->heading('Balance Sheet')
41
+                            ->description('Snapshot of assets, liabilities, and equity at a specific point in time.')
42
+                            ->icon('heroicon-o-clipboard-document-list')
43
+                            ->iconColor(Color::Emerald)
44
+                            ->url('#'),
45
+                        ReportEntry::make('cash_flow_statement')
46
+                            ->hiddenLabel()
47
+                            ->heading('Cash Flow Statement')
48
+                            ->description('Shows cash inflows and outflows over a specific period of time.')
49
+                            ->icon('heroicon-o-document-currency-dollar')
50
+                            ->iconColor(Color::Cyan)
51
+                            ->url('#'),
52
+                    ]),
25 53
                 Section::make('Detailed Reports')
26 54
                     ->aside()
27
-                    ->description('Dig into the details of your business’s transactions, balances, and accounts.')
55
+                    ->description('Dig into the details of your company’s transactions, balances, and accounts.')
28 56
                     ->extraAttributes(['class' => 'es-report-card'])
29 57
                     ->schema([
30 58
                         ReportEntry::make('account_balances')

+ 4
- 5
app/Filament/Company/Pages/Reports/AccountBalances.php Wyświetl plik

@@ -67,12 +67,11 @@ class AccountBalances extends BaseReportPage
67 67
         ];
68 68
     }
69 69
 
70
-    public function form(Form $form): Form
70
+    public function filtersForm(Form $form): Form
71 71
     {
72 72
         return $form
73 73
             ->inlineLabel()
74 74
             ->columns()
75
-            ->live()
76 75
             ->schema([
77 76
                 $this->getDateRangeFormComponent(),
78 77
                 Cluster::make([
@@ -84,7 +83,7 @@ class AccountBalances extends BaseReportPage
84 83
 
85 84
     protected function buildReport(array $columns): ReportDTO
86 85
     {
87
-        return $this->reportService->buildAccountBalanceReport($this->startDate, $this->endDate, $columns);
86
+        return $this->reportService->buildAccountBalanceReport($this->getFormattedStartDate(), $this->getFormattedEndDate(), $columns);
88 87
     }
89 88
 
90 89
     protected function getTransformer(ReportDTO $reportDTO): ExportableReport
@@ -94,11 +93,11 @@ class AccountBalances extends BaseReportPage
94 93
 
95 94
     public function exportCSV(): StreamedResponse
96 95
     {
97
-        return $this->exportService->exportToCsv($this->company, $this->report, $this->startDate, $this->endDate);
96
+        return $this->exportService->exportToCsv($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'));
98 97
     }
99 98
 
100 99
     public function exportPDF(): StreamedResponse
101 100
     {
102
-        return $this->exportService->exportToPdf($this->company, $this->report, $this->startDate, $this->endDate);
101
+        return $this->exportService->exportToPdf($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'));
103 102
     }
104 103
 }

+ 24
- 14
app/Filament/Company/Pages/Reports/AccountTransactions.php Wyświetl plik

@@ -14,11 +14,11 @@ use Filament\Forms\Components\Actions;
14 14
 use Filament\Forms\Components\Select;
15 15
 use Filament\Forms\Form;
16 16
 use Filament\Support\Enums\Alignment;
17
+use Filament\Support\Enums\MaxWidth;
17 18
 use Filament\Tables\Actions\Action;
18 19
 use Guava\FilamentClusters\Forms\Cluster;
19 20
 use Illuminate\Contracts\Support\Htmlable;
20 21
 use Illuminate\Support\Collection;
21
-use Livewire\Attributes\Url;
22 22
 use Symfony\Component\HttpFoundation\StreamedResponse;
23 23
 
24 24
 class AccountTransactions extends BaseReportPage
@@ -33,15 +33,24 @@ class AccountTransactions extends BaseReportPage
33 33
 
34 34
     protected ExportService $exportService;
35 35
 
36
-    #[Url]
37
-    public ?string $account_id = 'all';
38
-
39 36
     public function boot(ReportService $reportService, ExportService $exportService): void
40 37
     {
41 38
         $this->reportService = $reportService;
42 39
         $this->exportService = $exportService;
43 40
     }
44 41
 
42
+    public function getMaxContentWidth(): MaxWidth | string | null
43
+    {
44
+        return 'max-w-[90rem]';
45
+    }
46
+
47
+    protected function initializeDefaultFilters(): void
48
+    {
49
+        if (empty($this->getFilterState('selectedAccount'))) {
50
+            $this->setFilterState('selectedAccount', 'all');
51
+        }
52
+    }
53
+
45 54
     /**
46 55
      * @return array<Column>
47 56
      */
@@ -50,6 +59,7 @@ class AccountTransactions extends BaseReportPage
50 59
         return [
51 60
             Column::make('date')
52 61
                 ->label('Date')
62
+                ->markAsDate()
53 63
                 ->alignment(Alignment::Left),
54 64
             Column::make('description')
55 65
                 ->label('Description')
@@ -66,13 +76,12 @@ class AccountTransactions extends BaseReportPage
66 76
         ];
67 77
     }
68 78
 
69
-    public function form(Form $form): Form
79
+    public function filtersForm(Form $form): Form
70 80
     {
71 81
         return $form
72 82
             ->columns(4)
73
-            ->live()
74 83
             ->schema([
75
-                Select::make('account_id')
84
+                Select::make('selectedAccount')
76 85
                     ->label('Account')
77 86
                     ->options($this->getAccountOptions())
78 87
                     ->selectablePlaceholder(false)
@@ -83,10 +92,11 @@ class AccountTransactions extends BaseReportPage
83 92
                     $this->getEndDateFormComponent(),
84 93
                 ])->label("\u{200B}"), // its too bad hiddenLabel removes spacing of the label
85 94
                 Actions::make([
86
-                    Actions\Action::make('loadReportData')
95
+                    Actions\Action::make('applyFilters')
87 96
                         ->label('Update Report')
88
-                        ->submit('loadReportData')
89
-                        ->keyBindings(['mod+s']),
97
+                        ->action('applyFilters')
98
+                        ->keyBindings(['mod+s'])
99
+                        ->button(),
90 100
                 ])->alignEnd()->verticallyAlignEnd(),
91 101
             ]);
92 102
     }
@@ -96,7 +106,7 @@ class AccountTransactions extends BaseReportPage
96 106
         $accounts = Account::query()
97 107
             ->get()
98 108
             ->groupBy(fn (Account $account) => $account->category->getPluralLabel())
99
-            ->map(fn (Collection $accounts, string $category) => $accounts->pluck('name', 'id'))
109
+            ->map(fn (Collection $accounts) => $accounts->pluck('name', 'id'))
100 110
             ->toArray();
101 111
 
102 112
         $allAccountsOption = [
@@ -108,7 +118,7 @@ class AccountTransactions extends BaseReportPage
108 118
 
109 119
     protected function buildReport(array $columns): ReportDTO
110 120
     {
111
-        return $this->reportService->buildAccountTransactionsReport($this->startDate, $this->endDate, $columns, $this->account_id);
121
+        return $this->reportService->buildAccountTransactionsReport($this->getFormattedStartDate(), $this->getFormattedEndDate(), $columns, $this->getFilterState('selectedAccount'));
112 122
     }
113 123
 
114 124
     protected function getTransformer(ReportDTO $reportDTO): ExportableReport
@@ -118,12 +128,12 @@ class AccountTransactions extends BaseReportPage
118 128
 
119 129
     public function exportCSV(): StreamedResponse
120 130
     {
121
-        return $this->exportService->exportToCsv($this->company, $this->report, $this->startDate, $this->endDate);
131
+        return $this->exportService->exportToCsv($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'));
122 132
     }
123 133
 
124 134
     public function exportPDF(): StreamedResponse
125 135
     {
126
-        return $this->exportService->exportToPdf($this->company, $this->report, $this->startDate, $this->endDate);
136
+        return $this->exportService->exportToPdf($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'));
127 137
     }
128 138
 
129 139
     public function getEmptyStateHeading(): string | Htmlable

+ 28
- 107
app/Filament/Company/Pages/Reports/BaseReportPage.php Wyświetl plik

@@ -4,45 +4,38 @@ namespace App\Filament\Company\Pages\Reports;
4 4
 
5 5
 use App\Contracts\ExportableReport;
6 6
 use App\DTO\ReportDTO;
7
+use App\Filament\Company\Pages\Concerns\HasDeferredFiltersForm;
8
+use App\Filament\Company\Pages\Concerns\HasToggleTableColumnForm;
7 9
 use App\Filament\Forms\Components\DateRangeSelect;
8 10
 use App\Models\Company;
11
+use App\Services\DateRangeService;
9 12
 use App\Support\Column;
10 13
 use Filament\Actions\Action;
11 14
 use Filament\Actions\ActionGroup;
12
-use Filament\Forms\Components\Checkbox;
13 15
 use Filament\Forms\Components\Component;
14 16
 use Filament\Forms\Components\DatePicker;
15 17
 use Filament\Forms\Form;
16 18
 use Filament\Forms\Set;
17 19
 use Filament\Pages\Page;
18
-use Filament\Support\Enums\ActionSize;
19 20
 use Filament\Support\Enums\IconPosition;
20 21
 use Filament\Support\Enums\IconSize;
21
-use Filament\Support\Facades\FilamentIcon;
22 22
 use Illuminate\Support\Carbon;
23 23
 use Livewire\Attributes\Computed;
24
-use Livewire\Attributes\Session;
25 24
 use Symfony\Component\HttpFoundation\StreamedResponse;
26 25
 
27 26
 abstract class BaseReportPage extends Page
28 27
 {
29
-    public string $startDate = '';
28
+    use HasDeferredFiltersForm;
29
+    use HasToggleTableColumnForm;
30 30
 
31
-    public string $endDate = '';
31
+    public string $fiscalYearStartDate;
32 32
 
33
-    public string $dateRange = '';
34
-
35
-    public string $fiscalYearStartDate = '';
36
-
37
-    public string $fiscalYearEndDate = '';
33
+    public string $fiscalYearEndDate;
38 34
 
39 35
     public Company $company;
40 36
 
41 37
     public bool $reportLoaded = false;
42 38
 
43
-    #[Session]
44
-    public array $toggledTableColumns = [];
45
-
46 39
     abstract protected function buildReport(array $columns): ReportDTO;
47 40
 
48 41
     abstract public function exportCSV(): StreamedResponse;
@@ -61,16 +54,6 @@ abstract class BaseReportPage extends Page
61 54
         $this->initializeProperties();
62 55
 
63 56
         $this->loadDefaultDateRange();
64
-
65
-        $this->loadDefaultTableColumnToggleState();
66
-    }
67
-
68
-    protected function getForms(): array
69
-    {
70
-        return [
71
-            'toggleTableColumnForm',
72
-            'form',
73
-        ];
74 57
     }
75 58
 
76 59
     protected function initializeProperties(): void
@@ -82,8 +65,14 @@ abstract class BaseReportPage extends Page
82 65
 
83 66
     protected function loadDefaultDateRange(): void
84 67
     {
85
-        if (empty($this->dateRange)) {
86
-            $this->dateRange = $this->getDefaultDateRange();
68
+        $startDate = $this->getFilterState('startDate');
69
+        $endDate = $this->getFilterState('endDate');
70
+
71
+        if ($this->isValidDate($startDate) && $this->isValidDate($endDate)) {
72
+            $matchingDateRange = app(DateRangeService::class)->getMatchingDateRangeOption(Carbon::parse($startDate), Carbon::parse($endDate));
73
+            $this->setFilterState('dateRange', $matchingDateRange);
74
+        } else {
75
+            $this->setFilterState('dateRange', $this->getDefaultDateRange());
87 76
             $this->setDateRange(Carbon::parse($this->fiscalYearStartDate), Carbon::parse($this->fiscalYearEndDate));
88 77
         }
89 78
     }
@@ -91,37 +80,8 @@ abstract class BaseReportPage extends Page
91 80
     public function loadReportData(): void
92 81
     {
93 82
         unset($this->report);
94
-        $this->reportLoaded = true;
95
-    }
96 83
 
97
-    protected function loadDefaultTableColumnToggleState(): void
98
-    {
99
-        $tableColumns = $this->getTable();
100
-
101
-        if (empty($this->toggledTableColumns)) {
102
-            foreach ($tableColumns as $column) {
103
-                if ($column->isToggleable()) {
104
-                    if ($column->isToggledHiddenByDefault()) {
105
-                        $this->toggledTableColumns[$column->getName()] = false;
106
-                    } else {
107
-                        $this->toggledTableColumns[$column->getName()] = true;
108
-                    }
109
-                } else {
110
-                    $this->toggledTableColumns[$column->getName()] = true;
111
-                }
112
-            }
113
-        }
114
-
115
-        foreach ($tableColumns as $column) {
116
-            $columnName = $column->getName();
117
-            if (! $column->isToggleable()) {
118
-                $this->toggledTableColumns[$columnName] = true;
119
-            }
120
-
121
-            if ($column->isToggleable() && $column->isToggledHiddenByDefault() && isset($this->toggledTableColumns[$columnName]) && $this->toggledTableColumns[$columnName]) {
122
-                $this->toggledTableColumns[$columnName] = false;
123
-            }
124
-        }
84
+        $this->reportLoaded = true;
125 85
     }
126 86
 
127 87
     public function getDefaultDateRange(): string
@@ -129,16 +89,6 @@ abstract class BaseReportPage extends Page
129 89
         return 'FY-' . now()->year;
130 90
     }
131 91
 
132
-    protected function getToggledColumns(): array
133
-    {
134
-        return array_values(
135
-            array_filter(
136
-                $this->getTable(),
137
-                fn (Column $column) => $this->toggledTableColumns[$column->getName()] ?? false,
138
-            )
139
-        );
140
-    }
141
-
142 92
     #[Computed(persist: true)]
143 93
     public function report(): ?ExportableReport
144 94
     {
@@ -154,47 +104,24 @@ abstract class BaseReportPage extends Page
154 104
 
155 105
     public function setDateRange(Carbon $start, Carbon $end): void
156 106
     {
157
-        $this->startDate = $start->toDateString();
158
-        $this->endDate = $end->isFuture() ? now()->toDateString() : $end->toDateString();
159
-    }
160
-
161
-    public function toggleColumnsAction(): Action
162
-    {
163
-        return Action::make('toggleColumns')
164
-            ->label(__('filament-tables::table.actions.toggle_columns.label'))
165
-            ->iconButton()
166
-            ->size(ActionSize::Large)
167
-            ->icon(FilamentIcon::resolve('tables::actions.toggle-columns') ?? 'heroicon-m-view-columns')
168
-            ->color('gray');
107
+        $this->setFilterState('startDate', $start->startOfDay()->toDateTimeString());
108
+        $this->setFilterState('endDate', $end->isFuture() ? now()->endOfDay()->toDateTimeString() : $end->endOfDay()->toDateTimeString());
169 109
     }
170 110
 
171
-    public function toggleTableColumnForm(Form $form): Form
111
+    public function getFormattedStartDate(): string
172 112
     {
173
-        return $form
174
-            ->schema($this->getTableColumnToggleFormSchema())
175
-            ->statePath('toggledTableColumns');
113
+        return Carbon::parse($this->getFilterState('startDate'))->startOfDay()->toDateTimeString();
176 114
     }
177 115
 
178
-    protected function hasToggleableColumns(): bool
116
+    public function getFormattedEndDate(): string
179 117
     {
180
-        return ! empty($this->getTableColumnToggleFormSchema());
118
+        return Carbon::parse($this->getFilterState('endDate'))->endOfDay()->toDateTimeString();
181 119
     }
182 120
 
183
-    /**
184
-     * @return array<Checkbox>
185
-     */
186
-    protected function getTableColumnToggleFormSchema(): array
121
+    public function toggleTableColumnForm(Form $form): Form
187 122
     {
188
-        $schema = [];
189
-
190
-        foreach ($this->getTable() as $column) {
191
-            if ($column->isToggleable()) {
192
-                $schema[] = Checkbox::make($column->getName())
193
-                    ->label($column->getLabel());
194
-            }
195
-        }
196
-
197
-        return $schema;
123
+        return $form
124
+            ->schema($this->getTableColumnToggleFormSchema());
198 125
     }
199 126
 
200 127
     protected function getHeaderActions(): array
@@ -228,18 +155,12 @@ abstract class BaseReportPage extends Page
228 155
             ->endDateField('endDate');
229 156
     }
230 157
 
231
-    protected function resetDateRange(): void
232
-    {
233
-        $this->dateRange = $this->getDefaultDateRange();
234
-        $this->setDateRange(Carbon::parse($this->fiscalYearStartDate), Carbon::parse($this->fiscalYearEndDate));
235
-    }
236
-
237 158
     protected function getStartDateFormComponent(): Component
238 159
     {
239 160
         return DatePicker::make('startDate')
240 161
             ->label('Start Date')
241
-            ->displayFormat('Y-m-d')
242
-            ->afterStateUpdated(static function (Set $set) {
162
+            ->live()
163
+            ->afterStateUpdated(static function ($state, Set $set) {
243 164
                 $set('dateRange', 'Custom');
244 165
             });
245 166
     }
@@ -248,7 +169,7 @@ abstract class BaseReportPage extends Page
248 169
     {
249 170
         return DatePicker::make('endDate')
250 171
             ->label('End Date')
251
-            ->displayFormat('Y-m-d')
172
+            ->live()
252 173
             ->afterStateUpdated(static function (Set $set) {
253 174
                 $set('dateRange', 'Custom');
254 175
             });

+ 83
- 0
app/Filament/Company/Pages/Reports/IncomeStatement.php Wyświetl plik

@@ -0,0 +1,83 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Pages\Reports;
4
+
5
+use App\Contracts\ExportableReport;
6
+use App\DTO\ReportDTO;
7
+use App\Services\ExportService;
8
+use App\Services\ReportService;
9
+use App\Support\Column;
10
+use App\Transformers\IncomeStatementReportTransformer;
11
+use Filament\Forms\Form;
12
+use Filament\Support\Enums\Alignment;
13
+use Guava\FilamentClusters\Forms\Cluster;
14
+use Symfony\Component\HttpFoundation\StreamedResponse;
15
+
16
+class IncomeStatement extends BaseReportPage
17
+{
18
+    protected static string $view = 'filament.company.pages.reports.income-statement';
19
+
20
+    protected static ?string $slug = 'reports/income-statement';
21
+
22
+    protected static bool $shouldRegisterNavigation = false;
23
+
24
+    protected ReportService $reportService;
25
+
26
+    protected ExportService $exportService;
27
+
28
+    public function boot(ReportService $reportService, ExportService $exportService): void
29
+    {
30
+        $this->reportService = $reportService;
31
+        $this->exportService = $exportService;
32
+    }
33
+
34
+    public function getTable(): array
35
+    {
36
+        return [
37
+            Column::make('account_code')
38
+                ->label('Account Code')
39
+                ->toggleable()
40
+                ->alignment(Alignment::Center),
41
+            Column::make('account_name')
42
+                ->label('Account')
43
+                ->alignment(Alignment::Left),
44
+            Column::make('net_movement')
45
+                ->label('Amount')
46
+                ->alignment(Alignment::Right),
47
+        ];
48
+    }
49
+
50
+    public function filtersForm(Form $form): Form
51
+    {
52
+        return $form
53
+            ->inlineLabel()
54
+            ->columns()
55
+            ->schema([
56
+                $this->getDateRangeFormComponent(),
57
+                Cluster::make([
58
+                    $this->getStartDateFormComponent(),
59
+                    $this->getEndDateFormComponent(),
60
+                ])->hiddenLabel(),
61
+            ]);
62
+    }
63
+
64
+    protected function buildReport(array $columns): ReportDTO
65
+    {
66
+        return $this->reportService->buildIncomeStatementReport($this->getFormattedStartDate(), $this->getFormattedEndDate(), $columns);
67
+    }
68
+
69
+    protected function getTransformer(ReportDTO $reportDTO): ExportableReport
70
+    {
71
+        return new IncomeStatementReportTransformer($reportDTO);
72
+    }
73
+
74
+    public function exportCSV(): StreamedResponse
75
+    {
76
+        return $this->exportService->exportToCsv($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'));
77
+    }
78
+
79
+    public function exportPDF(): StreamedResponse
80
+    {
81
+        return $this->exportService->exportToPdf($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'));
82
+    }
83
+}

+ 5
- 9
app/Filament/Company/Pages/Reports/TrialBalance.php Wyświetl plik

@@ -50,15 +50,11 @@ class TrialBalance extends BaseReportPage
50 50
         ];
51 51
     }
52 52
 
53
-    public function form(Form $form): Form
53
+    public function filtersForm(Form $form): Form
54 54
     {
55 55
         return $form
56 56
             ->inlineLabel()
57
-            ->columns([
58
-                'lg' => 1,
59
-                '2xl' => 2,
60
-            ])
61
-            ->live()
57
+            ->columns()
62 58
             ->schema([
63 59
                 $this->getDateRangeFormComponent(),
64 60
                 Cluster::make([
@@ -70,7 +66,7 @@ class TrialBalance extends BaseReportPage
70 66
 
71 67
     protected function buildReport(array $columns): ReportDTO
72 68
     {
73
-        return $this->reportService->buildTrialBalanceReport($this->startDate, $this->endDate, $columns);
69
+        return $this->reportService->buildTrialBalanceReport($this->getFormattedStartDate(), $this->getFormattedEndDate(), $columns);
74 70
     }
75 71
 
76 72
     protected function getTransformer(ReportDTO $reportDTO): ExportableReport
@@ -80,11 +76,11 @@ class TrialBalance extends BaseReportPage
80 76
 
81 77
     public function exportCSV(): StreamedResponse
82 78
     {
83
-        return $this->exportService->exportToCsv($this->company, $this->report, $this->startDate, $this->endDate);
79
+        return $this->exportService->exportToCsv($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'));
84 80
     }
85 81
 
86 82
     public function exportPDF(): StreamedResponse
87 83
     {
88
-        return $this->exportService->exportToPdf($this->company, $this->report, $this->startDate, $this->endDate);
84
+        return $this->exportService->exportToPdf($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'));
89 85
     }
90 86
 }

+ 11
- 4
app/Filament/Company/Resources/Banking/AccountResource.php Wyświetl plik

@@ -15,6 +15,7 @@ use Filament\Support\Enums\Alignment;
15 15
 use Filament\Support\Enums\FontWeight;
16 16
 use Filament\Tables;
17 17
 use Filament\Tables\Table;
18
+use Illuminate\Database\Eloquent\Builder;
18 19
 use Illuminate\Support\Collection;
19 20
 use Illuminate\Support\Facades\Auth;
20 21
 use Illuminate\Validation\Rules\Unique;
@@ -44,6 +45,7 @@ class AccountResource extends Resource
44 45
                             ->localizeLabel()
45 46
                             ->searchable()
46 47
                             ->columnSpan(1)
48
+                            ->disabledOn('edit')
47 49
                             ->default(BankAccountType::DEFAULT)
48 50
                             ->live()
49 51
                             ->afterStateUpdated(static function (Forms\Set $set, $state, ?BankAccount $bankAccount, string $operation) {
@@ -64,6 +66,7 @@ class AccountResource extends Resource
64 66
                             ->schema([
65 67
                                 Forms\Components\Select::make('subtype_id')
66 68
                                     ->options(static fn (Forms\Get $get) => static::groupSubtypesBySubtypeType(BankAccountType::parse($get('data.type', true))))
69
+                                    ->disabledOn('edit')
67 70
                                     ->localizeLabel()
68 71
                                     ->searchable()
69 72
                                     ->live()
@@ -79,6 +82,7 @@ class AccountResource extends Resource
79 82
                                     ->localizeLabel()
80 83
                                     ->required(),
81 84
                                 CreateCurrencySelect::make('currency_code')
85
+                                    ->disabledOn('edit')
82 86
                                     ->relationship('currency', 'name'),
83 87
                             ]),
84 88
                         Forms\Components\Group::make()
@@ -104,6 +108,12 @@ class AccountResource extends Resource
104 108
     public static function table(Table $table): Table
105 109
     {
106 110
         return $table
111
+            ->modifyQueryUsing(function (Builder $query) {
112
+                $query->with([
113
+                    'account',
114
+                    'account.subtype',
115
+                ]);
116
+            })
107 117
             ->columns([
108 118
                 Tables\Columns\TextColumn::make('account.name')
109 119
                     ->localizeLabel('Account')
@@ -138,10 +148,7 @@ class AccountResource extends Resource
138 148
             ])
139 149
             ->checkIfRecordIsSelectableUsing(static function (BankAccount $record) {
140 150
                 return $record->isDisabled();
141
-            })
142
-            ->emptyStateActions([
143
-                Tables\Actions\CreateAction::make(),
144
-            ]);
151
+            });
145 152
     }
146 153
 
147 154
     public static function getPages(): array

+ 21
- 0
app/Filament/Company/Resources/Banking/AccountResource/Pages/CreateAccount.php Wyświetl plik

@@ -2,11 +2,18 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Banking\AccountResource\Pages;
4 4
 
5
+use App\Concerns\HandlesResourceRecordCreation;
5 6
 use App\Filament\Company\Resources\Banking\AccountResource;
7
+use App\Models\Banking\BankAccount;
6 8
 use Filament\Resources\Pages\CreateRecord;
9
+use Filament\Support\Exceptions\Halt;
10
+use Illuminate\Database\Eloquent\Model;
11
+use Illuminate\Support\Facades\Auth;
7 12
 
8 13
 class CreateAccount extends CreateRecord
9 14
 {
15
+    use HandlesResourceRecordCreation;
16
+
10 17
     protected static string $resource = AccountResource::class;
11 18
 
12 19
     protected function getRedirectUrl(): string
@@ -20,4 +27,18 @@ class CreateAccount extends CreateRecord
20 27
 
21 28
         return $data;
22 29
     }
30
+
31
+    /**
32
+     * @throws Halt
33
+     */
34
+    protected function handleRecordCreation(array $data): Model
35
+    {
36
+        $user = Auth::user();
37
+
38
+        if (! $user) {
39
+            throw new Halt('No authenticated user found');
40
+        }
41
+
42
+        return $this->handleRecordCreationWithUniqueField($data, new BankAccount, $user);
43
+    }
23 44
 }

+ 17
- 0
app/Filament/Company/Resources/Banking/AccountResource/Pages/EditAccount.php Wyświetl plik

@@ -5,6 +5,9 @@ namespace App\Filament\Company\Resources\Banking\AccountResource\Pages;
5 5
 use App\Concerns\HandlesResourceRecordUpdate;
6 6
 use App\Filament\Company\Resources\Banking\AccountResource;
7 7
 use Filament\Resources\Pages\EditRecord;
8
+use Filament\Support\Exceptions\Halt;
9
+use Illuminate\Database\Eloquent\Model;
10
+use Illuminate\Support\Facades\Auth;
8 11
 
9 12
 class EditAccount extends EditRecord
10 13
 {
@@ -30,4 +33,18 @@ class EditAccount extends EditRecord
30 33
 
31 34
         return $data;
32 35
     }
36
+
37
+    /**
38
+     * @throws Halt
39
+     */
40
+    protected function handleRecordUpdate(Model $record, array $data): Model
41
+    {
42
+        $user = Auth::user();
43
+
44
+        if (! $user) {
45
+            throw new Halt('No authenticated user found');
46
+        }
47
+
48
+        return $this->handleRecordUpdateWithUniqueField($record, $data, $user);
49
+    }
33 50
 }

+ 15
- 64
app/Filament/Forms/Components/DateRangeSelect.php Wyświetl plik

@@ -2,36 +2,35 @@
2 2
 
3 3
 namespace App\Filament\Forms\Components;
4 4
 
5
-use App\Facades\Accounting;
6
-use App\Models\Company;
7
-use Carbon\CarbonPeriod;
5
+use App\Services\DateRangeService;
8 6
 use Filament\Forms\Components\Select;
9 7
 use Filament\Forms\Set;
10 8
 use Illuminate\Support\Carbon;
11 9
 
12 10
 class DateRangeSelect extends Select
13 11
 {
14
-    public string $fiscalYearStartDate = '';
12
+    public string $fiscalYearStartDate;
15 13
 
16
-    public string $fiscalYearEndDate = '';
14
+    public string $fiscalYearEndDate;
17 15
 
18
-    public string $startDateField = '';
16
+    public ?string $startDateField = null;
19 17
 
20
-    public string $endDateField = '';
21
-
22
-    public Company $company;
18
+    public ?string $endDateField = null;
23 19
 
24 20
     protected function setUp(): void
25 21
     {
26 22
         parent::setUp();
27 23
 
28
-        $this->company = auth()->user()->currentCompany;
29
-        $this->fiscalYearStartDate = $this->company->locale->fiscalYearStartDate();
30
-        $this->fiscalYearEndDate = $this->company->locale->fiscalYearEndDate();
24
+        $company = auth()->user()->currentCompany;
25
+        $this->fiscalYearStartDate = $company->locale->fiscalYearStartDate();
26
+        $this->fiscalYearEndDate = $company->locale->fiscalYearEndDate();
31 27
 
32
-        $this->options($this->getDateRangeOptions())
28
+        $this->options(app(DateRangeService::class)->getDateRangeOptions())
29
+            ->live()
33 30
             ->afterStateUpdated(function ($state, Set $set) {
34
-                $this->updateDateRange($state, $set);
31
+                if ($this->startDateField && $this->endDateField) {
32
+                    $this->updateDateRange($state, $set);
33
+                }
35 34
             });
36 35
     }
37 36
 
@@ -49,54 +48,6 @@ class DateRangeSelect extends Select
49 48
         return $this;
50 49
     }
51 50
 
52
-    public function getDateRangeOptions(): array
53
-    {
54
-        $earliestDate = Carbon::parse(Accounting::getEarliestTransactionDate());
55
-        $currentDate = now();
56
-        $fiscalYearStartCurrent = Carbon::parse($this->fiscalYearStartDate);
57
-
58
-        $options = [
59
-            'Fiscal Year' => [],
60
-            'Fiscal Quarter' => [],
61
-            'Calendar Year' => [],
62
-            'Calendar Quarter' => [],
63
-            'Month' => [],
64
-            'Custom' => [],
65
-        ];
66
-
67
-        $period = CarbonPeriod::create($earliestDate, '1 month', $currentDate);
68
-
69
-        foreach ($period as $date) {
70
-            $options['Fiscal Year']['FY-' . $date->year] = $date->year;
71
-
72
-            $fiscalYearStart = $fiscalYearStartCurrent->copy()->subYears($currentDate->year - $date->year);
73
-
74
-            for ($i = 0; $i < 4; $i++) {
75
-                $quarterNumber = $i + 1;
76
-                $quarterStart = $fiscalYearStart->copy()->addMonths(($quarterNumber - 1) * 3);
77
-                $quarterEnd = $quarterStart->copy()->addMonths(3)->subDay();
78
-
79
-                if ($quarterStart->lessThanOrEqualTo($currentDate) && $quarterEnd->greaterThanOrEqualTo($earliestDate)) {
80
-                    $options['Fiscal Quarter']['FQ-' . $quarterNumber . '-' . $date->year] = 'Q' . $quarterNumber . ' ' . $date->year;
81
-                }
82
-            }
83
-
84
-            $options['Calendar Year']['Y-' . $date->year] = $date->year;
85
-            $quarterKey = 'Q-' . $date->quarter . '-' . $date->year;
86
-            $options['Calendar Quarter'][$quarterKey] = 'Q' . $date->quarter . ' ' . $date->year;
87
-            $options['Month']['M-' . $date->format('Y-m')] = $date->format('F Y');
88
-            $options['Custom']['Custom'] = 'Custom';
89
-        }
90
-
91
-        $options['Fiscal Year'] = array_reverse($options['Fiscal Year'], true);
92
-        $options['Fiscal Quarter'] = array_reverse($options['Fiscal Quarter'], true);
93
-        $options['Calendar Year'] = array_reverse($options['Calendar Year'], true);
94
-        $options['Calendar Quarter'] = array_reverse($options['Calendar Quarter'], true);
95
-        $options['Month'] = array_reverse($options['Month'], true);
96
-
97
-        return $options;
98
-    }
99
-
100 51
     public function updateDateRange($state, Set $set): void
101 52
     {
102 53
         if ($state === null) {
@@ -165,7 +116,7 @@ class DateRangeSelect extends Select
165 116
 
166 117
     public function setDateRange(Carbon $start, Carbon $end, Set $set): void
167 118
     {
168
-        $set($this->startDateField, $start->format('Y-m-d'));
169
-        $set($this->endDateField, $end->isFuture() ? now()->format('Y-m-d') : $end->format('Y-m-d'));
119
+        $set($this->startDateField, $start->startOfDay()->toDateTimeString());
120
+        $set($this->endDateField, $end->isFuture() ? now()->endOfDay()->toDateTimeString() : $end->endOfDay()->toDateTimeString());
170 121
     }
171 122
 }

+ 114
- 0
app/Filament/Tables/Actions/ReplicateBulkAction.php Wyświetl plik

@@ -0,0 +1,114 @@
1
+<?php
2
+
3
+namespace App\Filament\Tables\Actions;
4
+
5
+use Closure;
6
+use Filament\Actions\Concerns\CanReplicateRecords;
7
+use Filament\Actions\Contracts\ReplicatesRecords;
8
+use Filament\Tables\Actions\BulkAction;
9
+use Illuminate\Database\Eloquent\Collection;
10
+use Illuminate\Database\Eloquent\Model;
11
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
12
+use Illuminate\Database\Eloquent\Relations\HasMany;
13
+use Illuminate\Database\Eloquent\Relations\HasOne;
14
+
15
+class ReplicateBulkAction extends BulkAction implements ReplicatesRecords
16
+{
17
+    use CanReplicateRecords;
18
+
19
+    protected ?Closure $afterReplicaSaved = null;
20
+
21
+    protected array $relationshipsToReplicate = [];
22
+
23
+    public static function getDefaultName(): ?string
24
+    {
25
+        return 'replicate';
26
+    }
27
+
28
+    protected function setUp(): void
29
+    {
30
+        parent::setUp();
31
+
32
+        $this->label(__('Replicate Selected'));
33
+
34
+        $this->modalHeading(fn (): string => __('Replicate selected :label', ['label' => $this->getPluralModelLabel()]));
35
+
36
+        $this->modalSubmitActionLabel(__('Replicate'));
37
+
38
+        $this->successNotificationTitle(__('Replicated'));
39
+
40
+        $this->icon('heroicon-m-square-3-stack-3d');
41
+
42
+        $this->requiresConfirmation();
43
+
44
+        $this->modalIcon('heroicon-o-square-3-stack-3d');
45
+
46
+        $this->action(function () {
47
+            $result = $this->process(function (Collection $records) {
48
+                $records->each(function (Model $record) {
49
+                    $this->replica = $record->replicate($this->getExcludedAttributes());
50
+
51
+                    $this->replica->fill($record->attributesToArray());
52
+
53
+                    $this->callBeforeReplicaSaved();
54
+
55
+                    $this->replica->save();
56
+
57
+                    $this->replicateRelationships($record, $this->replica);
58
+
59
+                    $this->callAfterReplicaSaved($record, $this->replica);
60
+                });
61
+            });
62
+
63
+            try {
64
+                return $result;
65
+            } finally {
66
+                $this->success();
67
+            }
68
+        });
69
+    }
70
+
71
+    public function replicateRelationships(Model $original, Model $replica): void
72
+    {
73
+        foreach ($this->relationshipsToReplicate as $relationship) {
74
+            $relation = $original->$relationship();
75
+
76
+            if ($relation instanceof BelongsToMany) {
77
+                $replica->$relationship()->sync($relation->pluck($relation->getRelated()->getKeyName()));
78
+            } elseif ($relation instanceof HasMany) {
79
+                $relation->each(function (Model $related) use ($replica, $relationship) {
80
+                    $relatedReplica = $related->replicate($this->getExcludedAttributes());
81
+                    $relatedReplica->{$replica->$relationship()->getForeignKeyName()} = $replica->getKey();
82
+                    $relatedReplica->save();
83
+                });
84
+            } elseif ($relation instanceof HasOne && $relation->exists()) {
85
+                $related = $relation->first();
86
+                $relatedReplica = $related->replicate($this->getExcludedAttributes());
87
+                $relatedReplica->{$replica->$relationship()->getForeignKeyName()} = $replica->getKey();
88
+                $relatedReplica->save();
89
+            }
90
+        }
91
+    }
92
+
93
+    public function withReplicatedRelationships(array $relationships): static
94
+    {
95
+        $this->relationshipsToReplicate = $relationships;
96
+
97
+        return $this;
98
+    }
99
+
100
+    public function afterReplicaSaved(Closure $callback): static
101
+    {
102
+        $this->afterReplicaSaved = $callback;
103
+
104
+        return $this;
105
+    }
106
+
107
+    public function callAfterReplicaSaved(Model $original, Model $replica): void
108
+    {
109
+        $this->evaluate($this->afterReplicaSaved, [
110
+            'original' => $original,
111
+            'replica' => $replica,
112
+        ]);
113
+    }
114
+}

+ 35
- 26
app/Listeners/ConfigureCompanyDefault.php Wyświetl plik

@@ -26,57 +26,66 @@ class ConfigureCompanyDefault
26 26
     public function handle(CompanyConfigured $event): void
27 27
     {
28 28
         $company = $event->company;
29
+
30
+        session([
31
+            'current_company_id' => $company->id,
32
+            'default_language' => $company->locale->language ?? config('transmatic.source_locale'),
33
+            'default_timezone' => $company->locale->timezone ?? config('app.timezone'),
34
+            'default_pagination_page_option' => $company->appearance->records_per_page->value ?? RecordsPerPage::DEFAULT,
35
+            'default_sort' => $company->appearance->table_sort_direction->value ?? TableSortDirection::DEFAULT,
36
+            'default_primary_color' => $company->appearance->primary_color->value ?? PrimaryColor::DEFAULT,
37
+            'default_font' => $company->appearance->font->value ?? Font::DEFAULT,
38
+            'default_date_format' => $company->locale->date_format->value ?? DateFormat::DEFAULT,
39
+            'default_week_start' => $company->locale->week_start->value ?? WeekStart::DEFAULT,
40
+        ]);
41
+
42
+        app()->setLocale(session('default_language'));
43
+        locale_set_default(session('default_language'));
44
+        config(['app.timezone' => session('default_timezone')]);
45
+        date_default_timezone_set(session('default_timezone'));
46
+
29 47
         $paginationPageOptions = RecordsPerPage::caseValues();
30
-        $defaultPaginationPageOption = $company->appearance->records_per_page->value ?? RecordsPerPage::DEFAULT;
31
-        $defaultSort = $company->appearance->table_sort_direction->value ?? TableSortDirection::DEFAULT;
32
-        $defaultPrimaryColor = $company->appearance->primary_color ?? PrimaryColor::from(PrimaryColor::DEFAULT);
33
-        $defaultFont = $company->appearance->font->value ?? Font::DEFAULT;
34
-        $default_language = $company->locale->language ?? config('transmatic.source_locale');
35
-        $defaultTimezone = $company->locale->timezone ?? config('app.timezone');
36
-        $dateFormat = $company->locale->date_format->value ?? DateFormat::DEFAULT;
37
-        $weekStart = $company->locale->week_start->value ?? WeekStart::DEFAULT;
38
-
39
-        app()->setLocale($default_language);
40
-        locale_set_default($default_language);
41
-        config(['app.timezone' => $defaultTimezone]);
42
-        date_default_timezone_set($defaultTimezone);
43
-
44
-        Table::configureUsing(static function (Table $table) use ($paginationPageOptions, $defaultSort, $defaultPaginationPageOption): void {
48
+
49
+        Table::configureUsing(static function (Table $table) use ($paginationPageOptions): void {
45 50
 
46 51
             $table
47 52
                 ->paginationPageOptions($paginationPageOptions)
48
-                ->defaultSort(column: 'id', direction: $defaultSort)
49
-                ->defaultPaginationPageOption($defaultPaginationPageOption);
53
+                ->defaultSort(column: 'id', direction: session('default_sort'))
54
+                ->defaultPaginationPageOption(session('default_pagination_page_option'));
50 55
         }, isImportant: true);
51 56
 
52 57
         FilamentColor::register([
53
-            'primary' => $defaultPrimaryColor->getColor(),
58
+            'primary' => PrimaryColor::from(session('default_primary_color'))->getColor(),
54 59
         ]);
55 60
 
56 61
         Filament::getPanel('company')
57
-            ->font($defaultFont)
62
+            ->font(session('default_font'))
58 63
             ->brandName($company->name);
59 64
 
60
-        DatePicker::configureUsing(static function (DatePicker $component) use ($dateFormat, $weekStart) {
65
+        DatePicker::configureUsing(static function (DatePicker $component) {
61 66
             $component
62
-                ->displayFormat($dateFormat)
63
-                ->firstDayOfWeek($weekStart);
67
+                ->displayFormat(session('default_date_format'))
68
+                ->firstDayOfWeek(session('default_week_start'));
64 69
         });
65 70
 
66 71
         Tab::configureUsing(static function (Tab $tab) {
67 72
             $label = $tab->getLabel();
68 73
 
69
-            $translatedLabel = translate($label);
74
+            if ($label) {
75
+                $translatedLabel = translate($label);
70 76
 
71
-            $tab->label(ucwords($translatedLabel));
77
+                $tab->label(ucwords($translatedLabel));
78
+            }
72 79
         }, isImportant: true);
73 80
 
74 81
         Section::configureUsing(static function (Section $section): void {
75 82
             $heading = $section->getHeading();
76 83
 
77
-            $translatedHeading = translate($heading);
84
+            if ($heading) {
85
+                $translatedHeading = translate($heading);
78 86
 
79
-            $section->heading(ucfirst($translatedHeading));
87
+                $section->heading(ucfirst($translatedHeading));
88
+            }
80 89
         }, isImportant: true);
81 90
 
82 91
         ResourcesTab::configureUsing(static function (ResourcesTab $tab): void {

+ 0
- 1
app/Listeners/SyncAssociatedModels.php Wyświetl plik

@@ -40,7 +40,6 @@ class SyncAssociatedModels
40 40
 
41 41
         $keyToMethodMap = [
42 42
             'bank_account_id' => 'bankAccount',
43
-            'currency_code' => 'currency',
44 43
             'sales_tax_id' => 'salesTax',
45 44
             'purchase_tax_id' => 'purchaseTax',
46 45
             'sales_discount_id' => 'salesDiscount',

+ 11
- 7
app/Models/Setting/Localization.php Wyświetl plik

@@ -83,19 +83,23 @@ class Localization extends Model
83 83
 
84 84
     public function fiscalYearStartDate(): string
85 85
     {
86
-        return Carbon::parse($this->fiscalYearEndDate())->subYear()->addDay()->toDateString();
86
+        return once(function () {
87
+            return Carbon::parse($this->fiscalYearEndDate())->subYear()->addDay()->toDateString();
88
+        });
87 89
     }
88 90
 
89 91
     public function fiscalYearEndDate(): string
90 92
     {
91
-        $today = now();
92
-        $fiscalYearEndThisYear = Carbon::createFromDate($today->year, $this->fiscal_year_end_month, $this->fiscal_year_end_day);
93
+        return once(function () {
94
+            $today = now();
95
+            $fiscalYearEndThisYear = Carbon::createFromDate($today->year, $this->fiscal_year_end_month, $this->fiscal_year_end_day);
93 96
 
94
-        if ($today->gt($fiscalYearEndThisYear)) {
95
-            return $fiscalYearEndThisYear->copy()->addYear()->toDateString();
96
-        }
97
+            if ($today->gt($fiscalYearEndThisYear)) {
98
+                return $fiscalYearEndThisYear->copy()->addYear()->toDateString();
99
+            }
97 100
 
98
-        return $fiscalYearEndThisYear->toDateString();
101
+            return $fiscalYearEndThisYear->toDateString();
102
+        });
99 103
     }
100 104
 
101 105
     public function getDateTimeFormatAttribute(): string

+ 2
- 1
app/Observers/TransactionObserver.php Wyświetl plik

@@ -17,7 +17,8 @@ class TransactionObserver
17 17
      */
18 18
     public function created(Transaction $transaction): void
19 19
     {
20
-        if ($transaction->type->isJournal()) {
20
+        // Additional check to avoid duplication during replication
21
+        if ($transaction->journalEntries()->exists() || $transaction->type->isJournal() || str_starts_with($transaction->description, '(Copy of)')) {
21 22
             return;
22 23
         }
23 24
 

+ 2
- 1
app/Providers/AppServiceProvider.php Wyświetl plik

@@ -4,6 +4,7 @@ namespace App\Providers;
4 4
 
5 5
 use App\Contracts\AccountHandler;
6 6
 use App\Services\AccountService;
7
+use App\Services\DateRangeService;
7 8
 use BezhanSalleh\PanelSwitch\PanelSwitch;
8 9
 use Filament\Notifications\Livewire\Notifications;
9 10
 use Filament\Support\Assets\Js;
@@ -25,7 +26,7 @@ class AppServiceProvider extends ServiceProvider
25 26
      */
26 27
     public function register(): void
27 28
     {
28
-        //
29
+        $this->app->singleton(DateRangeService::class);
29 30
     }
30 31
 
31 32
     /**

+ 23
- 1
app/Providers/MacroServiceProvider.php Wyświetl plik

@@ -11,6 +11,7 @@ use App\Utilities\Accounting\AccountCode;
11 11
 use App\Utilities\Currency\CurrencyAccessor;
12 12
 use BackedEnum;
13 13
 use Closure;
14
+use Filament\Forms\Components\DatePicker;
14 15
 use Filament\Forms\Components\Field;
15 16
 use Filament\Forms\Components\TextInput;
16 17
 use Filament\Tables\Columns\TextColumn;
@@ -54,7 +55,7 @@ class MacroServiceProvider extends ServiceProvider
54 55
             return $this;
55 56
         });
56 57
 
57
-        TextColumn::macro('localizeDate', function (): static {
58
+        TextColumn::macro('defaultDateFormat', function (): static {
58 59
             $localization = Localization::firstOrFail();
59 60
 
60 61
             $dateFormat = $localization->date_format->value ?? DateFormat::DEFAULT;
@@ -65,6 +66,18 @@ class MacroServiceProvider extends ServiceProvider
65 66
             return $this;
66 67
         });
67 68
 
69
+        DatePicker::macro('defaultDateFormat', function (): static {
70
+            $localization = Localization::firstOrFail();
71
+
72
+            $dateFormat = $localization->date_format->value ?? DateFormat::DEFAULT;
73
+            $timezone = $localization->timezone ?? Carbon::now()->timezoneName;
74
+
75
+            $this->displayFormat($dateFormat)
76
+                ->timezone($timezone);
77
+
78
+            return $this;
79
+        });
80
+
68 81
         TextColumn::macro('currency', function (string | Closure | null $currency = null, ?bool $convert = null): static {
69 82
             $this->formatStateUsing(static function (TextColumn $column, $state) use ($currency, $convert): ?string {
70 83
                 if (blank($state)) {
@@ -209,5 +222,14 @@ class MacroServiceProvider extends ServiceProvider
209 222
 
210 223
             return '';
211 224
         });
225
+
226
+        Carbon::macro('toDefaultDateFormat', function () {
227
+            $localization = Localization::firstOrFail();
228
+
229
+            $dateFormat = $localization->date_format->value ?? DateFormat::DEFAULT;
230
+            $timezone = $localization->timezone ?? Carbon::now()->timezoneName;
231
+
232
+            return $this->setTimezone($timezone)->format($dateFormat);
233
+        });
212 234
     }
213 235
 }

+ 2
- 4
app/Repositories/Accounting/JournalEntryRepository.php Wyświetl plik

@@ -4,7 +4,6 @@ namespace App\Repositories\Accounting;
4 4
 
5 5
 use App\Models\Accounting\Account;
6 6
 use Illuminate\Database\Eloquent\Builder;
7
-use Illuminate\Support\Carbon;
8 7
 
9 8
 class JournalEntryRepository
10 9
 {
@@ -13,9 +12,8 @@ class JournalEntryRepository
13 12
         $query = $account->journalEntries()->where('type', $type);
14 13
 
15 14
         if ($startDate && $endDate) {
16
-            $endOfDay = Carbon::parse($endDate)->endOfDay();
17
-            $query->whereHas('transaction', static function (Builder $query) use ($startDate, $endOfDay) {
18
-                $query->whereBetween('posted_at', [$startDate, $endOfDay]);
15
+            $query->whereHas('transaction', static function (Builder $query) use ($startDate, $endDate) {
16
+                $query->whereBetween('posted_at', [$startDate, $endDate]);
19 17
             });
20 18
         } elseif ($startDate) {
21 19
             $query->whereHas('transaction', static function (Builder $query) use ($startDate) {

+ 23
- 2
app/Scopes/CurrentCompanyScope.php Wyświetl plik

@@ -4,8 +4,10 @@ namespace App\Scopes;
4 4
 
5 5
 use Illuminate\Database\Eloquent\Builder;
6 6
 use Illuminate\Database\Eloquent\Model;
7
+use Illuminate\Database\Eloquent\ModelNotFoundException;
7 8
 use Illuminate\Database\Eloquent\Scope;
8 9
 use Illuminate\Support\Facades\Auth;
10
+use Illuminate\Support\Facades\Log;
9 11
 
10 12
 class CurrentCompanyScope implements Scope
11 13
 {
@@ -14,8 +16,27 @@ class CurrentCompanyScope implements Scope
14 16
      */
15 17
     public function apply(Builder $builder, Model $model): void
16 18
     {
17
-        if (Auth::check() && Auth::user()->currentCompany) {
18
-            $builder->where("{$model->getTable()}.company_id", Auth::user()->currentCompany->id);
19
+        if (app()->runningInConsole()) {
20
+            return;
21
+        }
22
+
23
+        $companyId = session('current_company_id');
24
+
25
+        if (! $companyId && Auth::check() && Auth::user()->currentCompany) {
26
+            $companyId = Auth::user()->currentCompany->id;
27
+            session(['current_company_id' => $companyId]);
28
+        }
29
+
30
+        if (! $companyId) {
31
+            $companyId = Auth::user()->currentCompany->id;
32
+        }
33
+
34
+        if ($companyId) {
35
+            $builder->where("{$model->getTable()}.company_id", $companyId);
36
+        } else {
37
+            Log::error('CurrentCompanyScope: No company_id found for user ' . Auth::id());
38
+
39
+            throw new ModelNotFoundException('CurrentCompanyScope: No company_id set in the session or on the user.');
19 40
         }
20 41
     }
21 42
 }

+ 117
- 53
app/Services/AccountService.php Wyświetl plik

@@ -10,6 +10,9 @@ use App\Models\Accounting\Transaction;
10 10
 use App\Repositories\Accounting\JournalEntryRepository;
11 11
 use App\Utilities\Currency\CurrencyAccessor;
12 12
 use App\ValueObjects\Money;
13
+use Closure;
14
+use Illuminate\Database\Eloquent\Builder;
15
+use Illuminate\Database\Query\JoinClause;
13 16
 use Illuminate\Support\Facades\DB;
14 17
 
15 18
 class AccountService implements AccountHandler
@@ -50,6 +53,118 @@ class AccountService implements AccountHandler
50 53
         return new Money($balances['starting_balance'], $account->currency_code);
51 54
     }
52 55
 
56
+    public function getTransactionDetailsSubquery(string $startDate, string $endDate): Closure
57
+    {
58
+        return static function ($query) use ($startDate, $endDate) {
59
+            $query->select(
60
+                'journal_entries.id',
61
+                'journal_entries.account_id',
62
+                'journal_entries.transaction_id',
63
+                'journal_entries.type',
64
+                'journal_entries.amount',
65
+                DB::raw('journal_entries.amount * IF(journal_entries.type = "debit", 1, -1) AS signed_amount')
66
+            )
67
+                ->whereBetween('transactions.posted_at', [$startDate, $endDate])
68
+                ->join('transactions', 'transactions.id', '=', 'journal_entries.transaction_id')
69
+                ->orderBy('transactions.posted_at')
70
+                ->with('transaction:id,type,description,posted_at');
71
+        };
72
+    }
73
+
74
+    public function getAccountBalances(string $startDate, string $endDate, array $accountIds = []): Builder
75
+    {
76
+        $accountIds = array_map('intval', $accountIds);
77
+
78
+        $query = Account::query()
79
+            ->select([
80
+                'accounts.id',
81
+                'accounts.name',
82
+                'accounts.category',
83
+                'accounts.subtype_id',
84
+                'accounts.currency_code',
85
+                'accounts.code',
86
+            ])
87
+            ->addSelect([
88
+                DB::raw("
89
+                    COALESCE(
90
+                        IF(accounts.category IN ('asset', 'expense'),
91
+                            SUM(IF(journal_entries.type = 'debit' AND transactions.posted_at < ?, journal_entries.amount, 0)) -
92
+                            SUM(IF(journal_entries.type = 'credit' AND transactions.posted_at < ?, journal_entries.amount, 0)),
93
+                            SUM(IF(journal_entries.type = 'credit' AND transactions.posted_at < ?, journal_entries.amount, 0)) -
94
+                            SUM(IF(journal_entries.type = 'debit' AND transactions.posted_at < ?, journal_entries.amount, 0))
95
+                        ), 0
96
+                    ) AS starting_balance
97
+                "),
98
+                DB::raw("
99
+                    COALESCE(SUM(
100
+                        IF(journal_entries.type = 'debit' AND transactions.posted_at BETWEEN ? AND ?, journal_entries.amount, 0)
101
+                    ), 0) AS total_debit
102
+                "),
103
+                DB::raw("
104
+                    COALESCE(SUM(
105
+                        IF(journal_entries.type = 'credit' AND transactions.posted_at BETWEEN ? AND ?, journal_entries.amount, 0)
106
+                    ), 0) AS total_credit
107
+                "),
108
+            ])
109
+            ->join('journal_entries', 'journal_entries.account_id', '=', 'accounts.id')
110
+            ->join('transactions', function (JoinClause $join) use ($endDate) {
111
+                $join->on('transactions.id', '=', 'journal_entries.transaction_id')
112
+                    ->where('transactions.posted_at', '<=', $endDate);
113
+            })
114
+            ->groupBy('accounts.id')
115
+            ->with(['subtype:id,name']);
116
+
117
+        if (! empty($accountIds)) {
118
+            $query->whereIn('accounts.id', $accountIds);
119
+        }
120
+
121
+        $query->addBinding([$startDate, $startDate, $startDate, $startDate, $startDate, $endDate, $startDate, $endDate], 'select');
122
+
123
+        return $query;
124
+    }
125
+
126
+    public function getTotalBalanceForAllBankAccounts(string $startDate, string $endDate): Money
127
+    {
128
+        $accountIds = Account::whereHas('bankAccount')
129
+            ->pluck('id')
130
+            ->toArray();
131
+
132
+        if (empty($accountIds)) {
133
+            return new Money(0, CurrencyAccessor::getDefaultCurrency());
134
+        }
135
+
136
+        $result = DB::table('journal_entries')
137
+            ->join('transactions', function (JoinClause $join) use ($endDate) {
138
+                $join->on('transactions.id', '=', 'journal_entries.transaction_id')
139
+                    ->where('transactions.posted_at', '<=', $endDate);
140
+            })
141
+            ->whereIn('journal_entries.account_id', $accountIds)
142
+            ->selectRaw('
143
+            SUM(CASE
144
+                WHEN transactions.posted_at < ? AND journal_entries.type = "debit" THEN journal_entries.amount
145
+                WHEN transactions.posted_at < ? AND journal_entries.type = "credit" THEN -journal_entries.amount
146
+                ELSE 0
147
+            END) AS totalStartingBalance,
148
+            SUM(CASE
149
+                WHEN transactions.posted_at BETWEEN ? AND ? AND journal_entries.type = "debit" THEN journal_entries.amount
150
+                WHEN transactions.posted_at BETWEEN ? AND ? AND journal_entries.type = "credit" THEN -journal_entries.amount
151
+                ELSE 0
152
+            END) AS totalNetMovement
153
+        ', [
154
+                $startDate,
155
+                $startDate,
156
+                $startDate,
157
+                $endDate,
158
+                $startDate,
159
+                $endDate,
160
+            ])
161
+            ->first();
162
+
163
+        $totalBalance = $result->totalStartingBalance + $result->totalNetMovement;
164
+
165
+        return new Money($totalBalance, CurrencyAccessor::getDefaultCurrency());
166
+    }
167
+
53 168
     public function getEndingBalance(Account $account, string $startDate, string $endDate): ?Money
54 169
     {
55 170
         $calculatedBalances = $this->calculateBalances($account, $startDate, $endDate);
@@ -73,7 +188,7 @@ class AccountService implements AccountHandler
73 188
         })
74 189
             ->where('type', 'credit')
75 190
             ->whereHas('transaction', static function ($query) use ($startDate) {
76
-                $query->where('posted_at', '<=', $startDate);
191
+                $query->where('posted_at', '<', $startDate);
77 192
             })
78 193
             ->sum('amount');
79 194
 
@@ -82,7 +197,7 @@ class AccountService implements AccountHandler
82 197
         })
83 198
             ->where('type', 'debit')
84 199
             ->whereHas('transaction', static function ($query) use ($startDate) {
85
-                $query->where('posted_at', '<=', $startDate);
200
+                $query->where('posted_at', '<', $startDate);
86 201
             })
87 202
             ->sum('amount');
88 203
 
@@ -149,57 +264,6 @@ class AccountService implements AccountHandler
149 264
         return array_filter($balances, static fn ($value) => $value !== null);
150 265
     }
151 266
 
152
-    public function getTotalBalanceForAllBankAccounts(string $startDate, string $endDate): Money
153
-    {
154
-        $accountIds = Account::whereHas('bankAccount')
155
-            ->pluck('id')
156
-            ->toArray();
157
-
158
-        if (empty($accountIds)) {
159
-            return new Money(0, CurrencyAccessor::getDefaultCurrency());
160
-        }
161
-
162
-        $result = DB::table('journal_entries')
163
-            ->join('transactions', 'journal_entries.transaction_id', '=', 'transactions.id')
164
-            ->whereIn('journal_entries.account_id', $accountIds)
165
-            ->where('transactions.posted_at', '<=', $endDate)
166
-            ->selectRaw('
167
-            SUM(CASE
168
-                WHEN transactions.posted_at <= ? AND journal_entries.type = "debit" THEN journal_entries.amount
169
-                WHEN transactions.posted_at <= ? AND journal_entries.type = "credit" THEN -journal_entries.amount
170
-                ELSE 0
171
-            END) AS totalStartingBalance,
172
-            SUM(CASE
173
-                WHEN transactions.posted_at BETWEEN ? AND ? AND journal_entries.type = "debit" THEN journal_entries.amount
174
-                WHEN transactions.posted_at BETWEEN ? AND ? AND journal_entries.type = "credit" THEN -journal_entries.amount
175
-                ELSE 0
176
-            END) AS totalNetMovement
177
-        ', [
178
-                $startDate,
179
-                $startDate,
180
-                $startDate,
181
-                $endDate,
182
-                $startDate,
183
-                $endDate,
184
-            ])
185
-            ->first();
186
-
187
-        $totalBalance = $result->totalStartingBalance + $result->totalNetMovement;
188
-
189
-        return new Money($totalBalance, CurrencyAccessor::getDefaultCurrency());
190
-    }
191
-
192
-    public function getAccountCategoryOrder(): array
193
-    {
194
-        return [
195
-            AccountCategory::Asset->getPluralLabel(),
196
-            AccountCategory::Liability->getPluralLabel(),
197
-            AccountCategory::Equity->getPluralLabel(),
198
-            AccountCategory::Revenue->getPluralLabel(),
199
-            AccountCategory::Expense->getPluralLabel(),
200
-        ];
201
-    }
202
-
203 267
     public function getEarliestTransactionDate(): string
204 268
     {
205 269
         $earliestDate = Transaction::oldest('posted_at')

+ 145
- 0
app/Services/DateRangeService.php Wyświetl plik

@@ -0,0 +1,145 @@
1
+<?php
2
+
3
+namespace App\Services;
4
+
5
+use App\Facades\Accounting;
6
+use Carbon\CarbonPeriod;
7
+use Illuminate\Support\Carbon;
8
+
9
+class DateRangeService
10
+{
11
+    protected string $fiscalYearStartDate = '';
12
+
13
+    protected string $fiscalYearEndDate = '';
14
+
15
+    public function __construct()
16
+    {
17
+        $company = auth()->user()->currentCompany;
18
+        $this->fiscalYearStartDate = $company->locale->fiscalYearStartDate();
19
+        $this->fiscalYearEndDate = $company->locale->fiscalYearEndDate();
20
+    }
21
+
22
+    public function getDateRangeOptions(): array
23
+    {
24
+        return once(function () {
25
+            return $this->generateDateRangeOptions();
26
+        });
27
+    }
28
+
29
+    private function generateDateRangeOptions(): array
30
+    {
31
+        $earliestDate = Carbon::parse(Accounting::getEarliestTransactionDate());
32
+        $currentDate = now();
33
+        $fiscalYearStartCurrent = Carbon::parse($this->fiscalYearStartDate);
34
+
35
+        $options = [
36
+            'Fiscal Year' => [],
37
+            'Fiscal Quarter' => [],
38
+            'Calendar Year' => [],
39
+            'Calendar Quarter' => [],
40
+            'Month' => [],
41
+            'Custom' => [],
42
+        ];
43
+
44
+        $period = CarbonPeriod::create($earliestDate, '1 month', $currentDate);
45
+
46
+        foreach ($period as $date) {
47
+            $options['Fiscal Year']['FY-' . $date->year] = $date->year;
48
+
49
+            $fiscalYearStart = $fiscalYearStartCurrent->copy()->subYears($currentDate->year - $date->year);
50
+
51
+            for ($i = 0; $i < 4; $i++) {
52
+                $quarterNumber = $i + 1;
53
+                $quarterStart = $fiscalYearStart->copy()->addMonths(($quarterNumber - 1) * 3);
54
+                $quarterEnd = $quarterStart->copy()->addMonths(3)->subDay();
55
+
56
+                if ($quarterStart->lessThanOrEqualTo($currentDate) && $quarterEnd->greaterThanOrEqualTo($earliestDate)) {
57
+                    $options['Fiscal Quarter']['FQ-' . $quarterNumber . '-' . $date->year] = 'Q' . $quarterNumber . ' ' . $date->year;
58
+                }
59
+            }
60
+
61
+            $options['Calendar Year']['Y-' . $date->year] = $date->year;
62
+            $quarterKey = 'Q-' . $date->quarter . '-' . $date->year;
63
+            $options['Calendar Quarter'][$quarterKey] = 'Q' . $date->quarter . ' ' . $date->year;
64
+            $options['Month']['M-' . $date->format('Y-m')] = $date->format('F Y');
65
+            $options['Custom']['Custom'] = 'Custom';
66
+        }
67
+
68
+        $options['Fiscal Year'] = array_reverse($options['Fiscal Year'], true);
69
+        $options['Fiscal Quarter'] = array_reverse($options['Fiscal Quarter'], true);
70
+        $options['Calendar Year'] = array_reverse($options['Calendar Year'], true);
71
+        $options['Calendar Quarter'] = array_reverse($options['Calendar Quarter'], true);
72
+        $options['Month'] = array_reverse($options['Month'], true);
73
+
74
+        return $options;
75
+    }
76
+
77
+    public function getMatchingDateRangeOption(Carbon $startDate, Carbon $endDate): string
78
+    {
79
+        $options = $this->getDateRangeOptions();
80
+
81
+        foreach ($options as $type => $ranges) {
82
+            foreach ($ranges as $key => $label) {
83
+                [$expectedStart, $expectedEnd] = $this->getExpectedDateRange($type, $key);
84
+
85
+                if ($expectedStart === null || $expectedEnd === null) {
86
+                    continue;
87
+                }
88
+
89
+                $expectedEnd = $expectedEnd->isFuture() ? now()->startOfDay() : $expectedEnd;
90
+
91
+                if ($startDate->eq($expectedStart) && $endDate->eq($expectedEnd)) {
92
+                    return $key; // Return the matching range key (e.g., "FY-2024")
93
+                }
94
+            }
95
+        }
96
+
97
+        return 'Custom'; // Return "Custom" if no matching range is found
98
+    }
99
+
100
+    private function getExpectedDateRange(string $type, string $key): array
101
+    {
102
+        switch ($type) {
103
+            case 'Fiscal Year':
104
+                $year = (int) substr($key, 3);
105
+                $start = Carbon::parse($this->fiscalYearStartDate)->subYears(now()->year - $year)->startOfDay();
106
+                $end = Carbon::parse($this->fiscalYearEndDate)->subYears(now()->year - $year)->startOfDay();
107
+
108
+                break;
109
+
110
+            case 'Fiscal Quarter':
111
+                [$quarter, $year] = explode('-', substr($key, 3));
112
+                $start = Carbon::parse($this->fiscalYearStartDate)->subYears(now()->year - $year)->addMonths(($quarter - 1) * 3)->startOfDay();
113
+                $end = $start->copy()->addMonths(3)->subDay()->startOfDay();
114
+
115
+                break;
116
+
117
+            case 'Calendar Year':
118
+                $year = (int) substr($key, 2);
119
+                $start = Carbon::createFromDate($year)->startOfYear()->startOfDay();
120
+                $end = Carbon::createFromDate($year)->endOfYear()->startOfDay();
121
+
122
+                break;
123
+
124
+            case 'Calendar Quarter':
125
+                [$quarter, $year] = explode('-', substr($key, 2));
126
+                $month = ($quarter - 1) * 3 + 1;
127
+                $start = Carbon::createFromDate($year, $month, 1)->startOfDay();
128
+                $end = $start->copy()->endOfQuarter()->startOfDay();
129
+
130
+                break;
131
+
132
+            case 'Month':
133
+                $yearMonth = substr($key, 2);
134
+                $start = Carbon::parse($yearMonth)->startOfMonth()->startOfDay();
135
+                $end = Carbon::parse($yearMonth)->endOfMonth()->startOfDay();
136
+
137
+                break;
138
+
139
+            default:
140
+                return [null, null];
141
+        }
142
+
143
+        return [$start, $end];
144
+    }
145
+}

+ 40
- 12
app/Services/ExportService.php Wyświetl plik

@@ -4,18 +4,20 @@ namespace App\Services;
4 4
 
5 5
 use App\Contracts\ExportableReport;
6 6
 use App\Models\Company;
7
+use App\Support\Column;
7 8
 use Barryvdh\Snappy\Facades\SnappyPdf;
9
+use Carbon\Exceptions\InvalidFormatException;
8 10
 use Illuminate\Support\Carbon;
9 11
 use Symfony\Component\HttpFoundation\StreamedResponse;
10 12
 
11 13
 class ExportService
12 14
 {
13
-    public function exportToCsv(Company $company, ExportableReport $report, string $startDate, string $endDate, bool $separateCategoryHeaders = false): StreamedResponse
15
+    public function exportToCsv(Company $company, ExportableReport $report, string $startDate, string $endDate): StreamedResponse
14 16
     {
15
-        $formattedStartDate = Carbon::parse($startDate)->format('Y-m-d');
16
-        $formattedEndDate = Carbon::parse($endDate)->format('Y-m-d');
17
+        $formattedStartDate = Carbon::parse($startDate)->toDateString();
18
+        $formattedEndDate = Carbon::parse($endDate)->toDateString();
17 19
 
18
-        $timestamp = Carbon::now()->format('Y-m-d-H_i');
20
+        $timestamp = Carbon::now()->format('Y-m-d_H-i-s');
19 21
 
20 22
         $filename = $company->name . ' ' . $report->getTitle() . ' ' . $formattedStartDate . ' to ' . $formattedEndDate . ' ' . $timestamp . '.csv';
21 23
 
@@ -24,12 +26,15 @@ class ExportService
24 26
             'Content-Disposition' => 'attachment; filename="' . $filename . '"',
25 27
         ];
26 28
 
27
-        $callback = function () use ($report, $company, $formattedStartDate, $formattedEndDate) {
29
+        $callback = function () use ($startDate, $endDate, $report, $company) {
28 30
             $file = fopen('php://output', 'wb');
29 31
 
32
+            $defaultStartDateFormat = Carbon::parse($startDate)->toDefaultDateFormat();
33
+            $defaultEndDateFormat = Carbon::parse($endDate)->toDefaultDateFormat();
34
+
30 35
             fputcsv($file, [$report->getTitle()]);
31 36
             fputcsv($file, [$company->name]);
32
-            fputcsv($file, ['Date Range: ' . $formattedStartDate . ' to ' . $formattedEndDate]);
37
+            fputcsv($file, ['Date Range: ' . $defaultStartDateFormat . ' to ' . $defaultEndDateFormat]);
33 38
             fputcsv($file, []);
34 39
 
35 40
             fputcsv($file, $report->getHeaders());
@@ -44,7 +49,30 @@ class ExportService
44 49
                 }
45 50
 
46 51
                 foreach ($category->data as $accountRow) {
47
-                    fputcsv($file, $accountRow);
52
+                    $row = [];
53
+                    $columns = $report->getColumns();
54
+
55
+                    /**
56
+                     * @var Column $column
57
+                     */
58
+                    foreach ($columns as $index => $column) {
59
+                        $cell = $accountRow[$index] ?? '';
60
+
61
+                        if ($column->isDate()) {
62
+                            try {
63
+                                $row[] = Carbon::parse($cell)->toDateString();
64
+                            } catch (InvalidFormatException) {
65
+                                $row[] = $cell;
66
+                            }
67
+                        } elseif (is_array($cell)) {
68
+                            // Handle array cells by extracting 'name' or 'description'
69
+                            $row[] = $cell['name'] ?? $cell['description'] ?? '';
70
+                        } else {
71
+                            $row[] = $cell;
72
+                        }
73
+                    }
74
+
75
+                    fputcsv($file, $row);
48 76
                 }
49 77
 
50 78
                 if (filled($category->summary)) {
@@ -66,18 +94,18 @@ class ExportService
66 94
 
67 95
     public function exportToPdf(Company $company, ExportableReport $report, string $startDate, string $endDate): StreamedResponse
68 96
     {
69
-        $formattedStartDate = Carbon::parse($startDate)->format('Y-m-d');
70
-        $formattedEndDate = Carbon::parse($endDate)->format('Y-m-d');
97
+        $formattedStartDate = Carbon::parse($startDate)->toDateString();
98
+        $formattedEndDate = Carbon::parse($endDate)->toDateString();
71 99
 
72
-        $timestamp = Carbon::now()->format('Y-m-d-H_i');
100
+        $timestamp = Carbon::now()->format('Y-m-d_H-i-s');
73 101
 
74 102
         $filename = $company->name . ' ' . $report->getTitle() . ' ' . $formattedStartDate . ' to ' . $formattedEndDate . ' ' . $timestamp . '.pdf';
75 103
 
76 104
         $pdf = SnappyPdf::loadView($report->getPdfView(), [
77 105
             'company' => $company,
78 106
             'report' => $report,
79
-            'startDate' => Carbon::parse($startDate)->format('M d, Y'),
80
-            'endDate' => Carbon::parse($endDate)->format('M d, Y'),
107
+            'startDate' => Carbon::parse($startDate)->toDefaultDateFormat(),
108
+            'endDate' => Carbon::parse($endDate)->toDefaultDateFormat(),
81 109
         ]);
82 110
 
83 111
         return response()->streamDownload(function () use ($pdf) {

+ 238
- 142
app/Services/ReportService.php Wyświetl plik

@@ -11,9 +11,8 @@ use App\Enums\Accounting\AccountCategory;
11 11
 use App\Models\Accounting\Account;
12 12
 use App\Support\Column;
13 13
 use App\Utilities\Currency\CurrencyAccessor;
14
-use Illuminate\Database\Eloquent\Builder;
15
-use Illuminate\Database\Eloquent\Relations\Relation;
16
-use Illuminate\Support\Collection;
14
+use App\ValueObjects\Money;
15
+use Illuminate\Support\Carbon;
17 16
 
18 17
 class ReportService
19 18
 {
@@ -38,79 +37,128 @@ class ReportService
38 37
         );
39 38
     }
40 39
 
41
-    private function filterBalances(array $balances, array $fields): array
40
+    public function buildAccountBalanceReport(string $startDate, string $endDate, array $columns = []): ReportDTO
42 41
     {
43
-        return array_filter($balances, static fn ($key) => in_array($key, $fields, true), ARRAY_FILTER_USE_KEY);
42
+        $orderedCategories = AccountCategory::getOrderedCategories();
43
+
44
+        $accounts = $this->accountService->getAccountBalances($startDate, $endDate)->get();
45
+
46
+        $columnNameKeys = array_map(fn (Column $column) => $column->getName(), $columns);
47
+
48
+        $accountCategories = [];
49
+        $reportTotalBalances = [];
50
+
51
+        foreach ($orderedCategories as $category) {
52
+            $accountsInCategory = $accounts->where('category', $category)
53
+                ->sortBy('code', SORT_NATURAL);
54
+
55
+            $relevantFields = array_intersect($category->getRelevantBalanceFields(), $columnNameKeys);
56
+
57
+            $categorySummaryBalances = array_fill_keys($relevantFields, 0);
58
+
59
+            $categoryAccounts = [];
60
+
61
+            /** @var Account $account */
62
+            foreach ($accountsInCategory as $account) {
63
+                $accountBalances = $this->calculateAccountBalances($account, $category);
64
+
65
+                foreach ($relevantFields as $field) {
66
+                    $categorySummaryBalances[$field] += $accountBalances[$field];
67
+                }
68
+
69
+                $formattedAccountBalances = $this->formatBalances($accountBalances);
70
+
71
+                $categoryAccounts[] = new AccountDTO(
72
+                    $account->name,
73
+                    $account->code,
74
+                    $account->id,
75
+                    $formattedAccountBalances,
76
+                    Carbon::parse($startDate)->toDateString(),
77
+                    Carbon::parse($endDate)->toDateString(),
78
+                );
79
+            }
80
+
81
+            foreach ($relevantFields as $field) {
82
+                $reportTotalBalances[$field] = ($reportTotalBalances[$field] ?? 0) + $categorySummaryBalances[$field];
83
+            }
84
+
85
+            $formattedCategorySummaryBalances = $this->formatBalances($categorySummaryBalances);
86
+
87
+            $accountCategories[$category->getPluralLabel()] = new AccountCategoryDTO(
88
+                $categoryAccounts,
89
+                $formattedCategorySummaryBalances,
90
+            );
91
+        }
92
+
93
+        $formattedReportTotalBalances = $this->formatBalances($reportTotalBalances);
94
+
95
+        return new ReportDTO($accountCategories, $formattedReportTotalBalances, $columns);
44 96
     }
45 97
 
46
-    private function getCategoryGroupedAccounts(array $allCategories): Collection
98
+    private function calculateAccountBalances(Account $account, AccountCategory $category): array
47 99
     {
48
-        return Account::whereHas('journalEntries')
49
-            ->select(['id', 'name', 'currency_code', 'category', 'code'])
50
-            ->get()
51
-            ->groupBy(fn (Account $account) => $account->category->getPluralLabel())
52
-            ->sortBy(static fn (Collection $groupedAccounts, string $key) => array_search($key, $allCategories, true));
100
+        $balances = [
101
+            'debit_balance' => $account->total_debit ?? 0,
102
+            'credit_balance' => $account->total_credit ?? 0,
103
+        ];
104
+
105
+        if ($category->isNormalDebitBalance()) {
106
+            $balances['net_movement'] = $balances['debit_balance'] - $balances['credit_balance'];
107
+        } else {
108
+            $balances['net_movement'] = $balances['credit_balance'] - $balances['debit_balance'];
109
+        }
110
+
111
+        if ($category->isReal()) {
112
+            $balances['starting_balance'] = $account->starting_balance ?? 0;
113
+            $balances['ending_balance'] = $balances['starting_balance'] + $balances['net_movement'];
114
+        }
115
+
116
+        return $balances;
53 117
     }
54 118
 
55
-    public function buildAccountBalanceReport(string $startDate, string $endDate, array $columns = []): ReportDTO
119
+    public function calculateRetainedEarnings(string $startDate): Money
56 120
     {
57
-        $allCategories = $this->accountService->getAccountCategoryOrder();
121
+        $modifiedStartDate = Carbon::parse($this->accountService->getEarliestTransactionDate())->startOfYear()->toDateTimeString();
122
+        $endDate = Carbon::parse($startDate)->subYear()->endOfYear()->toDateTimeString();
58 123
 
59
-        $categoryGroupedAccounts = $this->getCategoryGroupedAccounts($allCategories);
124
+        $revenueAccounts = $this->accountService->getAccountBalances($modifiedStartDate, $endDate)->where('category', AccountCategory::Revenue)->get();
60 125
 
61
-        $balanceFields = ['starting_balance', 'debit_balance', 'credit_balance', 'net_movement', 'ending_balance'];
126
+        $expenseAccounts = $this->accountService->getAccountBalances($modifiedStartDate, $endDate)->where('category', AccountCategory::Expense)->get();
62 127
 
63
-        $columnNameKeys = array_map(fn (Column $column) => $column->getName(), $columns);
128
+        $revenueTotal = 0;
129
+        $expenseTotal = 0;
130
+
131
+        foreach ($revenueAccounts as $account) {
132
+            $revenueBalances = $this->calculateAccountBalances($account, AccountCategory::Revenue);
133
+            $revenueTotal += $revenueBalances['net_movement'];
134
+        }
135
+
136
+        foreach ($expenseAccounts as $account) {
137
+            $expenseBalances = $this->calculateAccountBalances($account, AccountCategory::Expense);
138
+            $expenseTotal += $expenseBalances['net_movement'];
139
+        }
64 140
 
65
-        $updatedBalanceFields = array_filter($balanceFields, fn (string $balanceField) => in_array($balanceField, $columnNameKeys, true));
141
+        $retainedEarnings = $revenueTotal - $expenseTotal;
66 142
 
67
-        return $this->buildReport(
68
-            $allCategories,
69
-            $categoryGroupedAccounts,
70
-            fn (Account $account) => $this->accountService->getBalances($account, $startDate, $endDate, $updatedBalanceFields),
71
-            $updatedBalanceFields,
72
-            $columns,
73
-            fn (string $categoryName, array &$categorySummaryBalances) => $this->adjustAccountBalanceCategoryFields($categoryName, $categorySummaryBalances),
74
-        );
143
+        return new Money($retainedEarnings, CurrencyAccessor::getDefaultCurrency());
75 144
     }
76 145
 
77 146
     public function buildAccountTransactionsReport(string $startDate, string $endDate, ?array $columns = null, ?string $accountId = 'all'): ReportDTO
78 147
     {
79 148
         $columns ??= [];
80
-        $query = Account::whereHas('journalEntries.transaction', function (Builder $query) use ($startDate, $endDate) {
81
-            $query->whereBetween('posted_at', [$startDate, $endDate]);
82
-        })
83
-            ->with(['journalEntries' => function (Relation $query) use ($startDate, $endDate) {
84
-                $query->whereHas('transaction', function (Builder $query) use ($startDate, $endDate) {
85
-                    $query->whereBetween('posted_at', [$startDate, $endDate]);
86
-                })
87
-                    ->with('transaction:id,type,description,posted_at')
88
-                    ->select(['account_id', 'transaction_id'])
89
-                    ->selectRaw('SUM(CASE WHEN type = "debit" THEN amount ELSE 0 END) AS total_debit')
90
-                    ->selectRaw('SUM(CASE WHEN type = "credit" THEN amount ELSE 0 END) AS total_credit')
91
-                    ->selectRaw('(SELECT MIN(posted_at) FROM transactions WHERE transactions.id = journal_entries.transaction_id) AS earliest_posted_at')
92
-                    ->groupBy('account_id', 'transaction_id')
93
-                    ->orderBy('earliest_posted_at');
94
-            }])
95
-            ->select(['id', 'name', 'category', 'subtype_id', 'currency_code']);
96
-
97
-        if ($accountId !== 'all') {
98
-            $query->where('id', $accountId);
99
-        }
149
+        $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
100 150
 
101
-        $accounts = $query->get();
151
+        $accountIds = $accountId !== 'all' ? [$accountId] : [];
102 152
 
103
-        $reportCategories = [];
153
+        $query = $this->accountService->getAccountBalances($startDate, $endDate, $accountIds);
104 154
 
105
-        $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
155
+        $accounts = $query->with(['journalEntries' => $this->accountService->getTransactionDetailsSubquery($startDate, $endDate)])->get();
156
+
157
+        $reportCategories = [];
106 158
 
107 159
         foreach ($accounts as $account) {
108 160
             $accountTransactions = [];
109
-            $startingBalance = $this->accountService->getStartingBalance($account, $startDate, true);
110
-
111
-            $currentBalance = $startingBalance?->getAmount() ?? 0;
112
-            $totalDebit = 0;
113
-            $totalCredit = 0;
161
+            $currentBalance = $account->starting_balance;
114 162
 
115 163
             $accountTransactions[] = new AccountTransactionDTO(
116 164
                 id: null,
@@ -118,42 +166,47 @@ class ReportService
118 166
                 description: '',
119 167
                 debit: '',
120 168
                 credit: '',
121
-                balance: $startingBalance?->formatInDefaultCurrency() ?? 0,
169
+                balance: money($currentBalance, $defaultCurrency)->format(),
122 170
                 type: null,
123
-                tableAction: null,
171
+                tableAction: null
124 172
             );
125 173
 
174
+            /** @var Account $account */
126 175
             foreach ($account->journalEntries as $journalEntry) {
127 176
                 $transaction = $journalEntry->transaction;
128
-                $totalDebit += $journalEntry->total_debit;
129
-                $totalCredit += $journalEntry->total_credit;
177
+                $signedAmount = $journalEntry->signed_amount;
178
+
179
+                if ($account->category->isNormalDebitBalance()) {
180
+                    $currentBalance += $signedAmount;
181
+                } else {
182
+                    $currentBalance -= $signedAmount;
183
+                }
130 184
 
131
-                $currentBalance += $journalEntry->total_debit;
132
-                $currentBalance -= $journalEntry->total_credit;
185
+                $formattedAmount = money(abs($signedAmount), $defaultCurrency)->format();
133 186
 
134 187
                 $accountTransactions[] = new AccountTransactionDTO(
135 188
                     id: $transaction->id,
136
-                    date: $transaction->posted_at->format('Y-m-d'),
137
-                    description: $transaction->description ?? '',
138
-                    debit: $journalEntry->total_debit ? money($journalEntry->total_debit, $defaultCurrency)->format() : '',
139
-                    credit: $journalEntry->total_credit ? money($journalEntry->total_credit, $defaultCurrency)->format() : '',
189
+                    date: $transaction->posted_at->toDefaultDateFormat(),
190
+                    description: $transaction->description ?? 'Add a description',
191
+                    debit: $journalEntry->type->isDebit() ? $formattedAmount : '',
192
+                    credit: $journalEntry->type->isCredit() ? $formattedAmount : '',
140 193
                     balance: money($currentBalance, $defaultCurrency)->format(),
141 194
                     type: $transaction->type,
142
-                    tableAction: $transaction->type->isJournal() ? 'updateJournalTransaction' : 'updateTransaction',
195
+                    tableAction: $transaction->type->isJournal() ? 'updateJournalTransaction' : 'updateTransaction'
143 196
                 );
144 197
             }
145 198
 
146
-            $balanceChange = $currentBalance - ($startingBalance?->getAmount() ?? 0);
199
+            $balanceChange = $currentBalance - $account->starting_balance;
147 200
 
148 201
             $accountTransactions[] = new AccountTransactionDTO(
149 202
                 id: null,
150 203
                 date: 'Totals and Ending Balance',
151 204
                 description: '',
152
-                debit: money($totalDebit, $defaultCurrency)->format(),
153
-                credit: money($totalCredit, $defaultCurrency)->format(),
205
+                debit: money($account->total_debit, $defaultCurrency)->format(),
206
+                credit: money($account->total_credit, $defaultCurrency)->format(),
154 207
                 balance: money($currentBalance, $defaultCurrency)->format(),
155 208
                 type: null,
156
-                tableAction: null,
209
+                tableAction: null
157 210
             );
158 211
 
159 212
             $accountTransactions[] = new AccountTransactionDTO(
@@ -164,7 +217,7 @@ class ReportService
164 217
                 credit: '',
165 218
                 balance: money($balanceChange, $defaultCurrency)->format(),
166 219
                 type: null,
167
-                tableAction: null,
220
+                tableAction: null
168 221
             );
169 222
 
170 223
             $reportCategories[] = [
@@ -177,79 +230,78 @@ class ReportService
177 230
         return new ReportDTO(categories: $reportCategories, fields: $columns);
178 231
     }
179 232
 
180
-    private function buildReport(array $allCategories, Collection $categoryGroupedAccounts, callable $balanceCalculator, array $balanceFields, array $allFields, ?callable $initializeCategoryBalances = null, bool $includeRetainedEarnings = false, ?string $startDate = null): ReportDTO
233
+    public function buildTrialBalanceReport(string $startDate, string $endDate, array $columns = []): ReportDTO
181 234
     {
235
+        $orderedCategories = AccountCategory::getOrderedCategories();
236
+
237
+        $accounts = $this->accountService->getAccountBalances($startDate, $endDate)->get();
238
+
239
+        $balanceFields = ['debit_balance', 'credit_balance'];
240
+
182 241
         $accountCategories = [];
183 242
         $reportTotalBalances = array_fill_keys($balanceFields, 0);
184 243
 
185
-        foreach ($allCategories as $categoryName) {
186
-            $accountsInCategory = $categoryGroupedAccounts[$categoryName] ?? collect();
187
-            $categorySummaryBalances = array_fill_keys($balanceFields, 0);
244
+        foreach ($orderedCategories as $category) {
245
+            $accountsInCategory = $accounts->where('category', $category)
246
+                ->sortBy('code', SORT_NATURAL);
188 247
 
189
-            if ($initializeCategoryBalances) {
190
-                $initializeCategoryBalances($categoryName, $categorySummaryBalances);
191
-            }
248
+            $categorySummaryBalances = array_fill_keys($balanceFields, 0);
192 249
 
193 250
             $categoryAccounts = [];
194 251
 
252
+            /** @var Account $account */
195 253
             foreach ($accountsInCategory as $account) {
196
-                /** @var Account $account */
197
-                $accountBalances = $balanceCalculator($account);
254
+                $accountBalances = $this->calculateAccountBalances($account, $category);
198 255
 
199
-                if (array_sum($accountBalances) === 0) {
200
-                    continue;
201
-                }
256
+                $endingBalance = $accountBalances['ending_balance'] ?? $accountBalances['net_movement'];
257
+
258
+                $trialBalance = $this->calculateTrialBalance($account->category, $endingBalance);
202 259
 
203
-                foreach ($accountBalances as $accountBalanceType => $accountBalance) {
204
-                    if (array_key_exists($accountBalanceType, $categorySummaryBalances)) {
205
-                        $categorySummaryBalances[$accountBalanceType] += $accountBalance;
206
-                    }
260
+                foreach ($trialBalance as $balanceType => $balance) {
261
+                    $categorySummaryBalances[$balanceType] += $balance;
207 262
                 }
208 263
 
209
-                $filteredAccountBalances = $this->filterBalances($accountBalances, $balanceFields);
210
-                $formattedAccountBalances = $this->formatBalances($filteredAccountBalances);
264
+                $formattedAccountBalances = $this->formatBalances($trialBalance);
211 265
 
212 266
                 $categoryAccounts[] = new AccountDTO(
213 267
                     $account->name,
214 268
                     $account->code,
215 269
                     $account->id,
216 270
                     $formattedAccountBalances,
271
+                    Carbon::parse($startDate)->toDateString(),
272
+                    Carbon::parse($endDate)->toDateString(),
217 273
                 );
218 274
             }
219 275
 
220
-            if ($includeRetainedEarnings && $categoryName === AccountCategory::Equity->getPluralLabel()) {
221
-                $retainedEarnings = $this->accountService->getRetainedEarnings($startDate);
222
-                $retainedEarningsAmount = $retainedEarnings->getAmount();
276
+            if ($category === AccountCategory::Equity) {
277
+                $modifiedStartDate = Carbon::parse($this->accountService->getEarliestTransactionDate())->startOfYear()->toDateString();
278
+                $modifiedEndDate = Carbon::parse($startDate)->subYear()->endOfYear()->toDateString();
223 279
 
224
-                if ($retainedEarningsAmount >= 0) {
225
-                    $categorySummaryBalances['credit_balance'] += $retainedEarningsAmount;
226
-                    $categoryAccounts[] = new AccountDTO(
227
-                        'Retained Earnings',
228
-                        'RE',
229
-                        null,
230
-                        $this->formatBalances(['debit_balance' => 0, 'credit_balance' => $retainedEarningsAmount])
231
-                    );
232
-                } else {
233
-                    $categorySummaryBalances['debit_balance'] += abs($retainedEarningsAmount);
234
-                    $categoryAccounts[] = new AccountDTO(
235
-                        'Retained Earnings',
236
-                        'RE',
237
-                        null,
238
-                        $this->formatBalances(['debit_balance' => abs($retainedEarningsAmount), 'credit_balance' => 0])
239
-                    );
240
-                }
280
+                $retainedEarningsAmount = $this->calculateRetainedEarnings($startDate)->getAmount();
281
+                $isCredit = $retainedEarningsAmount >= 0;
282
+
283
+                $categorySummaryBalances[$isCredit ? 'credit_balance' : 'debit_balance'] += abs($retainedEarningsAmount);
284
+
285
+                $categoryAccounts[] = new AccountDTO(
286
+                    'Retained Earnings',
287
+                    'RE',
288
+                    null,
289
+                    $this->formatBalances([
290
+                        'debit_balance' => $isCredit ? 0 : abs($retainedEarningsAmount),
291
+                        'credit_balance' => $isCredit ? $retainedEarningsAmount : 0,
292
+                    ]),
293
+                    $modifiedStartDate,
294
+                    $modifiedEndDate,
295
+                );
241 296
             }
242 297
 
243
-            foreach ($balanceFields as $field) {
244
-                if (array_key_exists($field, $categorySummaryBalances)) {
245
-                    $reportTotalBalances[$field] += $categorySummaryBalances[$field];
246
-                }
298
+            foreach ($categorySummaryBalances as $balanceType => $balance) {
299
+                $reportTotalBalances[$balanceType] += $balance;
247 300
             }
248 301
 
249
-            $filteredCategorySummaryBalances = $this->filterBalances($categorySummaryBalances, $balanceFields);
250
-            $formattedCategorySummaryBalances = $this->formatBalances($filteredCategorySummaryBalances);
302
+            $formattedCategorySummaryBalances = $this->formatBalances($categorySummaryBalances);
251 303
 
252
-            $accountCategories[$categoryName] = new AccountCategoryDTO(
304
+            $accountCategories[$category->getPluralLabel()] = new AccountCategoryDTO(
253 305
                 $categoryAccounts,
254 306
                 $formattedCategorySummaryBalances,
255 307
             );
@@ -257,38 +309,12 @@ class ReportService
257 309
 
258 310
         $formattedReportTotalBalances = $this->formatBalances($reportTotalBalances);
259 311
 
260
-        return new ReportDTO($accountCategories, $formattedReportTotalBalances, $allFields);
261
-    }
262
-
263
-    private function adjustAccountBalanceCategoryFields(string $categoryName, array &$categorySummaryBalances): void
264
-    {
265
-        if (in_array($categoryName, [AccountCategory::Expense->getPluralLabel(), AccountCategory::Revenue->getPluralLabel()], true)) {
266
-            unset($categorySummaryBalances['starting_balance'], $categorySummaryBalances['ending_balance']);
267
-        }
268
-    }
269
-
270
-    public function buildTrialBalanceReport(string $startDate, string $endDate, array $columns = []): ReportDTO
271
-    {
272
-        $allCategories = $this->accountService->getAccountCategoryOrder();
273
-
274
-        $categoryGroupedAccounts = $this->getCategoryGroupedAccounts($allCategories);
275
-
276
-        $balanceFields = ['debit_balance', 'credit_balance'];
277
-
278
-        return $this->buildReport($allCategories, $categoryGroupedAccounts, function (Account $account) use ($startDate, $endDate) {
279
-            $endingBalance = $this->accountService->getEndingBalance($account, $startDate, $endDate)?->getAmount() ?? 0;
280
-
281
-            if ($endingBalance === 0) {
282
-                return [];
283
-            }
284
-
285
-            return $this->calculateTrialBalance($account->category, $endingBalance);
286
-        }, $balanceFields, $columns, null, true, $startDate);
312
+        return new ReportDTO($accountCategories, $formattedReportTotalBalances, $columns);
287 313
     }
288 314
 
289 315
     private function calculateTrialBalance(AccountCategory $category, int $endingBalance): array
290 316
     {
291
-        if (in_array($category, [AccountCategory::Asset, AccountCategory::Expense], true)) {
317
+        if ($category->isNormalDebitBalance()) {
292 318
             if ($endingBalance >= 0) {
293 319
                 return ['debit_balance' => $endingBalance, 'credit_balance' => 0];
294 320
             }
@@ -302,4 +328,74 @@ class ReportService
302 328
 
303 329
         return ['debit_balance' => abs($endingBalance), 'credit_balance' => 0];
304 330
     }
331
+
332
+    public function buildIncomeStatementReport(string $startDate, string $endDate, array $columns = []): ReportDTO
333
+    {
334
+        $accounts = $this->accountService->getAccountBalances($startDate, $endDate)->get();
335
+
336
+        $accountCategories = [];
337
+        $totalRevenue = 0;
338
+        $cogs = 0;
339
+        $totalExpenses = 0;
340
+
341
+        $categoryGroups = [
342
+            'Revenue' => [
343
+                'accounts' => $accounts->where('category', AccountCategory::Revenue),
344
+                'total' => &$totalRevenue,
345
+            ],
346
+            'Cost of Goods Sold' => [
347
+                'accounts' => $accounts->where('subtype.name', 'Cost of Goods Sold'),
348
+                'total' => &$cogs,
349
+            ],
350
+            'Expenses' => [
351
+                'accounts' => $accounts->where('category', AccountCategory::Expense)->where('subtype.name', '!=', 'Cost of Goods Sold'),
352
+                'total' => &$totalExpenses,
353
+            ],
354
+        ];
355
+
356
+        foreach ($categoryGroups as $label => $group) {
357
+            $categoryAccounts = [];
358
+            $netMovement = 0;
359
+
360
+            foreach ($group['accounts']->sortBy('code', SORT_NATURAL) as $account) {
361
+                $category = null;
362
+
363
+                if ($label === 'Revenue') {
364
+                    $category = AccountCategory::Revenue;
365
+                } elseif ($label === 'Expenses') {
366
+                    $category = AccountCategory::Expense;
367
+                } elseif ($label === 'Cost of Goods Sold') {
368
+                    // COGS is treated as part of Expenses, so we use AccountCategory::Expense
369
+                    $category = AccountCategory::Expense;
370
+                }
371
+
372
+                if ($category !== null) {
373
+                    $accountBalances = $this->calculateAccountBalances($account, $category);
374
+                    $movement = $accountBalances['net_movement'];
375
+                    $netMovement += $movement;
376
+                    $group['total'] += $movement;
377
+
378
+                    $categoryAccounts[] = new AccountDTO(
379
+                        $account->name,
380
+                        $account->code,
381
+                        $account->id,
382
+                        $this->formatBalances(['net_movement' => $movement]),
383
+                        Carbon::parse($startDate)->toDateString(),
384
+                        Carbon::parse($endDate)->toDateString(),
385
+                    );
386
+                }
387
+            }
388
+
389
+            $accountCategories[$label] = new AccountCategoryDTO(
390
+                $categoryAccounts,
391
+                $this->formatBalances(['net_movement' => $netMovement]),
392
+            );
393
+        }
394
+
395
+        $grossProfit = $totalRevenue - $cogs;
396
+        $netProfit = $grossProfit - $totalExpenses;
397
+        $formattedReportTotalBalances = $this->formatBalances(['net_movement' => $netProfit]);
398
+
399
+        return new ReportDTO($accountCategories, $formattedReportTotalBalances, $columns);
400
+    }
305 401
 }

+ 14
- 0
app/Support/Column.php Wyświetl plik

@@ -18,6 +18,8 @@ class Column extends Component
18 18
     use HasLabel;
19 19
     use HasName;
20 20
 
21
+    protected bool $isDate = false;
22
+
21 23
     final public function __construct(string $name)
22 24
     {
23 25
         $this->name($name);
@@ -40,4 +42,16 @@ class Column extends Component
40 42
             default => '',
41 43
         };
42 44
     }
45
+
46
+    public function markAsDate(): static
47
+    {
48
+        $this->isDate = true;
49
+
50
+        return $this;
51
+    }
52
+
53
+    public function isDate(): bool
54
+    {
55
+        return $this->isDate;
56
+    }
43 57
 }

+ 2
- 0
app/Transformers/AccountBalanceReportTransformer.php Wyświetl plik

@@ -46,6 +46,8 @@ class AccountBalanceReportTransformer extends BaseReportTransformer
46 46
                         'account_name' => [
47 47
                             'name' => $account->accountName,
48 48
                             'id' => $account->accountId ?? null,
49
+                            'start_date' => $account->startDate,
50
+                            'end_date' => $account->endDate,
49 51
                         ],
50 52
                         'starting_balance' => $account->balance->startingBalance ?? '',
51 53
                         'debit_balance' => $account->balance->debitBalance,

+ 132
- 0
app/Transformers/IncomeStatementReportTransformer.php Wyświetl plik

@@ -0,0 +1,132 @@
1
+<?php
2
+
3
+namespace App\Transformers;
4
+
5
+use App\DTO\AccountDTO;
6
+use App\DTO\ReportCategoryDTO;
7
+use App\Support\Column;
8
+
9
+class IncomeStatementReportTransformer extends BaseReportTransformer
10
+{
11
+    protected string $totalRevenue = '$0.00';
12
+
13
+    protected string $totalCogs = '$0.00';
14
+
15
+    protected string $totalExpenses = '$0.00';
16
+
17
+    public function getTitle(): string
18
+    {
19
+        return 'Income Statement';
20
+    }
21
+
22
+    public function getHeaders(): array
23
+    {
24
+        return array_map(fn (Column $column) => $column->getLabel(), $this->getColumns());
25
+    }
26
+
27
+    public function calculateTotals(): void
28
+    {
29
+        foreach ($this->report->categories as $accountCategoryName => $accountCategory) {
30
+            match ($accountCategoryName) {
31
+                'Revenue' => $this->totalRevenue = $accountCategory->summary->netMovement ?? '',
32
+                'Cost of Goods Sold' => $this->totalCogs = $accountCategory->summary->netMovement ?? '',
33
+                'Expenses' => $this->totalExpenses = $accountCategory->summary->netMovement ?? '',
34
+            };
35
+        }
36
+    }
37
+
38
+    public function getCategories(): array
39
+    {
40
+        $categories = [];
41
+
42
+        foreach ($this->report->categories as $accountCategoryName => $accountCategory) {
43
+            // Initialize header with empty strings
44
+            $header = [];
45
+
46
+            foreach ($this->getColumns() as $index => $column) {
47
+                if ($column->getName() === 'account_name') {
48
+                    $header[$index] = $accountCategoryName;
49
+                } else {
50
+                    $header[$index] = '';
51
+                }
52
+            }
53
+
54
+            $data = array_map(function (AccountDTO $account) {
55
+                $row = [];
56
+
57
+                foreach ($this->getColumns() as $column) {
58
+                    $row[] = match ($column->getName()) {
59
+                        'account_code' => $account->accountCode,
60
+                        'account_name' => [
61
+                            'name' => $account->accountName,
62
+                            'id' => $account->accountId ?? null,
63
+                            'start_date' => $account->startDate,
64
+                            'end_date' => $account->endDate,
65
+                        ],
66
+                        'net_movement' => $account->balance->netMovement ?? '',
67
+                        default => '',
68
+                    };
69
+                }
70
+
71
+                return $row;
72
+            }, $accountCategory->accounts);
73
+
74
+            $summary = [];
75
+
76
+            foreach ($this->getColumns() as $column) {
77
+                $summary[] = match ($column->getName()) {
78
+                    'account_name' => 'Total ' . $accountCategoryName,
79
+                    'net_movement' => $accountCategory->summary->netMovement ?? '',
80
+                    default => '',
81
+                };
82
+            }
83
+
84
+            $categories[] = new ReportCategoryDTO(
85
+                header: $header,
86
+                data: $data,
87
+                summary: $summary,
88
+            );
89
+        }
90
+
91
+        return $categories;
92
+    }
93
+
94
+    public function getOverallTotals(): array
95
+    {
96
+        $totals = [];
97
+
98
+        foreach ($this->getColumns() as $column) {
99
+            $totals[] = match ($column->getName()) {
100
+                'account_name' => 'Net Earnings',
101
+                'net_movement' => $this->report->overallTotal->netMovement ?? '',
102
+                default => '',
103
+            };
104
+        }
105
+
106
+        return $totals;
107
+    }
108
+
109
+    public function getSummary(): array
110
+    {
111
+        $this->calculateTotals();
112
+
113
+        return [
114
+            [
115
+                'label' => 'Revenue',
116
+                'value' => $this->totalRevenue,
117
+            ],
118
+            [
119
+                'label' => 'Cost of Goods Sold',
120
+                'value' => $this->totalCogs,
121
+            ],
122
+            [
123
+                'label' => 'Expenses',
124
+                'value' => $this->totalExpenses,
125
+            ],
126
+            [
127
+                'label' => 'Net Earnings',
128
+                'value' => $this->report->overallTotal->netMovement ?? '',
129
+            ],
130
+        ];
131
+    }
132
+}

+ 2
- 0
app/Transformers/TrialBalanceReportTransformer.php Wyświetl plik

@@ -46,6 +46,8 @@ class TrialBalanceReportTransformer extends BaseReportTransformer
46 46
                         'account_name' => [
47 47
                             'name' => $account->accountName,
48 48
                             'id' => $account->accountId ?? null,
49
+                            'start_date' => $account->startDate,
50
+                            'end_date' => $account->endDate,
49 51
                         ],
50 52
                         'debit_balance' => $account->balance->debitBalance,
51 53
                         'credit_balance' => $account->balance->creditBalance,

+ 383
- 642
composer.lock
Plik diff jest za duży
Wyświetl plik


+ 9
- 1
database/factories/UserFactory.php Wyświetl plik

@@ -68,7 +68,15 @@ class UserFactory extends Factory
68 68
 
69 69
         return $this->afterCreating(function (User $user) use ($countryCode) {
70 70
             Company::factory()
71
-                ->has(CompanyProfile::factory()->withCountry($countryCode), 'profile')
71
+                ->has(
72
+                    CompanyProfile::factory()
73
+                        ->withCountry($countryCode)
74
+                        ->state([
75
+                            'created_by' => $user->id,
76
+                            'updated_by' => $user->id,
77
+                        ]),
78
+                    'profile'
79
+                )
72 80
                 ->afterCreating(function (Company $company) use ($user, $countryCode) {
73 81
                     DB::transaction(function () use ($company, $user, $countryCode) {
74 82
                         $companyDefaultService = app()->make(CompanyDefaultService::class);

+ 147
- 143
package-lock.json Wyświetl plik

@@ -541,9 +541,9 @@
541 541
             }
542 542
         },
543 543
         "node_modules/@rollup/rollup-android-arm-eabi": {
544
-            "version": "4.19.1",
545
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.19.1.tgz",
546
-            "integrity": "sha512-XzqSg714++M+FXhHfXpS1tDnNZNpgxxuGZWlRG/jSj+VEPmZ0yg6jV4E0AL3uyBKxO8mO3xtOsP5mQ+XLfrlww==",
544
+            "version": "4.21.2",
545
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.2.tgz",
546
+            "integrity": "sha512-fSuPrt0ZO8uXeS+xP3b+yYTCBUd05MoSp2N/MFOgjhhUhMmchXlpTQrTpI8T+YAwAQuK7MafsCOxW7VrPMrJcg==",
547 547
             "cpu": [
548 548
                 "arm"
549 549
             ],
@@ -555,9 +555,9 @@
555 555
             ]
556 556
         },
557 557
         "node_modules/@rollup/rollup-android-arm64": {
558
-            "version": "4.19.1",
559
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.19.1.tgz",
560
-            "integrity": "sha512-thFUbkHteM20BGShD6P08aungq4irbIZKUNbG70LN8RkO7YztcGPiKTTGZS7Kw+x5h8hOXs0i4OaHwFxlpQN6A==",
558
+            "version": "4.21.2",
559
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.2.tgz",
560
+            "integrity": "sha512-xGU5ZQmPlsjQS6tzTTGwMsnKUtu0WVbl0hYpTPauvbRAnmIvpInhJtgjj3mcuJpEiuUw4v1s4BimkdfDWlh7gA==",
561 561
             "cpu": [
562 562
                 "arm64"
563 563
             ],
@@ -569,9 +569,9 @@
569 569
             ]
570 570
         },
571 571
         "node_modules/@rollup/rollup-darwin-arm64": {
572
-            "version": "4.19.1",
573
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.19.1.tgz",
574
-            "integrity": "sha512-8o6eqeFZzVLia2hKPUZk4jdE3zW7LCcZr+MD18tXkgBBid3lssGVAYuox8x6YHoEPDdDa9ixTaStcmx88lio5Q==",
572
+            "version": "4.21.2",
573
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.2.tgz",
574
+            "integrity": "sha512-99AhQ3/ZMxU7jw34Sq8brzXqWH/bMnf7ZVhvLk9QU2cOepbQSVTns6qoErJmSiAvU3InRqC2RRZ5ovh1KN0d0Q==",
575 575
             "cpu": [
576 576
                 "arm64"
577 577
             ],
@@ -583,9 +583,9 @@
583 583
             ]
584 584
         },
585 585
         "node_modules/@rollup/rollup-darwin-x64": {
586
-            "version": "4.19.1",
587
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.19.1.tgz",
588
-            "integrity": "sha512-4T42heKsnbjkn7ovYiAdDVRRWZLU9Kmhdt6HafZxFcUdpjlBlxj4wDrt1yFWLk7G4+E+8p2C9tcmSu0KA6auGA==",
586
+            "version": "4.21.2",
587
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.2.tgz",
588
+            "integrity": "sha512-ZbRaUvw2iN/y37x6dY50D8m2BnDbBjlnMPotDi/qITMJ4sIxNY33HArjikDyakhSv0+ybdUxhWxE6kTI4oX26w==",
589 589
             "cpu": [
590 590
                 "x64"
591 591
             ],
@@ -597,9 +597,9 @@
597 597
             ]
598 598
         },
599 599
         "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
600
-            "version": "4.19.1",
601
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.19.1.tgz",
602
-            "integrity": "sha512-MXg1xp+e5GhZ3Vit1gGEyoC+dyQUBy2JgVQ+3hUrD9wZMkUw/ywgkpK7oZgnB6kPpGrxJ41clkPPnsknuD6M2Q==",
600
+            "version": "4.21.2",
601
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.2.tgz",
602
+            "integrity": "sha512-ztRJJMiE8nnU1YFcdbd9BcH6bGWG1z+jP+IPW2oDUAPxPjo9dverIOyXz76m6IPA6udEL12reYeLojzW2cYL7w==",
603 603
             "cpu": [
604 604
                 "arm"
605 605
             ],
@@ -611,9 +611,9 @@
611 611
             ]
612 612
         },
613 613
         "node_modules/@rollup/rollup-linux-arm-musleabihf": {
614
-            "version": "4.19.1",
615
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.19.1.tgz",
616
-            "integrity": "sha512-DZNLwIY4ftPSRVkJEaxYkq7u2zel7aah57HESuNkUnz+3bZHxwkCUkrfS2IWC1sxK6F2QNIR0Qr/YXw7nkF3Pw==",
614
+            "version": "4.21.2",
615
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.2.tgz",
616
+            "integrity": "sha512-flOcGHDZajGKYpLV0JNc0VFH361M7rnV1ee+NTeC/BQQ1/0pllYcFmxpagltANYt8FYf9+kL6RSk80Ziwyhr7w==",
617 617
             "cpu": [
618 618
                 "arm"
619 619
             ],
@@ -625,9 +625,9 @@
625 625
             ]
626 626
         },
627 627
         "node_modules/@rollup/rollup-linux-arm64-gnu": {
628
-            "version": "4.19.1",
629
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.19.1.tgz",
630
-            "integrity": "sha512-C7evongnjyxdngSDRRSQv5GvyfISizgtk9RM+z2biV5kY6S/NF/wta7K+DanmktC5DkuaJQgoKGf7KUDmA7RUw==",
628
+            "version": "4.21.2",
629
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.2.tgz",
630
+            "integrity": "sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw==",
631 631
             "cpu": [
632 632
                 "arm64"
633 633
             ],
@@ -639,9 +639,9 @@
639 639
             ]
640 640
         },
641 641
         "node_modules/@rollup/rollup-linux-arm64-musl": {
642
-            "version": "4.19.1",
643
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.19.1.tgz",
644
-            "integrity": "sha512-89tFWqxfxLLHkAthAcrTs9etAoBFRduNfWdl2xUs/yLV+7XDrJ5yuXMHptNqf1Zw0UCA3cAutkAiAokYCkaPtw==",
642
+            "version": "4.21.2",
643
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.2.tgz",
644
+            "integrity": "sha512-48pD/fJkTiHAZTnZwR0VzHrao70/4MlzJrq0ZsILjLW/Ab/1XlVUStYyGt7tdyIiVSlGZbnliqmult/QGA2O2w==",
645 645
             "cpu": [
646 646
                 "arm64"
647 647
             ],
@@ -653,9 +653,9 @@
653 653
             ]
654 654
         },
655 655
         "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
656
-            "version": "4.19.1",
657
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.19.1.tgz",
658
-            "integrity": "sha512-PromGeV50sq+YfaisG8W3fd+Cl6mnOOiNv2qKKqKCpiiEke2KiKVyDqG/Mb9GWKbYMHj5a01fq/qlUR28PFhCQ==",
656
+            "version": "4.21.2",
657
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.2.tgz",
658
+            "integrity": "sha512-cZdyuInj0ofc7mAQpKcPR2a2iu4YM4FQfuUzCVA2u4HI95lCwzjoPtdWjdpDKyHxI0UO82bLDoOaLfpZ/wviyQ==",
659 659
             "cpu": [
660 660
                 "ppc64"
661 661
             ],
@@ -667,9 +667,9 @@
667 667
             ]
668 668
         },
669 669
         "node_modules/@rollup/rollup-linux-riscv64-gnu": {
670
-            "version": "4.19.1",
671
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.19.1.tgz",
672
-            "integrity": "sha512-/1BmHYh+iz0cNCP0oHCuF8CSiNj0JOGf0jRlSo3L/FAyZyG2rGBuKpkZVH9YF+x58r1jgWxvm1aRg3DHrLDt6A==",
670
+            "version": "4.21.2",
671
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.2.tgz",
672
+            "integrity": "sha512-RL56JMT6NwQ0lXIQmMIWr1SW28z4E4pOhRRNqwWZeXpRlykRIlEpSWdsgNWJbYBEWD84eocjSGDu/XxbYeCmwg==",
673 673
             "cpu": [
674 674
                 "riscv64"
675 675
             ],
@@ -681,9 +681,9 @@
681 681
             ]
682 682
         },
683 683
         "node_modules/@rollup/rollup-linux-s390x-gnu": {
684
-            "version": "4.19.1",
685
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.19.1.tgz",
686
-            "integrity": "sha512-0cYP5rGkQWRZKy9/HtsWVStLXzCF3cCBTRI+qRL8Z+wkYlqN7zrSYm6FuY5Kd5ysS5aH0q5lVgb/WbG4jqXN1Q==",
684
+            "version": "4.21.2",
685
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.2.tgz",
686
+            "integrity": "sha512-PMxkrWS9z38bCr3rWvDFVGD6sFeZJw4iQlhrup7ReGmfn7Oukrr/zweLhYX6v2/8J6Cep9IEA/SmjXjCmSbrMQ==",
687 687
             "cpu": [
688 688
                 "s390x"
689 689
             ],
@@ -695,9 +695,9 @@
695 695
             ]
696 696
         },
697 697
         "node_modules/@rollup/rollup-linux-x64-gnu": {
698
-            "version": "4.19.1",
699
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.19.1.tgz",
700
-            "integrity": "sha512-XUXeI9eM8rMP8aGvii/aOOiMvTs7xlCosq9xCjcqI9+5hBxtjDpD+7Abm1ZhVIFE1J2h2VIg0t2DX/gjespC2Q==",
698
+            "version": "4.21.2",
699
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.2.tgz",
700
+            "integrity": "sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw==",
701 701
             "cpu": [
702 702
                 "x64"
703 703
             ],
@@ -709,9 +709,9 @@
709 709
             ]
710 710
         },
711 711
         "node_modules/@rollup/rollup-linux-x64-musl": {
712
-            "version": "4.19.1",
713
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.19.1.tgz",
714
-            "integrity": "sha512-V7cBw/cKXMfEVhpSvVZhC+iGifD6U1zJ4tbibjjN+Xi3blSXaj/rJynAkCFFQfoG6VZrAiP7uGVzL440Q6Me2Q==",
712
+            "version": "4.21.2",
713
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.2.tgz",
714
+            "integrity": "sha512-7twFizNXudESmC9oneLGIUmoHiiLppz/Xs5uJQ4ShvE6234K0VB1/aJYU3f/4g7PhssLGKBVCC37uRkkOi8wjg==",
715 715
             "cpu": [
716 716
                 "x64"
717 717
             ],
@@ -723,9 +723,9 @@
723 723
             ]
724 724
         },
725 725
         "node_modules/@rollup/rollup-win32-arm64-msvc": {
726
-            "version": "4.19.1",
727
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.19.1.tgz",
728
-            "integrity": "sha512-88brja2vldW/76jWATlBqHEoGjJLRnP0WOEKAUbMcXaAZnemNhlAHSyj4jIwMoP2T750LE9lblvD4e2jXleZsA==",
726
+            "version": "4.21.2",
727
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.2.tgz",
728
+            "integrity": "sha512-9rRero0E7qTeYf6+rFh3AErTNU1VCQg2mn7CQcI44vNUWM9Ze7MSRS/9RFuSsox+vstRt97+x3sOhEey024FRQ==",
729 729
             "cpu": [
730 730
                 "arm64"
731 731
             ],
@@ -737,9 +737,9 @@
737 737
             ]
738 738
         },
739 739
         "node_modules/@rollup/rollup-win32-ia32-msvc": {
740
-            "version": "4.19.1",
741
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.19.1.tgz",
742
-            "integrity": "sha512-LdxxcqRVSXi6k6JUrTah1rHuaupoeuiv38du8Mt4r4IPer3kwlTo+RuvfE8KzZ/tL6BhaPlzJ3835i6CxrFIRQ==",
740
+            "version": "4.21.2",
741
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.2.tgz",
742
+            "integrity": "sha512-5rA4vjlqgrpbFVVHX3qkrCo/fZTj1q0Xxpg+Z7yIo3J2AilW7t2+n6Q8Jrx+4MrYpAnjttTYF8rr7bP46BPzRw==",
743 743
             "cpu": [
744 744
                 "ia32"
745 745
             ],
@@ -751,9 +751,9 @@
751 751
             ]
752 752
         },
753 753
         "node_modules/@rollup/rollup-win32-x64-msvc": {
754
-            "version": "4.19.1",
755
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.19.1.tgz",
756
-            "integrity": "sha512-2bIrL28PcK3YCqD9anGxDxamxdiJAxA+l7fWIwM5o8UqNy1t3d1NdAweO2XhA0KTDJ5aH1FsuiT5+7VhtHliXg==",
754
+            "version": "4.21.2",
755
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.2.tgz",
756
+            "integrity": "sha512-6UUxd0+SKomjdzuAcp+HAmxw1FlGBnl1v2yEPSabtx4lBfdXHDVsW7+lQkgz9cNFJGY3AWR7+V8P5BqkD9L9nA==",
757 757
             "cpu": [
758 758
                 "x64"
759 759
             ],
@@ -765,22 +765,22 @@
765 765
             ]
766 766
         },
767 767
         "node_modules/@tailwindcss/forms": {
768
-            "version": "0.5.7",
769
-            "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.7.tgz",
770
-            "integrity": "sha512-QE7X69iQI+ZXwldE+rzasvbJiyV/ju1FGHH0Qn2W3FKbuYtqp8LKcy6iSw79fVUT5/Vvf+0XgLCeYVG+UV6hOw==",
768
+            "version": "0.5.9",
769
+            "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.9.tgz",
770
+            "integrity": "sha512-tM4XVr2+UVTxXJzey9Twx48c1gcxFStqn1pQz0tRsX8o3DvxhN5oY5pvyAbUx7VTaZxpej4Zzvc6h+1RJBzpIg==",
771 771
             "dev": true,
772 772
             "license": "MIT",
773 773
             "dependencies": {
774 774
                 "mini-svg-data-uri": "^1.2.3"
775 775
             },
776 776
             "peerDependencies": {
777
-                "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1"
777
+                "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20"
778 778
             }
779 779
         },
780 780
         "node_modules/@tailwindcss/typography": {
781
-            "version": "0.5.13",
782
-            "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.13.tgz",
783
-            "integrity": "sha512-ADGcJ8dX21dVVHIwTRgzrcunY6YY9uSlAHHGVKvkA+vLc5qLwEszvKts40lx7z0qc4clpjclwLeK5rVCV2P/uw==",
781
+            "version": "0.5.15",
782
+            "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.15.tgz",
783
+            "integrity": "sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==",
784 784
             "dev": true,
785 785
             "license": "MIT",
786 786
             "dependencies": {
@@ -790,7 +790,7 @@
790 790
                 "postcss-selector-parser": "6.0.10"
791 791
             },
792 792
             "peerDependencies": {
793
-                "tailwindcss": ">=3.0.0 || insiders"
793
+                "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20"
794 794
             }
795 795
         },
796 796
         "node_modules/@types/estree": {
@@ -801,9 +801,9 @@
801 801
             "license": "MIT"
802 802
         },
803 803
         "node_modules/ansi-regex": {
804
-            "version": "6.0.1",
805
-            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
806
-            "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
804
+            "version": "6.1.0",
805
+            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
806
+            "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
807 807
             "dev": true,
808 808
             "license": "MIT",
809 809
             "engines": {
@@ -862,9 +862,9 @@
862 862
             "license": "MIT"
863 863
         },
864 864
         "node_modules/autoprefixer": {
865
-            "version": "10.4.19",
866
-            "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz",
867
-            "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==",
865
+            "version": "10.4.20",
866
+            "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
867
+            "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==",
868 868
             "dev": true,
869 869
             "funding": [
870 870
                 {
@@ -882,11 +882,11 @@
882 882
             ],
883 883
             "license": "MIT",
884 884
             "dependencies": {
885
-                "browserslist": "^4.23.0",
886
-                "caniuse-lite": "^1.0.30001599",
885
+                "browserslist": "^4.23.3",
886
+                "caniuse-lite": "^1.0.30001646",
887 887
                 "fraction.js": "^4.3.7",
888 888
                 "normalize-range": "^0.1.2",
889
-                "picocolors": "^1.0.0",
889
+                "picocolors": "^1.0.1",
890 890
                 "postcss-value-parser": "^4.2.0"
891 891
             },
892 892
             "bin": {
@@ -900,9 +900,9 @@
900 900
             }
901 901
         },
902 902
         "node_modules/axios": {
903
-            "version": "1.7.2",
904
-            "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz",
905
-            "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==",
903
+            "version": "1.7.7",
904
+            "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
905
+            "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
906 906
             "dev": true,
907 907
             "license": "MIT",
908 908
             "dependencies": {
@@ -955,9 +955,9 @@
955 955
             }
956 956
         },
957 957
         "node_modules/browserslist": {
958
-            "version": "4.23.2",
959
-            "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.2.tgz",
960
-            "integrity": "sha512-qkqSyistMYdxAcw+CzbZwlBy8AGmS/eEWs+sEV5TnLRGDOL+C5M2EnH6tlZyg0YoAxGJAFKh61En9BR941GnHA==",
958
+            "version": "4.23.3",
959
+            "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz",
960
+            "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==",
961 961
             "dev": true,
962 962
             "funding": [
963 963
                 {
@@ -975,9 +975,9 @@
975 975
             ],
976 976
             "license": "MIT",
977 977
             "dependencies": {
978
-                "caniuse-lite": "^1.0.30001640",
979
-                "electron-to-chromium": "^1.4.820",
980
-                "node-releases": "^2.0.14",
978
+                "caniuse-lite": "^1.0.30001646",
979
+                "electron-to-chromium": "^1.5.4",
980
+                "node-releases": "^2.0.18",
981 981
                 "update-browserslist-db": "^1.1.0"
982 982
             },
983 983
             "bin": {
@@ -998,9 +998,9 @@
998 998
             }
999 999
         },
1000 1000
         "node_modules/caniuse-lite": {
1001
-            "version": "1.0.30001643",
1002
-            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001643.tgz",
1003
-            "integrity": "sha512-ERgWGNleEilSrHM6iUz/zJNSQTP8Mr21wDWpdgvRwcTXGAq6jMtOUPP4dqFPTdKqZ2wKTdtB+uucZ3MRpAUSmg==",
1001
+            "version": "1.0.30001659",
1002
+            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001659.tgz",
1003
+            "integrity": "sha512-Qxxyfv3RdHAfJcXelgf0hU4DFUVXBGTjqrBUZLUh8AtlGnsDo+CnncYtTd95+ZKfnANUOzxyIQCuU/UeBZBYoA==",
1004 1004
             "dev": true,
1005 1005
             "funding": [
1006 1006
                 {
@@ -1159,9 +1159,9 @@
1159 1159
             "license": "MIT"
1160 1160
         },
1161 1161
         "node_modules/electron-to-chromium": {
1162
-            "version": "1.5.2",
1163
-            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.2.tgz",
1164
-            "integrity": "sha512-kc4r3U3V3WLaaZqThjYz/Y6z8tJe+7K0bbjUVo3i+LWIypVdMx5nXCkwRe6SWbY6ILqLdc1rKcKmr3HoH7wjSQ==",
1162
+            "version": "1.5.18",
1163
+            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.18.tgz",
1164
+            "integrity": "sha512-1OfuVACu+zKlmjsNdcJuVQuVE61sZOLbNM4JAQ1Rvh6EOj0/EUKhMJjRH73InPlXSh8HIJk1cVZ8pyOV/FMdUQ==",
1165 1165
             "dev": true,
1166 1166
             "license": "ISC"
1167 1167
         },
@@ -1212,9 +1212,9 @@
1212 1212
             }
1213 1213
         },
1214 1214
         "node_modules/escalade": {
1215
-            "version": "3.1.2",
1216
-            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
1217
-            "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==",
1215
+            "version": "3.2.0",
1216
+            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1217
+            "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1218 1218
             "dev": true,
1219 1219
             "license": "MIT",
1220 1220
             "engines": {
@@ -1275,9 +1275,9 @@
1275 1275
             }
1276 1276
         },
1277 1277
         "node_modules/follow-redirects": {
1278
-            "version": "1.15.6",
1279
-            "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
1280
-            "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
1278
+            "version": "1.15.9",
1279
+            "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
1280
+            "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
1281 1281
             "dev": true,
1282 1282
             "funding": [
1283 1283
                 {
@@ -1296,9 +1296,9 @@
1296 1296
             }
1297 1297
         },
1298 1298
         "node_modules/foreground-child": {
1299
-            "version": "3.2.1",
1300
-            "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz",
1301
-            "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==",
1299
+            "version": "3.3.0",
1300
+            "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
1301
+            "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==",
1302 1302
             "dev": true,
1303 1303
             "license": "ISC",
1304 1304
             "dependencies": {
@@ -1427,9 +1427,9 @@
1427 1427
             }
1428 1428
         },
1429 1429
         "node_modules/is-core-module": {
1430
-            "version": "2.15.0",
1431
-            "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.0.tgz",
1432
-            "integrity": "sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==",
1430
+            "version": "2.15.1",
1431
+            "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz",
1432
+            "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==",
1433 1433
             "dev": true,
1434 1434
             "license": "MIT",
1435 1435
             "dependencies": {
@@ -1594,9 +1594,9 @@
1594 1594
             }
1595 1595
         },
1596 1596
         "node_modules/micromatch": {
1597
-            "version": "4.0.7",
1598
-            "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
1599
-            "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==",
1597
+            "version": "4.0.8",
1598
+            "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
1599
+            "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
1600 1600
             "dev": true,
1601 1601
             "license": "MIT",
1602 1602
             "dependencies": {
@@ -1786,9 +1786,9 @@
1786 1786
             }
1787 1787
         },
1788 1788
         "node_modules/picocolors": {
1789
-            "version": "1.0.1",
1790
-            "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
1791
-            "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==",
1789
+            "version": "1.1.0",
1790
+            "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
1791
+            "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==",
1792 1792
             "dev": true,
1793 1793
             "license": "ISC"
1794 1794
         },
@@ -1826,9 +1826,9 @@
1826 1826
             }
1827 1827
         },
1828 1828
         "node_modules/postcss": {
1829
-            "version": "8.4.40",
1830
-            "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz",
1831
-            "integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==",
1829
+            "version": "8.4.45",
1830
+            "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.45.tgz",
1831
+            "integrity": "sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==",
1832 1832
             "dev": true,
1833 1833
             "funding": [
1834 1834
                 {
@@ -1968,9 +1968,9 @@
1968 1968
             }
1969 1969
         },
1970 1970
         "node_modules/postcss-nested/node_modules/postcss-selector-parser": {
1971
-            "version": "6.1.1",
1972
-            "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.1.tgz",
1973
-            "integrity": "sha512-b4dlw/9V8A71rLIDsSwVmak9z2DuBUB7CA1/wSdelNEzqsjoSPeADTWNO09lpH49Diy3/JIZ2bSPB1dI3LJCHg==",
1971
+            "version": "6.1.2",
1972
+            "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
1973
+            "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
1974 1974
             "dev": true,
1975 1975
             "license": "MIT",
1976 1976
             "dependencies": {
@@ -2056,9 +2056,9 @@
2056 2056
             }
2057 2057
         },
2058 2058
         "node_modules/postcss-nesting/node_modules/postcss-selector-parser": {
2059
-            "version": "6.1.1",
2060
-            "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.1.tgz",
2061
-            "integrity": "sha512-b4dlw/9V8A71rLIDsSwVmak9z2DuBUB7CA1/wSdelNEzqsjoSPeADTWNO09lpH49Diy3/JIZ2bSPB1dI3LJCHg==",
2059
+            "version": "6.1.2",
2060
+            "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
2061
+            "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
2062 2062
             "dev": true,
2063 2063
             "license": "MIT",
2064 2064
             "dependencies": {
@@ -2171,9 +2171,9 @@
2171 2171
             }
2172 2172
         },
2173 2173
         "node_modules/rollup": {
2174
-            "version": "4.19.1",
2175
-            "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.19.1.tgz",
2176
-            "integrity": "sha512-K5vziVlg7hTpYfFBI+91zHBEMo6jafYXpkMlqZjg7/zhIG9iHqazBf4xz9AVdjS9BruRn280ROqLI7G3OFRIlw==",
2174
+            "version": "4.21.2",
2175
+            "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.2.tgz",
2176
+            "integrity": "sha512-e3TapAgYf9xjdLvKQCkQTnbTKd4a6jwlpQSJJFokHGaX2IVjoEqkIIhiQfqsi0cdwlOD+tQGuOd5AJkc5RngBw==",
2177 2177
             "dev": true,
2178 2178
             "license": "MIT",
2179 2179
             "dependencies": {
@@ -2187,22 +2187,22 @@
2187 2187
                 "npm": ">=8.0.0"
2188 2188
             },
2189 2189
             "optionalDependencies": {
2190
-                "@rollup/rollup-android-arm-eabi": "4.19.1",
2191
-                "@rollup/rollup-android-arm64": "4.19.1",
2192
-                "@rollup/rollup-darwin-arm64": "4.19.1",
2193
-                "@rollup/rollup-darwin-x64": "4.19.1",
2194
-                "@rollup/rollup-linux-arm-gnueabihf": "4.19.1",
2195
-                "@rollup/rollup-linux-arm-musleabihf": "4.19.1",
2196
-                "@rollup/rollup-linux-arm64-gnu": "4.19.1",
2197
-                "@rollup/rollup-linux-arm64-musl": "4.19.1",
2198
-                "@rollup/rollup-linux-powerpc64le-gnu": "4.19.1",
2199
-                "@rollup/rollup-linux-riscv64-gnu": "4.19.1",
2200
-                "@rollup/rollup-linux-s390x-gnu": "4.19.1",
2201
-                "@rollup/rollup-linux-x64-gnu": "4.19.1",
2202
-                "@rollup/rollup-linux-x64-musl": "4.19.1",
2203
-                "@rollup/rollup-win32-arm64-msvc": "4.19.1",
2204
-                "@rollup/rollup-win32-ia32-msvc": "4.19.1",
2205
-                "@rollup/rollup-win32-x64-msvc": "4.19.1",
2190
+                "@rollup/rollup-android-arm-eabi": "4.21.2",
2191
+                "@rollup/rollup-android-arm64": "4.21.2",
2192
+                "@rollup/rollup-darwin-arm64": "4.21.2",
2193
+                "@rollup/rollup-darwin-x64": "4.21.2",
2194
+                "@rollup/rollup-linux-arm-gnueabihf": "4.21.2",
2195
+                "@rollup/rollup-linux-arm-musleabihf": "4.21.2",
2196
+                "@rollup/rollup-linux-arm64-gnu": "4.21.2",
2197
+                "@rollup/rollup-linux-arm64-musl": "4.21.2",
2198
+                "@rollup/rollup-linux-powerpc64le-gnu": "4.21.2",
2199
+                "@rollup/rollup-linux-riscv64-gnu": "4.21.2",
2200
+                "@rollup/rollup-linux-s390x-gnu": "4.21.2",
2201
+                "@rollup/rollup-linux-x64-gnu": "4.21.2",
2202
+                "@rollup/rollup-linux-x64-musl": "4.21.2",
2203
+                "@rollup/rollup-win32-arm64-msvc": "4.21.2",
2204
+                "@rollup/rollup-win32-ia32-msvc": "4.21.2",
2205
+                "@rollup/rollup-win32-x64-msvc": "4.21.2",
2206 2206
                 "fsevents": "~2.3.2"
2207 2207
             }
2208 2208
         },
@@ -2267,9 +2267,9 @@
2267 2267
             }
2268 2268
         },
2269 2269
         "node_modules/source-map-js": {
2270
-            "version": "1.2.0",
2271
-            "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
2272
-            "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
2270
+            "version": "1.2.1",
2271
+            "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
2272
+            "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
2273 2273
             "dev": true,
2274 2274
             "license": "BSD-3-Clause",
2275 2275
             "engines": {
@@ -2417,9 +2417,9 @@
2417 2417
             }
2418 2418
         },
2419 2419
         "node_modules/tailwindcss": {
2420
-            "version": "3.4.7",
2421
-            "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.7.tgz",
2422
-            "integrity": "sha512-rxWZbe87YJb4OcSopb7up2Ba4U82BoiSGUdoDr3Ydrg9ckxFS/YWsvhN323GMcddgU65QRy7JndC7ahhInhvlQ==",
2420
+            "version": "3.4.10",
2421
+            "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz",
2422
+            "integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==",
2423 2423
             "dev": true,
2424 2424
             "license": "MIT",
2425 2425
             "dependencies": {
@@ -2455,9 +2455,9 @@
2455 2455
             }
2456 2456
         },
2457 2457
         "node_modules/tailwindcss/node_modules/postcss-selector-parser": {
2458
-            "version": "6.1.1",
2459
-            "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.1.tgz",
2460
-            "integrity": "sha512-b4dlw/9V8A71rLIDsSwVmak9z2DuBUB7CA1/wSdelNEzqsjoSPeADTWNO09lpH49Diy3/JIZ2bSPB1dI3LJCHg==",
2458
+            "version": "6.1.2",
2459
+            "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
2460
+            "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
2461 2461
             "dev": true,
2462 2462
             "license": "MIT",
2463 2463
             "dependencies": {
@@ -2550,15 +2550,15 @@
2550 2550
             "license": "MIT"
2551 2551
         },
2552 2552
         "node_modules/vite": {
2553
-            "version": "5.3.5",
2554
-            "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.5.tgz",
2555
-            "integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==",
2553
+            "version": "5.4.3",
2554
+            "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.3.tgz",
2555
+            "integrity": "sha512-IH+nl64eq9lJjFqU+/yrRnrHPVTlgy42/+IzbOdaFDVlyLgI/wDlf+FCobXLX1cT0X5+7LMyH1mIy2xJdLfo8Q==",
2556 2556
             "dev": true,
2557 2557
             "license": "MIT",
2558 2558
             "dependencies": {
2559 2559
                 "esbuild": "^0.21.3",
2560
-                "postcss": "^8.4.39",
2561
-                "rollup": "^4.13.0"
2560
+                "postcss": "^8.4.43",
2561
+                "rollup": "^4.20.0"
2562 2562
             },
2563 2563
             "bin": {
2564 2564
                 "vite": "bin/vite.js"
@@ -2577,6 +2577,7 @@
2577 2577
                 "less": "*",
2578 2578
                 "lightningcss": "^1.21.0",
2579 2579
                 "sass": "*",
2580
+                "sass-embedded": "*",
2580 2581
                 "stylus": "*",
2581 2582
                 "sugarss": "*",
2582 2583
                 "terser": "^5.4.0"
@@ -2594,6 +2595,9 @@
2594 2595
                 "sass": {
2595 2596
                     "optional": true
2596 2597
                 },
2598
+                "sass-embedded": {
2599
+                    "optional": true
2600
+                },
2597 2601
                 "stylus": {
2598 2602
                     "optional": true
2599 2603
                 },
@@ -2731,9 +2735,9 @@
2731 2735
             }
2732 2736
         },
2733 2737
         "node_modules/yaml": {
2734
-            "version": "2.5.0",
2735
-            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz",
2736
-            "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==",
2738
+            "version": "2.5.1",
2739
+            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz",
2740
+            "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==",
2737 2741
             "dev": true,
2738 2742
             "license": "ISC",
2739 2743
             "bin": {

+ 9
- 11
resources/css/filament/company/theme.css Wyświetl plik

@@ -13,8 +13,7 @@
13 13
 }
14 14
 
15 15
 .choices__group {
16
-    color: rgba(var(--gray-900), 1);
17
-    font-weight: bold;
16
+    @apply text-gray-900 dark:text-white font-semibold;
18 17
 }
19 18
 
20 19
 .choices[data-type="select-one"] .choices__inner {
@@ -25,7 +24,7 @@
25 24
     box-sizing: border-box;
26 25
 }
27 26
 
28
-.choices__item {
27
+.choices:not(.is-disabled) .choices__item {
29 28
     cursor: pointer;
30 29
 }
31 30
 
@@ -148,13 +147,12 @@
148 147
     left: 0;
149 148
     width: 100%;
150 149
     height: 100%;
151
-    background-image:
152
-        linear-gradient(99.6deg,
153
-            rgba(232, 233, 235, 1) 10.6%,
154
-            rgba(240, 241, 243, 1) 32.9%,
155
-            rgba(248, 249, 251, 0.7) 50%,
156
-            rgba(240, 241, 243, 1) 67.1%,
157
-            rgba(232, 233, 235, 1) 83.4%);
150
+    background-image: linear-gradient(99.6deg,
151
+    rgba(232, 233, 235, 1) 10.6%,
152
+    rgba(240, 241, 243, 1) 32.9%,
153
+    rgba(248, 249, 251, 0.7) 50%,
154
+    rgba(240, 241, 243, 1) 67.1%,
155
+    rgba(232, 233, 235, 1) 83.4%);
158 156
     pointer-events: none;
159 157
     z-index: -1;
160 158
 }
@@ -178,7 +176,7 @@
178 176
         rgba(var(--primary-900), 0.5) 45%,
179 177
         rgba(var(--primary-950), 0.3) 60%,
180 178
         rgba(var(--primary-950), 0.1) 75%,
181
-        rgba(3,7,18,0) 100%
179
+        rgba(3, 7, 18, 0) 100%
182 180
     );
183 181
     width: 100%;
184 182
     height: 100%;

+ 5
- 1
resources/views/components/company/reports/account-transactions-report-pdf.blade.php Wyświetl plik

@@ -129,7 +129,11 @@
129 129
                 ])>
130 130
                 @foreach($transaction as $cellIndex => $cell)
131 131
                     <td class="{{ $report->getAlignmentClass($cellIndex) }} {{ $cellIndex === 1 ? 'whitespace-normal' : 'whitespace-nowrap' }}">
132
-                        {{ $cell }}
132
+                        @if(is_array($cell) && isset($cell['description']))
133
+                            {{ $cell['description'] }}
134
+                        @else
135
+                            {{ $cell }}
136
+                        @endif
133 137
                     </td>
134 138
                 @endforeach
135 139
             </tr>

+ 4
- 4
resources/views/components/company/reports/report-pdf.blade.php Wyświetl plik

@@ -84,7 +84,7 @@
84 84
 
85 85
         .category-summary-row > td,
86 86
         .table-footer-row > td {
87
-            font-weight: 600;
87
+            font-weight: bold;
88 88
             background-color: #ffffff; /* White background for footer */
89 89
         }
90 90
     </style>
@@ -118,7 +118,7 @@
118 118
             <tr>
119 119
                 @foreach($account as $index => $cell)
120 120
                     <td class="{{ $report->getAlignmentClass($index) }} {{ $index === 1 ? 'whitespace-normal' : 'whitespace-nowrap' }}">
121
-                        @if(isset($cell['id']) && isset($cell['name']))
121
+                        @if(is_array($cell) && isset($cell['name']))
122 122
                             {{ $cell['name'] }}
123 123
                         @else
124 124
                             {{ $cell }}
@@ -139,7 +139,7 @@
139 139
         </tr>
140 140
         </tbody>
141 141
     @endforeach
142
-    <tfoot>
142
+    <tbody>
143 143
     <tr class="table-footer-row">
144 144
         @foreach ($report->getOverallTotals() as $index => $total)
145 145
             <td class="{{ $report->getAlignmentClass($index) }}">
@@ -147,7 +147,7 @@
147 147
             </td>
148 148
         @endforeach
149 149
     </tr>
150
-    </tfoot>
150
+    </tbody>
151 151
 </table>
152 152
 </body>
153 153
 </html>

+ 21
- 3
resources/views/components/company/tables/reports/detailed-report.blade.php Wyświetl plik

@@ -1,4 +1,4 @@
1
-<table class="w-full table-auto divide-y divide-gray-200 dark:divide-white/5" x-data>
1
+<table class="w-full table-auto divide-y divide-gray-200 dark:divide-white/5">
2 2
     <thead class="divide-y divide-gray-200 dark:divide-white/5">
3 3
     <tr class="bg-gray-50 dark:bg-white/5">
4 4
         @foreach($report->getHeaders() as $index => $header)
@@ -27,14 +27,32 @@
27 27
                     <x-filament-tables::cell class="{{ $report->getAlignmentClass($cellIndex) }}">
28 28
                         <div class="px-3 py-4 text-sm leading-6 text-gray-950 dark:text-white">
29 29
                             @if(is_array($cell) && isset($cell['name']))
30
-                                @if(isset($cell['id']))
30
+                                @if($cell['name'] === 'Retained Earnings' && isset($cell['start_date']) && isset($cell['end_date']))
31 31
                                     <x-filament::link
32 32
                                         color="primary"
33 33
                                         target="_blank"
34 34
                                         icon="heroicon-o-arrow-top-right-on-square"
35 35
                                         :icon-position="\Filament\Support\Enums\IconPosition::After"
36 36
                                         :icon-size="\Filament\Support\Enums\IconSize::Small"
37
-                                        href="{{ \App\Filament\Company\Pages\Reports\AccountTransactions::getUrl(['account_id' => $cell['id']]) }}"
37
+                                        href="{{ \App\Filament\Company\Pages\Reports\IncomeStatement::getUrl([
38
+                                            'startDate' => $cell['start_date'],
39
+                                            'endDate' => $cell['end_date']
40
+                                        ]) }}"
41
+                                    >
42
+                                        {{ $cell['name'] }}
43
+                                    </x-filament::link>
44
+                                @elseif(isset($cell['id']) && isset($cell['start_date']) && isset($cell['end_date']))
45
+                                    <x-filament::link
46
+                                        color="primary"
47
+                                        target="_blank"
48
+                                        icon="heroicon-o-arrow-top-right-on-square"
49
+                                        :icon-position="\Filament\Support\Enums\IconPosition::After"
50
+                                        :icon-size="\Filament\Support\Enums\IconSize::Small"
51
+                                        href="{{ \App\Filament\Company\Pages\Reports\AccountTransactions::getUrl([
52
+                                            'startDate' => $cell['start_date'],
53
+                                            'endDate' => $cell['end_date'],
54
+                                            'selectedAccount' => $cell['id']
55
+                                        ]) }}"
38 56
                                     >
39 57
                                         {{ $cell['name'] }}
40 58
                                     </x-filament::link>

+ 14
- 7
resources/views/components/panel-shift-dropdown.blade.php Wyświetl plik

@@ -11,8 +11,8 @@
11 11
     $panels = $component->getNavigationAsHierarchyArray();
12 12
 @endphp
13 13
 
14
-<div x-data="panelShiftDropdown">
15
-    <div x-on:click="toggleDropdown()" class="flex cursor-pointer">
14
+<div x-data="panelShiftDropdown" x-on:click.outside="closeDropdown">
15
+    <div x-on:click="toggleDropdown" class="flex cursor-pointer">
16 16
         <button
17 17
             type="button"
18 18
             class="fi-tenant-menu-trigger group flex w-full items-center justify-center gap-x-3 rounded-lg p-2 text-sm font-medium outline-none transition duration-75 hover:bg-gray-100 focus-visible:bg-gray-100 dark:hover:bg-white/5 dark:focus-visible:bg-white/5"
@@ -41,22 +41,25 @@
41 41
             />
42 42
         </button>
43 43
     </div>
44
-    <div x-show="open" class="flex flex-col transition duration-200 ease-in-out grow shrink mt-4 absolute z-10 w-screen max-w-[360px] end-8 rounded-lg bg-white shadow-lg ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10 overflow-hidden">
44
+    <div x-show="open"
45
+         class="flex flex-col transition duration-200 ease-in-out grow shrink mt-4 absolute z-10 w-screen max-w-[360px] end-8 rounded-lg bg-white shadow-lg ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10 overflow-hidden">
45 46
         @foreach($panels as $panelId => $panel)
46 47
             <x-panel-shift-dropdown.panel :panel-id="$panelId">
47 48
                 @if($panelId !== 'main' && isset($panel['label']))
48
-                    <x-panel-shift-dropdown.subpanel-header :label="$panel['label']" :panel-id="$panelId" />
49
+                    <x-panel-shift-dropdown.subpanel-header :label="$panel['label']" :panel-id="$panelId"/>
49 50
                 @endif
50 51
                 @if($panel['renderItems'])
51 52
                     @foreach($panel['items'] as $item)
52
-                        <x-panel-shift-dropdown.content-handler :item="$item" />
53
+                        <x-panel-shift-dropdown.content-handler :item="$item"/>
53 54
                     @endforeach
54 55
                 @endif
55 56
                 @if($panelId === 'company-settings')
56
-                    <x-panel-shift-dropdown.company-settings :current-tenant="$currentTenant" icon="heroicon-m-building-office-2" />
57
+                    <x-panel-shift-dropdown.company-settings :current-tenant="$currentTenant"
58
+                                                             icon="heroicon-m-building-office-2"/>
57 59
                 @endif
58 60
                 @if($panelId === 'company-switcher')
59
-                    <x-panel-shift-dropdown.company-switcher :current-tenant="$currentTenant" icon="heroicon-m-adjustments-horizontal" />
61
+                    <x-panel-shift-dropdown.company-switcher :current-tenant="$currentTenant"
62
+                                                             icon="heroicon-m-adjustments-horizontal"/>
60 63
                 @endif
61 64
                 @if($panelId === 'display-and-accessibility')
62 65
                     <x-panel-shift-dropdown.display-accessibility icon="heroicon-s-moon"/>
@@ -91,6 +94,10 @@
91 94
                 this.open = !this.open;
92 95
             },
93 96
 
97
+            closeDropdown() {
98
+                this.open = false;
99
+            },
100
+
94 101
             setActiveMenu(menu) {
95 102
                 this.transitionPanel(menu, 'forward');
96 103
             },

+ 14
- 9
resources/views/filament/company/pages/reports/account-transactions.blade.php Wyświetl plik

@@ -1,19 +1,24 @@
1 1
 <x-filament-panels::page>
2
+    <x-filament::section>
3
+        @if(method_exists($this, 'filtersForm'))
4
+            {{ $this->filtersForm }}
5
+        @endif
6
+    </x-filament::section>
7
+
2 8
     <x-filament-tables::container>
3
-        <form wire:submit="loadReportData" class="p-6">
4
-            {{ $this->form }}
5
-        </form>
6
-        <div class="relative divide-y divide-gray-200 overflow-x-auto dark:divide-white/10 dark:border-t-white/10 min-h-64">
7
-            <div wire:init="loadReportData" class="flex items-center justify-center w-full h-full absolute">
8
-                <div wire:loading wire:target="loadReportData">
9
-                    <x-filament::loading-indicator class="p-6 text-primary-700 dark:text-primary-300" />
9
+        <div class="es-table__header-ctn"></div>
10
+        <div
11
+            class="relative divide-y divide-gray-200 overflow-x-auto dark:divide-white/10 dark:border-t-white/10 min-h-64">
12
+            <div wire:init="applyFilters" class="flex items-center justify-center w-full h-full absolute">
13
+                <div wire:loading wire:target="applyFilters">
14
+                    <x-filament::loading-indicator class="p-6 text-primary-700 dark:text-primary-300"/>
10 15
                 </div>
11 16
             </div>
12 17
 
13 18
             @if($this->reportLoaded)
14
-                <div wire:loading.remove wire:target="loadReportData">
19
+                <div wire:loading.remove wire:target="applyFilters">
15 20
                     @if($this->report && !$this->tableHasEmptyState())
16
-                        <x-company.tables.reports.account-transactions :report="$this->report" />
21
+                        <x-company.tables.reports.account-transactions :report="$this->report"/>
17 22
                     @else
18 23
                         <x-filament-tables::empty-state
19 24
                             :actions="$this->getEmptyStateActions()"

+ 30
- 22
resources/views/filament/company/pages/reports/detailed-report.blade.php Wyświetl plik

@@ -1,33 +1,41 @@
1 1
 <x-filament-panels::page>
2
+    <x-filament::section>
3
+        <div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
4
+            <!-- Form Container -->
5
+            @if(method_exists($this, 'filtersForm'))
6
+                {{ $this->filtersForm }}
7
+            @endif
8
+
9
+            <!-- Grouping Button and Column Toggle -->
10
+            @if($this->hasToggleableColumns())
11
+                <x-filament-tables::column-toggle.dropdown
12
+                    :form="$this->toggleTableColumnForm"
13
+                    :trigger-action="$this->toggleColumnsAction"
14
+                />
15
+            @endif
16
+
17
+            <div class="inline-flex items-center min-w-0 md:min-w-[9.5rem] justify-end">
18
+                {{ $this->applyFiltersAction }}
19
+            </div>
20
+        </div>
21
+    </x-filament::section>
22
+
2 23
     <x-filament-tables::container>
3
-        <form wire:submit="loadReportData" class="p-6">
4
-            <div class="flex flex-col md:flex-row items-start md:items-center justify-center gap-4 md:gap-12">
5
-                {{ $this->form }}
6
-                @if($this->hasToggleableColumns())
7
-                    <x-filament-tables::column-toggle.dropdown
8
-                        :form="$this->toggleTableColumnForm"
9
-                        :trigger-action="$this->toggleColumnsAction"
10
-                    />
11
-                @endif
12
-                <x-filament::button type="submit" wire:target="loadReportData" class="flex-shrink-0">
13
-                    Update Report
14
-                </x-filament::button>
24
+        <div class="es-table__header-ctn"></div>
25
+        <div
26
+            class="relative divide-y divide-gray-200 overflow-x-auto dark:divide-white/10 dark:border-t-white/10 min-h-64">
27
+            <div wire:init="applyFilters" class="flex items-center justify-center w-full h-full absolute">
28
+                <div wire:loading wire:target="applyFilters">
29
+                    <x-filament::loading-indicator class="p-6 text-primary-700 dark:text-primary-300"/>
30
+                </div>
15 31
             </div>
16
-        </form>
17
-        <div wire:init="loadReportData"
18
-             class="relative divide-y divide-gray-200 overflow-x-auto dark:divide-white/10 dark:border-t-white/10 min-h-64">
32
+
19 33
             @if($this->reportLoaded)
20
-                <div wire:loading.remove wire:target="loadReportData">
34
+                <div wire:loading.remove wire:target="applyFilters">
21 35
                     @if($this->report)
22 36
                         <x-company.tables.reports.detailed-report :report="$this->report"/>
23 37
                     @endif
24 38
                 </div>
25
-            @else
26
-                <div class="absolute inset-0 flex items-center justify-center">
27
-                    <div wire:loading wire:target="loadReportData">
28
-                        <x-filament::loading-indicator class="p-6 text-primary-700 dark:text-primary-300"/>
29
-                    </div>
30
-                </div>
31 39
             @endif
32 40
         </div>
33 41
         <div class="es-table__footer-ctn border-t border-gray-200"></div>

+ 81
- 0
resources/views/filament/company/pages/reports/income-statement.blade.php Wyświetl plik

@@ -0,0 +1,81 @@
1
+<x-filament-panels::page>
2
+    <x-filament::section>
3
+        <div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
4
+            <!-- Form Container -->
5
+            @if(method_exists($this, 'filtersForm'))
6
+                {{ $this->filtersForm }}
7
+            @endif
8
+
9
+            <!-- Grouping Button and Column Toggle -->
10
+            @if($this->hasToggleableColumns())
11
+                <x-filament-tables::column-toggle.dropdown
12
+                    :form="$this->toggleTableColumnForm"
13
+                    :trigger-action="$this->toggleColumnsAction"
14
+                />
15
+            @endif
16
+
17
+            <div class="inline-flex items-center min-w-0 md:min-w-[9.5rem] justify-end">
18
+                {{ $this->applyFiltersAction }}
19
+            </div>
20
+        </div>
21
+    </x-filament::section>
22
+
23
+
24
+    <x-filament::section>
25
+        <!-- Summary Section -->
26
+        @if($this->reportLoaded)
27
+            <div
28
+                class="flex flex-col md:flex-row items-center md:items-end text-center justify-center gap-4 md:gap-8">
29
+                @foreach($this->report->getSummary() as $summary)
30
+                    <div class="text-sm">
31
+                        <div class="text-gray-600 font-medium mb-2">{{ $summary['label'] }}</div>
32
+
33
+                        @php
34
+                            $isNetEarnings = $summary['label'] === 'Net Earnings';
35
+                            $isPositive = money($summary['value'], \App\Utilities\Currency\CurrencyAccessor::getDefaultCurrency())->isPositive();
36
+                        @endphp
37
+
38
+                        <strong
39
+                            @class([
40
+                                'text-lg',
41
+                                'text-green-700' => $isNetEarnings && $isPositive,
42
+                                'text-danger-700' => $isNetEarnings && ! $isPositive,
43
+                            ])
44
+                        >
45
+                            {{ $summary['value'] }}
46
+                        </strong>
47
+                    </div>
48
+
49
+                    @if(! $loop->last)
50
+                        <div class="flex items-center justify-center px-2">
51
+                            <strong class="text-lg">
52
+                                {{ $loop->remaining === 1 ? '=' : '-' }}
53
+                            </strong>
54
+                        </div>
55
+                    @endif
56
+                @endforeach
57
+            </div>
58
+        @endif
59
+    </x-filament::section>
60
+
61
+    <x-filament-tables::container>
62
+        <div class="es-table__header-ctn"></div>
63
+        <div
64
+            class="relative divide-y divide-gray-200 overflow-x-auto dark:divide-white/10 dark:border-t-white/10 min-h-64">
65
+            <div wire:init="applyFilters" class="flex items-center justify-center w-full h-full absolute">
66
+                <div wire:loading wire:target="applyFilters">
67
+                    <x-filament::loading-indicator class="p-6 text-primary-700 dark:text-primary-300"/>
68
+                </div>
69
+            </div>
70
+
71
+            @if($this->reportLoaded)
72
+                <div wire:loading.remove wire:target="applyFilters">
73
+                    @if($this->report)
74
+                        <x-company.tables.reports.detailed-report :report="$this->report"/>
75
+                    @endif
76
+                </div>
77
+            @endif
78
+        </div>
79
+        <div class="es-table__footer-ctn border-t border-gray-200"></div>
80
+    </x-filament-tables::container>
81
+</x-filament-panels::page>

Ładowanie…
Anuluj
Zapisz