feat(projects): usuarios/empresas del proyecto como tablas Rappasoft + permiso assign companies

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 16:22:59 +02:00
parent c378ab5884
commit 480dfc657f
8 changed files with 433 additions and 188 deletions
+23 -41
View File
@@ -2,15 +2,15 @@
namespace App\Livewire; namespace App\Livewire;
use Livewire\Component;
use App\Models\Project;
use App\Models\Company; use App\Models\Company;
use App\Models\Project;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Livewire\Component;
class ProjectCompanies extends Component class ProjectCompanies extends Component
{ {
public Project $project; public Project $project;
public $assignedCompanies = [];
public $allCompanies = []; public $allCompanies = [];
public $selectedCompanyId = ''; public $selectedCompanyId = '';
public $selectedRole = 'other'; public $selectedRole = 'other';
@@ -18,64 +18,46 @@ class ProjectCompanies extends Component
public function mount(Project $project) public function mount(Project $project)
{ {
$this->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->project->companies()->pluck('companies.id')->toArray();
$assignedIds = $this->assignedCompanies->pluck('id')->toArray();
$this->allCompanies = Company::whereNotIn('id', $assignedIds)->orderBy('name')->get(); $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() public function assignCompany()
{ {
$user = Auth::user(); abort_unless(Auth::user()->can('assign companies'), 403);
if (!$user->can('assign users')) {
session()->flash('error', 'No tienes permisos para asignar compañías.');
return;
}
$this->validate([ $this->validate([
'selectedCompanyId' => 'required|exists:companies,id', '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, [ $this->project->companies()->attach($this->selectedCompanyId, [
'role_in_project' => $this->selectedRole 'role_in_project' => $this->selectedRole,
]); ]);
$this->reset(['selectedCompanyId', 'selectedRole']); $this->reset(['selectedCompanyId', 'selectedRole']);
$this->loadCompanies(); $this->loadAvailable();
$this->dispatch('notify', 'Compañía asignada al proyecto.'); $this->dispatch('project-companies-changed');
} $this->dispatch('notify', 'Empresa 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.');
} }
public function render() public function render()
{ {
return view('livewire.project-companies'); return view('livewire.project-companies', [
'roles' => ProjectCompaniesTable::ROLES,
]);
} }
} }
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace App\Livewire;
use App\Models\Company;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
class ProjectCompaniesTable extends DataTableComponent
{
protected $model = Company::class;
public int $projectId;
/** role_in_project => 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 = '<div class="flex items-center gap-2">
<span class="w-7 h-7 rounded-full bg-primary text-primary-content flex items-center justify-center text-xs font-bold shrink-0">'.$initial.'</span>
<div><span class="font-medium">'.e($value).'</span>';
if ($row->tax_id) {
$html .= '<div class="text-xs text-base-content/50">'.e($row->tax_id).'</div>';
}
$html .= '</div></div>';
return $html;
})
->html(),
Column::make('Rol', 'role_in_project')
->label(function ($row) {
$current = $row->role_in_project;
if (! Auth::user()->can('assign companies')) {
return '<span class="badge badge-sm">'.(self::ROLES[$current] ?? ucfirst((string) $current)).'</span>';
}
$opts = '';
foreach (self::ROLES as $val => $label) {
$opts .= '<option value="'.$val.'"'.($current === $val ? ' selected' : '').'>'.$label.'</option>';
}
return '<select wire:change="changeRole('.$row->id.', $event.target.value)" class="select select-bordered select-xs">'.$opts.'</select>';
})
->html(),
Column::make('Acciones')
->label(function ($row) {
if (! Auth::user()->can('assign companies')) {
return '';
}
return '<div class="flex justify-end">
<button wire:click="removeCompany('.$row->id.')" wire:confirm="¿Quitar a '.e($row->name).' del proyecto?"
class="btn btn-xs btn-error btn-outline" title="Quitar del proyecto">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>';
})
->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.');
}
}
+21 -39
View File
@@ -2,15 +2,15 @@
namespace App\Livewire; namespace App\Livewire;
use Livewire\Component;
use App\Models\Project; use App\Models\Project;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Livewire\Component;
class ProjectUsers extends Component class ProjectUsers extends Component
{ {
public Project $project; public Project $project;
public $assignedUsers = [];
public $allUsers = []; public $allUsers = [];
public $selectedUserId = ''; public $selectedUserId = '';
public $selectedRole = 'viewer'; public $selectedRole = 'viewer';
@@ -18,64 +18,46 @@ class ProjectUsers extends Component
public function mount(Project $project) public function mount(Project $project)
{ {
$this->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->project->users()->pluck('users.id')->toArray();
$assignedIds = $this->assignedUsers->pluck('id')->toArray();
$this->allUsers = User::whereNotIn('id', $assignedIds)->orderBy('name')->get(); $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() public function assignUser()
{ {
$user = Auth::user(); abort_unless(Auth::user()->can('assign users'), 403);
if (!$user->can('assign users')) {
session()->flash('error', 'No tienes permisos para asignar usuarios.');
return;
}
$this->validate([ $this->validate([
'selectedUserId' => 'required|exists:users,id', '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, [ $this->project->users()->attach($this->selectedUserId, [
'role_in_project' => $this->selectedRole 'role_in_project' => $this->selectedRole,
]); ]);
$this->reset(['selectedUserId', 'selectedRole']); $this->reset(['selectedUserId', 'selectedRole']);
$this->loadUsers(); $this->loadAvailable();
$this->dispatch('project-users-changed');
$this->dispatch('notify', 'Usuario asignado al proyecto.'); $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() public function render()
{ {
return view('livewire.project-users'); return view('livewire.project-users', [
'roles' => ProjectUsersTable::ROLES,
]);
} }
} }
+125
View File
@@ -0,0 +1,125 @@
<?php
namespace App\Livewire;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
class ProjectUsersTable extends DataTableComponent
{
protected $model = User::class;
public int $projectId;
/** role_in_project => 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 '<div class="flex items-center gap-2">
<span class="w-7 h-7 rounded-full bg-primary text-primary-content flex items-center justify-center text-xs font-bold shrink-0">'.$initial.'</span>
<span class="font-medium">'.e($value).'</span>
</div>';
})
->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 '<span class="badge badge-sm">'.(self::ROLES[$current] ?? ucfirst((string) $current)).'</span>';
}
$opts = '';
foreach (self::ROLES as $val => $label) {
$opts .= '<option value="'.$val.'"'.($current === $val ? ' selected' : '').'>'.$label.'</option>';
}
return '<select wire:change="changeRole('.$row->id.', $event.target.value)" class="select select-bordered select-xs">'.$opts.'</select>';
})
->html(),
Column::make('Acciones')
->label(function ($row) {
if (! Auth::user()->can('assign users')) {
return '';
}
return '<div class="flex justify-end">
<button wire:click="removeUser('.$row->id.')" wire:confirm="¿Quitar a '.e($row->name).' del proyecto?"
class="btn btn-xs btn-error btn-outline" title="Quitar del proyecto">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>';
})
->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.');
}
}
@@ -53,6 +53,7 @@ class PermissionCatalogSeeder extends Seeder
'create companies' => 'Crear empresas', 'create companies' => 'Crear empresas',
'edit companies' => 'Editar empresas', 'edit companies' => 'Editar empresas',
'delete companies' => 'Eliminar empresas', 'delete companies' => 'Eliminar empresas',
'assign companies' => 'Asignar empresas/roles a proyectos',
], ],
'Usuarios' => [ 'Usuarios' => [
'view users' => 'Ver usuarios', 'view users' => 'Ver usuarios',
@@ -1,13 +1,6 @@
<div> <div>
@if(session()->has('message')) {{-- Asignar empresa --}}
<div class="alert alert-success mb-2 text-sm">{{ session('message') }}</div> @can('assign companies')
@endif
@if(session()->has('error'))
<div class="alert alert-error mb-2 text-sm">{{ session('error') }}</div>
@endif
{{-- Asignar compañía --}}
@can('assign users')
<form wire:submit.prevent="assignCompany" class="flex items-end gap-2 mb-4"> <form wire:submit.prevent="assignCompany" class="flex items-end gap-2 mb-4">
<div class="flex-1"> <div class="flex-1">
<label class="label-text text-xs">{{ __('Companies') }}</label> <label class="label-text text-xs">{{ __('Companies') }}</label>
@@ -17,60 +10,20 @@
<option value="{{ $company->id }}">{{ $company->name }} @if($company->tax_id) ({{ $company->tax_id }}) @endif</option> <option value="{{ $company->id }}">{{ $company->name }} @if($company->tax_id) ({{ $company->tax_id }}) @endif</option>
@endforeach @endforeach
</select> </select>
@error('selectedCompanyId') <span class="text-error text-xs">{{ $message }}</span> @enderror
</div> </div>
<div class="w-32"> <div class="w-40">
<label class="label-text text-xs">{{ __('Role') }}</label> <label class="label-text text-xs">{{ __('Role') }}</label>
<select wire:model="selectedRole" class="select select-bordered select-sm w-full"> <select wire:model="selectedRole" class="select select-bordered select-sm w-full">
<option value="owner">{{ __('Owner') }}</option> @foreach($roles as $value => $label)
<option value="constructor">{{ __('Constructor') }}</option> <option value="{{ $value }}">{{ $label }}</option>
<option value="subcontractor">{{ __('Subcontractor') }}</option> @endforeach
<option value="consultant">{{ __('Consultant') }}</option>
<option value="supplier">{{ __('Supplier') }}</option>
<option value="other">{{ __('Other') }}</option>
</select> </select>
</div> </div>
<button type="submit" class="btn btn-primary btn-sm">{{ __('Assign') }}</button> <button type="submit" class="btn btn-primary btn-sm">{{ __('Assign') }}</button>
</form> </form>
@endcan @endcan
{{-- Lista de compañías asignadas --}} {{-- Tabla Rappasoft de empresas asignadas --}}
@if($assignedCompanies->isNotEmpty()) <livewire:project-companies-table :project-id="$project->id" :key="'project-companies-table-'.$project->id" />
<div class="space-y-1">
@foreach($assignedCompanies as $company)
<div class="flex items-center justify-between p-2 border rounded text-sm">
<div class="flex items-center gap-2">
<span class="w-6 h-6 rounded-full bg-primary text-white flex items-center justify-center text-xs font-bold">
{{ strtoupper(substr($company->name, 0, 1)) }}
</span>
<div>
<span class="font-medium">{{ $company->name }}</span>
@if($company->tax_id)
<span class="text-xs text-gray-400 ml-1">{{ $company->tax_id }}</span>
@endif
</div>
</div>
<div class="flex items-center gap-1">
@can('assign users')
<select wire:change="changeRole({{ $company->id }}, $event.target.value)"
class="select select-bordered select-xs">
<option value="owner" @selected($company->pivot->role_in_project == 'owner')>{{ __('Owner') }}</option>
<option value="constructor" @selected($company->pivot->role_in_project == 'constructor')>{{ __('Constructor') }}</option>
<option value="subcontractor" @selected($company->pivot->role_in_project == 'subcontractor')>{{ __('Subcontractor') }}</option>
<option value="consultant" @selected($company->pivot->role_in_project == 'consultant')>{{ __('Consultant') }}</option>
<option value="supplier" @selected($company->pivot->role_in_project == 'supplier')>{{ __('Supplier') }}</option>
<option value="other" @selected($company->pivot->role_in_project == 'other')>{{ __('Other') }}</option>
</select>
<button wire:click="removeCompany({{ $company->id }})"
class="btn btn-xs btn-ghost text-error"
onclick="return confirm('{{ __('Remove') }} {{ $company->name }}?')"></button>
@else
<span class="badge badge-sm">{{ ucfirst($company->pivot->role_in_project) }}</span>
@endcan
</div>
</div>
@endforeach
</div>
@else
<p class="text-sm text-gray-400 text-center py-4">{{ __('No companies assigned yet') }}</p>
@endif
</div> </div>
@@ -1,11 +1,4 @@
<div> <div>
@if(session()->has('message'))
<div class="alert alert-success mb-2 text-sm">{{ session('message') }}</div>
@endif
@if(session()->has('error'))
<div class="alert alert-error mb-2 text-sm">{{ session('error') }}</div>
@endif
{{-- Asignar usuario --}} {{-- Asignar usuario --}}
@can('assign users') @can('assign users')
<form wire:submit.prevent="assignUser" class="flex items-end gap-2 mb-4"> <form wire:submit.prevent="assignUser" class="flex items-end gap-2 mb-4">
@@ -17,54 +10,20 @@
<option value="{{ $u->id }}">{{ $u->name }} ({{ $u->email }})</option> <option value="{{ $u->id }}">{{ $u->name }} ({{ $u->email }})</option>
@endforeach @endforeach
</select> </select>
@error('selectedUserId') <span class="text-error text-xs">{{ $message }}</span> @enderror
</div> </div>
<div class="w-32"> <div class="w-40">
<label class="label-text text-xs">{{ __('Role') }}</label> <label class="label-text text-xs">{{ __('Role') }}</label>
<select wire:model="selectedRole" class="select select-bordered select-sm w-full"> <select wire:model="selectedRole" class="select select-bordered select-sm w-full">
<option value="supervisor">{{ __('Supervisor') }}</option> @foreach($roles as $value => $label)
<option value="consultant">{{ __('Consultant') }}</option> <option value="{{ $value }}">{{ $label }}</option>
<option value="client">{{ __('Client') }}</option> @endforeach
<option value="viewer">{{ __('Viewer') }}</option>
</select> </select>
</div> </div>
<button type="submit" class="btn btn-primary btn-sm">{{ __('Assign') }}</button> <button type="submit" class="btn btn-primary btn-sm">{{ __('Assign') }}</button>
</form> </form>
@endcan @endcan
{{-- Lista de usuarios asignados --}} {{-- Tabla Rappasoft de usuarios asignados --}}
@if($assignedUsers->isNotEmpty()) <livewire:project-users-table :project-id="$project->id" :key="'project-users-table-'.$project->id" />
<div class="space-y-1">
@foreach($assignedUsers as $user)
<div class="flex items-center justify-between p-2 border rounded text-sm">
<div class="flex items-center gap-2">
<span class="w-6 h-6 rounded-full bg-primary text-white flex items-center justify-center text-xs font-bold">
{{ strtoupper(substr($user->name, 0, 1)) }}
</span>
<div>
<span class="font-medium">{{ $user->name }}</span>
<span class="text-xs text-gray-400 ml-1">{{ $user->email }}</span>
</div>
</div>
<div class="flex items-center gap-1">
@can('assign users')
<select wire:change="changeRole({{ $user->id }}, $event.target.value)"
class="select select-bordered select-xs">
<option value="supervisor" @selected($user->pivot->role_in_project == 'supervisor')>{{ __('Supervisor') }}</option>
<option value="consultant" @selected($user->pivot->role_in_project == 'consultant')>{{ __('Consultant') }}</option>
<option value="client" @selected($user->pivot->role_in_project == 'client')>{{ __('Client') }}</option>
<option value="viewer" @selected($user->pivot->role_in_project == 'viewer')>{{ __('Viewer') }}</option>
</select>
<button wire:click="removeUser({{ $user->id }})"
class="btn btn-xs btn-ghost text-error"
onclick="return confirm('{{ __('Remove') }} {{ $user->name }}?')"></button>
@else
<span class="badge badge-sm">{{ ucfirst($user->pivot->role_in_project) }}</span>
@endcan
</div>
</div>
@endforeach
</div>
@else
<p class="text-sm text-gray-400 text-center py-4">{{ __('No users assigned yet') }}</p>
@endif
</div> </div>
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace Tests\Feature;
use App\Livewire\ProjectCompanies;
use App\Livewire\ProjectCompaniesTable;
use App\Livewire\ProjectUsers;
use App\Livewire\ProjectUsersTable;
use App\Models\Company;
use App\Models\Project;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Tests\TestCase;
class ProjectAssignmentsTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private Project $project;
protected function setUp(): void
{
parent::setUp();
foreach (['assign users', 'assign companies'] as $p) {
Permission::findOrCreate($p);
}
$this->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');
}
}