diff --git a/app/Livewire/Projects/FeatureManager.php b/app/Livewire/Projects/FeatureManager.php new file mode 100644 index 0000000..3136ac4 --- /dev/null +++ b/app/Livewire/Projects/FeatureManager.php @@ -0,0 +1,81 @@ +project = $project; + abort_unless($this->canManage(), 403); + $this->featureTypes = FeatureType::orderBy('name')->get(); + } + + private function canManage(): bool + { + $user = Auth::user(); + return $user->can('manage all') + || ($user->can('edit layers') && $this->project->users()->where('user_id', $user->id)->exists()); + } + + #[On('feature-edit')] + public function editFeature($id): void + { + abort_unless($this->canManage(), 403); + $feature = $this->findFeature($id); + $this->editingId = $feature->id; + $this->name = $feature->name ?? ''; + $this->featureTypeId = $feature->feature_type_id ?? ''; + $this->isActive = (bool) $feature->is_active; + $this->resetErrorBag(); + $this->showForm = true; + } + + public function save(): void + { + abort_unless($this->canManage(), 403); + $this->validate([ + 'name' => 'required|string|max:255', + 'featureTypeId' => 'nullable|exists:feature_types,id', + ]); + + $this->findFeature($this->editingId)->update([ + 'name' => $this->name, + 'feature_type_id' => $this->featureTypeId ?: null, + 'is_active' => $this->isActive, + ]); + + $this->showForm = false; + $this->dispatch('features-changed'); + $this->dispatch('notify', 'Elemento actualizado'); + } + + private function findFeature($id): Feature + { + return Feature::whereHas('layer.phase', fn ($q) => $q->where('project_id', $this->project->id))->findOrFail($id); + } + + public function render() + { + return view('livewire.projects.feature-manager'); + } +} diff --git a/app/Livewire/Projects/FeatureTypeManager.php b/app/Livewire/Projects/FeatureTypeManager.php new file mode 100644 index 0000000..f4ac37e --- /dev/null +++ b/app/Livewire/Projects/FeatureTypeManager.php @@ -0,0 +1,87 @@ +can('edit layers'), 403); + $this->loadTypes(); + } + + public function loadTypes(): void + { + $this->types = FeatureType::withCount('features')->orderBy('name')->get(); + } + + protected function rules(): array + { + return [ + 'name' => 'required|string|max:255|unique:feature_types,name,' . ($this->editingId ?? 'NULL'), + 'description' => 'nullable|string|max:255', + 'color' => 'required|string|max:7', + ]; + } + + public function newType(): void + { + $this->reset(['editingId', 'name', 'description']); + $this->color = '#6b7280'; + $this->resetErrorBag(); + $this->showForm = true; + } + + public function edit($id): void + { + $t = FeatureType::findOrFail($id); + $this->editingId = $t->id; + $this->name = $t->name; + $this->description = $t->description ?? ''; + $this->color = $t->color ?? '#6b7280'; + $this->resetErrorBag(); + $this->showForm = true; + } + + public function save(): void + { + abort_unless(Auth::user()->can('edit layers'), 403); + $this->validate(); + + FeatureType::updateOrCreate( + ['id' => $this->editingId], + ['name' => $this->name, 'description' => $this->description ?: null, 'color' => $this->color], + ); + + $this->showForm = false; + $this->loadTypes(); + $this->dispatch('notify', 'Tipo de elemento guardado'); + } + + public function delete($id): void + { + abort_unless(Auth::user()->can('edit layers'), 403); + FeatureType::findOrFail($id)->delete(); + $this->loadTypes(); + $this->dispatch('notify', 'Tipo de elemento eliminado'); + } + + public function render() + { + return view('livewire.projects.feature-type-manager'); + } +} diff --git a/app/Livewire/Projects/ProjectFeaturesTable.php b/app/Livewire/Projects/ProjectFeaturesTable.php new file mode 100644 index 0000000..61ddf4a --- /dev/null +++ b/app/Livewire/Projects/ProjectFeaturesTable.php @@ -0,0 +1,141 @@ +setPrimaryKey('id') + ->setDefaultSort('name', 'asc') + ->setSortingPillsEnabled(false) + ->setSecondaryHeaderEnabled() + ->setAdditionalSelects(['features.id as id', 'features.layer_id as layer_id', 'features.feature_type_id as feature_type_id']); + } + + #[On('features-changed')] + public function refreshRows(): void + { + // + } + + private function canManage(): bool + { + $user = Auth::user(); + return $user->can('manage all') || + ($user->can('edit layers') && + Project::whereKey($this->projectId)->whereHas('users', fn ($q) => $q->where('user_id', $user->id))->exists()); + } + + public function builder(): Builder + { + abort_unless($this->canManage(), 403); + + return Feature::query() + ->whereHas('layer.phase', fn ($q) => $q->where('project_id', $this->projectId)) + ->with(['layer.phase', 'featureType']); + } + + public function columns(): array + { + return [ + Column::make('Elemento', 'name') + ->sortable()->searchable() + ->secondaryHeaderFilter('name') + ->format(fn ($value) => '' . e($value) . '') + ->html(), + + Column::make('Capa') + ->secondaryHeaderFilter('layer') + ->label(fn ($row) => e($row->layer?->name ?? '—')), + + Column::make('Fase') + ->label(fn ($row) => e($row->layer?->phase?->name ?? '—')), + + Column::make('Tipo') + ->secondaryHeaderFilter('type') + ->label(fn ($row) => $row->featureType + ? '' . e($row->featureType->name) . '' + : '') + ->html(), + + Column::make('Activo', 'is_active') + ->sortable() + ->label(function ($row) { + if ($row->is_active) { + return ''; + } + return ''; + }) + ->html(), + + Column::make('Acciones') + ->label(fn ($row) => + '
+ + +
') + ->html(), + ]; + } + + public function filters(): array + { + $types = FeatureType::orderBy('name')->pluck('name', 'id')->toArray(); + + return [ + TextFilter::make('Elemento', 'name') + ->filter(fn (Builder $q, string $v) => $q->where('features.name', 'like', '%' . $v . '%')), + + SelectFilter::make('Capa', 'layer') + ->options(['' => 'Todas'] + \App\Models\Layer::whereHas('phase', fn ($q) => $q->where('project_id', $this->projectId))->orderBy('name')->pluck('name', 'id')->toArray()) + ->filter(fn (Builder $q, string $v) => $q->where('features.layer_id', $v)), + + SelectFilter::make('Tipo', 'type') + ->options(['' => 'Todos'] + $types) + ->filter(fn (Builder $q, string $v) => $q->where('features.feature_type_id', $v)), + ]; + } + + public function toggleActive(int $id): void + { + abort_unless($this->canManage(), 403); + $feature = $this->findFeature($id); + $feature->update(['is_active' => ! $feature->is_active]); + $this->dispatch('features-changed'); + $this->dispatch('notify', $feature->is_active ? 'Elemento activado' : 'Elemento desactivado'); + } + + public function deleteFeature(int $id): void + { + abort_unless($this->canManage(), 403); + $this->findFeature($id)->delete(); + $this->dispatch('features-changed'); + $this->dispatch('notify', 'Elemento eliminado'); + } + + private function findFeature(int $id): Feature + { + return Feature::whereHas('layer.phase', fn ($q) => $q->where('project_id', $this->projectId))->findOrFail($id); + } +} diff --git a/app/Models/Feature.php b/app/Models/Feature.php index 6be23ba..f06d4e3 100644 --- a/app/Models/Feature.php +++ b/app/Models/Feature.php @@ -13,16 +13,22 @@ class Feature extends Model const STATUSES = ['planned', 'started', 'in_progress', 'completed', 'verified']; protected $fillable = [ - 'layer_id', 'name', 'geometry', 'properties', 'template_id', - 'progress', 'status', 'responsible', 'responsible_user_id', + 'layer_id', 'name', 'geometry', 'properties', 'template_id', 'feature_type_id', + 'progress', 'status', 'is_active', 'responsible', 'responsible_user_id', 'uuid', 'client_updated_at', ]; protected $casts = [ 'geometry' => 'array', 'properties' => 'array', + 'is_active' => 'boolean', ]; + public function featureType() + { + return $this->belongsTo(FeatureType::class); + } + public function layer() { return $this->belongsTo(Layer::class); diff --git a/app/Models/FeatureType.php b/app/Models/FeatureType.php new file mode 100644 index 0000000..23b062d --- /dev/null +++ b/app/Models/FeatureType.php @@ -0,0 +1,18 @@ +hasMany(Feature::class); + } +} diff --git a/database/migrations/2026_06_25_100000_create_feature_types_and_link_features.php b/database/migrations/2026_06_25_100000_create_feature_types_and_link_features.php new file mode 100644 index 0000000..18fbeab --- /dev/null +++ b/database/migrations/2026_06_25_100000_create_feature_types_and_link_features.php @@ -0,0 +1,36 @@ +id(); + $table->string('name')->unique(); + $table->string('description')->nullable(); + $table->string('color', 7)->default('#6b7280'); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::table('features', function (Blueprint $table) { + $table->foreignId('feature_type_id')->nullable()->after('template_id') + ->constrained('feature_types')->nullOnDelete(); + $table->boolean('is_active')->default(true)->after('status'); + }); + } + + public function down(): void + { + Schema::table('features', function (Blueprint $table) { + $table->dropConstrainedForeignId('feature_type_id'); + $table->dropColumn('is_active'); + }); + + Schema::dropIfExists('feature_types'); + } +}; diff --git a/resources/views/livewire/projects/feature-manager.blade.php b/resources/views/livewire/projects/feature-manager.blade.php new file mode 100644 index 0000000..122d55e --- /dev/null +++ b/resources/views/livewire/projects/feature-manager.blade.php @@ -0,0 +1,61 @@ +
+ + Volver + + +
+
+

Elementos del proyecto

+

{{ $project->name }} · editar nombre, tipo, activar/desactivar o eliminar

+
+ + Tipos de elemento + +
+ + + + {{-- Modal editar elemento --}} + @if($showForm) +
+
+
+
+

Editar elemento

+ +
+
+
+ + + @error('name'){{ $message }}@enderror +
+
+ + + @if($featureTypes->isEmpty()) + No hay tipos. + Crear tipos + + @endif +
+
+ +
+
+ + +
+
+
+
+ @endif +
diff --git a/resources/views/livewire/projects/feature-type-manager.blade.php b/resources/views/livewire/projects/feature-type-manager.blade.php new file mode 100644 index 0000000..1d91e3f --- /dev/null +++ b/resources/views/livewire/projects/feature-type-manager.blade.php @@ -0,0 +1,73 @@ +
+
+
+

Tipos de elemento

+

Catálogo reutilizable de tipos para los elementos del mapa

+
+ +
+ + @if($types->isEmpty()) +
+ +

Sin tipos de elemento

+

Crea tipos (p. ej. Pilar, Viga, Muro) para clasificar los elementos.

+
+ @else +
+ @foreach($types as $t) +
+
+
+ +
+
{{ $t->name }}
+ @if($t->description)
{{ $t->description }}
@endif +
+ {{ $t->features_count }} uso(s) +
+
+ + +
+
+
+ @endforeach +
+ @endif + + {{-- Modal crear/editar --}} + @if($showForm) +
+
+
+
+

{{ $editingId ? 'Editar tipo' : 'Nuevo tipo' }}

+ +
+
+
+ + + @error('name'){{ $message }}@enderror +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ @endif +
diff --git a/resources/views/livewire/projects/project-dashboard.blade.php b/resources/views/livewire/projects/project-dashboard.blade.php index 2ab3789..715efc9 100644 --- a/resources/views/livewire/projects/project-dashboard.blade.php +++ b/resources/views/livewire/projects/project-dashboard.blade.php @@ -36,6 +36,12 @@ Mapa + @can('edit layers') + + + Elementos + + @endcan @can('edit projects') diff --git a/routes/web.php b/routes/web.php index a752008..f22d7c7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -83,6 +83,10 @@ Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboa // Rutas para el LayerManager: Route::get('/projects/{project}/phases/{phase}/layers/manage', \App\Livewire\Layers\LayerManager::class)->name('layers.manage'); + // Elementos del proyecto y catálogo de tipos de elemento + Route::get('/projects/{project}/features', \App\Livewire\Projects\FeatureManager::class)->middleware('can:edit layers')->name('projects.features'); + Route::get('/feature-types', \App\Livewire\Projects\FeatureTypeManager::class)->middleware('can:edit layers')->name('feature-types'); + // Cronograma Gantt y reporte del proyecto Route::get('/projects/{project}/gantt', PhaseGantt::class)->name('projects.gantt'); Route::get('/projects/{project}/report', [ProjectReportController::class, 'show'])->name('projects.report'); diff --git a/tests/Feature/FeatureManagementTest.php b/tests/Feature/FeatureManagementTest.php new file mode 100644 index 0000000..bc7e465 --- /dev/null +++ b/tests/Feature/FeatureManagementTest.php @@ -0,0 +1,125 @@ +user = User::factory()->create(); + $this->user->givePermissionTo('edit layers'); + + $this->project = Project::create([ + 'reference' => 'FT-1', 'name' => 'Proyecto Feat', 'address' => 'x', + 'lat' => 40.0, 'lng' => -3.0, 'start_date' => now()->toDateString(), + 'end_date_estimated' => now()->addMonth()->toDateString(), + 'status' => 'in_progress', 'created_by' => $this->user->id, + ]); + $this->project->users()->attach($this->user->id, ['role_in_project' => 'supervisor']); + + $phase = Phase::create(['project_id' => $this->project->id, 'name' => 'F1', 'order' => 1, 'color' => '#000', 'progress_percent' => 0]); + $layer = Layer::create(['project_id' => $this->project->id, 'phase_id' => $phase->id, 'name' => 'Capa A', 'color' => '#111', 'uploaded_by' => $this->user->id]); + $this->feature = Feature::create([ + 'layer_id' => $layer->id, 'name' => 'Pilar 12', + 'geometry' => ['type' => 'Point', 'coordinates' => [-3.0, 40.0]], + 'progress' => 0, 'status' => 'planned', 'is_active' => true, + ]); + } + + // ── Catálogo de tipos ──────────────────────────────────────────────────────── + + public function test_feature_type_manager_creates_a_type(): void + { + Livewire::actingAs($this->user) + ->test(FeatureTypeManager::class) + ->call('newType') + ->set('name', 'Pilar') + ->set('color', '#ff0000') + ->call('save') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('feature_types', ['name' => 'Pilar', 'color' => '#ff0000']); + } + + public function test_feature_type_manager_requires_edit_layers(): void + { + $weak = User::factory()->create(); + Livewire::actingAs($weak) + ->test(FeatureTypeManager::class) + ->assertForbidden(); + } + + // ── Editor de elementos ────────────────────────────────────────────────────── + + public function test_features_table_lists_and_toggles_active(): void + { + Livewire::actingAs($this->user) + ->test(ProjectFeaturesTable::class, ['projectId' => $this->project->id]) + ->assertOk() + ->assertSee('Pilar 12') + ->call('toggleActive', $this->feature->id); + + $this->assertFalse($this->feature->fresh()->is_active); + } + + public function test_feature_manager_edits_name_type_and_active(): void + { + $type = FeatureType::create(['name' => 'Viga', 'color' => '#00ff00']); + + Livewire::actingAs($this->user) + ->test(FeatureManager::class, ['project' => $this->project]) + ->call('editFeature', $this->feature->id) + ->assertSet('name', 'Pilar 12') + ->set('name', 'Pilar 12-B') + ->set('featureTypeId', $type->id) + ->set('isActive', false) + ->call('save') + ->assertHasNoErrors(); + + $f = $this->feature->fresh(); + $this->assertEquals('Pilar 12-B', $f->name); + $this->assertEquals($type->id, $f->feature_type_id); + $this->assertFalse($f->is_active); + } + + public function test_features_table_deletes_individual_feature(): void + { + Livewire::actingAs($this->user) + ->test(ProjectFeaturesTable::class, ['projectId' => $this->project->id]) + ->call('deleteFeature', $this->feature->id); + + $this->assertDatabaseMissing('features', ['id' => $this->feature->id, 'deleted_at' => null]); + } + + public function test_feature_manager_forbidden_without_edit_layers(): void + { + $weak = User::factory()->create(); + $this->project->users()->attach($weak->id, ['role_in_project' => 'viewer']); + + Livewire::actingAs($weak) + ->test(FeatureManager::class, ['project' => $this->project]) + ->assertForbidden(); + } +}