Преглед на файлове

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

Development 3.x
3.x
Andrew Wallo преди 1 година
родител
ревизия
daf5315ee4
No account linked to committer's email address
променени са 54 файла, в които са добавени 2473 реда и са изтрити 1469 реда
  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 Целия файл

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">
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
 This repo is currently a work in progress — PRs and issues welcome!
11
 This repo is currently a work in progress — PRs and issues welcome!
13
 
12
 
14
 # Getting started
13
 # Getting started
15
 
14
 
16
 ## Installation
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
 Clone the repository
20
 Clone the repository
21
 
21
 
77
 
77
 
78
     php artisan db:seed
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
 ## Generating PDFs for Reports
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
 ### Wkhtmltopdf Installation
90
 ### Wkhtmltopdf Installation
89
 
91
 
90
 1. **Download and install Wkhtmltopdf**
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
 2. **Configure the binary paths**
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
 ## Live Currency
107
 ## Live Currency
104
 
108
 
105
 ### Overview
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
 **Disclaimer**: There is no affiliation between this application and ExchangeRate-API.
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
 ### Initial Setup
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
 ```bash
126
 ```bash
118
 php artisan migrate:fresh
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
 ```bash
133
 ```bash
124
 php artisan currency:init
134
 php artisan currency:init
128
 
138
 
129
 ### Configuration
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
 ```php
144
 ```php
134
 'currency_api' => [
145
 'currency_api' => [
141
 
152
 
142
 ### Live Currency Page
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
 ### Important Information
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
 ## Automatic Translation
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
 ### Configuration & Usage
176
 ### Configuration & Usage
157
 
177
 
158
 To utilize this feature for additional languages or custom translations:
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
 2. Configure the package with your preferred translation service credentials.
182
 2. Configure the package with your preferred translation service credentials.
161
 3. Run the translation commands as per the package instructions to generate new translations.
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
 Change to the following:
188
 Change to the following:
189
+
166
 ```php
190
 ```php
167
 public static function getAllLanguages(): array
191
 public static function getAllLanguages(): array
168
 {
192
 {
192
 ## Dependencies
216
 ## Dependencies
193
 
217
 
194
 - [filamentphp/filament](https://github.com/filamentphp/filament) - A collection of beautiful full-stack components
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
 - [squirephp/squire](https://github.com/squirephp/squire) - A library of static Eloquent models for common fixture data
225
 - [squirephp/squire](https://github.com/squirephp/squire) - A library of static Eloquent models for common fixture data
199
 - [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. 
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
 ## License
231
 ## License
204
 
232
 

+ 14
- 5
app/Casts/TransactionAmountCast.php Целия файл

11
 
11
 
12
 class TransactionAmountCast implements CastsAttributes
12
 class TransactionAmountCast implements CastsAttributes
13
 {
13
 {
14
+    private array $currencyCache = [];
15
+
14
     public function get(Model $model, string $key, mixed $value, array $attributes): string
16
     public function get(Model $model, string $key, mixed $value, array $attributes): string
15
     {
17
     {
16
         // Attempt to retrieve the currency code from the related bankAccount->account model
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
         if ($value !== null) {
21
         if ($value !== null) {
20
-            return CurrencyConverter::prepareForMutator($value, $currency_code);
22
+            return CurrencyConverter::prepareForMutator($value, $currencyCode);
21
         }
23
         }
22
 
24
 
23
         return '';
25
         return '';
28
      */
30
      */
29
     public function set(Model $model, string $key, mixed $value, array $attributes): int
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
         if (is_numeric($value)) {
35
         if (is_numeric($value)) {
34
             $value = (string) $value;
36
             $value = (string) $value;
36
             throw new UnexpectedValueException('Expected string or numeric value for money cast');
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
             return CurrencyAccessor::getDefaultCurrency();
51
             return CurrencyAccessor::getDefaultCurrency();
50
         }
52
         }
51
 
53
 
54
+        if (isset($this->currencyCache[$bankAccountId])) {
55
+            return $this->currencyCache[$bankAccountId];
56
+        }
57
+
52
         $bankAccount = BankAccount::find($bankAccountId);
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 Целия файл

11
     public static function bootBlamable(): void
11
     public static function bootBlamable(): void
12
     {
12
     {
13
         static::creating(static function ($model) {
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
         static::updating(static function ($model) {
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 Целия файл

15
     {
15
     {
16
         static::creating(static function ($model) {
16
         static::creating(static function ($model) {
17
             if (empty($model->company_id)) {
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
                 } else {
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 Целия файл

3
 namespace App\Concerns;
3
 namespace App\Concerns;
4
 
4
 
5
 use App\Models\User;
5
 use App\Models\User;
6
-use BackedEnum;
7
 use Illuminate\Database\Eloquent\Builder;
6
 use Illuminate\Database\Eloquent\Builder;
8
 use Illuminate\Database\Eloquent\Model;
7
 use Illuminate\Database\Eloquent\Model;
9
 
8
 
11
 {
10
 {
12
     protected function handleRecordCreationWithUniqueField(array $data, Model $model, User $user, ?string $uniqueField = null, ?array $evaluatedTypes = null): Model
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
             $data['enabled'] = false;
15
             $data['enabled'] = false;
20
             $instance = $model->newInstance($data);
16
             $instance = $model->newInstance($data);
21
             $instance->save();
17
             $instance->save();
43
         return $instance;
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
     private function toggleRecords(Builder $query, bool &$shouldBeEnabled): void
42
     private function toggleRecords(Builder $query, bool &$shouldBeEnabled): void
54
     {
43
     {
55
         if ($shouldBeEnabled) {
44
         if ($shouldBeEnabled) {

+ 0
- 2
app/Contracts/AccountHandler.php Целия файл

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

+ 2
- 0
app/DTO/AccountDTO.php Целия файл

9
         public string $accountCode,
9
         public string $accountCode,
10
         public ?int $accountId,
10
         public ?int $accountId,
11
         public AccountBalanceDTO $balance,
11
         public AccountBalanceDTO $balance,
12
+        public ?string $startDate,
13
+        public ?string $endDate,
12
     ) {}
14
     ) {}
13
 }
15
 }

+ 65
- 0
app/Enums/Accounting/AccountCategory.php Целия файл

38
 
38
 
39
         return null;
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 Целия файл

6
 use App\Filament\Company\Clusters\Settings;
6
 use App\Filament\Company\Clusters\Settings;
7
 use App\Models\Banking\BankAccount;
7
 use App\Models\Banking\BankAccount;
8
 use App\Models\Setting\CompanyDefault as CompanyDefaultModel;
8
 use App\Models\Setting\CompanyDefault as CompanyDefaultModel;
9
-use App\Models\Setting\Currency;
10
 use App\Models\Setting\Discount;
9
 use App\Models\Setting\Discount;
11
 use App\Models\Setting\Tax;
10
 use App\Models\Setting\Tax;
12
 use Filament\Actions\Action;
11
 use Filament\Actions\Action;
13
 use Filament\Actions\ActionGroup;
12
 use Filament\Actions\ActionGroup;
14
 use Filament\Forms\Components\Component;
13
 use Filament\Forms\Components\Component;
14
+use Filament\Forms\Components\Placeholder;
15
 use Filament\Forms\Components\Section;
15
 use Filament\Forms\Components\Section;
16
 use Filament\Forms\Components\Select;
16
 use Filament\Forms\Components\Select;
17
 use Filament\Forms\Form;
17
 use Filament\Forms\Form;
133
                     ->selectablePlaceholder(false)
133
                     ->selectablePlaceholder(false)
134
                     ->searchable()
134
                     ->searchable()
135
                     ->preload(),
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
             ])->columns();
140
             ])->columns();
145
     }
141
     }
146
 
142
 

+ 1
- 44
app/Filament/Company/Clusters/Settings/Resources/CurrencyResource.php Целия файл

19
 use Filament\Tables;
19
 use Filament\Tables;
20
 use Filament\Tables\Table;
20
 use Filament\Tables\Table;
21
 use Illuminate\Database\Eloquent\Collection;
21
 use Illuminate\Database\Eloquent\Collection;
22
-use Wallo\FilamentSelectify\Components\ToggleButton;
23
 
22
 
24
 class CurrencyResource extends Resource
23
 class CurrencyResource extends Resource
25
 {
24
 {
51
                             ->live()
50
                             ->live()
52
                             ->required()
51
                             ->required()
53
                             ->localizeLabel()
52
                             ->localizeLabel()
54
-                            ->hidden(static fn (Forms\Get $get, $state): bool => $get('enabled') && $state !== null)
55
                             ->afterStateUpdated(static function (Forms\Set $set, $state) {
53
                             ->afterStateUpdated(static function (Forms\Set $set, $state) {
56
                                 $fields = ['name', 'precision', 'symbol', 'symbol_first', 'decimal_mark', 'thousands_separator'];
54
                                 $fields = ['name', 'precision', 'symbol', 'symbol_first', 'decimal_mark', 'thousands_separator'];
57
 
55
 
71
 
69
 
72
                                 array_walk($fields, static fn ($field) => $set($field, $currencyDetails[$field] ?? null));
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
                         Forms\Components\TextInput::make('name')
72
                         Forms\Components\TextInput::make('name')
81
                             ->localizeLabel()
73
                             ->localizeLabel()
82
                             ->maxLength(50)
74
                             ->maxLength(50)
86
                             ->rule('gt:0')
78
                             ->rule('gt:0')
87
                             ->live()
79
                             ->live()
88
                             ->localizeLabel()
80
                             ->localizeLabel()
89
-                            ->disabled(static fn (?CurrencyModel $record): bool => $record?->isEnabled() ?? false)
90
-                            ->dehydrated()
91
                             ->required(),
81
                             ->required(),
92
                         Forms\Components\Select::make('precision')
82
                         Forms\Components\Select::make('precision')
93
                             ->localizeLabel()
83
                             ->localizeLabel()
123
                                 };
113
                                 };
124
                             })
114
                             })
125
                             ->nullable(),
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
                     ])->columns(),
116
                     ])->columns(),
157
             ]);
117
             ]);
158
     }
118
     }
230
             ])
190
             ])
231
             ->checkIfRecordIsSelectableUsing(static function (CurrencyModel $record) {
191
             ->checkIfRecordIsSelectableUsing(static function (CurrencyModel $record) {
232
                 return $record->isDisabled();
192
                 return $record->isDisabled();
233
-            })
234
-            ->emptyStateActions([
235
-                Tables\Actions\CreateAction::make(),
236
-            ]);
193
+            });
237
     }
194
     }
238
 
195
 
239
     public static function getPages(): array
196
     public static function getPages(): array

+ 0
- 28
app/Filament/Company/Clusters/Settings/Resources/CurrencyResource/Pages/CreateCurrency.php Целия файл

2
 
2
 
3
 namespace App\Filament\Company\Clusters\Settings\Resources\CurrencyResource\Pages;
3
 namespace App\Filament\Company\Clusters\Settings\Resources\CurrencyResource\Pages;
4
 
4
 
5
-use App\Concerns\HandlesResourceRecordCreation;
6
 use App\Filament\Company\Clusters\Settings\Resources\CurrencyResource;
5
 use App\Filament\Company\Clusters\Settings\Resources\CurrencyResource;
7
-use App\Models\Setting\Currency;
8
 use Filament\Resources\Pages\CreateRecord;
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
 class CreateCurrency extends CreateRecord
8
 class CreateCurrency extends CreateRecord
14
 {
9
 {
15
-    use HandlesResourceRecordCreation;
16
-
17
     protected static string $resource = CurrencyResource::class;
10
     protected static string $resource = CurrencyResource::class;
18
 
11
 
19
     protected function getRedirectUrl(): string
12
     protected function getRedirectUrl(): string
20
     {
13
     {
21
         return $this->getResource()::getUrl('index');
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 Целия файл

2
 
2
 
3
 namespace App\Filament\Company\Clusters\Settings\Resources\CurrencyResource\Pages;
3
 namespace App\Filament\Company\Clusters\Settings\Resources\CurrencyResource\Pages;
4
 
4
 
5
-use App\Concerns\HandlesResourceRecordUpdate;
6
 use App\Filament\Company\Clusters\Settings\Resources\CurrencyResource;
5
 use App\Filament\Company\Clusters\Settings\Resources\CurrencyResource;
7
-use App\Models\Setting\Currency;
8
 use Filament\Actions;
6
 use Filament\Actions;
9
 use Filament\Resources\Pages\EditRecord;
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
 class EditCurrency extends EditRecord
9
 class EditCurrency extends EditRecord
15
 {
10
 {
16
-    use HandlesResourceRecordUpdate;
17
-
18
     protected static string $resource = CurrencyResource::class;
11
     protected static string $resource = CurrencyResource::class;
19
 
12
 
20
     protected function getHeaderActions(): array
13
     protected function getHeaderActions(): array
28
     {
21
     {
29
         return $this->getResource()::getUrl('index');
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 Целия файл

10
 use App\Filament\Company\Pages\Service\ConnectedAccount;
10
 use App\Filament\Company\Pages\Service\ConnectedAccount;
11
 use App\Filament\Forms\Components\DateRangeSelect;
11
 use App\Filament\Forms\Components\DateRangeSelect;
12
 use App\Filament\Forms\Components\JournalEntryRepeater;
12
 use App\Filament\Forms\Components\JournalEntryRepeater;
13
+use App\Filament\Tables\Actions\ReplicateBulkAction;
13
 use App\Models\Accounting\Account;
14
 use App\Models\Accounting\Account;
14
 use App\Models\Accounting\JournalEntry;
15
 use App\Models\Accounting\JournalEntry;
15
 use App\Models\Accounting\Transaction;
16
 use App\Models\Accounting\Transaction;
216
         return $table
217
         return $table
217
             ->query(static::getEloquentQuery())
218
             ->query(static::getEloquentQuery())
218
             ->modifyQueryUsing(function (Builder $query) {
219
             ->modifyQueryUsing(function (Builder $query) {
220
+                $query->with([
221
+                    'account',
222
+                    'bankAccount.account',
223
+                    'journalEntries.account',
224
+                ]);
225
+
219
                 if ($this->bankAccountIdFiltered !== 'all') {
226
                 if ($this->bankAccountIdFiltered !== 'all') {
220
                     $query->where('bank_account_id', $this->bankAccountIdFiltered);
227
                     $query->where('bank_account_id', $this->bankAccountIdFiltered);
221
                 }
228
                 }
224
                 Tables\Columns\TextColumn::make('posted_at')
231
                 Tables\Columns\TextColumn::make('posted_at')
225
                     ->label('Date')
232
                     ->label('Date')
226
                     ->sortable()
233
                     ->sortable()
227
-                    ->localizeDate(),
234
+                    ->defaultDateFormat(),
228
                 Tables\Columns\TextColumn::make('description')
235
                 Tables\Columns\TextColumn::make('description')
229
                     ->label('Description')
236
                     ->label('Description')
230
                     ->limit(30)
237
                     ->limit(30)
373
                     Tables\Actions\ReplicateAction::make()
380
                     Tables\Actions\ReplicateAction::make()
374
                         ->excludeAttributes(['created_by', 'updated_by', 'created_at', 'updated_at'])
381
                         ->excludeAttributes(['created_by', 'updated_by', 'created_at', 'updated_at'])
375
                         ->modal(false)
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
                     ->dropdownPlacement('bottom-start')
396
                     ->dropdownPlacement('bottom-start')
383
             ->bulkActions([
399
             ->bulkActions([
384
                 Tables\Actions\BulkActionGroup::make([
400
                 Tables\Actions\BulkActionGroup::make([
385
                     Tables\Actions\DeleteBulkAction::make(),
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
                             ->endDateField("{$fieldPrefix}_end_date"),
658
                             ->endDateField("{$fieldPrefix}_end_date"),
631
                         DatePicker::make("{$fieldPrefix}_start_date")
659
                         DatePicker::make("{$fieldPrefix}_start_date")
632
                             ->label("{$label} From")
660
                             ->label("{$label} From")
633
-                            ->displayFormat('Y-m-d')
634
                             ->columnStart(1)
661
                             ->columnStart(1)
635
                             ->afterStateUpdated(static function (Set $set) use ($fieldPrefix) {
662
                             ->afterStateUpdated(static function (Set $set) use ($fieldPrefix) {
636
                                 $set("{$fieldPrefix}_date_range", 'Custom');
663
                                 $set("{$fieldPrefix}_date_range", 'Custom');
637
                             }),
664
                             }),
638
                         DatePicker::make("{$fieldPrefix}_end_date")
665
                         DatePicker::make("{$fieldPrefix}_end_date")
639
                             ->label("{$label} To")
666
                             ->label("{$label} To")
640
-                            ->displayFormat('Y-m-d')
641
                             ->afterStateUpdated(static function (Set $set) use ($fieldPrefix) {
667
                             ->afterStateUpdated(static function (Set $set) use ($fieldPrefix) {
642
                                 $set("{$fieldPrefix}_date_range", 'Custom');
668
                                 $set("{$fieldPrefix}_date_range", 'Custom');
643
                             }),
669
                             }),

+ 179
- 0
app/Filament/Company/Pages/Concerns/HasDeferredFiltersForm.php Целия файл

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 Целия файл

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 Целия файл

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 Целия файл

4
 
4
 
5
 use App\Filament\Company\Pages\Reports\AccountBalances;
5
 use App\Filament\Company\Pages\Reports\AccountBalances;
6
 use App\Filament\Company\Pages\Reports\AccountTransactions;
6
 use App\Filament\Company\Pages\Reports\AccountTransactions;
7
+use App\Filament\Company\Pages\Reports\IncomeStatement;
7
 use App\Filament\Company\Pages\Reports\TrialBalance;
8
 use App\Filament\Company\Pages\Reports\TrialBalance;
8
 use App\Infolists\Components\ReportEntry;
9
 use App\Infolists\Components\ReportEntry;
9
 use Filament\Infolists\Components\Section;
10
 use Filament\Infolists\Components\Section;
22
         return $infolist
23
         return $infolist
23
             ->state([])
24
             ->state([])
24
             ->schema([
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
                 Section::make('Detailed Reports')
53
                 Section::make('Detailed Reports')
26
                     ->aside()
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
                     ->extraAttributes(['class' => 'es-report-card'])
56
                     ->extraAttributes(['class' => 'es-report-card'])
29
                     ->schema([
57
                     ->schema([
30
                         ReportEntry::make('account_balances')
58
                         ReportEntry::make('account_balances')

+ 4
- 5
app/Filament/Company/Pages/Reports/AccountBalances.php Целия файл

67
         ];
67
         ];
68
     }
68
     }
69
 
69
 
70
-    public function form(Form $form): Form
70
+    public function filtersForm(Form $form): Form
71
     {
71
     {
72
         return $form
72
         return $form
73
             ->inlineLabel()
73
             ->inlineLabel()
74
             ->columns()
74
             ->columns()
75
-            ->live()
76
             ->schema([
75
             ->schema([
77
                 $this->getDateRangeFormComponent(),
76
                 $this->getDateRangeFormComponent(),
78
                 Cluster::make([
77
                 Cluster::make([
84
 
83
 
85
     protected function buildReport(array $columns): ReportDTO
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
     protected function getTransformer(ReportDTO $reportDTO): ExportableReport
89
     protected function getTransformer(ReportDTO $reportDTO): ExportableReport
94
 
93
 
95
     public function exportCSV(): StreamedResponse
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
     public function exportPDF(): StreamedResponse
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 Целия файл

14
 use Filament\Forms\Components\Select;
14
 use Filament\Forms\Components\Select;
15
 use Filament\Forms\Form;
15
 use Filament\Forms\Form;
16
 use Filament\Support\Enums\Alignment;
16
 use Filament\Support\Enums\Alignment;
17
+use Filament\Support\Enums\MaxWidth;
17
 use Filament\Tables\Actions\Action;
18
 use Filament\Tables\Actions\Action;
18
 use Guava\FilamentClusters\Forms\Cluster;
19
 use Guava\FilamentClusters\Forms\Cluster;
19
 use Illuminate\Contracts\Support\Htmlable;
20
 use Illuminate\Contracts\Support\Htmlable;
20
 use Illuminate\Support\Collection;
21
 use Illuminate\Support\Collection;
21
-use Livewire\Attributes\Url;
22
 use Symfony\Component\HttpFoundation\StreamedResponse;
22
 use Symfony\Component\HttpFoundation\StreamedResponse;
23
 
23
 
24
 class AccountTransactions extends BaseReportPage
24
 class AccountTransactions extends BaseReportPage
33
 
33
 
34
     protected ExportService $exportService;
34
     protected ExportService $exportService;
35
 
35
 
36
-    #[Url]
37
-    public ?string $account_id = 'all';
38
-
39
     public function boot(ReportService $reportService, ExportService $exportService): void
36
     public function boot(ReportService $reportService, ExportService $exportService): void
40
     {
37
     {
41
         $this->reportService = $reportService;
38
         $this->reportService = $reportService;
42
         $this->exportService = $exportService;
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
      * @return array<Column>
55
      * @return array<Column>
47
      */
56
      */
50
         return [
59
         return [
51
             Column::make('date')
60
             Column::make('date')
52
                 ->label('Date')
61
                 ->label('Date')
62
+                ->markAsDate()
53
                 ->alignment(Alignment::Left),
63
                 ->alignment(Alignment::Left),
54
             Column::make('description')
64
             Column::make('description')
55
                 ->label('Description')
65
                 ->label('Description')
66
         ];
76
         ];
67
     }
77
     }
68
 
78
 
69
-    public function form(Form $form): Form
79
+    public function filtersForm(Form $form): Form
70
     {
80
     {
71
         return $form
81
         return $form
72
             ->columns(4)
82
             ->columns(4)
73
-            ->live()
74
             ->schema([
83
             ->schema([
75
-                Select::make('account_id')
84
+                Select::make('selectedAccount')
76
                     ->label('Account')
85
                     ->label('Account')
77
                     ->options($this->getAccountOptions())
86
                     ->options($this->getAccountOptions())
78
                     ->selectablePlaceholder(false)
87
                     ->selectablePlaceholder(false)
83
                     $this->getEndDateFormComponent(),
92
                     $this->getEndDateFormComponent(),
84
                 ])->label("\u{200B}"), // its too bad hiddenLabel removes spacing of the label
93
                 ])->label("\u{200B}"), // its too bad hiddenLabel removes spacing of the label
85
                 Actions::make([
94
                 Actions::make([
86
-                    Actions\Action::make('loadReportData')
95
+                    Actions\Action::make('applyFilters')
87
                         ->label('Update Report')
96
                         ->label('Update Report')
88
-                        ->submit('loadReportData')
89
-                        ->keyBindings(['mod+s']),
97
+                        ->action('applyFilters')
98
+                        ->keyBindings(['mod+s'])
99
+                        ->button(),
90
                 ])->alignEnd()->verticallyAlignEnd(),
100
                 ])->alignEnd()->verticallyAlignEnd(),
91
             ]);
101
             ]);
92
     }
102
     }
96
         $accounts = Account::query()
106
         $accounts = Account::query()
97
             ->get()
107
             ->get()
98
             ->groupBy(fn (Account $account) => $account->category->getPluralLabel())
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
             ->toArray();
110
             ->toArray();
101
 
111
 
102
         $allAccountsOption = [
112
         $allAccountsOption = [
108
 
118
 
109
     protected function buildReport(array $columns): ReportDTO
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
     protected function getTransformer(ReportDTO $reportDTO): ExportableReport
124
     protected function getTransformer(ReportDTO $reportDTO): ExportableReport
118
 
128
 
119
     public function exportCSV(): StreamedResponse
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
     public function exportPDF(): StreamedResponse
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
     public function getEmptyStateHeading(): string | Htmlable
139
     public function getEmptyStateHeading(): string | Htmlable

+ 28
- 107
app/Filament/Company/Pages/Reports/BaseReportPage.php Целия файл

4
 
4
 
5
 use App\Contracts\ExportableReport;
5
 use App\Contracts\ExportableReport;
6
 use App\DTO\ReportDTO;
6
 use App\DTO\ReportDTO;
7
+use App\Filament\Company\Pages\Concerns\HasDeferredFiltersForm;
8
+use App\Filament\Company\Pages\Concerns\HasToggleTableColumnForm;
7
 use App\Filament\Forms\Components\DateRangeSelect;
9
 use App\Filament\Forms\Components\DateRangeSelect;
8
 use App\Models\Company;
10
 use App\Models\Company;
11
+use App\Services\DateRangeService;
9
 use App\Support\Column;
12
 use App\Support\Column;
10
 use Filament\Actions\Action;
13
 use Filament\Actions\Action;
11
 use Filament\Actions\ActionGroup;
14
 use Filament\Actions\ActionGroup;
12
-use Filament\Forms\Components\Checkbox;
13
 use Filament\Forms\Components\Component;
15
 use Filament\Forms\Components\Component;
14
 use Filament\Forms\Components\DatePicker;
16
 use Filament\Forms\Components\DatePicker;
15
 use Filament\Forms\Form;
17
 use Filament\Forms\Form;
16
 use Filament\Forms\Set;
18
 use Filament\Forms\Set;
17
 use Filament\Pages\Page;
19
 use Filament\Pages\Page;
18
-use Filament\Support\Enums\ActionSize;
19
 use Filament\Support\Enums\IconPosition;
20
 use Filament\Support\Enums\IconPosition;
20
 use Filament\Support\Enums\IconSize;
21
 use Filament\Support\Enums\IconSize;
21
-use Filament\Support\Facades\FilamentIcon;
22
 use Illuminate\Support\Carbon;
22
 use Illuminate\Support\Carbon;
23
 use Livewire\Attributes\Computed;
23
 use Livewire\Attributes\Computed;
24
-use Livewire\Attributes\Session;
25
 use Symfony\Component\HttpFoundation\StreamedResponse;
24
 use Symfony\Component\HttpFoundation\StreamedResponse;
26
 
25
 
27
 abstract class BaseReportPage extends Page
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
     public Company $company;
35
     public Company $company;
40
 
36
 
41
     public bool $reportLoaded = false;
37
     public bool $reportLoaded = false;
42
 
38
 
43
-    #[Session]
44
-    public array $toggledTableColumns = [];
45
-
46
     abstract protected function buildReport(array $columns): ReportDTO;
39
     abstract protected function buildReport(array $columns): ReportDTO;
47
 
40
 
48
     abstract public function exportCSV(): StreamedResponse;
41
     abstract public function exportCSV(): StreamedResponse;
61
         $this->initializeProperties();
54
         $this->initializeProperties();
62
 
55
 
63
         $this->loadDefaultDateRange();
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
     protected function initializeProperties(): void
59
     protected function initializeProperties(): void
82
 
65
 
83
     protected function loadDefaultDateRange(): void
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
             $this->setDateRange(Carbon::parse($this->fiscalYearStartDate), Carbon::parse($this->fiscalYearEndDate));
76
             $this->setDateRange(Carbon::parse($this->fiscalYearStartDate), Carbon::parse($this->fiscalYearEndDate));
88
         }
77
         }
89
     }
78
     }
91
     public function loadReportData(): void
80
     public function loadReportData(): void
92
     {
81
     {
93
         unset($this->report);
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
     public function getDefaultDateRange(): string
87
     public function getDefaultDateRange(): string
129
         return 'FY-' . now()->year;
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
     #[Computed(persist: true)]
92
     #[Computed(persist: true)]
143
     public function report(): ?ExportableReport
93
     public function report(): ?ExportableReport
144
     {
94
     {
154
 
104
 
155
     public function setDateRange(Carbon $start, Carbon $end): void
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
     protected function getHeaderActions(): array
127
     protected function getHeaderActions(): array
228
             ->endDateField('endDate');
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
     protected function getStartDateFormComponent(): Component
158
     protected function getStartDateFormComponent(): Component
238
     {
159
     {
239
         return DatePicker::make('startDate')
160
         return DatePicker::make('startDate')
240
             ->label('Start Date')
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
                 $set('dateRange', 'Custom');
164
                 $set('dateRange', 'Custom');
244
             });
165
             });
245
     }
166
     }
248
     {
169
     {
249
         return DatePicker::make('endDate')
170
         return DatePicker::make('endDate')
250
             ->label('End Date')
171
             ->label('End Date')
251
-            ->displayFormat('Y-m-d')
172
+            ->live()
252
             ->afterStateUpdated(static function (Set $set) {
173
             ->afterStateUpdated(static function (Set $set) {
253
                 $set('dateRange', 'Custom');
174
                 $set('dateRange', 'Custom');
254
             });
175
             });

+ 83
- 0
app/Filament/Company/Pages/Reports/IncomeStatement.php Целия файл

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 Целия файл

50
         ];
50
         ];
51
     }
51
     }
52
 
52
 
53
-    public function form(Form $form): Form
53
+    public function filtersForm(Form $form): Form
54
     {
54
     {
55
         return $form
55
         return $form
56
             ->inlineLabel()
56
             ->inlineLabel()
57
-            ->columns([
58
-                'lg' => 1,
59
-                '2xl' => 2,
60
-            ])
61
-            ->live()
57
+            ->columns()
62
             ->schema([
58
             ->schema([
63
                 $this->getDateRangeFormComponent(),
59
                 $this->getDateRangeFormComponent(),
64
                 Cluster::make([
60
                 Cluster::make([
70
 
66
 
71
     protected function buildReport(array $columns): ReportDTO
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
     protected function getTransformer(ReportDTO $reportDTO): ExportableReport
72
     protected function getTransformer(ReportDTO $reportDTO): ExportableReport
80
 
76
 
81
     public function exportCSV(): StreamedResponse
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
     public function exportPDF(): StreamedResponse
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 Целия файл

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

+ 21
- 0
app/Filament/Company/Resources/Banking/AccountResource/Pages/CreateAccount.php Целия файл

2
 
2
 
3
 namespace App\Filament\Company\Resources\Banking\AccountResource\Pages;
3
 namespace App\Filament\Company\Resources\Banking\AccountResource\Pages;
4
 
4
 
5
+use App\Concerns\HandlesResourceRecordCreation;
5
 use App\Filament\Company\Resources\Banking\AccountResource;
6
 use App\Filament\Company\Resources\Banking\AccountResource;
7
+use App\Models\Banking\BankAccount;
6
 use Filament\Resources\Pages\CreateRecord;
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
 class CreateAccount extends CreateRecord
13
 class CreateAccount extends CreateRecord
9
 {
14
 {
15
+    use HandlesResourceRecordCreation;
16
+
10
     protected static string $resource = AccountResource::class;
17
     protected static string $resource = AccountResource::class;
11
 
18
 
12
     protected function getRedirectUrl(): string
19
     protected function getRedirectUrl(): string
20
 
27
 
21
         return $data;
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 Целия файл

5
 use App\Concerns\HandlesResourceRecordUpdate;
5
 use App\Concerns\HandlesResourceRecordUpdate;
6
 use App\Filament\Company\Resources\Banking\AccountResource;
6
 use App\Filament\Company\Resources\Banking\AccountResource;
7
 use Filament\Resources\Pages\EditRecord;
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
 class EditAccount extends EditRecord
12
 class EditAccount extends EditRecord
10
 {
13
 {
30
 
33
 
31
         return $data;
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 Целия файл

2
 
2
 
3
 namespace App\Filament\Forms\Components;
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
 use Filament\Forms\Components\Select;
6
 use Filament\Forms\Components\Select;
9
 use Filament\Forms\Set;
7
 use Filament\Forms\Set;
10
 use Illuminate\Support\Carbon;
8
 use Illuminate\Support\Carbon;
11
 
9
 
12
 class DateRangeSelect extends Select
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
     protected function setUp(): void
20
     protected function setUp(): void
25
     {
21
     {
26
         parent::setUp();
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
             ->afterStateUpdated(function ($state, Set $set) {
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
         return $this;
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
     public function updateDateRange($state, Set $set): void
51
     public function updateDateRange($state, Set $set): void
101
     {
52
     {
102
         if ($state === null) {
53
         if ($state === null) {
165
 
116
 
166
     public function setDateRange(Carbon $start, Carbon $end, Set $set): void
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 Целия файл

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 Целия файл

26
     public function handle(CompanyConfigured $event): void
26
     public function handle(CompanyConfigured $event): void
27
     {
27
     {
28
         $company = $event->company;
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
         $paginationPageOptions = RecordsPerPage::caseValues();
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
             $table
51
             $table
47
                 ->paginationPageOptions($paginationPageOptions)
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
         }, isImportant: true);
55
         }, isImportant: true);
51
 
56
 
52
         FilamentColor::register([
57
         FilamentColor::register([
53
-            'primary' => $defaultPrimaryColor->getColor(),
58
+            'primary' => PrimaryColor::from(session('default_primary_color'))->getColor(),
54
         ]);
59
         ]);
55
 
60
 
56
         Filament::getPanel('company')
61
         Filament::getPanel('company')
57
-            ->font($defaultFont)
62
+            ->font(session('default_font'))
58
             ->brandName($company->name);
63
             ->brandName($company->name);
59
 
64
 
60
-        DatePicker::configureUsing(static function (DatePicker $component) use ($dateFormat, $weekStart) {
65
+        DatePicker::configureUsing(static function (DatePicker $component) {
61
             $component
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
         Tab::configureUsing(static function (Tab $tab) {
71
         Tab::configureUsing(static function (Tab $tab) {
67
             $label = $tab->getLabel();
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
         }, isImportant: true);
79
         }, isImportant: true);
73
 
80
 
74
         Section::configureUsing(static function (Section $section): void {
81
         Section::configureUsing(static function (Section $section): void {
75
             $heading = $section->getHeading();
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
         }, isImportant: true);
89
         }, isImportant: true);
81
 
90
 
82
         ResourcesTab::configureUsing(static function (ResourcesTab $tab): void {
91
         ResourcesTab::configureUsing(static function (ResourcesTab $tab): void {

+ 0
- 1
app/Listeners/SyncAssociatedModels.php Целия файл

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

+ 11
- 7
app/Models/Setting/Localization.php Целия файл

83
 
83
 
84
     public function fiscalYearStartDate(): string
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
     public function fiscalYearEndDate(): string
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
     public function getDateTimeFormatAttribute(): string
105
     public function getDateTimeFormatAttribute(): string

+ 2
- 1
app/Observers/TransactionObserver.php Целия файл

17
      */
17
      */
18
     public function created(Transaction $transaction): void
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
             return;
22
             return;
22
         }
23
         }
23
 
24
 

+ 2
- 1
app/Providers/AppServiceProvider.php Целия файл

4
 
4
 
5
 use App\Contracts\AccountHandler;
5
 use App\Contracts\AccountHandler;
6
 use App\Services\AccountService;
6
 use App\Services\AccountService;
7
+use App\Services\DateRangeService;
7
 use BezhanSalleh\PanelSwitch\PanelSwitch;
8
 use BezhanSalleh\PanelSwitch\PanelSwitch;
8
 use Filament\Notifications\Livewire\Notifications;
9
 use Filament\Notifications\Livewire\Notifications;
9
 use Filament\Support\Assets\Js;
10
 use Filament\Support\Assets\Js;
25
      */
26
      */
26
     public function register(): void
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 Целия файл

11
 use App\Utilities\Currency\CurrencyAccessor;
11
 use App\Utilities\Currency\CurrencyAccessor;
12
 use BackedEnum;
12
 use BackedEnum;
13
 use Closure;
13
 use Closure;
14
+use Filament\Forms\Components\DatePicker;
14
 use Filament\Forms\Components\Field;
15
 use Filament\Forms\Components\Field;
15
 use Filament\Forms\Components\TextInput;
16
 use Filament\Forms\Components\TextInput;
16
 use Filament\Tables\Columns\TextColumn;
17
 use Filament\Tables\Columns\TextColumn;
54
             return $this;
55
             return $this;
55
         });
56
         });
56
 
57
 
57
-        TextColumn::macro('localizeDate', function (): static {
58
+        TextColumn::macro('defaultDateFormat', function (): static {
58
             $localization = Localization::firstOrFail();
59
             $localization = Localization::firstOrFail();
59
 
60
 
60
             $dateFormat = $localization->date_format->value ?? DateFormat::DEFAULT;
61
             $dateFormat = $localization->date_format->value ?? DateFormat::DEFAULT;
65
             return $this;
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
         TextColumn::macro('currency', function (string | Closure | null $currency = null, ?bool $convert = null): static {
81
         TextColumn::macro('currency', function (string | Closure | null $currency = null, ?bool $convert = null): static {
69
             $this->formatStateUsing(static function (TextColumn $column, $state) use ($currency, $convert): ?string {
82
             $this->formatStateUsing(static function (TextColumn $column, $state) use ($currency, $convert): ?string {
70
                 if (blank($state)) {
83
                 if (blank($state)) {
209
 
222
 
210
             return '';
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 Целия файл

4
 
4
 
5
 use App\Models\Accounting\Account;
5
 use App\Models\Accounting\Account;
6
 use Illuminate\Database\Eloquent\Builder;
6
 use Illuminate\Database\Eloquent\Builder;
7
-use Illuminate\Support\Carbon;
8
 
7
 
9
 class JournalEntryRepository
8
 class JournalEntryRepository
10
 {
9
 {
13
         $query = $account->journalEntries()->where('type', $type);
12
         $query = $account->journalEntries()->where('type', $type);
14
 
13
 
15
         if ($startDate && $endDate) {
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
         } elseif ($startDate) {
18
         } elseif ($startDate) {
21
             $query->whereHas('transaction', static function (Builder $query) use ($startDate) {
19
             $query->whereHas('transaction', static function (Builder $query) use ($startDate) {

+ 23
- 2
app/Scopes/CurrentCompanyScope.php Целия файл

4
 
4
 
5
 use Illuminate\Database\Eloquent\Builder;
5
 use Illuminate\Database\Eloquent\Builder;
6
 use Illuminate\Database\Eloquent\Model;
6
 use Illuminate\Database\Eloquent\Model;
7
+use Illuminate\Database\Eloquent\ModelNotFoundException;
7
 use Illuminate\Database\Eloquent\Scope;
8
 use Illuminate\Database\Eloquent\Scope;
8
 use Illuminate\Support\Facades\Auth;
9
 use Illuminate\Support\Facades\Auth;
10
+use Illuminate\Support\Facades\Log;
9
 
11
 
10
 class CurrentCompanyScope implements Scope
12
 class CurrentCompanyScope implements Scope
11
 {
13
 {
14
      */
16
      */
15
     public function apply(Builder $builder, Model $model): void
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 Целия файл

10
 use App\Repositories\Accounting\JournalEntryRepository;
10
 use App\Repositories\Accounting\JournalEntryRepository;
11
 use App\Utilities\Currency\CurrencyAccessor;
11
 use App\Utilities\Currency\CurrencyAccessor;
12
 use App\ValueObjects\Money;
12
 use App\ValueObjects\Money;
13
+use Closure;
14
+use Illuminate\Database\Eloquent\Builder;
15
+use Illuminate\Database\Query\JoinClause;
13
 use Illuminate\Support\Facades\DB;
16
 use Illuminate\Support\Facades\DB;
14
 
17
 
15
 class AccountService implements AccountHandler
18
 class AccountService implements AccountHandler
50
         return new Money($balances['starting_balance'], $account->currency_code);
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
     public function getEndingBalance(Account $account, string $startDate, string $endDate): ?Money
168
     public function getEndingBalance(Account $account, string $startDate, string $endDate): ?Money
54
     {
169
     {
55
         $calculatedBalances = $this->calculateBalances($account, $startDate, $endDate);
170
         $calculatedBalances = $this->calculateBalances($account, $startDate, $endDate);
73
         })
188
         })
74
             ->where('type', 'credit')
189
             ->where('type', 'credit')
75
             ->whereHas('transaction', static function ($query) use ($startDate) {
190
             ->whereHas('transaction', static function ($query) use ($startDate) {
76
-                $query->where('posted_at', '<=', $startDate);
191
+                $query->where('posted_at', '<', $startDate);
77
             })
192
             })
78
             ->sum('amount');
193
             ->sum('amount');
79
 
194
 
82
         })
197
         })
83
             ->where('type', 'debit')
198
             ->where('type', 'debit')
84
             ->whereHas('transaction', static function ($query) use ($startDate) {
199
             ->whereHas('transaction', static function ($query) use ($startDate) {
85
-                $query->where('posted_at', '<=', $startDate);
200
+                $query->where('posted_at', '<', $startDate);
86
             })
201
             })
87
             ->sum('amount');
202
             ->sum('amount');
88
 
203
 
149
         return array_filter($balances, static fn ($value) => $value !== null);
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
     public function getEarliestTransactionDate(): string
267
     public function getEarliestTransactionDate(): string
204
     {
268
     {
205
         $earliestDate = Transaction::oldest('posted_at')
269
         $earliestDate = Transaction::oldest('posted_at')

+ 145
- 0
app/Services/DateRangeService.php Целия файл

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 Целия файл

4
 
4
 
5
 use App\Contracts\ExportableReport;
5
 use App\Contracts\ExportableReport;
6
 use App\Models\Company;
6
 use App\Models\Company;
7
+use App\Support\Column;
7
 use Barryvdh\Snappy\Facades\SnappyPdf;
8
 use Barryvdh\Snappy\Facades\SnappyPdf;
9
+use Carbon\Exceptions\InvalidFormatException;
8
 use Illuminate\Support\Carbon;
10
 use Illuminate\Support\Carbon;
9
 use Symfony\Component\HttpFoundation\StreamedResponse;
11
 use Symfony\Component\HttpFoundation\StreamedResponse;
10
 
12
 
11
 class ExportService
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
         $filename = $company->name . ' ' . $report->getTitle() . ' ' . $formattedStartDate . ' to ' . $formattedEndDate . ' ' . $timestamp . '.csv';
22
         $filename = $company->name . ' ' . $report->getTitle() . ' ' . $formattedStartDate . ' to ' . $formattedEndDate . ' ' . $timestamp . '.csv';
21
 
23
 
24
             'Content-Disposition' => 'attachment; filename="' . $filename . '"',
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
             $file = fopen('php://output', 'wb');
30
             $file = fopen('php://output', 'wb');
29
 
31
 
32
+            $defaultStartDateFormat = Carbon::parse($startDate)->toDefaultDateFormat();
33
+            $defaultEndDateFormat = Carbon::parse($endDate)->toDefaultDateFormat();
34
+
30
             fputcsv($file, [$report->getTitle()]);
35
             fputcsv($file, [$report->getTitle()]);
31
             fputcsv($file, [$company->name]);
36
             fputcsv($file, [$company->name]);
32
-            fputcsv($file, ['Date Range: ' . $formattedStartDate . ' to ' . $formattedEndDate]);
37
+            fputcsv($file, ['Date Range: ' . $defaultStartDateFormat . ' to ' . $defaultEndDateFormat]);
33
             fputcsv($file, []);
38
             fputcsv($file, []);
34
 
39
 
35
             fputcsv($file, $report->getHeaders());
40
             fputcsv($file, $report->getHeaders());
44
                 }
49
                 }
45
 
50
 
46
                 foreach ($category->data as $accountRow) {
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
                 if (filled($category->summary)) {
78
                 if (filled($category->summary)) {
66
 
94
 
67
     public function exportToPdf(Company $company, ExportableReport $report, string $startDate, string $endDate): StreamedResponse
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
         $filename = $company->name . ' ' . $report->getTitle() . ' ' . $formattedStartDate . ' to ' . $formattedEndDate . ' ' . $timestamp . '.pdf';
102
         $filename = $company->name . ' ' . $report->getTitle() . ' ' . $formattedStartDate . ' to ' . $formattedEndDate . ' ' . $timestamp . '.pdf';
75
 
103
 
76
         $pdf = SnappyPdf::loadView($report->getPdfView(), [
104
         $pdf = SnappyPdf::loadView($report->getPdfView(), [
77
             'company' => $company,
105
             'company' => $company,
78
             'report' => $report,
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
         return response()->streamDownload(function () use ($pdf) {
111
         return response()->streamDownload(function () use ($pdf) {

+ 238
- 142
app/Services/ReportService.php Целия файл

11
 use App\Models\Accounting\Account;
11
 use App\Models\Accounting\Account;
12
 use App\Support\Column;
12
 use App\Support\Column;
13
 use App\Utilities\Currency\CurrencyAccessor;
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
 class ReportService
17
 class ReportService
19
 {
18
 {
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
     public function buildAccountTransactionsReport(string $startDate, string $endDate, ?array $columns = null, ?string $accountId = 'all'): ReportDTO
146
     public function buildAccountTransactionsReport(string $startDate, string $endDate, ?array $columns = null, ?string $accountId = 'all'): ReportDTO
78
     {
147
     {
79
         $columns ??= [];
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
         foreach ($accounts as $account) {
159
         foreach ($accounts as $account) {
108
             $accountTransactions = [];
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
             $accountTransactions[] = new AccountTransactionDTO(
163
             $accountTransactions[] = new AccountTransactionDTO(
116
                 id: null,
164
                 id: null,
118
                 description: '',
166
                 description: '',
119
                 debit: '',
167
                 debit: '',
120
                 credit: '',
168
                 credit: '',
121
-                balance: $startingBalance?->formatInDefaultCurrency() ?? 0,
169
+                balance: money($currentBalance, $defaultCurrency)->format(),
122
                 type: null,
170
                 type: null,
123
-                tableAction: null,
171
+                tableAction: null
124
             );
172
             );
125
 
173
 
174
+            /** @var Account $account */
126
             foreach ($account->journalEntries as $journalEntry) {
175
             foreach ($account->journalEntries as $journalEntry) {
127
                 $transaction = $journalEntry->transaction;
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
                 $accountTransactions[] = new AccountTransactionDTO(
187
                 $accountTransactions[] = new AccountTransactionDTO(
135
                     id: $transaction->id,
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
                     balance: money($currentBalance, $defaultCurrency)->format(),
193
                     balance: money($currentBalance, $defaultCurrency)->format(),
141
                     type: $transaction->type,
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
             $accountTransactions[] = new AccountTransactionDTO(
201
             $accountTransactions[] = new AccountTransactionDTO(
149
                 id: null,
202
                 id: null,
150
                 date: 'Totals and Ending Balance',
203
                 date: 'Totals and Ending Balance',
151
                 description: '',
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
                 balance: money($currentBalance, $defaultCurrency)->format(),
207
                 balance: money($currentBalance, $defaultCurrency)->format(),
155
                 type: null,
208
                 type: null,
156
-                tableAction: null,
209
+                tableAction: null
157
             );
210
             );
158
 
211
 
159
             $accountTransactions[] = new AccountTransactionDTO(
212
             $accountTransactions[] = new AccountTransactionDTO(
164
                 credit: '',
217
                 credit: '',
165
                 balance: money($balanceChange, $defaultCurrency)->format(),
218
                 balance: money($balanceChange, $defaultCurrency)->format(),
166
                 type: null,
219
                 type: null,
167
-                tableAction: null,
220
+                tableAction: null
168
             );
221
             );
169
 
222
 
170
             $reportCategories[] = [
223
             $reportCategories[] = [
177
         return new ReportDTO(categories: $reportCategories, fields: $columns);
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
         $accountCategories = [];
241
         $accountCategories = [];
183
         $reportTotalBalances = array_fill_keys($balanceFields, 0);
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
             $categoryAccounts = [];
250
             $categoryAccounts = [];
194
 
251
 
252
+            /** @var Account $account */
195
             foreach ($accountsInCategory as $account) {
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
                 $categoryAccounts[] = new AccountDTO(
266
                 $categoryAccounts[] = new AccountDTO(
213
                     $account->name,
267
                     $account->name,
214
                     $account->code,
268
                     $account->code,
215
                     $account->id,
269
                     $account->id,
216
                     $formattedAccountBalances,
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
                 $categoryAccounts,
305
                 $categoryAccounts,
254
                 $formattedCategorySummaryBalances,
306
                 $formattedCategorySummaryBalances,
255
             );
307
             );
257
 
309
 
258
         $formattedReportTotalBalances = $this->formatBalances($reportTotalBalances);
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
     private function calculateTrialBalance(AccountCategory $category, int $endingBalance): array
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
             if ($endingBalance >= 0) {
318
             if ($endingBalance >= 0) {
293
                 return ['debit_balance' => $endingBalance, 'credit_balance' => 0];
319
                 return ['debit_balance' => $endingBalance, 'credit_balance' => 0];
294
             }
320
             }
302
 
328
 
303
         return ['debit_balance' => abs($endingBalance), 'credit_balance' => 0];
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 Целия файл

18
     use HasLabel;
18
     use HasLabel;
19
     use HasName;
19
     use HasName;
20
 
20
 
21
+    protected bool $isDate = false;
22
+
21
     final public function __construct(string $name)
23
     final public function __construct(string $name)
22
     {
24
     {
23
         $this->name($name);
25
         $this->name($name);
40
             default => '',
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 Целия файл

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

+ 132
- 0
app/Transformers/IncomeStatementReportTransformer.php Целия файл

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 Целия файл

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

+ 383
- 642
composer.lock
Файловите разлики са ограничени, защото са твърде много
Целия файл


+ 9
- 1
database/factories/UserFactory.php Целия файл

68
 
68
 
69
         return $this->afterCreating(function (User $user) use ($countryCode) {
69
         return $this->afterCreating(function (User $user) use ($countryCode) {
70
             Company::factory()
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
                 ->afterCreating(function (Company $company) use ($user, $countryCode) {
80
                 ->afterCreating(function (Company $company) use ($user, $countryCode) {
73
                     DB::transaction(function () use ($company, $user, $countryCode) {
81
                     DB::transaction(function () use ($company, $user, $countryCode) {
74
                         $companyDefaultService = app()->make(CompanyDefaultService::class);
82
                         $companyDefaultService = app()->make(CompanyDefaultService::class);

+ 147
- 143
package-lock.json Целия файл

541
             }
541
             }
542
         },
542
         },
543
         "node_modules/@rollup/rollup-android-arm-eabi": {
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
             "cpu": [
547
             "cpu": [
548
                 "arm"
548
                 "arm"
549
             ],
549
             ],
555
             ]
555
             ]
556
         },
556
         },
557
         "node_modules/@rollup/rollup-android-arm64": {
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
             "cpu": [
561
             "cpu": [
562
                 "arm64"
562
                 "arm64"
563
             ],
563
             ],
569
             ]
569
             ]
570
         },
570
         },
571
         "node_modules/@rollup/rollup-darwin-arm64": {
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
             "cpu": [
575
             "cpu": [
576
                 "arm64"
576
                 "arm64"
577
             ],
577
             ],
583
             ]
583
             ]
584
         },
584
         },
585
         "node_modules/@rollup/rollup-darwin-x64": {
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
             "cpu": [
589
             "cpu": [
590
                 "x64"
590
                 "x64"
591
             ],
591
             ],
597
             ]
597
             ]
598
         },
598
         },
599
         "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
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
             "cpu": [
603
             "cpu": [
604
                 "arm"
604
                 "arm"
605
             ],
605
             ],
611
             ]
611
             ]
612
         },
612
         },
613
         "node_modules/@rollup/rollup-linux-arm-musleabihf": {
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
             "cpu": [
617
             "cpu": [
618
                 "arm"
618
                 "arm"
619
             ],
619
             ],
625
             ]
625
             ]
626
         },
626
         },
627
         "node_modules/@rollup/rollup-linux-arm64-gnu": {
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
             "cpu": [
631
             "cpu": [
632
                 "arm64"
632
                 "arm64"
633
             ],
633
             ],
639
             ]
639
             ]
640
         },
640
         },
641
         "node_modules/@rollup/rollup-linux-arm64-musl": {
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
             "cpu": [
645
             "cpu": [
646
                 "arm64"
646
                 "arm64"
647
             ],
647
             ],
653
             ]
653
             ]
654
         },
654
         },
655
         "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
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
             "cpu": [
659
             "cpu": [
660
                 "ppc64"
660
                 "ppc64"
661
             ],
661
             ],
667
             ]
667
             ]
668
         },
668
         },
669
         "node_modules/@rollup/rollup-linux-riscv64-gnu": {
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
             "cpu": [
673
             "cpu": [
674
                 "riscv64"
674
                 "riscv64"
675
             ],
675
             ],
681
             ]
681
             ]
682
         },
682
         },
683
         "node_modules/@rollup/rollup-linux-s390x-gnu": {
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
             "cpu": [
687
             "cpu": [
688
                 "s390x"
688
                 "s390x"
689
             ],
689
             ],
695
             ]
695
             ]
696
         },
696
         },
697
         "node_modules/@rollup/rollup-linux-x64-gnu": {
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
             "cpu": [
701
             "cpu": [
702
                 "x64"
702
                 "x64"
703
             ],
703
             ],
709
             ]
709
             ]
710
         },
710
         },
711
         "node_modules/@rollup/rollup-linux-x64-musl": {
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
             "cpu": [
715
             "cpu": [
716
                 "x64"
716
                 "x64"
717
             ],
717
             ],
723
             ]
723
             ]
724
         },
724
         },
725
         "node_modules/@rollup/rollup-win32-arm64-msvc": {
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
             "cpu": [
729
             "cpu": [
730
                 "arm64"
730
                 "arm64"
731
             ],
731
             ],
737
             ]
737
             ]
738
         },
738
         },
739
         "node_modules/@rollup/rollup-win32-ia32-msvc": {
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
             "cpu": [
743
             "cpu": [
744
                 "ia32"
744
                 "ia32"
745
             ],
745
             ],
751
             ]
751
             ]
752
         },
752
         },
753
         "node_modules/@rollup/rollup-win32-x64-msvc": {
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
             "cpu": [
757
             "cpu": [
758
                 "x64"
758
                 "x64"
759
             ],
759
             ],
765
             ]
765
             ]
766
         },
766
         },
767
         "node_modules/@tailwindcss/forms": {
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
             "dev": true,
771
             "dev": true,
772
             "license": "MIT",
772
             "license": "MIT",
773
             "dependencies": {
773
             "dependencies": {
774
                 "mini-svg-data-uri": "^1.2.3"
774
                 "mini-svg-data-uri": "^1.2.3"
775
             },
775
             },
776
             "peerDependencies": {
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
         "node_modules/@tailwindcss/typography": {
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
             "dev": true,
784
             "dev": true,
785
             "license": "MIT",
785
             "license": "MIT",
786
             "dependencies": {
786
             "dependencies": {
790
                 "postcss-selector-parser": "6.0.10"
790
                 "postcss-selector-parser": "6.0.10"
791
             },
791
             },
792
             "peerDependencies": {
792
             "peerDependencies": {
793
-                "tailwindcss": ">=3.0.0 || insiders"
793
+                "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20"
794
             }
794
             }
795
         },
795
         },
796
         "node_modules/@types/estree": {
796
         "node_modules/@types/estree": {
801
             "license": "MIT"
801
             "license": "MIT"
802
         },
802
         },
803
         "node_modules/ansi-regex": {
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
             "dev": true,
807
             "dev": true,
808
             "license": "MIT",
808
             "license": "MIT",
809
             "engines": {
809
             "engines": {
862
             "license": "MIT"
862
             "license": "MIT"
863
         },
863
         },
864
         "node_modules/autoprefixer": {
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
             "dev": true,
868
             "dev": true,
869
             "funding": [
869
             "funding": [
870
                 {
870
                 {
882
             ],
882
             ],
883
             "license": "MIT",
883
             "license": "MIT",
884
             "dependencies": {
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
                 "fraction.js": "^4.3.7",
887
                 "fraction.js": "^4.3.7",
888
                 "normalize-range": "^0.1.2",
888
                 "normalize-range": "^0.1.2",
889
-                "picocolors": "^1.0.0",
889
+                "picocolors": "^1.0.1",
890
                 "postcss-value-parser": "^4.2.0"
890
                 "postcss-value-parser": "^4.2.0"
891
             },
891
             },
892
             "bin": {
892
             "bin": {
900
             }
900
             }
901
         },
901
         },
902
         "node_modules/axios": {
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
             "dev": true,
906
             "dev": true,
907
             "license": "MIT",
907
             "license": "MIT",
908
             "dependencies": {
908
             "dependencies": {
955
             }
955
             }
956
         },
956
         },
957
         "node_modules/browserslist": {
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
             "dev": true,
961
             "dev": true,
962
             "funding": [
962
             "funding": [
963
                 {
963
                 {
975
             ],
975
             ],
976
             "license": "MIT",
976
             "license": "MIT",
977
             "dependencies": {
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
                 "update-browserslist-db": "^1.1.0"
981
                 "update-browserslist-db": "^1.1.0"
982
             },
982
             },
983
             "bin": {
983
             "bin": {
998
             }
998
             }
999
         },
999
         },
1000
         "node_modules/caniuse-lite": {
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
             "dev": true,
1004
             "dev": true,
1005
             "funding": [
1005
             "funding": [
1006
                 {
1006
                 {
1159
             "license": "MIT"
1159
             "license": "MIT"
1160
         },
1160
         },
1161
         "node_modules/electron-to-chromium": {
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
             "dev": true,
1165
             "dev": true,
1166
             "license": "ISC"
1166
             "license": "ISC"
1167
         },
1167
         },
1212
             }
1212
             }
1213
         },
1213
         },
1214
         "node_modules/escalade": {
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
             "dev": true,
1218
             "dev": true,
1219
             "license": "MIT",
1219
             "license": "MIT",
1220
             "engines": {
1220
             "engines": {
1275
             }
1275
             }
1276
         },
1276
         },
1277
         "node_modules/follow-redirects": {
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
             "dev": true,
1281
             "dev": true,
1282
             "funding": [
1282
             "funding": [
1283
                 {
1283
                 {
1296
             }
1296
             }
1297
         },
1297
         },
1298
         "node_modules/foreground-child": {
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
             "dev": true,
1302
             "dev": true,
1303
             "license": "ISC",
1303
             "license": "ISC",
1304
             "dependencies": {
1304
             "dependencies": {
1427
             }
1427
             }
1428
         },
1428
         },
1429
         "node_modules/is-core-module": {
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
             "dev": true,
1433
             "dev": true,
1434
             "license": "MIT",
1434
             "license": "MIT",
1435
             "dependencies": {
1435
             "dependencies": {
1594
             }
1594
             }
1595
         },
1595
         },
1596
         "node_modules/micromatch": {
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
             "dev": true,
1600
             "dev": true,
1601
             "license": "MIT",
1601
             "license": "MIT",
1602
             "dependencies": {
1602
             "dependencies": {
1786
             }
1786
             }
1787
         },
1787
         },
1788
         "node_modules/picocolors": {
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
             "dev": true,
1792
             "dev": true,
1793
             "license": "ISC"
1793
             "license": "ISC"
1794
         },
1794
         },
1826
             }
1826
             }
1827
         },
1827
         },
1828
         "node_modules/postcss": {
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
             "dev": true,
1832
             "dev": true,
1833
             "funding": [
1833
             "funding": [
1834
                 {
1834
                 {
1968
             }
1968
             }
1969
         },
1969
         },
1970
         "node_modules/postcss-nested/node_modules/postcss-selector-parser": {
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
             "dev": true,
1974
             "dev": true,
1975
             "license": "MIT",
1975
             "license": "MIT",
1976
             "dependencies": {
1976
             "dependencies": {
2056
             }
2056
             }
2057
         },
2057
         },
2058
         "node_modules/postcss-nesting/node_modules/postcss-selector-parser": {
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
             "dev": true,
2062
             "dev": true,
2063
             "license": "MIT",
2063
             "license": "MIT",
2064
             "dependencies": {
2064
             "dependencies": {
2171
             }
2171
             }
2172
         },
2172
         },
2173
         "node_modules/rollup": {
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
             "dev": true,
2177
             "dev": true,
2178
             "license": "MIT",
2178
             "license": "MIT",
2179
             "dependencies": {
2179
             "dependencies": {
2187
                 "npm": ">=8.0.0"
2187
                 "npm": ">=8.0.0"
2188
             },
2188
             },
2189
             "optionalDependencies": {
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
                 "fsevents": "~2.3.2"
2206
                 "fsevents": "~2.3.2"
2207
             }
2207
             }
2208
         },
2208
         },
2267
             }
2267
             }
2268
         },
2268
         },
2269
         "node_modules/source-map-js": {
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
             "dev": true,
2273
             "dev": true,
2274
             "license": "BSD-3-Clause",
2274
             "license": "BSD-3-Clause",
2275
             "engines": {
2275
             "engines": {
2417
             }
2417
             }
2418
         },
2418
         },
2419
         "node_modules/tailwindcss": {
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
             "dev": true,
2423
             "dev": true,
2424
             "license": "MIT",
2424
             "license": "MIT",
2425
             "dependencies": {
2425
             "dependencies": {
2455
             }
2455
             }
2456
         },
2456
         },
2457
         "node_modules/tailwindcss/node_modules/postcss-selector-parser": {
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
             "dev": true,
2461
             "dev": true,
2462
             "license": "MIT",
2462
             "license": "MIT",
2463
             "dependencies": {
2463
             "dependencies": {
2550
             "license": "MIT"
2550
             "license": "MIT"
2551
         },
2551
         },
2552
         "node_modules/vite": {
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
             "dev": true,
2556
             "dev": true,
2557
             "license": "MIT",
2557
             "license": "MIT",
2558
             "dependencies": {
2558
             "dependencies": {
2559
                 "esbuild": "^0.21.3",
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
             "bin": {
2563
             "bin": {
2564
                 "vite": "bin/vite.js"
2564
                 "vite": "bin/vite.js"
2577
                 "less": "*",
2577
                 "less": "*",
2578
                 "lightningcss": "^1.21.0",
2578
                 "lightningcss": "^1.21.0",
2579
                 "sass": "*",
2579
                 "sass": "*",
2580
+                "sass-embedded": "*",
2580
                 "stylus": "*",
2581
                 "stylus": "*",
2581
                 "sugarss": "*",
2582
                 "sugarss": "*",
2582
                 "terser": "^5.4.0"
2583
                 "terser": "^5.4.0"
2594
                 "sass": {
2595
                 "sass": {
2595
                     "optional": true
2596
                     "optional": true
2596
                 },
2597
                 },
2598
+                "sass-embedded": {
2599
+                    "optional": true
2600
+                },
2597
                 "stylus": {
2601
                 "stylus": {
2598
                     "optional": true
2602
                     "optional": true
2599
                 },
2603
                 },
2731
             }
2735
             }
2732
         },
2736
         },
2733
         "node_modules/yaml": {
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
             "dev": true,
2741
             "dev": true,
2738
             "license": "ISC",
2742
             "license": "ISC",
2739
             "bin": {
2743
             "bin": {

+ 9
- 11
resources/css/filament/company/theme.css Целия файл

13
 }
13
 }
14
 
14
 
15
 .choices__group {
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
 .choices[data-type="select-one"] .choices__inner {
19
 .choices[data-type="select-one"] .choices__inner {
25
     box-sizing: border-box;
24
     box-sizing: border-box;
26
 }
25
 }
27
 
26
 
28
-.choices__item {
27
+.choices:not(.is-disabled) .choices__item {
29
     cursor: pointer;
28
     cursor: pointer;
30
 }
29
 }
31
 
30
 
148
     left: 0;
147
     left: 0;
149
     width: 100%;
148
     width: 100%;
150
     height: 100%;
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
     pointer-events: none;
156
     pointer-events: none;
159
     z-index: -1;
157
     z-index: -1;
160
 }
158
 }
178
         rgba(var(--primary-900), 0.5) 45%,
176
         rgba(var(--primary-900), 0.5) 45%,
179
         rgba(var(--primary-950), 0.3) 60%,
177
         rgba(var(--primary-950), 0.3) 60%,
180
         rgba(var(--primary-950), 0.1) 75%,
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
     width: 100%;
181
     width: 100%;
184
     height: 100%;
182
     height: 100%;

+ 5
- 1
resources/views/components/company/reports/account-transactions-report-pdf.blade.php Целия файл

129
                 ])>
129
                 ])>
130
                 @foreach($transaction as $cellIndex => $cell)
130
                 @foreach($transaction as $cellIndex => $cell)
131
                     <td class="{{ $report->getAlignmentClass($cellIndex) }} {{ $cellIndex === 1 ? 'whitespace-normal' : 'whitespace-nowrap' }}">
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
                     </td>
137
                     </td>
134
                 @endforeach
138
                 @endforeach
135
             </tr>
139
             </tr>

+ 4
- 4
resources/views/components/company/reports/report-pdf.blade.php Целия файл

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

+ 21
- 3
resources/views/components/company/tables/reports/detailed-report.blade.php Целия файл

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
     <thead class="divide-y divide-gray-200 dark:divide-white/5">
2
     <thead class="divide-y divide-gray-200 dark:divide-white/5">
3
     <tr class="bg-gray-50 dark:bg-white/5">
3
     <tr class="bg-gray-50 dark:bg-white/5">
4
         @foreach($report->getHeaders() as $index => $header)
4
         @foreach($report->getHeaders() as $index => $header)
27
                     <x-filament-tables::cell class="{{ $report->getAlignmentClass($cellIndex) }}">
27
                     <x-filament-tables::cell class="{{ $report->getAlignmentClass($cellIndex) }}">
28
                         <div class="px-3 py-4 text-sm leading-6 text-gray-950 dark:text-white">
28
                         <div class="px-3 py-4 text-sm leading-6 text-gray-950 dark:text-white">
29
                             @if(is_array($cell) && isset($cell['name']))
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
                                     <x-filament::link
31
                                     <x-filament::link
32
                                         color="primary"
32
                                         color="primary"
33
                                         target="_blank"
33
                                         target="_blank"
34
                                         icon="heroicon-o-arrow-top-right-on-square"
34
                                         icon="heroicon-o-arrow-top-right-on-square"
35
                                         :icon-position="\Filament\Support\Enums\IconPosition::After"
35
                                         :icon-position="\Filament\Support\Enums\IconPosition::After"
36
                                         :icon-size="\Filament\Support\Enums\IconSize::Small"
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
                                         {{ $cell['name'] }}
57
                                         {{ $cell['name'] }}
40
                                     </x-filament::link>
58
                                     </x-filament::link>

+ 14
- 7
resources/views/components/panel-shift-dropdown.blade.php Целия файл

11
     $panels = $component->getNavigationAsHierarchyArray();
11
     $panels = $component->getNavigationAsHierarchyArray();
12
 @endphp
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
         <button
16
         <button
17
             type="button"
17
             type="button"
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"
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
             />
41
             />
42
         </button>
42
         </button>
43
     </div>
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
         @foreach($panels as $panelId => $panel)
46
         @foreach($panels as $panelId => $panel)
46
             <x-panel-shift-dropdown.panel :panel-id="$panelId">
47
             <x-panel-shift-dropdown.panel :panel-id="$panelId">
47
                 @if($panelId !== 'main' && isset($panel['label']))
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
                 @endif
50
                 @endif
50
                 @if($panel['renderItems'])
51
                 @if($panel['renderItems'])
51
                     @foreach($panel['items'] as $item)
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
                     @endforeach
54
                     @endforeach
54
                 @endif
55
                 @endif
55
                 @if($panelId === 'company-settings')
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
                 @endif
59
                 @endif
58
                 @if($panelId === 'company-switcher')
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
                 @endif
63
                 @endif
61
                 @if($panelId === 'display-and-accessibility')
64
                 @if($panelId === 'display-and-accessibility')
62
                     <x-panel-shift-dropdown.display-accessibility icon="heroicon-s-moon"/>
65
                     <x-panel-shift-dropdown.display-accessibility icon="heroicon-s-moon"/>
91
                 this.open = !this.open;
94
                 this.open = !this.open;
92
             },
95
             },
93
 
96
 
97
+            closeDropdown() {
98
+                this.open = false;
99
+            },
100
+
94
             setActiveMenu(menu) {
101
             setActiveMenu(menu) {
95
                 this.transitionPanel(menu, 'forward');
102
                 this.transitionPanel(menu, 'forward');
96
             },
103
             },

+ 14
- 9
resources/views/filament/company/pages/reports/account-transactions.blade.php Целия файл

1
 <x-filament-panels::page>
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
     <x-filament-tables::container>
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
                 </div>
15
                 </div>
11
             </div>
16
             </div>
12
 
17
 
13
             @if($this->reportLoaded)
18
             @if($this->reportLoaded)
14
-                <div wire:loading.remove wire:target="loadReportData">
19
+                <div wire:loading.remove wire:target="applyFilters">
15
                     @if($this->report && !$this->tableHasEmptyState())
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
                     @else
22
                     @else
18
                         <x-filament-tables::empty-state
23
                         <x-filament-tables::empty-state
19
                             :actions="$this->getEmptyStateActions()"
24
                             :actions="$this->getEmptyStateActions()"

+ 30
- 22
resources/views/filament/company/pages/reports/detailed-report.blade.php Целия файл

1
 <x-filament-panels::page>
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
     <x-filament-tables::container>
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
             </div>
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
             @if($this->reportLoaded)
33
             @if($this->reportLoaded)
20
-                <div wire:loading.remove wire:target="loadReportData">
34
+                <div wire:loading.remove wire:target="applyFilters">
21
                     @if($this->report)
35
                     @if($this->report)
22
                         <x-company.tables.reports.detailed-report :report="$this->report"/>
36
                         <x-company.tables.reports.detailed-report :report="$this->report"/>
23
                     @endif
37
                     @endif
24
                 </div>
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
             @endif
39
             @endif
32
         </div>
40
         </div>
33
         <div class="es-table__footer-ctn border-t border-gray-200"></div>
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 Целия файл

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>

Loading…
Отказ
Запис