From 1978b4fb7ac3aa087ad54cad38ef77bea3f83a11 Mon Sep 17 00:00:00 2001 From: Hadassa Date: Wed, 27 May 2026 22:29:30 -0300 Subject: [PATCH 1/5] feat(panel-admin): profile tab on UserResource #256 --- .../Resources/Users/Pages/CreateUser.php | 13 ++ .../Resources/Users/Pages/EditUser.php | 119 ++++++++++++++++ .../Resources/Users/Pages/ListUsers.php | 21 +++ .../Resources/Users/Schemas/UserForm.php | 127 ++++++++++++++++++ .../Filament/Resources/Users/UserResource.php | 46 +++++++ .../src/PanelAdminServiceProvider.php | 3 + .../tests/Feature/Users/UserResourceTest.php | 77 +++++++++++ 7 files changed, 406 insertions(+) create mode 100644 app-modules/panel-admin/src/Filament/Resources/Users/Pages/CreateUser.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Users/Pages/ListUsers.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Users/Schemas/UserForm.php create mode 100644 app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php create mode 100644 app-modules/panel-admin/tests/Feature/Users/UserResourceTest.php diff --git a/app-modules/panel-admin/src/Filament/Resources/Users/Pages/CreateUser.php b/app-modules/panel-admin/src/Filament/Resources/Users/Pages/CreateUser.php new file mode 100644 index 000000000..d28901e70 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Users/Pages/CreateUser.php @@ -0,0 +1,13 @@ +getProfile(); + + $data['profile'] = [ + 'nickname' => $profile->nickname, + 'birthdate' => $profile->birthdate?->format('Y-m-d'), + 'headline' => $profile->headline, + 'seniority_level' => $profile->seniority_level, + 'years_experience' => $profile->years_experience, + 'about' => $profile->about, + 'available_for_proposals' => $profile->available_for_proposals, + 'start_availability' => $profile->start_availability, + 'social_links' => $this->socialLinksToRepeater($profile->social_links), + ]; + + return $data; + } + + protected function handleRecordUpdate(Model $record, array $data): Model + { + $profile = $this->getProfile(); + $profileData = $data['profile'] ?? []; + + $socialLinks = $this->repeaterToSocialLinks($profileData['social_links'] ?? []); + + $dto = UpsertProfileDTO::fromArray([ + 'nickname' => $profileData['nickname'] ?? null, + 'birthdate' => $profileData['birthdate'] ?? null, + 'about' => $profileData['about'] ?? null, + 'headline' => $profileData['headline'] ?? null, + 'seniority_level' => $profileData['seniority_level'] ?? null, + 'years_experience' => $profileData['years_experience'] ?? null, + 'social_links' => $socialLinks !== [] ? $socialLinks : null, + ]); + + resolve(UpsertProfile::class)->handle($profile, $dto); + + $available = (bool) ($profileData['available_for_proposals'] ?? false); + $rawStartAvailability = $profileData['start_availability'] ?? null; + $startAvailability = match (true) { + $rawStartAvailability instanceof StartAvailability => $rawStartAvailability, + is_string($rawStartAvailability) => StartAvailability::from($rawStartAvailability), + $available => StartAvailability::Negotiable, + default => null, + }; + + resolve(ToggleAvailability::class)->handle($profile, $available, $startAvailability); + + Notification::make() + ->success() + ->title('Perfil atualizado com sucesso!') + ->send(); + + return $record; + } + + private function getProfile(): Profile + { + $tenantId = Filament::getTenant()?->getKey(); + abort_unless($tenantId, 403); + + return Profile::query()->firstOrCreate([ + 'user_id' => $this->record->getKey(), + 'tenant_id' => $tenantId, + ]); + } + + private function socialLinksToRepeater(?array $socialLinks): array + { + if ($socialLinks === null) { + return []; + } + + return collect($socialLinks) + ->map(fn ($handle, $platform) => ['platform' => $platform, 'handle' => $handle]) + ->values() + ->all(); + } + + private function repeaterToSocialLinks(array $repeaterData): array + { + $links = []; + + foreach ($repeaterData as $item) { + $platform = $item['platform'] ?? null; + $handle = $item['handle'] ?? null; + + if (filled($platform) && filled($handle)) { + $key = $platform instanceof SocialPlatform ? $platform->value : (string) $platform; + $links[$key] = (string) $handle; + } + } + + return $links; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Users/Pages/ListUsers.php b/app-modules/panel-admin/src/Filament/Resources/Users/Pages/ListUsers.php new file mode 100644 index 000000000..210200bb8 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Users/Pages/ListUsers.php @@ -0,0 +1,21 @@ +components([ + Tabs::make() + ->tabs([ + Tab::make('Conta') + ->schema([ + TextInput::make('name') + ->label('Nome') + ->required(), + + TextInput::make('email') + ->label('Email') + ->email() + ->required(), + ]), + + Tab::make('Profile') + ->schema([ + Section::make('Dados Pessoais') + ->schema([ + Grid::make(2)->schema([ + TextInput::make('profile.nickname') + ->label('Apelido') + ->maxLength(100) + ->columnSpan(1), + + DatePicker::make('profile.birthdate') + ->label('Data de nascimento') + ->columnSpan(1), + ]), + ]), + + Section::make('Dados Profissionais') + ->schema([ + Grid::make(3)->schema([ + TextInput::make('profile.headline') + ->label('Título') + ->maxLength(100) + ->columnSpan(1), + + Select::make('profile.seniority_level') + ->label('Senioridade') + ->options(SeniorityLevel::class) + ->columnSpan(1), + + TextInput::make('profile.years_experience') + ->label('Anos de experiência') + ->numeric() + ->minValue(0) + ->maxValue(50) + ->columnSpan(1), + + Textarea::make('profile.about') + ->label('Bio') + ->maxLength(500) + ->rows(4) + ->columnSpanFull(), + ]), + ]), + + Section::make('Links Sociais') + ->schema([ + Repeater::make('profile.social_links') + ->label('') + ->schema([ + Grid::make(2)->schema([ + Select::make('platform') + ->label('Plataforma') + ->options(SocialPlatform::class) + ->required() + ->columnSpan(1), + + TextInput::make('handle') + ->label('Handle') + ->required() + ->columnSpan(1), + ]), + ]) + ->defaultItems(0) + ->reorderable(false) + ->columnSpanFull(), + ]), + + Section::make('Disponibilidade') + ->schema([ + Toggle::make('profile.available_for_proposals') + ->label('Disponível para propostas') + ->live(), + + Select::make('profile.start_availability') + ->label('Disponibilidade de início') + ->options(StartAvailability::class) + ->live() + ->visible(fn (Get $get): bool => (bool) $get('profile.available_for_proposals')), + ]), + ]), + ]) + ->columnSpanFull(), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php b/app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php new file mode 100644 index 000000000..ab9e5da18 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php @@ -0,0 +1,46 @@ +columns([]) + ->filters([]); + } + + public static function getPages(): array + { + return [ + 'index' => ListUsers::route('/'), + 'create' => CreateUser::route('/create'), + 'edit' => EditUser::route('/{record}/edit'), + ]; + } +} diff --git a/app-modules/panel-admin/src/PanelAdminServiceProvider.php b/app-modules/panel-admin/src/PanelAdminServiceProvider.php index a8ff60d69..9924cb85f 100644 --- a/app-modules/panel-admin/src/PanelAdminServiceProvider.php +++ b/app-modules/panel-admin/src/PanelAdminServiceProvider.php @@ -8,6 +8,7 @@ use Filament\Navigation\NavigationItem; use Filament\Panel; use He4rt\PanelAdmin\Filament\Resources\ExternalIdentities\ExternalIdentityResource; +use He4rt\PanelAdmin\Filament\Resources\Users\UserResource; use He4rt\PanelAdmin\Marketing\MarketingCluster; use He4rt\PanelAdmin\Moderation\Livewire\AppealQueue; use He4rt\PanelAdmin\Moderation\Livewire\ModerationDashboardLivewire; @@ -34,6 +35,7 @@ public function register(): void ->navigation($this->buildNavigation(...)) ->resources([ ExternalIdentityResource::class, + UserResource::class, ]) ->discoverResources( in: __DIR__.'/Moderation/Resources', @@ -104,6 +106,7 @@ private function defaultNavigation(NavigationBuilder $builder): NavigationBuilde ...MarketingCluster::getNavigationItems(), ...TwitchCluster::getNavigationItems(), ...ExternalIdentityResource::getNavigationItems(), + ...UserResource::getNavigationItems(), ]); } diff --git a/app-modules/panel-admin/tests/Feature/Users/UserResourceTest.php b/app-modules/panel-admin/tests/Feature/Users/UserResourceTest.php new file mode 100644 index 000000000..43a353d66 --- /dev/null +++ b/app-modules/panel-admin/tests/Feature/Users/UserResourceTest.php @@ -0,0 +1,77 @@ +admin = User::factory()->create(); + $this->member = User::factory()->create(); + $this->tenant = Tenant::factory()->create(['slug' => 'test-tenant']); + $this->tenant->members()->attach($this->member); + + $this->actingAs($this->admin); + + Filament::setCurrentPanel(Filament::getPanel('admin')); + Filament::setTenant($this->tenant); + + $this->profile = Profile::query() + ->where('user_id', $this->member->id) + ->where('tenant_id', $this->tenant->id) + ->first(); +}); + +test('admin sees profile tab on user resource', function (): void { + livewire(EditUser::class, ['record' => $this->member->getRouteKey()]) + ->assertSeeText('Profile'); +}); + +test('profile tab loads member data', function (): void { + $this->profile->update(['headline' => 'Backend Dev']); + + livewire(EditUser::class, ['record' => $this->member->getRouteKey()]) + ->assertOk() + ->assertSchemaStateSet([ + 'profile.headline' => 'Backend Dev', + ]); +}); + +test('admin can edit member bio', function (): void { + livewire(EditUser::class, ['record' => $this->member->getRouteKey()]) + ->fillForm([ + 'profile.about' => 'Bio moderada pelo admin', + ]) + ->call('save') + ->assertNotified(); + + expect($this->profile->fresh()->about)->toBe('Bio moderada pelo admin'); +}); + +test('validates bio max length', function (): void { + livewire(EditUser::class, ['record' => $this->member->getRouteKey()]) + ->fillForm([ + 'profile.about' => str_repeat('a', 501), + ]) + ->call('save') + ->assertHasFormErrors(['profile.about']); +}); + +test('toggle availability shows start availability field', function (): void { + $this->profile->update([ + 'available_for_proposals' => true, + 'start_availability' => StartAvailability::Immediate, + ]); + + livewire(EditUser::class, ['record' => $this->member->getRouteKey()]) + ->assertSchemaStateSet([ + 'profile.available_for_proposals' => true, + 'profile.start_availability' => StartAvailability::Immediate, + ]); +}); From 3167810b98d20da14f772bbea167816489f5dcae Mon Sep 17 00:00:00 2001 From: Hadassa Date: Wed, 27 May 2026 23:03:45 -0300 Subject: [PATCH 2/5] feat(panel-admin): profile tab on UserResource #256 --- .../src/Filament/Resources/Users/Pages/EditUser.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php b/app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php index 699e31bc6..e0932da76 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php +++ b/app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php @@ -88,6 +88,10 @@ private function getProfile(): Profile ]); } + /** + * @param array|null $socialLinks + * @return list + */ private function socialLinksToRepeater(?array $socialLinks): array { if ($socialLinks === null) { @@ -100,6 +104,10 @@ private function socialLinksToRepeater(?array $socialLinks): array ->all(); } + /** + * @param array> $repeaterData + * @return array + */ private function repeaterToSocialLinks(array $repeaterData): array { $links = []; From 4fdf956fadc2be61172f61f7a393f6e4fc3495f1 Mon Sep 17 00:00:00 2001 From: Hadassa Date: Wed, 27 May 2026 23:47:31 -0300 Subject: [PATCH 3/5] fix: add table columns, persist account fields, fix social links edge cases --- .../Resources/Users/Pages/EditUser.php | 15 ++++++++++++++- .../Filament/Resources/Users/UserResource.php | 18 +++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php b/app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php index e0932da76..7a2eab280 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php +++ b/app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php @@ -15,6 +15,7 @@ use He4rt\Profile\Enums\StartAvailability; use He4rt\Profile\Models\Profile; use Illuminate\Database\Eloquent\Model; +use Illuminate\Validation\ValidationException; class EditUser extends EditRecord { @@ -53,7 +54,7 @@ protected function handleRecordUpdate(Model $record, array $data): Model 'headline' => $profileData['headline'] ?? null, 'seniority_level' => $profileData['seniority_level'] ?? null, 'years_experience' => $profileData['years_experience'] ?? null, - 'social_links' => $socialLinks !== [] ? $socialLinks : null, + 'social_links' => $socialLinks, ]); resolve(UpsertProfile::class)->handle($profile, $dto); @@ -74,6 +75,11 @@ protected function handleRecordUpdate(Model $record, array $data): Model ->title('Perfil atualizado com sucesso!') ->send(); + $record->update([ + 'name' => $data['name'] ?? $record->name, + 'email' => $data['email'] ?? $record->email, + ]); + return $record; } @@ -118,6 +124,13 @@ private function repeaterToSocialLinks(array $repeaterData): array if (filled($platform) && filled($handle)) { $key = $platform instanceof SocialPlatform ? $platform->value : (string) $platform; + + if (isset($links[$key])) { + throw ValidationException::withMessages([ + 'profile.social_links' => ['Plataforma duplicada: '.$key], + ]); + } + $links[$key] = (string) $handle; } } diff --git a/app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php b/app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php index ab9e5da18..be4196320 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php +++ b/app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php @@ -8,6 +8,7 @@ use Filament\Resources\Resource; use Filament\Schemas\Schema; use Filament\Support\Icons\Heroicon; +use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use He4rt\Identity\User\Models\User; use He4rt\PanelAdmin\Filament\Resources\Users\Pages\CreateUser; @@ -31,7 +32,22 @@ public static function form(Schema $schema): Schema public static function table(Table $table): Table { return $table - ->columns([]) + ->columns([ + TextColumn::make('name') + ->label('Nome') + ->searchable() + ->sortable(), + + TextColumn::make('email') + ->label('Email') + ->searchable() + ->sortable(), + + TextColumn::make('created_at') + ->label('Criado em') + ->dateTime('d/m/Y') + ->sortable(), + ]) ->filters([]); } From 43935a993bf39ca2c1abce619dfe3869a05c1533 Mon Sep 17 00:00:00 2001 From: Hadassa Date: Wed, 27 May 2026 23:54:36 -0300 Subject: [PATCH 4/5] fix: resolve PHPStan errors and CodeRabbit suggestions --- .../panel-admin/src/Filament/Resources/Users/Pages/EditUser.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php b/app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php index 7a2eab280..d010133be 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php +++ b/app-modules/panel-admin/src/Filament/Resources/Users/Pages/EditUser.php @@ -7,6 +7,7 @@ use Filament\Facades\Filament; use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; +use He4rt\Identity\User\Models\User; use He4rt\PanelAdmin\Filament\Resources\Users\UserResource; use He4rt\Profile\Actions\ToggleAvailability; use He4rt\Profile\Actions\UpsertProfile; @@ -75,6 +76,7 @@ protected function handleRecordUpdate(Model $record, array $data): Model ->title('Perfil atualizado com sucesso!') ->send(); + /** @var User $record */ $record->update([ 'name' => $data['name'] ?? $record->name, 'email' => $data['email'] ?? $record->email, From 704642d66ea730722b8e960161c74c9227d7443e Mon Sep 17 00:00:00 2001 From: Hadassa Date: Thu, 28 May 2026 00:12:15 -0300 Subject: [PATCH 5/5] fix: add table filters for created_at and tenant --- .../src/Filament/Resources/Users/UserResource.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php b/app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php index be4196320..44ab73b7c 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php +++ b/app-modules/panel-admin/src/Filament/Resources/Users/UserResource.php @@ -9,12 +9,15 @@ use Filament\Schemas\Schema; use Filament\Support\Icons\Heroicon; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\Filter; +use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use He4rt\Identity\User\Models\User; use He4rt\PanelAdmin\Filament\Resources\Users\Pages\CreateUser; use He4rt\PanelAdmin\Filament\Resources\Users\Pages\EditUser; use He4rt\PanelAdmin\Filament\Resources\Users\Pages\ListUsers; use He4rt\PanelAdmin\Filament\Resources\Users\Schemas\UserForm; +use Illuminate\Database\Eloquent\Builder; class UserResource extends Resource { @@ -48,7 +51,15 @@ public static function table(Table $table): Table ->dateTime('d/m/Y') ->sortable(), ]) - ->filters([]); + ->filters([ + Filter::make('created_at') + ->label('Criado este mês') + ->query(fn (Builder $query) => $query->whereMonth('created_at', now()->month)), + + SelectFilter::make('tenant') + ->label('Tenant') + ->relationship('tenants', 'name'), + ]); } public static function getPages(): array