From a08b7732391b13e895b6940717172f2a9ede7707 Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Sat, 18 Jul 2026 04:16:23 +0200 Subject: [PATCH 01/15] improved .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index f81cd96c..a80efa5a 100644 --- a/.gitignore +++ b/.gitignore @@ -115,3 +115,5 @@ actual-real-resolved-issues.md current-issues.md merge-order.md "saving some issues.md" +issues_full.json +refine-issues.json From 9ad1b604fc2791bc7eb760eaeb45228a66c9bcb8 Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Sat, 18 Jul 2026 04:23:23 +0200 Subject: [PATCH 02/15] style: fix Pint violations across factories, observers, and tests Ran vendor/bin/pint on develop: import ordering, binary-operator spacing, and empty-body brace style. Purely cosmetic, no behavior change. --- .../Database/Factories/CompanyUserFactory.php | 2 +- .../Factories/CustomFieldValueFactory.php | 4 +- .../Core/Database/Factories/NoteFactory.php | 4 +- .../Seeders/RoleHasPermissionsSeederTest.php | 2 +- .../Expenses/Observers/ExpenseObserver.php | 4 +- .../InvoiceDuplicateNumberPreventionTest.php | 1 - .../Tests/Feature/RecurringInvoicesTest.php | 70 +++++++++---------- .../Payments/Observers/PaymentObserver.php | 4 +- .../QuoteDuplicateNumberPreventionTest.php | 1 - Modules/Quotes/Tests/Unit/QuoteModelTest.php | 6 +- 10 files changed, 46 insertions(+), 52 deletions(-) diff --git a/Modules/Core/Database/Factories/CompanyUserFactory.php b/Modules/Core/Database/Factories/CompanyUserFactory.php index 8cba3bca..3a1857bb 100644 --- a/Modules/Core/Database/Factories/CompanyUserFactory.php +++ b/Modules/Core/Database/Factories/CompanyUserFactory.php @@ -2,8 +2,8 @@ namespace Modules\Core\Database\Factories; -use Modules\Core\Models\CompanyUser; use Modules\Core\Models\Company; +use Modules\Core\Models\CompanyUser; use Modules\Core\Models\User; class CompanyUserFactory extends AbstractFactory diff --git a/Modules/Core/Database/Factories/CustomFieldValueFactory.php b/Modules/Core/Database/Factories/CustomFieldValueFactory.php index 4c603c85..b7bf5245 100644 --- a/Modules/Core/Database/Factories/CustomFieldValueFactory.php +++ b/Modules/Core/Database/Factories/CustomFieldValueFactory.php @@ -13,10 +13,10 @@ class CustomFieldValueFactory extends AbstractFactory public function definition(): array { - $company = $this->resolveCompany() ?? Company::factory()->create(); + $company = $this->resolveCompany() ?? Company::factory()->create(); $customField = CustomField::query()->where('company_id', $company->id)->inRandomOrder()->first() ?? CustomField::factory()->for($company)->create(); - $fieldable = Relation::factory()->for($company)->create(); + $fieldable = Relation::factory()->for($company)->create(); return [ 'company_id' => $company->id, diff --git a/Modules/Core/Database/Factories/NoteFactory.php b/Modules/Core/Database/Factories/NoteFactory.php index 484988a2..7d45cb5a 100644 --- a/Modules/Core/Database/Factories/NoteFactory.php +++ b/Modules/Core/Database/Factories/NoteFactory.php @@ -13,8 +13,8 @@ class NoteFactory extends AbstractFactory public function definition(): array { - $company = $this->resolveCompany() ?? Company::factory()->create(); - $notable = Relation::factory()->for($company)->create(); + $company = $this->resolveCompany() ?? Company::factory()->create(); + $notable = Relation::factory()->for($company)->create(); return [ 'company_id' => $company->id, diff --git a/Modules/Core/Tests/Feature/Seeders/RoleHasPermissionsSeederTest.php b/Modules/Core/Tests/Feature/Seeders/RoleHasPermissionsSeederTest.php index a7710122..87202027 100644 --- a/Modules/Core/Tests/Feature/Seeders/RoleHasPermissionsSeederTest.php +++ b/Modules/Core/Tests/Feature/Seeders/RoleHasPermissionsSeederTest.php @@ -35,7 +35,7 @@ public function a_role_receives_a_newly_added_default_permission_on_rerun(): voi $newPermission = Permission::create(['name' => 'view-a-brand-new-thing', 'guard_name' => 'web']); app(PermissionRegistrar::class)->forgetCachedPermissions(); - $seeder = new class extends RoleHasPermissionsSeeder { + $seeder = new class () extends RoleHasPermissionsSeeder { protected function getDefaultPermissionsForRole(string $roleName): array { $permissions = parent::getDefaultPermissionsForRole($roleName); diff --git a/Modules/Expenses/Observers/ExpenseObserver.php b/Modules/Expenses/Observers/ExpenseObserver.php index a5ee0334..ffdc5d42 100644 --- a/Modules/Expenses/Observers/ExpenseObserver.php +++ b/Modules/Expenses/Observers/ExpenseObserver.php @@ -4,6 +4,4 @@ use Modules\Core\Observers\AbstractObserver; -class ExpenseObserver extends AbstractObserver -{ -} +class ExpenseObserver extends AbstractObserver {} diff --git a/Modules/Invoices/Tests/Feature/InvoiceDuplicateNumberPreventionTest.php b/Modules/Invoices/Tests/Feature/InvoiceDuplicateNumberPreventionTest.php index 19d39ff2..0549abc7 100644 --- a/Modules/Invoices/Tests/Feature/InvoiceDuplicateNumberPreventionTest.php +++ b/Modules/Invoices/Tests/Feature/InvoiceDuplicateNumberPreventionTest.php @@ -7,7 +7,6 @@ use Modules\Core\Tests\AbstractAdminPanelTestCase; use Modules\Invoices\Enums\InvoiceStatus; use Modules\Invoices\Models\Invoice; -use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use RuntimeException; diff --git a/Modules/Invoices/Tests/Feature/RecurringInvoicesTest.php b/Modules/Invoices/Tests/Feature/RecurringInvoicesTest.php index 8b05cc67..6ebf0539 100644 --- a/Modules/Invoices/Tests/Feature/RecurringInvoicesTest.php +++ b/Modules/Invoices/Tests/Feature/RecurringInvoicesTest.php @@ -31,11 +31,11 @@ public function it_lists_recurring_invoices(): void $this->markTestIncomplete(); /* arrange */ - $company = $this->user->companies()->first(); - $user = $this->user; - $customer = Relation::factory()->for($company)->customer()->create(); - $invoice = Invoice::factory()->for($company)->create(); - $product = Product::factory()->for($company)->create(); + $company = $this->user->companies()->first(); + $user = $this->user; + $customer = Relation::factory()->for($company)->customer()->create(); + $invoice = Invoice::factory()->for($company)->create(); + $product = Product::factory()->for($company)->create(); /** @payload */ $payload = [ @@ -66,11 +66,11 @@ public function it_creates_recurring_invoice_with_items(): void $this->markTestIncomplete(); /* arrange */ - $company = $this->user->companies()->first(); - $user = $this->user; - $customer = Relation::factory()->for($company)->customer()->create(); - $invoice = Invoice::factory()->for($company)->create(); - $product = Product::factory()->for($company)->create(); + $company = $this->user->companies()->first(); + $user = $this->user; + $customer = Relation::factory()->for($company)->customer()->create(); + $invoice = Invoice::factory()->for($company)->create(); + $product = Product::factory()->for($company)->create(); /** @payload */ $payload = [ @@ -106,11 +106,11 @@ public function it_fails_without_items(): void $this->markTestIncomplete(); /* arrange */ - $company = $this->user->companies()->first(); - $user = $this->user; - $customer = Relation::factory()->for($company)->customer()->create(); - $invoice = Invoice::factory()->for($company)->create(); - $product = Product::factory()->for($company)->create(); + $company = $this->user->companies()->first(); + $user = $this->user; + $customer = Relation::factory()->for($company)->customer()->create(); + $invoice = Invoice::factory()->for($company)->create(); + $product = Product::factory()->for($company)->create(); /** @payload */ $payload = [ @@ -134,11 +134,11 @@ public function it_fails_without_frequency(): void $this->markTestIncomplete(); /* arrange */ - $company = $this->user->companies()->first(); - $user = $this->user; - $customer = Relation::factory()->for($company)->customer()->create(); - $invoice = Invoice::factory()->for($company)->create(); - $product = Product::factory()->for($company)->create(); + $company = $this->user->companies()->first(); + $user = $this->user; + $customer = Relation::factory()->for($company)->customer()->create(); + $invoice = Invoice::factory()->for($company)->create(); + $product = Product::factory()->for($company)->create(); /** @payload */ $payload = [ @@ -173,11 +173,11 @@ public function it_fails_to_create_recurringinvoice_without_required_start_at(): { $this->markTestIncomplete(); /* arrange */ - $company = $this->user->companies()->first(); - $user = $this->user; - $customer = Relation::factory()->for($company)->customer()->create(); - $invoice = Invoice::factory()->for($company)->create(); - $product = Product::factory()->for($company)->create(); + $company = $this->user->companies()->first(); + $user = $this->user; + $customer = Relation::factory()->for($company)->customer()->create(); + $invoice = Invoice::factory()->for($company)->create(); + $product = Product::factory()->for($company)->create(); /** @payload */ $payload = [ @@ -207,11 +207,11 @@ public function it_fails_if_end_at_is_before_today(): void $this->markTestIncomplete(); /* arrange */ - $company = $this->user->companies()->first(); - $user = $this->user; - $customer = Relation::factory()->for($company)->customer()->create(); - $invoice = Invoice::factory()->for($company)->create(); - $product = Product::factory()->for($company)->create(); + $company = $this->user->companies()->first(); + $user = $this->user; + $customer = Relation::factory()->for($company)->customer()->create(); + $invoice = Invoice::factory()->for($company)->create(); + $product = Product::factory()->for($company)->create(); /** @payload */ $payload = [ @@ -288,12 +288,12 @@ public function it_fails_to_update_recurringinvoice_when_required_fields_are_mis $record = RecurringInvoice::factory()->create(); $payload = [ - 'company_id' => 'Value', - 'invoice_id' => 'Value', + 'company_id' => 'Value', + 'invoice_id' => 'Value', 'numbering_id' => 'Value', - 'frequency' => 'Value', - 'start_at' => '2025-04-30', - 'end_at' => '2025-04-30', + 'frequency' => 'Value', + 'start_at' => '2025-04-30', + 'end_at' => '2025-04-30', ]; /* act */ diff --git a/Modules/Payments/Observers/PaymentObserver.php b/Modules/Payments/Observers/PaymentObserver.php index 26abedaf..27d03bbe 100644 --- a/Modules/Payments/Observers/PaymentObserver.php +++ b/Modules/Payments/Observers/PaymentObserver.php @@ -4,6 +4,4 @@ use Modules\Core\Observers\AbstractObserver; -class PaymentObserver extends AbstractObserver -{ -} +class PaymentObserver extends AbstractObserver {} diff --git a/Modules/Quotes/Tests/Feature/QuoteDuplicateNumberPreventionTest.php b/Modules/Quotes/Tests/Feature/QuoteDuplicateNumberPreventionTest.php index 6d6715a0..34b4d6a9 100644 --- a/Modules/Quotes/Tests/Feature/QuoteDuplicateNumberPreventionTest.php +++ b/Modules/Quotes/Tests/Feature/QuoteDuplicateNumberPreventionTest.php @@ -8,7 +8,6 @@ use Modules\Quotes\Models\Quote; use Modules\Quotes\Support\QuoteNumberGenerator; use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use RuntimeException; diff --git a/Modules/Quotes/Tests/Unit/QuoteModelTest.php b/Modules/Quotes/Tests/Unit/QuoteModelTest.php index 249a530a..d66718be 100644 --- a/Modules/Quotes/Tests/Unit/QuoteModelTest.php +++ b/Modules/Quotes/Tests/Unit/QuoteModelTest.php @@ -70,9 +70,9 @@ public function it_allows_creating_a_quote_via_mass_assignment(): void /* Assert */ $this->assertDatabaseHas('quotes', [ - 'id' => $created->id, - 'company_id' => $quote['company_id'], - 'prospect_id' => $quote['prospect_id'], + 'id' => $created->id, + 'company_id' => $quote['company_id'], + 'prospect_id' => $quote['prospect_id'], 'quote_number' => $quote['quote_number'], 'quote_total' => $quote['quote_total'], ]); From 00b3ada380012fcb8f04bcd894749b971ff314e6 Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Sun, 19 Jul 2026 09:43:31 +0200 Subject: [PATCH 03/15] improved .gitignore --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index a80efa5a..050bedf1 100644 --- a/.gitignore +++ b/.gitignore @@ -117,3 +117,10 @@ merge-order.md "saving some issues.md" issues_full.json refine-issues.json +/plans/ +feature-parity.md +issues_index.json +parity-results.md +report-2026-07-18.md +results-transcript.md +summary-feature-parity.md From b17fabbb3c6eb847d9f9cdbe0aa695ece1d34121 Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Sun, 19 Jul 2026 13:04:16 +0200 Subject: [PATCH 04/15] quick improvements on composer.json and CLAUDE file --- CLAUDE.md | 10 +++++++++- composer.json | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 700cbeac..be28588a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,15 @@ ## What this is -Laravel 11 + Filament v4 + Livewire v3 invoicing app. Modular architecture via `nwidart/laravel-modules`. PHP 8.1+ enums, Spatie roles/permissions, multi-tenancy via Filament's built-in tenant system scoped to `Company`. +Laravel 13 + Filament v5 + Livewire v4 invoicing app. Modular architecture via `nwidart/laravel-modules` v13. PHP 8.3+ (dev box: 8.4.23), Spatie roles/permissions, multi-tenancy via Filament's built-in tenant system scoped to `Company`. + +**Resolved versions** (from `composer.lock`): +- `laravel/framework` 13.15.0 (PHP ^8.3) +- `filament/filament` 5.6.7 (+ 9 sub-packages @ 5.6.7) +- `livewire/livewire` 4.3.1 +- `nwidart/laravel-modules` 13.0.0 +- `spatie/laravel-permission` 8.0.0 +- `danharrin/livewire-rate-limiting` 2.2.0 --- diff --git a/composer.json b/composer.json index 8f1d786a..9ca877c6 100644 --- a/composer.json +++ b/composer.json @@ -17,13 +17,13 @@ "minimum-stability": "dev", "prefer-stable": true, "require": { - "php": "^8.2", + "php": "^8.3", "awcodes/mason": "^3.1", "doctrine/dbal": ">=4.4", "dompdf/dompdf": "^3.1", "filament/actions": ">=5.6", "filament/filament": ">=5.6", - "laravel/framework": ">=12.46", + "laravel/framework": "^13.0", "maatwebsite/excel": ">=3.1", "maennchen/zipstream-php": ">=3.2", "nwidart/laravel-modules": ">=12.0", From d449ff2e2379fe79ed17ddbd21b1c17c3aec445d Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Mon, 20 Jul 2026 15:35:59 +0200 Subject: [PATCH 05/15] improve .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 050bedf1..f324afa6 100644 --- a/.gitignore +++ b/.gitignore @@ -124,3 +124,6 @@ parity-results.md report-2026-07-18.md results-transcript.md summary-feature-parity.md +plan-2026-07-19.md +storage/dompdf_log +untouched.json From 1170e424e83690cbc6b5ab784ce85680c74f30fb Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Fri, 24 Jul 2026 07:57:09 +0200 Subject: [PATCH 06/15] fix: order Recent*Widget queries by id, not latest() on missing created_at Expense, Payment, Project, and Task all set $timestamps = false and have no created_at column, but the RecentExpensesWidget/RecentPaymentsWidget/ RecentProjectsWidget/RecentTasksWidget dashboard widgets called ->latest() with no argument, which defaults to ordering by created_at regardless of $timestamps. MySQL (CI) raises a hard 1054 Unknown column error; SQLite silently reinterprets the double-quoted "created_at" identifier as a string literal when it can't resolve it, so the bug never surfaced in local sqlite-backed test runs. --- .../Expenses/Filament/Company/Widgets/RecentExpensesWidget.php | 2 +- .../Payments/Filament/Company/Widgets/RecentPaymentsWidget.php | 2 +- .../Projects/Filament/Company/Widgets/RecentProjectsWidget.php | 2 +- Modules/Projects/Filament/Company/Widgets/RecentTasksWidget.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Modules/Expenses/Filament/Company/Widgets/RecentExpensesWidget.php b/Modules/Expenses/Filament/Company/Widgets/RecentExpensesWidget.php index 6157587b..d779e5cc 100644 --- a/Modules/Expenses/Filament/Company/Widgets/RecentExpensesWidget.php +++ b/Modules/Expenses/Filament/Company/Widgets/RecentExpensesWidget.php @@ -30,7 +30,7 @@ public function table(Table $table): Table protected function getTableQuery(): Builder|Relation|null { /** @var Builder $query */ - $query = Expense::query()->latest()->limit(10); + $query = Expense::query()->latest('id')->limit(10); return $query; } diff --git a/Modules/Payments/Filament/Company/Widgets/RecentPaymentsWidget.php b/Modules/Payments/Filament/Company/Widgets/RecentPaymentsWidget.php index b8b81a67..b45185e3 100644 --- a/Modules/Payments/Filament/Company/Widgets/RecentPaymentsWidget.php +++ b/Modules/Payments/Filament/Company/Widgets/RecentPaymentsWidget.php @@ -28,7 +28,7 @@ public function table(Table $table): Table protected function getTableQuery(): Builder|Relation|null { /** @var Builder $query */ - $query = Payment::query()->latest()->limit(10); + $query = Payment::query()->latest('id')->limit(10); return $query; } diff --git a/Modules/Projects/Filament/Company/Widgets/RecentProjectsWidget.php b/Modules/Projects/Filament/Company/Widgets/RecentProjectsWidget.php index 5a3b2151..48d50131 100644 --- a/Modules/Projects/Filament/Company/Widgets/RecentProjectsWidget.php +++ b/Modules/Projects/Filament/Company/Widgets/RecentProjectsWidget.php @@ -30,7 +30,7 @@ public function table(Table $table): Table protected function getTableQuery(): Builder|Relation|null { /** @var Builder $query */ - $query = Project::query()->latest()->limit(10); + $query = Project::query()->latest('id')->limit(10); return $query; } diff --git a/Modules/Projects/Filament/Company/Widgets/RecentTasksWidget.php b/Modules/Projects/Filament/Company/Widgets/RecentTasksWidget.php index d98a00de..7dc6cdfc 100644 --- a/Modules/Projects/Filament/Company/Widgets/RecentTasksWidget.php +++ b/Modules/Projects/Filament/Company/Widgets/RecentTasksWidget.php @@ -30,7 +30,7 @@ public function table(Table $table): Table protected function getTableQuery(): Builder|Relation|null { /** @var Builder $query */ - $query = Task::query()->latest()->limit(10); + $query = Task::query()->latest('id')->limit(10); return $query; } From 7de31de7eb03493c881ba585c0e1633a2b9d0d90 Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Fri, 24 Jul 2026 12:52:27 +0200 Subject: [PATCH 07/15] chore: eliminate the SQLite testing fallback, run against real MariaDB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local tests silently diverging from CI's MariaDB (via a documented SQLite .env.testing fallback) has repeatedly masked real bugs this session — ->latest() defaulting to a nonexistent created_at column, and identifier quoting differences, both passed locally on SQLite and only failed on CI. - docker-compose.yml: cli service now injects DB_CONNECTION=mysql/DB_HOST=db etc. itself and depends_on db, so `docker compose run --rm cli php artisan test` works against real MariaDB with zero per-developer .env.testing edits - Add docker-resources/mariadb/init/01-create-test-db.sql to provision a dedicated invoiceplane_test database alongside the dev one on first boot - Fix db service: the named `database` volume was declared but never mounted, so all local dev/test data was lost on every container recreate - docker-resources/php-cli/Dockerfile: rebuild on Debian (php:8.4-cli) with the minimal proven extension set, matching the ip2-test-php:8.4 image this session used successfully throughout — see #689 for a still-open false- failure issue found with a fresh cli image build, flagged in the docs - Update AGENTS.md/CLAUDE.md/README.md/.github/DOCKER.md/Makefile to point at the compose db/cli path instead of the SQLite instructions - Note throughout: use `php artisan test`, not raw vendor/bin/phpunit — the two were observed to behave differently for this app's Livewire form tests --- .github/DOCKER.md | 31 +++++++-- AGENTS.md | 5 +- CLAUDE.md | 16 ++++- Makefile | 7 ++ README.md | 22 ++++--- docker-compose.yml | 23 ++++++- .../mariadb/init/01-create-test-db.sql | 6 ++ docker-resources/php-cli/Dockerfile | 65 ++++++++----------- 8 files changed, 114 insertions(+), 61 deletions(-) create mode 100644 docker-resources/mariadb/init/01-create-test-db.sql diff --git a/.github/DOCKER.md b/.github/DOCKER.md index 4d40d9ac..aa80503c 100644 --- a/.github/DOCKER.md +++ b/.github/DOCKER.md @@ -44,21 +44,38 @@ Visit: http://localhost:8080 (override the port with `APP_PORT` in `.env`). Both PHP images ship the full extension set the app needs: `intl`, `gd`, `pdo_mysql`, `bcmath`, `zip`, `exif`, `soap`, `redis`. The CLI image also has -Composer, a 1G memory limit for the test suite, and bundled `pdo_sqlite` -(the suite runs on an in-memory sqlite database — no db service needed for -tests). +Composer and a 1G memory limit for the test suite. --- ## Running the test suite ```bash -docker compose run --rm cli vendor/bin/phpunit --exclude-group failing,troubleshooting +docker compose run --rm cli php artisan test --exclude-group failing,troubleshooting ``` -`APP_ENV=testing` is the `cli` service default, so `.env.testing` -(sqlite `:memory:`) is picked up automatically. See `RUNNING_TESTS.md` for -filters, groups, and suites. +Use `php artisan test`, not `vendor/bin/phpunit` directly — the two have been observed to behave +differently for this app: a raw `vendor/bin/phpunit` run silently drops some submitted field +values in Livewire form tests. `artisan test` is the proven-reliable path and is what CI uses, so +standardize on it. + +**Known issue (see [#689](https://github.com/InvoicePlane/InvoicePlane-v2/issues/689)):** a +freshly-`docker compose build`'t `cli` image has, at least once, reproduced this same +field-dropping bug at scale (100+ false failures) even under `artisan test`, for reasons not yet +isolated — despite extension/ini parity with a known-good image. Before trusting a full local run +from a rebuilt `cli` image, sanity-check it against a small, known test first, e.g.: +```bash +docker compose run --rm cli php artisan test --filter=ContactsTest +``` +All 11 assertions should pass. If any fail with "field is required" errors on data you know you +supplied, don't trust the rest of that run — see the linked issue. + +`APP_ENV=testing` is the `cli` service default, and it always connects to +the compose stack's real `db` service (MariaDB) for tests — the `cli` +service injects `DB_CONNECTION=mysql`/`DB_HOST=db`/etc. itself, so nothing +in `.env.testing` needs editing. This intentionally does not fall back to +SQLite: SQLite's lenient identifier quoting has masked real bugs before that +only surfaced against MariaDB in CI. ### File ownership on Linux diff --git a/AGENTS.md b/AGENTS.md index ef004d0d..a5a1361b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,10 +10,9 @@ Laravel 11 + Filament v4 + Livewire v3 invoicing app. Modular architecture via ` composer install cp .env.example .env && php artisan key:generate php artisan migrate && php artisan db:seed -# Tests (no MySQL locally? use SQLite) +# Tests run against real MariaDB — no SQLite fallback (parity with CI) cp .env.testing.example .env.testing -# set DB_CONNECTION=sqlite, DB_DATABASE=:memory: in .env.testing -php artisan test +docker compose run --rm cli php artisan test --exclude-group failing,troubleshooting ``` --- diff --git a/CLAUDE.md b/CLAUDE.md index be28588a..f50c1634 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -195,11 +195,21 @@ User::factory()->create(['is_active' => true, 'email_verified_at' => now()]) ### DB for tests -Tests need a DB. Production CI uses MariaDB 11. For local dev without MySQL, set in `.env.testing`: +Tests need a real MariaDB DB — matching CI (MariaDB 11) — not SQLite. SQLite's lenient identifier +quoting has silently masked real bugs before (e.g. `->latest()` defaulting to a nonexistent +`created_at` column on `$timestamps = false` models passed locally, failed on CI). Run via the +`cli` compose service, which points at the stack's `db` service automatically: ``` -DB_CONNECTION=sqlite -DB_DATABASE=:memory: +docker compose run --rm cli php artisan test --exclude-group failing,troubleshooting ``` +No `.env.testing` edits needed — the `cli` service injects `DB_CONNECTION=mysql`/`DB_HOST=db` etc. +itself. Use `php artisan test`, not `vendor/bin/phpunit` directly — the two have been observed to +behave differently for this app's Livewire form tests; `artisan test` is the reliable one. +**Known issue:** a freshly-rebuilt `cli` image has reproduced false Livewire-form failures at +scale even under `artisan test`, for reasons not yet isolated — see +[#689](https://github.com/InvoicePlane/InvoicePlane-v2/issues/689) and sanity-check with +`--filter=ContactsTest` (should be 11/11 passing) before trusting a full run from a rebuilt image. +See `.github/DOCKER.md`. ### AAA phase comment style diff --git a/Makefile b/Makefile index 754ccdc0..8228ff1a 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,13 @@ ## InvoicePlane v2 — Development Makefile ## ────────────────────────────────────────────────────────────────────────────── ## +## NOTE: `vendor/bin/phpunit` (used by the targets below) and `php artisan +## test` (make artisan-test) have been observed to behave differently for +## this app — a raw phpunit run has silently dropped submitted field values +## in Livewire form tests in some environments. If a target below reports a +## failure that `make artisan-filter FILTER="..."` doesn't reproduce, prefer +## the artisan-test variant; it matches what CI runs. +## ## QUICK START ## make test Run the full PHPUnit suite (all tests) ## make smoke Run only @group smoke tests (fast sanity check) diff --git a/README.md b/README.md index 6a549e7e..1c6efda1 100644 --- a/README.md +++ b/README.md @@ -239,20 +239,22 @@ docker exec ivpldock-workspace-1 bash -c "cd /var/www/projects/ip2 && vendor/bin Or use the Makefile shorthand (see `Makefile` for available targets). -**Without Docker:** if you don't have the Docker workspace set up, you can run the suite locally against an in-memory SQLite database instead. Create/edit `.env.testing`: - -```env -DB_CONNECTION=sqlite -DB_DATABASE=:memory: -``` - -Then run tests normally: +**Preferred: Docker Compose.** The `cli` service runs the suite against a real MariaDB `db` +service — the same engine CI uses — with no setup beyond `docker compose run`: ```bash -php artisan test +docker compose run --rm cli php artisan test --exclude-group failing,troubleshooting ``` -See [RUNNING_TESTS.md](.github/RUNNING_TESTS.md) for advanced testing. +Use `php artisan test`, not `vendor/bin/phpunit` directly — the two have been observed to behave +differently for this app's Livewire form tests (`vendor/bin/phpunit` silently drops submitted +field values in some environments); `artisan test` is the reliable one and matches CI. A +freshly-rebuilt `cli` image has, at least once, reproduced this same problem even under +`artisan test` for reasons not yet isolated — see +[#689](https://github.com/InvoicePlane/InvoicePlane-v2/issues/689) before trusting a full run. + +SQLite is intentionally not used for this project's tests: its lenient identifier quoting has +masked real bugs that only surfaced on MariaDB in CI. See `.github/DOCKER.md`. ### Code Quality diff --git a/docker-compose.yml b/docker-compose.yml index 19212588..39457943 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,7 +35,10 @@ services: # by default (profile "tools"). Examples: # docker compose run --rm cli composer install # docker compose run --rm cli php artisan migrate --seed - # docker compose run --rm cli vendor/bin/phpunit --exclude-group failing,troubleshooting + # docker compose run --rm cli php artisan test --exclude-group failing,troubleshooting + # (use `php artisan test`, not `vendor/bin/phpunit` directly — the two + # have been observed to behave differently for this app's Livewire + # form tests; artisan test is the reliable one, matching CI) cli: container_name: 'ivplflmnt_cli' build: @@ -45,6 +48,17 @@ services: tty: true environment: APP_ENV: "${APP_ENV:-testing}" + # Overrides whatever's in .env.testing so the test suite always + # runs against real MariaDB here, matching CI — no per-developer + # sqlite fallback, no edits needed. + DB_CONNECTION: mysql + DB_HOST: db + DB_PORT: 3306 + DB_DATABASE: invoiceplane_test + DB_USERNAME: root + DB_PASSWORD: "" + depends_on: + - db volumes: - .:/var/www/html networks: @@ -61,6 +75,13 @@ services: MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: "yes" MARIADB_DATABASE: "${DB_DATABASE}" TZ: "Europe/London" + volumes: + - database:/var/lib/mysql + # Only runs on first boot of a fresh volume — provisions the + # dedicated invoiceplane_test database the `cli` service tests + # against. Reset with `docker compose down -v` if upgrading an + # existing volume that predates this. + - ./docker-resources/mariadb/init:/docker-entrypoint-initdb.d:ro networks: - laravel diff --git a/docker-resources/mariadb/init/01-create-test-db.sql b/docker-resources/mariadb/init/01-create-test-db.sql new file mode 100644 index 00000000..f99135b6 --- /dev/null +++ b/docker-resources/mariadb/init/01-create-test-db.sql @@ -0,0 +1,6 @@ +-- Runs once, on first boot of a fresh `database` volume (mariadb's +-- entrypoint executes everything under /docker-entrypoint-initdb.d/). +-- Provisions a dedicated test database alongside the dev one (MARIADB_DATABASE) +-- so `docker compose run --rm cli vendor/bin/phpunit` works out of the box +-- against real MariaDB, matching CI, with no per-developer .env.testing edits. +CREATE DATABASE IF NOT EXISTS invoiceplane_test; diff --git a/docker-resources/php-cli/Dockerfile b/docker-resources/php-cli/Dockerfile index 15d25843..d7cb9ec5 100644 --- a/docker-resources/php-cli/Dockerfile +++ b/docker-resources/php-cli/Dockerfile @@ -1,4 +1,10 @@ -FROM php:8.4-cli-alpine +FROM php:8.4-cli + +# Debian base, matching the image proven to run this suite reliably — +# the equivalent Alpine (musl) build was found to silently drop form +# fields during Livewire component testing (a real, reproducible bug, +# not a database or CI issue). Don't switch back to -alpine without +# re-verifying UserProfileTest::it_saves_the_user_data_form first. # Match the host user so files created in mounted volumes (vendor/, # storage/, compiled views) keep sane ownership. Override at build time: @@ -6,53 +12,38 @@ FROM php:8.4-cli-alpine ARG UID=1000 ARG GID=1000 -RUN addgroup -g ${GID} dockeruser \ - && adduser -D -s /bin/bash -u ${UID} -G dockeruser dockeruser +RUN groupadd -g ${GID} dockeruser \ + && useradd -m -s /bin/bash -u ${UID} -g dockeruser dockeruser -# Install build dependencies (temporary) -RUN apk add --no-cache --virtual .build-deps \ - autoconf \ - g++ \ - make \ - pkgconf \ - zstd-dev \ - # Install runtime dependencies (permanent) - && apk add --no-cache \ - bash \ +RUN apt-get update && apt-get install -y --no-install-recommends \ git \ curl \ zip \ unzip \ - icu-dev \ - libxml2-dev \ - oniguruma-dev \ - libzip-dev \ + libicu-dev \ libpng-dev \ - libjpeg-turbo-dev \ - freetype-dev \ - zstd \ - # Configure and install PHP extensions (pdo_sqlite ships with the base - # image — the test suite runs on an in-memory sqlite database) + libjpeg62-turbo-dev \ + libfreetype6-dev \ + libzip-dev \ + # Configure and install PHP extensions — only the ones NOT already + # compiled into the base php:8.4-cli image (which already ships + # mbstring, xml, dom, sodium, opcache, pdo, pdo_sqlite, etc.). + # Re-installing an already-built-in extension via docker-php-ext-install + # was tried and produced a real, reproducible bug: Livewire form tests + # silently lost submitted field values (e.g. + # UserProfileTest::it_saves_the_user_data_form, ContactsTest — required + # fields reported as missing even though fillForm() supplied them). + # Root cause not fully isolated, but the fix is confirmed: stick to this + # minimal set, matching the proven-reliable ip2-test-php:8.4 image. && docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-install -j$(nproc) \ - pdo \ + intl \ + gd \ pdo_mysql \ - mbstring \ - exif \ - pcntl \ bcmath \ - gd \ zip \ - intl \ - xml \ - soap \ - opcache \ - # Install PECL extensions - && pecl install redis \ - && docker-php-ext-enable redis \ - # Remove only build dependencies - && apk del .build-deps \ - && rm -rf /var/cache/apk/* + exif \ + && rm -rf /var/lib/apt/lists/* # PHPUnit needs more than the 128M default on the full suite RUN echo 'memory_limit=1G' > /usr/local/etc/php/conf.d/memory-limit.ini From ca0f39930b48af0d234e787f4a2777f54c079fcd Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Mon, 13 Jul 2026 09:25:06 +0200 Subject: [PATCH 08/15] feat(#130): report template storage layer and brick registry (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-file report template storage — a template is a folder with manifest.json + bands.json on the report_templates disk, no database tables. Ships default invoice/quote templates in resources/ and a reports:sync-system command that copies them into system storage. Harvests the 17 report bricks from feature/145-report-builder into Modules\Core\Mason (per module convention), rebased onto a ReportBrick base class that adds band placement rules and config-schema introspection. Brick signatures updated for the current awcodes/mason API (nullable toHtml data, BrickAction-managed insert flow). Adds PageBreak and Spacer utility bricks for #95. Security per PRD: slugs restricted to [a-z0-9-], company paths derived from tenant context only, bands validated on load (unknown bricks skipped, widths enum-checked, configs filtered per brick schema). Refs #130 #521 #528 Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 5 + .../Core/Console/ReportsSyncSystemCommand.php | 47 +++ Modules/Core/Enums/ReportBand.php | 14 + Modules/Core/Enums/ReportBlockWidth.php | 24 ++ Modules/Core/Enums/ReportTemplateType.php | 32 ++ .../Mason/Bricks/DetailCustomerAgingBrick.php | 98 +++++ .../Core/Mason/Bricks/DetailExpenseBrick.php | 89 +++++ .../Bricks/DetailInvoiceProductBrick.php | 89 +++++ .../Bricks/DetailInvoiceProjectBrick.php | 89 +++++ .../Core/Mason/Bricks/DetailItemsBrick.php | 83 +++++ .../Mason/Bricks/DetailQuoteProductBrick.php | 89 +++++ .../Mason/Bricks/DetailQuoteProjectBrick.php | 89 +++++ .../Core/Mason/Bricks/DetailTasksBrick.php | 92 +++++ .../Core/Mason/Bricks/FooterNotesBrick.php | 75 ++++ .../Core/Mason/Bricks/FooterSummaryBrick.php | 75 ++++ .../Core/Mason/Bricks/FooterTermsBrick.php | 75 ++++ .../Core/Mason/Bricks/FooterTotalsBrick.php | 92 +++++ .../Core/Mason/Bricks/HeaderClientBrick.php | 83 +++++ .../Core/Mason/Bricks/HeaderCompanyBrick.php | 93 +++++ .../Mason/Bricks/HeaderInvoiceMetaBrick.php | 86 +++++ .../Core/Mason/Bricks/HeaderProjectBrick.php | 89 +++++ .../Mason/Bricks/HeaderQuoteMetaBrick.php | 86 +++++ Modules/Core/Mason/Bricks/PageBreakBrick.php | 58 +++ Modules/Core/Mason/Bricks/SpacerBrick.php | 70 ++++ Modules/Core/Mason/ReportBrick.php | 83 +++++ Modules/Core/Mason/ReportBricksCollection.php | 140 +++++++ .../Core/Providers/CoreServiceProvider.php | 4 +- .../Core/Services/ReportTemplateStorage.php | 348 ++++++++++++++++++ .../Feature/ReportsSyncSystemCommandTest.php | 71 ++++ Modules/Core/Tests/Unit/MasonBricksTest.php | 288 +++++++++++++++ Modules/Core/Tests/Unit/ReportBrickTest.php | 96 +++++ .../Tests/Unit/ReportBricksCollectionTest.php | 167 +++++++++ .../Tests/Unit/ReportTemplateStorageTest.php | 270 ++++++++++++++ resources/lang/en/ip.php | 27 ++ .../invoice/default/bands.json | 40 ++ .../invoice/default/manifest.json | 7 + .../report-templates/quote/default/bands.json | 40 ++ .../quote/default/manifest.json | 7 + .../mason/bricks/page-break/index.blade.php | 6 + .../mason/bricks/page-break/preview.blade.php | 9 + .../views/mason/bricks/spacer/index.blade.php | 6 + .../mason/bricks/spacer/preview.blade.php | 8 + 42 files changed, 3338 insertions(+), 1 deletion(-) create mode 100644 Modules/Core/Console/ReportsSyncSystemCommand.php create mode 100644 Modules/Core/Enums/ReportBlockWidth.php create mode 100644 Modules/Core/Enums/ReportTemplateType.php create mode 100644 Modules/Core/Mason/Bricks/DetailCustomerAgingBrick.php create mode 100644 Modules/Core/Mason/Bricks/DetailExpenseBrick.php create mode 100644 Modules/Core/Mason/Bricks/DetailInvoiceProductBrick.php create mode 100644 Modules/Core/Mason/Bricks/DetailInvoiceProjectBrick.php create mode 100644 Modules/Core/Mason/Bricks/DetailItemsBrick.php create mode 100644 Modules/Core/Mason/Bricks/DetailQuoteProductBrick.php create mode 100644 Modules/Core/Mason/Bricks/DetailQuoteProjectBrick.php create mode 100644 Modules/Core/Mason/Bricks/DetailTasksBrick.php create mode 100644 Modules/Core/Mason/Bricks/FooterNotesBrick.php create mode 100644 Modules/Core/Mason/Bricks/FooterSummaryBrick.php create mode 100644 Modules/Core/Mason/Bricks/FooterTermsBrick.php create mode 100644 Modules/Core/Mason/Bricks/FooterTotalsBrick.php create mode 100644 Modules/Core/Mason/Bricks/HeaderClientBrick.php create mode 100644 Modules/Core/Mason/Bricks/HeaderCompanyBrick.php create mode 100644 Modules/Core/Mason/Bricks/HeaderInvoiceMetaBrick.php create mode 100644 Modules/Core/Mason/Bricks/HeaderProjectBrick.php create mode 100644 Modules/Core/Mason/Bricks/HeaderQuoteMetaBrick.php create mode 100644 Modules/Core/Mason/Bricks/PageBreakBrick.php create mode 100644 Modules/Core/Mason/Bricks/SpacerBrick.php create mode 100644 Modules/Core/Mason/ReportBrick.php create mode 100644 Modules/Core/Mason/ReportBricksCollection.php create mode 100644 Modules/Core/Services/ReportTemplateStorage.php create mode 100644 Modules/Core/Tests/Feature/ReportsSyncSystemCommandTest.php create mode 100644 Modules/Core/Tests/Unit/MasonBricksTest.php create mode 100644 Modules/Core/Tests/Unit/ReportBrickTest.php create mode 100644 Modules/Core/Tests/Unit/ReportBricksCollectionTest.php create mode 100644 Modules/Core/Tests/Unit/ReportTemplateStorageTest.php create mode 100644 resources/report-templates/invoice/default/bands.json create mode 100644 resources/report-templates/invoice/default/manifest.json create mode 100644 resources/report-templates/quote/default/bands.json create mode 100644 resources/report-templates/quote/default/manifest.json create mode 100644 resources/views/mason/bricks/page-break/index.blade.php create mode 100644 resources/views/mason/bricks/page-break/preview.blade.php create mode 100644 resources/views/mason/bricks/spacer/index.blade.php create mode 100644 resources/views/mason/bricks/spacer/preview.blade.php diff --git a/CLAUDE.md b/CLAUDE.md index f50c1634..1eaacdfe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -317,3 +317,8 @@ No DTO layer — services accept arrays and return Eloquent models. - `Str::lower($company->search_code)` is always the URL tenant parameter - The three tenant middleware classes live at `Modules/Core/Http/Middleware/` - Panel providers live at `Modules/Core/Providers/`, not `app/Providers/` + +# LESSONS + +- Never write `*/` inside a PHP docblock (e.g. glob patterns like `Header*/Detail*`) — it terminates the comment and causes a parse error. +- When running a test suite in the background, redirect FULL output to a file — never pipe through `tail`/`head`, it destroys the failure details and forces a rerun. diff --git a/Modules/Core/Console/ReportsSyncSystemCommand.php b/Modules/Core/Console/ReportsSyncSystemCommand.php new file mode 100644 index 00000000..e016ebc4 --- /dev/null +++ b/Modules/Core/Console/ReportsSyncSystemCommand.php @@ -0,0 +1,47 @@ +error("Source directory [{$source}] does not exist."); + + return self::FAILURE; + } + + $disk = Storage::disk(ReportTemplateStorage::DISK); + $synced = 0; + + foreach (File::allFiles($source) as $file) { + $disk->put( + ReportTemplateStorage::SCOPE_SYSTEM . '/' . str_replace('\\', '/', $file->getRelativePathname()), + $file->getContents(), + ); + + $synced++; + } + + $this->info("Synced {$synced} report template file(s) into system storage."); + + return self::SUCCESS; + } +} diff --git a/Modules/Core/Enums/ReportBand.php b/Modules/Core/Enums/ReportBand.php index 90e149cc..de3747a9 100644 --- a/Modules/Core/Enums/ReportBand.php +++ b/Modules/Core/Enums/ReportBand.php @@ -10,6 +10,20 @@ enum ReportBand: string case GROUP_HEADER = 'group_header'; case HEADER = 'header'; + /** + * Get all bands in document order (header first, footer last). + * + * @return array + */ + public static function ordered(): array + { + $bands = self::cases(); + + usort($bands, fn (self $a, self $b): int => $a->getOrder() <=> $b->getOrder()); + + return $bands; + } + /** * Get the display label for the band. */ diff --git a/Modules/Core/Enums/ReportBlockWidth.php b/Modules/Core/Enums/ReportBlockWidth.php new file mode 100644 index 00000000..c5ff65da --- /dev/null +++ b/Modules/Core/Enums/ReportBlockWidth.php @@ -0,0 +1,24 @@ + 4, + self::HALF => 6, + self::TWO_THIRDS => 8, + self::FULL => 12, + }; + } +} diff --git a/Modules/Core/Enums/ReportTemplateType.php b/Modules/Core/Enums/ReportTemplateType.php new file mode 100644 index 00000000..fa5f0634 --- /dev/null +++ b/Modules/Core/Enums/ReportTemplateType.php @@ -0,0 +1,32 @@ + trans('ip.invoice'), + self::QUOTE => trans('ip.quote'), + }; + } + + public function color(): string + { + return match ($this) { + self::INVOICE => 'success', + self::QUOTE => 'info', + }; + } +} diff --git a/Modules/Core/Mason/Bricks/DetailCustomerAgingBrick.php b/Modules/Core/Mason/Bricks/DetailCustomerAgingBrick.php new file mode 100644 index 00000000..a7ac3080 --- /dev/null +++ b/Modules/Core/Mason/Bricks/DetailCustomerAgingBrick.php @@ -0,0 +1,98 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.customer_aging_details'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.detail-customer-aging.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.detail-customer-aging.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_customer_aging')) + ->modalHeading(trans('ip.customer_aging_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_invoice_number') + ->label(trans('ip.show_invoice_number')) + ->default(true), + Checkbox::make('show_invoice_date') + ->label(trans('ip.show_invoice_date')) + ->default(true), + Checkbox::make('show_due_date') + ->label(trans('ip.show_due_date')) + ->default(true), + Checkbox::make('show_current') + ->label(trans('ip.show_current')) + ->default(true), + Checkbox::make('show_30_days') + ->label(trans('ip.show_30_days')) + ->default(true), + Checkbox::make('show_60_days') + ->label(trans('ip.show_60_days')) + ->default(true), + Checkbox::make('show_90_days') + ->label(trans('ip.show_90_days')) + ->default(true), + Checkbox::make('show_over_90_days') + ->label(trans('ip.show_over_90_days')) + ->default(true), + Checkbox::make('show_total_due') + ->label(trans('ip.show_total_due')) + ->default(true), + Checkbox::make('highlight_overdue') + ->label(trans('ip.highlight_overdue')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/DetailExpenseBrick.php b/Modules/Core/Mason/Bricks/DetailExpenseBrick.php new file mode 100644 index 00000000..8fc5198d --- /dev/null +++ b/Modules/Core/Mason/Bricks/DetailExpenseBrick.php @@ -0,0 +1,89 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.expense_details'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.detail-expense.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.detail-expense.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_expense_details')) + ->modalHeading(trans('ip.expense_details_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_expense_number') + ->label(trans('ip.show_expense_number')) + ->default(true), + Checkbox::make('show_expense_date') + ->label(trans('ip.show_expense_date')) + ->default(true), + Checkbox::make('show_category') + ->label(trans('ip.show_category')) + ->default(true), + Checkbox::make('show_vendor') + ->label(trans('ip.show_vendor')) + ->default(false), + Checkbox::make('show_description') + ->label(trans('ip.show_description')) + ->default(true), + Checkbox::make('show_amount') + ->label(trans('ip.show_amount')) + ->default(true), + Checkbox::make('show_status') + ->label(trans('ip.show_status')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/DetailInvoiceProductBrick.php b/Modules/Core/Mason/Bricks/DetailInvoiceProductBrick.php new file mode 100644 index 00000000..a294063e --- /dev/null +++ b/Modules/Core/Mason/Bricks/DetailInvoiceProductBrick.php @@ -0,0 +1,89 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.invoice_product_details'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.detail-invoice-product.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.detail-invoice-product.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_invoice_product_details')) + ->modalHeading(trans('ip.invoice_product_details_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_sku') + ->label(trans('ip.show_sku')) + ->default(true), + Checkbox::make('show_description') + ->label(trans('ip.show_description')) + ->default(true), + Checkbox::make('show_quantity') + ->label(trans('ip.show_quantity')) + ->default(true), + Checkbox::make('show_unit_price') + ->label(trans('ip.show_unit_price')) + ->default(true), + Checkbox::make('show_tax') + ->label(trans('ip.show_tax')) + ->default(true), + Checkbox::make('show_discount') + ->label(trans('ip.show_discount')) + ->default(false), + Checkbox::make('show_total') + ->label(trans('ip.show_total')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/DetailInvoiceProjectBrick.php b/Modules/Core/Mason/Bricks/DetailInvoiceProjectBrick.php new file mode 100644 index 00000000..4b482098 --- /dev/null +++ b/Modules/Core/Mason/Bricks/DetailInvoiceProjectBrick.php @@ -0,0 +1,89 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.invoice_project_details'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.detail-invoice-project.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.detail-invoice-project.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_invoice_project_details')) + ->modalHeading(trans('ip.invoice_project_details_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_project_name') + ->label(trans('ip.show_project_name')) + ->default(true), + Checkbox::make('show_task_name') + ->label(trans('ip.show_task_name')) + ->default(true), + Checkbox::make('show_description') + ->label(trans('ip.show_description')) + ->default(true), + Checkbox::make('show_hours') + ->label(trans('ip.show_hours')) + ->default(true), + Checkbox::make('show_rate') + ->label(trans('ip.show_rate')) + ->default(true), + Checkbox::make('show_total') + ->label(trans('ip.show_total')) + ->default(true), + Checkbox::make('group_by_project') + ->label(trans('ip.group_by_project')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/DetailItemsBrick.php b/Modules/Core/Mason/Bricks/DetailItemsBrick.php new file mode 100644 index 00000000..7c99247c --- /dev/null +++ b/Modules/Core/Mason/Bricks/DetailItemsBrick.php @@ -0,0 +1,83 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.line_items_table'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.detail-items.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.detail-items.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_line_items')) + ->modalHeading(trans('ip.line_items_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_description') + ->label(trans('ip.show_description')) + ->default(true), + Checkbox::make('show_quantity') + ->label(trans('ip.show_quantity')) + ->default(true), + Checkbox::make('show_price') + ->label(trans('ip.show_price')) + ->default(true), + Checkbox::make('show_tax') + ->label(trans('ip.show_tax')) + ->default(true), + Checkbox::make('show_total') + ->label(trans('ip.show_total')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/DetailQuoteProductBrick.php b/Modules/Core/Mason/Bricks/DetailQuoteProductBrick.php new file mode 100644 index 00000000..6385cb9d --- /dev/null +++ b/Modules/Core/Mason/Bricks/DetailQuoteProductBrick.php @@ -0,0 +1,89 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.quote_product_details'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.detail-quote-product.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.detail-quote-product.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_quote_product_details')) + ->modalHeading(trans('ip.quote_product_details_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_sku') + ->label(trans('ip.show_sku')) + ->default(true), + Checkbox::make('show_description') + ->label(trans('ip.show_description')) + ->default(true), + Checkbox::make('show_quantity') + ->label(trans('ip.show_quantity')) + ->default(true), + Checkbox::make('show_unit_price') + ->label(trans('ip.show_unit_price')) + ->default(true), + Checkbox::make('show_tax') + ->label(trans('ip.show_tax')) + ->default(true), + Checkbox::make('show_discount') + ->label(trans('ip.show_discount')) + ->default(false), + Checkbox::make('show_total') + ->label(trans('ip.show_total')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/DetailQuoteProjectBrick.php b/Modules/Core/Mason/Bricks/DetailQuoteProjectBrick.php new file mode 100644 index 00000000..800e7ad3 --- /dev/null +++ b/Modules/Core/Mason/Bricks/DetailQuoteProjectBrick.php @@ -0,0 +1,89 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.quote_project_details'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.detail-quote-project.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.detail-quote-project.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_quote_project_details')) + ->modalHeading(trans('ip.quote_project_details_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_project_name') + ->label(trans('ip.show_project_name')) + ->default(true), + Checkbox::make('show_task_name') + ->label(trans('ip.show_task_name')) + ->default(true), + Checkbox::make('show_description') + ->label(trans('ip.show_description')) + ->default(true), + Checkbox::make('show_hours') + ->label(trans('ip.show_hours')) + ->default(true), + Checkbox::make('show_rate') + ->label(trans('ip.show_rate')) + ->default(true), + Checkbox::make('show_total') + ->label(trans('ip.show_total')) + ->default(true), + Checkbox::make('group_by_project') + ->label(trans('ip.group_by_project')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/DetailTasksBrick.php b/Modules/Core/Mason/Bricks/DetailTasksBrick.php new file mode 100644 index 00000000..8c89bdda --- /dev/null +++ b/Modules/Core/Mason/Bricks/DetailTasksBrick.php @@ -0,0 +1,92 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.tasks_table'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.detail-tasks.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.detail-tasks.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_tasks')) + ->modalHeading(trans('ip.tasks_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_task_number') + ->label(trans('ip.show_task_number')) + ->default(true), + Checkbox::make('show_task_name') + ->label(trans('ip.show_task_name')) + ->default(true), + Checkbox::make('show_description') + ->label(trans('ip.show_description')) + ->default(true), + Checkbox::make('show_due_at') + ->label(trans('ip.show_due_at')) + ->default(false), + Checkbox::make('show_task_price') + ->label(trans('ip.show_task_price')) + ->default(true), + Checkbox::make('show_task_status') + ->label(trans('ip.show_task_status')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(6) + ->maxValue(12), + Select::make('header_style') + ->label(trans('ip.header_style')) + ->options([ + 'normal' => trans('ip.normal'), + 'bold' => trans('ip.bold'), + 'italic' => trans('ip.italic'), + ]) + ->default('bold'), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/FooterNotesBrick.php b/Modules/Core/Mason/Bricks/FooterNotesBrick.php new file mode 100644 index 00000000..097e3f31 --- /dev/null +++ b/Modules/Core/Mason/Bricks/FooterNotesBrick.php @@ -0,0 +1,75 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.footer'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.footer-notes.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.footer-notes.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_notes')) + ->modalHeading(trans('ip.notes_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + RichEditor::make('footer_content') + ->label(trans('ip.footer_content')) + ->columnSpanFull() + ->toolbarButtons([ + 'bold', + 'italic', + 'underline', + 'bulletList', + 'orderedList', + ]), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(8) + ->minValue(6) + ->maxValue(12), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/FooterSummaryBrick.php b/Modules/Core/Mason/Bricks/FooterSummaryBrick.php new file mode 100644 index 00000000..55b7fd89 --- /dev/null +++ b/Modules/Core/Mason/Bricks/FooterSummaryBrick.php @@ -0,0 +1,75 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.summary'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.footer-summary.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.footer-summary.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_summary')) + ->modalHeading(trans('ip.summary_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + RichEditor::make('summary_content') + ->label(trans('ip.summary_content')) + ->columnSpanFull() + ->toolbarButtons([ + 'bold', + 'italic', + 'underline', + 'bulletList', + 'orderedList', + ]), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(6) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/FooterTermsBrick.php b/Modules/Core/Mason/Bricks/FooterTermsBrick.php new file mode 100644 index 00000000..cc887bf7 --- /dev/null +++ b/Modules/Core/Mason/Bricks/FooterTermsBrick.php @@ -0,0 +1,75 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.terms_conditions'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.footer-terms.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.footer-terms.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_terms')) + ->modalHeading(trans('ip.terms_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + RichEditor::make('terms_content') + ->label(trans('ip.terms_content')) + ->columnSpanFull() + ->toolbarButtons([ + 'bold', + 'italic', + 'underline', + 'bulletList', + 'orderedList', + ]), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(8) + ->minValue(6) + ->maxValue(12), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/FooterTotalsBrick.php b/Modules/Core/Mason/Bricks/FooterTotalsBrick.php new file mode 100644 index 00000000..7c45ef2d --- /dev/null +++ b/Modules/Core/Mason/Bricks/FooterTotalsBrick.php @@ -0,0 +1,92 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.totals_section'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.footer-totals.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.footer-totals.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_totals')) + ->modalHeading(trans('ip.totals_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_subtotal') + ->label(trans('ip.show_subtotal')) + ->default(true), + Checkbox::make('show_tax') + ->label(trans('ip.show_tax')) + ->default(true), + Checkbox::make('show_total') + ->label(trans('ip.show_total')) + ->default(true), + Checkbox::make('show_paid') + ->label(trans('ip.show_paid')) + ->default(false), + Checkbox::make('show_balance') + ->label(trans('ip.show_balance')) + ->default(false), + Checkbox::make('highlight_total') + ->label(trans('ip.highlight_total')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(10) + ->minValue(8) + ->maxValue(16), + Select::make('text_align') + ->label(trans('ip.text_align')) + ->options([ + 'left' => trans('ip.align_left'), + 'center' => trans('ip.align_center'), + 'right' => trans('ip.align_right'), + ]) + ->default('right'), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/HeaderClientBrick.php b/Modules/Core/Mason/Bricks/HeaderClientBrick.php new file mode 100644 index 00000000..3649420d --- /dev/null +++ b/Modules/Core/Mason/Bricks/HeaderClientBrick.php @@ -0,0 +1,83 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.client_header'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.header-client.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.header-client.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_client_header')) + ->modalHeading(trans('ip.client_header_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_phone') + ->label(trans('ip.show_phone')) + ->default(true), + Checkbox::make('show_email') + ->label(trans('ip.show_email')) + ->default(true), + Checkbox::make('show_address') + ->label(trans('ip.show_address')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(10) + ->minValue(8) + ->maxValue(16), + Select::make('text_align') + ->label(trans('ip.text_align')) + ->options([ + 'left' => trans('ip.align_left'), + 'center' => trans('ip.align_center'), + 'right' => trans('ip.align_right'), + ]) + ->default('right'), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/HeaderCompanyBrick.php b/Modules/Core/Mason/Bricks/HeaderCompanyBrick.php new file mode 100644 index 00000000..2ec0e61c --- /dev/null +++ b/Modules/Core/Mason/Bricks/HeaderCompanyBrick.php @@ -0,0 +1,93 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.company_header'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.header-company.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.header-company.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_company_header')) + ->modalHeading(trans('ip.company_header_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_vat_id') + ->label(trans('ip.show_vat_id')) + ->default(true), + Checkbox::make('show_phone') + ->label(trans('ip.show_phone')) + ->default(true), + Checkbox::make('show_email') + ->label(trans('ip.show_email')) + ->default(true), + Checkbox::make('show_address') + ->label(trans('ip.show_address')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(10) + ->minValue(8) + ->maxValue(16), + Select::make('font_weight') + ->label(trans('ip.font_weight')) + ->options([ + 'normal' => trans('ip.font_weight_normal'), + 'bold' => trans('ip.font_weight_bold'), + ]) + ->default('bold'), + Select::make('text_align') + ->label(trans('ip.text_align')) + ->options([ + 'left' => trans('ip.align_left'), + 'center' => trans('ip.align_center'), + 'right' => trans('ip.align_right'), + ]) + ->default('left'), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/HeaderInvoiceMetaBrick.php b/Modules/Core/Mason/Bricks/HeaderInvoiceMetaBrick.php new file mode 100644 index 00000000..9d2d7bc4 --- /dev/null +++ b/Modules/Core/Mason/Bricks/HeaderInvoiceMetaBrick.php @@ -0,0 +1,86 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.invoice_metadata'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.header-invoice-meta.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.header-invoice-meta.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_invoice_metadata')) + ->modalHeading(trans('ip.invoice_metadata_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_invoice_number') + ->label(trans('ip.show_invoice_number')) + ->default(true), + Checkbox::make('show_invoice_date') + ->label(trans('ip.show_invoice_date')) + ->default(true), + Checkbox::make('show_due_date') + ->label(trans('ip.show_due_date')) + ->default(true), + Checkbox::make('show_po_number') + ->label(trans('ip.show_po_number')) + ->default(false), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(10) + ->minValue(8) + ->maxValue(16), + Select::make('text_align') + ->label(trans('ip.text_align')) + ->options([ + 'left' => trans('ip.align_left'), + 'center' => trans('ip.align_center'), + 'right' => trans('ip.align_right'), + ]) + ->default('right'), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/HeaderProjectBrick.php b/Modules/Core/Mason/Bricks/HeaderProjectBrick.php new file mode 100644 index 00000000..c22c1eca --- /dev/null +++ b/Modules/Core/Mason/Bricks/HeaderProjectBrick.php @@ -0,0 +1,89 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.project_header'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.header-project.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.header-project.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_project')) + ->modalHeading(trans('ip.project_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_project_number') + ->label(trans('ip.show_project_number')) + ->default(true), + Checkbox::make('show_project_name') + ->label(trans('ip.show_project_name')) + ->default(true), + Checkbox::make('show_start_date') + ->label(trans('ip.show_start_date')) + ->default(true), + Checkbox::make('show_end_date') + ->label(trans('ip.show_end_date')) + ->default(true), + Checkbox::make('show_status') + ->label(trans('ip.show_status')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(10) + ->minValue(6) + ->maxValue(16), + Select::make('text_align') + ->label(trans('ip.text_align')) + ->options([ + 'left' => trans('ip.align_left'), + 'center' => trans('ip.align_center'), + 'right' => trans('ip.align_right'), + ]) + ->default('left'), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/HeaderQuoteMetaBrick.php b/Modules/Core/Mason/Bricks/HeaderQuoteMetaBrick.php new file mode 100644 index 00000000..034288df --- /dev/null +++ b/Modules/Core/Mason/Bricks/HeaderQuoteMetaBrick.php @@ -0,0 +1,86 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.quote_metadata'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.header-quote-meta.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.header-quote-meta.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_quote_meta')) + ->modalHeading(trans('ip.quote_meta_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_quote_number') + ->label(trans('ip.show_quote_number')) + ->default(true), + Checkbox::make('show_quoted_at') + ->label(trans('ip.show_quoted_at')) + ->default(true), + Checkbox::make('show_expires_at') + ->label(trans('ip.show_expires_at')) + ->default(true), + Checkbox::make('show_status') + ->label(trans('ip.show_status')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(10) + ->minValue(6) + ->maxValue(16), + Select::make('text_align') + ->label(trans('ip.text_align')) + ->options([ + 'left' => trans('ip.align_left'), + 'center' => trans('ip.align_center'), + 'right' => trans('ip.align_right'), + ]) + ->default('right'), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/PageBreakBrick.php b/Modules/Core/Mason/Bricks/PageBreakBrick.php new file mode 100644 index 00000000..a2e237c3 --- /dev/null +++ b/Modules/Core/Mason/Bricks/PageBreakBrick.php @@ -0,0 +1,58 @@ +'); + } + + public static function allowedBands(): array + { + return ReportBand::cases(); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.page_break'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.page-break.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.page-break.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->modalHidden(); + } +} diff --git a/Modules/Core/Mason/Bricks/SpacerBrick.php b/Modules/Core/Mason/Bricks/SpacerBrick.php new file mode 100644 index 00000000..feccb43d --- /dev/null +++ b/Modules/Core/Mason/Bricks/SpacerBrick.php @@ -0,0 +1,70 @@ +'); + } + + public static function allowedBands(): array + { + return ReportBand::cases(); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.spacer'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.spacer.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.spacer.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_spacer')) + ->modalHeading(trans('ip.spacer_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + TextInput::make('height') + ->label(trans('ip.spacer_height')) + ->numeric() + ->default(20) + ->minValue(1) + ->maxValue(500), + ]); + } +} diff --git a/Modules/Core/Mason/ReportBrick.php b/Modules/Core/Mason/ReportBrick.php new file mode 100644 index 00000000..9a8c2e36 --- /dev/null +++ b/Modules/Core/Mason/ReportBrick.php @@ -0,0 +1,83 @@ +> + */ + protected static array $configKeysCache = []; + + /** + * The bands this brick may be placed in. + * + * Defaults are inferred from the class name prefix (Header, Detail, Footer); + * override for bricks that do not follow the prefix convention. + * + * @return array + */ + public static function allowedBands(): array + { + $basename = class_basename(static::class); + + return match (true) { + str_starts_with($basename, 'Header') => [ReportBand::HEADER, ReportBand::GROUP_HEADER], + str_starts_with($basename, 'Detail') => [ReportBand::DETAILS], + str_starts_with($basename, 'Footer') => [ReportBand::GROUP_FOOTER, ReportBand::FOOTER], + default => ReportBand::cases(), + }; + } + + /** + * The config keys this brick accepts, derived from its configure action + * schema. Used to filter persisted config against the brick's own schema. + * + * @return array + */ + public static function configKeys(): array + { + if (isset(static::$configKeysCache[static::class])) { + return static::$configKeysCache[static::class]; + } + + $action = static::configureBrickAction(Action::make('configure')); + + $property = new ReflectionProperty(Action::class, 'schema'); + $schema = $property->getValue($action); + + $keys = []; + + if (is_array($schema)) { + foreach ($schema as $component) { + if (method_exists($component, 'getName')) { + $keys[] = $component->getName(); + } + } + } + + return static::$configKeysCache[static::class] = $keys; + } + + /** + * Filter a persisted config array down to the keys this brick declares. + */ + public static function filterConfig(array $config): array + { + return array_intersect_key($config, array_flip(static::configKeys())); + } +} diff --git a/Modules/Core/Mason/ReportBricksCollection.php b/Modules/Core/Mason/ReportBricksCollection.php new file mode 100644 index 00000000..6dc35c6f --- /dev/null +++ b/Modules/Core/Mason/ReportBricksCollection.php @@ -0,0 +1,140 @@ + + */ + public static function all(): array + { + return [ + ...self::header(), + ...self::detail(), + ...self::footer(), + ...self::utility(), + ]; + } + + /** + * Get utility bricks allowed in every band. + * + * @return array + */ + public static function utility(): array + { + return [ + PageBreakBrick::class, + SpacerBrick::class, + ]; + } + + /** + * Get the bricks allowed in the given band. + * + * @return array + */ + public static function forBand(ReportBand $band): array + { + return array_values(array_filter( + self::all(), + fn (string $brick): bool => in_array($band, $brick::allowedBands(), true), + )); + } + + /** + * Find a brick class by its brick id. + * + * @return class-string|null + */ + public static function findById(string $id): ?string + { + foreach (self::all() as $brick) { + if ($brick::getId() === $id) { + return $brick; + } + } + + return null; + } + + /** + * Get header section bricks. + * + * @return array + */ + public static function header(): array + { + return [ + HeaderCompanyBrick::class, + HeaderClientBrick::class, + HeaderInvoiceMetaBrick::class, + HeaderQuoteMetaBrick::class, + HeaderProjectBrick::class, + ]; + } + + /** + * Get detail section bricks. + * + * @return array + */ + public static function detail(): array + { + return [ + DetailItemsBrick::class, + DetailTasksBrick::class, + DetailInvoiceProductBrick::class, + DetailInvoiceProjectBrick::class, + DetailQuoteProductBrick::class, + DetailQuoteProjectBrick::class, + DetailCustomerAgingBrick::class, + DetailExpenseBrick::class, + ]; + } + + /** + * Get footer section bricks. + * + * @return array + */ + public static function footer(): array + { + return [ + FooterTotalsBrick::class, + FooterNotesBrick::class, + FooterTermsBrick::class, + FooterSummaryBrick::class, + ]; + } +} diff --git a/Modules/Core/Providers/CoreServiceProvider.php b/Modules/Core/Providers/CoreServiceProvider.php index 58b27645..0618a9ad 100644 --- a/Modules/Core/Providers/CoreServiceProvider.php +++ b/Modules/Core/Providers/CoreServiceProvider.php @@ -69,7 +69,9 @@ public function provides(): array protected function registerCommands(): void { - $this->commands([]); + $this->commands([ + \Modules\Core\Console\ReportsSyncSystemCommand::class, + ]); } protected function registerCommandSchedules(): void diff --git a/Modules/Core/Services/ReportTemplateStorage.php b/Modules/Core/Services/ReportTemplateStorage.php new file mode 100644 index 00000000..9cde2910 --- /dev/null +++ b/Modules/Core/Services/ReportTemplateStorage.php @@ -0,0 +1,348 @@ + + */ + public function listSystem(?ReportTemplateType $type = null): array + { + $templates = []; + $types = $type ? [$type->value] : ReportTemplateType::values(); + + foreach ($types as $typeValue) { + foreach (Storage::disk(self::DISK)->directories(self::SCOPE_SYSTEM . '/' . $typeValue) as $directory) { + $manifest = $this->readJson($directory . '/manifest.json'); + + if ($manifest === null) { + continue; + } + + $templates[] = [ + 'scope' => self::SCOPE_SYSTEM, + 'type' => $typeValue, + 'slug' => basename($directory), + 'manifest' => $manifest, + ]; + } + } + + return $templates; + } + + /** + * List the current company's templates, optionally filtered by type. + * + * @return array + */ + public function listCompany(?ReportTemplateType $type = null): array + { + $templates = []; + + foreach (Storage::disk(self::DISK)->directories((string) $this->companyId()) as $directory) { + $manifest = $this->readJson($directory . '/manifest.json'); + + if ($manifest === null) { + continue; + } + + if ($type !== null && ($manifest['type'] ?? null) !== $type->value) { + continue; + } + + $templates[] = [ + 'scope' => self::SCOPE_COMPANY, + 'type' => (string) ($manifest['type'] ?? ''), + 'slug' => basename($directory), + 'manifest' => $manifest, + ]; + } + + return $templates; + } + + public function exists(string $scope, string $slug, ?ReportTemplateType $type = null): bool + { + return Storage::disk(self::DISK)->exists($this->path($scope, $slug, $type) . '/manifest.json'); + } + + /** + * Load a template. Bands are validated on load: unknown brick ids and + * bricks not allowed in their band are skipped, widths fall back to + * full, and configs are filtered against each brick's own schema. + * + * @return array{manifest: array, bands: array}|null + */ + public function load(string $scope, string $slug, ?ReportTemplateType $type = null): ?array + { + $base = $this->path($scope, $slug, $type); + $manifest = $this->readJson($base . '/manifest.json'); + + if ($manifest === null) { + return null; + } + + $bands = $this->readJson($base . '/bands.json') ?? []; + + return [ + 'manifest' => $manifest, + 'bands' => $this->sanitizeBands($bands), + ]; + } + + /** + * Persist a template's manifest and bands. Bands are sanitized before + * writing so only known bricks, valid widths, and schema-filtered + * configs ever reach disk. + */ + public function save(string $scope, string $slug, array $manifest, array $bands, ?ReportTemplateType $type = null): void + { + $base = $this->path($scope, $slug, $type); + $disk = Storage::disk(self::DISK); + + $disk->put($base . '/manifest.json', $this->encodeJson($manifest)); + $disk->put($base . '/bands.json', $this->encodeJson($this->sanitizeBands($bands))); + } + + /** + * Clone a template into the current company (or into the system scope). + * Cloning copies the folder and rewrites the manifest. + * + * @return array{scope: string, type: string, slug: string, manifest: array} + */ + public function clone( + string $fromScope, + string $fromSlug, + string $newName, + ?ReportTemplateType $type = null, + string $toScope = self::SCOPE_COMPANY, + ): array { + $source = $this->load($fromScope, $fromSlug, $type); + + if ($source === null) { + throw new RuntimeException("Report template [{$fromScope}/{$fromSlug}] does not exist."); + } + + $manifest = $source['manifest']; + $templateType = $type ?? ReportTemplateType::tryFrom((string) ($manifest['type'] ?? '')); + $newSlug = $this->uniqueSlug($toScope, Str::slug($newName), $templateType); + + $manifest['name'] = $newName; + $manifest['slug'] = $newSlug; + $manifest['cloned_from'] = $this->path($fromScope, $fromSlug, $type); + + $this->save($toScope, $newSlug, $manifest, $source['bands'], $templateType); + + return [ + 'scope' => $toScope, + 'type' => (string) ($manifest['type'] ?? ''), + 'slug' => $newSlug, + 'manifest' => $manifest, + ]; + } + + /** + * Rename a template's display name (the slug is stable once created). + */ + public function rename(string $scope, string $slug, string $newName, ?ReportTemplateType $type = null): void + { + $template = $this->load($scope, $slug, $type); + + if ($template === null) { + throw new RuntimeException("Report template [{$scope}/{$slug}] does not exist."); + } + + $manifest = $template['manifest']; + $manifest['name'] = $newName; + + Storage::disk(self::DISK)->put( + $this->path($scope, $slug, $type) . '/manifest.json', + $this->encodeJson($manifest), + ); + } + + /** + * Delete a template folder. Shipped system defaults are protected. + */ + public function delete(string $scope, string $slug, ?ReportTemplateType $type = null): bool + { + if ($scope === self::SCOPE_SYSTEM && $slug === 'default') { + throw new RuntimeException('System default templates cannot be deleted.'); + } + + $base = $this->path($scope, $slug, $type); + + if ( ! Storage::disk(self::DISK)->exists($base . '/manifest.json')) { + return false; + } + + return Storage::disk(self::DISK)->deleteDirectory($base); + } + + /** + * Reduce arbitrary decoded band data to the valid five-band structure. + * + * @return array> + */ + public function sanitizeBands(array $bands): array + { + $sanitized = []; + + foreach (ReportBand::ordered() as $band) { + $sanitized[$band->value] = []; + + $entries = $bands[$band->value] ?? []; + + if ( ! is_array($entries)) { + continue; + } + + foreach ($entries as $entry) { + if ( ! is_array($entry)) { + continue; + } + + $brickClass = ReportBricksCollection::findById((string) ($entry['brick'] ?? '')); + + if ($brickClass === null || ! in_array($band, $brickClass::allowedBands(), true)) { + continue; + } + + $width = ReportBlockWidth::tryFrom((string) ($entry['width'] ?? '')) ?? ReportBlockWidth::FULL; + $config = is_array($entry['config'] ?? null) ? $entry['config'] : []; + + $sanitized[$band->value][] = [ + 'brick' => $brickClass::getId(), + 'width' => $width->value, + 'config' => $brickClass::filterConfig($config), + ]; + } + } + + return $sanitized; + } + + /** + * Resolve the disk path for a template. Slugs are strictly validated + * ([a-z0-9-] only) so path traversal is impossible; company paths come + * from the tenant context exclusively. + */ + public function path(string $scope, string $slug, ?ReportTemplateType $type = null): string + { + $this->assertValidSlug($slug); + + if ($scope === self::SCOPE_SYSTEM) { + if ($type === null) { + throw new InvalidArgumentException('System template paths require a document type.'); + } + + return self::SCOPE_SYSTEM . '/' . $type->value . '/' . $slug; + } + + if ($scope === self::SCOPE_COMPANY) { + return $this->companyId() . '/' . $slug; + } + + throw new InvalidArgumentException("Unknown report template scope [{$scope}]."); + } + + protected function assertValidSlug(string $slug): void + { + if ($slug === '' || preg_match('/^[a-z0-9][a-z0-9-]*$/', $slug) !== 1) { + throw new InvalidArgumentException("Invalid report template slug [{$slug}]."); + } + } + + /** + * Current company id from the tenant context (Filament tenant, then + * session, then the user's first company). Never caller-supplied. + */ + protected function companyId(): int + { + $tenant = Filament::getTenant(); + + if ($tenant !== null) { + return (int) $tenant->getKey(); + } + + if (session()?->has('current_company_id')) { + return (int) session('current_company_id'); + } + + $company = Auth::user()?->companies()->first(); + + if ($company !== null) { + return (int) $company->id; + } + + throw new RuntimeException('No company context available for report template storage.'); + } + + protected function uniqueSlug(string $scope, string $slug, ?ReportTemplateType $type): string + { + $this->assertValidSlug($slug); + + $candidate = $slug; + $suffix = 2; + + while ($this->exists($scope, $candidate, $type)) { + $candidate = $slug . '-' . $suffix++; + } + + return $candidate; + } + + protected function readJson(string $path): ?array + { + if ( ! Storage::disk(self::DISK)->exists($path)) { + return null; + } + + try { + $decoded = json_decode((string) Storage::disk(self::DISK)->get($path), true, 64, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return null; + } + + return is_array($decoded) ? $decoded : null; + } + + protected function encodeJson(array $data): string + { + return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } +} diff --git a/Modules/Core/Tests/Feature/ReportsSyncSystemCommandTest.php b/Modules/Core/Tests/Feature/ReportsSyncSystemCommandTest.php new file mode 100644 index 00000000..172a5252 --- /dev/null +++ b/Modules/Core/Tests/Feature/ReportsSyncSystemCommandTest.php @@ -0,0 +1,71 @@ +artisan('reports:sync-system')->assertSuccessful(); + + /* Assert */ + $disk = Storage::disk(ReportTemplateStorage::DISK); + $this->assertTrue($disk->exists('system/invoice/default/manifest.json')); + $this->assertTrue($disk->exists('system/invoice/default/bands.json')); + $this->assertTrue($disk->exists('system/quote/default/manifest.json')); + $this->assertTrue($disk->exists('system/quote/default/bands.json')); + } + + #[Test] + public function it_is_idempotent_when_run_twice(): void + { + /* Act */ + $this->artisan('reports:sync-system')->assertSuccessful(); + $firstRun = Storage::disk(ReportTemplateStorage::DISK)->allFiles('system'); + + $this->artisan('reports:sync-system')->assertSuccessful(); + $secondRun = Storage::disk(ReportTemplateStorage::DISK)->allFiles('system'); + + /* Assert */ + $this->assertSame($firstRun, $secondRun); + + $storage = new ReportTemplateStorage(); + $this->assertCount(2, $storage->listSystem()); + } + + #[Test] + public function it_loads_a_synced_default_template_with_valid_bands(): void + { + /* Arrange */ + $this->artisan('reports:sync-system')->assertSuccessful(); + + /* Act */ + $storage = new ReportTemplateStorage(); + $template = $storage->load( + ReportTemplateStorage::SCOPE_SYSTEM, + 'default', + \Modules\Core\Enums\ReportTemplateType::INVOICE, + ); + + /* Assert */ + $this->assertNotNull($template); + $this->assertSame('invoice', $template['manifest']['type']); + $this->assertNotEmpty($template['bands']['header']); + $this->assertNotEmpty($template['bands']['details']); + $this->assertNotEmpty($template['bands']['footer']); + } +} diff --git a/Modules/Core/Tests/Unit/MasonBricksTest.php b/Modules/Core/Tests/Unit/MasonBricksTest.php new file mode 100644 index 00000000..916940c8 --- /dev/null +++ b/Modules/Core/Tests/Unit/MasonBricksTest.php @@ -0,0 +1,288 @@ +assertEquals('header_company', $id); + } + + #[Test] + public function it_header_company_brick_generates_preview_html(): void + { + /* Arrange */ + $config = [ + 'show_vat_id' => true, + 'show_phone' => true, + 'font_size' => 10, + ]; + + /* Act */ + $html = HeaderCompanyBrick::toPreviewHtml($config); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString(trans('ip.company_name'), $html); + } + + #[Test] + public function it_header_company_brick_generates_render_html(): void + { + /* Arrange */ + $config = ['show_vat_id' => true]; + $data = [ + 'company' => [ + 'name' => 'Test Company', + 'vat_id' => '123456', + ], + ]; + + /* Act */ + $html = HeaderCompanyBrick::toHtml($config, $data); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString('Test Company', $html); + } + + #[Test] + public function it_header_client_brick_has_correct_id(): void + { + /* Act */ + $id = HeaderClientBrick::getId(); + + /* Assert */ + $this->assertEquals('header_client', $id); + } + + #[Test] + public function it_header_client_brick_generates_html(): void + { + /* Arrange */ + $config = ['show_phone' => true]; + $data = [ + 'client' => [ + 'name' => 'Test Client', + 'phone' => '555-1234', + ], + ]; + + /* Act */ + $html = HeaderClientBrick::toHtml($config, $data); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString('Test Client', $html); + } + + #[Test] + public function it_header_invoice_meta_brick_has_correct_id(): void + { + /* Act */ + $id = HeaderInvoiceMetaBrick::getId(); + + /* Assert */ + $this->assertEquals('header_invoice_meta', $id); + } + + #[Test] + public function it_header_invoice_meta_brick_shows_configured_fields(): void + { + /* Arrange */ + $config = [ + 'show_invoice_number' => true, + 'show_invoice_date' => true, + 'show_due_date' => false, + ]; + $data = [ + 'invoice' => [ + 'number' => 'INV-001', + 'date' => '2024-01-01', + ], + ]; + + /* Act */ + $html = HeaderInvoiceMetaBrick::toHtml($config, $data); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString('INV-001', $html); + } + + #[Test] + public function it_detail_items_brick_has_correct_id(): void + { + /* Act */ + $id = DetailItemsBrick::getId(); + + /* Assert */ + $this->assertEquals('detail_items', $id); + } + + #[Test] + public function it_detail_items_brick_renders_items_table(): void + { + /* Arrange */ + $config = [ + 'show_description' => true, + 'show_quantity' => true, + 'show_price' => true, + ]; + $data = [ + 'items' => [ + [ + 'description' => 'Item 1', + 'quantity' => 2, + 'price' => '100.00', + ], + ], + ]; + + /* Act */ + $html = DetailItemsBrick::toHtml($config, $data); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString('Item 1', $html); + } + + #[Test] + public function it_footer_totals_brick_has_correct_id(): void + { + /* Act */ + $id = FooterTotalsBrick::getId(); + + /* Assert */ + $this->assertEquals('footer_totals', $id); + } + + #[Test] + public function it_footer_totals_brick_displays_configured_totals(): void + { + /* Arrange */ + $config = [ + 'show_subtotal' => true, + 'show_tax' => true, + 'show_total' => true, + ]; + $data = [ + 'totals' => [ + 'subtotal' => '100.00', + 'tax' => '10.00', + 'total' => '110.00', + ], + ]; + + /* Act */ + $html = FooterTotalsBrick::toHtml($config, $data); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString('110.00', $html); + } + + #[Test] + public function it_footer_notes_brick_has_correct_id(): void + { + /* Act */ + $id = FooterNotesBrick::getId(); + + /* Assert */ + $this->assertEquals('footer_notes', $id); + } + + #[Test] + public function it_footer_notes_brick_renders_custom_content(): void + { + /* Arrange */ + $config = [ + 'footer_content' => '

Custom payment terms

', + ]; + $data = []; + + /* Act */ + $html = FooterNotesBrick::toHtml($config, $data); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString('Custom payment terms', $html); + } + + #[Test] + public function it_all_bricks_have_unique_ids(): void + { + /* Arrange */ + $bricks = [ + HeaderCompanyBrick::class, + HeaderClientBrick::class, + HeaderInvoiceMetaBrick::class, + DetailItemsBrick::class, + FooterTotalsBrick::class, + FooterNotesBrick::class, + ]; + + /* Act */ + $ids = array_map(fn ($brick) => $brick::getId(), $bricks); + + /* Assert */ + $this->assertCount(6, array_unique($ids)); + $this->assertCount(6, $ids); + } + + #[Test] + public function it_all_bricks_return_labels(): void + { + /* Arrange */ + $bricks = [ + HeaderCompanyBrick::class, + HeaderClientBrick::class, + HeaderInvoiceMetaBrick::class, + DetailItemsBrick::class, + FooterTotalsBrick::class, + FooterNotesBrick::class, + ]; + + /* Act & Assert */ + foreach ($bricks as $brick) { + $label = $brick::getLabel(); + $this->assertIsString($label); + $this->assertNotEmpty($label); + } + } + + #[Test] + public function it_all_bricks_return_icons(): void + { + /* Arrange */ + $bricks = [ + HeaderCompanyBrick::class, + HeaderClientBrick::class, + HeaderInvoiceMetaBrick::class, + DetailItemsBrick::class, + FooterTotalsBrick::class, + FooterNotesBrick::class, + ]; + + /* Act & Assert */ + foreach ($bricks as $brick) { + $icon = $brick::getIcon(); + $this->assertNotNull($icon); + } + } +} diff --git a/Modules/Core/Tests/Unit/ReportBrickTest.php b/Modules/Core/Tests/Unit/ReportBrickTest.php new file mode 100644 index 00000000..16f24baf --- /dev/null +++ b/Modules/Core/Tests/Unit/ReportBrickTest.php @@ -0,0 +1,96 @@ +assertSame([ReportBand::HEADER, ReportBand::GROUP_HEADER], HeaderCompanyBrick::allowedBands()); + $this->assertSame([ReportBand::DETAILS], DetailItemsBrick::allowedBands()); + $this->assertSame([ReportBand::GROUP_FOOTER, ReportBand::FOOTER], FooterTotalsBrick::allowedBands()); + } + + #[Test] + public function it_derives_config_keys_from_the_configure_action_schema(): void + { + /* Act */ + $keys = HeaderCompanyBrick::configKeys(); + + /* Assert */ + $this->assertContains('show_vat_id', $keys); + $this->assertContains('font_size', $keys); + $this->assertNotContains('unknown_key', $keys); + } + + #[Test] + public function it_reports_config_keys_for_every_registered_brick_without_error(): void + { + /* Assert */ + foreach (ReportBricksCollection::all() as $brick) { + $this->assertIsArray($brick::configKeys(), "configKeys failed for {$brick}"); + } + } + + #[Test] + public function it_filters_config_to_declared_keys_only(): void + { + /* Act */ + $filtered = HeaderCompanyBrick::filterConfig([ + 'show_vat_id' => true, + 'injected' => 'payload', + ]); + + /* Assert */ + $this->assertSame(['show_vat_id' => true], $filtered); + } + + #[Test] + public function it_has_no_config_keys_for_the_page_break_brick(): void + { + /* Assert */ + $this->assertSame([], PageBreakBrick::configKeys()); + } + + #[Test] + public function it_renders_every_registered_brick_preview_and_html_without_error(): void + { + /* Assert */ + foreach (ReportBricksCollection::all() as $brick) { + $this->assertIsString($brick::toPreviewHtml([]), "toPreviewHtml failed for {$brick}"); + $this->assertIsString($brick::toHtml([], []), "toHtml failed for {$brick}"); + } + } + + #[Test] + public function it_renders_a_page_break_as_page_break_css(): void + { + /* Act */ + $html = PageBreakBrick::toHtml([], []); + + /* Assert */ + $this->assertStringContainsString('page-break-after: always', $html); + } + + #[Test] + public function it_renders_the_spacer_with_the_configured_height(): void + { + /* Act */ + $html = SpacerBrick::toHtml(['height' => 42], []); + + /* Assert */ + $this->assertStringContainsString('height: 42px', $html); + } +} diff --git a/Modules/Core/Tests/Unit/ReportBricksCollectionTest.php b/Modules/Core/Tests/Unit/ReportBricksCollectionTest.php new file mode 100644 index 00000000..5eb27fb0 --- /dev/null +++ b/Modules/Core/Tests/Unit/ReportBricksCollectionTest.php @@ -0,0 +1,167 @@ +assertIsArray($bricks); + $this->assertCount(19, $bricks); + } + + #[Test] + public function it_returns_header_bricks(): void + { + /* Act */ + $headerBricks = ReportBricksCollection::header(); + + /* Assert */ + $this->assertIsArray($headerBricks); + $this->assertCount(5, $headerBricks); + $this->assertContains(HeaderCompanyBrick::class, $headerBricks); + $this->assertContains(HeaderClientBrick::class, $headerBricks); + $this->assertContains(HeaderInvoiceMetaBrick::class, $headerBricks); + $this->assertContains(HeaderQuoteMetaBrick::class, $headerBricks); + $this->assertContains(HeaderProjectBrick::class, $headerBricks); + } + + #[Test] + public function it_returns_detail_bricks(): void + { + /* Act */ + $detailBricks = ReportBricksCollection::detail(); + + /* Assert */ + $this->assertIsArray($detailBricks); + $this->assertCount(8, $detailBricks); + $this->assertContains(DetailItemsBrick::class, $detailBricks); + $this->assertContains(DetailTasksBrick::class, $detailBricks); + $this->assertContains(DetailInvoiceProductBrick::class, $detailBricks); + $this->assertContains(DetailInvoiceProjectBrick::class, $detailBricks); + $this->assertContains(DetailQuoteProductBrick::class, $detailBricks); + $this->assertContains(DetailQuoteProjectBrick::class, $detailBricks); + $this->assertContains(DetailCustomerAgingBrick::class, $detailBricks); + $this->assertContains(DetailExpenseBrick::class, $detailBricks); + } + + #[Test] + public function it_returns_footer_bricks(): void + { + /* Act */ + $footerBricks = ReportBricksCollection::footer(); + + /* Assert */ + $this->assertIsArray($footerBricks); + $this->assertCount(4, $footerBricks); + $this->assertContains(FooterTotalsBrick::class, $footerBricks); + $this->assertContains(FooterNotesBrick::class, $footerBricks); + $this->assertContains(FooterTermsBrick::class, $footerBricks); + $this->assertContains(FooterSummaryBrick::class, $footerBricks); + } + + #[Test] + public function it_all_method_combines_all_sections(): void + { + /* Arrange */ + $headerCount = count(ReportBricksCollection::header()); + $detailCount = count(ReportBricksCollection::detail()); + $footerCount = count(ReportBricksCollection::footer()); + $utilityCount = count(ReportBricksCollection::utility()); + + /* Act */ + $allBricks = ReportBricksCollection::all(); + + /* Assert */ + $this->assertCount($headerCount + $detailCount + $footerCount + $utilityCount, $allBricks); + } + + #[Test] + public function it_returns_only_bricks_allowed_in_the_requested_band(): void + { + /* Act */ + $headerBricks = ReportBricksCollection::forBand(ReportBand::HEADER); + $detailBricks = ReportBricksCollection::forBand(ReportBand::DETAILS); + + /* Assert */ + $this->assertContains(HeaderCompanyBrick::class, $headerBricks); + $this->assertNotContains(DetailItemsBrick::class, $headerBricks); + $this->assertNotContains(FooterTotalsBrick::class, $headerBricks); + + $this->assertContains(DetailItemsBrick::class, $detailBricks); + $this->assertNotContains(HeaderCompanyBrick::class, $detailBricks); + } + + #[Test] + public function it_allows_utility_bricks_in_every_band(): void + { + /* Assert */ + foreach (ReportBand::cases() as $band) { + $bandBricks = ReportBricksCollection::forBand($band); + + $this->assertContains(PageBreakBrick::class, $bandBricks, "Page break should be allowed in {$band->value}"); + $this->assertContains(SpacerBrick::class, $bandBricks, "Spacer should be allowed in {$band->value}"); + } + } + + #[Test] + public function it_finds_a_brick_class_by_its_id(): void + { + /* Assert */ + $this->assertSame(HeaderCompanyBrick::class, ReportBricksCollection::findById('header_company')); + $this->assertSame(PageBreakBrick::class, ReportBricksCollection::findById('page_break')); + $this->assertNull(ReportBricksCollection::findById('does_not_exist')); + } + + #[Test] + public function it_all_bricks_are_valid_class_names(): void + { + /* Act */ + $allBricks = ReportBricksCollection::all(); + + /* Assert */ + foreach ($allBricks as $brick) { + $this->assertTrue(class_exists($brick), "Class {$brick} should exist"); + } + } + + #[Test] + public function it_no_duplicate_bricks_in_collection(): void + { + /* Act */ + $allBricks = ReportBricksCollection::all(); + + /* Assert */ + $uniqueBricks = array_unique($allBricks); + $this->assertCount(count($allBricks), $uniqueBricks); + } +} diff --git a/Modules/Core/Tests/Unit/ReportTemplateStorageTest.php b/Modules/Core/Tests/Unit/ReportTemplateStorageTest.php new file mode 100644 index 00000000..85176669 --- /dev/null +++ b/Modules/Core/Tests/Unit/ReportTemplateStorageTest.php @@ -0,0 +1,270 @@ +storage = new ReportTemplateStorage(); + + session()->put('current_company_id', 1); + } + + #[Test] + public function it_round_trips_a_saved_template_through_load(): void + { + /* Act */ + $this->storage->save(ReportTemplateStorage::SCOPE_COMPANY, 'test-template', $this->manifest(), $this->bands()); + $loaded = $this->storage->load(ReportTemplateStorage::SCOPE_COMPANY, 'test-template'); + + /* Assert */ + $this->assertNotNull($loaded); + $this->assertSame('Test Template', $loaded['manifest']['name']); + $this->assertSame('header_company', $loaded['bands']['header'][0]['brick']); + $this->assertSame(['show_vat_id' => false], $loaded['bands']['header'][0]['config']); + $this->assertSame('detail_items', $loaded['bands']['details'][0]['brick']); + } + + #[Test] + public function it_skips_unknown_bricks_when_sanitizing_bands(): void + { + /* Act */ + $sanitized = $this->storage->sanitizeBands([ + 'header' => [ + ['brick' => 'header_company', 'width' => 'half', 'config' => []], + ['brick' => 'evil_unknown_brick', 'width' => 'half', 'config' => []], + ], + ]); + + /* Assert */ + $this->assertCount(1, $sanitized['header']); + $this->assertSame('header_company', $sanitized['header'][0]['brick']); + } + + #[Test] + public function it_skips_bricks_placed_in_a_disallowed_band(): void + { + /* Act */ + $sanitized = $this->storage->sanitizeBands([ + 'header' => [['brick' => 'detail_items', 'width' => 'full', 'config' => []]], + ]); + + /* Assert */ + $this->assertSame([], $sanitized['header']); + } + + #[Test] + public function it_falls_back_to_full_width_for_invalid_widths(): void + { + /* Act */ + $sanitized = $this->storage->sanitizeBands([ + 'header' => [['brick' => 'header_company', 'width' => 'gigantic', 'config' => []]], + ]); + + /* Assert */ + $this->assertSame('full', $sanitized['header'][0]['width']); + } + + #[Test] + public function it_filters_brick_config_against_the_brick_schema(): void + { + /* Act */ + $sanitized = $this->storage->sanitizeBands([ + 'header' => [[ + 'brick' => 'header_company', + 'width' => 'half', + 'config' => ['show_vat_id' => true, 'malicious_key' => '