From a2724e6102a34ac47e3f103c7c413d12fadbaada Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Sat, 4 Jul 2026 14:23:53 +0200 Subject: [PATCH] feat(clients): client detail view (#221) Co-Authored-By: Claude Fable 5 --- .gitignore | 5 + .../RelationHasLinkedRecordsException.php | 13 ++ .../Relations/Pages/ViewRelation.php | 54 +++++++ .../ExpensesRelationManager.php | 23 +++ .../InvoicesRelationManager.php | 38 +++++ .../ProjectsRelationManager.php | 24 ++++ .../QuotesRelationManager.php | 38 +++++ .../RelationManagers/TasksRelationManager.php | 24 ++++ .../Resources/Relations/RelationResource.php | 47 +++++- .../Relations/Tables/RelationsTable.php | 35 ++++- Modules/Clients/Models/Relation.php | 14 ++ Modules/Clients/Services/RelationService.php | 22 +++ .../Feature/RelationListRowActionsTest.php | 134 ++++++++++++++++++ .../Tests/Feature/ViewRelationTest.php | 76 ++++++++++ Modules/Core/Database/Seeders/RolesSeeder.php | 1 + .../Core/Tests/AbstractAdminPanelTestCase.php | 11 ++ .../Tests/AbstractCompanyPanelTestCase.php | 11 ++ .../Resources/Invoices/InvoiceResource.php | 4 +- .../Invoices/Pages/CreateInvoice.php | 19 +++ .../Invoices/Tests/Feature/InvoicesTest.php | 25 ++-- .../Resources/Quotes/Pages/CreateQuote.php | 20 +++ .../Resources/Quotes/QuoteResource.php | 4 +- Modules/Quotes/Tests/Feature/QuotesTest.php | 9 +- composer.json | 6 +- pint_output.log | 5 + resources/lang/en/ip.php | 3 +- 26 files changed, 641 insertions(+), 24 deletions(-) create mode 100644 Modules/Clients/Exceptions/RelationHasLinkedRecordsException.php create mode 100644 Modules/Clients/Filament/Company/Resources/Relations/Pages/ViewRelation.php create mode 100644 Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/ExpensesRelationManager.php create mode 100644 Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/InvoicesRelationManager.php create mode 100644 Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/ProjectsRelationManager.php create mode 100644 Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/QuotesRelationManager.php create mode 100644 Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/TasksRelationManager.php create mode 100644 Modules/Clients/Tests/Feature/RelationListRowActionsTest.php create mode 100644 Modules/Clients/Tests/Feature/ViewRelationTest.php create mode 100644 pint_output.log diff --git a/.gitignore b/.gitignore index 375301ed7..bac5879a3 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,8 @@ package-lock.json *.sqlite /failures.txt /yarnpack.txt +/automation/.idea/ +/automation/vendor/ +/automation/test-honesty/vendor/ +.claude/fable5/runtime/control.json +upd.sh diff --git a/Modules/Clients/Exceptions/RelationHasLinkedRecordsException.php b/Modules/Clients/Exceptions/RelationHasLinkedRecordsException.php new file mode 100644 index 000000000..73081f23e --- /dev/null +++ b/Modules/Clients/Exceptions/RelationHasLinkedRecordsException.php @@ -0,0 +1,13 @@ +label(trans('ip.create_invoice')) + ->icon('heroicon-o-document-plus') + ->url(fn (): string => InvoiceResource::getUrl('create', [ + 'customer_id' => $this->getRecord()->id, + ])), + + Action::make('create_quote') + ->label(trans('ip.create_quote')) + ->icon('heroicon-o-document-text') + ->url(fn (): string => QuoteResource::getUrl('create', [ + 'customer_id' => $this->getRecord()->id, + ])), + + EditAction::make(), + + DeleteAction::make() + ->hidden(fn () => $this->getRecord()->hasLinkedRecords()) + ->action(function (): void { + try { + app(RelationService::class)->deleteRelation($this->getRecord()); + $this->redirect(RelationResource::getUrl('index')); + } catch (RelationHasLinkedRecordsException $e) { + Notification::make() + ->title(trans('ip.cannot_delete_client_has_linked_records')) + ->danger() + ->send(); + } + }), + ]; + } +} diff --git a/Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/ExpensesRelationManager.php b/Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/ExpensesRelationManager.php new file mode 100644 index 000000000..470f3a94b --- /dev/null +++ b/Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/ExpensesRelationManager.php @@ -0,0 +1,23 @@ +columns([ + TextColumn::make('expense_number')->sortable()->searchable(), + TextColumn::make('expensed_at')->date()->sortable(), + TextColumn::make('description')->limit(40)->searchable(), + TextColumn::make('expense_amount')->money()->sortable(), + ]); + } +} diff --git a/Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/InvoicesRelationManager.php b/Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/InvoicesRelationManager.php new file mode 100644 index 000000000..048c9cf05 --- /dev/null +++ b/Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/InvoicesRelationManager.php @@ -0,0 +1,38 @@ +columns([ + TextColumn::make('invoice_number')->sortable()->searchable(), + TextColumn::make('invoice_status') + ->badge() + ->formatStateUsing(fn ($state) => ($state instanceof InvoiceStatus ? $state : InvoiceStatus::tryFrom($state))?->label()) + ->color(fn ($state) => ($state instanceof InvoiceStatus ? $state : InvoiceStatus::tryFrom($state))?->color() ?? 'secondary') + ->sortable(), + TextColumn::make('invoiced_at')->date()->sortable(), + TextColumn::make('invoice_due_at')->date()->sortable(), + TextColumn::make('invoice_total')->money()->sortable(), + ]) + ->headerActions([ + Action::make('create_invoice') + ->label(trans('ip.create_invoice')) + ->url(fn (): string => InvoiceResource::getUrl('create', [ + 'customer_id' => $this->getOwnerRecord()->id, + ])), + ]); + } +} diff --git a/Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/ProjectsRelationManager.php b/Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/ProjectsRelationManager.php new file mode 100644 index 000000000..de9ea332e --- /dev/null +++ b/Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/ProjectsRelationManager.php @@ -0,0 +1,24 @@ +columns([ + TextColumn::make('project_number')->sortable()->searchable(), + TextColumn::make('project_name')->limit(40)->sortable()->searchable(), + TextColumn::make('project_status')->sortable()->badge(), + TextColumn::make('start_at')->date()->sortable(), + TextColumn::make('end_at')->date()->sortable(), + ]); + } +} diff --git a/Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/QuotesRelationManager.php b/Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/QuotesRelationManager.php new file mode 100644 index 000000000..5087becb3 --- /dev/null +++ b/Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/QuotesRelationManager.php @@ -0,0 +1,38 @@ +columns([ + TextColumn::make('quote_number')->sortable()->searchable(), + TextColumn::make('quote_status') + ->badge() + ->formatStateUsing(fn ($state) => ($state instanceof QuoteStatus ? $state : QuoteStatus::tryFrom($state))?->label()) + ->color(fn ($state) => ($state instanceof QuoteStatus ? $state : QuoteStatus::tryFrom($state))?->color() ?? 'secondary') + ->sortable(), + TextColumn::make('quoted_at')->date()->sortable(), + TextColumn::make('quote_expires_at')->date()->sortable(), + TextColumn::make('quote_total')->money()->sortable(), + ]) + ->headerActions([ + Action::make('create_quote') + ->label(trans('ip.create_quote')) + ->url(fn (): string => QuoteResource::getUrl('create', [ + 'customer_id' => $this->getOwnerRecord()->id, + ])), + ]); + } +} diff --git a/Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/TasksRelationManager.php b/Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/TasksRelationManager.php new file mode 100644 index 000000000..ef0b462f9 --- /dev/null +++ b/Modules/Clients/Filament/Company/Resources/Relations/RelationManagers/TasksRelationManager.php @@ -0,0 +1,24 @@ +columns([ + TextColumn::make('task_number')->sortable()->searchable(), + TextColumn::make('task_name')->limit(40)->sortable()->searchable(), + TextColumn::make('task_status')->sortable()->badge(), + TextColumn::make('due_at')->date()->sortable(), + TextColumn::make('task_price')->money()->sortable(), + ]); + } +} diff --git a/Modules/Clients/Filament/Company/Resources/Relations/RelationResource.php b/Modules/Clients/Filament/Company/Resources/Relations/RelationResource.php index ecd602e30..41a563fec 100644 --- a/Modules/Clients/Filament/Company/Resources/Relations/RelationResource.php +++ b/Modules/Clients/Filament/Company/Resources/Relations/RelationResource.php @@ -3,16 +3,28 @@ namespace Modules\Clients\Filament\Company\Resources\Relations; use BackedEnum; +use Filament\Infolists\Components\TextEntry; +use Filament\Schemas\Components\Grid; +use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; use Filament\Support\Icons\Heroicon; use Filament\Tables\Table; +use Modules\Clients\Enums\RelationStatus; +use Modules\Clients\Enums\RelationType; use Illuminate\Database\Eloquent\Model; use Modules\Clients\Filament\Company\Resources\Relations\Pages\ListRelations; +use Modules\Clients\Filament\Company\Resources\Relations\Pages\ViewRelation; +use Modules\Clients\Filament\Company\Resources\Relations\RelationManagers\ExpensesRelationManager; +use Modules\Clients\Filament\Company\Resources\Relations\RelationManagers\InvoicesRelationManager; +use Modules\Clients\Filament\Company\Resources\Relations\RelationManagers\ProjectsRelationManager; +use Modules\Clients\Filament\Company\Resources\Relations\RelationManagers\QuotesRelationManager; +use Modules\Clients\Filament\Company\Resources\Relations\RelationManagers\TasksRelationManager; use Modules\Clients\Filament\Company\Resources\Relations\Schemas\RelationForm; use Modules\Clients\Filament\Company\Resources\Relations\Tables\RelationsTable; use Modules\Clients\Models\Relation; use Modules\Core\Enums\Permission; use Modules\Core\Filament\Company\Resources\BaseResource; +use Modules\Core\Helpers\EnumHelper; class RelationResource extends BaseResource { @@ -30,15 +42,48 @@ public static function table(Table $table): Table return RelationsTable::configure($table); } + public static function infolist(Schema $schema): Schema + { + return $schema->components([ + Section::make(trans('ip.client_information')) + ->schema([ + Grid::make(3)->schema([ + TextEntry::make('company_name')->label(trans('ip.company_name')), + TextEntry::make('trading_name')->label(trans('ip.trading_name')), + TextEntry::make('relation_number')->label(trans('ip.relation_number')), + TextEntry::make('relation_type') + ->label(trans('ip.type')) + ->formatStateUsing(fn ($state) => EnumHelper::safeEnum(RelationType::class, $state)?->label() ?? '-') + ->badge(), + TextEntry::make('relation_status') + ->label(trans('ip.status')) + ->formatStateUsing(fn ($state) => EnumHelper::safeEnum(RelationStatus::class, $state)?->label() ?? '-') + ->badge(), + TextEntry::make('registered_at')->label(trans('ip.date'))->date(), + TextEntry::make('coc_number')->label(trans('ip.coc_number')), + TextEntry::make('vat_number')->label(trans('ip.vat_id')), + TextEntry::make('currency_code')->label(trans('ip.currency')), + ]), + ]), + ]); + } + public static function getRelations(): array { - return []; + return [ + InvoicesRelationManager::class, + QuotesRelationManager::class, + ExpensesRelationManager::class, + TasksRelationManager::class, + ProjectsRelationManager::class, + ]; } public static function getPages(): array { return [ 'index' => ListRelations::route('/'), + 'view' => ViewRelation::route('/{record}'), ]; } diff --git a/Modules/Clients/Filament/Company/Resources/Relations/Tables/RelationsTable.php b/Modules/Clients/Filament/Company/Resources/Relations/Tables/RelationsTable.php index c3fb4ee03..c4f7b9070 100644 --- a/Modules/Clients/Filament/Company/Resources/Relations/Tables/RelationsTable.php +++ b/Modules/Clients/Filament/Company/Resources/Relations/Tables/RelationsTable.php @@ -2,19 +2,26 @@ namespace Modules\Clients\Filament\Company\Resources\Relations\Tables; +use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteAction; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; +use Filament\Actions\ViewAction; +use Filament\Notifications\Notification; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Modules\Clients\Enums\RelationStatus; use Modules\Clients\Enums\RelationType; +use Modules\Clients\Exceptions\RelationHasLinkedRecordsException; use Modules\Clients\Models\Relation; use Modules\Clients\Services\CustomerService; +use Modules\Clients\Services\RelationService; use Modules\Core\Enums\Permission; use Modules\Core\Helpers\EnumHelper; +use Modules\Invoices\Filament\Company\Resources\Invoices\InvoiceResource; +use Modules\Quotes\Filament\Company\Resources\Quotes\QuoteResource; class RelationsTable { @@ -88,6 +95,22 @@ public static function configure(Table $table): Table ->filters([]) ->recordActions([ ActionGroup::make([ + ViewAction::make('view'), + Action::make('create_invoice') + ->label(trans('ip.create_invoice')) + ->icon('heroicon-o-document-plus') + ->visible(fn (Relation $record) => $record->relation_type === RelationType::CUSTOMER + && auth()->user()?->can(Permission::CREATE_INVOICES->value)) + ->url(fn (Relation $record): string => InvoiceResource::getUrl('create', [ + 'customer_id' => $record->id, + ])), + Action::make('create_quote') + ->label(trans('ip.create_quote')) + ->icon('heroicon-o-document-text') + ->visible(fn () => auth()->user()?->can(Permission::CREATE_QUOTES->value)) + ->url(fn (Relation $record): string => QuoteResource::getUrl('create', [ + 'customer_id' => $record->id, + ])), EditAction::make('edit') ->visible(fn () => auth()->user()?->can(Permission::EDIT_RELATIONS->value)) ->action(function (Relation $record, array $data) { @@ -95,9 +118,17 @@ public static function configure(Table $table): Table }) ->modalWidth('full'), DeleteAction::make('delete') - ->visible(fn () => auth()->user()?->can(Permission::DELETE_RELATIONS->value)) + ->visible(fn (Relation $record) => ! $record->hasLinkedRecords() + && auth()->user()?->can(Permission::DELETE_RELATIONS->value)) ->action(function (Relation $record, array $data) { - app(\Modules\Clients\Services\RelationService::class)->deleteRelation($record); + try { + app(RelationService::class)->deleteRelation($record); + } catch (RelationHasLinkedRecordsException) { + Notification::make() + ->title(trans('ip.cannot_delete_client_has_linked_records')) + ->danger() + ->send(); + } }), ]), ]) diff --git a/Modules/Clients/Models/Relation.php b/Modules/Clients/Models/Relation.php index 42f190a86..03baab260 100644 --- a/Modules/Clients/Models/Relation.php +++ b/Modules/Clients/Models/Relation.php @@ -158,6 +158,20 @@ public function tasks(): HasMany return $this->hasMany(Task::class, 'customer_id'); } + /* + * Deleting a relation with linked records would orphan financial + * history, so delete actions and the service guard both check this. + */ + public function hasLinkedRecords(): bool + { + return $this->invoices()->withoutGlobalScopes()->exists() + || $this->quotes()->withoutGlobalScopes()->exists() + || $this->payments()->withoutGlobalScopes()->exists() + || $this->expenses()->withoutGlobalScopes()->exists() + || $this->tasks()->withoutGlobalScopes()->exists() + || $this->projects()->withoutGlobalScopes()->exists(); + } + /** * Define a one-to-many relationship to User models. * diff --git a/Modules/Clients/Services/RelationService.php b/Modules/Clients/Services/RelationService.php index a25c72730..2dc4d3fa2 100644 --- a/Modules/Clients/Services/RelationService.php +++ b/Modules/Clients/Services/RelationService.php @@ -8,6 +8,7 @@ use Modules\Clients\Enums\RelationType; use Modules\Clients\Events\CustomerWasCreated; use Modules\Clients\Events\CustomerWasUpdated; +use Modules\Clients\Exceptions\RelationHasLinkedRecordsException; use Modules\Clients\Models\Relation; use Modules\Core\Services\BaseService; use Throwable; @@ -105,8 +106,16 @@ public function updateRelation(Relation $relation, array $data): Relation public function deleteRelation(Relation $relation): Relation { + $this->guardAgainstLinkedRecords($relation); + DB::beginTransaction(); try { + /* + * Contacts belong to the client (contacts.relation_id is + * ON DELETE RESTRICT), so they go with it; the relation's + * primary_contact_id FK is SET NULL, making this order safe. + */ + $relation->contacts()->delete(); $relation->delete(); DB::commit(); } catch (Throwable $e) { @@ -117,6 +126,19 @@ public function deleteRelation(Relation $relation): Relation return $relation; } + /** + * Throw an exception if the relation has any linked business records. + * Uses withoutGlobalScopes to bypass company-scope and catch all related records. + * + * @throws RelationHasLinkedRecordsException + */ + protected function guardAgainstLinkedRecords(Relation $relation): void + { + if ($relation->hasLinkedRecords()) { + throw new RelationHasLinkedRecordsException(); + } + } + protected function generateRelationNumber(string $relationType): string { $prefix = RelationType::from($relationType)->prefix(); diff --git a/Modules/Clients/Tests/Feature/RelationListRowActionsTest.php b/Modules/Clients/Tests/Feature/RelationListRowActionsTest.php new file mode 100644 index 000000000..0742b4c8c --- /dev/null +++ b/Modules/Clients/Tests/Feature/RelationListRowActionsTest.php @@ -0,0 +1,134 @@ +run(); + (new RolesSeeder())->run(); + $this->user->assignRole(UserRole::CUSTOMER_ADMIN->value); + } + + #[Test] + #[Group('crud')] + public function it_shows_create_invoice_action_only_for_customer_relations(): void + { + /* Arrange */ + $customer = Relation::factory()->for($this->company)->customer()->create(); + $prospect = Relation::factory()->for($this->company)->prospect()->create(); + + /* Act */ + $component = Livewire::actingAs($this->user)->test(ListRelations::class); + + /* Assert */ + $component + ->assertSuccessful() + ->assertActionVisible(TestAction::make('create_invoice')->table($customer)) + ->assertActionHidden(TestAction::make('create_invoice')->table($prospect)); + } + + #[Test] + #[Group('crud')] + public function it_shows_create_quote_action_for_all_relation_types(): void + { + /* Arrange */ + $customer = Relation::factory()->for($this->company)->customer()->create(); + $prospect = Relation::factory()->for($this->company)->prospect()->create(); + + /* Act */ + $component = Livewire::actingAs($this->user)->test(ListRelations::class); + + /* Assert */ + $component + ->assertSuccessful() + ->assertActionVisible(TestAction::make('create_quote')->table($customer)) + ->assertActionVisible(TestAction::make('create_quote')->table($prospect)); + } + + #[Test] + #[Group('crud')] + public function it_hides_delete_action_when_relation_has_linked_records(): void + { + /* Arrange */ + $this->user->givePermissionTo(Permission::DELETE_RELATIONS->value); + + $clean = Relation::factory()->for($this->company)->customer()->create(); + $billed = Relation::factory()->for($this->company)->customer()->create(); + + $documentGroup = Numbering::factory()->for($this->company)->create(); + Invoice::factory()->for($this->company)->create([ + 'customer_id' => $billed->id, + 'numbering_id' => $documentGroup->id, + 'user_id' => $this->user->id, + ]); + + /* Act */ + $component = Livewire::actingAs($this->user)->test(ListRelations::class); + + /* Assert */ + $component + ->assertSuccessful() + ->assertActionVisible(TestAction::make('delete')->table($clean)) + ->assertActionHidden(TestAction::make('delete')->table($billed)); + } + + #[Test] + #[Group('crud')] + public function it_prefills_customer_on_invoice_create_page_from_query_string(): void + { + /* Arrange */ + $customer = Relation::factory()->for($this->company)->customer()->create(); + + /* Act */ + $component = Livewire::actingAs($this->user) + ->withQueryParams(['customer_id' => $customer->id]) + ->test(CreateInvoice::class); + + /* Assert */ + $component + ->assertSuccessful() + ->assertFormSet(['customer_id' => $customer->id]); + } + + #[Test] + #[Group('crud')] + public function it_prefills_prospect_on_quote_create_page_from_query_string(): void + { + /* Arrange */ + $prospect = Relation::factory()->for($this->company)->prospect()->create(); + + /* Act */ + $component = Livewire::actingAs($this->user) + ->withQueryParams(['customer_id' => $prospect->id]) + ->test(CreateQuote::class); + + /* Assert */ + $component + ->assertSuccessful() + ->assertFormSet(['prospect_id' => $prospect->id]); + } +} diff --git a/Modules/Clients/Tests/Feature/ViewRelationTest.php b/Modules/Clients/Tests/Feature/ViewRelationTest.php new file mode 100644 index 000000000..b1b5ce051 --- /dev/null +++ b/Modules/Clients/Tests/Feature/ViewRelationTest.php @@ -0,0 +1,76 @@ +for($this->company)->customer()->create(); + + /* Act */ + $component = Livewire::actingAs($this->user) + ->test(ViewRelation::class, [ + 'record' => $customer->getKey(), + 'tenant' => Str::lower($this->company->search_code), + ]); + + /* Assert */ + $component->assertSuccessful(); + } + + #[Test] + public function it_prevents_deletion_of_client_with_linked_invoices(): void + { + /* Arrange */ + $customer = Relation::factory()->for($this->company)->customer()->create(); + Invoice::factory()->for($this->company)->create(['customer_id' => $customer->id]); + + /* Act & Assert */ + $this->expectException(RelationHasLinkedRecordsException::class); + + app(RelationService::class)->deleteRelation($customer); + } + + #[Test] + public function it_prevents_deletion_of_client_with_linked_quotes(): void + { + /* Arrange */ + $customer = Relation::factory()->for($this->company)->customer()->create(); + Quote::factory()->for($this->company)->create(['prospect_id' => $customer->id]); + + /* Act & Assert */ + $this->expectException(RelationHasLinkedRecordsException::class); + + app(RelationService::class)->deleteRelation($customer); + } + + #[Test] + public function it_allows_deletion_of_client_without_linked_records(): void + { + /* Arrange */ + $customer = Relation::factory()->for($this->company)->customer()->create(); + + /* Act */ + app(RelationService::class)->deleteRelation($customer); + + /* Assert */ + $this->assertDatabaseMissing('relations', ['id' => $customer->id]); + } +} diff --git a/Modules/Core/Database/Seeders/RolesSeeder.php b/Modules/Core/Database/Seeders/RolesSeeder.php index 452f13259..03d996fb3 100644 --- a/Modules/Core/Database/Seeders/RolesSeeder.php +++ b/Modules/Core/Database/Seeders/RolesSeeder.php @@ -120,6 +120,7 @@ function ($p) use ($customerResources) { $isBasicAction = str_starts_with($p, 'view-') || str_starts_with($p, 'create-') || str_starts_with($p, 'edit-') + || str_starts_with($p, 'delete-') || str_starts_with($p, 'export-') || str_starts_with($p, 'duplicate-'); $isCustomerResource = (bool) array_filter( diff --git a/Modules/Core/Tests/AbstractAdminPanelTestCase.php b/Modules/Core/Tests/AbstractAdminPanelTestCase.php index 5ea2f7670..a484e8a79 100644 --- a/Modules/Core/Tests/AbstractAdminPanelTestCase.php +++ b/Modules/Core/Tests/AbstractAdminPanelTestCase.php @@ -5,6 +5,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Carbon; +use Modules\Core\Database\Seeders\PermissionsSeeder; +use Modules\Core\Database\Seeders\RolesSeeder; +use Modules\Core\Enums\UserRole; use Modules\Core\Models\Company; use Modules\Core\Models\User; @@ -34,6 +37,14 @@ protected function setUp(): void session(['current_company_id' => $this->company->id]); + /* + * Admin resources gate every page on Spatie permissions (canViewAny + * etc.), so the test user needs the seeded super_admin permission set. + */ + (new PermissionsSeeder())->run(); + (new RolesSeeder())->run(); + $this->superAdmin->assignRole(UserRole::SUPER_ADMIN->value); + $this->withoutExceptionHandling(); } diff --git a/Modules/Core/Tests/AbstractCompanyPanelTestCase.php b/Modules/Core/Tests/AbstractCompanyPanelTestCase.php index 1be904606..768d61c53 100644 --- a/Modules/Core/Tests/AbstractCompanyPanelTestCase.php +++ b/Modules/Core/Tests/AbstractCompanyPanelTestCase.php @@ -7,6 +7,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Carbon; +use Modules\Core\Database\Seeders\PermissionsSeeder; +use Modules\Core\Database\Seeders\RolesSeeder; +use Modules\Core\Enums\UserRole; use Modules\Core\Models\Company; use Modules\Core\Models\User; @@ -44,6 +47,14 @@ protected function setUp(): void $currentCompanyId = $this->user->getCurrentCompanyId(); session(['current_company_id' => $currentCompanyId]); + /* + * Resources gate every page on Spatie permissions (canViewAny etc.), + * so the test user needs the seeded client_admin permission set. + */ + (new PermissionsSeeder())->run(); + (new RolesSeeder())->run(); + $this->user->assignRole(UserRole::CUSTOMER_ADMIN->value); + $this->withoutExceptionHandling(); } diff --git a/Modules/Invoices/Filament/Company/Resources/Invoices/InvoiceResource.php b/Modules/Invoices/Filament/Company/Resources/Invoices/InvoiceResource.php index c2278efd8..407639a93 100644 --- a/Modules/Invoices/Filament/Company/Resources/Invoices/InvoiceResource.php +++ b/Modules/Invoices/Filament/Company/Resources/Invoices/InvoiceResource.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Model; use Modules\Core\Enums\Permission; use Modules\Core\Filament\Company\Resources\BaseResource; +use Modules\Invoices\Filament\Company\Resources\Invoices\Pages\CreateInvoice; use Modules\Invoices\Filament\Company\Resources\Invoices\Pages\ListInvoices; use Modules\Invoices\Filament\Company\Resources\Invoices\Schemas\InvoiceForm; use Modules\Invoices\Filament\Company\Resources\Invoices\Tables\InvoicesTable; @@ -60,7 +61,8 @@ public static function getRelations(): array public static function getPages(): array { return [ - 'index' => ListInvoices::route('/'), + 'index' => ListInvoices::route('/'), + 'create' => CreateInvoice::route('/create'), ]; } diff --git a/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/CreateInvoice.php b/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/CreateInvoice.php index b6d0bdbaf..e7682280a 100644 --- a/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/CreateInvoice.php +++ b/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/CreateInvoice.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Model; use Modules\Invoices\Filament\Company\Resources\Invoices\InvoiceResource; use Modules\Invoices\Services\InvoiceService; +use function request; class CreateInvoice extends CreateRecord { @@ -45,4 +46,22 @@ protected function handleRecordCreation(array $data): Model { return app(InvoiceService::class)->createInvoice($data); } + + protected function mutateFormDataBeforeCreate(array $data): array + { + if ($customerId = request()->integer('customer_id')) { + $data['customer_id'] = $customerId; + } + + return $data; + } + + public function mount(): void + { + parent::mount(); + + if ($customerId = request()->integer('customer_id')) { + $this->form->fill(['customer_id' => $customerId]); + } + } } diff --git a/Modules/Invoices/Tests/Feature/InvoicesTest.php b/Modules/Invoices/Tests/Feature/InvoicesTest.php index cd401fec8..8d50d5708 100644 --- a/Modules/Invoices/Tests/Feature/InvoicesTest.php +++ b/Modules/Invoices/Tests/Feature/InvoicesTest.php @@ -8,6 +8,7 @@ use Illuminate\Support\Str; use Livewire\Livewire; use Modules\Clients\Models\Relation; +use Modules\Core\Enums\NumberingType; use Modules\Core\Models\Numbering; use Modules\Core\Models\TaxRate; use Modules\Core\Tests\AbstractCompanyPanelTestCase; @@ -38,7 +39,7 @@ public function it_lists_invoices(): void /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -87,7 +88,7 @@ public function it_creates_an_invoice_through_a_modal(): void { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -141,7 +142,7 @@ public function it_fails_to_create_invoice_through_a_modal_without_required_invo { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -186,7 +187,7 @@ public function it_fails_to_create_invoice_through_a_modal_without_required_invo { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -229,7 +230,7 @@ public function it_fails_to_create_invoice_through_a_modal_without_required_cust { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -274,7 +275,7 @@ public function it_updates_an_invoice_through_a_modal(): void { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -325,7 +326,7 @@ public function it_updates_an_invoice_through_a_modal(): void public function it_creates_an_invoice_with_items(): void { $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -381,7 +382,7 @@ public function it_fails_to_create_invoice_without_required_invoice_number(): vo /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -425,7 +426,7 @@ public function it_fails_to_create_invoice_without_required_invoice_status(): vo /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -467,7 +468,7 @@ public function it_fails_to_create_invoice_without_required_customer(): void /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -510,7 +511,7 @@ public function it_updates_an_invoice(): void { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -586,7 +587,7 @@ public function it_deletes_an_invoice(): void /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); diff --git a/Modules/Quotes/Filament/Company/Resources/Quotes/Pages/CreateQuote.php b/Modules/Quotes/Filament/Company/Resources/Quotes/Pages/CreateQuote.php index bbb84eee1..b0e4d02e7 100644 --- a/Modules/Quotes/Filament/Company/Resources/Quotes/Pages/CreateQuote.php +++ b/Modules/Quotes/Filament/Company/Resources/Quotes/Pages/CreateQuote.php @@ -7,6 +7,8 @@ use Modules\Quotes\Filament\Company\Resources\Quotes\QuoteResource; use Modules\Quotes\Services\QuoteService; +use function request; + class CreateQuote extends CreateRecord { protected static string $resource = QuoteResource::class; @@ -40,8 +42,26 @@ public function create(bool $another = false): void $this->redirect($this->getRedirectUrl()); } + public function mount(): void + { + parent::mount(); + + if ($customerId = request()->integer('customer_id')) { + $this->form->fill(['prospect_id' => $customerId]); + } + } + protected function handleRecordCreation(array $data): Model { return app(QuoteService::class)->createQuote($data); } + + protected function mutateFormDataBeforeCreate(array $data): array + { + if ($customerId = request()->integer('customer_id')) { + $data['prospect_id'] = $customerId; + } + + return $data; + } } diff --git a/Modules/Quotes/Filament/Company/Resources/Quotes/QuoteResource.php b/Modules/Quotes/Filament/Company/Resources/Quotes/QuoteResource.php index ba4a9abe1..5c0e59a09 100644 --- a/Modules/Quotes/Filament/Company/Resources/Quotes/QuoteResource.php +++ b/Modules/Quotes/Filament/Company/Resources/Quotes/QuoteResource.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Model; use Modules\Core\Enums\Permission; use Modules\Core\Filament\Company\Resources\BaseResource; +use Modules\Quotes\Filament\Company\Resources\Quotes\Pages\CreateQuote; use Modules\Quotes\Filament\Company\Resources\Quotes\Pages\ListQuotes; use Modules\Quotes\Filament\Company\Resources\Quotes\Schemas\QuoteForm; use Modules\Quotes\Filament\Company\Resources\Quotes\Tables\QuotesTable; @@ -59,7 +60,8 @@ public static function getRelations(): array public static function getPages(): array { return [ - 'index' => ListQuotes::route('/'), + 'index' => ListQuotes::route('/'), + 'create' => CreateQuote::route('/create'), ]; } diff --git a/Modules/Quotes/Tests/Feature/QuotesTest.php b/Modules/Quotes/Tests/Feature/QuotesTest.php index 2fdfdce56..5b3e5baf2 100644 --- a/Modules/Quotes/Tests/Feature/QuotesTest.php +++ b/Modules/Quotes/Tests/Feature/QuotesTest.php @@ -7,6 +7,7 @@ use Illuminate\Support\Str; use Livewire\Livewire; use Modules\Clients\Models\Relation; +use Modules\Core\Enums\NumberingType; use Modules\Core\Models\Numbering; use Modules\Core\Models\TaxRate; use Modules\Core\Tests\AbstractCompanyPanelTestCase; @@ -65,7 +66,7 @@ public function it_creates_a_quote_through_a_modal(): void { /* Arrange */ $prospect = Relation::factory()->for($this->company)->prospect()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::QUOTE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); @@ -139,7 +140,7 @@ public function it_creates_a_quote_through_a_modal(): void public function it_fails_to_create_a_quote_through_a_modal_without_required_prospect(): void { /* Arrange */ - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::QUOTE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); @@ -295,7 +296,7 @@ public function it_updates_a_quote_through_a_modal(): void { /* Arrange */ $prospect = Relation::factory()->for($this->company)->prospect()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::QUOTE->value])->create(); $quote = Quote::factory() ->for($this->company) @@ -348,7 +349,7 @@ public function it_creates_a_quote(): void { /* Arrange */ $prospect = Relation::factory()->for($this->company)->prospect()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::QUOTE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); diff --git a/composer.json b/composer.json index b3603f84c..b3c54f2fa 100644 --- a/composer.json +++ b/composer.json @@ -49,12 +49,14 @@ "psr-4": { "App\\": "app/", "Modules\\": "Modules/", - "Database\\Seeders\\": "database/seeders/" + "Database\\Seeders\\": "database/seeders/", + "Fable5\\": "automation/fable5/src/" } }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/" + "Tests\\": "tests/", + "Fable5\\Tests\\": "automation/fable5/tests/" } }, "scripts": { diff --git a/pint_output.log b/pint_output.log new file mode 100644 index 000000000..79f26c47c --- /dev/null +++ b/pint_output.log @@ -0,0 +1,5 @@ + + + No dirty files found. + + diff --git a/resources/lang/en/ip.php b/resources/lang/en/ip.php index e60cf5855..4be6eb44f 100644 --- a/resources/lang/en/ip.php +++ b/resources/lang/en/ip.php @@ -664,7 +664,8 @@ 'invoice_status_partially_paid' => 'Partially Paid', 'invoice_status_sent' => 'Sent', 'invoice_status_viewed' => 'Viewed', - 'cannot_delete_paid_invoice' => 'Paid invoices cannot be deleted.', + 'cannot_delete_paid_invoice' => 'Paid invoices cannot be deleted.', + 'cannot_delete_client_has_linked_records' => 'This client cannot be deleted because it has linked invoices, quotes, expenses, tasks, or projects.', 'invoice_sumex' => 'Sumex', 'invoice_sumex_canton' => 'Canton', 'invoice_sumex_diagnosis' => 'Diagnosis',