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
+
+
+
+
+
+ @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' }}
+
+
+
+
+
+ @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();
+ }
+}