Files
construprogress/app/Livewire/Layers/LayerManager.php
T
Javier Braña 90630379fb chore: cleanup dead code + format with Pint
- Remove unused FeaturesController (empty stubs, no routes)
- Remove ConvertSpatialFile CLI command (unused; service used in LayerManager)
- Remove MigrateGeojsonToFeatures CLI command (one-shot migration, not referenced)
- Remove .claude/worktrees/ (11 old agent worktrees from June)
- Apply Laravel Pint formatting across 219 files (style only, no functional changes)

Tests: 101 passing (319 assertions)
API routes: unchanged (8 routes intact)
2026-08-28 13:04:28 +02:00

439 lines
16 KiB
PHP

<?php
namespace App\Livewire\Layers;
use App\Models\Feature;
use App\Models\InspectionTemplate;
use App\Models\Layer;
use App\Models\Phase;
use App\Models\Project;
use App\Services\SpatialFileConverter;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\WithFileUploads;
#[Layout('layouts.app')]
class LayerManager extends Component
{
use WithFileUploads;
public Project $project;
public Phase $phase;
public $layers;
public $selectedLayer = null;
public $visibleLayers = [];
public $uploadFile = null;
public $layerName = '';
public $layerColor = '#3b82f6';
// Batch assign
public $templates = [];
public $batchTemplateId = null;
public $batchStatus = '';
public function mount(Project $project, Phase $phase)
{
$this->project = $project;
$this->phase = $phase;
if ($this->phase->project_id !== $this->project->id) {
abort(404);
}
$user = Auth::user();
if (! $user->can('manage all') && ! $project->users()->where('user_id', $user->id)->exists()) {
abort(403);
}
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
$this->loadLayers();
$this->visibleLayers = $this->layers->pluck('id')->toArray();
$this->emitInitialLayersData();
}
// ── Data loaders ──────────────────────────────────────────────────────────
public function loadLayers()
{
$this->layers = Layer::withCount('features')
->withAvg('features', 'progress')
->where('phase_id', $this->phase->id)
->latest()
->get();
$this->visibleLayers = array_values(
array_intersect($this->visibleLayers, $this->layers->pluck('id')->toArray())
);
}
private function buildLayerPayload(Layer $layer): array
{
$color = $layer->color ?: '#3b82f6';
$features = ($layer->relationLoaded('features') ? $layer->features : $layer->features()->get())
->map(fn ($f) => [
'type' => 'Feature',
'id' => $f->id,
'geometry' => $f->geometry,
'properties' => [
'name' => $f->name ?? 'Elemento',
'progress' => $f->progress,
'status' => $f->status ?? 'planned',
'responsible' => $f->responsible,
'template_id' => $f->template_id,
],
])->values()->toArray();
return [
'id' => $layer->id,
'color' => $color,
'geojson' => [
'type' => 'FeatureCollection',
'features' => $features,
'style' => ['color' => $color],
],
];
}
private function emitInitialLayersData()
{
$this->layers->loadMissing('features');
$this->dispatch('initialLayersData', [
'layers' => $this->layers->map(fn ($l) => $this->buildLayerPayload($l)),
'visibleLayers' => $this->visibleLayers,
'selectedLayerId' => $this->selectedLayer?->id,
]);
}
// ── Visibility ────────────────────────────────────────────────────────────
public function toggleLayerVisibility($layerId)
{
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
$this->dispatch('notify', 'No puedes ocultar la capa que estás editando');
return;
}
if (in_array($layerId, $this->visibleLayers)) {
$this->visibleLayers = array_values(array_diff($this->visibleLayers, [$layerId]));
} else {
$this->visibleLayers[] = $layerId;
}
$this->dispatch('visibilityChanged', $this->visibleLayers);
}
// ── Select ────────────────────────────────────────────────────────────────
public function selectLayer($layerId)
{
$this->selectedLayer = Layer::with('features')->find($layerId);
if (! $this->selectedLayer) {
return;
}
if (! in_array($layerId, $this->visibleLayers)) {
$this->visibleLayers[] = $layerId;
$this->dispatch('visibilityChanged', $this->visibleLayers);
}
$payload = $this->buildLayerPayload($this->selectedLayer);
$this->dispatch('layerSelectedForEdit', [
'layerId' => $layerId,
'geojson' => $payload['geojson'],
'color' => $payload['color'],
]);
$this->dispatch('notify', 'Editando: '.$this->selectedLayer->name);
}
// ── Import file ───────────────────────────────────────────────────────────
public function importFile()
{
$user = Auth::user();
if (! $user->can('upload layers')) {
$this->dispatch('notify', 'Sin permisos para subir capas');
return;
}
$this->validate([
'uploadFile' => 'required|file|max:51200',
'layerName' => 'required|string|max:255',
'layerColor' => 'nullable|string|size:7',
]);
$ext = strtolower($this->uploadFile->getClientOriginalExtension());
$allowed = ['geojson', 'json', 'kmz', 'kml', 'shp', 'dwg', 'zip'];
if (! in_array($ext, $allowed)) {
$this->dispatch('notify', 'Extensión no permitida. Válidas: '.implode(', ', $allowed));
return;
}
$geojson = SpatialFileConverter::convertToGeoJson($this->uploadFile);
if (! $geojson) {
$this->dispatch('notify', 'No se pudo convertir el archivo. Comprueba que sea GeoJSON, KML o Shapefile válido.');
return;
}
$layerColor = $this->layerColor ?: '#3b82f6';
$layerName = $this->layerName;
try {
DB::transaction(function () use ($geojson, $layerColor, $layerName, $user) {
$path = $this->uploadFile->store(
"uploads/projects/{$this->project->id}/layers", 'public'
);
$layer = Layer::create([
'project_id' => $this->project->id,
'phase_id' => $this->phase->id,
'name' => $layerName,
'color' => $layerColor,
'original_file' => $path,
'uploaded_by' => $user->id,
]);
$idx = 0;
foreach ($geojson['features'] ?? [] as $fd) {
$idx++;
$name = trim($fd['properties']['name'] ?? '');
if ($name === '') {
$name = $layerName.' — Elemento '.$idx;
}
Feature::create([
'layer_id' => $layer->id,
'name' => $name,
'geometry' => $fd['geometry'],
'properties' => $fd['properties'] ?? [],
'template_id' => $fd['properties']['template_id'] ?? null,
'progress' => $fd['properties']['progress'] ?? 0,
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
? $fd['properties']['status']
: 'planned',
'responsible' => $fd['properties']['responsible'] ?? null,
]);
}
$this->visibleLayers[] = $layer->id;
});
} catch (\Throwable $e) {
$this->dispatch('notify', 'Error al importar: '.$e->getMessage());
return;
}
$this->loadLayers();
$this->reset(['uploadFile', 'layerName']);
$this->emitInitialLayersData();
$this->dispatch('notify', 'Capa importada correctamente');
}
// ── Create empty layer ────────────────────────────────────────────────────
public function createEmptyLayer()
{
$user = Auth::user();
if (! $user->can('upload layers')) {
$this->dispatch('notify', 'Sin permisos para crear capas');
return;
}
$layer = Layer::create([
'project_id' => $this->project->id,
'phase_id' => $this->phase->id,
'name' => $this->layerName ?: 'Nueva capa',
'color' => $this->layerColor ?: '#3b82f6',
'original_file' => null,
'uploaded_by' => $user->id,
]);
$this->loadLayers();
$this->visibleLayers[] = $layer->id;
$this->selectLayer($layer->id);
$this->emitInitialLayersData();
$this->dispatch('notify', 'Capa vacía creada. Dibuja elementos en el mapa.');
}
// ── Save drawn GeoJSON ────────────────────────────────────────────────────
#[On('save-manual-geojson')]
public function saveManualGeojson($geojsonString)
{
if (! $this->selectedLayer) {
$this->dispatch('notify', 'No hay capa seleccionada');
return;
}
$geojson = json_decode($geojsonString, true);
if (json_last_error() !== JSON_ERROR_NONE || ! isset($geojson['features'])) {
$this->dispatch('notify', 'GeoJSON inválido');
return;
}
$layerId = $this->selectedLayer->id;
$layerName = $this->selectedLayer->name;
try {
DB::transaction(function () use ($geojson, $layerId, $layerName) {
// forceDelete: reemplazamos completamente los elementos de la capa
Feature::where('layer_id', $layerId)->forceDelete();
$idx = 0;
foreach ($geojson['features'] as $fd) {
$idx++;
$name = trim($fd['properties']['name'] ?? '');
if ($name === '') {
$name = $layerName.' — Elemento '.$idx;
}
Feature::create([
'layer_id' => $layerId,
'name' => $name,
'geometry' => $fd['geometry'],
'properties' => $fd['properties'] ?? [],
'template_id' => $fd['properties']['template_id'] ?? null,
'progress' => $fd['properties']['progress'] ?? 0,
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
? $fd['properties']['status']
: 'planned',
'responsible' => $fd['properties']['responsible'] ?? null,
]);
}
});
} catch (\Throwable $e) {
$this->dispatch('notify', 'Error al guardar: '.$e->getMessage());
return;
}
$this->loadLayers();
$this->selectLayer($this->selectedLayer->id);
$this->emitInitialLayersData();
$this->dispatch('notify', count($geojson['features']).' elementos guardados');
}
// ── Delete layer ──────────────────────────────────────────────────────────
public function deleteLayer($layerId)
{
$user = Auth::user();
if (! $user->can('delete layers')) {
abort(403);
}
// Verify it belongs to this phase (prevents cross-project deletion)
$layer = Layer::where('id', $layerId)->where('phase_id', $this->phase->id)->first();
if (! $layer) {
return;
}
if ($layer->original_file) {
Storage::disk('public')->delete($layer->original_file);
}
$layer->features()->delete();
$layer->delete();
$this->loadLayers();
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
$this->selectedLayer = null;
$this->dispatch('layerSelectedForEdit', null);
}
$this->emitInitialLayersData();
$this->dispatch('notify', 'Capa eliminada');
}
// ── Export GeoJSON ────────────────────────────────────────────────────────
public function exportLayer($layerId)
{
$layer = Layer::with('features')
->where('id', $layerId)
->where('phase_id', $this->phase->id)
->first();
if (! $layer) {
return;
}
$fc = [
'type' => 'FeatureCollection',
'name' => $layer->name,
'features' => $layer->features->map(fn ($f) => [
'type' => 'Feature',
'geometry' => $f->geometry,
'properties' => array_merge($f->properties ?? [], [
'name' => $f->name,
'progress' => $f->progress,
'status' => $f->status,
'responsible' => $f->responsible,
'template_id' => $f->template_id,
]),
])->values()->toArray(),
];
$filename = preg_replace('/[^a-z0-9_\-]/i', '_', $layer->name).'.geojson';
return response()->streamDownload(function () use ($fc) {
echo json_encode($fc, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}, $filename, ['Content-Type' => 'application/geo+json']);
}
// ── Batch assign template / status ────────────────────────────────────────
public function batchAssign($layerId)
{
$layer = Layer::where('id', $layerId)->where('phase_id', $this->phase->id)->first();
if (! $layer) {
return;
}
$data = [];
if ($this->batchStatus && in_array($this->batchStatus, Feature::STATUSES)) {
$data['status'] = $this->batchStatus;
}
if ($this->batchTemplateId) {
$data['template_id'] = (int) $this->batchTemplateId;
}
if (empty($data)) {
$this->dispatch('notify', 'Selecciona un estado o template para asignar');
return;
}
$count = $layer->features()->update($data);
$this->loadLayers();
$this->emitInitialLayersData();
$this->dispatch('notify', "$count elemento(s) actualizados");
}
// ── Cancel editing ────────────────────────────────────────────────────────
public function cancelEditing()
{
$this->selectedLayer = null;
$this->dispatch('layerSelectedForEdit', null);
}
public function render()
{
return view('livewire.layers.layer-manager');
}
}