8025fa6d05
Permissions now actually govern access instead of the hard-coded Admin role:
- Super-admin bypass (see all projects / full access) -> can('manage all')
in Project::scopeAccessibleBy, ProjectMap, ProjectDashboard, PhaseGantt,
LayerManager, ProjectReportController.
- Redundant '|| hasRole(Admin)' fallbacks dropped (Gate::before already lets
manage-all through can()): LayerManager (upload/delete layers), MediaManager
(upload), ProjectMap (update progress), ProjectUsers/ProjectCompanies
(assign users).
- Admin-only screens now gated by the matching permission: AdminUsers/UserView
-> can('view users'), UserForm -> can('create users')|can('edit users'),
CompanyView -> can('view companies').
- MediaManager delete: can('delete media') OR owner.
- Kept UserForm's domain guard (can't remove your own Admin role).
Note: the /admin route group still has middleware can:manage all, so admin
screens stay super-admin-only until that group is relaxed per-route.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
401 lines
15 KiB
PHP
401 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire;
|
|
|
|
use Livewire\Component;
|
|
use Livewire\WithFileUploads;
|
|
use Livewire\Attributes\Layout;
|
|
use App\Models\Project;
|
|
use App\Models\Phase;
|
|
use App\Models\Layer;
|
|
use App\Models\Feature;
|
|
use App\Models\InspectionTemplate;
|
|
use App\Services\SpatialFileConverter;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
#[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 ────────────────────────────────────────────────────
|
|
|
|
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');
|
|
}
|
|
}
|