From 480dfc657fac4691ed6b7ae9330c43f2d31387fd Mon Sep 17 00:00:00 2001 From: javier Date: Thu, 18 Jun 2026 16:22:59 +0200 Subject: [PATCH] feat(projects): usuarios/empresas del proyecto como tablas Rappasoft + permiso assign companies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Permiso 'assign companies' propio (antes empresas reutilizaba 'assign users'); concedido a los roles que ya tenían 'assign users'. - ProjectUsersTable y ProjectCompaniesTable (Rappasoft): búsqueda, filtro por rol, cambio de rol en línea y quitar; gateadas por assign users / assign companies. - ProjectUsers/ProjectCompanies quedan como contenedor (form de asignación) que embebe la tabla y refresca el desplegable vía eventos. - Unificadas confirmaciones (wire:confirm) y notificaciones (dispatch notify). Tests: ProjectAssignmentsTest (4). Suite 69 passing (solo 2 pre-existentes sqlite). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Livewire/ProjectCompanies.php | 66 ++++----- app/Livewire/ProjectCompaniesTable.php | 127 ++++++++++++++++++ app/Livewire/ProjectUsers.php | 62 +++------ app/Livewire/ProjectUsersTable.php | 125 +++++++++++++++++ database/seeders/PermissionCatalogSeeder.php | 1 + .../livewire/project-companies.blade.php | 67 ++------- .../views/livewire/project-users.blade.php | 57 ++------ tests/Feature/ProjectAssignmentsTest.php | 116 ++++++++++++++++ 8 files changed, 433 insertions(+), 188 deletions(-) create mode 100644 app/Livewire/ProjectCompaniesTable.php create mode 100644 app/Livewire/ProjectUsersTable.php create mode 100644 tests/Feature/ProjectAssignmentsTest.php diff --git a/app/Livewire/ProjectCompanies.php b/app/Livewire/ProjectCompanies.php index 2e96b87..f91f634 100644 --- a/app/Livewire/ProjectCompanies.php +++ b/app/Livewire/ProjectCompanies.php @@ -2,15 +2,15 @@ namespace App\Livewire; -use Livewire\Component; -use App\Models\Project; use App\Models\Company; +use App\Models\Project; use Illuminate\Support\Facades\Auth; +use Livewire\Attributes\On; +use Livewire\Component; class ProjectCompanies extends Component { public Project $project; - public $assignedCompanies = []; public $allCompanies = []; public $selectedCompanyId = ''; public $selectedRole = 'other'; @@ -18,64 +18,46 @@ class ProjectCompanies extends Component public function mount(Project $project) { $this->project = $project; - $this->loadCompanies(); + $this->loadAvailable(); } - public function loadCompanies() + /** Companies not yet assigned to the project (for the dropdown). */ + public function loadAvailable(): void { - $this->assignedCompanies = $this->project->companies()->withPivot('role_in_project')->get(); - $assignedIds = $this->assignedCompanies->pluck('id')->toArray(); + $assignedIds = $this->project->companies()->pluck('companies.id')->toArray(); $this->allCompanies = Company::whereNotIn('id', $assignedIds)->orderBy('name')->get(); } + /** Reload the dropdown when the embedded table changes assignments. */ + #[On('project-companies-changed')] + public function onCompaniesChanged(): void + { + $this->loadAvailable(); + } + public function assignCompany() { - $user = Auth::user(); - if (!$user->can('assign users')) { - session()->flash('error', 'No tienes permisos para asignar compañías.'); - return; - } + abort_unless(Auth::user()->can('assign companies'), 403); $this->validate([ 'selectedCompanyId' => 'required|exists:companies,id', - 'selectedRole' => 'required|in:owner,constructor,subcontractor,consultant,supplier,other', + 'selectedRole' => 'required|in:' . implode(',', array_keys(ProjectCompaniesTable::ROLES)), ]); $this->project->companies()->attach($this->selectedCompanyId, [ - 'role_in_project' => $this->selectedRole + 'role_in_project' => $this->selectedRole, ]); $this->reset(['selectedCompanyId', 'selectedRole']); - $this->loadCompanies(); - $this->dispatch('notify', 'Compañía asignada al proyecto.'); - } - - public function removeCompany($companyId) - { - $user = Auth::user(); - if (!$user->can('assign users')) { - session()->flash('error', 'Sin permisos.'); - return; - } - - $this->project->companies()->detach($companyId); - $this->loadCompanies(); - $this->dispatch('notify', 'Compañía eliminada del proyecto.'); - } - - public function changeRole($companyId, $role) - { - if (!in_array($role, ['owner', 'constructor', 'subcontractor', 'consultant', 'supplier', 'other'])) return; - - $this->project->companies()->updateExistingPivot($companyId, [ - 'role_in_project' => $role - ]); - $this->loadCompanies(); - $this->dispatch('notify', 'Rol actualizado.'); + $this->loadAvailable(); + $this->dispatch('project-companies-changed'); + $this->dispatch('notify', 'Empresa asignada al proyecto.'); } public function render() { - return view('livewire.project-companies'); + return view('livewire.project-companies', [ + 'roles' => ProjectCompaniesTable::ROLES, + ]); } -} \ No newline at end of file +} diff --git a/app/Livewire/ProjectCompaniesTable.php b/app/Livewire/ProjectCompaniesTable.php new file mode 100644 index 0000000..bf1ad8d --- /dev/null +++ b/app/Livewire/ProjectCompaniesTable.php @@ -0,0 +1,127 @@ + label */ + public const ROLES = [ + 'owner' => 'Promotor', + 'constructor' => 'Constructor', + 'subcontractor' => 'Subcontratista', + 'consultant' => 'Consultor', + 'supplier' => 'Proveedor', + 'other' => 'Otro', + ]; + + public function configure(): void + { + $this->setPrimaryKey('id') + ->setDefaultSort('companies.name', 'asc') + ->setSortingPillsEnabled(false) + ->setAdditionalSelects(['companies.id as id', 'company_project.role_in_project as role_in_project']); + } + + #[On('project-companies-changed')] + public function refreshRows(): void + { + // no-op: triggers re-render so the builder re-runs. + } + + public function builder(): Builder + { + return Company::query() + ->join('company_project', 'company_project.company_id', '=', 'companies.id') + ->where('company_project.project_id', $this->projectId); + } + + public function columns(): array + { + return [ + Column::make('Empresa', 'name') + ->sortable() + ->searchable() + ->format(function ($value, $row) { + $initial = strtoupper(mb_substr($value ?? '?', 0, 1)); + $html = '
+ '.$initial.' +
'.e($value).''; + if ($row->tax_id) { + $html .= '
'.e($row->tax_id).'
'; + } + $html .= '
'; + return $html; + }) + ->html(), + + Column::make('Rol', 'role_in_project') + ->label(function ($row) { + $current = $row->role_in_project; + if (! Auth::user()->can('assign companies')) { + return ''.(self::ROLES[$current] ?? ucfirst((string) $current)).''; + } + $opts = ''; + foreach (self::ROLES as $val => $label) { + $opts .= ''; + } + return ''; + }) + ->html(), + + Column::make('Acciones') + ->label(function ($row) { + if (! Auth::user()->can('assign companies')) { + return ''; + } + return '
+ +
'; + }) + ->html(), + ]; + } + + public function filters(): array + { + return [ + SelectFilter::make('Rol', 'role') + ->options(['' => 'Rol: todos'] + self::ROLES) + ->filter(fn (Builder $query, string $value) => $query->where('company_project.role_in_project', $value)), + ]; + } + + public function changeRole($companyId, $role): void + { + abort_unless(Auth::user()->can('assign companies'), 403); + if (! array_key_exists($role, self::ROLES)) { + return; + } + \App\Models\Project::findOrFail($this->projectId) + ->companies()->updateExistingPivot($companyId, ['role_in_project' => $role]); + $this->dispatch('project-companies-changed'); + $this->dispatch('notify', 'Rol actualizado.'); + } + + public function removeCompany($companyId): void + { + abort_unless(Auth::user()->can('assign companies'), 403); + \App\Models\Project::findOrFail($this->projectId)->companies()->detach($companyId); + $this->dispatch('project-companies-changed'); + $this->dispatch('notify', 'Empresa eliminada del proyecto.'); + } +} diff --git a/app/Livewire/ProjectUsers.php b/app/Livewire/ProjectUsers.php index 7cd588d..112a3f5 100644 --- a/app/Livewire/ProjectUsers.php +++ b/app/Livewire/ProjectUsers.php @@ -2,15 +2,15 @@ namespace App\Livewire; -use Livewire\Component; use App\Models\Project; use App\Models\User; use Illuminate\Support\Facades\Auth; +use Livewire\Attributes\On; +use Livewire\Component; class ProjectUsers extends Component { public Project $project; - public $assignedUsers = []; public $allUsers = []; public $selectedUserId = ''; public $selectedRole = 'viewer'; @@ -18,64 +18,46 @@ class ProjectUsers extends Component public function mount(Project $project) { $this->project = $project; - $this->loadUsers(); + $this->loadAvailable(); } - public function loadUsers() + /** Users not yet assigned to the project (for the dropdown). */ + public function loadAvailable(): void { - $this->assignedUsers = $this->project->users()->withPivot('role_in_project')->get(); - $assignedIds = $this->assignedUsers->pluck('id')->toArray(); + $assignedIds = $this->project->users()->pluck('users.id')->toArray(); $this->allUsers = User::whereNotIn('id', $assignedIds)->orderBy('name')->get(); } + /** Reload the dropdown when the embedded table changes assignments. */ + #[On('project-users-changed')] + public function onUsersChanged(): void + { + $this->loadAvailable(); + } + public function assignUser() { - $user = Auth::user(); - if (!$user->can('assign users')) { - session()->flash('error', 'No tienes permisos para asignar usuarios.'); - return; - } + abort_unless(Auth::user()->can('assign users'), 403); $this->validate([ 'selectedUserId' => 'required|exists:users,id', - 'selectedRole' => 'required|in:supervisor,consultant,client,viewer', + 'selectedRole' => 'required|in:' . implode(',', array_keys(ProjectUsersTable::ROLES)), ]); $this->project->users()->attach($this->selectedUserId, [ - 'role_in_project' => $this->selectedRole + 'role_in_project' => $this->selectedRole, ]); $this->reset(['selectedUserId', 'selectedRole']); - $this->loadUsers(); + $this->loadAvailable(); + $this->dispatch('project-users-changed'); $this->dispatch('notify', 'Usuario asignado al proyecto.'); } - public function removeUser($userId) - { - $user = Auth::user(); - if (!$user->can('assign users')) { - session()->flash('error', 'Sin permisos.'); - return; - } - - $this->project->users()->detach($userId); - $this->loadUsers(); - $this->dispatch('notify', 'Usuario eliminado del proyecto.'); - } - - public function changeRole($userId, $role) - { - if (!in_array($role, ['supervisor', 'consultant', 'client', 'viewer'])) return; - - $this->project->users()->updateExistingPivot($userId, [ - 'role_in_project' => $role - ]); - $this->loadUsers(); - $this->dispatch('notify', 'Rol actualizado.'); - } - public function render() { - return view('livewire.project-users'); + return view('livewire.project-users', [ + 'roles' => ProjectUsersTable::ROLES, + ]); } -} \ No newline at end of file +} diff --git a/app/Livewire/ProjectUsersTable.php b/app/Livewire/ProjectUsersTable.php new file mode 100644 index 0000000..c7feb36 --- /dev/null +++ b/app/Livewire/ProjectUsersTable.php @@ -0,0 +1,125 @@ + label */ + public const ROLES = [ + 'supervisor' => 'Supervisor', + 'consultant' => 'Consultor', + 'client' => 'Cliente', + 'viewer' => 'Observador', + ]; + + public function configure(): void + { + $this->setPrimaryKey('id') + ->setDefaultSort('users.name', 'asc') + ->setSortingPillsEnabled(false) + ->setAdditionalSelects(['users.id as id', 'project_user.role_in_project as role_in_project']); + } + + #[On('project-users-changed')] + public function refreshRows(): void + { + // no-op: triggers re-render so the builder re-runs. + } + + public function builder(): Builder + { + return User::query() + ->join('project_user', 'project_user.user_id', '=', 'users.id') + ->where('project_user.project_id', $this->projectId); + } + + public function columns(): array + { + return [ + Column::make('Nombre', 'name') + ->sortable() + ->searchable() + ->format(function ($value, $row) { + $initial = strtoupper(mb_substr($value ?? '?', 0, 1)); + return '
+ '.$initial.' + '.e($value).' +
'; + }) + ->html(), + + Column::make('Email', 'email') + ->sortable() + ->searchable(), + + Column::make('Rol', 'role_in_project') + ->label(function ($row) { + $current = $row->role_in_project; + if (! Auth::user()->can('assign users')) { + return ''.(self::ROLES[$current] ?? ucfirst((string) $current)).''; + } + $opts = ''; + foreach (self::ROLES as $val => $label) { + $opts .= ''; + } + return ''; + }) + ->html(), + + Column::make('Acciones') + ->label(function ($row) { + if (! Auth::user()->can('assign users')) { + return ''; + } + return '
+ +
'; + }) + ->html(), + ]; + } + + public function filters(): array + { + return [ + SelectFilter::make('Rol', 'role') + ->options(['' => 'Rol: todos'] + self::ROLES) + ->filter(fn (Builder $query, string $value) => $query->where('project_user.role_in_project', $value)), + ]; + } + + public function changeRole($userId, $role): void + { + abort_unless(Auth::user()->can('assign users'), 403); + if (! array_key_exists($role, self::ROLES)) { + return; + } + \App\Models\Project::findOrFail($this->projectId) + ->users()->updateExistingPivot($userId, ['role_in_project' => $role]); + $this->dispatch('project-users-changed'); + $this->dispatch('notify', 'Rol actualizado.'); + } + + public function removeUser($userId): void + { + abort_unless(Auth::user()->can('assign users'), 403); + \App\Models\Project::findOrFail($this->projectId)->users()->detach($userId); + $this->dispatch('project-users-changed'); + $this->dispatch('notify', 'Usuario eliminado del proyecto.'); + } +} diff --git a/database/seeders/PermissionCatalogSeeder.php b/database/seeders/PermissionCatalogSeeder.php index f226488..f70fc09 100644 --- a/database/seeders/PermissionCatalogSeeder.php +++ b/database/seeders/PermissionCatalogSeeder.php @@ -53,6 +53,7 @@ class PermissionCatalogSeeder extends Seeder 'create companies' => 'Crear empresas', 'edit companies' => 'Editar empresas', 'delete companies' => 'Eliminar empresas', + 'assign companies' => 'Asignar empresas/roles a proyectos', ], 'Usuarios' => [ 'view users' => 'Ver usuarios', diff --git a/resources/views/livewire/project-companies.blade.php b/resources/views/livewire/project-companies.blade.php index 9a581a0..45fe938 100644 --- a/resources/views/livewire/project-companies.blade.php +++ b/resources/views/livewire/project-companies.blade.php @@ -1,13 +1,6 @@
- @if(session()->has('message')) -
{{ session('message') }}
- @endif - @if(session()->has('error')) -
{{ session('error') }}
- @endif - - {{-- Asignar compañía --}} - @can('assign users') + {{-- Asignar empresa --}} + @can('assign companies')
@@ -17,60 +10,20 @@ @endforeach + @error('selectedCompanyId') {{ $message }} @enderror
-
+
@endcan - {{-- Lista de compañías asignadas --}} - @if($assignedCompanies->isNotEmpty()) -
- @foreach($assignedCompanies as $company) -
-
- - {{ strtoupper(substr($company->name, 0, 1)) }} - -
- {{ $company->name }} - @if($company->tax_id) - {{ $company->tax_id }} - @endif -
-
-
- @can('assign users') - - - @else - {{ ucfirst($company->pivot->role_in_project) }} - @endcan -
-
- @endforeach -
- @else -

{{ __('No companies assigned yet') }}

- @endif -
\ No newline at end of file + {{-- Tabla Rappasoft de empresas asignadas --}} + +
diff --git a/resources/views/livewire/project-users.blade.php b/resources/views/livewire/project-users.blade.php index c1fb42f..41b1c33 100644 --- a/resources/views/livewire/project-users.blade.php +++ b/resources/views/livewire/project-users.blade.php @@ -1,11 +1,4 @@
- @if(session()->has('message')) -
{{ session('message') }}
- @endif - @if(session()->has('error')) -
{{ session('error') }}
- @endif - {{-- Asignar usuario --}} @can('assign users')
@@ -17,54 +10,20 @@ @endforeach + @error('selectedUserId') {{ $message }} @enderror
-
+
@endcan - {{-- Lista de usuarios asignados --}} - @if($assignedUsers->isNotEmpty()) -
- @foreach($assignedUsers as $user) -
-
- - {{ strtoupper(substr($user->name, 0, 1)) }} - -
- {{ $user->name }} - {{ $user->email }} -
-
-
- @can('assign users') - - - @else - {{ ucfirst($user->pivot->role_in_project) }} - @endcan -
-
- @endforeach -
- @else -

{{ __('No users assigned yet') }}

- @endif -
\ No newline at end of file + {{-- Tabla Rappasoft de usuarios asignados --}} + + diff --git a/tests/Feature/ProjectAssignmentsTest.php b/tests/Feature/ProjectAssignmentsTest.php new file mode 100644 index 0000000..7d2c6c3 --- /dev/null +++ b/tests/Feature/ProjectAssignmentsTest.php @@ -0,0 +1,116 @@ +admin = User::factory()->create(); + $this->admin->givePermissionTo(['assign users', 'assign companies']); + + $this->project = Project::create([ + 'reference' => 'ASG-1', 'name' => 'Proyecto Asig', 'address' => 'x', + 'lat' => 40.0, 'lng' => -3.0, 'start_date' => now()->toDateString(), + 'end_date_estimated' => now()->addMonth()->toDateString(), + 'status' => 'in_progress', 'created_by' => $this->admin->id, + ]); + } + + // ── Users ────────────────────────────────────────────────────────────────────── + + public function test_assign_user_and_table_lists_it(): void + { + $member = User::factory()->create(['name' => 'Zoe Member']); + + Livewire::actingAs($this->admin) + ->test(ProjectUsers::class, ['project' => $this->project]) + ->set('selectedUserId', $member->id) + ->set('selectedRole', 'supervisor') + ->call('assignUser') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('project_user', [ + 'project_id' => $this->project->id, 'user_id' => $member->id, 'role_in_project' => 'supervisor', + ]); + + Livewire::actingAs($this->admin) + ->test(ProjectUsersTable::class, ['projectId' => $this->project->id]) + ->assertOk() + ->assertSee('Zoe Member'); + } + + public function test_users_table_change_role_and_remove(): void + { + $member = User::factory()->create(); + $this->project->users()->attach($member->id, ['role_in_project' => 'viewer']); + + Livewire::actingAs($this->admin) + ->test(ProjectUsersTable::class, ['projectId' => $this->project->id]) + ->call('changeRole', $member->id, 'supervisor'); + $this->assertDatabaseHas('project_user', ['user_id' => $member->id, 'role_in_project' => 'supervisor']); + + Livewire::actingAs($this->admin) + ->test(ProjectUsersTable::class, ['projectId' => $this->project->id]) + ->call('removeUser', $member->id); + $this->assertDatabaseMissing('project_user', ['project_id' => $this->project->id, 'user_id' => $member->id]); + } + + // ── Companies ──────────────────────────────────────────────────────────────────── + + public function test_assign_company_requires_assign_companies_permission(): void + { + $weak = User::factory()->create(); + $weak->givePermissionTo('assign users'); // but NOT 'assign companies' + $company = Company::create(['name' => 'ACME', 'estado' => 'activo', 'type' => 'constructor']); + + Livewire::actingAs($weak) + ->test(ProjectCompanies::class, ['project' => $this->project]) + ->set('selectedCompanyId', $company->id) + ->set('selectedRole', 'constructor') + ->call('assignCompany') + ->assertForbidden(); + } + + public function test_assign_company_and_table_lists_it(): void + { + $company = Company::create(['name' => 'Constructora Sur', 'estado' => 'activo', 'type' => 'constructor']); + + Livewire::actingAs($this->admin) + ->test(ProjectCompanies::class, ['project' => $this->project]) + ->set('selectedCompanyId', $company->id) + ->set('selectedRole', 'constructor') + ->call('assignCompany') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('company_project', [ + 'project_id' => $this->project->id, 'company_id' => $company->id, 'role_in_project' => 'constructor', + ]); + + Livewire::actingAs($this->admin) + ->test(ProjectCompaniesTable::class, ['projectId' => $this->project->id]) + ->assertOk() + ->assertSee('Constructora Sur'); + } +}