diff --git a/Modules/Clients/Models/Contact.php b/Modules/Clients/Models/Contact.php index 18628f60d..7ee8b3ec0 100644 --- a/Modules/Clients/Models/Contact.php +++ b/Modules/Clients/Models/Contact.php @@ -68,7 +68,7 @@ public function addresses(): HasManyThrough public function getFullNameAttribute(): string { - return trim($this->first_name . ' ' . $this->last_name); + return mb_trim($this->first_name . ' ' . $this->last_name); } public function getPrimaryEmailAttribute(): ?string 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/ReportBand.php b/Modules/Core/Enums/ReportBand.php new file mode 100644 index 000000000..90e149cc3 --- /dev/null +++ b/Modules/Core/Enums/ReportBand.php @@ -0,0 +1,69 @@ + 'Header', + self::GROUP_HEADER => 'Group Header', + self::DETAILS => 'Details', + self::GROUP_FOOTER => 'Group Footer', + self::FOOTER => 'Footer', + }; + } + + /** + * Get the CSS color class for the band. + * Uses Filament's semantic color names. + */ + public function getColorClass(): string + { + return match ($this) { + self::HEADER => 'bg-success-500 dark:bg-success-600', + self::GROUP_HEADER => 'bg-info-500 dark:bg-info-600', + self::DETAILS => 'bg-primary-500 dark:bg-primary-600', + self::GROUP_FOOTER => 'bg-info-500 dark:bg-info-600', + self::FOOTER => 'bg-success-500 dark:bg-success-600', + }; + } + + /** + * Get the CSS border color class for the band. + */ + public function getBorderColorClass(): string + { + return match ($this) { + self::HEADER => 'border-warning-700 dark:border-warning-800', + self::GROUP_HEADER => 'border-danger-700 dark:border-danger-800', + self::DETAILS => 'border-primary-700 dark:border-primary-800', + self::GROUP_FOOTER => 'border-success-700 dark:border-success-800', + self::FOOTER => 'border-info-700 dark:border-info-800', + }; + } + + /** + * Get the order/position for sorting bands. + */ + public function getOrder(): int + { + return match ($this) { + self::HEADER => 1, + self::GROUP_HEADER => 2, + self::DETAILS => 3, + self::GROUP_FOOTER => 4, + self::FOOTER => 5, + }; + } +} 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/ReportDataSource.php b/Modules/Core/Enums/ReportDataSource.php new file mode 100644 index 000000000..7f2218675 --- /dev/null +++ b/Modules/Core/Enums/ReportDataSource.php @@ -0,0 +1,18 @@ + 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..8d4fb1ecf --- /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, array $data) { + 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..a671e110d --- /dev/null +++ b/Modules/Core/Filament/Admin/Resources/ReportTemplates/Pages/ReportBuilder.php @@ -0,0 +1,497 @@ +record = $record; + $this->loadBlocks(); + } + + 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($bands): void + { + // $bands is already grouped by band from Alpine.js + $blocks = []; + foreach ($bands as $band) { + if ( ! isset($band['blocks'])) { + continue; + } + foreach ($band['blocks'] as $block) { + // Ensure the block data has all necessary fields before passing to service + if ( ! isset($block['type'])) { + $systemBlocks = app(ReportTemplateService::class)->getSystemBlocks(); + $type = str_replace('block_', '', $block['id']); + if (isset($systemBlocks[$type])) { + $block = BlockTransformer::toArray($systemBlocks[$type]); + } + } + + $block['band'] = $band['key'] ?? 'header'; + $blocks[$block['id']] = $block; + } + } + $this->blocks = $blocks; + $service = app(ReportTemplateService::class); + $service->persistBlocks($this->record, $this->blocks); + $this->dispatch('blocks-saved'); + } + + public function saveBlockConfiguration(string $blockType, array $config): void + { + $service = app(ReportTemplateService::class); + $dbBlock = ReportBlock::where('block_type', $blockType)->first(); + + if ($dbBlock) { + $service->saveBlockConfig($dbBlock, $config); + $this->dispatch('block-config-saved'); + } + } + + public function getAvailableFields(): array + { + return [ + ['id' => 'company_name', 'label' => 'Company Name'], + ['id' => 'company_address', 'label' => 'Company Address'], + ['id' => 'company_phone', 'label' => 'Company Phone'], + ['id' => 'company_email', 'label' => 'Company Email'], + ['id' => 'company_vat_id', 'label' => 'Company VAT ID'], + ['id' => 'client_name', 'label' => 'Client Name'], + ['id' => 'client_address', 'label' => 'Client Address'], + ['id' => 'client_phone', 'label' => 'Client Phone'], + ['id' => 'client_email', 'label' => 'Client Email'], + ['id' => 'invoice_number', 'label' => 'Invoice Number'], + ['id' => 'invoice_date', 'label' => 'Invoice Date'], + ['id' => 'invoice_due_date', 'label' => 'Due Date'], + ['id' => 'invoice_subtotal', 'label' => 'Subtotal'], + ['id' => 'invoice_tax_total', 'label' => 'Tax Total'], + ['id' => 'invoice_total', 'label' => 'Invoice Total'], + ['id' => 'item_description', 'label' => 'Item Description'], + ['id' => 'item_quantity', 'label' => 'Item Quantity'], + ['id' => 'item_price', 'label' => 'Item Price'], + ['id' => 'item_tax_name', 'label' => 'Item Tax Name'], + ['id' => 'item_tax_rate', 'label' => 'Item Tax Rate'], + ['id' => 'footer_notes', 'label' => 'Notes'], + ]; + } + + /** + * Loads the template blocks from the filesystem via the service. + */ + protected function loadBlocks(): void + { + $service = app(ReportTemplateService::class); + $blockDTOs = $service->loadBlocks($this->record); + + $this->blocks = []; + foreach ($blockDTOs as $blockDTO) { + $blockArray = BlockTransformer::toArray($blockDTO); + $this->blocks[$blockArray['id']] = $blockArray; + } + } +} 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/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/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/ReportFieldService.php b/Modules/Core/Services/ReportFieldService.php new file mode 100644 index 000000000..6dcb4d645 --- /dev/null +++ b/Modules/Core/Services/ReportFieldService.php @@ -0,0 +1,78 @@ + $field['id'], + 'label' => trans($field['label']), + 'source' => $field['source'], + 'format' => $field['format'] ?? null, + ]; + } + + return $fields; + } + + /** + * Get fields for a specific data source. + * + * @param string $source + * + * @return array + */ + public function getFieldsBySource(string $source): array + { + $config = config('report-fields', []); + $fields = []; + + // Filter fields by source + foreach ($config as $field) { + if ($field['source'] === $source) { + $fields[] = [ + 'id' => $field['id'], + 'label' => trans($field['label']), + 'source' => $field['source'], + 'format' => $field['format'] ?? null, + ]; + } + } + + return $fields; + } + + /** + * Get all unique data sources from fields. + * + * @return array + */ + public function getDataSources(): array + { + $config = config('report-fields', []); + $sources = []; + + foreach ($config as $field) { + if ( ! in_array($field['source'], $sources, true)) { + $sources[] = $field['source']; + } + } + + return $sources; + } +} diff --git a/Modules/Core/Services/ReportRenderer.php b/Modules/Core/Services/ReportRenderer.php new file mode 100644 index 000000000..23d78eae7 --- /dev/null +++ b/Modules/Core/Services/ReportRenderer.php @@ -0,0 +1,347 @@ +templateService = $templateService; + $this->blockFactory = $blockFactory; + } + + /** + * Render template to HTML string. + * + * @param ReportTemplate $template The template to render + * @param Invoice $invoice The invoice data to render + * + * @return string HTML markup + */ + public function renderToHtml(ReportTemplate $template, Invoice $invoice): string + { + try { + $blocks = $this->templateService->loadBlocks($template); + $company = $invoice->company; + + usort($blocks, fn ($a, $b) => $a->getPosition()->getY() <=> $b->getPosition()->getY()); + + $content = ''; + foreach ($blocks as $block) { + $handler = $this->blockFactory->make($block->getType()); + if ($handler === null) { + Log::channel('report-builder')->warning('Unknown block type', ['type' => $block->getType()]); + + continue; + } + $content .= $handler->render($block, $invoice, $company); + } + + return $this->wrapInHtmlTemplate($content, $template, $invoice); + } catch (Error $e) { + Log::channel('report-builder')->error('Error rendering template to HTML', [ + 'template_id' => $template->id, + 'invoice_id' => $invoice->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + throw $e; + } catch (ErrorException $e) { + Log::channel('report-builder')->error('ErrorException rendering template to HTML', [ + 'template_id' => $template->id, + 'invoice_id' => $invoice->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + throw $e; + } catch (Throwable $e) { + Log::channel('report-builder')->error('Throwable rendering template to HTML', [ + 'template_id' => $template->id, + 'invoice_id' => $invoice->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + throw $e; + } + } + + /** + * Render template to PDF string. + * + * @param ReportTemplate $template The template to render + * @param Invoice $invoice The invoice data to render + * + * @return string PDF content as string + */ + public function renderToPdf(ReportTemplate $template, Invoice $invoice): string + { + try { + $html = $this->renderToHtml($template, $invoice); + + $mpdf = new \Mpdf\Mpdf([ + 'mode' => 'utf-8', + 'format' => 'A4', + 'margin_left' => 15, + 'margin_right' => 15, + 'margin_top' => 16, + 'margin_bottom' => 16, + 'margin_header' => 9, + 'margin_footer' => 9, + ]); + + $mpdf->WriteHTML($html); + + return $mpdf->Output('', 'S'); + } catch (Error $e) { + Log::channel('report-builder')->error('Error rendering template to PDF', [ + 'template_id' => $template->id, + 'invoice_id' => $invoice->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + throw $e; + } catch (ErrorException $e) { + Log::channel('report-builder')->error('ErrorException rendering template to PDF', [ + 'template_id' => $template->id, + 'invoice_id' => $invoice->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + throw $e; + } catch (Throwable $e) { + Log::channel('report-builder')->error('Throwable rendering template to PDF', [ + 'template_id' => $template->id, + 'invoice_id' => $invoice->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + throw $e; + } + } + + /** + * Render template to preview HTML with sample data. + * + * @param ReportTemplate $template The template to render + * @param mixed $sample Sample invoice data + * + * @return string HTML markup + */ + public function renderToPreview(ReportTemplate $template, $sample): string + { + try { + $blocks = $this->templateService->loadBlocks($template); + $company = $sample->company ?? $template->company; + + usort($blocks, fn ($a, $b) => $a->getPosition()->getY() <=> $b->getPosition()->getY()); + + $content = ''; + foreach ($blocks as $block) { + $handler = $this->blockFactory->make($block->getType()); + if ($handler === null) { + Log::channel('report-builder')->warning('Unknown block type', ['type' => $block->getType()]); + + continue; + } + $content .= $handler->render($block, $sample, $company); + } + + return $this->wrapInHtmlTemplate($content, $template, $sample); + } catch (Error $e) { + Log::channel('report-builder')->error('Error rendering template to preview', [ + 'template_id' => $template->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + throw $e; + } catch (ErrorException $e) { + Log::channel('report-builder')->error('ErrorException rendering template to preview', [ + 'template_id' => $template->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + throw $e; + } catch (Throwable $e) { + Log::channel('report-builder')->error('Throwable rendering template to preview', [ + 'template_id' => $template->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + throw $e; + } + } + + /** + * Wrap content in HTML template with PDF styles. + * + * @param string $content The rendered content + * @param ReportTemplate $template The template + * @param mixed $invoice The invoice data + * + * @return string Complete HTML document + */ + private function wrapInHtmlTemplate(string $content, ReportTemplate $template, $invoice): string + { + $styles = $this->getPdfStyles(); + + $html = << + + + + + Invoice {$invoice->number} + + + +
+ {$content} +
+ + +HTML; + + return $html; + } + + /** + * Get PDF-ready CSS styles. + * + * @return string CSS styles + */ + private function getPdfStyles(): string + { + return <<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..522ba3bbe --- /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..029038758 --- /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..f126f1fd8 --- /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/ReportRenderingTest.php b/Modules/Core/Tests/Feature/ReportRenderingTest.php new file mode 100644 index 000000000..745860d4f --- /dev/null +++ b/Modules/Core/Tests/Feature/ReportRenderingTest.php @@ -0,0 +1,194 @@ +service = app(ReportTemplateService::class); + $this->renderer = app(ReportRenderer::class); + } + + #[Test] + #[Group('rendering')] + /** + * @payload + * { + * "blocks": [ + * {"id": "block_company_header", "type": "company_header", "position": {"x": 0, "y": 0, "width": 6, "height": 4}}, + * {"id": "block_invoice_items", "type": "invoice_items", "position": {"x": 0, "y": 6, "width": 12, "height": 6}} + * ], + * "data": {"company": {"name": "Test Company"}, "items": []} + * } + */ + public function it_renders_template_to_html_with_correct_block_order(): 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, + ], + [ + 'id' => 'block_invoice_items', + 'type' => 'invoice_items', + 'position' => ['x' => 0, 'y' => 6, 'width' => 12, 'height' => 6], + 'config' => ['show_description' => true], + 'label' => 'Invoice Items', + 'isCloneable' => true, + 'dataSource' => 'invoice', + 'isCloned' => false, + 'clonedFrom' => null, + ], + ]; + + $template = $this->service->createTemplate( + $company, + 'Test Template', + 'invoice', + $blocks + ); + + $data = [ + 'company' => [ + 'name' => 'Test Company', + 'vat_id' => 'VAT123', + ], + 'items' => [], + ]; + + /* act */ + $html = $this->renderer->render($template, $data); + + /* assert */ + $this->assertIsString($html); + $this->assertStringContainsString('Test Company', $html); + } + + #[Test] + #[Group('rendering')] + public function it_renders_template_to_pdf(): void + { + /* arrange */ + $company = $this->createCompanyContext(); + + $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, + ], + ]; + + $template = $this->service->createTemplate( + $company, + 'Test Template', + 'invoice', + $blocks + ); + + $data = [ + 'company' => [ + 'name' => 'Test Company', + ], + ]; + + /* act */ + $pdf = $this->renderer->renderToPdf($template, $data); + + /* assert */ + $this->assertNotNull($pdf); + $this->assertIsString($pdf); + $this->assertStringStartsWith('%PDF-', $pdf); + } + + #[Test] + #[Group('rendering')] + public function it_handles_missing_blocks_with_error_log(): void + { + /* arrange */ + $company = $this->createCompanyContext(); + + $blocks = [ + [ + 'id' => 'block_missing_type', + 'type' => 'non_existent_block_type', + 'position' => ['x' => 0, 'y' => 0, 'width' => 6, 'height' => 4], + 'config' => [], + 'label' => 'Missing Block', + 'isCloneable' => false, + 'dataSource' => 'custom', + 'isCloned' => false, + 'clonedFrom' => null, + ], + ]; + + $template = $this->service->createTemplate( + $company, + 'Test Template', + 'invoice', + $blocks + ); + + $data = [ + 'company' => [ + 'name' => 'Test Company', + ], + ]; + + /* act */ + Log::shouldReceive('error') + ->once() + ->with(Mockery::pattern('/Block handler not found/i'), Mockery::any()); + + $html = $this->renderer->render($template, $data); + + /* assert */ + $this->assertIsString($html); + } + + protected function createCompanyContext(): Company + { + /** @var Company $company */ + $company = Company::factory()->create(); + $user = User::factory()->create(); + $user->companies()->attach($company); + session(['current_company_id' => $company->id]); + + return $company; + } +} 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/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..8fbb30d84 --- /dev/null +++ b/Modules/Core/Tests/Unit/ReportBlockWidthTest.php @@ -0,0 +1,124 @@ +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 => 4, // 1/3 of 12 = 4 + ReportBlockWidth::HALF => 6, // 1/2 of 12 = 6 + ReportBlockWidth::TWO_THIRDS => 8, // 2/3 of 12 = 8 + ReportBlockWidth::FULL => 12, // 12/12 = 12 + ]; + + /* Act & Assert */ + foreach ($widths as $width => $expectedGrid) { + $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/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..b7a44acf8 --- /dev/null +++ b/Modules/Core/Tests/Unit/ReportTemplateServiceTest.php @@ -0,0 +1,251 @@ +fileRepository = $this->createMock(ReportTemplateFileRepository::class); + $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(); + + /* act */ + $position->setX(0)->setY(0)->setWidth(6)->setHeight(4); + + /* assert */ + $block = new BlockDTO(); + $block->setId('block_1') + ->setType('company_header') + ->setPosition($position) + ->setConfig([]) + ->setIsCloneable(true) + ->setIsCloned(false); + $this->fileRepository->expects($this->once()) + ->method('save') + ->with(1, 'test-template', $this->isType('array')); + $this->service->persistBlocks($template, [$block]); + } + + #[Test] + #[Group('unit')] + public function it_loads_blocks(): void + { + /* arrange */ + $template = new ReportTemplate(); + $template->company_id = 1; + $template->slug = 'test-template'; + + $this->fileRepository->expects($this->once()) + ->method('load') + ->with(1, 'test-template') + ->willReturn([ + [ + 'id' => 'block_1', + 'type' => 'company_header', + 'position' => ['x' => 0, 'y' => 0, 'width' => 6, 'height' => 4], + 'config' => [], + ], + ]); + + /* 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..928c9bb7a --- /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/Templates/Views/templates/emails/html.blade.php b/Modules/Core/resources/Templates/Views/templates/emails/html.blade.php new file mode 100644 index 000000000..9082111c0 --- /dev/null +++ b/Modules/Core/resources/Templates/Views/templates/emails/html.blade.php @@ -0,0 +1,9 @@ + + + + + + +{!! $body !!} + + diff --git a/Modules/Core/resources/Templates/Views/templates/emails/text.blade.php b/Modules/Core/resources/Templates/Views/templates/emails/text.blade.php new file mode 100644 index 000000000..ee80de9f7 --- /dev/null +++ b/Modules/Core/resources/Templates/Views/templates/emails/text.blade.php @@ -0,0 +1 @@ +{!! strip_tags($body) !!} diff --git a/Modules/Core/resources/Templates/Views/templates/invoices/default.blade.php b/Modules/Core/resources/Templates/Views/templates/invoices/default.blade.php new file mode 100644 index 000000000..229254db6 --- /dev/null +++ b/Modules/Core/resources/Templates/Views/templates/invoices/default.blade.php @@ -0,0 +1,172 @@ + + + + + @lang('ip.invoice') #{{ $invoice->number }} + + + + + + + + + + +
+

{{ mb_strtoupper(trans('ip.invoice')) }}

+ {{ mb_strtoupper(trans('ip.invoice')) }} #{{ $invoice->number }}
+ {{ mb_strtoupper(trans('ip.issued')) }} {{ $invoice->formatted_created_at }}
+ {{ mb_strtoupper(trans('ip.due_date')) }} {{ $invoice->formatted_due_at }}

+ {{ mb_strtoupper(trans('ip.bill_to')) }}
{{ $invoice->customer->name }}
+ @if ($invoice->customer->address) + {!! $invoice->customer->formatted_address !!}
+ @endif +
+ {!! $invoice->companyProfile->logo() !!}
+ {{ $invoice->companyProfile->company }}
+ {!! $invoice->companyProfile->formatted_address !!}
+ @if ($invoice->companyProfile->phone) + {{ $invoice->companyProfile->phone }}
+ @endif + @if ($invoice->user->email) + {{ $invoice->user->email }} + @endif +
+ + + + + + + + + + + + + @foreach ($invoice->items as $item) + + + + + + + + @endforeach + + + + + + + @if ($invoice->discount > 0) + + + + + @endif + + @foreach ($invoice->summarized_taxes as $tax) + + + + + @endforeach + + + + + + + + + + + + + + +
{{ mb_strtoupper(trans('ip.product')) }}{{ mb_strtoupper(trans('ip.description')) }}{{ mb_strtoupper(trans('ip.quantity')) }}{{ mb_strtoupper(trans('ip.price')) }}{{ mb_strtoupper(trans('ip.total')) }}
{!! $item->name !!}{!! $item->formatted_description !!}{{ $item->formatted_quantity }}{{ $item->formatted_price }}{{ $item->amount->formatted_subtotal }}
{{ mb_strtoupper(trans('ip.subtotal')) }}{{ $invoice->amount->formatted_subtotal }}
{{ mb_strtoupper(trans('ip.discount')) }}{{ $invoice->amount->formatted_discount }}
{{ mb_strtoupper($tax->name) }} ({{ $tax->percent }}){{ $tax->total }}
{{ mb_strtoupper(trans('ip.total')) }}{{ $invoice->amount->formatted_total }}
{{ mb_strtoupper(trans('ip.paid')) }}{{ $invoice->amount->formatted_paid }}
{{ mb_strtoupper(trans('ip.balance')) }}{{ $invoice->amount->formatted_balance }}
+ +@if ($invoice->terms) +
{{ mb_strtoupper(trans('ip.terms_and_conditions')) }}
+
{!! $invoice->formatted_terms !!}
+@endif + + + + + diff --git a/Modules/Core/resources/Templates/Views/templates/quotes/default.blade.php b/Modules/Core/resources/Templates/Views/templates/quotes/default.blade.php new file mode 100644 index 000000000..d54f6bc8e --- /dev/null +++ b/Modules/Core/resources/Templates/Views/templates/quotes/default.blade.php @@ -0,0 +1,164 @@ + + + + + @lang('ip.quote') #{{ $quote->number }} + + + + + + + + + + +
+

{{ mb_strtoupper(trans('ip.quote')) }}

+ {{ mb_strtoupper(trans('ip.quote')) }} #{{ $quote->number }}
+ {{ mb_strtoupper(trans('ip.issued')) }} {{ $quote->formatted_created_at }}
+ {{ mb_strtoupper(trans('ip.expires')) }} {{ $quote->formatted_expires_at }} +

+ {{ mb_strtoupper(trans('ip.bill_to')) }}
{{ $quote->customer->name }}
+ @if ($quote->customer->address) + {!! $quote->customer->formatted_address !!}
+ @endif +
+ {!! $quote->companyProfile->logo() !!}
+ {{ $quote->companyProfile->company }}
+ {!! $quote->companyProfile->formatted_address !!}
+ @if ($quote->companyProfile->phone) + {{ $quote->companyProfile->phone }}
+ @endif + @if ($quote->user->email) + {{ $quote->user->email }} + @endif +
+ + + + + + + + + + + + + @foreach ($quote->items as $item) + + + + + + + + @endforeach + + + + + + + @if ($quote->discount > 0) + + + + + @endif + + @foreach ($quote->summarized_taxes as $tax) + + + + + @endforeach + + + + + + +
{{ mb_strtoupper(trans('ip.product')) }}{{ mb_strtoupper(trans('ip.description')) }}{{ mb_strtoupper(trans('ip.quantity')) }}{{ mb_strtoupper(trans('ip.price')) }}{{ mb_strtoupper(trans('ip.total')) }}
{!! $item->name !!}{!! $item->formatted_description !!}{{ $item->formatted_quantity }}{{ $item->formatted_price }}{{ $item->amount->formatted_subtotal }}
{{ mb_strtoupper(trans('ip.subtotal')) }}{{ $quote->amount->formatted_subtotal }}
{{ mb_strtoupper(trans('ip.discount')) }}{{ $quote->amount->formatted_discount }}
{{ mb_strtoupper($tax->name) }} ({{ $tax->percent }}){{ $tax->total }}
{{ mb_strtoupper(trans('ip.total')) }}{{ $quote->amount->formatted_total }}
+ +@if ($quote->terms) +
{{ mb_strtoupper(trans('ip.terms_and_conditions')) }}
+
{!! $quote->formatted_terms !!}
+@endif + + + + + diff --git a/Modules/Core/resources/views/filament/admin/pages/settings.blade.php b/Modules/Core/resources/views/filament/admin/pages/settings.blade.php new file mode 100644 index 000000000..34fe999eb --- /dev/null +++ b/Modules/Core/resources/views/filament/admin/pages/settings.blade.php @@ -0,0 +1,10 @@ + + + {{ $this->form }} + + + + 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/Modules/Core/resources/views/filament/admin/resources/report-template-resource/pages/design-report-template.blade.php b/Modules/Core/resources/views/filament/admin/resources/report-template-resource/pages/design-report-template.blade.php new file mode 100644 index 000000000..3e854d9f3 --- /dev/null +++ b/Modules/Core/resources/views/filament/admin/resources/report-template-resource/pages/design-report-template.blade.php @@ -0,0 +1,318 @@ +@php + use Modules\Core\Services\ReportTemplateService; + use Modules\Core\Transformers\BlockTransformer; + use Modules\Core\Enums\ReportBand; + + $systemBlocks = app(ReportTemplateService::class)->getSystemBlocks(); + $systemBlocksArray = array_map(fn($block) => BlockTransformer::toArray($block), $systemBlocks); + + // Build bands array with hardcoded colors as requested/working previously + $bandsConfig = [ + ['name' => 'Header Band', 'key' => 'header', 'color' => '#e5e9f0', 'darkColor' => '#2e3440', 'border' => '#81a1c1'], + ['name' => 'Detail Group Header Band', 'key' => 'group_header', 'color' => '#eceff4', 'darkColor' => '#3b4252', 'border' => '#8fbcbb'], + ['name' => 'Details Band', 'key' => 'details', 'color' => '#d8dee9', 'darkColor' => '#434c5e', 'border' => '#5e81ac'], + ['name' => 'Detail Group Footer Band', 'key' => 'group_footer', 'color' => '#e5e9f0', 'darkColor' => '#2e3440', 'border' => '#81a1c1'], + ['name' => 'Footer Band', 'key' => 'footer', 'color' => '#eceff4', 'darkColor' => '#3b4252', 'border' => '#8fbcbb'], + ]; +@endphp + +
+
+ {{-- Header Bar --}} +
+
+

Report Designer

+

Design your report layout by dragging and dropping + blocks into bands.

+
+
+ + Close + + + Save Changes + +
+
+ + {{-- Help Card (Pro Tip) moved under header --}} +
+
+ +
+

Pro Tip

+

Drag blocks into any band to build + your layout. Use the Edit button on any block to configure its fields and + appearance globally!

+
+
+
+ + {{-- Main Content: Robust CSS Grid for forced side-by-side layout --}} +
+ + {{-- Design Area (Left) - 75% width --}} +
+ +
+ + {{-- Sidebar: Available Blocks (Right) - 25% width --}} +
+
+
+

+ + @lang('ip.available_blocks') +

+
+ +
+
+ +
+
+
+
+
+ +
+ +
+
diff --git a/config/report-fields.php b/config/report-fields.php new file mode 100644 index 000000000..23c731c60 --- /dev/null +++ b/config/report-fields.php @@ -0,0 +1,117 @@ + 'company_name', 'label' => 'ip.report_field_company_name', 'source' => 'company'], + ['id' => 'company_address_1', 'label' => 'ip.report_field_company_address_1', 'source' => 'company'], + ['id' => 'company_address_2', 'label' => 'ip.report_field_company_address_2', 'source' => 'company'], + ['id' => 'company_city', 'label' => 'ip.report_field_company_city', 'source' => 'company'], + ['id' => 'company_state', 'label' => 'ip.report_field_company_state', 'source' => 'company'], + ['id' => 'company_zip', 'label' => 'ip.report_field_company_zip', 'source' => 'company'], + ['id' => 'company_country', 'label' => 'ip.report_field_company_country', 'source' => 'company'], + ['id' => 'company_phone', 'label' => 'ip.report_field_company_phone', 'source' => 'company'], + ['id' => 'company_email', 'label' => 'ip.report_field_company_email', 'source' => 'company'], + ['id' => 'company_vat_id', 'label' => 'ip.report_field_company_vat_id', 'source' => 'company'], + ['id' => 'company_id_number', 'label' => 'ip.report_field_company_id_number', 'source' => 'company'], + ['id' => 'company_coc_number', 'label' => 'ip.report_field_company_coc_number', 'source' => 'company'], + ['id' => 'customer_name', 'label' => 'ip.report_field_customer_name', 'source' => 'customer'], + ['id' => 'customer_address_1', 'label' => 'ip.report_field_customer_address_1', 'source' => 'customer'], + ['id' => 'customer_address_2', 'label' => 'ip.report_field_customer_address_2', 'source' => 'customer'], + ['id' => 'customer_city', 'label' => 'ip.report_field_customer_city', 'source' => 'customer'], + ['id' => 'customer_state', 'label' => 'ip.report_field_customer_state', 'source' => 'customer'], + ['id' => 'customer_zip', 'label' => 'ip.report_field_customer_zip', 'source' => 'customer'], + ['id' => 'customer_country', 'label' => 'ip.report_field_customer_country', 'source' => 'customer'], + ['id' => 'customer_phone', 'label' => 'ip.report_field_customer_phone', 'source' => 'customer'], + ['id' => 'customer_email', 'label' => 'ip.report_field_customer_email', 'source' => 'customer'], + ['id' => 'customer_vat_id', 'label' => 'ip.report_field_customer_vat_id', 'source' => 'customer'], + ['id' => 'invoice_number', 'label' => 'ip.report_field_invoice_number', 'source' => 'invoice'], + ['id' => 'invoice_date', 'label' => 'ip.report_field_invoice_date', 'source' => 'invoice', 'format' => 'date'], + ['id' => 'invoice_date_created', 'label' => 'ip.report_field_invoice_date_created', 'source' => 'invoice', 'format' => 'date'], + ['id' => 'invoice_date_due', 'label' => 'ip.report_field_invoice_date_due', 'source' => 'invoice', 'format' => 'date'], + ['id' => 'invoice_guest_url', 'label' => 'ip.report_field_invoice_guest_url', 'source' => 'invoice', 'format' => 'url'], + ['id' => 'invoice_item_subtotal', 'label' => 'ip.report_field_invoice_item_subtotal', 'source' => 'invoice', 'format' => 'currency'], + ['id' => 'invoice_item_tax_total', 'label' => 'ip.report_field_invoice_item_tax_total', 'source' => 'invoice', 'format' => 'currency'], + ['id' => 'invoice_total', 'label' => 'ip.report_field_invoice_total', 'source' => 'invoice', 'format' => 'currency'], + ['id' => 'invoice_paid', 'label' => 'ip.report_field_invoice_paid', 'source' => 'invoice', 'format' => 'currency'], + ['id' => 'invoice_balance', 'label' => 'ip.report_field_invoice_balance', 'source' => 'invoice', 'format' => 'currency'], + ['id' => 'invoice_status', 'label' => 'ip.report_field_invoice_status', 'source' => 'invoice'], + ['id' => 'invoice_notes', 'label' => 'ip.report_field_invoice_notes', 'source' => 'invoice'], + ['id' => 'invoice_terms', 'label' => 'ip.report_field_invoice_terms', 'source' => 'invoice'], + ['id' => 'item_description', 'label' => 'ip.report_field_item_description', 'source' => 'invoice_item'], + ['id' => 'item_name', 'label' => 'ip.report_field_item_name', 'source' => 'invoice_item'], + ['id' => 'item_quantity', 'label' => 'ip.report_field_item_quantity', 'source' => 'invoice_item', 'format' => 'number'], + ['id' => 'item_price', 'label' => 'ip.report_field_item_price', 'source' => 'invoice_item', 'format' => 'currency'], + ['id' => 'item_subtotal', 'label' => 'ip.report_field_item_subtotal', 'source' => 'invoice_item', 'format' => 'currency'], + ['id' => 'item_tax_name', 'label' => 'ip.report_field_item_tax_name', 'source' => 'invoice_item'], + ['id' => 'item_tax_rate', 'label' => 'ip.report_field_item_tax_rate', 'source' => 'invoice_item', 'format' => 'percentage'], + ['id' => 'item_tax_amount', 'label' => 'ip.report_field_item_tax_amount', 'source' => 'invoice_item', 'format' => 'currency'], + ['id' => 'item_total', 'label' => 'ip.report_field_item_total', 'source' => 'invoice_item', 'format' => 'currency'], + ['id' => 'item_discount', 'label' => 'ip.report_field_item_discount', 'source' => 'invoice_item', 'format' => 'currency'], + ['id' => 'quote_number', 'label' => 'ip.report_field_quote_number', 'source' => 'quote'], + ['id' => 'quote_date', 'label' => 'ip.report_field_quote_date', 'source' => 'quote', 'format' => 'date'], + ['id' => 'quote_date_created', 'label' => 'ip.report_field_quote_date_created', 'source' => 'quote', 'format' => 'date'], + ['id' => 'quote_date_expires', 'label' => 'ip.report_field_quote_date_expires', 'source' => 'quote', 'format' => 'date'], + ['id' => 'quote_guest_url', 'label' => 'ip.report_field_quote_guest_url', 'source' => 'quote', 'format' => 'url'], + ['id' => 'quote_subtotal', 'label' => 'ip.report_field_quote_subtotal', 'source' => 'quote', 'format' => 'currency'], + ['id' => 'quote_tax_total', 'label' => 'ip.report_field_quote_tax_total', 'source' => 'quote', 'format' => 'currency'], + ['id' => 'quote_discount', 'label' => 'ip.report_field_quote_discount', 'source' => 'quote', 'format' => 'currency'], + ['id' => 'quote_total', 'label' => 'ip.report_field_quote_total', 'source' => 'quote', 'format' => 'currency'], + ['id' => 'quote_status', 'label' => 'ip.report_field_quote_status', 'source' => 'quote'], + ['id' => 'quote_notes', 'label' => 'ip.report_field_quote_notes', 'source' => 'quote'], + ['id' => 'quote_item_description', 'label' => 'ip.report_field_quote_item_description', 'source' => 'quote_item'], + ['id' => 'quote_item_name', 'label' => 'ip.report_field_quote_item_name', 'source' => 'quote_item'], + ['id' => 'quote_item_quantity', 'label' => 'ip.report_field_quote_item_quantity', 'source' => 'quote_item', 'format' => 'number'], + ['id' => 'quote_item_price', 'label' => 'ip.report_field_quote_item_price', 'source' => 'quote_item', 'format' => 'currency'], + ['id' => 'quote_item_subtotal', 'label' => 'ip.report_field_quote_item_subtotal', 'source' => 'quote_item', 'format' => 'currency'], + ['id' => 'quote_item_tax_name', 'label' => 'ip.report_field_quote_item_tax_name', 'source' => 'quote_item'], + ['id' => 'quote_item_tax_rate', 'label' => 'ip.report_field_quote_item_tax_rate', 'source' => 'quote_item', 'format' => 'percentage'], + ['id' => 'quote_item_total', 'label' => 'ip.report_field_quote_item_total', 'source' => 'quote_item', 'format' => 'currency'], + ['id' => 'quote_item_discount', 'label' => 'ip.report_field_quote_item_discount', 'source' => 'quote_item', 'format' => 'currency'], + ['id' => 'payment_date', 'label' => 'ip.report_field_payment_date', 'source' => 'payment', 'format' => 'date'], + ['id' => 'payment_amount', 'label' => 'ip.report_field_payment_amount', 'source' => 'payment', 'format' => 'currency'], + ['id' => 'payment_method', 'label' => 'ip.report_field_payment_method', 'source' => 'payment'], + ['id' => 'payment_note', 'label' => 'ip.report_field_payment_note', 'source' => 'payment'], + ['id' => 'payment_reference', 'label' => 'ip.report_field_payment_reference', 'source' => 'payment'], + ['id' => 'project_name', 'label' => 'ip.report_field_project_name', 'source' => 'project'], + ['id' => 'project_description', 'label' => 'ip.report_field_project_description', 'source' => 'project'], + ['id' => 'project_start_date', 'label' => 'ip.report_field_project_start_date', 'source' => 'project', 'format' => 'date'], + ['id' => 'project_end_date', 'label' => 'ip.report_field_project_end_date', 'source' => 'project', 'format' => 'date'], + ['id' => 'project_status', 'label' => 'ip.report_field_project_status', 'source' => 'project'], + ['id' => 'task_name', 'label' => 'ip.report_field_task_name', 'source' => 'task'], + ['id' => 'task_description', 'label' => 'ip.report_field_task_description', 'source' => 'task'], + ['id' => 'task_start_date', 'label' => 'ip.report_field_task_start_date', 'source' => 'task', 'format' => 'date'], + ['id' => 'task_finish_date', 'label' => 'ip.report_field_task_finish_date', 'source' => 'task', 'format' => 'date'], + ['id' => 'task_hours', 'label' => 'ip.report_field_task_hours', 'source' => 'task', 'format' => 'number'], + ['id' => 'task_rate', 'label' => 'ip.report_field_task_rate', 'source' => 'task', 'format' => 'currency'], + ['id' => 'expense_date', 'label' => 'ip.report_field_expense_date', 'source' => 'expense', 'format' => 'date'], + ['id' => 'expense_category', 'label' => 'ip.report_field_expense_category', 'source' => 'expense'], + ['id' => 'expense_amount', 'label' => 'ip.report_field_expense_amount', 'source' => 'expense', 'format' => 'currency'], + ['id' => 'expense_description', 'label' => 'ip.report_field_expense_description', 'source' => 'expense'], + ['id' => 'expense_vendor', 'label' => 'ip.report_field_expense_vendor', 'source' => 'expense'], + ['id' => 'relation_name', 'label' => 'ip.report_field_relation_name', 'source' => 'relation'], + ['id' => 'relation_address_1', 'label' => 'ip.report_field_relation_address_1', 'source' => 'relation'], + ['id' => 'relation_address_2', 'label' => 'ip.report_field_relation_address_2', 'source' => 'relation'], + ['id' => 'relation_city', 'label' => 'ip.report_field_relation_city', 'source' => 'relation'], + ['id' => 'relation_state', 'label' => 'ip.report_field_relation_state', 'source' => 'relation'], + ['id' => 'relation_zip', 'label' => 'ip.report_field_relation_zip', 'source' => 'relation'], + ['id' => 'relation_country', 'label' => 'ip.report_field_relation_country', 'source' => 'relation'], + ['id' => 'relation_phone', 'label' => 'ip.report_field_relation_phone', 'source' => 'relation'], + ['id' => 'relation_email', 'label' => 'ip.report_field_relation_email', 'source' => 'relation'], + ['id' => 'sumex_casedate', 'label' => 'ip.report_field_sumex_casedate', 'source' => 'sumex', 'format' => 'date'], + ['id' => 'sumex_casenumber', 'label' => 'ip.report_field_sumex_casenumber', 'source' => 'sumex'], + ['id' => 'current_date', 'label' => 'ip.report_field_current_date', 'source' => 'common', 'format' => 'date'], + ['id' => 'footer_notes', 'label' => 'ip.report_field_footer_notes', 'source' => 'common'], + ['id' => 'page_number', 'label' => 'ip.report_field_page_number', 'source' => 'common'], + ['id' => 'total_pages', 'label' => 'ip.report_field_total_pages', 'source' => 'common'], +]; diff --git a/custom/addons/.gitkeep b/custom/addons/.gitkeep new file mode 100755 index 000000000..e69de29bb diff --git a/custom/overrides/.gitkeep b/custom/overrides/.gitkeep new file mode 100755 index 000000000..e69de29bb diff --git a/custom/templates/email_templates/.gitignore b/custom/templates/email_templates/.gitignore new file mode 100755 index 000000000..c96a04f00 --- /dev/null +++ b/custom/templates/email_templates/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/custom/templates/invoice_templates/custom.blade.php b/custom/templates/invoice_templates/custom.blade.php new file mode 100755 index 000000000..a946f813a --- /dev/null +++ b/custom/templates/invoice_templates/custom.blade.php @@ -0,0 +1,171 @@ + + + + + {{ trans('ip.invoice') }} #{{ $invoice->number }} + + + + + + + + + + +
+

{{ mb_strtoupper(trans('ip.invoice')) }}

+ {{ mb_strtoupper(trans('ip.invoice')) }} #{{ $invoice->number }}
+ {{ mb_strtoupper(trans('ip.issued')) }} {{ $invoice->formatted_created_at }}
+ {{ mb_strtoupper(trans('ip.due_date')) }} {{ $invoice->formatted_due_at }}

+ {{ mb_strtoupper(trans('ip.bill_to')) }}
{{ $invoice->client->name }}
+ @if ($invoice->client->address) + {!! $invoice->client->formatted_address !!}
+ @endif +
+ {!! $invoice->companyProfile->logo() !!}
+ {{ $invoice->companyProfile->company }}
+ {!! $invoice->companyProfile->formatted_address !!}
+ @if ($invoice->companyProfile->phone) + {{ $invoice->companyProfile->phone }}
+ @endif + @if ($invoice->user->email) + {{ $invoice->user->email }} + @endif +
+ + + + + + + + + + + + + @foreach ($invoice->items as $item) + + + + + + + + @endforeach + + + + + + + @if ($invoice->discount > 0) + + + + + @endif + + @foreach ($invoice->summarized_taxes as $tax) + + + + + @endforeach + + + + + + + + + + + + + + +
{{ mb_strtoupper(trans('ip.product')) }}{{ mb_strtoupper(trans('ip.description')) }}{{ mb_strtoupper(trans('ip.quantity')) }}{{ mb_strtoupper(trans('ip.price')) }}{{ mb_strtoupper(trans('ip.total')) }}
{!! $item->name !!}{!! $item->formatted_description !!}{{ $item->formatted_quantity }}{{ $item->formatted_price }}{{ $item->amount->formatted_subtotal }}
{{ mb_strtoupper(trans('ip.subtotal')) }}{{ $invoice->amount->formatted_subtotal }}
{{ mb_strtoupper(trans('ip.discount')) }}{{ $invoice->amount->formatted_discount }}
{{ mb_strtoupper($tax->name) }} ({{ $tax->percent }}){{ $tax->total }}
{{ mb_strtoupper(trans('ip.total')) }}{{ $invoice->amount->formatted_total }}
{{ mb_strtoupper(trans('ip.paid')) }}{{ $invoice->amount->formatted_paid }}
{{ mb_strtoupper(trans('ip.balance')) }}{{ $invoice->amount->formatted_balance }}
+ +@if ($invoice->terms) +
{{ mb_strtoupper(trans('ip.terms_and_conditions')) }}
+
{!! $invoice->formatted_terms !!}
+@endif + + + + + \ No newline at end of file diff --git a/custom/templates/quote_templates/custom.blade.php b/custom/templates/quote_templates/custom.blade.php new file mode 100755 index 000000000..8fa96fb25 --- /dev/null +++ b/custom/templates/quote_templates/custom.blade.php @@ -0,0 +1,164 @@ + + + + + {{ trans('ip.quote') }} #{{ $quote->number }} + + + + + + + + + + +
+

{{ mb_strtoupper(trans('ip.quote')) }}

+ {{ mb_strtoupper(trans('ip.quote')) }} #{{ $quote->number }}
+ {{ mb_strtoupper(trans('ip.issued')) }} {{ $quote->formatted_created_at }}
+ {{ mb_strtoupper(trans('ip.expires')) }} {{ $quote->formatted_expires_at }} +

+ {{ mb_strtoupper(trans('ip.bill_to')) }}
{{ $quote->client->name }}
+ @if ($quote->client->address) + {!! $quote->client->formatted_address !!}
+ @endif +
+ {!! $quote->companyProfile->logo() !!}
+ {{ $quote->companyProfile->company }}
+ {!! $quote->companyProfile->formatted_address !!}
+ @if ($quote->companyProfile->phone) + {{ $quote->companyProfile->phone }}
+ @endif + @if ($quote->user->email) + {{ $quote->user->email }} + @endif +
+ + + + + + + + + + + + + @foreach ($quote->items as $item) + + + + + + + + @endforeach + + + + + + + @if ($quote->discount > 0) + + + + + @endif + + @foreach ($quote->summarized_taxes as $tax) + + + + + @endforeach + + + + + + +
{{ mb_strtoupper(trans('ip.product')) }}{{ mb_strtoupper(trans('ip.description')) }}{{ mb_strtoupper(trans('ip.quantity')) }}{{ mb_strtoupper(trans('ip.price')) }}{{ mb_strtoupper(trans('ip.total')) }}
{!! $item->name !!}{!! $item->formatted_description !!}{{ $item->formatted_quantity }}{{ $item->formatted_price }}{{ $item->amount->formatted_subtotal }}
{{ mb_strtoupper(trans('ip.subtotal')) }}{{ $quote->amount->formatted_subtotal }}
{{ mb_strtoupper(trans('ip.discount')) }}{{ $quote->amount->formatted_discount }}
{{ mb_strtoupper($tax->name) }} ({{ $tax->percent }}){{ $tax->total }}
{{ mb_strtoupper(trans('ip.total')) }}{{ $quote->amount->formatted_total }}
+ +@if ($quote->terms) +
{{ mb_strtoupper(trans('ip.terms_and_conditions')) }}
+
{!! $quote->formatted_terms !!}
+@endif + + + + + \ No newline at end of file diff --git a/phpunit.txt b/phpunit.txt new file mode 100644 index 000000000..6c11aea6c --- /dev/null +++ b/phpunit.txt @@ -0,0 +1,353 @@ + + WARN Modules\Invoices\Tests\Unit\Peppol\FormatHandlers\FormatHandlersTest + ! it returns correct mime type → Data set "Facturae (Spain)" provided by Modules\Invoices\Tests\Unit\Peppol\FormatHandlers\FormatHandlersTest::handlerProvider has more arguments (2) than the test method accepts (1) + ! it returns correct file extension → Data set "Facturae (Spain)" provided by Modules\Invoices\Tests\Unit\Peppol\FormatHandlers\FormatHandlersTest::handlerProvider has more arguments (2) than the test method accepts (1) + ! it transforms invoice correctly → Data set "Facturae (Spain)" provided by Modules\Invoices\Tests\Unit\Peppol\FormatHandlers\FormatHandlersTest::handlerProvider has more arguments (2) than the test method accepts (1) + ! it validates basic invoice fields → Data set "Facturae (Spain)" provided by Modules\Invoices\Tests\Unit\Peppol\FormatHandlers\FormatHandlersTest::handlerProvider has more arguments (2) than the test method accepts (1) + ! it validates missing customer → Data set "Facturae (Spain)" provided by Modules\Invoices\Tests\Unit\Peppol\FormatHandlers\FormatHandlersTest::handlerProvider has more arguments (2) than the test method accepts (1) + ! it validates missing invoice number → Data set "Facturae (Spain)" provided by Modules\Invoices\Tests\Unit\Peppol\FormatHandlers\FormatHandlersTest::handlerProvider has more arguments (2) than the test method accepts (1) + ! it validates missing items → Data set "Facturae (Spain)" provided by Modules\Invoices\Tests\Unit\Peppol\FormatHandlers\FormatHandlersTest::handlerProvider has more arguments (2) than the test method accepts (1) + ! it generates xml → Data set "Facturae (Spain)" provided by Modules\Invoices\Tests\Unit\Peppol\FormatHandlers\FormatHandlersTest::handlerProvider has more arguments (2) than the test method accepts (1) + + FAIL Modules\Core\Tests\Unit\Services\NumberingCompanyIsolationTest + ⨯ it allows changing expense numbering with year month 0.23s + ⨯ it recalculates next id when set to lower value for troubleshooting 0.02s + + FAIL Modules\Core\Tests\Unit\Services\NumberingServiceTest + ⨯ it creates a numbering 0.03s + ⨯ it auto sets prefix from type when not provided 0.02s + ⨯ it converts starting id to next id 0.02s + ✓ it generates formatted number preview 0.02s + ⨯ it deletes numbering when not in use 0.02s + ✓ it checks if numbering is applied 0.02s + ✓ it increments numbers correctly 0.02s + + FAIL Modules\Core\Tests\Unit\SettingsTest + ⨯ it filters numberings by current company id 0.08s + ⨯ it handles no current company id in session 0.05s + ⨯ it returns empty options when no numberings exist 0.06s + ⨯ it switches company context properly 0.05s + ⨯ it loads default settings properly 0.06s + ⨯ it validates update check interval boundaries 0.05s + ⨯ it validates email format for notifications 0.05s + ⨯ it has all required tabs 0.06s + ⨯ it persists settings 0.05s + + FAIL Modules\Invoices\Tests\Unit\Actions\SendInvoiceToPeppolActionTest + ⨯ it executes successfully with valid invoice 0.03s + ──────────────────────────────────────────────────────────────────────────── + FAILED Modules\Core\Tests\Unit\Services\NumberingCompanyIsolationTest >… + Failed asserting that null matches expected 'EXP-0001'. + + at Modules/Core/Tests/Unit/Services/NumberingCompanyIsolationTest.php:154 + 150▕ // Generate third number with new format + 151▕ $thirdNumber = $generator->forNumberingId($numbering->id)->generate(); + 152▕ + 153▕ /* Assert */ + ➜ 154▕ $this->assertEquals('EXP-0001', $firstNumber); + 155▕ $this->assertEquals('EXP-0002', $secondNumber); + 156▕ $this->assertEquals('EXP-2025-12-0003', $thirdNumber); // Number continues + 157▕ } + 158▕ + + 1 Modules/Core/Tests/Unit/Services/NumberingCompanyIsolationTest.php:154 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Modules\Core\Tests\Unit\Services\NumberingCompany… QueryException + SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: tasks.customer_id (Connection: sqlite, SQL: insert into "tasks" ("company_id", "customer_id", "task_number", "assigned_to", "task_status", "task_name", "task_price", "due_at", "description") values (17, ?, TSK-45529, ?, cancelled, autem perferendis pariatur, 80.4045, 2025-05-12 00:00:00, ?)) + + at vendor/laravel/framework/src/Illuminate/Database/Connection.php:826 + 822▕ $this->getName(), $query, $this->prepareBindings($bindings), $e + 823▕ ); + 824▕ } + 825▕ + ➜ 826▕ throw new QueryException( + 827▕ $this->getName(), $query, $this->prepareBindings($bindings), $e + 828▕ ); + 829▕ } + 830▕ } + + +18 vendor frames  + 19 Modules/Core/Tests/Unit/Services/NumberingCompanyIsolationTest.php:227 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Modules\Core\Tests\Unit\Services\NumberingService… QueryException + SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: numbering.last_id (Connection: sqlite, SQL: insert into "numbering" ("type", "name", "next_id", "left_pad", "format", "prefix", "company_id", "last_id") values (Project, Test Numbering, 1, 4, PRJ-{{number}}, PRJ, 1, ?)) + + at vendor/laravel/framework/src/Illuminate/Database/Connection.php:826 + 822▕ $this->getName(), $query, $this->prepareBindings($bindings), $e + 823▕ ); + 824▕ } + 825▕ + ➜ 826▕ throw new QueryException( + 827▕ $this->getName(), $query, $this->prepareBindings($bindings), $e + 828▕ ); + 829▕ } + 830▕ } + + +16 vendor frames  + 17 Modules/Core/Services/NumberingService.php:31 + +3 vendor frames  + 21 Modules/Core/Services/NumberingService.php:26 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Modules\Core\Tests\Unit\Services\NumberingService… QueryException + SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: numbering.last_id (Connection: sqlite, SQL: insert into "numbering" ("type", "name", "next_id", "left_pad", "company_id", "last_id", "prefix") values (Project, Test Numbering, 1, 4, 1, ?, PRJ)) + + at vendor/laravel/framework/src/Illuminate/Database/Connection.php:826 + 822▕ $this->getName(), $query, $this->prepareBindings($bindings), $e + 823▕ ); + 824▕ } + 825▕ + ➜ 826▕ throw new QueryException( + 827▕ $this->getName(), $query, $this->prepareBindings($bindings), $e + 828▕ ); + 829▕ } + 830▕ } + + +16 vendor frames  + 17 Modules/Core/Services/NumberingService.php:31 + +3 vendor frames  + 21 Modules/Core/Services/NumberingService.php:26 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Modules\Core\Tests\Unit\Services\NumberingService… QueryException + SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: numbering.last_id (Connection: sqlite, SQL: insert into "numbering" ("type", "name", "left_pad", "company_id", "next_id", "last_id", "prefix") values (Project, Project Numbering, 4, 1, 100, ?, PRJ)) + + at vendor/laravel/framework/src/Illuminate/Database/Connection.php:826 + 822▕ $this->getName(), $query, $this->prepareBindings($bindings), $e + 823▕ ); + 824▕ } + 825▕ + ➜ 826▕ throw new QueryException( + 827▕ $this->getName(), $query, $this->prepareBindings($bindings), $e + 828▕ ); + 829▕ } + 830▕ } + + +16 vendor frames  + 17 Modules/Core/Services/NumberingService.php:31 + +3 vendor frames  + 21 Modules/Core/Services/NumberingService.php:26 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Modules\Core\Tests\Unit\Services\NumberingService… QueryException + SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: numbering.company_id (Connection: sqlite, SQL: insert into "numbering" ("company_id", "type", "name", "next_id", "left_pad", "format", "prefix", "last_id") values (?, Project, Test Numbering, 1, 3, {{prefix}}-{{number}}, PAY, 0)) + + at vendor/laravel/framework/src/Illuminate/Database/Connection.php:826 + 822▕ $this->getName(), $query, $this->prepareBindings($bindings), $e + 823▕ ); + 824▕ } + 825▕ + ➜ 826▕ throw new QueryException( + 827▕ $this->getName(), $query, $this->prepareBindings($bindings), $e + 828▕ ); + 829▕ } + 830▕ } + + +18 vendor frames  + 19 Modules/Core/Tests/Unit/Services/NumberingServiceTest.php:127 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Modules\Core\Tests\Unit\SettingsTest > it filters… ViewException + Unable to locate a class or view for component [filament-panels::form.actions]. (View: /data/Projects/ivplv2/Modules/Core/resources/views/filament/admin/pages/settings.blade.php) + + at vendor/laravel/framework/src/Illuminate/View/Compilers/ComponentTagCompiler.php:315 + 311▕ if (Str::startsWith($component, 'mail::')) { + 312▕ return $component; + 313▕ } + 314▕ + ➜ 315▕ throw new InvalidArgumentException( + 316▕ "Unable to locate a class or view for component [{$component}]." + 317▕ ); + 318▕ } + 319▕ + + +23 vendor frames  + 24 storage/framework/views/de79b4abf489da38abd7c88b7c8b2888.php:11 + +35 vendor frames  + 60 Modules/Core/Tests/Unit/SettingsTest.php:56 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Modules\Core\Tests\Unit\SettingsTest > it handles… ViewException + Unable to locate a class or view for component [filament-panels::form.actions]. (View: /data/Projects/ivplv2/Modules/Core/resources/views/filament/admin/pages/settings.blade.php) + + at vendor/laravel/framework/src/Illuminate/View/Compilers/ComponentTagCompiler.php:315 + 311▕ if (Str::startsWith($component, 'mail::')) { + 312▕ return $component; + 313▕ } + 314▕ + ➜ 315▕ throw new InvalidArgumentException( + 316▕ "Unable to locate a class or view for component [{$component}]." + 317▕ ); + 318▕ } + 319▕ + + +23 vendor frames  + 24 storage/framework/views/de79b4abf489da38abd7c88b7c8b2888.php:11 + +33 vendor frames  + 58 Modules/Core/Tests/Unit/SettingsTest.php:99 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Modules\Core\Tests\Unit\SettingsTest > it returns… ViewException + Unable to locate a class or view for component [filament-panels::form.actions]. (View: /data/Projects/ivplv2/Modules/Core/resources/views/filament/admin/pages/settings.blade.php) + + at vendor/laravel/framework/src/Illuminate/View/Compilers/ComponentTagCompiler.php:315 + 311▕ if (Str::startsWith($component, 'mail::')) { + 312▕ return $component; + 313▕ } + 314▕ + ➜ 315▕ throw new InvalidArgumentException( + 316▕ "Unable to locate a class or view for component [{$component}]." + 317▕ ); + 318▕ } + 319▕ + + +23 vendor frames  + 24 storage/framework/views/de79b4abf489da38abd7c88b7c8b2888.php:11 + +33 vendor frames  + 58 Modules/Core/Tests/Unit/SettingsTest.php:117 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Modules\Core\Tests\Unit\SettingsTest > it switches… ViewException + Unable to locate a class or view for component [filament-panels::form.actions]. (View: /data/Projects/ivplv2/Modules/Core/resources/views/filament/admin/pages/settings.blade.php) + + at vendor/laravel/framework/src/Illuminate/View/Compilers/ComponentTagCompiler.php:315 + 311▕ if (Str::startsWith($component, 'mail::')) { + 312▕ return $component; + 313▕ } + 314▕ + ➜ 315▕ throw new InvalidArgumentException( + 316▕ "Unable to locate a class or view for component [{$component}]." + 317▕ ); + 318▕ } + 319▕ + + +23 vendor frames  + 24 storage/framework/views/de79b4abf489da38abd7c88b7c8b2888.php:11 + +33 vendor frames  + 58 Modules/Core/Tests/Unit/SettingsTest.php:155 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Modules\Core\Tests\Unit\SettingsTest > it loads de… ViewException + Unable to locate a class or view for component [filament-panels::form.actions]. (View: /data/Projects/ivplv2/Modules/Core/resources/views/filament/admin/pages/settings.blade.php) + + at vendor/laravel/framework/src/Illuminate/View/Compilers/ComponentTagCompiler.php:315 + 311▕ if (Str::startsWith($component, 'mail::')) { + 312▕ return $component; + 313▕ } + 314▕ + ➜ 315▕ throw new InvalidArgumentException( + 316▕ "Unable to locate a class or view for component [{$component}]." + 317▕ ); + 318▕ } + 319▕ + + +23 vendor frames  + 24 storage/framework/views/de79b4abf489da38abd7c88b7c8b2888.php:11 + +33 vendor frames  + 58 Modules/Core/Tests/Unit/SettingsTest.php:176 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Modules\Core\Tests\Unit\SettingsTest > it validate… ViewException + Unable to locate a class or view for component [filament-panels::form.actions]. (View: /data/Projects/ivplv2/Modules/Core/resources/views/filament/admin/pages/settings.blade.php) + + at vendor/laravel/framework/src/Illuminate/View/Compilers/ComponentTagCompiler.php:315 + 311▕ if (Str::startsWith($component, 'mail::')) { + 312▕ return $component; + 313▕ } + 314▕ + ➜ 315▕ throw new InvalidArgumentException( + 316▕ "Unable to locate a class or view for component [{$component}]." + 317▕ ); + 318▕ } + 319▕ + + +23 vendor frames  + 24 storage/framework/views/de79b4abf489da38abd7c88b7c8b2888.php:11 + +33 vendor frames  + 58 Modules/Core/Tests/Unit/SettingsTest.php:202 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Modules\Core\Tests\Unit\SettingsTest > it validate… ViewException + Unable to locate a class or view for component [filament-panels::form.actions]. (View: /data/Projects/ivplv2/Modules/Core/resources/views/filament/admin/pages/settings.blade.php) + + at vendor/laravel/framework/src/Illuminate/View/Compilers/ComponentTagCompiler.php:315 + 311▕ if (Str::startsWith($component, 'mail::')) { + 312▕ return $component; + 313▕ } + 314▕ + ➜ 315▕ throw new InvalidArgumentException( + 316▕ "Unable to locate a class or view for component [{$component}]." + 317▕ ); + 318▕ } + 319▕ + + +23 vendor frames  + 24 storage/framework/views/de79b4abf489da38abd7c88b7c8b2888.php:11 + +33 vendor frames  + 58 Modules/Core/Tests/Unit/SettingsTest.php:229 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Modules\Core\Tests\Unit\SettingsTest > it has all… ViewException + Unable to locate a class or view for component [filament-panels::form.actions]. (View: /data/Projects/ivplv2/Modules/Core/resources/views/filament/admin/pages/settings.blade.php) + + at vendor/laravel/framework/src/Illuminate/View/Compilers/ComponentTagCompiler.php:315 + 311▕ if (Str::startsWith($component, 'mail::')) { + 312▕ return $component; + 313▕ } + 314▕ + ➜ 315▕ throw new InvalidArgumentException( + 316▕ "Unable to locate a class or view for component [{$component}]." + 317▕ ); + 318▕ } + 319▕ + + +23 vendor frames  + 24 storage/framework/views/de79b4abf489da38abd7c88b7c8b2888.php:11 + +33 vendor frames  + 58 Modules/Core/Tests/Unit/SettingsTest.php:252 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Modules\Core\Tests\Unit\SettingsTest > it persists… ViewException + Unable to locate a class or view for component [filament-panels::form.actions]. (View: /data/Projects/ivplv2/Modules/Core/resources/views/filament/admin/pages/settings.blade.php) + + at vendor/laravel/framework/src/Illuminate/View/Compilers/ComponentTagCompiler.php:315 + 311▕ if (Str::startsWith($component, 'mail::')) { + 312▕ return $component; + 313▕ } + 314▕ + ➜ 315▕ throw new InvalidArgumentException( + 316▕ "Unable to locate a class or view for component [{$component}]." + 317▕ ); + 318▕ } + 319▕ + + +23 vendor frames  + 24 storage/framework/views/de79b4abf489da38abd7c88b7c8b2888.php:11 + +33 vendor frames  + 58 Modules/Core/Tests/Unit/SettingsTest.php:274 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Modules\Invoices\Tests\Unit\Actions\SendInvoiceTo… QueryException + SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: relations.company_id (Connection: sqlite, SQL: insert into "relations" ("company_id", "relation_type", "relation_status", "relation_number", "company_name", "trading_name", "unique_name", "id_number", "coc_number", "vat_number", "currency_code", "language", "registered_at") values (?, prospect, inactive, xf804416, Bogan-Thiel, Bogan-Thiel PLC, bogan-thiel-plc, ?, ?, LU093840255, ?, ?, 2024-10-29)) + + at vendor/laravel/framework/src/Illuminate/Database/Connection.php:826 + 822▕ $this->getName(), $query, $this->prepareBindings($bindings), $e + 823▕ ); + 824▕ } + 825▕ + ➜ 826▕ throw new QueryException( + 827▕ $this->getName(), $query, $this->prepareBindings($bindings), $e + 828▕ ); + 829▕ } + 830▕ } + + +27 vendor frames  + 28 Modules/Invoices/Tests/Unit/Actions/SendInvoiceToPeppolActionTest.php:265 + 29 Modules/Invoices/Tests/Unit/Actions/SendInvoiceToPeppolActionTest.php:60 + + + Tests: 16 failed, 8 warnings, 3 passed (7 assertions) + Duration: 1.06s + diff --git a/public/fonts/filament/filament/inter/index.css b/public/fonts/filament/filament/inter/index.css new file mode 100644 index 000000000..425213ef4 --- /dev/null +++ b/public/fonts/filament/filament/inter/index.css @@ -0,0 +1 @@ +@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-ext-wght-normal-IYF56FF6.woff2") format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-wght-normal-JEOLYBOO.woff2") format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-ext-wght-normal-EOVOK2B5.woff2") format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-wght-normal-IRE366VL.woff2") format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-vietnamese-wght-normal-CE5GGD3W.woff2") format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-ext-wght-normal-HA22NDSG.woff2") format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-wght-normal-NRMW37G5.woff2") format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD} diff --git a/public/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-ASVAGXXE.woff2 b/public/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-ASVAGXXE.woff2 new file mode 100644 index 000000000..0ba164bb6 Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-ASVAGXXE.woff2 differ diff --git a/public/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-IYF56FF6.woff2 b/public/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-IYF56FF6.woff2 new file mode 100644 index 000000000..de83a9c74 Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-IYF56FF6.woff2 differ diff --git a/public/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-XKHXBTUO.woff2 b/public/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-XKHXBTUO.woff2 new file mode 100644 index 000000000..a61a0be57 Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-XKHXBTUO.woff2 differ diff --git a/public/fonts/filament/filament/inter/inter-cyrillic-wght-normal-EWLSKVKN.woff2 b/public/fonts/filament/filament/inter/inter-cyrillic-wght-normal-EWLSKVKN.woff2 new file mode 100644 index 000000000..83a6f10f2 Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-cyrillic-wght-normal-EWLSKVKN.woff2 differ diff --git a/public/fonts/filament/filament/inter/inter-cyrillic-wght-normal-JEOLYBOO.woff2 b/public/fonts/filament/filament/inter/inter-cyrillic-wght-normal-JEOLYBOO.woff2 new file mode 100644 index 000000000..d75091476 Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-cyrillic-wght-normal-JEOLYBOO.woff2 differ diff --git a/public/fonts/filament/filament/inter/inter-cyrillic-wght-normal-R5CMSONN.woff2 b/public/fonts/filament/filament/inter/inter-cyrillic-wght-normal-R5CMSONN.woff2 new file mode 100644 index 000000000..b655a4388 Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-cyrillic-wght-normal-R5CMSONN.woff2 differ diff --git a/public/fonts/filament/filament/inter/inter-greek-ext-wght-normal-7GGTF7EK.woff2 b/public/fonts/filament/filament/inter/inter-greek-ext-wght-normal-7GGTF7EK.woff2 new file mode 100644 index 000000000..cf56a71f1 Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-greek-ext-wght-normal-7GGTF7EK.woff2 differ diff --git a/public/fonts/filament/filament/inter/inter-greek-ext-wght-normal-EOVOK2B5.woff2 b/public/fonts/filament/filament/inter/inter-greek-ext-wght-normal-EOVOK2B5.woff2 new file mode 100644 index 000000000..6e7141f88 Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-greek-ext-wght-normal-EOVOK2B5.woff2 differ diff --git a/public/fonts/filament/filament/inter/inter-greek-ext-wght-normal-ZEVLMORV.woff2 b/public/fonts/filament/filament/inter/inter-greek-ext-wght-normal-ZEVLMORV.woff2 new file mode 100644 index 000000000..9117b5b04 Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-greek-ext-wght-normal-ZEVLMORV.woff2 differ diff --git a/public/fonts/filament/filament/inter/inter-greek-wght-normal-AXVTPQD5.woff2 b/public/fonts/filament/filament/inter/inter-greek-wght-normal-AXVTPQD5.woff2 new file mode 100644 index 000000000..eb38b38ea Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-greek-wght-normal-AXVTPQD5.woff2 differ diff --git a/public/fonts/filament/filament/inter/inter-greek-wght-normal-IRE366VL.woff2 b/public/fonts/filament/filament/inter/inter-greek-wght-normal-IRE366VL.woff2 new file mode 100644 index 000000000..024f07703 Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-greek-wght-normal-IRE366VL.woff2 differ diff --git a/public/fonts/filament/filament/inter/inter-greek-wght-normal-N43DBLU2.woff2 b/public/fonts/filament/filament/inter/inter-greek-wght-normal-N43DBLU2.woff2 new file mode 100644 index 000000000..907b4a4d7 Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-greek-wght-normal-N43DBLU2.woff2 differ diff --git a/public/fonts/filament/filament/inter/inter-latin-ext-wght-normal-5SRY4DMZ.woff2 b/public/fonts/filament/filament/inter/inter-latin-ext-wght-normal-5SRY4DMZ.woff2 new file mode 100644 index 000000000..887153b81 Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-latin-ext-wght-normal-5SRY4DMZ.woff2 differ diff --git a/public/fonts/filament/filament/inter/inter-latin-ext-wght-normal-GZCIV3NH.woff2 b/public/fonts/filament/filament/inter/inter-latin-ext-wght-normal-GZCIV3NH.woff2 new file mode 100644 index 000000000..3df865d7f Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-latin-ext-wght-normal-GZCIV3NH.woff2 differ diff --git a/public/fonts/filament/filament/inter/inter-latin-ext-wght-normal-HA22NDSG.woff2 b/public/fonts/filament/filament/inter/inter-latin-ext-wght-normal-HA22NDSG.woff2 new file mode 100644 index 000000000..479d010d2 Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-latin-ext-wght-normal-HA22NDSG.woff2 differ diff --git a/public/fonts/filament/filament/inter/inter-latin-wght-normal-NRMW37G5.woff2 b/public/fonts/filament/filament/inter/inter-latin-wght-normal-NRMW37G5.woff2 new file mode 100644 index 000000000..d15208de0 Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-latin-wght-normal-NRMW37G5.woff2 differ diff --git a/public/fonts/filament/filament/inter/inter-latin-wght-normal-O25CN4JL.woff2 b/public/fonts/filament/filament/inter/inter-latin-wght-normal-O25CN4JL.woff2 new file mode 100644 index 000000000..798d6d9f6 Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-latin-wght-normal-O25CN4JL.woff2 differ diff --git a/public/fonts/filament/filament/inter/inter-latin-wght-normal-OPIJAQLS.woff2 b/public/fonts/filament/filament/inter/inter-latin-wght-normal-OPIJAQLS.woff2 new file mode 100644 index 000000000..40255432a Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-latin-wght-normal-OPIJAQLS.woff2 differ diff --git a/public/fonts/filament/filament/inter/inter-vietnamese-wght-normal-CE5GGD3W.woff2 b/public/fonts/filament/filament/inter/inter-vietnamese-wght-normal-CE5GGD3W.woff2 new file mode 100644 index 000000000..a40c4699c Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-vietnamese-wght-normal-CE5GGD3W.woff2 differ diff --git a/public/fonts/filament/filament/inter/inter-vietnamese-wght-normal-TWG5UU7E.woff2 b/public/fonts/filament/filament/inter/inter-vietnamese-wght-normal-TWG5UU7E.woff2 new file mode 100644 index 000000000..ce21ca172 Binary files /dev/null and b/public/fonts/filament/filament/inter/inter-vietnamese-wght-normal-TWG5UU7E.woff2 differ