From c56af3a9ff3f41b7047afedc19dd0f31afe8cdf6 Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Mon, 13 Jul 2026 09:25:06 +0200 Subject: [PATCH 1/6] 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 9dfe654aa..96e207591 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -255,3 +255,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 000000000..e016ebc4d --- /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 90e149cc3..de3747a93 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 000000000..c5ff65da5 --- /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 000000000..fa5f06349 --- /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 000000000..a7ac30804 --- /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 000000000..8fc5198d9 --- /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 000000000..a294063ec --- /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 000000000..4b4820982 --- /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 000000000..7c99247c7 --- /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 000000000..6385cb9d1 --- /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 000000000..800e7ad3d --- /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 000000000..8c89bddab --- /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 000000000..097e3f312 --- /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 000000000..55b7fd890 --- /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 000000000..cc887bf70 --- /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 000000000..7c45ef2d1 --- /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 000000000..3649420d3 --- /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 000000000..2ec0e61cc --- /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 000000000..9d2d7bc46 --- /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 000000000..c22c1ecaf --- /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 000000000..034288dff --- /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 000000000..a2e237c3a --- /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 000000000..feccb43dd --- /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 000000000..9a8c2e367 --- /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 000000000..6dc35c6f2 --- /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 85eab6ac8..472977aba 100644 --- a/Modules/Core/Providers/CoreServiceProvider.php +++ b/Modules/Core/Providers/CoreServiceProvider.php @@ -71,7 +71,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 000000000..9cde29101 --- /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 000000000..172a5252b --- /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 000000000..916940c8d --- /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 000000000..16f24baf8 --- /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 000000000..5eb27fb05 --- /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 000000000..85176669a --- /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' => '