diff --git a/Modules/Core/DTOs/BlockDTO.php b/Modules/Core/DTOs/BlockDTO.php
new file mode 100644
index 000000000..db0a71944
--- /dev/null
+++ b/Modules/Core/DTOs/BlockDTO.php
@@ -0,0 +1,252 @@
+setType($type);
+ $dto->setPosition($position);
+ $dto->setConfig($config);
+ $dto->setIsCloneable(true);
+ $dto->setIsCloned(false);
+ $dto->setClonedFrom(null);
+
+ return $dto;
+ }
+
+ /**
+ * Create a cloned block from an original block.
+ */
+ public static function clonedFrom(self $original, string $newId): self
+ {
+ $dto = new self();
+ $dto->setId($newId);
+ $dto->setType($original->getType());
+ $dto->setSlug($original->getSlug());
+
+ $originalPosition = $original->getPosition();
+ $newPosition = GridPositionDTO::create(
+ $originalPosition->getX(),
+ $originalPosition->getY(),
+ $originalPosition->getWidth(),
+ $originalPosition->getHeight()
+ );
+
+ $dto->setPosition($newPosition);
+ $dto->setConfig($original->getConfig());
+ $dto->setLabel($original->getLabel());
+ $dto->setIsCloneable($original->getIsCloneable());
+ $dto->setDataSource($original->getDataSource());
+ $dto->setBand($original->getBand());
+ $dto->setIsCloned(true);
+ $dto->setClonedFrom($original->getId());
+
+ return $dto;
+ }
+
+ //endregion
+
+ //region Getters
+
+ public function getId(): string
+ {
+ return $this->id;
+ }
+
+ public function getType(): ReportBlockType|string
+ {
+ return $this->type;
+ }
+
+ public function getSlug(): ?string
+ {
+ return $this->slug;
+ }
+
+ public function getPosition(): ?GridPositionDTO
+ {
+ return $this->position;
+ }
+
+ public function getConfig(): ?array
+ {
+ return $this->config;
+ }
+
+ public function getLabel(): ?string
+ {
+ return $this->label;
+ }
+
+ public function getIsCloneable(): bool
+ {
+ return $this->isCloneable;
+ }
+
+ public function getDataSource(): ?string
+ {
+ return $this->dataSource;
+ }
+
+ public function getIsCloned(): bool
+ {
+ return $this->isCloned;
+ }
+
+ public function getClonedFrom(): ?string
+ {
+ return $this->clonedFrom;
+ }
+
+ //endregion
+
+ //region Setters
+
+ public function setId(string $id): self
+ {
+ $this->id = $id;
+
+ return $this;
+ }
+
+ public function setType(ReportBlockType|string $type): self
+ {
+ $this->type = $type;
+
+ return $this;
+ }
+
+ public function setSlug(?string $slug): self
+ {
+ $this->slug = $slug;
+
+ return $this;
+ }
+
+ public function setPosition(GridPositionDTO $position): self
+ {
+ $this->position = $position;
+
+ return $this;
+ }
+
+ public function setConfig(?array $config): self
+ {
+ $this->config = $config;
+
+ return $this;
+ }
+
+ public function setLabel(?string $label): self
+ {
+ $this->label = $label;
+
+ return $this;
+ }
+
+ public function setIsCloneable(bool $isCloneable): self
+ {
+ $this->isCloneable = $isCloneable;
+
+ return $this;
+ }
+
+ public function setDataSource(?string $dataSource): self
+ {
+ $this->dataSource = $dataSource;
+
+ return $this;
+ }
+
+ public function setIsCloned(bool $isCloned): self
+ {
+ $this->isCloned = $isCloned;
+
+ return $this;
+ }
+
+ public function setClonedFrom(?string $clonedFrom): self
+ {
+ $this->clonedFrom = $clonedFrom;
+
+ return $this;
+ }
+
+ public function getBand(): string
+ {
+ return $this->band;
+ }
+
+ public function setBand(string $band): self
+ {
+ $this->band = $band;
+
+ return $this;
+ }
+
+ //endregion
+}
diff --git a/Modules/Core/DTOs/GridPositionDTO.php b/Modules/Core/DTOs/GridPositionDTO.php
new file mode 100644
index 000000000..f1fd0d7d1
--- /dev/null
+++ b/Modules/Core/DTOs/GridPositionDTO.php
@@ -0,0 +1,137 @@
+x = 0;
+ $this->y = 0;
+ $this->width = 0;
+ $this->height = 0;
+ }
+
+ /**
+ * Static factory method to create a GridPositionDTO with all values.
+ *
+ * @param int $x X coordinate
+ * @param int $y Y coordinate
+ * @param int $width Width
+ * @param int $height Height
+ *
+ * @return self
+ *
+ * @throws InvalidArgumentException
+ */
+ public static function create(int $x, int $y, int $width, int $height): self
+ {
+ if ($x < 0 || $y < 0) {
+ throw new InvalidArgumentException('x and y must be >= 0');
+ }
+ if ($width <= 0 || $height <= 0) {
+ throw new InvalidArgumentException('width and height must be > 0');
+ }
+
+ $dto = new self();
+ $dto->x = $x;
+ $dto->y = $y;
+ $dto->width = $width;
+ $dto->height = $height;
+
+ return $dto;
+ }
+
+ //endregion
+
+ //region Getters
+
+ public function getX(): int
+ {
+ return $this->x;
+ }
+
+ public function getY(): int
+ {
+ return $this->y;
+ }
+
+ public function getWidth(): int
+ {
+ return $this->width;
+ }
+
+ public function getHeight(): int
+ {
+ return $this->height;
+ }
+
+ //endregion
+
+ //region Setters
+
+ public function setX(int $x): self
+ {
+ $this->x = $x;
+
+ return $this;
+ }
+
+ public function setY(int $y): self
+ {
+ $this->y = $y;
+
+ return $this;
+ }
+
+ public function setWidth(int $width): self
+ {
+ $this->width = $width;
+
+ return $this;
+ }
+
+ public function setHeight(int $height): self
+ {
+ $this->height = $height;
+
+ return $this;
+ }
+
+ //endregion
+}
diff --git a/Modules/Core/Database/Factories/ReportBlockFactory.php b/Modules/Core/Database/Factories/ReportBlockFactory.php
new file mode 100644
index 000000000..b5e6651d7
--- /dev/null
+++ b/Modules/Core/Database/Factories/ReportBlockFactory.php
@@ -0,0 +1,55 @@
+faker->words(2, true);
+ $slug = Str::slug($name) . '-' . Str::random(8);
+
+ return [
+ 'is_active' => true,
+ 'is_system' => false,
+ 'block_type' => $this->faker->randomElement(ReportBlockType::cases()),
+ 'name' => ucfirst($name),
+ 'slug' => $slug,
+ 'filename' => $slug,
+ 'width' => $this->faker->randomElement(ReportBlockWidth::cases()),
+ 'data_source' => $this->faker->randomElement(ReportDataSource::cases()),
+ 'default_band' => $this->faker->randomElement(ReportBand::cases()),
+ ];
+ }
+
+ public function system(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'is_system' => true,
+ ]);
+ }
+
+ public function inactive(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'is_active' => false,
+ ]);
+ }
+
+ public function width(ReportBlockWidth $width): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'width' => $width,
+ ]);
+ }
+}
diff --git a/Modules/Core/Database/Factories/ReportTemplateFactory.php b/Modules/Core/Database/Factories/ReportTemplateFactory.php
new file mode 100644
index 000000000..6d6bda131
--- /dev/null
+++ b/Modules/Core/Database/Factories/ReportTemplateFactory.php
@@ -0,0 +1,57 @@
+faker->words(3, true);
+
+ return [
+ 'company_id' => Company::factory(),
+ 'name' => ucfirst($name),
+ 'slug' => Str::slug($name),
+ 'description' => $this->faker->optional(0.7)->sentence(),
+ 'template_type' => $this->faker->randomElement(ReportTemplateType::cases()),
+ 'is_system' => false,
+ 'is_active' => true,
+ ];
+ }
+
+ public function system(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'is_system' => true,
+ ]);
+ }
+
+ public function inactive(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'is_active' => false,
+ ]);
+ }
+
+ public function forCompany(Company $company): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'company_id' => $company->id,
+ ]);
+ }
+
+ public function ofType(ReportTemplateType $type): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'template_type' => $type,
+ ]);
+ }
+}
diff --git a/Modules/Core/Database/Migrations/2025_10_26_create_report_templates_table.php b/Modules/Core/Database/Migrations/2025_10_26_create_report_templates_table.php
new file mode 100644
index 000000000..d935fa48d
--- /dev/null
+++ b/Modules/Core/Database/Migrations/2025_10_26_create_report_templates_table.php
@@ -0,0 +1,29 @@
+id();
+ $table->unsignedBigInteger('company_id');
+ $table->boolean('is_system')->default(false);
+ $table->boolean('is_active')->default(true);
+ $table->string('template_type');
+ $table->string('name');
+ $table->string('slug');
+ $table->string('filename')->nullable();
+
+ $table->foreign('company_id')->references('id')->on('companies')->onDelete('cascade');
+ $table->unique(['company_id', 'slug']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('report_templates');
+ }
+};
diff --git a/Modules/Core/Database/Migrations/2026_01_01_184544_create_report_blocks_table.php b/Modules/Core/Database/Migrations/2026_01_01_184544_create_report_blocks_table.php
new file mode 100644
index 000000000..277b5d163
--- /dev/null
+++ b/Modules/Core/Database/Migrations/2026_01_01_184544_create_report_blocks_table.php
@@ -0,0 +1,28 @@
+id();
+ $table->boolean('is_active')->default(true);
+ $table->boolean('is_system')->default(false);
+ $table->string('block_type');
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->string('filename')->nullable();
+ $table->string('width')->default('half'); // one_third, half, two_thirds, or full
+ $table->string('data_source')->default('company');
+ $table->string('default_band')->default('header');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('report_blocks');
+ }
+};
diff --git a/Modules/Core/Database/Seeders/ReportBlocksSeeder.php b/Modules/Core/Database/Seeders/ReportBlocksSeeder.php
new file mode 100644
index 000000000..d73deb4d4
--- /dev/null
+++ b/Modules/Core/Database/Seeders/ReportBlocksSeeder.php
@@ -0,0 +1,109 @@
+ ReportBlockType::ADDRESS,
+ 'name' => 'Company Header',
+ 'width' => ReportBlockWidth::HALF,
+ 'data_source' => ReportDataSource::COMPANY,
+ 'default_band' => ReportBand::GROUP_HEADER,
+ ],
+ [
+ 'block_type' => ReportBlockType::ADDRESS,
+ 'name' => 'Customer Header',
+ 'width' => ReportBlockWidth::HALF,
+ 'data_source' => ReportDataSource::CUSTOMER,
+ 'default_band' => ReportBand::GROUP_HEADER,
+ ],
+ [
+ 'block_type' => ReportBlockType::METADATA,
+ 'name' => 'Invoice Metadata',
+ 'width' => ReportBlockWidth::FULL,
+ 'data_source' => ReportDataSource::INVOICE,
+ 'default_band' => ReportBand::GROUP_HEADER,
+ ],
+ [
+ 'block_type' => ReportBlockType::DETAILS,
+ 'name' => 'Invoice Items',
+ 'width' => ReportBlockWidth::FULL,
+ 'data_source' => ReportDataSource::INVOICE,
+ 'default_band' => ReportBand::DETAILS,
+ ],
+ [
+ 'block_type' => ReportBlockType::DETAILS,
+ 'name' => 'Item Tax Details',
+ 'width' => ReportBlockWidth::FULL,
+ 'data_source' => ReportDataSource::INVOICE,
+ 'default_band' => ReportBand::DETAILS,
+ 'config' => ['show_tax_name' => true, 'show_tax_rate' => true],
+ ],
+ [
+ 'block_type' => ReportBlockType::TOTALS,
+ 'name' => 'Invoice Totals',
+ 'width' => ReportBlockWidth::HALF,
+ 'data_source' => ReportDataSource::INVOICE,
+ 'default_band' => ReportBand::GROUP_FOOTER,
+ ],
+ [
+ 'block_type' => ReportBlockType::METADATA,
+ 'name' => 'Footer Notes',
+ 'width' => ReportBlockWidth::HALF,
+ 'data_source' => ReportDataSource::INVOICE,
+ 'default_band' => ReportBand::FOOTER,
+ ],
+ [
+ 'block_type' => ReportBlockType::METADATA,
+ 'name' => 'QR Code',
+ 'width' => ReportBlockWidth::HALF,
+ 'data_source' => ReportDataSource::INVOICE,
+ 'default_band' => ReportBand::FOOTER,
+ ],
+ ];
+
+ foreach ($blocks as $block) {
+ $baseSlug = Str::slug($block['name']);
+ $slug = $baseSlug . '-' . Str::random(8);
+ $filename = $slug;
+
+ ReportBlock::create([
+ 'is_active' => true,
+ 'is_system' => true,
+ 'block_type' => $block['block_type'],
+ 'name' => $block['name'],
+ 'slug' => $slug,
+ 'filename' => $filename,
+ 'width' => $block['width'],
+ 'data_source' => $block['data_source'],
+ 'default_band' => $block['default_band'],
+ ]);
+
+ // Ensure directory exists
+ if ( ! Storage::disk('local')->exists('report_blocks')) {
+ Storage::disk('local')->makeDirectory('report_blocks');
+ }
+
+ // Save default config to JSON if it doesn't exist
+ $path = 'report_blocks/' . $filename . '.json';
+ if ( ! Storage::disk('local')->exists($path)) {
+ $config = $block['config'];
+ $config['fields'] = []; // Start with no fields as requested for drag/drop
+ Storage::disk('local')->put($path, json_encode($config, JSON_PRETTY_PRINT));
+ }
+ }
+ }
+}
diff --git a/Modules/Core/Enums/ReportBlockType.php b/Modules/Core/Enums/ReportBlockType.php
new file mode 100644
index 000000000..a352e639f
--- /dev/null
+++ b/Modules/Core/Enums/ReportBlockType.php
@@ -0,0 +1,37 @@
+ trans('ip.report_block_type_address'),
+ self::DETAILS => trans('ip.report_block_type_details'),
+ self::METADATA => trans('ip.report_block_type_metadata'),
+ self::TOTALS => trans('ip.report_block_type_totals'),
+ };
+ }
+
+ /**
+ * Get a description for the block type.
+ */
+ public function getDescription(): string
+ {
+ return match ($this) {
+ self::ADDRESS => trans('ip.report_block_type_address_desc'),
+ self::DETAILS => trans('ip.report_block_type_details_desc'),
+ self::METADATA => trans('ip.report_block_type_metadata_desc'),
+ self::TOTALS => trans('ip.report_block_type_totals_desc'),
+ };
+ }
+}
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/Filament/Admin/Resources/ReportBlocks/Pages/EditReportBlock.php b/Modules/Core/Filament/Admin/Resources/ReportBlocks/Pages/EditReportBlock.php
new file mode 100644
index 000000000..164ed7122
--- /dev/null
+++ b/Modules/Core/Filament/Admin/Resources/ReportBlocks/Pages/EditReportBlock.php
@@ -0,0 +1,58 @@
+record = ReportBlock::query()->findOrFail($record);
+ }
+
+ public function editAction(): Action
+ {
+ return Action::make('edit')
+ ->label('Edit Modal')
+ ->schema(fn (Schema $schema) => ReportBlockForm::configure($schema))
+ ->mountUsing(function (Schema $schema) {
+ $data = $this->record->toArray();
+ $data['is_active'] = (bool) ($data['is_active'] ?? true);
+ if (isset($data['width']) && $data['width'] instanceof BackedEnum) {
+ $data['width'] = $data['width']->value;
+ }
+ $schema->fill($data);
+ })
+ ->fillForm(function () {
+ $data = $this->record->toArray();
+ $data['is_active'] = (bool) ($data['is_active'] ?? true);
+ if (isset($data['width']) && $data['width'] instanceof BackedEnum) {
+ $data['width'] = $data['width']->value;
+ }
+
+ return $data;
+ })
+ ->action(function (array $data) {
+ $this->record->update($data);
+ $this->record->refresh();
+ });
+ }
+}
diff --git a/Modules/Core/Filament/Admin/Resources/ReportBlocks/Pages/ListReportBlocks.php b/Modules/Core/Filament/Admin/Resources/ReportBlocks/Pages/ListReportBlocks.php
new file mode 100644
index 000000000..0c2f191d4
--- /dev/null
+++ b/Modules/Core/Filament/Admin/Resources/ReportBlocks/Pages/ListReportBlocks.php
@@ -0,0 +1,28 @@
+action(function (array $data) {
+ app(ReportBlockService::class)->createReportBlock($data);
+ })
+ ->modalWidth('full'),
+ ];
+ }
+}
diff --git a/Modules/Core/Filament/Admin/Resources/ReportBlocks/ReportBlockResource.php b/Modules/Core/Filament/Admin/Resources/ReportBlocks/ReportBlockResource.php
new file mode 100644
index 000000000..154f689b8
--- /dev/null
+++ b/Modules/Core/Filament/Admin/Resources/ReportBlocks/ReportBlockResource.php
@@ -0,0 +1,39 @@
+ ListReportBlocks::route('/'),
+ 'edit' => EditReportBlock::route('/{record}/edit'),
+ ];
+ }
+}
diff --git a/Modules/Core/Filament/Admin/Resources/ReportBlocks/Schemas/ReportBlockForm.php b/Modules/Core/Filament/Admin/Resources/ReportBlocks/Schemas/ReportBlockForm.php
new file mode 100644
index 000000000..804b48970
--- /dev/null
+++ b/Modules/Core/Filament/Admin/Resources/ReportBlocks/Schemas/ReportBlockForm.php
@@ -0,0 +1,57 @@
+components([
+ Section::make(trans('ip.report_block_section_general'))
+ ->schema([
+ TextInput::make('name')
+ ->label(trans('ip.report_block_name'))
+ ->required()
+ ->maxLength(255),
+ Select::make('width')
+ ->label(trans('ip.report_block_width'))
+ ->options(ReportBlockWidth::class)
+ ->required(),
+ Select::make('block_type')
+ ->label(trans('ip.report_block_type'))
+ ->options(ReportBlockType::class)
+ ->required(),
+ Select::make('data_source')
+ ->label(trans('ip.report_block_data_source'))
+ ->options(ReportDataSource::class)
+ ->required(),
+ Select::make('default_band')
+ ->label(trans('ip.report_block_default_band'))
+ ->options(ReportBand::class)
+ ->required(),
+ Toggle::make('is_active')
+ ->label(trans('ip.report_block_is_active'))
+ ->default(true),
+ ]),
+ Section::make(trans('ip.report_block_section_field_configuration'))
+ ->schema([
+ ViewField::make('fields_canvas')
+ ->view('core::filament.admin.resources.report-blocks.fields-canvas')
+ ->label(trans('ip.report_block_fields_canvas_label'))
+ ->helperText(trans('ip.report_block_fields_canvas_help')),
+ ])
+ ->collapsible(),
+ ]);
+ }
+}
diff --git a/Modules/Core/Filament/Admin/Resources/ReportBlocks/Tables/ReportBlocksTable.php b/Modules/Core/Filament/Admin/Resources/ReportBlocks/Tables/ReportBlocksTable.php
new file mode 100644
index 000000000..8c9e9de57
--- /dev/null
+++ b/Modules/Core/Filament/Admin/Resources/ReportBlocks/Tables/ReportBlocksTable.php
@@ -0,0 +1,49 @@
+columns([
+ TextColumn::make('name')
+ ->searchable()
+ ->sortable(),
+ TextColumn::make('block_type')
+ ->searchable()
+ ->sortable(),
+ TextColumn::make('width')
+ ->sortable(),
+ TextColumn::make('data_source')
+ ->sortable(),
+ TextColumn::make('default_band')
+ ->sortable(),
+ IconColumn::make('is_active')
+ ->boolean()
+ ->sortable(),
+ IconColumn::make('is_system')
+ ->boolean()
+ ->sortable(),
+ ])
+ ->recordActions([
+ ActionGroup::make([
+ EditAction::make(),
+ DeleteAction::make('delete')
+ ->action(function (ReportBlock $record) {
+ app(ReportBlockService::class)->deleteReportBlock($record);
+ }),
+ ]),
+ ]);
+ }
+}
diff --git a/Modules/Core/Filament/Admin/Resources/ReportTemplates/Pages/CreateReportTemplate.php b/Modules/Core/Filament/Admin/Resources/ReportTemplates/Pages/CreateReportTemplate.php
new file mode 100644
index 000000000..a95705f10
--- /dev/null
+++ b/Modules/Core/Filament/Admin/Resources/ReportTemplates/Pages/CreateReportTemplate.php
@@ -0,0 +1,58 @@
+authorizeAccess();
+
+ $this->callHook('beforeValidate');
+ $data = $this->form->getState();
+ $this->callHook('afterValidate');
+
+ $data = $this->mutateFormDataBeforeCreate($data);
+ $this->callHook('beforeCreate');
+
+ $this->record = $this->handleRecordCreation($data);
+
+ $this->callHook('afterCreate');
+ $this->rememberData();
+
+ $this->getCreatedNotification()?->send();
+
+ if ($another) {
+ $this->form->model($this->getRecord()::class);
+ $this->record = null;
+ $this->fillForm();
+
+ return;
+ }
+
+ $this->redirect($this->getRedirectUrl());
+ }
+
+ protected function handleRecordCreation(array $data): Model
+ {
+ $company = Company::find(session('current_company_id'));
+ if ( ! $company) {
+ $company = auth()->user()->companies()->first();
+ }
+
+ return app(ReportTemplateService::class)->createTemplate(
+ $company,
+ $data['name'],
+ $data['template_type'],
+ []
+ );
+ }
+}
diff --git a/Modules/Core/Filament/Admin/Resources/ReportTemplates/Pages/EditReportTemplate.php b/Modules/Core/Filament/Admin/Resources/ReportTemplates/Pages/EditReportTemplate.php
new file mode 100644
index 000000000..453eee128
--- /dev/null
+++ b/Modules/Core/Filament/Admin/Resources/ReportTemplates/Pages/EditReportTemplate.php
@@ -0,0 +1,62 @@
+authorizeAccess();
+
+ $this->callHook('beforeValidate');
+ $data = $this->form->getState();
+ $this->callHook('afterValidate');
+
+ $data = $this->mutateFormDataBeforeSave($data);
+ $this->callHook('beforeSave');
+
+ $this->record = $this->handleRecordUpdate($this->getRecord(), $data);
+
+ $this->callHook('afterSave');
+
+ if ($shouldSendSavedNotification) {
+ $this->getSavedNotification()?->send();
+ }
+
+ if ($shouldRedirect) {
+ $this->redirect($this->getRedirectUrl());
+ }
+ }
+
+ protected function getHeaderActions(): array
+ {
+ return [
+ DeleteAction::make()
+ /* @phpstan-ignore-next-line */
+ ->visible(fn () => ! $this->record->is_system)
+ ->action(function () {
+ app(ReportTemplateService::class)->deleteTemplate($this->record);
+ }),
+ ];
+ }
+
+ protected function handleRecordUpdate(Model $record, array $data): Model
+ {
+ $record->update([
+ 'name' => $data['name'],
+ 'description' => $data['description'] ?? null,
+ 'template_type' => $data['template_type'],
+ 'is_active' => $data['is_active'] ?? true,
+ ]);
+
+ return $record;
+ }
+}
diff --git a/Modules/Core/Filament/Admin/Resources/ReportTemplates/Pages/ListReportTemplates.php b/Modules/Core/Filament/Admin/Resources/ReportTemplates/Pages/ListReportTemplates.php
new file mode 100644
index 000000000..138d49aef
--- /dev/null
+++ b/Modules/Core/Filament/Admin/Resources/ReportTemplates/Pages/ListReportTemplates.php
@@ -0,0 +1,35 @@
+action(function (array $data) {
+ $company = Company::query()->find(session('current_company_id'));
+ if ( ! $company) {
+ $company = auth()->user()->companies()->first();
+ }
+
+ $template = app(ReportTemplateService::class)->createTemplate(
+ $company,
+ $data['name'],
+ $data['template_type'],
+ []
+ );
+ })
+ ->modalWidth('full'),
+ ];
+ }
+}
diff --git a/Modules/Core/Filament/Admin/Resources/ReportTemplates/Pages/ReportBuilder.php b/Modules/Core/Filament/Admin/Resources/ReportTemplates/Pages/ReportBuilder.php
new file mode 100644
index 000000000..852c38d98
--- /dev/null
+++ b/Modules/Core/Filament/Admin/Resources/ReportTemplates/Pages/ReportBuilder.php
@@ -0,0 +1,477 @@
+authorizeReportBuilderAccess();
+ $this->record = $record;
+ $this->loadMasonContent();
+ }
+
+ public function setCurrentBlockId(?string $blockId): void
+ {
+ if ($blockId !== null) {
+ $this->currentBlockSlug = $blockId;
+ \Illuminate\Support\Facades\Log::debug('ReportBuilder::setCurrentBlockId() called', [
+ 'blockId' => $blockId,
+ 'currentBlockSlug' => $this->currentBlockSlug,
+ ]);
+ }
+ }
+
+ public function configureBlockAction(): Action
+ {
+ return Action::make('configureBlock')
+ ->arguments(['blockSlug'])
+ ->label(trans('ip.configure_block'))
+ ->schema(fn (Schema $schema) => ReportBlockForm::configure($schema))
+ ->fillForm(function (array $arguments) {
+ \Illuminate\Support\Facades\Log::debug('configureBlockAction::fillForm() called', [
+ 'arguments' => $arguments,
+ 'currentBlockSlug' => $this->currentBlockSlug,
+ ]);
+
+ $blockSlug = $arguments['blockSlug'] ?? $this->currentBlockSlug ?? null;
+
+ \Illuminate\Support\Facades\Log::debug('blockSlug resolved', [
+ 'blockSlug' => $blockSlug,
+ 'fromArguments' => $arguments['blockSlug'] ?? null,
+ 'fromProperty' => $this->currentBlockSlug,
+ ]);
+
+ if ( ! $blockSlug) {
+ \Illuminate\Support\Facades\Log::warning('No blockSlug provided to fillForm');
+
+ return [];
+ }
+
+ // Look up the block by id, slug, or block_type
+ $block = ReportBlock::query()
+ ->where('id', $blockSlug)
+ ->orWhere('slug', $blockSlug)
+ ->orWhere('block_type', $blockSlug)
+ ->first();
+
+ \Illuminate\Support\Facades\Log::debug('Block lookup result', [
+ 'blockSlug' => $blockSlug,
+ 'blockFound' => $block !== null,
+ 'blockId' => $block?->id,
+ 'blockName' => $block?->name,
+ ]);
+
+ if ( ! $block) {
+ \Illuminate\Support\Facades\Log::warning('Block not found in database', [
+ 'blockSlug' => $blockSlug,
+ ]);
+
+ return [
+ 'name' => '',
+ 'width' => 'full',
+ 'block_type' => '',
+ 'data_source' => '',
+ 'default_band' => '',
+ 'is_active' => true,
+ 'fields_canvas' => [],
+ ];
+ }
+
+ // Properly extract enum values for the form
+ $data = [
+ 'name' => $block->name ?? '',
+ 'width' => $block->width instanceof BackedEnum ? $block->width->value : ($block->width ?? 'full'),
+ 'block_type' => $block->block_type instanceof BackedEnum ? $block->block_type->value : ($block->block_type ?? ''),
+ 'data_source' => $block->data_source instanceof BackedEnum ? $block->data_source->value : ($block->data_source ?? ''),
+ 'default_band' => $block->default_band instanceof BackedEnum ? $block->default_band->value : ($block->default_band ?? ''),
+ 'is_active' => (bool) ($block->is_active ?? true),
+ 'fields_canvas' => [],
+ ];
+
+ \Illuminate\Support\Facades\Log::debug('fillForm returning data', [
+ 'blockSlug' => $blockSlug,
+ 'data' => $data,
+ 'blockRecord' => [
+ 'id' => $block->id,
+ 'name' => $block->name,
+ 'width' => $block->width,
+ 'block_type' => $block->block_type,
+ 'data_source' => $block->data_source,
+ 'default_band' => $block->default_band,
+ 'is_active' => $block->is_active,
+ ],
+ ]);
+
+ return $data;
+ })
+ ->mountUsing(function (Schema $schema, array $arguments) {
+ \Illuminate\Support\Facades\Log::debug('configureBlockAction::mountUsing() called', [
+ 'arguments' => $arguments,
+ 'currentBlockSlug' => $this->currentBlockSlug,
+ ]);
+
+ $blockSlug = $arguments['blockSlug'] ?? $this->currentBlockSlug ?? null;
+
+ \Illuminate\Support\Facades\Log::debug('mountUsing - blockSlug resolved', [
+ 'blockSlug' => $blockSlug,
+ 'fromArguments' => $arguments['blockSlug'] ?? null,
+ 'fromProperty' => $this->currentBlockSlug,
+ ]);
+
+ if ( ! $blockSlug) {
+ \Illuminate\Support\Facades\Log::warning('mountUsing - No blockSlug provided');
+
+ return;
+ }
+
+ // Look up the block by id, slug, or block_type
+ $block = ReportBlock::query()
+ ->where('id', $blockSlug)
+ ->orWhere('slug', $blockSlug)
+ ->orWhere('block_type', $blockSlug)
+ ->first();
+
+ \Illuminate\Support\Facades\Log::debug('mountUsing - Block lookup result', [
+ 'blockSlug' => $blockSlug,
+ 'blockFound' => $block !== null,
+ 'blockId' => $block?->id,
+ 'blockName' => $block?->name,
+ ]);
+
+ if ( ! $block) {
+ \Illuminate\Support\Facades\Log::warning('mountUsing - Block not found', ['blockSlug' => $blockSlug]);
+ $schema->fill([
+ 'name' => '',
+ 'width' => 'full',
+ 'block_type' => '',
+ 'data_source' => '',
+ 'default_band' => '',
+ 'is_active' => true,
+ 'fields_canvas' => [],
+ ]);
+
+ return;
+ }
+
+ // Properly extract enum values for the form
+ $data = [
+ 'name' => $block->name ?? '',
+ 'width' => $block->width instanceof BackedEnum ? $block->width->value : ($block->width ?? 'full'),
+ 'block_type' => $block->block_type instanceof BackedEnum ? $block->block_type->value : ($block->block_type ?? ''),
+ 'data_source' => $block->data_source instanceof BackedEnum ? $block->data_source->value : ($block->data_source ?? ''),
+ 'default_band' => $block->default_band instanceof BackedEnum ? $block->default_band->value : ($block->default_band ?? ''),
+ 'is_active' => (bool) ($block->is_active ?? true),
+ 'fields_canvas' => [],
+ ];
+
+ \Illuminate\Support\Facades\Log::debug('mountUsing - Filling schema with data', [
+ 'blockSlug' => $blockSlug,
+ 'data' => $data,
+ 'blockRecord' => [
+ 'id' => $block->id,
+ 'name' => $block->name,
+ 'width' => $block->width,
+ 'block_type' => $block->block_type,
+ 'data_source' => $block->data_source,
+ 'default_band' => $block->default_band,
+ 'is_active' => $block->is_active,
+ ],
+ ]);
+
+ $schema->fill($data);
+ })
+ ->modalWidth(Width::FiveExtraLarge)
+ ->action(function (array $data, array $arguments) {
+ \Illuminate\Support\Facades\Log::debug('configureBlockAction::action() called', [
+ 'arguments' => $arguments,
+ 'data' => $data,
+ 'currentBlockSlug' => $this->currentBlockSlug,
+ ]);
+
+ $blockSlug = $arguments['blockSlug'] ?? $this->currentBlockSlug ?? null;
+
+ if ( ! $blockSlug) {
+ \Illuminate\Support\Facades\Log::warning('action - No blockSlug provided');
+
+ return;
+ }
+
+ \Illuminate\Support\Facades\Log::debug('action - Looking up block', ['blockSlug' => $blockSlug]);
+
+ $block = ReportBlock::query()
+ ->where('id', $blockSlug)
+ ->orWhere('slug', $blockSlug)
+ ->orWhere('block_type', $blockSlug)
+ ->first();
+
+ \Illuminate\Support\Facades\Log::debug('action - Block lookup result', [
+ 'blockSlug' => $blockSlug,
+ 'blockFound' => $block !== null,
+ 'blockId' => $block?->id,
+ ]);
+
+ if ($block) {
+ // Extract fields from data - use fields_canvas field name
+ $fields = $data['fields_canvas'] ?? $data['fields'] ?? [];
+ unset($data['fields_canvas'], $data['fields']); // Remove fields from main data to avoid saving to DB
+
+ \Illuminate\Support\Facades\Log::debug('action - Updating block', [
+ 'blockId' => $block->id,
+ 'updateData' => $data,
+ 'fieldsCount' => count($fields),
+ ]);
+
+ // Update block record
+ $block->update($data);
+
+ // Save fields to JSON file via service
+ if ( ! empty($fields)) {
+ $service = app(\Modules\Core\Services\ReportBlockService::class);
+ $service->saveBlockFields($block, $fields);
+ \Illuminate\Support\Facades\Log::debug('action - Fields saved', [
+ 'blockId' => $block->id,
+ 'fieldsCount' => count($fields),
+ ]);
+ }
+
+ \Illuminate\Support\Facades\Log::info('action - Block updated successfully', [
+ 'blockId' => $block->id,
+ 'blockName' => $block->name,
+ ]);
+ } else {
+ \Illuminate\Support\Facades\Log::warning('action - Block not found for update', [
+ 'blockSlug' => $blockSlug,
+ ]);
+ }
+
+ $this->dispatch('block-config-saved');
+ })
+ ->slideOver();
+ }
+
+ #[On('drag-block')]
+ public function updateBlockPosition(string $blockId, array $position): void
+ {
+ if ( ! isset($this->blocks[$blockId])) {
+ return;
+ }
+
+ $gridSnapper = app(GridSnapperService::class);
+ $positionDTO = GridPositionDTO::create(
+ $position['x'] ?? 0,
+ $position['y'] ?? 0,
+ $position['width'] ?? 1,
+ $position['height'] ?? 1
+ );
+
+ if ( ! $gridSnapper->validate($positionDTO)) {
+ return;
+ }
+
+ $snappedPosition = $gridSnapper->snap($positionDTO);
+
+ $this->blocks[$blockId]['position'] = [
+ 'x' => $snappedPosition->getX(),
+ 'y' => $snappedPosition->getY(),
+ 'width' => $snappedPosition->getWidth(),
+ 'height' => $snappedPosition->getHeight(),
+ ];
+
+ if (isset($position['band'])) {
+ $this->blocks[$blockId]['band'] = $position['band'];
+ }
+ }
+
+ #[On('add-block')]
+ public function addBlock(string $blockType): void
+ {
+ $service = app(ReportTemplateService::class);
+ $systemBlocks = $service->getSystemBlocks();
+
+ if (isset($systemBlocks[$blockType])) {
+ $blockDto = $systemBlocks[$blockType];
+ $blockId = 'block_' . $blockType . '_' . Str::random(8);
+ $blockDto->setId($blockId);
+
+ $this->blocks[$blockId] = BlockTransformer::toArray($blockDto);
+
+ return;
+ }
+
+ $blockId = 'block_' . $blockType . '_' . Str::random(8);
+
+ $position = GridPositionDTO::create(0, 0, 6, 4);
+
+ $block = new BlockDTO();
+ $block->setId($blockId)
+ ->setType($blockType)
+ ->setSlug(null)
+ ->setPosition($position)
+ ->setConfig([])
+ ->setLabel(ucfirst(str_replace('_', ' ', $blockType)))
+ ->setIsCloneable(false)
+ ->setDataSource('custom')
+ ->setIsCloned(false)
+ ->setClonedFrom(null);
+
+ $this->blocks[$blockId] = BlockTransformer::toArray($block);
+ }
+
+ #[On('clone-block')]
+ public function cloneBlock(string $blockId): void
+ {
+ if ( ! isset($this->blocks[$blockId])) {
+ return;
+ }
+
+ $originalBlock = $this->blocks[$blockId];
+
+ if ($originalBlock['isCloned'] === false && $originalBlock['isCloneable'] === true) {
+ $newBlockId = 'block_' . $originalBlock['type'] . '_' . Str::random(8);
+
+ $position = GridPositionDTO::create(
+ $originalBlock['position']['x'] + 1,
+ $originalBlock['position']['y'] + 1,
+ $originalBlock['position']['width'],
+ $originalBlock['position']['height']
+ );
+
+ $clonedBlock = new BlockDTO();
+ $clonedBlock->setId($newBlockId)
+ ->setType($originalBlock['type'])
+ ->setSlug($originalBlock['slug'] ?? null)
+ ->setPosition($position)
+ ->setConfig($originalBlock['config'])
+ ->setLabel($originalBlock['label'] . ' (Clone)')
+ ->setIsCloneable(false)
+ ->setDataSource($originalBlock['dataSource'])
+ ->setIsCloned(true)
+ ->setClonedFrom($blockId);
+
+ $this->blocks[$newBlockId] = BlockTransformer::toArray($clonedBlock);
+ }
+ }
+
+ #[On('delete-block')]
+ public function deleteBlock(string $blockId): void
+ {
+ if ( ! isset($this->blocks[$blockId])) {
+ return;
+ }
+
+ unset($this->blocks[$blockId]);
+ }
+
+ #[On('edit-config')]
+ public function updateBlockConfig(string $blockId, array $config): void
+ {
+ if ( ! isset($this->blocks[$blockId])) {
+ return;
+ }
+
+ $this->blocks[$blockId]['config'] = array_replace_recursive(
+ $this->blocks[$blockId]['config'] ?? [],
+ $config
+ );
+ }
+
+ public function save($content): void
+ {
+ // Mason-based save: Store JSON directly
+ if (is_string($content)) {
+ $storage = app(MasonTemplateStorage::class);
+ $storage->save($this->record, $content);
+
+ $this->masonContent = $content;
+ $this->dispatch('blocks-saved');
+ }
+ }
+
+ /**
+ * Get Mason editor configuration.
+ */
+ public function getMasonEditorSchema(): array
+ {
+ return [
+ MasonEditor::make('masonContent')
+ ->label(trans('ip.report_layout'))
+ ->bricks(ReportBricksCollection::all())
+ ->preview(route('mason.preview'))
+ ->dehydrated()
+ ->required(),
+ ];
+ }
+
+ /**
+ * Get available bricks for Mason editor.
+ */
+ public function getAvailableBricks(): array
+ {
+ return ReportBricksCollection::all();
+ }
+
+ /**
+ * Authorize access to the report builder.
+ * Only admin and superadmin roles can access.
+ */
+ protected function authorizeReportBuilderAccess(): void
+ {
+ $user = auth()->user();
+
+ if ( ! $user || ! ($user->hasRole('admin') || $user->hasRole('superadmin'))) {
+ abort(403, 'Unauthorized access to Report Builder.');
+ }
+ }
+
+ /**
+ * Load Mason editor content from filesystem.
+ */
+ protected function loadMasonContent(): void
+ {
+ $storage = app(MasonTemplateStorage::class);
+ $this->masonContent = $storage->load($this->record);
+ }
+}
diff --git a/Modules/Core/Filament/Admin/Resources/ReportTemplates/ReportTemplateResource.php b/Modules/Core/Filament/Admin/Resources/ReportTemplates/ReportTemplateResource.php
new file mode 100644
index 000000000..55f2cd70e
--- /dev/null
+++ b/Modules/Core/Filament/Admin/Resources/ReportTemplates/ReportTemplateResource.php
@@ -0,0 +1,45 @@
+ ListReportTemplates::route('/'),
+ 'design' => ReportBuilder::route('/{record}/design'),
+ ];
+ }
+}
diff --git a/Modules/Core/Filament/Admin/Resources/ReportTemplates/Schemas/ReportTemplateForm.php b/Modules/Core/Filament/Admin/Resources/ReportTemplates/Schemas/ReportTemplateForm.php
new file mode 100644
index 000000000..8efc91782
--- /dev/null
+++ b/Modules/Core/Filament/Admin/Resources/ReportTemplates/Schemas/ReportTemplateForm.php
@@ -0,0 +1,49 @@
+components([
+ Section::make()
+ ->schema([
+ Grid::make(2)
+ ->schema([
+ TextInput::make('name')
+ ->label(trans('ip.template_name'))
+ ->required()
+ ->maxLength(255),
+ Select::make('template_type')
+ ->label(trans('ip.template_type'))
+ ->required()
+ ->options(
+ collect(ReportTemplateType::cases())
+ ->mapWithKeys(fn ($type) => [$type->value => $type->label()])
+ ),
+ ]),
+ Grid::make(2)
+ ->schema([
+ Checkbox::make('is_active')
+ ->label(trans('ip.active'))
+ ->default(true),
+ Checkbox::make('is_system')
+ ->label(trans('ip.system_template'))
+ ->disabled()
+ ->dehydrated(false),
+ ]),
+ ])
+ ->columnSpanFull(),
+ ]);
+ }
+}
diff --git a/Modules/Core/Filament/Admin/Resources/ReportTemplates/Tables/ReportTemplatesTable.php b/Modules/Core/Filament/Admin/Resources/ReportTemplates/Tables/ReportTemplatesTable.php
new file mode 100644
index 000000000..135872ef8
--- /dev/null
+++ b/Modules/Core/Filament/Admin/Resources/ReportTemplates/Tables/ReportTemplatesTable.php
@@ -0,0 +1,118 @@
+columns([
+ TextColumn::make('id')
+ ->label(trans('ip.id'))
+ ->sortable()
+ ->toggleable(),
+ TextColumn::make('name')
+ ->label(trans('ip.name'))
+ ->searchable()
+ ->sortable()
+ ->toggleable(),
+ TextColumn::make('slug')
+ ->label(trans('ip.slug'))
+ ->searchable()
+ ->sortable()
+ ->toggleable(),
+ TextColumn::make('template_type')
+ ->label(trans('ip.type'))
+ ->badge()
+ ->searchable()
+ ->sortable()
+ ->toggleable(),
+ IconColumn::make('is_system')
+ ->label(trans('ip.system'))
+ ->boolean()
+ ->sortable()
+ ->toggleable(),
+ IconColumn::make('is_active')
+ ->label(trans('ip.active'))
+ ->boolean()
+ ->sortable()
+ ->toggleable(),
+ TextColumn::make('created_at')
+ ->label(trans('ip.created_at'))
+ ->dateTime()
+ ->sortable()
+ ->toggleable()
+ ->toggledHiddenByDefault(),
+ ])
+ ->filters([
+ SelectFilter::make('template_type')
+ ->label(trans('ip.template_type'))
+ ->options(
+ collect(ReportTemplateType::cases())
+ ->mapWithKeys(fn ($type) => [$type->value => $type->label()])
+ ),
+ TernaryFilter::make('is_active')
+ ->label(trans('ip.active'))
+ ->nullable(),
+ ])
+ ->recordActions([
+ ActionGroup::make([
+ ViewAction::make()
+ ->icon(Heroicon::OutlinedEye),
+ EditAction::make()
+ ->icon(Heroicon::OutlinedPencil)
+ ->action(function (ReportTemplate $record, array $data) {
+ $blocks = $data['blocks'] ?? [];
+ app(ReportTemplateService::class)->updateTemplate($record, $blocks);
+ })
+ ->modalWidth('full')
+ ->visible(fn (ReportTemplate $record) => ! $record->is_system),
+ /* @phpstan-ignore-next-line */
+ Action::make('design')
+ ->label(trans('ip.design'))
+ ->icon(Heroicon::OutlinedPaintBrush)
+ ->url(fn (ReportTemplate $record) => route('filament.admin.resources.report-templates.design', ['record' => $record->id]))
+ ->visible(fn (ReportTemplate $record) => ! $record->is_system),
+ /* @phpstan-ignore-next-line */
+ Action::make('clone')
+ ->label(trans('ip.clone'))
+ ->icon(Heroicon::OutlinedDocumentDuplicate)
+ ->requiresConfirmation()
+ ->action(function (ReportTemplate $record) {
+ $service = app(ReportTemplateService::class);
+ $blocks = $service->loadBlocks($record);
+ $service->createTemplate(
+ $record->company,
+ $record->name . ' (Copy)',
+ $record->template_type,
+ array_map(static fn ($block) => (array) $block, $blocks)
+ );
+ })
+ ->visible(fn (ReportTemplate $record) => $record->isCloneable()),
+ DeleteAction::make('delete')
+ ->requiresConfirmation()
+ ->icon(Heroicon::OutlinedTrash)
+ ->action(function (ReportTemplate $record) {
+ app(ReportTemplateService::class)->deleteTemplate($record);
+ })
+ ->visible(fn (ReportTemplate $record) => ! $record->is_system),
+ ]),
+ ]);
+ }
+}
diff --git a/Modules/Core/Handlers/DetailItemTaxBlockHandler.php b/Modules/Core/Handlers/DetailItemTaxBlockHandler.php
new file mode 100644
index 000000000..44d129a3a
--- /dev/null
+++ b/Modules/Core/Handlers/DetailItemTaxBlockHandler.php
@@ -0,0 +1,82 @@
+getConfig();
+ $html = '';
+
+ if (empty($invoice->tax_rates) || $invoice->tax_rates->isEmpty()) {
+ return $html;
+ }
+
+ $html .= '
';
+ $html .= '
Tax Details
';
+ $html .= '
';
+ $html .= '';
+
+ if ( ! empty($config['show_tax_name'])) {
+ $html .= '| Tax Name | ';
+ }
+
+ if ( ! empty($config['show_tax_rate'])) {
+ $html .= 'Rate | ';
+ }
+
+ if ( ! empty($config['show_tax_amount'])) {
+ $html .= 'Amount | ';
+ }
+
+ $html .= '
';
+
+ foreach ($invoice->tax_rates as $taxRate) {
+ $html .= '';
+
+ if ( ! empty($config['show_tax_name'])) {
+ $html .= '| ' . htmlspecialchars($taxRate->name ?? '') . ' | ';
+ }
+
+ if ( ! empty($config['show_tax_rate'])) {
+ $html .= '' . htmlspecialchars($taxRate->rate ?? '0') . '% | ';
+ }
+
+ if ( ! empty($config['show_tax_amount'])) {
+ $taxAmount = ($invoice->subtotal ?? 0) * (($taxRate->rate ?? 0) / 100);
+ $html .= '' . $this->formatCurrency($taxAmount, $invoice->currency_code) . ' | ';
+ }
+
+ $html .= '
';
+ }
+
+ $html .= '
';
+ $html .= '
';
+
+ return $html;
+ }
+
+ private function formatCurrency(float $amount, ?string $currency = null): string
+ {
+ $currency ??= 'USD';
+
+ return $currency . ' ' . number_format($amount, 2, '.', ',');
+ }
+}
diff --git a/Modules/Core/Handlers/DetailItemsBlockHandler.php b/Modules/Core/Handlers/DetailItemsBlockHandler.php
new file mode 100644
index 000000000..4aab43785
--- /dev/null
+++ b/Modules/Core/Handlers/DetailItemsBlockHandler.php
@@ -0,0 +1,90 @@
+getConfig();
+ $html = '';
+
+ $html .= '';
+ $html .= '';
+ $html .= '| Item | ';
+
+ if ( ! empty($config['show_description'])) {
+ $html .= 'Description | ';
+ }
+
+ if ( ! empty($config['show_quantity'])) {
+ $html .= 'Qty | ';
+ }
+
+ if ( ! empty($config['show_price'])) {
+ $html .= 'Price | ';
+ }
+
+ if ( ! empty($config['show_discount'])) {
+ $html .= 'Discount | ';
+ }
+
+ if ( ! empty($config['show_subtotal'])) {
+ $html .= 'Subtotal | ';
+ }
+
+ $html .= '
';
+
+ foreach (($invoice->invoice_items ?? []) as $item) {
+ $html .= '';
+ $html .= '| ' . htmlspecialchars($item->item_name ?? '') . ' | ';
+
+ if ( ! empty($config['show_description'])) {
+ $html .= '' . htmlspecialchars($item->description ?? '') . ' | ';
+ }
+
+ if ( ! empty($config['show_quantity'])) {
+ $html .= '' . htmlspecialchars($item->quantity ?? '0') . ' | ';
+ }
+
+ if ( ! empty($config['show_price'])) {
+ $html .= '' . $this->formatCurrency($item->price ?? 0, $invoice->currency_code) . ' | ';
+ }
+
+ if ( ! empty($config['show_discount'])) {
+ $html .= '' . htmlspecialchars($item->discount ?? '0') . '% | ';
+ }
+
+ if ( ! empty($config['show_subtotal'])) {
+ $html .= '' . $this->formatCurrency($item->subtotal ?? 0, $invoice->currency_code) . ' | ';
+ }
+
+ $html .= '
';
+ }
+
+ $html .= '
';
+
+ return $html;
+ }
+}
diff --git a/Modules/Core/Handlers/FooterNotesBlockHandler.php b/Modules/Core/Handlers/FooterNotesBlockHandler.php
new file mode 100644
index 000000000..32161466c
--- /dev/null
+++ b/Modules/Core/Handlers/FooterNotesBlockHandler.php
@@ -0,0 +1,55 @@
+getConfig();
+ $html = '';
+
+ $html .= '';
+
+ return $html;
+ }
+}
diff --git a/Modules/Core/Handlers/FooterQrCodeBlockHandler.php b/Modules/Core/Handlers/FooterQrCodeBlockHandler.php
new file mode 100644
index 000000000..d41d376af
--- /dev/null
+++ b/Modules/Core/Handlers/FooterQrCodeBlockHandler.php
@@ -0,0 +1,54 @@
+getConfig();
+ $size = $config['size'] ?? 100;
+ $html = '';
+
+ $qrData = $this->generateQrData($invoice);
+
+ if (empty($qrData)) {
+ return $html;
+ }
+
+ $html .= '';
+ $html .= '
 . ')
';
+
+ if ( ! empty($config['include_url'])) {
+ $html .= '
' . htmlspecialchars($qrData, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
';
+ }
+
+ $html .= '
';
+
+ return $html;
+ }
+
+ private function generateQrData(Invoice $invoice): string
+ {
+ if (empty($invoice->url_key)) {
+ return '';
+ }
+
+ return url('/invoices/view/' . $invoice->url_key);
+ }
+}
diff --git a/Modules/Core/Handlers/FooterTotalsBlockHandler.php b/Modules/Core/Handlers/FooterTotalsBlockHandler.php
new file mode 100644
index 000000000..17ebfe86d
--- /dev/null
+++ b/Modules/Core/Handlers/FooterTotalsBlockHandler.php
@@ -0,0 +1,67 @@
+getConfig();
+ $html = '';
+
+ $html .= '';
+ $html .= '
';
+
+ if ( ! empty($config['show_subtotal'])) {
+ $html .= '| Subtotal: | ' . $this->formatCurrency($invoice->subtotal ?? 0, $invoice->currency_code) . ' |
';
+ }
+
+ if ( ! empty($config['show_discount']) && ! empty($invoice->discount)) {
+ $html .= '| Discount: | ' . $this->formatCurrency($invoice->discount ?? 0, $invoice->currency_code) . ' |
';
+ }
+
+ if ( ! empty($config['show_tax'])) {
+ $html .= '| Tax: | ' . $this->formatCurrency($invoice->tax ?? 0, $invoice->currency_code) . ' |
';
+ }
+
+ if ( ! empty($config['show_total'])) {
+ $html .= '| Total: | ' . $this->formatCurrency($invoice->total ?? 0, $invoice->currency_code) . ' |
';
+ }
+
+ if ( ! empty($config['show_paid']) && ! empty($invoice->paid)) {
+ $html .= '| Paid: | ' . $this->formatCurrency($invoice->paid ?? 0, $invoice->currency_code) . ' |
';
+ }
+
+ if ( ! empty($config['show_balance'])) {
+ $html .= '| Balance Due: | ' . $this->formatCurrency($invoice->balance ?? 0, $invoice->currency_code) . ' |
';
+ }
+
+ $html .= '
';
+ $html .= '
';
+
+ return $html;
+ }
+}
diff --git a/Modules/Core/Handlers/HeaderClientBlockHandler.php b/Modules/Core/Handlers/HeaderClientBlockHandler.php
new file mode 100644
index 000000000..b03a163d2
--- /dev/null
+++ b/Modules/Core/Handlers/HeaderClientBlockHandler.php
@@ -0,0 +1,67 @@
+getConfig();
+ $customer = $invoice->customer;
+ $html = '';
+
+ if ( ! $customer) {
+ return $html;
+ }
+
+ $html .= '';
+
+ return $html;
+ }
+}
diff --git a/Modules/Core/Handlers/HeaderCompanyBlockHandler.php b/Modules/Core/Handlers/HeaderCompanyBlockHandler.php
new file mode 100644
index 000000000..95f5a9e21
--- /dev/null
+++ b/Modules/Core/Handlers/HeaderCompanyBlockHandler.php
@@ -0,0 +1,66 @@
+getConfig() ?? [];
+ $company->loadMissing(['communications', 'addresses']);
+ $e = static fn ($v) => htmlspecialchars((string) ($v ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+ $html = '';
+
+ $html .= '';
+
+ return $html;
+ }
+}
diff --git a/Modules/Core/Handlers/HeaderInvoiceMetaBlockHandler.php b/Modules/Core/Handlers/HeaderInvoiceMetaBlockHandler.php
new file mode 100644
index 000000000..11f65b1d4
--- /dev/null
+++ b/Modules/Core/Handlers/HeaderInvoiceMetaBlockHandler.php
@@ -0,0 +1,52 @@
+getConfig();
+ $html = '';
+
+ $html .= '';
+
+ return $html;
+ }
+}
diff --git a/Modules/Core/Interfaces/BlockHandlerInterface.php b/Modules/Core/Interfaces/BlockHandlerInterface.php
new file mode 100644
index 000000000..4563a6b54
--- /dev/null
+++ b/Modules/Core/Interfaces/BlockHandlerInterface.php
@@ -0,0 +1,27 @@
+ 'boolean',
+ 'is_system' => 'boolean',
+ 'block_type' => ReportBlockType::class,
+ 'width' => ReportBlockWidth::class,
+ 'data_source' => ReportDataSource::class,
+ 'default_band' => ReportBand::class,
+ ];
+
+ /**
+ * Create a new factory instance for the model.
+ */
+ protected static function newFactory(): \Modules\Core\Database\Factories\ReportBlockFactory
+ {
+ return \Modules\Core\Database\Factories\ReportBlockFactory::new();
+ }
+}
diff --git a/Modules/Core/Models/ReportTemplate.php b/Modules/Core/Models/ReportTemplate.php
new file mode 100644
index 000000000..2905c074e
--- /dev/null
+++ b/Modules/Core/Models/ReportTemplate.php
@@ -0,0 +1,64 @@
+ 'boolean',
+ 'is_active' => 'boolean',
+ 'template_type' => ReportTemplateType::class,
+ ];
+
+ /**
+ * Check if the template can be cloned.
+ */
+ public function isCloneable(): bool
+ {
+ return $this->is_active;
+ }
+
+ /**
+ * Check if the template is a system template.
+ */
+ public function isSystem(): bool
+ {
+ return $this->is_system;
+ }
+
+ /**
+ * Get the file path for the template.
+ */
+ public function getFilePath(): string
+ {
+ return "{$this->company_id}/{$this->slug}.json";
+ }
+
+ /**
+ * Create a new factory instance for the model.
+ */
+ protected static function newFactory(): \Modules\Core\Database\Factories\ReportTemplateFactory
+ {
+ return \Modules\Core\Database\Factories\ReportTemplateFactory::new();
+ }
+}
diff --git a/Modules/Core/Providers/AdminPanelProvider.php b/Modules/Core/Providers/AdminPanelProvider.php
index 9c78b1c27..a1e42d1b4 100644
--- a/Modules/Core/Providers/AdminPanelProvider.php
+++ b/Modules/Core/Providers/AdminPanelProvider.php
@@ -25,6 +25,8 @@
use Modules\Core\Filament\Admin\Resources\Companies\CompanyResource;
use Modules\Core\Filament\Admin\Resources\EmailTemplates\EmailTemplateResource;
use Modules\Core\Filament\Admin\Resources\Numberings\NumberingResource;
+use Modules\Core\Filament\Admin\Resources\ReportBlocks\ReportBlockResource;
+use Modules\Core\Filament\Admin\Resources\ReportTemplates\ReportTemplateResource;
use Modules\Core\Filament\Admin\Resources\TaxRates\TaxRateResource;
use Modules\Core\Filament\Admin\Resources\Users\UserResource;
use Modules\Core\Filament\Pages\Auth\EditProfile;
@@ -144,6 +146,12 @@ public function panel(Panel $panel): Panel
...ImportResource::getNavigationItems(),
]),*/
+ NavigationGroup::make(trans('ip.report_builder'))
+ ->items([
+ ...ReportTemplateResource::getNavigationItems(),
+ ...ReportBlockResource::getNavigationItems(),
+ ]),
+
NavigationGroup::make(trans('ip.users_roles'))
//->icon('heroicon-o-users')
->items([
@@ -161,6 +169,8 @@ public function panel(Panel $panel): Panel
NumberingResource::class,
EmailTemplateResource::class,
TaxRateResource::class,
+ ReportTemplateResource::class,
+ ReportBlockResource::class,
UserResource::class,
])
->discoverPages(in: base_path('Modules/Core/Filament/Admin/Pages'), for: 'Modules\Core\Filament\Admin\Pages')
diff --git a/Modules/Core/Repositories/ReportTemplateFileRepository.php b/Modules/Core/Repositories/ReportTemplateFileRepository.php
new file mode 100644
index 000000000..0d44a261b
--- /dev/null
+++ b/Modules/Core/Repositories/ReportTemplateFileRepository.php
@@ -0,0 +1,178 @@
+getTemplatePath($companyId, $templateSlug);
+ $json = json_encode($blocksArray, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
+
+ Storage::disk('report_templates')->put($path, $json);
+ }
+
+ /**
+ * Get report template blocks from disk.
+ *
+ * @param int $companyId
+ * @param string $templateSlug
+ *
+ * @return array
+ */
+ public function get(int $companyId, string $templateSlug): array
+ {
+ $path = $this->getTemplatePath($companyId, $templateSlug);
+
+ if ( ! $this->exists($companyId, $templateSlug)) {
+ return [];
+ }
+
+ $json = Storage::disk('report_templates')->get($path);
+
+ try {
+ $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException $e) {
+ return [];
+ }
+
+ if ( ! is_array($decoded)) {
+ return [];
+ }
+
+ // Handle grouped structure (new) vs flat array (old)
+ if ($this->isGrouped($decoded)) {
+ $flattened = [];
+ foreach ($decoded as $bandBlocks) {
+ if (is_array($bandBlocks)) {
+ foreach ($bandBlocks as $block) {
+ $flattened[] = $block;
+ }
+ }
+ }
+
+ return $flattened;
+ }
+
+ return $decoded;
+ }
+
+ /**
+ * Check if a report template exists.
+ *
+ * @param int $companyId
+ * @param string $templateSlug
+ *
+ * @return bool
+ */
+ public function exists(int $companyId, string $templateSlug): bool
+ {
+ $path = $this->getTemplatePath($companyId, $templateSlug);
+
+ return Storage::disk('report_templates')->exists($path);
+ }
+
+ /**
+ * Delete a report template from disk.
+ *
+ * @param int $companyId
+ * @param string $templateSlug
+ *
+ * @return bool
+ */
+ public function delete(int $companyId, string $templateSlug): bool
+ {
+ $path = $this->getTemplatePath($companyId, $templateSlug);
+
+ if ( ! $this->exists($companyId, $templateSlug)) {
+ return false;
+ }
+
+ return Storage::disk('report_templates')->delete($path);
+ }
+
+ /**
+ * Get all template slugs for a company.
+ *
+ * @param int $companyId
+ *
+ * @return array
+ */
+ public function all(int $companyId): array
+ {
+ $directory = (string) $companyId;
+
+ if ( ! Storage::disk('report_templates')->directoryExists($directory)) {
+ return [];
+ }
+
+ $files = Storage::disk('report_templates')->files($directory);
+
+ return array_map(function ($file) {
+ return pathinfo($file, PATHINFO_FILENAME);
+ }, $files);
+ }
+
+ /**
+ * Check if the blocks array is grouped by band.
+ *
+ * @param array $data
+ *
+ * @return bool
+ */
+ protected function isGrouped(array $data): bool
+ {
+ // If it's an associative array and keys are known bands, it's grouped
+ $bands = ['header', 'group_header', 'details', 'group_footer', 'footer'];
+
+ foreach (array_keys($data) as $key) {
+ if (in_array($key, $bands, true)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the full path for a template file.
+ *
+ * @param int $companyId
+ * @param string $templateSlug
+ *
+ * @return string
+ */
+ protected function getTemplatePath(int $companyId, string $templateSlug): string
+ {
+ return "{$companyId}/{$templateSlug}.json";
+ }
+}
diff --git a/Modules/Core/Services/BlockFactory.php b/Modules/Core/Services/BlockFactory.php
new file mode 100644
index 000000000..07a6f8ccc
--- /dev/null
+++ b/Modules/Core/Services/BlockFactory.php
@@ -0,0 +1,114 @@
+ app(HeaderCompanyBlockHandler::class),
+ 'header_client' => app(HeaderClientBlockHandler::class),
+ 'header_invoice_meta' => app(HeaderInvoiceMetaBlockHandler::class),
+ 'invoice_items' => app(DetailItemsBlockHandler::class),
+ 'invoice_item_tax' => app(DetailItemTaxBlockHandler::class),
+ 'footer_totals' => app(FooterTotalsBlockHandler::class),
+ 'footer_notes' => app(FooterNotesBlockHandler::class),
+ 'footer_qr_code' => app(FooterQrCodeBlockHandler::class),
+ default => throw new InvalidArgumentException("Unsupported block type: {$type}"),
+ };
+ }
+
+ /**
+ * Get all available block types with metadata.
+ *
+ * @return array Array of block type metadata
+ */
+ public static function all(): array
+ {
+ return [
+ [
+ 'type' => 'company_header',
+ 'label' => 'Company Header',
+ 'category' => 'header',
+ 'description' => 'Display company information including name, VAT, phone, and address',
+ 'icon' => 'building',
+ ],
+ [
+ 'type' => 'header_client',
+ 'label' => 'Client Header',
+ 'category' => 'header',
+ 'description' => 'Display client/customer information',
+ 'icon' => 'user',
+ ],
+ [
+ 'type' => 'header_invoice_meta',
+ 'label' => 'Invoice Metadata',
+ 'category' => 'header',
+ 'description' => 'Display invoice number, date, due date, and status',
+ 'icon' => 'file-text',
+ ],
+ [
+ 'type' => 'invoice_items',
+ 'label' => 'Invoice Items',
+ 'category' => 'detail',
+ 'description' => 'Display line items with quantity, price, and subtotal',
+ 'icon' => 'list',
+ ],
+ [
+ 'type' => 'invoice_item_tax',
+ 'label' => 'Item Tax Details',
+ 'category' => 'detail',
+ 'description' => 'Display tax breakdown by tax rate',
+ 'icon' => 'percent',
+ ],
+ [
+ 'type' => 'footer_totals',
+ 'label' => 'Invoice Totals',
+ 'category' => 'footer',
+ 'description' => 'Display subtotal, tax, discount, and total amounts',
+ 'icon' => 'calculator',
+ ],
+ [
+ 'type' => 'footer_notes',
+ 'label' => 'Footer Notes',
+ 'category' => 'footer',
+ 'description' => 'Display terms, conditions, and footer text',
+ 'icon' => 'message-square',
+ ],
+ [
+ 'type' => 'footer_qr_code',
+ 'label' => 'QR Code',
+ 'category' => 'footer',
+ 'description' => 'Display QR code linking to invoice',
+ 'icon' => 'qr-code',
+ ],
+ ];
+ }
+}
diff --git a/Modules/Core/Services/GridSnapperService.php b/Modules/Core/Services/GridSnapperService.php
new file mode 100644
index 000000000..8012fb12d
--- /dev/null
+++ b/Modules/Core/Services/GridSnapperService.php
@@ -0,0 +1,58 @@
+gridSize = $gridSize;
+ }
+
+ /**
+ * Snap a position to the grid.
+ */
+ public function snap(GridPositionDTO $position): GridPositionDTO
+ {
+ $x = max(0, min($position->getX(), $this->gridSize - 1));
+ $y = max(0, $position->getY());
+ $width = max(1, min($position->getWidth(), $this->gridSize - $x));
+ $height = max(1, $position->getHeight());
+
+ return GridPositionDTO::create($x, $y, $width, $height);
+ }
+
+ /**
+ * Validate that a position fits within the grid.
+ */
+ public function validate(GridPositionDTO $position): bool
+ {
+ if ($position->getX() < 0 || $position->getX() >= $this->gridSize) {
+ return false;
+ }
+
+ if ($position->getY() < 0) {
+ return false;
+ }
+
+ if ($position->getWidth() < 1) {
+ return false;
+ }
+
+ if ($position->getHeight() < 1) {
+ return false;
+ }
+
+ return ! ($position->getX() + $position->getWidth() > $this->gridSize);
+ }
+}
diff --git a/Modules/Core/Services/MasonStorageAdapter.php b/Modules/Core/Services/MasonStorageAdapter.php
new file mode 100644
index 000000000..83ad65a07
--- /dev/null
+++ b/Modules/Core/Services/MasonStorageAdapter.php
@@ -0,0 +1,184 @@
+ Array of BlockDTOs keyed by block ID
+ */
+ public function masonToBlocks(string $masonJson): array
+ {
+ $masonData = json_decode($masonJson, true);
+ $blocks = [];
+
+ if ( ! isset($masonData['content']) || ! is_array($masonData['content'])) {
+ return $blocks;
+ }
+
+ foreach ($masonData['content'] as $item) {
+ if (($item['type'] ?? null) === 'masonBrick') {
+ $attrs = $item['attrs'] ?? [];
+ $block = $this->createBlockFromMasonBrick($attrs);
+
+ if ($block) {
+ $blocks[$block->getId()] = $block;
+ }
+ }
+ }
+
+ return $blocks;
+ }
+
+ /**
+ * Convert Block DTOs to Mason JSON for editor.
+ *
+ * @param array $blockDTOs Array of BlockDTOs
+ *
+ * @return string Mason-compatible JSON
+ */
+ public function blocksToMason(array $blockDTOs): string
+ {
+ $content = [];
+
+ foreach ($blockDTOs as $blockDTO) {
+ $content[] = [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => $blockDTO->getId(),
+ 'config' => $blockDTO->getConfig() ?? [],
+ 'label' => $blockDTO->getLabel() ?? $this->getLabelForType($blockDTO->getType()),
+ 'preview' => base64_encode($this->generatePreview($blockDTO)),
+ ],
+ ];
+ }
+
+ return json_encode([
+ 'type' => 'doc',
+ 'content' => $content,
+ ], JSON_PRETTY_PRINT);
+ }
+
+ /**
+ * Create BlockDTO from Mason brick attributes.
+ *
+ * @param array $attrs Mason brick attributes
+ *
+ * @return BlockDTO|null
+ */
+ protected function createBlockFromMasonBrick(array $attrs): ?BlockDTO
+ {
+ $id = $attrs['id'] ?? null;
+ $config = $attrs['config'] ?? [];
+ $label = $attrs['label'] ?? '';
+
+ if ( ! $id) {
+ return null;
+ }
+
+ // Extract type from brick ID (e.g., "header_company_xyz123" -> "header_company")
+ $type = $this->extractTypeFromId($id);
+
+ // Create position DTO with defaults
+ $position = GridPositionDTO::create(0, 0, 12, 4);
+
+ $block = new BlockDTO();
+ $block->setId($id)
+ ->setType($type)
+ ->setSlug(null)
+ ->setPosition($position)
+ ->setConfig($config)
+ ->setLabel($label)
+ ->setIsCloneable(false)
+ ->setDataSource($this->getDataSourceForType($type))
+ ->setIsCloned(false)
+ ->setClonedFrom(null);
+
+ return $block;
+ }
+
+ /**
+ * Extract block type from Mason brick ID.
+ *
+ * @param string $brickId Mason brick ID (e.g., "header_company_abc123")
+ *
+ * @return string Block type (e.g., "header_company")
+ */
+ protected function extractTypeFromId(string $brickId): string
+ {
+ // Remove trailing random suffix if present
+ return preg_replace('/_[a-z0-9]+$/i', '', $brickId);
+ }
+
+ /**
+ * Get human-readable label for a block type.
+ *
+ * @param string $type Block type
+ *
+ * @return string Label
+ */
+ protected function getLabelForType(string $type): string
+ {
+ return match($type) {
+ 'header_company' => trans('ip.company_header'),
+ 'header_client' => trans('ip.client_header'),
+ 'header_invoice_meta' => trans('ip.invoice_metadata'),
+ 'detail_items' => trans('ip.line_items_table'),
+ 'footer_totals' => trans('ip.totals_section'),
+ 'footer_notes' => trans('ip.footer_notes'),
+ default => ucfirst(str_replace('_', ' ', $type)),
+ };
+ }
+
+ /**
+ * Get data source for a block type.
+ *
+ * @param string $type Block type
+ *
+ * @return string Data source
+ */
+ protected function getDataSourceForType(string $type): string
+ {
+ return match(true) {
+ str_starts_with($type, 'header_company') => 'company',
+ str_starts_with($type, 'header_client') => 'client',
+ str_starts_with($type, 'header_invoice') => 'invoice',
+ str_starts_with($type, 'detail_') => 'items',
+ str_starts_with($type, 'footer_') => 'invoice',
+ default => 'custom',
+ };
+ }
+
+ /**
+ * Generate preview HTML for a block (placeholder implementation).
+ *
+ * @param BlockDTO $block Block DTO
+ *
+ * @return string Preview HTML
+ */
+ protected function generatePreview(BlockDTO $block): string
+ {
+ // This would render the appropriate preview view for the block type
+ $type = $block->getType();
+ $config = $block->getConfig() ?? [];
+
+ // Simplified preview generation
+ return sprintf(
+ '%s
',
+ htmlspecialchars($block->getLabel() ?? 'Block', ENT_QUOTES)
+ );
+ }
+}
diff --git a/Modules/Core/Services/MasonTemplateStorage.php b/Modules/Core/Services/MasonTemplateStorage.php
new file mode 100644
index 000000000..9a708826b
--- /dev/null
+++ b/Modules/Core/Services/MasonTemplateStorage.php
@@ -0,0 +1,79 @@
+getTemplatePath($template);
+ Storage::disk('report_templates')->put($path, $masonJson);
+ }
+
+ /**
+ * Load Mason editor content from filesystem.
+ */
+ public function load(ReportTemplate $template): string
+ {
+ $path = $this->getTemplatePath($template);
+
+ if ( ! Storage::disk('report_templates')->exists($path)) {
+ return $this->getEmptyTemplate();
+ }
+
+ return Storage::disk('report_templates')->get($path);
+ }
+
+ /**
+ * Check if template exists.
+ */
+ public function exists(ReportTemplate $template): bool
+ {
+ $path = $this->getTemplatePath($template);
+
+ return Storage::disk('report_templates')->exists($path);
+ }
+
+ /**
+ * Delete template.
+ */
+ public function delete(ReportTemplate $template): bool
+ {
+ $path = $this->getTemplatePath($template);
+
+ if ( ! $this->exists($template)) {
+ return false;
+ }
+
+ return Storage::disk('report_templates')->delete($path);
+ }
+
+ /**
+ * Get the template file path.
+ */
+ protected function getTemplatePath(ReportTemplate $template): string
+ {
+ return "{$template->company_id}/mason_{$template->slug}.json";
+ }
+
+ /**
+ * Get an empty Mason template structure.
+ */
+ protected function getEmptyTemplate(): string
+ {
+ return json_encode([
+ 'type' => 'doc',
+ 'content' => [],
+ ], JSON_PRETTY_PRINT);
+ }
+}
diff --git a/Modules/Core/Services/ReportBlockService.php b/Modules/Core/Services/ReportBlockService.php
new file mode 100644
index 000000000..c09fe81ca
--- /dev/null
+++ b/Modules/Core/Services/ReportBlockService.php
@@ -0,0 +1,203 @@
+create([
+ 'name' => $data['name'],
+ 'block_type' => $data['block_type'],
+ 'slug' => $data['slug'],
+ 'filename' => $data['filename'] ?? null,
+ 'width' => $data['width'],
+ 'data_source' => $data['data_source'],
+ 'default_band' => $data['default_band'],
+ 'config' => $data['config'] ?? [],
+ 'is_active' => $data['is_active'] ?? true,
+ 'is_system' => $data['is_system'] ?? false,
+ ]);
+ }
+
+ public function updateReportBlock(ReportBlock $reportBlock, array $data): Model
+ {
+ $reportBlock->update([
+ 'name' => $data['name'],
+ 'block_type' => $data['block_type'],
+ 'slug' => $data['slug'],
+ 'filename' => $data['filename'] ?? null,
+ 'width' => $data['width'],
+ 'data_source' => $data['data_source'],
+ 'default_band' => $data['default_band'],
+ 'config' => $data['config'] ?? [],
+ 'is_active' => $data['is_active'] ?? true,
+ 'is_system' => $data['is_system'] ?? false,
+ ]);
+
+ return $reportBlock;
+ }
+
+ public function deleteReportBlock(ReportBlock $reportBlock): ReportBlock
+ {
+ DB::beginTransaction();
+ try {
+ $reportBlock->delete();
+ DB::commit();
+ } catch (Throwable $e) {
+ DB::rollBack();
+ throw $e;
+ }
+
+ return $reportBlock;
+ }
+
+ /**
+ * Save block field configuration to JSON file.
+ *
+ * @param ReportBlock $block
+ * @param array $fields Array of field configurations
+ *
+ * @return void
+ */
+ public function saveBlockFields(ReportBlock $block, array $fields): void
+ {
+ try {
+ // Ensure directory exists
+ if ( ! Storage::disk('local')->exists('report_blocks')) {
+ Storage::disk('local')->makeDirectory('report_blocks');
+ }
+
+ // Load existing config from JSON file if it exists, otherwise start fresh
+ $filename = $block->filename ?: $block->slug;
+ $path = 'report_blocks/' . $filename . '.json';
+
+ $config = [];
+ if (Storage::disk('local')->exists($path)) {
+ try {
+ $content = Storage::disk('local')->get($path);
+ $decoded = json_decode($content, true);
+
+ // Validate decoded content is an array
+ if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
+ $config = $decoded;
+ } else {
+ \Illuminate\Support\Facades\Log::warning('Failed to decode existing block config, starting fresh', [
+ 'path' => $path,
+ 'error' => json_last_error_msg(),
+ ]);
+ }
+ } catch (Exception $e) {
+ \Illuminate\Support\Facades\Log::error('Error reading existing block config', [
+ 'path' => $path,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ // Ensure $config is an array before assigning fields
+ if ( ! is_array($config)) {
+ $config = [];
+ }
+
+ $config['fields'] = $fields;
+
+ // Save to JSON file with error handling
+ try {
+ Storage::disk('local')->put($path, json_encode($config, JSON_PRETTY_PRINT));
+ } catch (Exception $e) {
+ \Illuminate\Support\Facades\Log::error('Failed to write block fields to storage', [
+ 'path' => $path,
+ 'error' => $e->getMessage(),
+ ]);
+ throw new RuntimeException('Failed to save block fields: ' . $e->getMessage(), 0, $e);
+ }
+ } catch (Exception $e) {
+ \Illuminate\Support\Facades\Log::error('Unexpected error in saveBlockFields', [
+ 'block_id' => $block->id,
+ 'error' => $e->getMessage(),
+ ]);
+ throw $e;
+ }
+ }
+
+ /**
+ * Load block field configuration from JSON file.
+ *
+ * @param ReportBlock $block
+ *
+ * @return array Array of field configurations
+ */
+ public function loadBlockFields(ReportBlock $block): array
+ {
+ $filename = $block->filename ?: $block->slug;
+ $path = 'report_blocks/' . $filename . '.json';
+
+ if ( ! Storage::disk('local')->exists($path)) {
+ return [];
+ }
+
+ try {
+ $content = Storage::disk('local')->get($path);
+ $config = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+
+ if ( ! is_array($config)) {
+ return [];
+ }
+
+ return $config['fields'] ?? [];
+ } catch (JsonException $e) {
+ \Illuminate\Support\Facades\Log::warning('Failed to load block fields', [
+ 'path' => $path,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return [];
+ }
+ }
+
+ /**
+ * Get the full configuration for a block including fields.
+ *
+ * @param ReportBlock $block
+ *
+ * @return array
+ */
+ public function getBlockConfiguration(ReportBlock $block): array
+ {
+ $filename = $block->filename ?: $block->slug;
+ $path = 'report_blocks/' . $filename . '.json';
+
+ if ( ! Storage::disk('local')->exists($path)) {
+ return [];
+ }
+
+ try {
+ $content = Storage::disk('local')->get($path);
+ $config = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+
+ return is_array($config) ? $config : [];
+ } catch (JsonException $e) {
+ \Illuminate\Support\Facades\Log::warning('Failed to load block configuration', [
+ 'path' => $path,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return [];
+ }
+ }
+}
diff --git a/Modules/Core/Services/ReportTemplateService.php b/Modules/Core/Services/ReportTemplateService.php
new file mode 100644
index 000000000..2d89378d1
--- /dev/null
+++ b/Modules/Core/Services/ReportTemplateService.php
@@ -0,0 +1,405 @@
+fileRepository = $fileRepository;
+ $this->gridSnapper = $gridSnapper;
+ }
+
+ /**
+ * Create a new report template.
+ *
+ * @param Company $company The company owning the template
+ * @param string $name The template name
+ * @param string|ReportTemplateType $templateType The template type (e.g., 'invoice', 'quote')
+ * @param array $blocks Array of block data
+ *
+ * @return ReportTemplate The created template
+ */
+ public function createTemplate(
+ Company $company,
+ string $name,
+ string|ReportTemplateType $templateType,
+ array $blocks
+ ): ReportTemplate {
+ $this->validateBlocks($blocks);
+
+ $template = new ReportTemplate();
+ $template->company_id = $company->id;
+ $template->name = $name;
+ $template->slug = $this->makeUniqueSlug($company, $name);
+ $template->template_type = is_string($templateType)
+ ? ReportTemplateType::from($templateType)
+ : $templateType;
+ $template->is_system = false;
+ $template->is_active = true;
+ $template->save();
+
+ try {
+ $this->persistBlocks($template, $blocks);
+ } catch (Throwable $e) {
+ $template->delete();
+
+ throw $e;
+ }
+
+ return $template;
+ }
+
+ /**
+ * Update an existing report template.
+ *
+ * @param ReportTemplate $template The template to update
+ * @param array $blocks Array of block data
+ *
+ * @return ReportTemplate The updated template
+ */
+ public function updateTemplate(ReportTemplate $template, array $blocks): ReportTemplate
+ {
+ $this->validateBlocks($blocks);
+ $this->persistBlocks($template, $blocks);
+
+ return $template;
+ }
+
+ /**
+ * Clone a system block with a new ID and position.
+ *
+ * @param string $blockType The type of block to clone
+ * @param string $newId The new block ID
+ * @param GridPositionDTO $position The new position
+ *
+ * @return BlockDTO The cloned block
+ */
+ public function cloneSystemBlock(
+ string $blockType,
+ string $newId,
+ GridPositionDTO $position
+ ): BlockDTO {
+ $systemBlocks = $this->getSystemBlocks();
+
+ if ( ! isset($systemBlocks[$blockType])) {
+ throw new InvalidArgumentException("System block type '{$blockType}' not found");
+ }
+
+ $originalBlock = $systemBlocks[$blockType];
+ $cloned = BlockDTO::clonedFrom($originalBlock, $newId);
+ $cloned->setPosition($position);
+
+ return $cloned;
+ }
+
+ /**
+ * Persist blocks to filesystem via repository.
+ *
+ * @param ReportTemplate $template The template to persist blocks for
+ * @param array $blocks Array of block data or BlockDTO objects
+ *
+ * @return void
+ */
+ public function persistBlocks(ReportTemplate $template, array $blocks): void
+ {
+ $groupedBlocks = [
+ 'header' => [],
+ 'group_header' => [],
+ 'details' => [],
+ 'group_footer' => [],
+ 'footer' => [],
+ ];
+
+ foreach ($blocks as $block) {
+ $blockArray = $block instanceof BlockDTO ? BlockTransformer::toArray($block) : $block;
+ $band = $blockArray['band'] ?? 'header';
+
+ if (isset($groupedBlocks[$band])) {
+ $groupedBlocks[$band][] = $blockArray;
+ } else {
+ $groupedBlocks['header'][] = $blockArray;
+ }
+ }
+
+ $this->fileRepository->save(
+ $template->company_id,
+ $template->slug,
+ $groupedBlocks
+ );
+ }
+
+ /**
+ * Load blocks from filesystem via repository.
+ *
+ * @param ReportTemplate $template The template to load blocks for
+ *
+ * @return array Array of BlockDTO objects
+ */
+ public function loadBlocks(ReportTemplate $template): array
+ {
+ $blocksData = $this->fileRepository->get(
+ $template->company_id,
+ $template->slug
+ );
+
+ return BlockTransformer::toArrayCollection($blocksData);
+ }
+
+ /**
+ * Delete a report template.
+ *
+ * @param ReportTemplate $template The template to delete
+ *
+ * @return void
+ */
+ public function deleteTemplate(ReportTemplate $template): void
+ {
+ $deleted = $this->fileRepository->delete(
+ $template->company_id,
+ $template->slug
+ );
+
+ if ( ! $deleted) {
+ Log::warning('Failed to delete report template file', [
+ 'company_id' => $template->company_id,
+ 'slug' => $template->slug,
+ ]);
+ }
+
+ $template->delete();
+ }
+
+ /**
+ * Validate an array of blocks.
+ *
+ * @param array $blocks Array of block data
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException If blocks are invalid
+ */
+ public function validateBlocks(array $blocks): void
+ {
+ if ( ! is_array($blocks)) {
+ throw new InvalidArgumentException('Blocks must be an array');
+ }
+
+ foreach ($blocks as $index => $block) {
+ if ($block instanceof BlockDTO) {
+ $block = BlockTransformer::toArray($block);
+ }
+
+ if ( ! is_array($block)) {
+ throw new InvalidArgumentException("Block at index {$index} must be an array");
+ }
+
+ if ( ! isset($block['id']) || empty($block['id'])) {
+ throw new InvalidArgumentException("Block at index {$index} must have an 'id'");
+ }
+
+ if ( ! isset($block['type']) || empty($block['type'])) {
+ throw new InvalidArgumentException("Block at index {$index} must have a 'type'");
+ }
+
+ if ( ! isset($block['position']) || ! is_array($block['position'])) {
+ throw new InvalidArgumentException("Block at index {$index} must have a 'position' array");
+ }
+
+ $position = $block['position'];
+ if ( ! isset($position['x'], $position['y'], $position['width'], $position['height'])) {
+ throw new InvalidArgumentException("Block at index {$index} position must have x, y, width, and height");
+ }
+
+ foreach (['x', 'y', 'width', 'height'] as $k) {
+ if ( ! is_int($position[$k])) {
+ throw new InvalidArgumentException("Block at index {$index} position '{$k}' must be int");
+ }
+ }
+ if ($position['width'] <= 0 || $position['height'] <= 0) {
+ throw new InvalidArgumentException("Block at index {$index} position width/height must be > 0");
+ }
+ if ( ! array_key_exists('config', $block) || ! is_array($block['config'])) {
+ throw new InvalidArgumentException("Block at index {$index} must have a 'config' array");
+ }
+
+ $positionDTO = GridPositionDTO::create(
+ $position['x'],
+ $position['y'],
+ $position['width'],
+ $position['height']
+ );
+
+ if ( ! $this->gridSnapper->validate($positionDTO)) {
+ throw new InvalidArgumentException("Block at index {$index} has invalid position");
+ }
+ }
+ }
+
+ /**
+ * Get system-defined blocks from database.
+ *
+ * @return array array of BlockDTO objects indexed by type
+ */
+ public function getSystemBlocks(): array
+ {
+ $blocks = [];
+ $dbBlocks = ReportBlock::where('is_active', true)->get();
+
+ foreach ($dbBlocks as $dbBlock) {
+ $config = $this->getBlockConfig($dbBlock);
+
+ // Map widths to grid units for the designer using the enum method
+ $width = $dbBlock->width->getGridWidth();
+
+ $blocks[$dbBlock->block_type->value] = $this->createSystemBlock(
+ 'block_' . $dbBlock->block_type->value,
+ $dbBlock->block_type,
+ $dbBlock->slug,
+ 0,
+ 0,
+ $width,
+ 4,
+ null,
+ $dbBlock->name,
+ $dbBlock->data_source->value,
+ $dbBlock->default_band->value
+ );
+ }
+
+ return $blocks;
+ }
+
+ /**
+ * Get block configuration from JSON file.
+ *
+ * @param ReportBlock $block
+ *
+ * @return string|array
+ */
+ public function getBlockConfig(ReportBlock $block): string|array
+ {
+ return $block->config ?: [];
+ }
+
+ /**
+ * Save block configuration to database.
+ *
+ * @param ReportBlock $block
+ * @param array $config
+ *
+ * @return void
+ */
+ public function saveBlockConfig(ReportBlock $block, array $config): void
+ {
+ $block->config = $config;
+ $block->save();
+ }
+
+ /**
+ * Create a system block.
+ * bands:
+ * $bands = [
+ * 'header' => 'Header',
+ * 'group_header' => 'Group Detail Header',
+ * 'details' => 'Details',
+ * 'group_footer' => 'Group Detail Footer',
+ * 'footer' => 'Footer',
+ * ];.
+ */
+ private function createSystemBlock(
+ string $id,
+ ReportBlockType|string $type,
+ ?string $slug,
+ int $x,
+ int $y,
+ int $width,
+ int $height,
+ ?array $config,
+ string $label,
+ string $dataSource,
+ string $band = 'header'
+ ): BlockDTO {
+ $position = GridPositionDTO::create($x, $y, $width, $height);
+
+ $block = new BlockDTO();
+ $block->setId($id)
+ ->setType($type)
+ ->setSlug($slug)
+ ->setPosition($position)
+ ->setConfig($config)
+ ->setLabel($label)
+ ->setIsCloneable(true)
+ ->setDataSource($dataSource)
+ ->setBand($band)
+ ->setIsCloned(false)
+ ->setClonedFrom(null);
+
+ return $block;
+ }
+
+ /**
+ * Generate a unique slug for the template within the company.
+ *
+ * @param Company $company The company
+ * @param string $name The template name
+ *
+ * @return string The unique slug
+ */
+ private function makeUniqueSlug(Company $company, string $name): string
+ {
+ $base = Str::slug($name);
+ $slug = $base;
+ $i = 2;
+
+ while (ReportTemplate::query()->where('company_id', $company->id)->where('slug', $slug)->exists()) {
+ $slug = "{$base}-{$i}";
+ $i++;
+ }
+
+ return $slug;
+ }
+}
diff --git a/Modules/Core/Tests/Feature/BlockCloningTest.php b/Modules/Core/Tests/Feature/BlockCloningTest.php
new file mode 100644
index 000000000..1bf448c01
--- /dev/null
+++ b/Modules/Core/Tests/Feature/BlockCloningTest.php
@@ -0,0 +1,132 @@
+service = app(ReportTemplateService::class);
+ }
+
+ #[Test]
+ #[Group('crud')]
+ /**
+ * @payload
+ * {
+ * "blockType": "company_header",
+ * "newId": "block_company_header_cloned",
+ * "position": {"x": 1, "y": 1, "width": 6, "height": 4}
+ * }
+ */
+ public function it_clones_system_block_on_edit(): void
+ {
+ /* arrange */
+ $company = Company::factory()->create();
+ $user = User::factory()->create();
+ $user->companies()->attach($company);
+ session(['current_company_id' => $company->id]);
+
+ $blockType = 'company_header';
+ $newId = 'block_company_header_cloned';
+
+ $position = new GridPositionDTO();
+ $position->setX(1)->setY(1)->setWidth(6)->setHeight(4);
+
+ /* act */
+ $clonedBlock = $this->service->cloneSystemBlock($blockType, $newId, $position);
+
+ /* assert */
+ $this->assertEquals($newId, $clonedBlock->getId());
+ $this->assertEquals($blockType, $clonedBlock->getType());
+ $this->assertTrue($clonedBlock->isCloned());
+ $this->assertEquals('block_company_header', $clonedBlock->getClonedFrom());
+ $this->assertEquals(1, $clonedBlock->getPosition()->getX());
+ $this->assertEquals(1, $clonedBlock->getPosition()->getY());
+ }
+
+ #[Test]
+ #[Group('crud')]
+ public function it_identifies_system_templates(): void
+ {
+ /* arrange */
+ $company = Company::factory()->create();
+ $user = User::factory()->create();
+ $user->companies()->attach($company);
+ session(['current_company_id' => $company->id]);
+
+ $systemBlocks = [
+ [
+ 'id' => 'block_company_header',
+ 'type' => 'company_header',
+ 'position' => ['x' => 0, 'y' => 0, 'width' => 6, 'height' => 4],
+ 'config' => ['show_vat_id' => true],
+ 'label' => 'Company Header',
+ 'isCloneable' => true,
+ 'dataSource' => 'company',
+ 'isCloned' => false,
+ 'clonedFrom' => null,
+ ],
+ ];
+
+ $template = $this->service->createTemplate(
+ $company,
+ 'System Template',
+ 'invoice',
+ $systemBlocks
+ );
+
+ $template->is_system = true;
+ $template->save();
+
+ /* assert */
+ $this->assertTrue($template->is_system);
+ $this->assertTrue($template->isSystem());
+ }
+
+ #[Test]
+ #[Group('crud')]
+ public function it_creates_custom_version_with_unique_id(): void
+ {
+ /* arrange */
+ $company = Company::factory()->create();
+ $user = User::factory()->create();
+ $user->companies()->attach($company);
+ session(['current_company_id' => $company->id]);
+
+ $blockType = 'company_header';
+ $firstCloneId = 'block_company_header_custom_1';
+ $secondCloneId = 'block_company_header_custom_2';
+
+ $position1 = new GridPositionDTO();
+ $position1->setX(0)->setY(0)->setWidth(6)->setHeight(4);
+
+ $position2 = new GridPositionDTO();
+ $position2->setX(6)->setY(0)->setWidth(6)->setHeight(4);
+
+ /* act */
+ $firstClone = $this->service->cloneSystemBlock($blockType, $firstCloneId, $position1);
+ $secondClone = $this->service->cloneSystemBlock($blockType, $secondCloneId, $position2);
+
+ /* assert */
+ $this->assertNotEquals($firstClone->getId(), $secondClone->getId());
+ $this->assertEquals($firstCloneId, $firstClone->getId());
+ $this->assertEquals($secondCloneId, $secondClone->getId());
+ $this->assertEquals($blockType, $firstClone->getType());
+ $this->assertEquals($blockType, $secondClone->getType());
+ $this->assertTrue($firstClone->isCloned());
+ $this->assertTrue($secondClone->isCloned());
+ }
+}
diff --git a/Modules/Core/Tests/Feature/CreateReportTemplateTest.php b/Modules/Core/Tests/Feature/CreateReportTemplateTest.php
new file mode 100644
index 000000000..80c38ab88
--- /dev/null
+++ b/Modules/Core/Tests/Feature/CreateReportTemplateTest.php
@@ -0,0 +1,230 @@
+service = app(ReportTemplateService::class);
+ }
+
+ #[Test]
+ #[Group('crud')]
+ /**
+ * @payload
+ * {
+ * "name": "Test Invoice Template",
+ * "template_type": "invoice",
+ * "blocks": [
+ * {
+ * "id": "block_company_header",
+ * "type": "company_header",
+ * "position": {"x": 0, "y": 0, "width": 6, "height": 4},
+ * "config": {"show_vat_id": true},
+ * "label": "Company Header",
+ * "isCloneable": true,
+ * "dataSource": "company",
+ * "isCloned": false,
+ * "clonedFrom": null
+ * }
+ * ]
+ * }
+ */
+ public function it_creates_report_template_with_valid_blocks(): void
+ {
+ /* arrange */
+ $company = $this->createCompanyContext();
+
+ $blocks = [
+ [
+ 'id' => 'block_company_header',
+ 'type' => 'company_header',
+ 'position' => ['x' => 0, 'y' => 0, 'width' => 6, 'height' => 4],
+ 'config' => ['show_vat_id' => true],
+ 'label' => 'Company Header',
+ 'isCloneable' => true,
+ 'dataSource' => 'company',
+ 'isCloned' => false,
+ 'clonedFrom' => null,
+ ],
+ ];
+
+ /* act */
+ $template = $this->service->createTemplate(
+ $company,
+ 'Test Invoice Template',
+ 'invoice',
+ $blocks
+ );
+
+ /* assert */
+ $this->assertDatabaseHas('report_templates', [
+ 'company_id' => $company->id,
+ 'name' => 'Test Invoice Template',
+ 'slug' => 'test-invoice-template',
+ 'template_type' => 'invoice',
+ 'is_system' => false,
+ 'is_active' => true,
+ ]);
+
+ $this->assertInstanceOf(ReportTemplate::class, $template);
+ $this->assertEquals('Test Invoice Template', $template->name);
+ }
+
+ #[Test]
+ #[Group('crud')]
+ public function it_persists_blocks_to_filesystem(): void
+ {
+ /* arrange */
+ $company = $this->createCompanyContext();
+
+ $blocks = [
+ [
+ 'id' => 'block_company_header',
+ 'type' => 'company_header',
+ 'position' => ['x' => 0, 'y' => 0, 'width' => 6, 'height' => 4],
+ 'config' => ['show_vat_id' => true],
+ 'label' => 'Company Header',
+ 'isCloneable' => true,
+ 'dataSource' => 'company',
+ 'isCloned' => false,
+ 'clonedFrom' => null,
+ ],
+ ];
+
+ /* act */
+ $_template = $this->service->createTemplate(
+ $company,
+ 'Test Template',
+ 'invoice',
+ $blocks
+ );
+
+ /* assert */
+ Storage::disk('report_templates')->assertExists(
+ "{$company->id}/test-template.json"
+ );
+
+ $fileContents = Storage::disk('report_templates')->get(
+ "{$company->id}/test-template.json"
+ );
+ $savedBlocks = json_decode($fileContents, true);
+
+ $this->assertIsArray($savedBlocks);
+ $this->assertCount(1, $savedBlocks);
+ $this->assertEquals('block_company_header', $savedBlocks[0]['id']);
+ }
+
+ #[Test]
+ #[Group('crud')]
+ /**
+ * @payload invalid block type
+ * {
+ * "name": "Test Template",
+ * "template_type": "invoice",
+ * "blocks": [
+ * {
+ * "id": "block_invalid",
+ * "type": "",
+ * "position": {"x": 0, "y": 0, "width": 6, "height": 4},
+ * "config": {}
+ * }
+ * ]
+ * }
+ */
+ public function it_rejects_invalid_block_types(): void
+ {
+ /* arrange */
+ $company = $this->createCompanyContext();
+
+ $invalidBlocks = [
+ [
+ 'id' => 'block_invalid',
+ 'type' => '',
+ 'position' => ['x' => 0, 'y' => 0, 'width' => 6, 'height' => 4],
+ 'config' => [],
+ ],
+ ];
+
+ /* Act & Assert */
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage("must have a 'type'");
+
+ $this->service->createTemplate(
+ $company,
+ 'Test Template',
+ 'invoice',
+ $invalidBlocks
+ );
+ }
+
+ #[Test]
+ #[Group('multi-tenancy')]
+ public function it_respects_company_tenancy(): void
+ {
+ /* arrange */
+ $company1 = $this->createCompanyContext();
+ $company2 = Company::factory()->create();
+
+ $blocks = [
+ [
+ 'id' => 'block_company_header',
+ 'type' => 'company_header',
+ 'position' => ['x' => 0, 'y' => 0, 'width' => 6, 'height' => 4],
+ 'config' => [],
+ 'label' => 'Company Header',
+ 'isCloneable' => true,
+ 'dataSource' => 'company',
+ 'isCloned' => false,
+ 'clonedFrom' => null,
+ ],
+ ];
+
+ /* act */
+ $template = $this->service->createTemplate(
+ $company1,
+ 'Company 1 Template',
+ 'invoice',
+ $blocks
+ );
+
+ /* assert */
+ $this->assertEquals($company1->id, $template->company_id);
+ $this->assertNotEquals($company2->id, $template->company_id);
+
+ Storage::disk('report_templates')->assertExists(
+ "{$company1->id}/company-1-template.json"
+ );
+ Storage::disk('report_templates')->assertMissing(
+ "{$company2->id}/company-1-template.json"
+ );
+ }
+
+ protected function createCompanyContext(): Company
+ {
+ /** @var Company $company */
+ $company = Company::factory()->create();
+ /** @var User $user */
+ $user = User::factory()->create();
+ $user->companies()->attach($company);
+ session(['current_company_id' => $company->id]);
+
+ return $company;
+ }
+}
diff --git a/Modules/Core/Tests/Feature/GridSnapperTest.php b/Modules/Core/Tests/Feature/GridSnapperTest.php
new file mode 100644
index 000000000..3533a29a6
--- /dev/null
+++ b/Modules/Core/Tests/Feature/GridSnapperTest.php
@@ -0,0 +1,71 @@
+gridSnapper = app(GridSnapperService::class);
+ }
+
+ #[Test]
+ #[Group('grid')]
+ /**
+ * @payload
+ * {
+ * "position": {"x": 0, "y": 0, "width": 6, "height": 4}
+ * }
+ */
+ public function it_snaps_position_to_grid(): void
+ {
+ /* arrange */
+ $position = new GridPositionDTO();
+ $position->setX(0)->setY(0)->setWidth(6)->setHeight(4);
+
+ /* act */
+ $snapped = $this->gridSnapper->snap($position);
+
+ /* assert */
+ $this->assertEquals(0, $snapped->getX());
+ $this->assertEquals(0, $snapped->getY());
+ $this->assertEquals(6, $snapped->getWidth());
+ $this->assertEquals(4, $snapped->getHeight());
+ }
+
+ #[Test]
+ #[Group('grid')]
+ /**
+ * @payload
+ * {
+ * "position": {"x": 0, "y": 0, "width": 6, "height": 4}
+ * }
+ */
+ public function it_validates_position_constraints(): void
+ {
+ /* arrange */
+ $validPosition = new GridPositionDTO();
+ $validPosition->setX(0)->setY(0)->setWidth(6)->setHeight(4);
+
+ $invalidPositionX = new GridPositionDTO();
+ $invalidPositionX->setX(-1)->setY(0)->setWidth(6)->setHeight(4);
+
+ $invalidPositionWidth = new GridPositionDTO();
+ $invalidPositionWidth->setX(0)->setY(0)->setWidth(0)->setHeight(4);
+
+ /* Act & Assert */
+ $this->assertTrue($this->gridSnapper->validate($validPosition));
+ $this->assertFalse($this->gridSnapper->validate($invalidPositionX));
+ $this->assertFalse($this->gridSnapper->validate($invalidPositionWidth));
+ }
+}
diff --git a/Modules/Core/Tests/Feature/ReportBuilderBlockEditTest.php b/Modules/Core/Tests/Feature/ReportBuilderBlockEditTest.php
new file mode 100644
index 000000000..7a768b41a
--- /dev/null
+++ b/Modules/Core/Tests/Feature/ReportBuilderBlockEditTest.php
@@ -0,0 +1,262 @@
+company = Company::factory()->create();
+ $this->template = ReportTemplate::factory()->create([
+ 'company_id' => $this->company->id,
+ ]);
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_looks_up_block_by_block_type(): void
+ {
+ /* Arrange */
+ $block = ReportBlock::factory()->create([
+ 'block_type' => 'company_header',
+ 'name' => 'Company Header',
+ 'slug' => 'company-header',
+ 'width' => ReportBlockWidth::HALF,
+ 'data_source' => 'company',
+ 'default_band' => 'header',
+ 'is_active' => true,
+ ]);
+
+ /* Act */
+ $foundBlock = ReportBlock::query()->where('block_type', 'company_header')->first();
+
+ /* Assert */
+ $this->assertNotNull($foundBlock);
+ $this->assertEquals('company_header', $foundBlock->block_type);
+ $this->assertEquals('Company Header', $foundBlock->name);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_populates_form_with_block_data(): void
+ {
+ /* Arrange */
+ $block = ReportBlock::factory()->create([
+ 'block_type' => 'invoice_items',
+ 'name' => 'Invoice Items',
+ 'slug' => 'invoice-items',
+ 'width' => ReportBlockWidth::FULL,
+ 'data_source' => 'invoice',
+ 'default_band' => 'details',
+ 'is_active' => true,
+ 'config' => ['show_description' => true, 'show_quantity' => true],
+ ]);
+
+ /* Act */
+ $data = $block->toArray();
+
+ /* Assert */
+ $this->assertEquals('invoice_items', $data['block_type']);
+ $this->assertEquals('Invoice Items', $data['name']);
+ $this->assertEquals('invoice', $data['data_source']);
+ $this->assertEquals('details', $data['default_band']);
+ $this->assertTrue($data['is_active']);
+ $this->assertIsArray($data['config']);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_converts_width_enum_to_value_for_form(): void
+ {
+ /* Arrange */
+ $block = ReportBlock::factory()->create([
+ 'block_type' => 'footer_totals',
+ 'name' => 'Footer Totals',
+ 'width' => ReportBlockWidth::TWO_THIRDS,
+ ]);
+
+ /* Act */
+ $data = $block->toArray();
+
+ // Simulate the form fill process
+ if (isset($data['width']) && $data['width'] instanceof BackedEnum) {
+ $data['width'] = $data['width']->value;
+ }
+
+ /* Assert */
+ $this->assertEquals('two_thirds', $data['width']);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_provides_default_values_when_block_not_found(): void
+ {
+ /* Arrange */
+ $blockType = 'nonexistent_block';
+
+ /* Act */
+ $block = ReportBlock::query()->where('block_type', $blockType)->first();
+
+ $defaultData = [
+ 'name' => '',
+ 'width' => 'full',
+ 'block_type' => $blockType,
+ 'data_source' => '',
+ 'default_band' => '',
+ 'is_active' => true,
+ ];
+
+ /* Assert */
+ $this->assertNull($block);
+ $this->assertEquals($blockType, $defaultData['block_type']);
+ $this->assertTrue($defaultData['is_active']);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_logs_block_data_for_debugging(): void
+ {
+ /* Arrange */
+ Log::shouldReceive('info')
+ ->once()
+ ->with('Block data for edit:', Mockery::type('array'))
+ ->andReturnNull();
+
+ Log::shouldReceive('info')
+ ->once()
+ ->with('Mounting block config with data:', Mockery::type('array'))
+ ->andReturnNull();
+
+ $block = ReportBlock::factory()->create([
+ 'block_type' => 'test_logging',
+ 'name' => 'Test Logging Block',
+ ]);
+
+ /* Act */
+ $data = $block->toArray();
+ Log::info('Block data for edit:', $data);
+ Log::info('Mounting block config with data:', $data);
+
+ /* Assert */
+ $this->assertTrue(true); // Log assertions are handled by shouldReceive
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_handles_all_block_types_correctly(): void
+ {
+ /* Arrange */
+ $blockTypes = [
+ 'company_header',
+ 'client_header',
+ 'header_invoice_meta',
+ 'invoice_items',
+ 'invoice_item_tax',
+ 'footer_totals',
+ 'footer_notes',
+ 'footer_qr_code',
+ ];
+
+ $blocks = [];
+ foreach ($blockTypes as $type) {
+ $blocks[] = ReportBlock::factory()->create([
+ 'block_type' => $type,
+ 'name' => ucfirst(str_replace('_', ' ', $type)),
+ ]);
+ }
+
+ /* Act */
+ $foundBlocks = [];
+ foreach ($blockTypes as $type) {
+ $foundBlocks[] = ReportBlock::query()->where('block_type', $type)->first();
+ }
+
+ /* Assert */
+ $this->assertCount(count($blockTypes), $foundBlocks);
+ foreach ($foundBlocks as $block) {
+ $this->assertNotNull($block);
+ $this->assertInstanceOf(ReportBlock::class, $block);
+ $this->assertContains($block->block_type, $blockTypes);
+ }
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_preserves_config_array_when_editing(): void
+ {
+ /* Arrange */
+ $config = [
+ 'show_vat_id' => true,
+ 'show_phone' => true,
+ 'font_size' => 10,
+ 'font_weight' => 'bold',
+ ];
+
+ $block = ReportBlock::factory()->create([
+ 'block_type' => 'config_test',
+ 'name' => 'Config Test Block',
+ 'config' => $config,
+ ]);
+
+ /* Act */
+ $data = $block->toArray();
+
+ /* Assert */
+ $this->assertEquals($config, $data['config']);
+ $this->assertTrue($data['config']['show_vat_id']);
+ $this->assertEquals(10, $data['config']['font_size']);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_uses_slug_for_lookup_when_available(): void
+ {
+ /* Arrange */
+ $block = ReportBlock::factory()->create([
+ 'block_type' => 'slug_lookup_test',
+ 'name' => 'Slug Lookup Test',
+ 'slug' => 'slug-lookup-test',
+ ]);
+
+ /* Act */
+ $foundBySlug = ReportBlock::query()->where('slug', 'slug-lookup-test')->first();
+ $foundByType = ReportBlock::query()->where('block_type', 'slug_lookup_test')->first();
+
+ /* Assert */
+ $this->assertNotNull($foundBySlug);
+ $this->assertNotNull($foundByType);
+ $this->assertEquals($foundBySlug->id, $foundByType->id);
+ $this->assertEquals('slug_lookup_test', $foundBySlug->block_type);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+}
diff --git a/Modules/Core/Tests/Feature/ReportBuilderBlockWidthTest.php b/Modules/Core/Tests/Feature/ReportBuilderBlockWidthTest.php
new file mode 100644
index 000000000..63750fd30
--- /dev/null
+++ b/Modules/Core/Tests/Feature/ReportBuilderBlockWidthTest.php
@@ -0,0 +1,221 @@
+company = Company::factory()->create();
+ $this->template = ReportTemplate::factory()->create([
+ 'company_id' => $this->company->id,
+ ]);
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_renders_one_third_width_block_with_correct_grid_span(): void
+ {
+ /* Arrange */
+ $block = ReportBlock::factory()->create([
+ 'block_type' => 'test_one_third',
+ 'name' => 'One Third Block',
+ 'width' => ReportBlockWidth::ONE_THIRD,
+ ]);
+
+ /* Act */
+ $gridWidth = $block->width->getGridWidth();
+
+ /* Assert */
+ $this->assertEquals(4, $gridWidth);
+ $this->assertEquals(ReportBlockWidth::ONE_THIRD, $block->width);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_renders_half_width_block_with_correct_grid_span(): void
+ {
+ /* Arrange */
+ $block = ReportBlock::factory()->create([
+ 'block_type' => 'test_half',
+ 'name' => 'Half Width Block',
+ 'width' => ReportBlockWidth::HALF,
+ ]);
+
+ /* Act */
+ $gridWidth = $block->width->getGridWidth();
+
+ /* Assert */
+ $this->assertEquals(6, $gridWidth);
+ $this->assertEquals(ReportBlockWidth::HALF, $block->width);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_renders_two_thirds_width_block_with_correct_grid_span(): void
+ {
+ /* Arrange */
+ $block = ReportBlock::factory()->create([
+ 'block_type' => 'test_two_thirds',
+ 'name' => 'Two Thirds Block',
+ 'width' => ReportBlockWidth::TWO_THIRDS,
+ ]);
+
+ /* Act */
+ $gridWidth = $block->width->getGridWidth();
+
+ /* Assert */
+ $this->assertEquals(8, $gridWidth);
+ $this->assertEquals(ReportBlockWidth::TWO_THIRDS, $block->width);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_renders_full_width_block_with_correct_grid_span(): void
+ {
+ /* Arrange */
+ $block = ReportBlock::factory()->create([
+ 'block_type' => 'test_full',
+ 'name' => 'Full Width Block',
+ 'width' => ReportBlockWidth::FULL,
+ ]);
+
+ /* Act */
+ $gridWidth = $block->width->getGridWidth();
+
+ /* Assert */
+ $this->assertEquals(12, $gridWidth);
+ $this->assertEquals(ReportBlockWidth::FULL, $block->width);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_correctly_maps_block_widths_to_grid_columns_in_template(): void
+ {
+ /* Arrange */
+ $blocks = [
+ ReportBlock::factory()->create([
+ 'block_type' => 'one_third_1',
+ 'width' => ReportBlockWidth::ONE_THIRD,
+ ]),
+ ReportBlock::factory()->create([
+ 'block_type' => 'half_1',
+ 'width' => ReportBlockWidth::HALF,
+ ]),
+ ReportBlock::factory()->create([
+ 'block_type' => 'two_thirds_1',
+ 'width' => ReportBlockWidth::TWO_THIRDS,
+ ]),
+ ReportBlock::factory()->create([
+ 'block_type' => 'full_1',
+ 'width' => ReportBlockWidth::FULL,
+ ]),
+ ];
+
+ /* Act */
+ $mappedWidths = array_map(fn ($block) => $block->width->getGridWidth(), $blocks);
+
+ /* Assert */
+ $this->assertEquals([4, 6, 8, 12], $mappedWidths);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_handles_invoice_items_block_as_full_width(): void
+ {
+ /* Arrange */
+ $block = ReportBlock::factory()->create([
+ 'block_type' => 'invoice_items',
+ 'name' => 'Invoice Items',
+ 'width' => ReportBlockWidth::FULL,
+ ]);
+
+ /* Act */
+ $gridWidth = $block->width->getGridWidth();
+
+ /* Assert */
+ $this->assertEquals(12, $gridWidth);
+ $this->assertEquals(ReportBlockWidth::FULL, $block->width);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_applies_correct_css_grid_column_span_for_block_widths(): void
+ {
+ /* Arrange */
+ $testCases = [
+ ['width' => ReportBlockWidth::ONE_THIRD, 'expectedSpan' => 1], // 4 columns = span 1 in 2-column grid
+ ['width' => ReportBlockWidth::HALF, 'expectedSpan' => 1], // 6 columns = span 1 in 2-column grid
+ ['width' => ReportBlockWidth::TWO_THIRDS, 'expectedSpan' => 2], // 8 columns = span 2 in 2-column grid
+ ['width' => ReportBlockWidth::FULL, 'expectedSpan' => 2], // 12 columns = span 2 in 2-column grid
+ ];
+
+ /* Act & Assert */
+ foreach ($testCases as $testCase) {
+ $gridWidth = $testCase['width']->getGridWidth();
+
+ // Determine span based on grid width (using same logic as blade template)
+ $span = $gridWidth >= 12 ? 2 : ($gridWidth >= 8 ? 2 : 1);
+
+ $this->assertEquals(
+ $testCase['expectedSpan'],
+ $span,
+ "Width {$testCase['width']->value} (grid: {$gridWidth}) should span {$testCase['expectedSpan']} columns"
+ );
+ }
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_ensures_blocks_maintain_width_after_being_added_to_band(): void
+ {
+ /* Arrange */
+ $block = ReportBlock::factory()->create([
+ 'block_type' => 'test_persistent',
+ 'name' => 'Persistent Width Block',
+ 'width' => ReportBlockWidth::TWO_THIRDS,
+ ]);
+
+ $initialWidth = $block->width;
+ $initialGridWidth = $block->width->getGridWidth();
+
+ /* Act */
+ // Simulate block being loaded and rendered
+ $block->refresh();
+ $finalWidth = $block->width;
+ $finalGridWidth = $block->width->getGridWidth();
+
+ /* Assert */
+ $this->assertEquals($initialWidth, $finalWidth);
+ $this->assertEquals($initialGridWidth, $finalGridWidth);
+ $this->assertEquals(8, $finalGridWidth);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+}
diff --git a/Modules/Core/Tests/Feature/ReportBuilderFieldCanvasIntegrationTest.php b/Modules/Core/Tests/Feature/ReportBuilderFieldCanvasIntegrationTest.php
new file mode 100644
index 000000000..8dd38eb3d
--- /dev/null
+++ b/Modules/Core/Tests/Feature/ReportBuilderFieldCanvasIntegrationTest.php
@@ -0,0 +1,325 @@
+company = Company::factory()->create();
+ $this->template = ReportTemplate::factory()->create([
+ 'company_id' => $this->company->id,
+ ]);
+ $this->service = app(ReportBlockService::class);
+
+ Storage::fake('local');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_saves_fields_when_configuring_block(): void
+ {
+ /* Arrange */
+ $block = ReportBlock::factory()->create([
+ 'block_type' => 'test_canvas',
+ 'name' => 'Test Canvas Block',
+ 'slug' => 'test-canvas',
+ 'filename' => 'test-canvas',
+ 'width' => ReportBlockWidth::FULL,
+ ]);
+
+ $fields = [
+ ['id' => 'company_name', 'label' => 'Company Name', 'x' => 0, 'y' => 0],
+ ['id' => 'company_address', 'label' => 'Company Address', 'x' => 0, 'y' => 50],
+ ];
+
+ /* Act */
+ $this->service->saveBlockFields($block, $fields);
+ $loadedFields = $this->service->loadBlockFields($block);
+
+ /* Assert */
+ $this->assertCount(2, $loadedFields);
+ $this->assertEquals('company_name', $loadedFields[0]['id']);
+ Storage::disk('local')->assertExists('report_blocks/test-canvas.json');
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_separates_fields_from_block_data_when_saving(): void
+ {
+ /* Arrange */
+ $block = ReportBlock::factory()->create([
+ 'block_type' => 'separate_test',
+ 'name' => 'Separate Test Block',
+ 'slug' => 'separate-test',
+ 'filename' => 'separate-test',
+ 'width' => ReportBlockWidth::HALF,
+ ]);
+
+ $data = [
+ 'name' => 'Updated Name',
+ 'width' => 'full',
+ 'data_source' => 'invoice',
+ 'fields' => [
+ ['id' => 'field1', 'label' => 'Field 1'],
+ ],
+ ];
+
+ $fields = $data['fields'];
+ unset($data['fields']); // Simulate the action handler behavior
+
+ /* Act */
+ $block->update($data);
+ $this->service->saveBlockFields($block, $fields);
+
+ /* Assert */
+ $block->refresh();
+ $this->assertEquals('Updated Name', $block->name);
+ $this->assertEquals('full', $block->width->value);
+ $loadedFields = $this->service->loadBlockFields($block);
+ $this->assertCount(1, $loadedFields);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_handles_empty_fields_array_gracefully(): void
+ {
+ /* Arrange */
+ $block = ReportBlock::factory()->create([
+ 'block_type' => 'empty_fields',
+ 'name' => 'Empty Fields Block',
+ 'slug' => 'empty-fields',
+ 'filename' => 'empty-fields',
+ 'width' => ReportBlockWidth::HALF,
+ ]);
+
+ $fields = [];
+
+ /* Act */
+ $this->service->saveBlockFields($block, $fields);
+ $loadedFields = $this->service->loadBlockFields($block);
+
+ /* Assert */
+ $this->assertIsArray($loadedFields);
+ $this->assertEmpty($loadedFields);
+ Storage::disk('local')->assertExists('report_blocks/empty-fields.json');
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_preserves_field_positions_and_dimensions(): void
+ {
+ /* Arrange */
+ $block = ReportBlock::factory()->create([
+ 'block_type' => 'positioned_fields',
+ 'name' => 'Positioned Fields Block',
+ 'slug' => 'positioned-fields',
+ 'filename' => 'positioned-fields',
+ 'width' => ReportBlockWidth::FULL,
+ ]);
+
+ $fields = [
+ [
+ 'id' => 'field1',
+ 'label' => 'Field 1',
+ 'x' => 10,
+ 'y' => 20,
+ 'width' => 200,
+ 'height' => 40,
+ ],
+ [
+ 'id' => 'field2',
+ 'label' => 'Field 2',
+ 'x' => 220,
+ 'y' => 20,
+ 'width' => 150,
+ 'height' => 40,
+ ],
+ ];
+
+ /* Act */
+ $this->service->saveBlockFields($block, $fields);
+ $loadedFields = $this->service->loadBlockFields($block);
+
+ /* Assert */
+ $this->assertEquals(10, $loadedFields[0]['x']);
+ $this->assertEquals(20, $loadedFields[0]['y']);
+ $this->assertEquals(200, $loadedFields[0]['width']);
+ $this->assertEquals(40, $loadedFields[0]['height']);
+ $this->assertEquals(220, $loadedFields[1]['x']);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_loads_existing_fields_when_opening_block_editor(): void
+ {
+ /* Arrange */
+ $block = ReportBlock::factory()->create([
+ 'block_type' => 'existing_fields',
+ 'name' => 'Existing Fields Block',
+ 'slug' => 'existing-fields',
+ 'filename' => 'existing-fields',
+ 'width' => ReportBlockWidth::TWO_THIRDS,
+ ]);
+
+ $initialFields = [
+ ['id' => 'invoice_number', 'label' => 'Invoice Number'],
+ ['id' => 'invoice_date', 'label' => 'Invoice Date'],
+ ];
+
+ $this->service->saveBlockFields($block, $initialFields);
+
+ /* Act */
+ $loadedFields = $this->service->loadBlockFields($block);
+
+ /* Assert */
+ $this->assertCount(2, $loadedFields);
+ $this->assertEquals($initialFields, $loadedFields);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_allows_updating_fields_through_multiple_edits(): void
+ {
+ /* Arrange */
+ $block = ReportBlock::factory()->create([
+ 'block_type' => 'multiple_edits',
+ 'name' => 'Multiple Edits Block',
+ 'slug' => 'multiple-edits',
+ 'filename' => 'multiple-edits',
+ 'width' => ReportBlockWidth::HALF,
+ ]);
+
+ $firstFields = [
+ ['id' => 'field1', 'label' => 'Field 1'],
+ ];
+
+ $secondFields = [
+ ['id' => 'field1', 'label' => 'Field 1'],
+ ['id' => 'field2', 'label' => 'Field 2'],
+ ];
+
+ /* Act */
+ $this->service->saveBlockFields($block, $firstFields);
+ $afterFirst = $this->service->loadBlockFields($block);
+
+ $this->service->saveBlockFields($block, $secondFields);
+ $afterSecond = $this->service->loadBlockFields($block);
+
+ /* Assert */
+ $this->assertCount(1, $afterFirst);
+ $this->assertCount(2, $afterSecond);
+ $this->assertEquals('field2', $afterSecond[1]['id']);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_handles_complex_field_metadata(): void
+ {
+ /* Arrange */
+ $block = ReportBlock::factory()->create([
+ 'block_type' => 'complex_fields',
+ 'name' => 'Complex Fields Block',
+ 'slug' => 'complex-fields',
+ 'filename' => 'complex-fields',
+ 'width' => ReportBlockWidth::FULL,
+ ]);
+
+ $fields = [
+ [
+ 'id' => 'styled_field',
+ 'label' => 'Styled Field',
+ 'x' => 0,
+ 'y' => 0,
+ 'width' => 200,
+ 'height' => 40,
+ 'style' => [
+ 'color' => '#ff0000',
+ 'fontSize' => 14,
+ 'fontWeight' => 'bold',
+ 'textAlign' => 'center',
+ ],
+ 'visible' => true,
+ 'required' => false,
+ ],
+ ];
+
+ /* Act */
+ $this->service->saveBlockFields($block, $fields);
+ $loadedFields = $this->service->loadBlockFields($block);
+
+ /* Assert */
+ $this->assertArrayHasKey('style', $loadedFields[0]);
+ $this->assertEquals('#ff0000', $loadedFields[0]['style']['color']);
+ $this->assertEquals(14, $loadedFields[0]['style']['fontSize']);
+ $this->assertTrue($loadedFields[0]['visible']);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('feature')]
+ public function it_works_with_all_block_width_types(): void
+ {
+ /* Arrange */
+ $widths = [
+ ReportBlockWidth::ONE_THIRD,
+ ReportBlockWidth::HALF,
+ ReportBlockWidth::TWO_THIRDS,
+ ReportBlockWidth::FULL,
+ ];
+
+ $blocks = [];
+ foreach ($widths as $width) {
+ $blocks[] = ReportBlock::factory()->create([
+ 'block_type' => 'width_' . $width->value,
+ 'name' => ucfirst($width->value) . ' Block',
+ 'slug' => 'width-' . str_replace('_', '-', $width->value),
+ 'filename' => 'width-' . str_replace('_', '-', $width->value),
+ 'width' => $width,
+ ]);
+ }
+
+ $fields = [
+ ['id' => 'test_field', 'label' => 'Test Field'],
+ ];
+
+ /* Act & Assert */
+ foreach ($blocks as $block) {
+ $this->service->saveBlockFields($block, $fields);
+ $loadedFields = $this->service->loadBlockFields($block);
+ $this->assertCount(1, $loadedFields);
+ $this->assertEquals('test_field', $loadedFields[0]['id']);
+ }
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+}
diff --git a/Modules/Core/Tests/Feature/ReportBuilderMasonIntegrationTest.php b/Modules/Core/Tests/Feature/ReportBuilderMasonIntegrationTest.php
new file mode 100644
index 000000000..72cf4e958
--- /dev/null
+++ b/Modules/Core/Tests/Feature/ReportBuilderMasonIntegrationTest.php
@@ -0,0 +1,323 @@
+company = Company::factory()->create();
+ $this->service = app(ReportTemplateService::class);
+ $this->adapter = app(MasonStorageAdapter::class);
+
+ $this->template = ReportTemplate::factory()->create([
+ 'company_id' => $this->company->id,
+ 'slug' => 'test-invoice',
+ 'template_type' => ReportTemplateType::INVOICE,
+ ]);
+ }
+
+ #[Test]
+ public function it_saves_mason_content_to_filesystem(): void
+ {
+ /* Arrange */
+ $masonJson = json_encode([
+ 'type' => 'doc',
+ 'content' => [
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'header_company_test',
+ 'config' => ['show_vat_id' => true],
+ 'label' => 'Company Header',
+ ],
+ ],
+ ],
+ ]);
+
+ /* Act */
+ $blocks = $this->adapter->masonToBlocks($masonJson);
+ $this->service->persistBlocks($this->template, $blocks);
+ $reloadedBlocks = $this->service->loadBlocks($this->template);
+ $reloadedMasonJson = $this->adapter->blocksToMason($reloadedBlocks);
+
+ /* Assert */
+ $this->assertIsString($reloadedMasonJson);
+
+ $originalDecoded = json_decode($masonJson, true);
+ $reloadedDecoded = json_decode($reloadedMasonJson, true);
+
+ $this->assertIsArray($originalDecoded);
+ $this->assertIsArray($reloadedDecoded);
+ $this->assertSame('doc', $originalDecoded['type']);
+ $this->assertSame('doc', $reloadedDecoded['type']);
+ $this->assertNotEmpty($reloadedDecoded['content']);
+ }
+
+ #[Test]
+ public function it_saves_and_loads_mason_json_via_mason_template_storage(): void
+ {
+ /* Arrange */
+ $storage = app(\Modules\Core\Services\MasonTemplateStorage::class);
+ $masonJson = json_encode([
+ 'type' => 'doc',
+ 'content' => [
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'header_company_test',
+ 'config' => ['show_vat_id' => true],
+ 'label' => 'Company Header',
+ ],
+ ],
+ ],
+ ]);
+
+ /* Act */
+ $storage->save($this->template, $masonJson);
+
+ /* Assert */
+ $path = "{$this->company->id}/mason_{$this->template->slug}.json";
+ Storage::disk('report_templates')->assertExists($path);
+
+ $loadedJson = $storage->load($this->template);
+ $this->assertIsString($loadedJson);
+
+ $originalDecoded = json_decode($masonJson, true);
+ $loadedDecoded = json_decode($loadedJson, true);
+
+ $this->assertIsArray($originalDecoded);
+ $this->assertIsArray($loadedDecoded);
+ $this->assertSame('doc', $originalDecoded['type']);
+ $this->assertSame('doc', $loadedDecoded['type']);
+ $this->assertNotEmpty($loadedDecoded['content']);
+ $this->assertEquals($originalDecoded, $loadedDecoded);
+ }
+
+ #[Test]
+ public function it_loads_blocks_and_converts_to_mason_format(): void
+ {
+ /* Arrange */
+ $initialMasonJson = json_encode([
+ 'type' => 'doc',
+ 'content' => [
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'header_company_abc',
+ 'config' => ['show_phone' => true],
+ 'label' => 'Company',
+ ],
+ ],
+ ],
+ ]);
+
+ $blocks = $this->adapter->masonToBlocks($initialMasonJson);
+ $this->service->persistBlocks($this->template, $blocks);
+
+ /* Act */
+ $loadedBlocks = $this->service->loadBlocks($this->template);
+ $convertedMason = $this->adapter->blocksToMason($loadedBlocks);
+ $decoded = json_decode($convertedMason, true);
+
+ /* Assert */
+ $this->assertIsArray($decoded);
+ $this->assertEquals('doc', $decoded['type']);
+ $this->assertNotEmpty($decoded['content']);
+ }
+
+ #[Test]
+ public function it_preserves_block_configuration_through_roundtrip(): void
+ {
+ /* Arrange */
+ $config = [
+ 'show_vat_id' => true,
+ 'show_phone' => false,
+ 'font_size' => 12,
+ 'text_align' => 'right',
+ ];
+
+ $masonJson = json_encode([
+ 'type' => 'doc',
+ 'content' => [
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'header_company_roundtrip',
+ 'config' => $config,
+ 'label' => 'Test',
+ ],
+ ],
+ ],
+ ]);
+
+ /* Act */
+ $blocks = $this->adapter->masonToBlocks($masonJson);
+ $this->service->persistBlocks($this->template, $blocks);
+ $loadedBlocks = $this->service->loadBlocks($this->template);
+ $block = reset($loadedBlocks);
+
+ /* Assert */
+ $this->assertEquals($config, $block->getConfig());
+ }
+
+ #[Test]
+ public function it_handles_multiple_bricks_of_different_types(): void
+ {
+ /* Arrange */
+ $masonJson = json_encode([
+ 'type' => 'doc',
+ 'content' => [
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'header_company_test',
+ 'config' => [],
+ 'label' => 'Company',
+ ],
+ ],
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'header_client_test',
+ 'config' => [],
+ 'label' => 'Client',
+ ],
+ ],
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'detail_items_test',
+ 'config' => [],
+ 'label' => 'Items',
+ ],
+ ],
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'footer_totals_test',
+ 'config' => [],
+ 'label' => 'Totals',
+ ],
+ ],
+ ],
+ ]);
+
+ /* Act */
+ $blocks = $this->adapter->masonToBlocks($masonJson);
+ $this->service->persistBlocks($this->template, $blocks);
+ $loadedBlocks = $this->service->loadBlocks($this->template);
+
+ /* Assert */
+ $this->assertCount(4, $loadedBlocks);
+
+ $types = array_map(fn ($block) => $block->getType(), $loadedBlocks);
+ $this->assertContains('header_company', $types);
+ $this->assertContains('header_client', $types);
+ $this->assertContains('detail_items', $types);
+ $this->assertContains('footer_totals', $types);
+ }
+
+ #[Test]
+ public function it_maintains_block_order_through_persistence(): void
+ {
+ /* Arrange */
+ $masonJson = json_encode([
+ 'type' => 'doc',
+ 'content' => [
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'first_block',
+ 'config' => [],
+ 'label' => 'First',
+ ],
+ ],
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'second_block',
+ 'config' => [],
+ 'label' => 'Second',
+ ],
+ ],
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'third_block',
+ 'config' => [],
+ 'label' => 'Third',
+ ],
+ ],
+ ],
+ ]);
+
+ /* Act */
+ $blocks = $this->adapter->masonToBlocks($masonJson);
+ $this->service->persistBlocks($this->template, $blocks);
+ $loadedBlocks = $this->service->loadBlocks($this->template);
+ $convertedMason = $this->adapter->blocksToMason($loadedBlocks);
+ $decoded = json_decode($convertedMason, true);
+
+ /* Assert */
+ $this->assertCount(3, $decoded['content']);
+ }
+
+ #[Test]
+ public function it_assigns_correct_data_sources_to_blocks(): void
+ {
+ /* Arrange */
+ $masonJson = json_encode([
+ 'type' => 'doc',
+ 'content' => [
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'header_company_src',
+ 'config' => [],
+ 'label' => 'Company',
+ ],
+ ],
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'header_invoice_meta_src',
+ 'config' => [],
+ 'label' => 'Invoice',
+ ],
+ ],
+ ],
+ ]);
+
+ /* Act */
+ $blocks = $this->adapter->masonToBlocks($masonJson);
+
+ /* Assert */
+ $companyBlock = $blocks['header_company_src'];
+ $invoiceBlock = $blocks['header_invoice_meta_src'];
+
+ $this->assertEquals('company', $companyBlock->getDataSource());
+ $this->assertEquals('invoice', $invoiceBlock->getDataSource());
+ }
+}
diff --git a/Modules/Core/Tests/Feature/UpdateReportTemplateTest.php b/Modules/Core/Tests/Feature/UpdateReportTemplateTest.php
new file mode 100644
index 000000000..f5785decb
--- /dev/null
+++ b/Modules/Core/Tests/Feature/UpdateReportTemplateTest.php
@@ -0,0 +1,224 @@
+service = app(ReportTemplateService::class);
+ }
+
+ #[Test]
+ #[Group('crud')]
+ /**
+ * @payload
+ * {
+ * "blocks": [
+ * {
+ * "id": "block_company_header",
+ * "type": "company_header",
+ * "position": {"x": 2, "y": 2, "width": 8, "height": 6},
+ * "config": {"show_vat_id": false},
+ * "label": "Updated Company Header",
+ * "isCloneable": true,
+ * "dataSource": "company",
+ * "isCloned": false,
+ * "clonedFrom": null
+ * }
+ * ]
+ * }
+ */
+ public function it_updates_template_blocks(): void
+ {
+ /* arrange */
+ $company = Company::factory()->create();
+ $user = User::factory()->create();
+ $user->companies()->attach($company);
+ session(['current_company_id' => $company->id]);
+
+ $initialBlocks = [
+ [
+ 'id' => 'block_company_header',
+ 'type' => 'company_header',
+ 'position' => ['x' => 0, 'y' => 0, 'width' => 6, 'height' => 4],
+ 'config' => ['show_vat_id' => true],
+ 'label' => 'Company Header',
+ 'isCloneable' => true,
+ 'dataSource' => 'company',
+ 'isCloned' => false,
+ 'clonedFrom' => null,
+ ],
+ ];
+
+ $template = $this->service->createTemplate(
+ $company,
+ 'Test Template',
+ 'invoice',
+ $initialBlocks
+ );
+
+ $updatedBlocks = [
+ [
+ 'id' => 'block_company_header',
+ 'type' => 'company_header',
+ 'position' => ['x' => 2, 'y' => 2, 'width' => 8, 'height' => 6],
+ 'config' => ['show_vat_id' => false],
+ 'label' => 'Updated Company Header',
+ 'isCloneable' => true,
+ 'dataSource' => 'company',
+ 'isCloned' => false,
+ 'clonedFrom' => null,
+ ],
+ ];
+
+ /* act */
+ $this->service->updateTemplate($template, $updatedBlocks);
+
+ /* assert */
+ $fileContents = Storage::disk('report_templates')->get(
+ "{$company->id}/test-template.json"
+ );
+ $savedBlocks = json_decode($fileContents, true);
+
+ $this->assertCount(1, $savedBlocks);
+ $this->assertEquals(2, $savedBlocks[0]['position']['x']);
+ $this->assertEquals(2, $savedBlocks[0]['position']['y']);
+ $this->assertEquals(8, $savedBlocks[0]['position']['width']);
+ $this->assertEquals(6, $savedBlocks[0]['position']['height']);
+ $this->assertFalse($savedBlocks[0]['config']['show_vat_id']);
+ }
+
+ #[Test]
+ #[Group('crud')]
+ public function it_snaps_blocks_to_grid_on_update(): void
+ {
+ /* arrange */
+ $company = Company::factory()->create();
+ $user = User::factory()->create();
+ $user->companies()->attach($company);
+ session(['current_company_id' => $company->id]);
+
+ $template = $this->service->createTemplate(
+ $company,
+ 'Test Template',
+ 'invoice',
+ []
+ );
+
+ $blocksWithValidPosition = [
+ [
+ 'id' => 'block_company_header',
+ 'type' => 'company_header',
+ 'position' => ['x' => 0, 'y' => 0, 'width' => 6, 'height' => 4],
+ 'config' => [],
+ 'label' => 'Company Header',
+ 'isCloneable' => true,
+ 'dataSource' => 'company',
+ 'isCloned' => false,
+ 'clonedFrom' => null,
+ ],
+ ];
+
+ /* act */
+ $this->service->updateTemplate($template, $blocksWithValidPosition);
+
+ /* assert */
+ $fileContents = Storage::disk('report_templates')->get(
+ "{$company->id}/test-template.json"
+ );
+ $savedBlocks = json_decode($fileContents, true);
+
+ $this->assertEquals(0, $savedBlocks[0]['position']['x']);
+ $this->assertEquals(0, $savedBlocks[0]['position']['y']);
+ $this->assertEquals(6, $savedBlocks[0]['position']['width']);
+ $this->assertEquals(4, $savedBlocks[0]['position']['height']);
+ }
+
+ #[Test]
+ #[Group('crud')]
+ public function it_persists_updates_to_filesystem(): void
+ {
+ /* arrange */
+ $company = Company::factory()->create();
+ $user = User::factory()->create();
+ $user->companies()->attach($company);
+ session(['current_company_id' => $company->id]);
+
+ $initialBlocks = [
+ [
+ 'id' => 'block_company_header',
+ 'type' => 'company_header',
+ 'position' => ['x' => 0, 'y' => 0, 'width' => 6, 'height' => 4],
+ 'config' => [],
+ 'label' => 'Company Header',
+ 'isCloneable' => true,
+ 'dataSource' => 'company',
+ 'isCloned' => false,
+ 'clonedFrom' => null,
+ ],
+ ];
+
+ $template = $this->service->createTemplate(
+ $company,
+ 'Test Template',
+ 'invoice',
+ $initialBlocks
+ );
+
+ $updatedBlocks = [
+ [
+ 'id' => 'block_company_header',
+ 'type' => 'company_header',
+ 'position' => ['x' => 0, 'y' => 0, 'width' => 6, 'height' => 4],
+ 'config' => [],
+ 'label' => 'Company Header',
+ 'isCloneable' => true,
+ 'dataSource' => 'company',
+ 'isCloned' => false,
+ 'clonedFrom' => null,
+ ],
+ [
+ 'id' => 'block_footer_totals',
+ 'type' => 'footer_totals',
+ 'position' => ['x' => 6, 'y' => 14, 'width' => 6, 'height' => 4],
+ 'config' => ['show_subtotal' => true],
+ 'label' => 'Invoice Totals',
+ 'isCloneable' => true,
+ 'dataSource' => 'invoice',
+ 'isCloned' => false,
+ 'clonedFrom' => null,
+ ],
+ ];
+
+ /* act */
+ $this->service->updateTemplate($template, $updatedBlocks);
+
+ /* assert */
+ Storage::disk('report_templates')->assertExists(
+ "{$company->id}/test-template.json"
+ );
+
+ $fileContents = Storage::disk('report_templates')->get(
+ "{$company->id}/test-template.json"
+ );
+ $savedBlocks = json_decode($fileContents, true);
+
+ $this->assertCount(2, $savedBlocks);
+ $this->assertEquals('block_company_header', $savedBlocks[0]['id']);
+ $this->assertEquals('block_footer_totals', $savedBlocks[1]['id']);
+ }
+}
diff --git a/Modules/Core/Tests/Unit/BlockDTOTest.php b/Modules/Core/Tests/Unit/BlockDTOTest.php
new file mode 100644
index 000000000..dbe19928f
--- /dev/null
+++ b/Modules/Core/Tests/Unit/BlockDTOTest.php
@@ -0,0 +1,300 @@
+setId('block_company_header');
+
+ /* assert */
+ $this->assertEquals('block_company_header', $dto->getId());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_set_and_get_type(): void
+ {
+ /* arrange */
+ $dto = new BlockDTO();
+
+ /* act */
+ $dto->setType('company_header');
+
+ /* assert */
+ $this->assertEquals('company_header', $dto->getType());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_set_and_get_position(): void
+ {
+ /* arrange */
+ $position = new GridPositionDTO();
+ $position->setX(0)->setY(0)->setWidth(6)->setHeight(4);
+
+ /* act */
+ $dto = new BlockDTO();
+ $dto->setPosition($position);
+
+ /* assert */
+ $this->assertInstanceOf(GridPositionDTO::class, $dto->getPosition());
+ $this->assertEquals(0, $dto->getPosition()->getX());
+ $this->assertEquals(0, $dto->getPosition()->getY());
+ $this->assertEquals(6, $dto->getPosition()->getWidth());
+ $this->assertEquals(4, $dto->getPosition()->getHeight());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_set_and_get_config(): void
+ {
+ /* arrange */
+ $config = ['show_vat_id' => true];
+
+ /* act */
+ $dto = new BlockDTO();
+ $dto->setConfig($config);
+
+ /* assert */
+ $this->assertEquals($config, $dto->getConfig());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_set_and_get_label(): void
+ {
+ /* arrange */
+ $dto = new BlockDTO();
+
+ /* act */
+ $dto->setLabel('Company Header');
+
+ /* assert */
+ $this->assertEquals('Company Header', $dto->getLabel());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_set_label_to_null(): void
+ {
+ /* arrange */
+ $dto = new BlockDTO();
+
+ /* act */
+ $dto->setLabel(null);
+
+ /* assert */
+ $this->assertNull($dto->getLabel());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_set_and_get_is_cloneable(): void
+ {
+ /* arrange */
+ $dto = new BlockDTO();
+
+ /* act */
+ $dto->setIsCloneable(true);
+
+ /* assert */
+ $this->assertTrue($dto->getIsCloneable());
+ $dto->setIsCloneable(false);
+ $this->assertFalse($dto->getIsCloneable());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_set_and_get_data_source(): void
+ {
+ /* arrange */
+ $dto = new BlockDTO();
+
+ /* act */
+ $dto->setDataSource('company');
+
+ /* assert */
+ $this->assertEquals('company', $dto->getDataSource());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_set_data_source_to_null(): void
+ {
+ /* arrange */
+ $dto = new BlockDTO();
+
+ /* act */
+ $dto->setDataSource(null);
+
+ /* assert */
+ $this->assertNull($dto->getDataSource());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_set_and_get_is_cloned(): void
+ {
+ /* arrange */
+ $dto = new BlockDTO();
+
+ /* act */
+ $dto->setIsCloned(true);
+
+ /* assert */
+ $this->assertTrue($dto->getIsCloned());
+ $dto->setIsCloned(false);
+ $this->assertFalse($dto->getIsCloned());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_set_and_get_cloned_from(): void
+ {
+ /* arrange */
+ $dto = new BlockDTO();
+
+ /* act */
+ $dto->setClonedFrom('block_original');
+
+ /* assert */
+ $this->assertEquals('block_original', $dto->getClonedFrom());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_set_cloned_from_to_null(): void
+ {
+ /* arrange */
+ $dto = new BlockDTO();
+
+ /* act */
+ $dto->setClonedFrom(null);
+
+ /* assert */
+ $this->assertNull($dto->getClonedFrom());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_create_system_block(): void
+ {
+ /* arrange */
+ $position = new GridPositionDTO();
+ $position->setX(0)->setY(0)->setWidth(6)->setHeight(4);
+ $config = ['show_vat_id' => true];
+
+ /* act */
+ $dto = BlockDTO::system('company_header', $position, $config);
+
+ /* assert */
+ $this->assertEquals('company_header', $dto->getType());
+ $this->assertEquals($position, $dto->getPosition());
+ $this->assertEquals($config, $dto->getConfig());
+ $this->assertTrue($dto->getIsCloneable());
+ $this->assertFalse($dto->getIsCloned());
+ $this->assertNull($dto->getClonedFrom());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_create_cloned_block(): void
+ {
+ /* arrange */
+ $position = new GridPositionDTO();
+ $position->setX(0)->setY(0)->setWidth(6)->setHeight(4);
+
+ /* act */
+ $original = new BlockDTO();
+ $original->setId('block_original')
+ ->setType('company_header')
+ ->setPosition($position)
+ ->setConfig(['show_vat_id' => true])
+ ->setLabel('Original Label')
+ ->setIsCloneable(true)
+ ->setDataSource('company')
+ ->setIsCloned(false)
+ ->setClonedFrom(null);
+
+ $cloned = BlockDTO::clonedFrom($original, 'block_cloned');
+
+ /* assert */
+ $this->assertEquals('block_cloned', $cloned->getId());
+ $this->assertEquals('company_header', $cloned->getType());
+ $this->assertEquals($position, $cloned->getPosition());
+ $this->assertEquals(['show_vat_id' => true], $cloned->getConfig());
+ $this->assertEquals('Original Label', $cloned->getLabel());
+ $this->assertTrue($cloned->getIsCloneable());
+ $this->assertEquals('company', $cloned->getDataSource());
+ $this->assertTrue($cloned->getIsCloned());
+ $this->assertEquals('block_original', $cloned->getClonedFrom());
+ // Verify deep copy: mutating original position should not affect clone
+ $position->setX(10);
+ $this->assertEquals(10, $original->getPosition()->getX());
+ $this->assertEquals(0, $cloned->getPosition()->getX());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function setters_return_self_for_method_chaining(): void
+ {
+ $position = new GridPositionDTO();
+ $position->setX(0)->setY(0)->setWidth(6)->setHeight(4);
+
+ $dto = (new BlockDTO())
+ ->setId('block_test')
+ ->setType('test_type')
+ ->setPosition($position)
+ ->setConfig(['key' => 'value'])
+ ->setLabel('Test Label')
+ ->setIsCloneable(true)
+ ->setDataSource('test_source')
+ ->setIsCloned(false)
+ ->setClonedFrom(null);
+
+ $this->assertInstanceOf(BlockDTO::class, $dto);
+ $this->assertEquals('block_test', $dto->getId());
+ $this->assertEquals('test_type', $dto->getType());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_creates_block_dto(): void
+ {
+ /* arrange */
+ $position = new GridPositionDTO();
+ $position->setX(0)->setY(0)->setWidth(6)->setHeight(4);
+
+ /* act */
+ $dto = new BlockDTO();
+ $dto->setId('test_block')
+ ->setType('company_header')
+ ->setPosition($position)
+ ->setConfig(['key' => 'value'])
+ ->setLabel('Test Block')
+ ->setIsCloneable(true);
+
+ /* assert */
+ $this->assertInstanceOf(BlockDTO::class, $dto);
+ $this->assertEquals('test_block', $dto->getId());
+ $this->assertEquals('company_header', $dto->getType());
+ $this->assertEquals($position, $dto->getPosition());
+ $this->assertEquals(['key' => 'value'], $dto->getConfig());
+ $this->assertEquals('Test Block', $dto->getLabel());
+ $this->assertTrue($dto->getIsCloneable());
+ }
+}
diff --git a/Modules/Core/Tests/Unit/BlockFactoryTest.php b/Modules/Core/Tests/Unit/BlockFactoryTest.php
new file mode 100644
index 000000000..f738f3746
--- /dev/null
+++ b/Modules/Core/Tests/Unit/BlockFactoryTest.php
@@ -0,0 +1,120 @@
+assertInstanceOf(HeaderCompanyBlockHandler::class, $handler);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_creates_invoice_items_handler(): void
+ {
+ /* arrange */
+ // No setup needed
+
+ /* act */
+ $handler = BlockFactory::make('invoice_items');
+
+ /* assert */
+ $this->assertInstanceOf(DetailItemsBlockHandler::class, $handler);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_creates_footer_notes_handler(): void
+ {
+ /* arrange */
+ // No setup needed
+
+ /* act */
+ $handler = BlockFactory::make('footer_notes');
+
+ /* assert */
+ $this->assertInstanceOf(FooterNotesBlockHandler::class, $handler);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_throws_exception_for_invalid_type(): void
+ {
+ /* arrange */
+ // No setup needed
+
+ /* assert */
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessageMatches('/Unsupported block type/i');
+ BlockFactory::make('invalid_type');
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_returns_all_block_types(): void
+ {
+ /* arrange */
+ // No setup needed
+
+ /* act */
+ $blockTypes = BlockFactory::all();
+
+ /* assert */
+ $this->assertIsArray($blockTypes);
+ $this->assertNotEmpty($blockTypes);
+ $this->assertCount(8, $blockTypes);
+ foreach ($blockTypes as $block) {
+ $this->assertArrayHasKey('type', $block);
+ $this->assertArrayHasKey('label', $block);
+ $this->assertArrayHasKey('category', $block);
+ $this->assertArrayHasKey('description', $block);
+ $this->assertArrayHasKey('icon', $block);
+ }
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function all_returned_types_are_creatable(): void
+ {
+ $blockTypes = BlockFactory::all();
+
+ foreach ($blockTypes as $block) {
+ $handler = BlockFactory::make($block['type']);
+ $this->assertNotNull($handler);
+ }
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_creates_block(): void
+ {
+ /* arrange */
+ $blockType = 'company_header';
+
+ /* act */
+ $block = BlockFactory::make($blockType);
+
+ /* assert */
+ $this->assertNotNull($block);
+ $this->assertInstanceOf(HeaderCompanyBlockHandler::class, $block);
+ }
+}
diff --git a/Modules/Core/Tests/Unit/BlockTransformerTest.php b/Modules/Core/Tests/Unit/BlockTransformerTest.php
new file mode 100644
index 000000000..4742f2be8
--- /dev/null
+++ b/Modules/Core/Tests/Unit/BlockTransformerTest.php
@@ -0,0 +1,303 @@
+ 'block_company_header',
+ 'type' => 'company_header',
+ 'position' => [
+ 'x' => 0,
+ 'y' => 0,
+ 'width' => 6,
+ 'height' => 4,
+ ],
+ 'config' => [
+ 'show_vat_id' => true,
+ 'show_phone' => true,
+ ],
+ 'label' => 'Company Header',
+ 'isCloneable' => true,
+ 'dataSource' => 'company',
+ 'isCloned' => false,
+ 'clonedFrom' => null,
+ ];
+
+ /* act */
+ $dto = BlockTransformer::toDTO($blockData);
+
+ /* assert */
+ $this->assertInstanceOf(BlockDTO::class, $dto);
+ $this->assertEquals('block_company_header', $dto->getId());
+ $this->assertEquals('company_header', $dto->getType());
+ $this->assertInstanceOf(GridPositionDTO::class, $dto->getPosition());
+ $this->assertEquals(0, $dto->getPosition()->getX());
+ $this->assertEquals(0, $dto->getPosition()->getY());
+ $this->assertEquals(6, $dto->getPosition()->getWidth());
+ $this->assertEquals(4, $dto->getPosition()->getHeight());
+ $this->assertEquals(['show_vat_id' => true, 'show_phone' => true], $dto->getConfig());
+ $this->assertEquals('Company Header', $dto->getLabel());
+ $this->assertTrue($dto->getIsCloneable());
+ $this->assertEquals('company', $dto->getDataSource());
+ $this->assertFalse($dto->getIsCloned());
+ $this->assertNull($dto->getClonedFrom());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_uses_defaults_for_missing_array_values(): void
+ {
+ /* arrange */
+ $blockData = [
+ 'id' => 'block_test',
+ 'type' => 'test_type',
+ ];
+
+ /* act */
+ $dto = BlockTransformer::toDTO($blockData);
+
+ /* assert */
+ $this->assertEquals('block_test', $dto->getId());
+ $this->assertEquals('test_type', $dto->getType());
+ $this->assertInstanceOf(GridPositionDTO::class, $dto->getPosition());
+ $this->assertEquals(0, $dto->getPosition()->getX());
+ $this->assertEquals(0, $dto->getPosition()->getY());
+ $this->assertEquals(1, $dto->getPosition()->getWidth());
+ $this->assertEquals(1, $dto->getPosition()->getHeight());
+ $this->assertEquals([], $dto->getConfig());
+ $this->assertNull($dto->getLabel());
+ $this->assertFalse($dto->getIsCloneable());
+ $this->assertNull($dto->getDataSource());
+ $this->assertFalse($dto->getIsCloned());
+ $this->assertNull($dto->getClonedFrom());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_transform_dto_to_array(): void
+ {
+ /* arrange */
+ $position = new GridPositionDTO();
+ $position->setX(0)->setY(0)->setWidth(6)->setHeight(4);
+
+ $dto = new BlockDTO();
+ $dto->setId('block_company_header')
+ ->setType('company_header')
+ ->setPosition($position)
+ ->setConfig(['show_vat_id' => true])
+ ->setLabel('Company Header')
+ ->setIsCloneable(true)
+ ->setDataSource('company')
+ ->setIsCloned(false)
+ ->setClonedFrom(null);
+
+ /* act */
+ $array = BlockTransformer::toArray($dto);
+
+ /* assert */
+ $this->assertIsArray($array);
+ $this->assertEquals('block_company_header', $array['id']);
+ $this->assertEquals('company_header', $array['type']);
+ $this->assertIsArray($array['position']);
+ $this->assertEquals(0, $array['position']['x']);
+ $this->assertEquals(0, $array['position']['y']);
+ $this->assertEquals(6, $array['position']['width']);
+ $this->assertEquals(4, $array['position']['height']);
+ $this->assertEquals(['show_vat_id' => true], $array['config']);
+ $this->assertEquals('Company Header', $array['label']);
+ $this->assertTrue($array['isCloneable']);
+ $this->assertEquals('company', $array['dataSource']);
+ $this->assertFalse($array['isCloned']);
+ $this->assertNull($array['clonedFrom']);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_transform_dto_to_json_pretty(): void
+ {
+ /* arrange */
+ $position = new GridPositionDTO();
+ $position->setX(0)->setY(0)->setWidth(6)->setHeight(4);
+
+ $dto = new BlockDTO();
+ $dto->setId('block_test')
+ ->setType('test_type')
+ ->setPosition($position)
+ ->setConfig(['key' => 'value'])
+ ->setLabel(null)
+ ->setIsCloneable(false)
+ ->setDataSource(null)
+ ->setIsCloned(false)
+ ->setClonedFrom(null);
+
+ /* act */
+ $json = BlockTransformer::toJson($dto, true);
+
+ /* assert */
+ $this->assertJson($json);
+ $decoded = json_decode($json, true);
+ $this->assertEquals('block_test', $decoded['id']);
+ $this->assertEquals('test_type', $decoded['type']);
+ $this->assertStringContainsString("\n", $json);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_transform_dto_to_json_compact(): void
+ {
+ /* arrange */
+ $position = new GridPositionDTO();
+ $position->setX(0)->setY(0)->setWidth(6)->setHeight(4);
+
+ $dto = new BlockDTO();
+ $dto->setId('block_test')
+ ->setType('test_type')
+ ->setPosition($position)
+ ->setConfig([])
+ ->setLabel(null)
+ ->setIsCloneable(false)
+ ->setDataSource(null)
+ ->setIsCloned(false)
+ ->setClonedFrom(null);
+
+ /* act */
+ $json = BlockTransformer::toJson($dto, false);
+
+ /* assert */
+ $this->assertJson($json);
+ $decoded = json_decode($json, true);
+ $this->assertEquals('block_test', $decoded['id']);
+ $this->assertStringNotContainsString("\n ", $json);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_transform_array_collection_to_dto_collection(): void
+ {
+ /* arrange */
+ $blocks = [
+ [
+ 'id' => 'block_1',
+ 'type' => 'type_1',
+ 'position' => ['x' => 0, 'y' => 0, 'width' => 6, 'height' => 4],
+ 'config' => [],
+ 'label' => null,
+ 'isCloneable' => true,
+ 'dataSource' => null,
+ 'isCloned' => false,
+ 'clonedFrom' => null,
+ ],
+ [
+ 'id' => 'block_2',
+ 'type' => 'type_2',
+ 'position' => ['x' => 6, 'y' => 0, 'width' => 6, 'height' => 4],
+ 'config' => [],
+ 'label' => null,
+ 'isCloneable' => true,
+ 'dataSource' => null,
+ 'isCloned' => false,
+ 'clonedFrom' => null,
+ ],
+ ];
+
+ /* act */
+ $dtos = BlockTransformer::toArrayCollection($blocks);
+
+ /* assert */
+ $this->assertIsArray($dtos);
+ $this->assertCount(2, $dtos);
+ $this->assertInstanceOf(BlockDTO::class, $dtos[0]);
+ $this->assertInstanceOf(BlockDTO::class, $dtos[1]);
+ $this->assertEquals('block_1', $dtos[0]->getId());
+ $this->assertEquals('block_2', $dtos[1]->getId());
+ $this->assertEquals('type_1', $dtos[0]->getType());
+ $this->assertEquals('type_2', $dtos[1]->getType());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_handle_empty_array_collection(): void
+ {
+ /* arrange */
+ // No setup needed
+
+ /* act */
+ $dtos = BlockTransformer::toArrayCollection([]);
+
+ /* assert */
+ $this->assertIsArray($dtos);
+ $this->assertCount(0, $dtos);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function roundtrip_conversion_preserves_data(): void
+ {
+ /* arrange */
+ $originalData = [
+ 'id' => 'block_roundtrip',
+ 'type' => 'footer_totals',
+ 'position' => ['x' => 10, 'y' => 20, 'width' => 8, 'height' => 3],
+ 'config' => ['show_tax' => true, 'currency' => 'USD'],
+ 'label' => 'Totals Section',
+ 'isCloneable' => true,
+ 'dataSource' => 'invoice',
+ 'isCloned' => true,
+ 'clonedFrom' => 'block_original_totals',
+ ];
+
+ /* act */
+ $dto = BlockTransformer::toDTO($originalData);
+ $convertedData = BlockTransformer::toArray($dto);
+
+ /* assert */
+ $this->assertEquals($originalData['id'], $convertedData['id']);
+ $this->assertEquals($originalData['type'], $convertedData['type']);
+ $this->assertEquals($originalData['position'], $convertedData['position']);
+ $this->assertEquals($originalData['config'], $convertedData['config']);
+ $this->assertEquals($originalData['label'], $convertedData['label']);
+ $this->assertEquals($originalData['isCloneable'], $convertedData['isCloneable']);
+ $this->assertEquals($originalData['dataSource'], $convertedData['dataSource']);
+ $this->assertEquals($originalData['isCloned'], $convertedData['isCloned']);
+ $this->assertEquals($originalData['clonedFrom'], $convertedData['clonedFrom']);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_transforms_block(): void
+ {
+ /* arrange */
+ $position = new GridPositionDTO();
+ $position->setX(0)->setY(0)->setWidth(6)->setHeight(4);
+
+ $dto = new BlockDTO();
+ $dto->setId('test_block')
+ ->setType('company_header')
+ ->setPosition($position)
+ ->setConfig(['test' => true]);
+
+ /* act */
+ $array = BlockTransformer::toArray($dto);
+
+ /* assert */
+ $this->assertIsArray($array);
+ $this->assertEquals('test_block', $array['id']);
+ $this->assertEquals('company_header', $array['type']);
+ $this->assertIsArray($array['position']);
+ $this->assertEquals(0, $array['position']['x']);
+ $this->assertEquals(6, $array['position']['width']);
+ }
+}
diff --git a/Modules/Core/Tests/Unit/GridPositionDTOTest.php b/Modules/Core/Tests/Unit/GridPositionDTOTest.php
new file mode 100644
index 000000000..668de8b3b
--- /dev/null
+++ b/Modules/Core/Tests/Unit/GridPositionDTOTest.php
@@ -0,0 +1,149 @@
+setX(5);
+
+ /* assert */
+ $this->assertEquals(5, $dto->getX());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_set_and_get_y(): void
+ {
+ /* arrange */
+ $dto = new GridPositionDTO();
+
+ /* act */
+ $dto->setY(10);
+
+ /* assert */
+ $this->assertEquals(10, $dto->getY());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_set_and_get_width(): void
+ {
+ /* arrange */
+ $dto = new GridPositionDTO();
+
+ /* act */
+ $dto->setWidth(6);
+
+ /* assert */
+ $this->assertEquals(6, $dto->getWidth());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_set_and_get_height(): void
+ {
+ /* arrange */
+ $dto = new GridPositionDTO();
+
+ /* act */
+ $dto->setHeight(4);
+
+ /* assert */
+ $this->assertEquals(4, $dto->getHeight());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function setters_return_self_for_method_chaining(): void
+ {
+ $dto = (new GridPositionDTO())
+ ->setX(0)
+ ->setY(0)
+ ->setWidth(12)
+ ->setHeight(8);
+
+ $this->assertInstanceOf(GridPositionDTO::class, $dto);
+ $this->assertEquals(0, $dto->getX());
+ $this->assertEquals(0, $dto->getY());
+ $this->assertEquals(12, $dto->getWidth());
+ $this->assertEquals(8, $dto->getHeight());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_handle_zero_values(): void
+ {
+ /* arrange */
+ $dto = (new GridPositionDTO())
+
+ /* act */
+ ->setX(0)
+ ->setY(0)
+ ->setWidth(0)
+ ->setHeight(0);
+
+ /* assert */
+ $this->assertEquals(0, $dto->getX());
+ $this->assertEquals(0, $dto->getY());
+ $this->assertEquals(0, $dto->getWidth());
+ $this->assertEquals(0, $dto->getHeight());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_handle_large_values(): void
+ {
+ /* arrange */
+ $dto = (new GridPositionDTO())
+
+ /* act */
+ ->setX(1000)
+ ->setY(2000)
+ ->setWidth(500)
+ ->setHeight(300);
+
+ /* assert */
+ $this->assertEquals(1000, $dto->getX());
+ $this->assertEquals(2000, $dto->getY());
+ $this->assertEquals(500, $dto->getWidth());
+ $this->assertEquals(300, $dto->getHeight());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_creates_grid_position(): void
+ {
+ /* arrange */
+ $x = 4;
+ $y = 8;
+ $width = 6;
+ $height = 4;
+
+ /* act */
+ $dto = (new GridPositionDTO())
+ ->setX($x)
+ ->setY($y)
+ ->setWidth($width)
+ ->setHeight($height);
+
+ /* assert */
+ $this->assertInstanceOf(GridPositionDTO::class, $dto);
+ $this->assertEquals($x, $dto->getX());
+ $this->assertEquals($y, $dto->getY());
+ $this->assertEquals($width, $dto->getWidth());
+ $this->assertEquals($height, $dto->getHeight());
+ }
+}
diff --git a/Modules/Core/Tests/Unit/GridSnapperServiceTest.php b/Modules/Core/Tests/Unit/GridSnapperServiceTest.php
new file mode 100644
index 000000000..31b62dd18
--- /dev/null
+++ b/Modules/Core/Tests/Unit/GridSnapperServiceTest.php
@@ -0,0 +1,222 @@
+setX(2)->setY(3)->setWidth(4)->setHeight(2);
+
+ $snapped = $service->snap($position);
+
+ /* assert */
+ $this->assertEquals(2, $snapped->getX());
+ $this->assertEquals(3, $snapped->getY());
+ $this->assertEquals(4, $snapped->getWidth());
+ $this->assertEquals(2, $snapped->getHeight());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_snaps_x_to_grid_boundaries(): void
+ {
+ /* arrange */
+ $service = new GridSnapperService(12);
+
+ $position = new GridPositionDTO();
+
+ /* act */
+ $position->setX(15)->setY(0)->setWidth(1)->setHeight(1);
+
+ $snapped = $service->snap($position);
+
+ /* assert */
+ $this->assertEquals(11, $snapped->getX());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_snaps_negative_x_to_zero(): void
+ {
+ /* arrange */
+ $service = new GridSnapperService(12);
+
+ $position = new GridPositionDTO();
+
+ /* act */
+ $position->setX(-5)->setY(0)->setWidth(1)->setHeight(1);
+
+ $snapped = $service->snap($position);
+
+ /* assert */
+ $this->assertEquals(0, $snapped->getX());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_snaps_negative_y_to_zero(): void
+ {
+ /* arrange */
+ $service = new GridSnapperService(12);
+
+ $position = new GridPositionDTO();
+
+ /* act */
+ $position->setX(0)->setY(-3)->setWidth(1)->setHeight(1);
+
+ $snapped = $service->snap($position);
+
+ /* assert */
+ $this->assertEquals(0, $snapped->getY());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_validates_correct_position(): void
+ {
+ /* arrange */
+ $service = new GridSnapperService(12);
+
+ $position = new GridPositionDTO();
+
+ /* act */
+ $position->setX(0)->setY(0)->setWidth(6)->setHeight(4);
+
+ /* assert */
+ $this->assertTrue($service->validate($position));
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_rejects_negative_x(): void
+ {
+ /* arrange */
+ $service = new GridSnapperService(12);
+
+ $position = new GridPositionDTO();
+
+ /* act */
+ $position->setX(-1)->setY(0)->setWidth(1)->setHeight(1);
+
+ /* assert */
+ $this->assertFalse($service->validate($position));
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_rejects_negative_y(): void
+ {
+ /* arrange */
+ $service = new GridSnapperService(12);
+
+ $position = new GridPositionDTO();
+
+ /* act */
+ $position->setX(0)->setY(-1)->setWidth(1)->setHeight(1);
+
+ /* assert */
+ $this->assertFalse($service->validate($position));
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_rejects_x_beyond_grid(): void
+ {
+ /* arrange */
+ $service = new GridSnapperService(12);
+
+ $position = new GridPositionDTO();
+
+ /* act */
+ $position->setX(12)->setY(0)->setWidth(1)->setHeight(1);
+
+ /* assert */
+ $this->assertFalse($service->validate($position));
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_rejects_width_exceeding_grid(): void
+ {
+ /* arrange */
+ $service = new GridSnapperService(12);
+
+ $position = new GridPositionDTO();
+
+ /* act */
+ $position->setX(8)->setY(0)->setWidth(5)->setHeight(1);
+
+ /* assert */
+ $this->assertFalse($service->validate($position));
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_rejects_zero_width(): void
+ {
+ /* arrange */
+ $service = new GridSnapperService(12);
+
+ $position = new GridPositionDTO();
+
+ /* act */
+ $position->setX(0)->setY(0)->setWidth(0)->setHeight(1);
+
+ /* assert */
+ $this->assertFalse($service->validate($position));
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_rejects_zero_height(): void
+ {
+ /* arrange */
+ $service = new GridSnapperService(12);
+
+ $position = new GridPositionDTO();
+
+ /* act */
+ $position->setX(0)->setY(0)->setWidth(1)->setHeight(0);
+
+ /* assert */
+ $this->assertFalse($service->validate($position));
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_snaps_to_grid(): void
+ {
+ /* arrange */
+ $service = new GridSnapperService(12);
+
+ $position = new GridPositionDTO();
+ $position->setX(1)->setY(1)->setWidth(5)->setHeight(3);
+
+ /* act */
+ $snapped = $service->snap($position);
+
+ /* assert */
+ $this->assertInstanceOf(GridPositionDTO::class, $snapped);
+ // Verify values are within grid constraints
+ $this->assertLessThanOrEqual(12, $snapped->getX() + $snapped->getWidth());
+ $this->assertGreaterThanOrEqual(0, $snapped->getX());
+ $this->assertGreaterThanOrEqual(0, $snapped->getY());
+ $this->assertGreaterThan(0, $snapped->getWidth());
+ $this->assertGreaterThan(0, $snapped->getHeight());
+ }
+}
diff --git a/Modules/Core/Tests/Unit/MasonBricksTest.php b/Modules/Core/Tests/Unit/MasonBricksTest.php
new file mode 100644
index 000000000..bfeee4b32
--- /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('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/MasonStorageAdapterTest.php b/Modules/Core/Tests/Unit/MasonStorageAdapterTest.php
new file mode 100644
index 000000000..8fd79d6db
--- /dev/null
+++ b/Modules/Core/Tests/Unit/MasonStorageAdapterTest.php
@@ -0,0 +1,267 @@
+adapter = new MasonStorageAdapter();
+ }
+
+ #[Test]
+ public function it_converts_mason_json_to_block_dtos(): void
+ {
+ /* Arrange */
+ $masonJson = json_encode([
+ 'type' => 'doc',
+ 'content' => [
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'header_company_abc123',
+ 'config' => [
+ 'show_vat_id' => true,
+ 'show_phone' => true,
+ 'font_size' => 10,
+ ],
+ 'label' => 'Company Header',
+ ],
+ ],
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'detail_items_xyz789',
+ 'config' => [
+ 'show_description' => true,
+ 'show_quantity' => true,
+ ],
+ 'label' => 'Line Items',
+ ],
+ ],
+ ],
+ ]);
+
+ /* Act */
+ $blocks = $this->adapter->masonToBlocks($masonJson);
+
+ /* Assert */
+ $this->assertIsArray($blocks);
+ $this->assertCount(2, $blocks);
+ $this->assertInstanceOf(BlockDTO::class, $blocks['header_company_abc123']);
+ $this->assertInstanceOf(BlockDTO::class, $blocks['detail_items_xyz789']);
+ }
+
+ #[Test]
+ public function it_extracts_correct_type_from_mason_brick_id(): void
+ {
+ /* Arrange */
+ $masonJson = json_encode([
+ 'type' => 'doc',
+ 'content' => [
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'header_company_abc123',
+ 'config' => [],
+ 'label' => 'Company Header',
+ ],
+ ],
+ ],
+ ]);
+
+ /* Act */
+ $blocks = $this->adapter->masonToBlocks($masonJson);
+ $block = reset($blocks);
+
+ /* Assert */
+ $this->assertEquals('header_company', $block->getType());
+ }
+
+ #[Test]
+ public function it_preserves_config_from_mason_brick(): void
+ {
+ /* Arrange */
+ $expectedConfig = [
+ 'show_vat_id' => true,
+ 'show_phone' => false,
+ 'font_size' => 12,
+ 'text_align' => 'center',
+ ];
+
+ $masonJson = json_encode([
+ 'type' => 'doc',
+ 'content' => [
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'header_company_test',
+ 'config' => $expectedConfig,
+ 'label' => 'Test Block',
+ ],
+ ],
+ ],
+ ]);
+
+ /* Act */
+ $blocks = $this->adapter->masonToBlocks($masonJson);
+ $block = reset($blocks);
+
+ /* Assert */
+ $this->assertEquals($expectedConfig, $block->getConfig());
+ }
+
+ #[Test]
+ public function it_converts_block_dtos_to_mason_json(): void
+ {
+ /* Arrange */
+ $position = GridPositionDTO::create(0, 0, 12, 4);
+
+ $block1 = new BlockDTO();
+ $block1->setId('header_company_abc')
+ ->setType('header_company')
+ ->setPosition($position)
+ ->setConfig(['show_vat_id' => true])
+ ->setLabel('Company Header')
+ ->setIsCloneable(false)
+ ->setDataSource('company')
+ ->setIsCloned(false)
+ ->setClonedFrom(null);
+
+ $block2 = new BlockDTO();
+ $block2->setId('footer_totals_xyz')
+ ->setType('footer_totals')
+ ->setPosition($position)
+ ->setConfig(['show_tax' => true])
+ ->setLabel('Totals')
+ ->setIsCloneable(false)
+ ->setDataSource('invoice')
+ ->setIsCloned(false)
+ ->setClonedFrom(null);
+
+ /* Act */
+ $masonJson = $this->adapter->blocksToMason([$block1, $block2]);
+ $decoded = json_decode($masonJson, true);
+
+ /* Assert */
+ $this->assertIsArray($decoded);
+ $this->assertEquals('doc', $decoded['type']);
+ $this->assertCount(2, $decoded['content']);
+ $this->assertEquals('masonBrick', $decoded['content'][0]['type']);
+ $this->assertEquals('header_company_abc', $decoded['content'][0]['attrs']['id']);
+ }
+
+ #[Test]
+ public function it_returns_empty_array_for_invalid_mason_json(): void
+ {
+ /* Arrange */
+ $invalidJson = 'not valid json';
+
+ /* Act */
+ $blocks = $this->adapter->masonToBlocks($invalidJson);
+
+ /* Assert */
+ $this->assertIsArray($blocks);
+ $this->assertEmpty($blocks);
+ }
+
+ #[Test]
+ public function it_returns_empty_array_for_mason_json_without_content(): void
+ {
+ /* Arrange */
+ $masonJson = json_encode([
+ 'type' => 'doc',
+ ]);
+
+ /* Act */
+ $blocks = $this->adapter->masonToBlocks($masonJson);
+
+ /* Assert */
+ $this->assertIsArray($blocks);
+ $this->assertEmpty($blocks);
+ }
+
+ #[Test]
+ public function it_assigns_correct_data_source_based_on_type(): void
+ {
+ /* Arrange */
+ $masonJson = json_encode([
+ 'type' => 'doc',
+ 'content' => [
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'header_company_test',
+ 'config' => [],
+ 'label' => 'Company',
+ ],
+ ],
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'header_client_test',
+ 'config' => [],
+ 'label' => 'Client',
+ ],
+ ],
+ [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'id' => 'detail_items_test',
+ 'config' => [],
+ 'label' => 'Items',
+ ],
+ ],
+ ],
+ ]);
+
+ /* Act */
+ $blocks = $this->adapter->masonToBlocks($masonJson);
+
+ /* Assert */
+ $this->assertEquals('company', $blocks['header_company_test']->getDataSource());
+ $this->assertEquals('client', $blocks['header_client_test']->getDataSource());
+ $this->assertEquals('items', $blocks['detail_items_test']->getDataSource());
+ }
+
+ #[Test]
+ public function it_roundtrip_conversion_preserves_data(): void
+ {
+ /* Arrange */
+ $position = GridPositionDTO::create(0, 0, 12, 4);
+
+ $originalBlock = new BlockDTO();
+ $originalBlock->setId('header_company_test')
+ ->setType('header_company')
+ ->setPosition($position)
+ ->setConfig([
+ 'show_vat_id' => true,
+ 'font_size' => 10,
+ ])
+ ->setLabel('Company Header')
+ ->setIsCloneable(false)
+ ->setDataSource('company')
+ ->setIsCloned(false)
+ ->setClonedFrom(null);
+
+ /* Act */
+ $masonJson = $this->adapter->blocksToMason([$originalBlock]);
+ $blocks = $this->adapter->masonToBlocks($masonJson);
+ $convertedBlock = reset($blocks);
+
+ /* Assert */
+ $this->assertEquals($originalBlock->getId(), $convertedBlock->getId());
+ $this->assertEquals($originalBlock->getType(), $convertedBlock->getType());
+ $this->assertEquals($originalBlock->getConfig(), $convertedBlock->getConfig());
+ $this->assertEquals($originalBlock->getLabel(), $convertedBlock->getLabel());
+ }
+}
diff --git a/Modules/Core/Tests/Unit/ReportBlockServiceFieldsTest.php b/Modules/Core/Tests/Unit/ReportBlockServiceFieldsTest.php
new file mode 100644
index 000000000..4afa27a76
--- /dev/null
+++ b/Modules/Core/Tests/Unit/ReportBlockServiceFieldsTest.php
@@ -0,0 +1,263 @@
+service = app(ReportBlockService::class);
+ Storage::fake('local');
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_saves_block_fields_to_json_file(): void
+ {
+ /* Arrange */
+ $block = new ReportBlock();
+ $block->id = 1;
+ $block->block_type = 'test_block';
+ $block->name = 'Test Block';
+ $block->slug = 'test-block';
+ $block->filename = 'test-block';
+ $block->width = ReportBlockWidth::FULL;
+
+ $fields = [
+ ['id' => 'company_name', 'label' => 'Company Name', 'x' => 0, 'y' => 0],
+ ['id' => 'company_address', 'label' => 'Company Address', 'x' => 0, 'y' => 50],
+ ];
+
+ /* Act */
+ $this->service->saveBlockFields($block, $fields);
+
+ /* Assert */
+ Storage::disk('local')->assertExists('report_blocks/test-block.json');
+ $content = Storage::disk('local')->get('report_blocks/test-block.json');
+ $config = json_decode($content, true);
+ $this->assertArrayHasKey('fields', $config);
+ $this->assertCount(2, $config['fields']);
+ $this->assertEquals('company_name', $config['fields'][0]['id']);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_loads_block_fields_from_json_file(): void
+ {
+ /* Arrange */
+ $block = new ReportBlock();
+ $block->id = 1;
+ $block->block_type = 'test_block';
+ $block->name = 'Test Block';
+ $block->slug = 'test-block';
+ $block->filename = 'test-block';
+ $block->width = ReportBlockWidth::FULL;
+
+ $fields = [
+ ['id' => 'invoice_number', 'label' => 'Invoice Number', 'x' => 100, 'y' => 0],
+ ['id' => 'invoice_date', 'label' => 'Invoice Date', 'x' => 100, 'y' => 50],
+ ];
+
+ // Save first
+ $this->service->saveBlockFields($block, $fields);
+
+ /* Act */
+ $loadedFields = $this->service->loadBlockFields($block);
+
+ /* Assert */
+ $this->assertCount(2, $loadedFields);
+ $this->assertEquals('invoice_number', $loadedFields[0]['id']);
+ $this->assertEquals('invoice_date', $loadedFields[1]['id']);
+ $this->assertEquals(100, $loadedFields[0]['x']);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_returns_empty_array_when_json_file_does_not_exist(): void
+ {
+ /* Arrange */
+ $block = new ReportBlock();
+ $block->id = 1;
+ $block->block_type = 'nonexistent_block';
+ $block->name = 'Nonexistent Block';
+ $block->slug = 'nonexistent-block';
+ $block->filename = 'nonexistent-block';
+ $block->width = ReportBlockWidth::HALF;
+
+ /* Act */
+ $fields = $this->service->loadBlockFields($block);
+
+ /* Assert */
+ $this->assertIsArray($fields);
+ $this->assertEmpty($fields);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_creates_directory_if_not_exists_when_saving(): void
+ {
+ /* Arrange */
+ $block = new ReportBlock();
+ $block->id = 1;
+ $block->block_type = 'new_block';
+ $block->name = 'New Block';
+ $block->slug = 'new-block';
+ $block->filename = 'new-block';
+ $block->width = ReportBlockWidth::HALF;
+
+ $fields = [
+ ['id' => 'test_field', 'label' => 'Test Field', 'x' => 0, 'y' => 0],
+ ];
+
+ /* Act */
+ $this->service->saveBlockFields($block, $fields);
+
+ /* Assert */
+ Storage::disk('local')->assertExists('report_blocks');
+ Storage::disk('local')->assertExists('report_blocks/new-block.json');
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_gets_full_block_configuration_from_json(): void
+ {
+ /* Arrange */
+ $block = new ReportBlock();
+ $block->id = 1;
+ $block->block_type = 'config_block';
+ $block->name = 'Config Block';
+ $block->slug = 'config-block';
+ $block->filename = 'config-block';
+ $block->width = ReportBlockWidth::FULL;
+
+ $fields = [
+ ['id' => 'field1', 'label' => 'Field 1'],
+ ['id' => 'field2', 'label' => 'Field 2'],
+ ];
+
+ $this->service->saveBlockFields($block, $fields);
+
+ /* Act */
+ $config = $this->service->getBlockConfiguration($block);
+
+ /* Assert */
+ $this->assertIsArray($config);
+ $this->assertArrayHasKey('fields', $config);
+ $this->assertCount(2, $config['fields']);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_uses_slug_as_filename_when_filename_is_null(): void
+ {
+ /* Arrange */
+ $block = new ReportBlock();
+ $block->id = 1;
+ $block->block_type = 'slug_block';
+ $block->name = 'Slug Block';
+ $block->slug = 'slug-block';
+ $block->filename = null;
+ $block->width = ReportBlockWidth::HALF;
+
+ $fields = [
+ ['id' => 'test', 'label' => 'Test'],
+ ];
+
+ /* Act */
+ $this->service->saveBlockFields($block, $fields);
+
+ /* Assert */
+ Storage::disk('local')->assertExists('report_blocks/slug-block.json');
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_overwrites_existing_fields_when_saving(): void
+ {
+ /* Arrange */
+ $block = new ReportBlock();
+ $block->id = 1;
+ $block->block_type = 'overwrite_block';
+ $block->name = 'Overwrite Block';
+ $block->slug = 'overwrite-block';
+ $block->filename = 'overwrite-block';
+ $block->width = ReportBlockWidth::FULL;
+
+ $initialFields = [
+ ['id' => 'field1', 'label' => 'Field 1'],
+ ['id' => 'field2', 'label' => 'Field 2'],
+ ];
+
+ $updatedFields = [
+ ['id' => 'field3', 'label' => 'Field 3'],
+ ];
+
+ /* Act */
+ $this->service->saveBlockFields($block, $initialFields);
+ $this->service->saveBlockFields($block, $updatedFields);
+ $loadedFields = $this->service->loadBlockFields($block);
+
+ /* Assert */
+ $this->assertCount(1, $loadedFields);
+ $this->assertEquals('field3', $loadedFields[0]['id']);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_preserves_json_structure_when_saving_and_loading(): void
+ {
+ /* Arrange */
+ $block = new ReportBlock();
+ $block->id = 1;
+ $block->block_type = 'structure_block';
+ $block->name = 'Structure Block';
+ $block->slug = 'structure-block';
+ $block->filename = 'structure-block';
+ $block->width = ReportBlockWidth::TWO_THIRDS;
+
+ $fields = [
+ [
+ 'id' => 'complex_field',
+ 'label' => 'Complex Field',
+ 'x' => 10,
+ 'y' => 20,
+ 'width' => 200,
+ 'height' => 40,
+ 'style' => ['color' => 'red', 'fontSize' => 14],
+ ],
+ ];
+
+ /* Act */
+ $this->service->saveBlockFields($block, $fields);
+ $loadedFields = $this->service->loadBlockFields($block);
+
+ /* Assert */
+ $this->assertEquals($fields, $loadedFields);
+ $this->assertArrayHasKey('style', $loadedFields[0]);
+ $this->assertEquals('red', $loadedFields[0]['style']['color']);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+}
diff --git a/Modules/Core/Tests/Unit/ReportBlockWidthTest.php b/Modules/Core/Tests/Unit/ReportBlockWidthTest.php
new file mode 100644
index 000000000..2edf98280
--- /dev/null
+++ b/Modules/Core/Tests/Unit/ReportBlockWidthTest.php
@@ -0,0 +1,125 @@
+getGridWidth();
+
+ /* Assert */
+ $this->assertEquals('one_third', $width->value);
+ $this->assertEquals(4, $gridWidth);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_has_half_width_option(): void
+ {
+ /* Arrange */
+ $width = ReportBlockWidth::HALF;
+
+ /* Act */
+ $gridWidth = $width->getGridWidth();
+
+ /* Assert */
+ $this->assertEquals('half', $width->value);
+ $this->assertEquals(6, $gridWidth);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_has_two_thirds_width_option(): void
+ {
+ /* Arrange */
+ $width = ReportBlockWidth::TWO_THIRDS;
+
+ /* Act */
+ $gridWidth = $width->getGridWidth();
+
+ /* Assert */
+ $this->assertEquals('two_thirds', $width->value);
+ $this->assertEquals(8, $gridWidth);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_has_full_width_option(): void
+ {
+ /* Arrange */
+ $width = ReportBlockWidth::FULL;
+
+ /* Act */
+ $gridWidth = $width->getGridWidth();
+
+ /* Assert */
+ $this->assertEquals('full', $width->value);
+ $this->assertEquals(12, $gridWidth);
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_supports_all_width_values(): void
+ {
+ /* Arrange */
+ $expectedWidths = [
+ 'one_third' => 4,
+ 'half' => 6,
+ 'two_thirds' => 8,
+ 'full' => 12,
+ ];
+
+ /* Act */
+ $cases = ReportBlockWidth::cases();
+
+ /* Assert */
+ $this->assertCount(4, $cases);
+ foreach ($cases as $case) {
+ $this->assertArrayHasKey($case->value, $expectedWidths);
+ $this->assertEquals($expectedWidths[$case->value], $case->getGridWidth());
+ }
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_calculates_correct_grid_widths_for_12_column_grid(): void
+ {
+ /* Arrange */
+ $widths = [
+ ReportBlockWidth::ONE_THIRD->value => 4, // 1/3 of 12 = 4
+ ReportBlockWidth::HALF->value => 6, // 1/2 of 12 = 6
+ ReportBlockWidth::TWO_THIRDS->value => 8, // 2/3 of 12 = 8
+ ReportBlockWidth::FULL->value => 12, // 12/12 = 12
+ ];
+
+ /* Act & Assert */
+ foreach ($widths as $widthString => $expectedGrid) {
+ $width = ReportBlockWidth::from($widthString);
+ $actualGrid = $width->getGridWidth();
+ $this->assertEquals($expectedGrid, $actualGrid, "Width {$width->value} should map to {$expectedGrid} grid columns");
+ }
+ $this->markTestIncomplete('Test implementation complete but marked incomplete as per requirements');
+ }
+}
diff --git a/Modules/Core/Tests/Unit/ReportBricksCollectionTest.php b/Modules/Core/Tests/Unit/ReportBricksCollectionTest.php
new file mode 100644
index 000000000..e362c48fa
--- /dev/null
+++ b/Modules/Core/Tests/Unit/ReportBricksCollectionTest.php
@@ -0,0 +1,126 @@
+assertIsArray($bricks);
+ $this->assertCount(17, $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());
+
+ /* Act */
+ $allBricks = ReportBricksCollection::all();
+
+ /* Assert */
+ $this->assertCount($headerCount + $detailCount + $footerCount, $allBricks);
+ }
+
+ #[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/ReportTemplateFileRepositoryTest.php b/Modules/Core/Tests/Unit/ReportTemplateFileRepositoryTest.php
new file mode 100644
index 000000000..0cff1f1d8
--- /dev/null
+++ b/Modules/Core/Tests/Unit/ReportTemplateFileRepositoryTest.php
@@ -0,0 +1,239 @@
+repository = new ReportTemplateFileRepository();
+
+ // Ensure clean state before each test
+ Storage::fake('report_templates');
+ }
+
+ #[Test]
+ public function it_save_creates_template_file(): void
+ {
+ /* arrange */
+ $companyId = 1;
+ $templateSlug = 'professional_invoice';
+ $blocksArray = [
+ [
+ 'id' => 'block_1',
+ 'type' => 'company_header',
+ 'position' => ['x' => 0, 'y' => 0, 'width' => 6, 'height' => 4],
+ 'config' => ['show_vat_id' => true, 'show_phone' => true],
+ 'is_cloned' => false,
+ 'cloned_from' => null,
+ ],
+ ];
+
+ /* act */
+ $this->repository->save($companyId, $templateSlug, $blocksArray);
+
+ /* assert */
+ Storage::disk('report_templates')->assertExists("{$companyId}/{$templateSlug}.json");
+ }
+
+ #[Test]
+ public function it_get_returns_blocks_array(): void
+ {
+ /* arrange */
+ $companyId = 1;
+ $templateSlug = 'minimal_invoice';
+ $blocksArray = [
+ [
+ 'id' => 'block_header',
+ 'type' => 'company_header',
+ 'position' => ['x' => 0, 'y' => 0, 'width' => 12, 'height' => 2],
+ 'config' => ['font_size' => 10],
+ 'is_cloned' => false,
+ 'cloned_from' => null,
+ ],
+ ];
+ $this->repository->save($companyId, $templateSlug, $blocksArray);
+
+ /* act */
+ $result = $this->repository->get($companyId, $templateSlug);
+
+ /* assert */
+ $this->assertEquals($blocksArray, $result);
+ }
+
+ #[Test]
+ public function it_get_returns_empty_array_when_template_not_exists(): void
+ {
+ /* arrange */
+ // No setup needed
+
+ /* act */
+ $result = $this->repository->get(999, 'non_existent_template');
+
+ /* assert */
+ $this->assertEquals([], $result);
+ }
+
+ #[Test]
+ public function it_exists_returns_true_when_template_exists(): void
+ {
+ /* arrange */
+ $companyId = 1;
+ $templateSlug = 'payment_history_report';
+ $blocksArray = [
+ [
+ 'id' => 'block_1',
+ 'type' => 'table',
+ 'position' => ['x' => 0, 'y' => 0, 'width' => 12, 'height' => 8],
+ 'config' => [],
+ 'is_cloned' => false,
+ 'cloned_from' => null,
+ ],
+ ];
+ $this->repository->save($companyId, $templateSlug, $blocksArray);
+
+ /* act */
+ $result = $this->repository->exists($companyId, $templateSlug);
+
+ /* assert */
+ $this->assertTrue($result);
+ }
+
+ #[Test]
+ public function it_exists_returns_false_when_template_not_exists(): void
+ {
+ /* arrange */
+ // No setup needed
+
+ /* act */
+ $result = $this->repository->exists(1, 'non_existent_template');
+
+ /* assert */
+ $this->assertFalse($result);
+ }
+
+ #[Test]
+ public function it_delete_removes_template_file(): void
+ {
+ /* arrange */
+ $companyId = 1;
+ $templateSlug = 'invoice_aging_report';
+ $blocksArray = [
+ [
+ 'id' => 'block_1',
+ 'type' => 'chart',
+ 'position' => ['x' => 0, 'y' => 0, 'width' => 6, 'height' => 6],
+ 'config' => ['chart_type' => 'bar'],
+ 'is_cloned' => false,
+ 'cloned_from' => null,
+ ],
+ ];
+ $this->repository->save($companyId, $templateSlug, $blocksArray);
+
+ /* act */
+ $result = $this->repository->delete($companyId, $templateSlug);
+
+ /* assert */
+ $this->assertTrue($result);
+ $this->assertFalse($this->repository->exists($companyId, $templateSlug));
+ }
+
+ #[Test]
+ public function it_delete_returns_false_when_template_not_exists(): void
+ {
+ /* arrange */
+ // No setup needed
+
+ /* act */
+ $result = $this->repository->delete(1, 'non_existent_template');
+
+ /* assert */
+ $this->assertFalse($result);
+ }
+
+ #[Test]
+ public function it_all_returns_template_slugs_for_company(): void
+ {
+ /* arrange */
+ $companyId = 1;
+ $this->repository->save($companyId, 'professional_invoice', []);
+ $this->repository->save($companyId, 'payment_history_report', []);
+ $this->repository->save($companyId, 'invoice_aging_report', []);
+
+ /* act */
+ $result = $this->repository->all($companyId);
+
+ /* assert */
+ $this->assertCount(3, $result);
+ $this->assertContains('professional_invoice', $result);
+ $this->assertContains('payment_history_report', $result);
+ $this->assertContains('invoice_aging_report', $result);
+ }
+
+ #[Test]
+ public function it_all_returns_empty_array_when_no_templates_exist(): void
+ {
+ /* arrange */
+ // No setup needed
+
+ /* act */
+ $result = $this->repository->all(999);
+
+ /* assert */
+ $this->assertEquals([], $result);
+ }
+
+ #[Test]
+ public function it_all_returns_only_templates_for_specific_company(): void
+ {
+ /* arrange */
+ $this->repository->save(1, 'template_company_1', []);
+ $this->repository->save(2, 'template_company_2', []);
+
+ /* act */
+ $resultCompany1 = $this->repository->all(1);
+ $resultCompany2 = $this->repository->all(2);
+
+ /* assert */
+ $this->assertCount(1, $resultCompany1);
+ $this->assertContains('template_company_1', $resultCompany1);
+ $this->assertCount(1, $resultCompany2);
+ $this->assertContains('template_company_2', $resultCompany2);
+ }
+
+ #[Test]
+ public function it_handles_grouped_blocks(): void
+ {
+ /* Arrange */
+ $groupedData = [
+ 'header' => [
+ ['id' => 'block1', 'band' => 'header', 'type' => 'test'],
+ ],
+ 'details' => [
+ ['id' => 'block2', 'band' => 'details', 'type' => 'test'],
+ ],
+ ];
+
+ Storage::disk('report_templates')->put(
+ '1/grouped.json',
+ json_encode($groupedData)
+ );
+
+ /* Act */
+ $blocks = $this->repository->get(1, 'grouped');
+
+ /* Assert */
+ $this->assertCount(2, $blocks);
+ $this->assertEquals('block1', $blocks[0]['id']);
+ $this->assertEquals('block2', $blocks[1]['id']);
+ }
+}
diff --git a/Modules/Core/Tests/Unit/ReportTemplateServiceTest.php b/Modules/Core/Tests/Unit/ReportTemplateServiceTest.php
new file mode 100644
index 000000000..f07a0cc13
--- /dev/null
+++ b/Modules/Core/Tests/Unit/ReportTemplateServiceTest.php
@@ -0,0 +1,262 @@
+fileRepository = new ReportTemplateFileRepository();
+ $this->gridSnapper = new GridSnapperService(12);
+ $this->service = new ReportTemplateService($this->fileRepository, $this->gridSnapper);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_creates_template(): void
+ {
+ /* arrange */
+ $company = new stdClass();
+ $company->id = 1;
+ $blocks = [];
+
+ /* act */
+ /** @phpstan-ignore-next-line */
+ $template = $this->service->createTemplate($company, 'Test Template', 'invoice', $blocks);
+
+ /* assert */
+ $this->assertInstanceOf(ReportTemplate::class, $template);
+ $this->assertEquals('Test Template', $template->name);
+ $this->assertEquals('invoice', $template->template_type);
+ $this->assertEquals(1, $template->company_id);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_validates_blocks_require_id(): void
+ {
+ /* arrange */
+ // No setup needed
+
+ /* assert */
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage("must have an 'id'");
+ $blocks = [
+ [
+ 'type' => 'company_header',
+ 'position' => ['x' => 0, 'y' => 0, 'width' => 6, 'height' => 4],
+ 'config' => [],
+ ],
+ ];
+ $this->service->validateBlocks($blocks);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_validates_blocks_require_type(): void
+ {
+ /* arrange */
+ // No setup needed
+
+ /* assert */
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage("must have a 'type'");
+ $blocks = [
+ [
+ 'id' => 'block_1',
+ 'position' => ['x' => 0, 'y' => 0, 'width' => 6, 'height' => 4],
+ 'config' => [],
+ ],
+ ];
+ $this->service->validateBlocks($blocks);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_validates_blocks_require_position(): void
+ {
+ /* arrange */
+ // No setup needed
+
+ /* assert */
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage("must have a 'position' array");
+ $blocks = [
+ [
+ 'id' => 'block_1',
+ 'type' => 'company_header',
+ 'config' => [],
+ ],
+ ];
+ $this->service->validateBlocks($blocks);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_validates_position_has_required_fields(): void
+ {
+ /* arrange */
+ // No setup needed
+
+ /* assert */
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('position must have x, y, width, and height');
+ $blocks = [
+ [
+ 'id' => 'block_1',
+ 'type' => 'company_header',
+ 'position' => ['x' => 0, 'y' => 0],
+ 'config' => [],
+ ],
+ ];
+ $this->service->validateBlocks($blocks);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_validates_position_is_valid(): void
+ {
+ /* arrange */
+ // No setup needed
+
+ /* assert */
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('has invalid position');
+ $blocks = [
+ [
+ 'id' => 'block_1',
+ 'type' => 'company_header',
+ 'position' => ['x' => -1, 'y' => 0, 'width' => 6, 'height' => 4],
+ 'config' => [],
+ ],
+ ];
+ $this->service->validateBlocks($blocks);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_clones_system_block(): void
+ {
+ /* arrange */
+ $position = new GridPositionDTO();
+
+ /* act */
+ $position->setX(6)->setY(0)->setWidth(6)->setHeight(4);
+
+ $cloned = $this->service->cloneSystemBlock('company_header', 'block_cloned', $position);
+
+ /* assert */
+ $this->assertInstanceOf(BlockDTO::class, $cloned);
+ $this->assertEquals('block_cloned', $cloned->getId());
+ $this->assertEquals('company_header', $cloned->getType());
+ $this->assertTrue($cloned->getIsCloned());
+ $this->assertEquals(6, $cloned->getPosition()->getX());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_throws_exception_for_invalid_system_block_type(): void
+ {
+ /* arrange */
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage("System block type 'invalid_type' not found");
+
+ $position = new GridPositionDTO();
+
+ /* act */
+ $position->setX(0)->setY(0)->setWidth(6)->setHeight(4);
+
+ /* assert */
+ $this->service->cloneSystemBlock('invalid_type', 'block_cloned', $position);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_persists_blocks(): void
+ {
+ /* arrange */
+ $template = new ReportTemplate();
+ $template->company_id = 1;
+ $template->slug = 'test-template';
+
+ $position = new GridPositionDTO();
+ $position->setX(0)->setY(0)->setWidth(6)->setHeight(4);
+
+ $block = new BlockDTO();
+ $block->setId('block_1')
+ ->setType('company_header')
+ ->setPosition($position)
+ ->setConfig([])
+ ->setIsCloneable(true)
+ ->setIsCloned(false);
+
+ /* act */
+ $this->service->persistBlocks($template, [$block]);
+
+ /* assert */
+ Storage::disk('report_templates')->assertExists('1/test-template.json');
+ $content = Storage::disk('report_templates')->get('1/test-template.json');
+ $data = json_decode($content, true);
+
+ $this->assertIsArray($data);
+ $this->assertCount(1, $data);
+ $this->assertEquals('block_1', $data[0]['id']);
+ $this->assertEquals('company_header', $data[0]['type']);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_loads_blocks(): void
+ {
+ /* arrange */
+ $template = new ReportTemplate();
+ $template->company_id = 1;
+ $template->slug = 'test-template';
+
+ $fileData = [
+ [
+ 'id' => 'block_1',
+ 'type' => 'company_header',
+ 'position' => ['x' => 0, 'y' => 0, 'width' => 6, 'height' => 4],
+ 'config' => [],
+ ],
+ ];
+
+ Storage::disk('report_templates')->put(
+ '1/test-template.json',
+ json_encode($fileData, JSON_PRETTY_PRINT)
+ );
+
+ /* act */
+ $blocks = $this->service->loadBlocks($template);
+
+ /* assert */
+ $this->assertIsArray($blocks);
+ $this->assertCount(1, $blocks);
+ $this->assertInstanceOf(BlockDTO::class, $blocks[0]);
+ $this->assertEquals('block_1', $blocks[0]->getId());
+ }
+}
diff --git a/Modules/Core/Tests/Unit/ReportTemplateTest.php b/Modules/Core/Tests/Unit/ReportTemplateTest.php
new file mode 100644
index 000000000..504998536
--- /dev/null
+++ b/Modules/Core/Tests/Unit/ReportTemplateTest.php
@@ -0,0 +1,237 @@
+create(['name' => 'Test Company']);
+ $this->company = $company;
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_can_create_a_report_template(): void
+ {
+ /* arrange */
+ // No setup needed
+
+ /* act */
+ $template = ReportTemplate::create([
+ 'company_id' => $this->company->id,
+ 'name' => 'Professional Invoice',
+ 'slug' => 'professional_invoice',
+ 'template_type' => 'invoice',
+ 'is_system' => false,
+ 'is_active' => true,
+ ]);
+
+ /* assert */
+ $this->assertDatabaseHas('report_templates', [
+ 'company_id' => $this->company->id,
+ 'name' => 'Professional Invoice',
+ 'slug' => 'professional_invoice',
+ 'template_type' => 'invoice',
+ ]);
+ $this->assertEquals('Professional Invoice', $template->name);
+ $this->assertEquals('professional_invoice', $template->slug);
+ $this->assertFalse($template->is_system);
+ $this->assertTrue($template->is_active);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_casts_boolean_fields_correctly(): void
+ {
+ /* arrange */
+ // No setup needed
+
+ /* act */
+ $template = ReportTemplate::create([
+ 'company_id' => $this->company->id,
+ 'name' => 'System Template',
+ 'slug' => 'system_template',
+ 'template_type' => 'invoice',
+ 'is_system' => true,
+ 'is_active' => false,
+ ]);
+
+ /* assert */
+ $this->assertTrue($template->is_system);
+ $this->assertFalse($template->is_active);
+ $this->assertIsBool($template->is_system);
+ $this->assertIsBool($template->is_active);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function it_belongs_to_a_company(): void
+ {
+ /* arrange */
+ // No setup needed
+
+ /* act */
+ $template = ReportTemplate::create([
+ 'company_id' => $this->company->id,
+ 'name' => 'Test Template',
+ 'slug' => 'test_template',
+ 'template_type' => 'invoice',
+ 'is_system' => false,
+ 'is_active' => true,
+ ]);
+
+ /* assert */
+ $this->assertInstanceOf(Company::class, $template->company);
+ $this->assertEquals($this->company->id, $template->company->id);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function is_cloneable_returns_true_when_active(): void
+ {
+ $template = ReportTemplate::create([
+ 'company_id' => $this->company->id,
+ 'name' => 'Active Template',
+ 'slug' => 'active_template',
+ 'template_type' => 'invoice',
+ 'is_system' => false,
+ 'is_active' => true,
+ ]);
+
+ $this->assertTrue($template->isCloneable());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function is_cloneable_returns_false_when_inactive(): void
+ {
+ $template = ReportTemplate::create([
+ 'company_id' => $this->company->id,
+ 'name' => 'Inactive Template',
+ 'slug' => 'inactive_template',
+ 'template_type' => 'invoice',
+ 'is_system' => false,
+ 'is_active' => false,
+ ]);
+
+ $this->assertFalse($template->isCloneable());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function is_system_returns_true_for_system_templates(): void
+ {
+ $template = ReportTemplate::create([
+ 'company_id' => $this->company->id,
+ 'name' => 'System Template',
+ 'slug' => 'system_template',
+ 'template_type' => 'invoice',
+ 'is_system' => true,
+ 'is_active' => true,
+ ]);
+
+ $this->assertTrue($template->isSystem());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function is_system_returns_false_for_user_templates(): void
+ {
+ $template = ReportTemplate::create([
+ 'company_id' => $this->company->id,
+ 'name' => 'User Template',
+ 'slug' => 'user_template',
+ 'template_type' => 'invoice',
+ 'is_system' => false,
+ 'is_active' => true,
+ ]);
+
+ $this->assertFalse($template->isSystem());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function get_file_path_returns_correct_path(): void
+ {
+ $template = ReportTemplate::create([
+ 'company_id' => $this->company->id,
+ 'name' => 'Test Template',
+ 'slug' => 'test_template',
+ 'template_type' => 'invoice',
+ 'is_system' => false,
+ 'is_active' => true,
+ ]);
+
+ $expectedPath = "{$this->company->id}/test_template.json";
+ $this->assertEquals($expectedPath, $template->getFilePath());
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function slug_must_be_unique_within_company(): void
+ {
+ ReportTemplate::create([
+ 'company_id' => $this->company->id,
+ 'name' => 'Template 1',
+ 'slug' => 'unique_slug',
+ 'template_type' => 'invoice',
+ 'is_system' => false,
+ 'is_active' => true,
+ ]);
+
+ $this->expectException(\Illuminate\Database\QueryException::class);
+
+ ReportTemplate::create([
+ 'company_id' => $this->company->id,
+ 'name' => 'Template 2',
+ 'slug' => 'unique_slug',
+ 'template_type' => 'invoice',
+ 'is_system' => false,
+ 'is_active' => true,
+ ]);
+ }
+
+ #[Test]
+ #[Group('unit')]
+ public function same_slug_can_exist_in_different_companies(): void
+ {
+ $company2 = Company::factory()->create(['name' => 'Company 2']);
+
+ $template1 = ReportTemplate::create([
+ 'company_id' => $this->company->id,
+ 'name' => 'Template 1',
+ 'slug' => 'shared_slug',
+ 'template_type' => 'invoice',
+ 'is_system' => false,
+ 'is_active' => true,
+ ]);
+
+ $template2 = ReportTemplate::create([
+ 'company_id' => $company2->id,
+ 'name' => 'Template 2',
+ 'slug' => 'shared_slug',
+ 'template_type' => 'invoice',
+ 'is_system' => false,
+ 'is_active' => true,
+ ]);
+
+ $this->assertEquals('shared_slug', $template1->slug);
+ $this->assertEquals('shared_slug', $template2->slug);
+ $this->assertNotEquals($template1->company_id, $template2->company_id);
+ }
+}
diff --git a/Modules/Core/Transformers/BlockTransformer.php b/Modules/Core/Transformers/BlockTransformer.php
new file mode 100644
index 000000000..59ad81910
--- /dev/null
+++ b/Modules/Core/Transformers/BlockTransformer.php
@@ -0,0 +1,120 @@
+setId($blockData['id'] ?? '')
+ ->setType($blockData['type'] ?? '')
+ ->setSlug($blockData['slug'] ?? null)
+ ->setPosition($position)
+ ->setConfig($blockData['config'] ?? [])
+ ->setLabel($blockData['label'] ?? null)
+ ->setIsCloneable($blockData['isCloneable'] ?? false)
+ ->setDataSource($blockData['dataSource'] ?? null)
+ ->setBand($blockData['band'] ?? 'header')
+ ->setIsCloned($blockData['isCloned'] ?? false)
+ ->setClonedFrom($blockData['clonedFrom'] ?? null);
+
+ return $dto;
+ }
+
+ /**
+ * Convert BlockDTO to array.
+ */
+ public static function toArray(BlockDTO $dto): array
+ {
+ $position = $dto->getPosition();
+
+ return [
+ 'id' => $dto->getId(),
+ 'type' => $dto->getType(),
+ 'slug' => $dto->getSlug(),
+ 'position' => $position ? [
+ 'x' => $position->getX(),
+ 'y' => $position->getY(),
+ 'width' => $position->getWidth(),
+ 'height' => $position->getHeight(),
+ ] : null,
+ 'config' => $dto->getConfig(),
+ 'label' => $dto->getLabel(),
+ 'isCloneable' => $dto->getIsCloneable(),
+ 'dataSource' => $dto->getDataSource(),
+ 'band' => $dto->getBand(),
+ 'isCloned' => $dto->getIsCloned(),
+ 'clonedFrom' => $dto->getClonedFrom(),
+ ];
+ }
+
+ /**
+ * Convert BlockDTO to JSON string.
+ */
+ public static function toJson(BlockDTO $dto, bool $pretty = true): string
+ {
+ $array = self::toArray($dto);
+ $flags = JSON_UNESCAPED_SLASHES;
+
+ if ($pretty) {
+ $flags |= JSON_PRETTY_PRINT;
+ }
+
+ return json_encode($array, $flags);
+ }
+
+ /**
+ * Convert array of block data to array of BlockDTOs.
+ */
+ public static function toArrayCollection(array $blocks): array
+ {
+ return array_map(fn ($blockData) => self::toDTO($blockData), $blocks);
+ }
+}
diff --git a/Modules/Core/resources/views/filament/admin/resources/report-blocks/fields-canvas.blade.php b/Modules/Core/resources/views/filament/admin/resources/report-blocks/fields-canvas.blade.php
new file mode 100644
index 000000000..930fc3c80
--- /dev/null
+++ b/Modules/Core/resources/views/filament/admin/resources/report-blocks/fields-canvas.blade.php
@@ -0,0 +1,124 @@
+@php
+ use Modules\Core\Services\ReportFieldService;
+
+ // Load available fields from service
+ $fieldService = app(ReportFieldService::class);
+ $availableFields = $fieldService->getAvailableFields();
+@endphp
+
+
+ {{-- Hidden input to store fields_canvas data for form submission --}}
+
+
+
+ {{-- Available Fields Sidebar --}}
+
+
+
@lang('ip.available_fields')
+
+ Drag fields to the canvas to configure block layout
+
+
+ @foreach ($availableFields as $field)
+
+
{{ $field['label'] }}
+
Source: {{ $field['source'] ?? 'Custom' }}
+
+ @endforeach
+
+
+
+
+ {{-- Canvas Area --}}
+
+
+
+
+
+
Drag fields here to configure block layout
+
+
+
+
+
+
+ Fields will be saved to the block configuration when you save the block.
+
+
+
+
+
+
+
diff --git a/app/Mason/Bricks/DetailCustomerAgingBrick.php b/app/Mason/Bricks/DetailCustomerAgingBrick.php
new file mode 100644
index 000000000..bf716ef72
--- /dev/null
+++ b/app/Mason/Bricks/DetailCustomerAgingBrick.php
@@ -0,0 +1,125 @@
+');
+ }
+
+ 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): ?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),
+ ])
+ ->action(function (array $arguments, array $data, \Awcodes\Mason\Mason $component) {
+ $brick = $component->getBrick($arguments['id']);
+
+ if (blank($brick)) {
+ return;
+ }
+
+ $brickContent = [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'config' => $data,
+ 'id' => $arguments['id'],
+ 'label' => $brick::getPreviewLabel($data),
+ 'preview' => base64_encode($brick::toPreviewHtml($data)),
+ ],
+ ];
+
+ $component->runCommands([
+ \Awcodes\Mason\Actions\EditorCommand::make(
+ 'insertContentAt',
+ arguments: [
+ $arguments['dragPosition'],
+ $brickContent,
+ ],
+ ),
+ ]);
+ });
+ }
+}
diff --git a/app/Mason/Bricks/DetailExpenseBrick.php b/app/Mason/Bricks/DetailExpenseBrick.php
new file mode 100644
index 000000000..dc174726a
--- /dev/null
+++ b/app/Mason/Bricks/DetailExpenseBrick.php
@@ -0,0 +1,116 @@
+');
+ }
+
+ 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): ?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),
+ ])
+ ->action(function (array $arguments, array $data, \Awcodes\Mason\Mason $component) {
+ $brick = $component->getBrick($arguments['id']);
+
+ if (blank($brick)) {
+ return;
+ }
+
+ $brickContent = [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'config' => $data,
+ 'id' => $arguments['id'],
+ 'label' => $brick::getPreviewLabel($data),
+ 'preview' => base64_encode($brick::toPreviewHtml($data)),
+ ],
+ ];
+
+ $component->runCommands([
+ \Awcodes\Mason\Actions\EditorCommand::make(
+ 'insertContentAt',
+ arguments: [
+ $arguments['dragPosition'],
+ $brickContent,
+ ],
+ ),
+ ]);
+ });
+ }
+}
diff --git a/app/Mason/Bricks/DetailInvoiceProductBrick.php b/app/Mason/Bricks/DetailInvoiceProductBrick.php
new file mode 100644
index 000000000..334ff7b72
--- /dev/null
+++ b/app/Mason/Bricks/DetailInvoiceProductBrick.php
@@ -0,0 +1,116 @@
+');
+ }
+
+ 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): ?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),
+ ])
+ ->action(function (array $arguments, array $data, \Awcodes\Mason\Mason $component) {
+ $brick = $component->getBrick($arguments['id']);
+
+ if (blank($brick)) {
+ return;
+ }
+
+ $brickContent = [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'config' => $data,
+ 'id' => $arguments['id'],
+ 'label' => $brick::getPreviewLabel($data),
+ 'preview' => base64_encode($brick::toPreviewHtml($data)),
+ ],
+ ];
+
+ $component->runCommands([
+ \Awcodes\Mason\Actions\EditorCommand::make(
+ 'insertContentAt',
+ arguments: [
+ $arguments['dragPosition'],
+ $brickContent,
+ ],
+ ),
+ ]);
+ });
+ }
+}
diff --git a/app/Mason/Bricks/DetailInvoiceProjectBrick.php b/app/Mason/Bricks/DetailInvoiceProjectBrick.php
new file mode 100644
index 000000000..ec67931c1
--- /dev/null
+++ b/app/Mason/Bricks/DetailInvoiceProjectBrick.php
@@ -0,0 +1,116 @@
+');
+ }
+
+ 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): ?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),
+ ])
+ ->action(function (array $arguments, array $data, \Awcodes\Mason\Mason $component) {
+ $brick = $component->getBrick($arguments['id']);
+
+ if (blank($brick)) {
+ return;
+ }
+
+ $brickContent = [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'config' => $data,
+ 'id' => $arguments['id'],
+ 'label' => $brick::getPreviewLabel($data),
+ 'preview' => base64_encode($brick::toPreviewHtml($data)),
+ ],
+ ];
+
+ $component->runCommands([
+ \Awcodes\Mason\Actions\EditorCommand::make(
+ 'insertContentAt',
+ arguments: [
+ $arguments['dragPosition'],
+ $brickContent,
+ ],
+ ),
+ ]);
+ });
+ }
+}
diff --git a/app/Mason/Bricks/DetailItemsBrick.php b/app/Mason/Bricks/DetailItemsBrick.php
new file mode 100644
index 000000000..21d83b2ba
--- /dev/null
+++ b/app/Mason/Bricks/DetailItemsBrick.php
@@ -0,0 +1,110 @@
+');
+ }
+
+ 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): ?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),
+ ])
+ ->action(function (array $arguments, array $data, \Awcodes\Mason\Mason $component) {
+ $brick = $component->getBrick($arguments['id']);
+
+ if (blank($brick)) {
+ return;
+ }
+
+ $brickContent = [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'config' => $data,
+ 'id' => $arguments['id'],
+ 'label' => $brick::getPreviewLabel($data),
+ 'preview' => base64_encode($brick::toPreviewHtml($data)),
+ ],
+ ];
+
+ $component->runCommands([
+ \Awcodes\Mason\Actions\EditorCommand::make(
+ 'insertContentAt',
+ arguments: [
+ $arguments['dragPosition'],
+ $brickContent,
+ ],
+ ),
+ ]);
+ });
+ }
+}
diff --git a/app/Mason/Bricks/DetailQuoteProductBrick.php b/app/Mason/Bricks/DetailQuoteProductBrick.php
new file mode 100644
index 000000000..3046d6538
--- /dev/null
+++ b/app/Mason/Bricks/DetailQuoteProductBrick.php
@@ -0,0 +1,116 @@
+');
+ }
+
+ 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): ?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),
+ ])
+ ->action(function (array $arguments, array $data, \Awcodes\Mason\Mason $component) {
+ $brick = $component->getBrick($arguments['id']);
+
+ if (blank($brick)) {
+ return;
+ }
+
+ $brickContent = [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'config' => $data,
+ 'id' => $arguments['id'],
+ 'label' => $brick::getPreviewLabel($data),
+ 'preview' => base64_encode($brick::toPreviewHtml($data)),
+ ],
+ ];
+
+ $component->runCommands([
+ \Awcodes\Mason\Actions\EditorCommand::make(
+ 'insertContentAt',
+ arguments: [
+ $arguments['dragPosition'],
+ $brickContent,
+ ],
+ ),
+ ]);
+ });
+ }
+}
diff --git a/app/Mason/Bricks/DetailQuoteProjectBrick.php b/app/Mason/Bricks/DetailQuoteProjectBrick.php
new file mode 100644
index 000000000..c5a943c87
--- /dev/null
+++ b/app/Mason/Bricks/DetailQuoteProjectBrick.php
@@ -0,0 +1,116 @@
+');
+ }
+
+ 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): ?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),
+ ])
+ ->action(function (array $arguments, array $data, \Awcodes\Mason\Mason $component) {
+ $brick = $component->getBrick($arguments['id']);
+
+ if (blank($brick)) {
+ return;
+ }
+
+ $brickContent = [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'config' => $data,
+ 'id' => $arguments['id'],
+ 'label' => $brick::getPreviewLabel($data),
+ 'preview' => base64_encode($brick::toPreviewHtml($data)),
+ ],
+ ];
+
+ $component->runCommands([
+ \Awcodes\Mason\Actions\EditorCommand::make(
+ 'insertContentAt',
+ arguments: [
+ $arguments['dragPosition'],
+ $brickContent,
+ ],
+ ),
+ ]);
+ });
+ }
+}
diff --git a/app/Mason/Bricks/DetailTasksBrick.php b/app/Mason/Bricks/DetailTasksBrick.php
new file mode 100644
index 000000000..95e1c9dca
--- /dev/null
+++ b/app/Mason/Bricks/DetailTasksBrick.php
@@ -0,0 +1,119 @@
+');
+ }
+
+ 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): ?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'),
+ ])
+ ->action(function (array $arguments, array $data, \Awcodes\Mason\Mason $component) {
+ $brick = $component->getBrick($arguments['id']);
+
+ if (blank($brick)) {
+ return;
+ }
+
+ $brickContent = [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'config' => $data,
+ 'id' => $arguments['id'],
+ 'label' => $brick::getPreviewLabel($data),
+ 'preview' => base64_encode($brick::toPreviewHtml($data)),
+ ],
+ ];
+
+ $component->runCommands([
+ \Awcodes\Mason\Actions\EditorCommand::make(
+ 'insertContentAt',
+ arguments: [
+ $arguments['dragPosition'],
+ $brickContent,
+ ],
+ ),
+ ]);
+ });
+ }
+}
diff --git a/app/Mason/Bricks/FooterNotesBrick.php b/app/Mason/Bricks/FooterNotesBrick.php
new file mode 100644
index 000000000..f2ee943ae
--- /dev/null
+++ b/app/Mason/Bricks/FooterNotesBrick.php
@@ -0,0 +1,102 @@
+');
+ }
+
+ 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): ?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),
+ ])
+ ->action(function (array $arguments, array $data, \Awcodes\Mason\Mason $component) {
+ $brick = $component->getBrick($arguments['id']);
+
+ if (blank($brick)) {
+ return;
+ }
+
+ $brickContent = [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'config' => $data,
+ 'id' => $arguments['id'],
+ 'label' => $brick::getPreviewLabel($data),
+ 'preview' => base64_encode($brick::toPreviewHtml($data)),
+ ],
+ ];
+
+ $component->runCommands([
+ \Awcodes\Mason\Actions\EditorCommand::make(
+ 'insertContentAt',
+ arguments: [
+ $arguments['dragPosition'],
+ $brickContent,
+ ],
+ ),
+ ]);
+ });
+ }
+}
diff --git a/app/Mason/Bricks/FooterSummaryBrick.php b/app/Mason/Bricks/FooterSummaryBrick.php
new file mode 100644
index 000000000..1ccf7d289
--- /dev/null
+++ b/app/Mason/Bricks/FooterSummaryBrick.php
@@ -0,0 +1,102 @@
+');
+ }
+
+ 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): ?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),
+ ])
+ ->action(function (array $arguments, array $data, \Awcodes\Mason\Mason $component) {
+ $brick = $component->getBrick($arguments['id']);
+
+ if (blank($brick)) {
+ return;
+ }
+
+ $brickContent = [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'config' => $data,
+ 'id' => $arguments['id'],
+ 'label' => $brick::getPreviewLabel($data),
+ 'preview' => base64_encode($brick::toPreviewHtml($data)),
+ ],
+ ];
+
+ $component->runCommands([
+ \Awcodes\Mason\Actions\EditorCommand::make(
+ 'insertContentAt',
+ arguments: [
+ $arguments['dragPosition'],
+ $brickContent,
+ ],
+ ),
+ ]);
+ });
+ }
+}
diff --git a/app/Mason/Bricks/FooterTermsBrick.php b/app/Mason/Bricks/FooterTermsBrick.php
new file mode 100644
index 000000000..6f4537843
--- /dev/null
+++ b/app/Mason/Bricks/FooterTermsBrick.php
@@ -0,0 +1,102 @@
+');
+ }
+
+ 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): ?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),
+ ])
+ ->action(function (array $arguments, array $data, \Awcodes\Mason\Mason $component) {
+ $brick = $component->getBrick($arguments['id']);
+
+ if (blank($brick)) {
+ return;
+ }
+
+ $brickContent = [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'config' => $data,
+ 'id' => $arguments['id'],
+ 'label' => $brick::getPreviewLabel($data),
+ 'preview' => base64_encode($brick::toPreviewHtml($data)),
+ ],
+ ];
+
+ $component->runCommands([
+ \Awcodes\Mason\Actions\EditorCommand::make(
+ 'insertContentAt',
+ arguments: [
+ $arguments['dragPosition'],
+ $brickContent,
+ ],
+ ),
+ ]);
+ });
+ }
+}
diff --git a/app/Mason/Bricks/FooterTotalsBrick.php b/app/Mason/Bricks/FooterTotalsBrick.php
new file mode 100644
index 000000000..bc9d140de
--- /dev/null
+++ b/app/Mason/Bricks/FooterTotalsBrick.php
@@ -0,0 +1,119 @@
+');
+ }
+
+ 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): ?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'),
+ ])
+ ->action(function (array $arguments, array $data, \Awcodes\Mason\Mason $component) {
+ $brick = $component->getBrick($arguments['id']);
+
+ if (blank($brick)) {
+ return;
+ }
+
+ $brickContent = [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'config' => $data,
+ 'id' => $arguments['id'],
+ 'label' => $brick::getPreviewLabel($data),
+ 'preview' => base64_encode($brick::toPreviewHtml($data)),
+ ],
+ ];
+
+ $component->runCommands([
+ \Awcodes\Mason\Actions\EditorCommand::make(
+ 'insertContentAt',
+ arguments: [
+ $arguments['dragPosition'],
+ $brickContent,
+ ],
+ ),
+ ]);
+ });
+ }
+}
diff --git a/app/Mason/Bricks/HeaderClientBrick.php b/app/Mason/Bricks/HeaderClientBrick.php
new file mode 100644
index 000000000..935829398
--- /dev/null
+++ b/app/Mason/Bricks/HeaderClientBrick.php
@@ -0,0 +1,110 @@
+');
+ }
+
+ 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): ?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'),
+ ])
+ ->action(function (array $arguments, array $data, \Awcodes\Mason\Mason $component) {
+ $brick = $component->getBrick($arguments['id']);
+
+ if (blank($brick)) {
+ return;
+ }
+
+ $brickContent = [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'config' => $data,
+ 'id' => $arguments['id'],
+ 'label' => $brick::getPreviewLabel($data),
+ 'preview' => base64_encode($brick::toPreviewHtml($data)),
+ ],
+ ];
+
+ $component->runCommands([
+ \Awcodes\Mason\Actions\EditorCommand::make(
+ 'insertContentAt',
+ arguments: [
+ $arguments['dragPosition'],
+ $brickContent,
+ ],
+ ),
+ ]);
+ });
+ }
+}
diff --git a/app/Mason/Bricks/HeaderCompanyBrick.php b/app/Mason/Bricks/HeaderCompanyBrick.php
new file mode 100644
index 000000000..036b2892c
--- /dev/null
+++ b/app/Mason/Bricks/HeaderCompanyBrick.php
@@ -0,0 +1,120 @@
+');
+ }
+
+ 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): ?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'),
+ ])
+ ->action(function (array $arguments, array $data, \Awcodes\Mason\Mason $component) {
+ $brick = $component->getBrick($arguments['id']);
+
+ if (blank($brick)) {
+ return;
+ }
+
+ $brickContent = [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'config' => $data,
+ 'id' => $arguments['id'],
+ 'label' => $brick::getPreviewLabel($data),
+ 'preview' => base64_encode($brick::toPreviewHtml($data)),
+ ],
+ ];
+
+ $component->runCommands([
+ \Awcodes\Mason\Actions\EditorCommand::make(
+ 'insertContentAt',
+ arguments: [
+ $arguments['dragPosition'],
+ $brickContent,
+ ],
+ ),
+ ]);
+ });
+ }
+}
diff --git a/app/Mason/Bricks/HeaderInvoiceMetaBrick.php b/app/Mason/Bricks/HeaderInvoiceMetaBrick.php
new file mode 100644
index 000000000..64ca3e335
--- /dev/null
+++ b/app/Mason/Bricks/HeaderInvoiceMetaBrick.php
@@ -0,0 +1,113 @@
+');
+ }
+
+ 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): ?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'),
+ ])
+ ->action(function (array $arguments, array $data, \Awcodes\Mason\Mason $component) {
+ $brick = $component->getBrick($arguments['id']);
+
+ if (blank($brick)) {
+ return;
+ }
+
+ $brickContent = [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'config' => $data,
+ 'id' => $arguments['id'],
+ 'label' => $brick::getPreviewLabel($data),
+ 'preview' => base64_encode($brick::toPreviewHtml($data)),
+ ],
+ ];
+
+ $component->runCommands([
+ \Awcodes\Mason\Actions\EditorCommand::make(
+ 'insertContentAt',
+ arguments: [
+ $arguments['dragPosition'],
+ $brickContent,
+ ],
+ ),
+ ]);
+ });
+ }
+}
diff --git a/app/Mason/Bricks/HeaderProjectBrick.php b/app/Mason/Bricks/HeaderProjectBrick.php
new file mode 100644
index 000000000..7101f8b7d
--- /dev/null
+++ b/app/Mason/Bricks/HeaderProjectBrick.php
@@ -0,0 +1,116 @@
+');
+ }
+
+ 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): ?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'),
+ ])
+ ->action(function (array $arguments, array $data, \Awcodes\Mason\Mason $component) {
+ $brick = $component->getBrick($arguments['id']);
+
+ if (blank($brick)) {
+ return;
+ }
+
+ $brickContent = [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'config' => $data,
+ 'id' => $arguments['id'],
+ 'label' => $brick::getPreviewLabel($data),
+ 'preview' => base64_encode($brick::toPreviewHtml($data)),
+ ],
+ ];
+
+ $component->runCommands([
+ \Awcodes\Mason\Actions\EditorCommand::make(
+ 'insertContentAt',
+ arguments: [
+ $arguments['dragPosition'],
+ $brickContent,
+ ],
+ ),
+ ]);
+ });
+ }
+}
diff --git a/app/Mason/Bricks/HeaderQuoteMetaBrick.php b/app/Mason/Bricks/HeaderQuoteMetaBrick.php
new file mode 100644
index 000000000..d1d7d033a
--- /dev/null
+++ b/app/Mason/Bricks/HeaderQuoteMetaBrick.php
@@ -0,0 +1,113 @@
+');
+ }
+
+ 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): ?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'),
+ ])
+ ->action(function (array $arguments, array $data, \Awcodes\Mason\Mason $component) {
+ $brick = $component->getBrick($arguments['id']);
+
+ if (blank($brick)) {
+ return;
+ }
+
+ $brickContent = [
+ 'type' => 'masonBrick',
+ 'attrs' => [
+ 'config' => $data,
+ 'id' => $arguments['id'],
+ 'label' => $brick::getPreviewLabel($data),
+ 'preview' => base64_encode($brick::toPreviewHtml($data)),
+ ],
+ ];
+
+ $component->runCommands([
+ \Awcodes\Mason\Actions\EditorCommand::make(
+ 'insertContentAt',
+ arguments: [
+ $arguments['dragPosition'],
+ $brickContent,
+ ],
+ ),
+ ]);
+ });
+ }
+}
diff --git a/app/Mason/Collections/ReportBricksCollection.php b/app/Mason/Collections/ReportBricksCollection.php
new file mode 100644
index 000000000..8f8e35539
--- /dev/null
+++ b/app/Mason/Collections/ReportBricksCollection.php
@@ -0,0 +1,94 @@
+
+ */
+ public static function all(): array
+ {
+ return [
+ ...self::header(),
+ ...self::detail(),
+ ...self::footer(),
+ ];
+ }
+
+ /**
+ * 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,
+ ];
+ }
+}