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 .= ''; + } + + if ( ! empty($config['show_tax_rate'])) { + $html .= ''; + } + + if ( ! empty($config['show_tax_amount'])) { + $html .= ''; + } + + $html .= ''; + + foreach ($invoice->tax_rates as $taxRate) { + $html .= ''; + + if ( ! empty($config['show_tax_name'])) { + $html .= ''; + } + + if ( ! empty($config['show_tax_rate'])) { + $html .= ''; + } + + if ( ! empty($config['show_tax_amount'])) { + $taxAmount = ($invoice->subtotal ?? 0) * (($taxRate->rate ?? 0) / 100); + $html .= ''; + } + + $html .= ''; + } + + $html .= '
Tax NameRateAmount
' . htmlspecialchars($taxRate->name ?? '') . '' . htmlspecialchars($taxRate->rate ?? '0') . '%' . $this->formatCurrency($taxAmount, $invoice->currency_code) . '
'; + $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 .= ''; + + if ( ! empty($config['show_description'])) { + $html .= ''; + } + + if ( ! empty($config['show_quantity'])) { + $html .= ''; + } + + if ( ! empty($config['show_price'])) { + $html .= ''; + } + + if ( ! empty($config['show_discount'])) { + $html .= ''; + } + + if ( ! empty($config['show_subtotal'])) { + $html .= ''; + } + + $html .= ''; + + foreach (($invoice->invoice_items ?? []) as $item) { + $html .= ''; + $html .= ''; + + if ( ! empty($config['show_description'])) { + $html .= ''; + } + + if ( ! empty($config['show_quantity'])) { + $html .= ''; + } + + if ( ! empty($config['show_price'])) { + $html .= ''; + } + + if ( ! empty($config['show_discount'])) { + $html .= ''; + } + + if ( ! empty($config['show_subtotal'])) { + $html .= ''; + } + + $html .= ''; + } + + $html .= '
ItemDescriptionQtyPriceDiscountSubtotal
' . htmlspecialchars($item->item_name ?? '') . '' . htmlspecialchars($item->description ?? '') . '' . htmlspecialchars($item->quantity ?? '0') . '' . $this->formatCurrency($item->price ?? 0, $invoice->currency_code) . '' . htmlspecialchars($item->discount ?? '0') . '%' . $this->formatCurrency($item->subtotal ?? 0, $invoice->currency_code) . '
'; + + 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 .= 'QR Code'; + + 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 .= ''; + } + + if ( ! empty($config['show_discount']) && ! empty($invoice->discount)) { + $html .= ''; + } + + if ( ! empty($config['show_tax'])) { + $html .= ''; + } + + if ( ! empty($config['show_total'])) { + $html .= ''; + } + + if ( ! empty($config['show_paid']) && ! empty($invoice->paid)) { + $html .= ''; + } + + if ( ! empty($config['show_balance'])) { + $html .= ''; + } + + $html .= '
Subtotal:' . $this->formatCurrency($invoice->subtotal ?? 0, $invoice->currency_code) . '
Discount:' . $this->formatCurrency($invoice->discount ?? 0, $invoice->currency_code) . '
Tax:' . $this->formatCurrency($invoice->tax ?? 0, $invoice->currency_code) . '
Total:' . $this->formatCurrency($invoice->total ?? 0, $invoice->currency_code) . '
Paid:' . $this->formatCurrency($invoice->paid ?? 0, $invoice->currency_code) . '
Balance Due:' . $this->formatCurrency($invoice->balance ?? 0, $invoice->currency_code) . '
'; + $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 .= '
'; + $html .= '

' . htmlspecialchars($customer->company_name ?? '') . '

'; + + if ( ! empty($config['show_email'])) { + $communication = $customer->communications->where('type', 'email')->first(); + if ($communication) { + $html .= '

Email: ' . htmlspecialchars($communication->value ?? '') . '

'; + } + } + + if ( ! empty($config['show_phone'])) { + $communication = $customer->communications->where('type', 'phone')->first(); + if ($communication) { + $html .= '

Phone: ' . htmlspecialchars($communication->value ?? '') . '

'; + } + } + + if ( ! empty($config['show_address'])) { + $address = $customer->addresses->first(); + if ($address) { + $html .= '

' . htmlspecialchars($address->address_1 ?? '') . '

'; + $html .= '

' . htmlspecialchars($address->city ?? '') . ' ' . htmlspecialchars($address->postal_code ?? '') . '

'; + if ( ! empty($address->country)) { + $html .= '

' . htmlspecialchars($address->country) . '

'; + } + } + } + + $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 .= '
'; + $html .= '

' . $e($company->name) . '

'; + + if (($config['show_vat_id'] ?? false) && ! empty($company->vat_number)) { + $html .= '

VAT: ' . $e($company->vat_number) . '

'; + } + + if ($config['show_phone'] ?? false) { + $communication = $company->communications->where('type', 'phone')->first(); + if ($communication) { + $html .= '

Phone: ' . $e($communication->value) . '

'; + } + } + + if ($config['show_email'] ?? false) { + $communication = $company->communications->where('type', 'email')->first(); + if ($communication) { + $html .= '

Email: ' . $e($communication->value) . '

'; + } + } + + if ($config['show_address'] ?? false) { + $address = $company->addresses->first(); + if ($address) { + $html .= '

' . $e($address->address_1) . '

'; + $html .= '

' . $e($address->city) . ' ' . $e($address->postal_code) . '

'; + } + } + + $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 .= '
'; + + if ( ! empty($config['show_number']) && ! empty($invoice->number)) { + $html .= '

Invoice #: ' . htmlspecialchars($invoice->number) . '

'; + } + + if ( ! empty($config['show_date']) && ! empty($invoice->invoiced_at)) { + $html .= '

Date: ' . $invoice->invoiced_at->format('Y-m-d') . '

'; + } + + if ( ! empty($config['show_due_date']) && ! empty($invoice->due_at)) { + $html .= '

Due Date: ' . $invoice->due_at->format('Y-m-d') . '

'; + } + + if ( ! empty($config['show_status'])) { + $status = $invoice->invoice_status?->label() ?? ''; + $html .= '

' . trans('ip.status') . ': ' . htmlspecialchars($status, ENT_QUOTES, 'UTF-8') . '

'; + } + + $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, + ]; + } +}