Initial commit - construprogress app
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Services\SpatialFileConverter;
|
||||
use App\Models\Phase;
|
||||
|
||||
class ConvertSpatialFile extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
|
||||
protected $signature = 'convert:spatial {file} {phase_id}';
|
||||
protected $description = 'Convert a spatial file (DWG, SHP, KML, GeoJSON) to GeoJSON and attach to phase';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$filePath = $this->argument('file');
|
||||
$phaseId = $this->argument('phase_id');
|
||||
$phase = Phase::findOrFail($phaseId);
|
||||
$file = new \Illuminate\Http\UploadedFile($filePath, basename($filePath));
|
||||
|
||||
$geojson = SpatialFileConverter::convertToGeoJson($file, $file->getClientOriginalName());
|
||||
if ($geojson) {
|
||||
$layer = $phase->layers()->create([
|
||||
'project_id' => $phase->project_id,
|
||||
'name' => 'Converted: ' . basename($filePath),
|
||||
'geojson_data' => $geojson,
|
||||
'uploaded_by' => 1, // admin
|
||||
'original_file' => $filePath
|
||||
]);
|
||||
$this->info("GeoJSON saved to layer ID {$layer->id}");
|
||||
} else {
|
||||
$this->error("Conversion failed for file type.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Layer;
|
||||
use App\Models\Feature;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class MigrateGeojsonToFeatures extends Command
|
||||
{
|
||||
protected $signature = 'migrate:geojson-to-features';
|
||||
protected $description = 'Migrate features from layer.geojson_data to features table';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$layers = Layer::whereNotNull('geojson_data')->get();
|
||||
$totalFeatures = 0;
|
||||
|
||||
foreach ($layers as $layer) {
|
||||
$geojson = $layer->geojson_data;
|
||||
if (!isset($geojson['features'])) continue;
|
||||
|
||||
foreach ($geojson['features'] as $featureData) {
|
||||
$geometry = $featureData['geometry'];
|
||||
$props = $featureData['properties'] ?? [];
|
||||
|
||||
Feature::create([
|
||||
'layer_id' => $layer->id,
|
||||
'name' => $props['name'] ?? null,
|
||||
'geometry' => $geometry,
|
||||
'properties' => $props,
|
||||
'template_id' => $props['template_id'] ?? null,
|
||||
'progress' => $props['progress'] ?? 0,
|
||||
'responsible' => $props['responsible'] ?? null,
|
||||
]);
|
||||
$totalFeatures++;
|
||||
}
|
||||
|
||||
// Opcional: Marcar la capa como migrada (podrías agregar columna 'migrated_at')
|
||||
$this->info("Layer {$layer->id} ({$layer->name}) migrated.");
|
||||
}
|
||||
|
||||
$this->info("Total features migrated: {$totalFeatures}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class VerifyEmailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
event(new Verified($request->user()));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\features;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class FeaturesController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(features $features)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*/
|
||||
public function edit(features $features)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, features $features)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(features $features)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\PendingSync;
|
||||
use App\Models\Phase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class OfflineSyncController extends Controller
|
||||
{
|
||||
public function storePending(Request $request)
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'action' => 'required|in:progress_update,task_complete',
|
||||
'payload' => 'required|array',
|
||||
]);
|
||||
$pending = PendingSync::create([
|
||||
'user_id' => Auth::id() ?? 1,
|
||||
'action' => $payload['action'],
|
||||
'payload' => $payload['payload'],
|
||||
]);
|
||||
return response()->json(['queued' => true]);
|
||||
}
|
||||
|
||||
public function sync(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$pendings = PendingSync::where('user_id', $user->id)->whereNull('synced_at')->get();
|
||||
foreach ($pendings as $pending) {
|
||||
if ($pending->action === 'progress_update') {
|
||||
$phase = Phase::find($pending->payload['phase_id']);
|
||||
if ($phase) {
|
||||
$phase->progress_percent = $pending->payload['progress'];
|
||||
$phase->save();
|
||||
$phase->progressUpdates()->create([
|
||||
'user_id' => $user->id,
|
||||
'progress_percent' => $pending->payload['progress'],
|
||||
'comment' => $pending->payload['comment'] ?? '',
|
||||
'location' => $pending->payload['location'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
$pending->synced_at = now();
|
||||
$pending->save();
|
||||
}
|
||||
return response()->json(['synced' => count($pendings)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
Gate::authorize('view projects');
|
||||
return view('projects.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
Gate::authorize('create projects');
|
||||
return view('projects.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
Gate::authorize('create projects');
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'address' => 'required',
|
||||
'lat' => 'required|numeric',
|
||||
'lng' => 'required|numeric',
|
||||
'start_date' => 'required|date',
|
||||
'end_date_estimated' => 'nullable|date',
|
||||
]);
|
||||
$project = Project::create(array_merge($validated, ['created_by' => Auth::id(), 'status' => 'planning']));
|
||||
|
||||
// Assign creator as supervisor in project
|
||||
$project->users()->attach(Auth::id(), ['role_in_project' => 'supervisor']);
|
||||
return redirect()->route('projects.map', $project)->with('success', 'Proyecto creado');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(string $id)
|
||||
{
|
||||
// No usamos show, redirigimos al mapa o a edición
|
||||
return redirect()->route('projects.map', $project);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*/
|
||||
public function edit(Project $project) // <--- ROUTE MODEL BINDING
|
||||
{
|
||||
Gate::authorize('edit projects', $project);
|
||||
return view('projects.edit', compact('project'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, Project $project) // <--- ROUTE MODEL BINDING
|
||||
{
|
||||
Gate::authorize('edit projects', $project);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'address' => 'required|string',
|
||||
'lat' => 'required|numeric',
|
||||
'lng' => 'required|numeric',
|
||||
'status' => 'required|in:planning,in_progress,paused,completed',
|
||||
'start_date' => 'required|date',
|
||||
'end_date_estimated' => 'nullable|date|after:start_date',
|
||||
]);
|
||||
|
||||
$project->update($validated);
|
||||
|
||||
return redirect()->route('projects.index')
|
||||
->with('success', 'Proyecto actualizado correctamente.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified project from storage.
|
||||
*/
|
||||
public function destroy(Project $project) // <--- ROUTE MODEL BINDING
|
||||
{
|
||||
Gate::authorize('delete projects', $project);
|
||||
|
||||
$project->delete();
|
||||
|
||||
return redirect()->route('projects.index')
|
||||
->with('success', 'Proyecto eliminado correctamente.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the map view for a specific project.
|
||||
*/
|
||||
public function map(Project $project)
|
||||
{
|
||||
// Cualquier usuario autenticado puede ver el mapa si tiene acceso al proyecto
|
||||
// (lo validaremos dentro del componente Livewire)
|
||||
return view('projects.map', compact('project'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Actions;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
||||
class Logout
|
||||
{
|
||||
/**
|
||||
* Log the current user out of the application.
|
||||
*/
|
||||
public function __invoke(): void
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
Session::invalidate();
|
||||
Session::regenerateToken();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Forms;
|
||||
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Form;
|
||||
|
||||
class LoginForm extends Form
|
||||
{
|
||||
#[Validate('required|string|email')]
|
||||
public string $email = '';
|
||||
|
||||
#[Validate('required|string')]
|
||||
public string $password = '';
|
||||
|
||||
#[Validate('boolean')]
|
||||
public bool $remember = false;
|
||||
|
||||
/**
|
||||
* Attempt to authenticate the request's credentials.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function authenticate(): void
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
if (! Auth::attempt($this->only(['email', 'password']), $this->remember)) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'form.email' => trans('auth.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the authentication request is not rate limited.
|
||||
*/
|
||||
protected function ensureIsNotRateLimited(): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event(new Lockout(request()));
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'form.email' => trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authentication rate limiting throttle key.
|
||||
*/
|
||||
protected function throttleKey(): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($this->email).'|'.request()->ip());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<?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\Services\SpatialFileConverter;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
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 = []; // IDs de capas visibles
|
||||
|
||||
public $uploadFile = null;
|
||||
public $layerName = '';
|
||||
public $layerColor = '#3b82f6';
|
||||
public $manualGeojson = null;
|
||||
public $drawingMode = false;
|
||||
|
||||
protected $rules = [
|
||||
'uploadFile' => 'required|file|mimes:geojson,kmz,kml,shp,dwg,zip|max:51200',
|
||||
'layerName' => 'required|string|max:255',
|
||||
'layerColor' => 'nullable|string|size:7',
|
||||
];
|
||||
|
||||
public function mount(Project $project, Phase $phase)
|
||||
{
|
||||
$this->project = $project;
|
||||
$this->phase = $phase;
|
||||
$this->loadLayers();
|
||||
if ($this->phase->project_id !== $this->project->id) {
|
||||
abort(404);
|
||||
}
|
||||
// Por defecto todas visibles
|
||||
$this->visibleLayers = $this->layers->pluck('id')->toArray();
|
||||
$this->emitInitialLayersData();
|
||||
}
|
||||
|
||||
public function loadLayers()
|
||||
{
|
||||
$this->layers = Layer::where('phase_id', $this->phase->id)->latest()->get();
|
||||
// Eliminar de visibles las que ya no existen
|
||||
$this->visibleLayers = array_intersect($this->visibleLayers, $this->layers->pluck('id')->toArray());
|
||||
}
|
||||
|
||||
private function emitInitialLayersData()
|
||||
{
|
||||
$layersData = $this->layers->map(function($layer) {
|
||||
return [
|
||||
'id' => $layer->id,
|
||||
'geojson' => $layer->geojson_data,
|
||||
'color' => $layer->geojson_data['style']['color'] ?? '#3b82f6',
|
||||
];
|
||||
});
|
||||
$this->dispatch('initialLayersData', [
|
||||
'layers' => $layersData,
|
||||
'visibleLayers' => $this->visibleLayers,
|
||||
'selectedLayerId' => $this->selectedLayer?->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggleLayerVisibility($layerId)
|
||||
{
|
||||
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
|
||||
session()->flash('info', 'No puedes ocultar la capa que estás editando.');
|
||||
return;
|
||||
}
|
||||
if (in_array($layerId, $this->visibleLayers)) {
|
||||
$this->visibleLayers = array_diff($this->visibleLayers, [$layerId]);
|
||||
} else {
|
||||
$this->visibleLayers[] = $layerId;
|
||||
}
|
||||
$this->dispatch('visibilityChanged', $this->visibleLayers);
|
||||
}
|
||||
|
||||
public function selectLayer($layerId)
|
||||
{
|
||||
$this->selectedLayer = Layer::find($layerId);
|
||||
if (!$this->selectedLayer) return;
|
||||
// Asegurar que la capa seleccionada está visible
|
||||
if (!in_array($layerId, $this->visibleLayers)) {
|
||||
$this->visibleLayers[] = $layerId;
|
||||
$this->dispatch('visibilityChanged', $this->visibleLayers);
|
||||
}
|
||||
$geojson = $this->selectedLayer->geojson_data;
|
||||
$this->dispatch('layerSelectedForEdit', [
|
||||
'layerId' => $layerId,
|
||||
'geojson' => $geojson,
|
||||
'color' => $geojson['style']['color'] ?? '#3b82f6',
|
||||
]);
|
||||
session()->flash('info', 'Editando capa: ' . $this->selectedLayer->name);
|
||||
}
|
||||
|
||||
public function importFile()
|
||||
{
|
||||
$this->validate();
|
||||
$user = Auth::user();
|
||||
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
|
||||
session()->flash('error', 'Sin permisos.');
|
||||
return;
|
||||
}
|
||||
|
||||
$projectDir = "uploads/projects/{$this->project->id}/layers";
|
||||
$originalPath = $this->uploadFile->store($projectDir, 'public');
|
||||
$geojson = SpatialFileConverter::convertToGeoJson($this->uploadFile);
|
||||
if (!$geojson) {
|
||||
session()->flash('error', 'Conversión fallida.');
|
||||
return;
|
||||
}
|
||||
$geojson['style'] = ['color' => $this->layerColor ?: '#3b82f6'];
|
||||
|
||||
$layer = Layer::create([
|
||||
'project_id' => $this->project->id,
|
||||
'phase_id' => $this->phase->id,
|
||||
'name' => $this->layerName,
|
||||
'geojson_data' => $geojson,
|
||||
'original_file' => $originalPath,
|
||||
'uploaded_by' => $user->id,
|
||||
]);
|
||||
|
||||
$this->loadLayers();
|
||||
$this->visibleLayers[] = $layer->id;
|
||||
$this->reset(['uploadFile', 'layerName']);
|
||||
$this->emitInitialLayersData();
|
||||
session()->flash('message', 'Capa importada.');
|
||||
}
|
||||
|
||||
public function createEmptyLayer()
|
||||
{
|
||||
$user = Auth::user();
|
||||
$emptyGeojson = [
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => [],
|
||||
'style' => ['color' => $this->layerColor ?: '#3b82f6']
|
||||
];
|
||||
$layer = Layer::create([
|
||||
'project_id' => $this->project->id,
|
||||
'phase_id' => $this->phase->id,
|
||||
'name' => $this->layerName ?: 'Nueva capa',
|
||||
'geojson_data' => $emptyGeojson,
|
||||
'original_file' => null,
|
||||
'uploaded_by' => $user->id,
|
||||
]);
|
||||
$this->loadLayers();
|
||||
$this->visibleLayers[] = $layer->id;
|
||||
$this->selectLayer($layer->id);
|
||||
$this->emitInitialLayersData();
|
||||
session()->flash('message', 'Capa vacía creada y seleccionada.');
|
||||
}
|
||||
|
||||
public function saveManualGeojson($geojsonString)
|
||||
{
|
||||
if (!$this->selectedLayer) {
|
||||
session()->flash('error', 'No hay capa seleccionada.');
|
||||
return;
|
||||
}
|
||||
$geojson = json_decode($geojsonString, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
session()->flash('error', 'GeoJSON inválido.');
|
||||
return;
|
||||
}
|
||||
$geojson['style'] = ['color' => $this->layerColor ?: ($this->selectedLayer->geojson_data['style']['color'] ?? '#3b82f6')];
|
||||
$this->selectedLayer->geojson_data = $geojson;
|
||||
$this->selectedLayer->save();
|
||||
|
||||
$this->loadLayers(); // recargar por si acaso
|
||||
$this->selectLayer($this->selectedLayer->id);
|
||||
$this->emitInitialLayersData();
|
||||
session()->flash('message', 'Capa guardada.');
|
||||
}
|
||||
|
||||
public function deleteLayer($layerId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->can('delete layers') && !$user->hasRole('Admin')) abort(403);
|
||||
$layer = Layer::find($layerId);
|
||||
if (!$layer) return;
|
||||
if ($layer->original_file) Storage::disk('public')->delete($layer->original_file);
|
||||
$layer->delete();
|
||||
$this->loadLayers();
|
||||
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
|
||||
$this->selectedLayer = null;
|
||||
}
|
||||
$this->emitInitialLayersData();
|
||||
session()->flash('message', 'Capa eliminada.');
|
||||
}
|
||||
|
||||
public function cancelEditing()
|
||||
{
|
||||
$this->selectedLayer = null;
|
||||
$this->dispatch('layerSelectedForEdit', null);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.layer-manager');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
<?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\Services\SpatialFileConverter;
|
||||
use App\Models\Feature;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
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 = []; // IDs de capas visibles
|
||||
|
||||
public $uploadFile = null;
|
||||
public $layerName = '';
|
||||
public $layerColor = '#3b82f6';
|
||||
public $manualGeojson = null;
|
||||
public $drawingMode = false;
|
||||
|
||||
protected $rules = [
|
||||
'uploadFile' => 'required|file|mimes:geojson,kmz,kml,shp,dwg,zip|max:51200',
|
||||
'layerName' => 'required|string|max:255',
|
||||
'layerColor' => 'nullable|string|size:7',
|
||||
];
|
||||
|
||||
public function mount(Project $project, Phase $phase)
|
||||
{
|
||||
$this->project = $project;
|
||||
$this->phase = $phase;
|
||||
$this->loadLayers();
|
||||
if ($this->phase->project_id !== $this->project->id) {
|
||||
abort(404);
|
||||
}
|
||||
// Por defecto todas visibles
|
||||
$this->visibleLayers = $this->layers->pluck('id')->toArray();
|
||||
$this->emitInitialLayersData();
|
||||
}
|
||||
|
||||
public function loadLayers()
|
||||
{
|
||||
$this->layers = Layer::with('features')->where('phase_id', $this->phase->id)->latest()->get();
|
||||
$this->visibleLayers = array_intersect($this->visibleLayers, $this->layers->pluck('id')->toArray());
|
||||
}
|
||||
|
||||
private function emitInitialLayersData()
|
||||
{
|
||||
$layersData = $this->layers->map(function($layer) {
|
||||
// Construir FeatureCollection a partir de los features de esta capa
|
||||
$features = $layer->features->map(function($feature) {
|
||||
return [
|
||||
'type' => 'Feature',
|
||||
'id' => $feature->id,
|
||||
'geometry' => $feature->geometry,
|
||||
'properties' => [
|
||||
'name' => $feature->name,
|
||||
'progress' => $feature->progress,
|
||||
'responsible' => $feature->responsible,
|
||||
'template_id' => $feature->template_id,
|
||||
]
|
||||
];
|
||||
})->values()->toArray();
|
||||
|
||||
$geojson = [
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => $features,
|
||||
'style' => ['color' => $this->layerColor ?: '#3b82f6'] // Podrías guardar el color en la tabla layers
|
||||
];
|
||||
|
||||
return [
|
||||
'id' => $layer->id,
|
||||
'geojson' => $geojson,
|
||||
'color' => $geojson['style']['color'],
|
||||
];
|
||||
});
|
||||
|
||||
$this->dispatch('initialLayersData', [
|
||||
'layers' => $layersData,
|
||||
'visibleLayers' => $this->visibleLayers,
|
||||
'selectedLayerId' => $this->selectedLayer?->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggleLayerVisibility($layerId)
|
||||
{
|
||||
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
|
||||
session()->flash('info', 'No puedes ocultar la capa que estás editando.');
|
||||
return;
|
||||
}
|
||||
if (in_array($layerId, $this->visibleLayers)) {
|
||||
$this->visibleLayers = array_diff($this->visibleLayers, [$layerId]);
|
||||
} else {
|
||||
$this->visibleLayers[] = $layerId;
|
||||
}
|
||||
$this->dispatch('visibilityChanged', $this->visibleLayers);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Construir el GeoJSON desde los features de la capa seleccionada
|
||||
$features = $this->selectedLayer->features->map(function($feature) {
|
||||
return [
|
||||
'type' => 'Feature',
|
||||
'id' => $feature->id,
|
||||
'geometry' => $feature->geometry,
|
||||
'properties' => [
|
||||
'name' => $feature->name,
|
||||
'progress' => $feature->progress,
|
||||
'responsible' => $feature->responsible,
|
||||
'template_id' => $feature->template_id,
|
||||
]
|
||||
];
|
||||
})->values()->toArray();
|
||||
|
||||
$geojson = [
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => $features,
|
||||
'style' => ['color' => $this->layerColor ?: '#3b82f6']
|
||||
];
|
||||
|
||||
$this->dispatch('layerSelectedForEdit', [
|
||||
'layerId' => $layerId,
|
||||
'geojson' => $geojson,
|
||||
'color' => $geojson['style']['color'],
|
||||
]);
|
||||
session()->flash('info', 'Editando capa: ' . $this->selectedLayer->name);
|
||||
}
|
||||
|
||||
public function importFile()
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
|
||||
session()->flash('error', 'Sin permisos.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar campos obligatorios y tamaño máximo
|
||||
$this->validate([
|
||||
'uploadFile' => 'required|file|max:51200',
|
||||
'layerName' => 'required|string|max:255',
|
||||
'layerColor' => 'nullable|string|size:7',
|
||||
]);
|
||||
|
||||
$extension = strtolower($this->uploadFile->getClientOriginalExtension());
|
||||
$mime = $this->uploadFile->getMimeType();
|
||||
|
||||
$allowedExtensions = ['geojson', 'kmz', 'kml', 'shp', 'dwg', 'zip'];
|
||||
$allowedMimes = [
|
||||
'application/vnd.google-earth.kml+xml',
|
||||
'application/vnd.google-earth.kmz',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/x-shapefile',
|
||||
'image/vnd.dwg',
|
||||
'application/acad',
|
||||
'application/geo+json',
|
||||
'text/xml', // ✅ Aceptar KML con text/xml
|
||||
'application/xml', // ✅ Alternativa
|
||||
];
|
||||
|
||||
if (!in_array($extension, $allowedExtensions) && !in_array($mime, $allowedMimes)) {
|
||||
session()->flash('error', 'Tipo de archivo no permitido. Extensiones válidas: ' . implode(', ', $allowedExtensions));
|
||||
return;
|
||||
}
|
||||
|
||||
$projectDir = "uploads/projects/{$this->project->id}/layers";
|
||||
$originalPath = $this->uploadFile->store($projectDir, 'public');
|
||||
$geojson = SpatialFileConverter::convertToGeoJson($this->uploadFile);
|
||||
|
||||
if (!$geojson) {
|
||||
session()->flash('error', 'Conversión fallida. Asegúrate de que el archivo sea válido (KML, GeoJSON, etc.).');
|
||||
return;
|
||||
}
|
||||
|
||||
$geojson['style'] = ['color' => $this->layerColor ?: '#3b82f6'];
|
||||
|
||||
$layer = Layer::create([
|
||||
'project_id' => $this->project->id,
|
||||
'phase_id' => $this->phase->id,
|
||||
'name' => $this->layerName,
|
||||
//'geojson_data' => $geojson,
|
||||
'original_file' => $originalPath,
|
||||
'uploaded_by' => $user->id,
|
||||
]);
|
||||
|
||||
// Crear features a partir del GeoJSON
|
||||
if (isset($geojson['features'])) {
|
||||
foreach ($geojson['features'] as $featureData) {
|
||||
Feature::create([
|
||||
'layer_id' => $layer->id,
|
||||
'name' => $featureData['properties']['name'] ?? null,
|
||||
'geometry' => $featureData['geometry'],
|
||||
'properties' => $featureData['properties'] ?? [],
|
||||
'template_id' => $featureData['properties']['template_id'] ?? null,
|
||||
'progress' => $featureData['properties']['progress'] ?? 0,
|
||||
'responsible' => $featureData['properties']['responsible'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->loadLayers();
|
||||
$this->visibleLayers[] = $layer->id;
|
||||
$this->reset(['uploadFile', 'layerName']);
|
||||
$this->emitInitialLayersData();
|
||||
session()->flash('message', 'Capa importada correctamente.');
|
||||
}
|
||||
|
||||
public function createEmptyLayer()
|
||||
{
|
||||
$user = Auth::user();
|
||||
$layer = Layer::create([
|
||||
'project_id' => $this->project->id,
|
||||
'phase_id' => $this->phase->id,
|
||||
'name' => $this->layerName ?: 'Nueva capa',
|
||||
'original_file' => null,
|
||||
'uploaded_by' => $user->id,
|
||||
// Opcional: guarda el color en una columna 'color' de la tabla layers
|
||||
]);
|
||||
$this->loadLayers();
|
||||
$this->visibleLayers[] = $layer->id;
|
||||
$this->selectLayer($layer->id);
|
||||
$this->emitInitialLayersData();
|
||||
session()->flash('message', 'Capa vacía creada. Usa el editor para añadir elementos.');
|
||||
}
|
||||
|
||||
public function saveManualGeojson($geojsonString)
|
||||
{
|
||||
if (!$this->selectedLayer) {
|
||||
session()->flash('error', 'No hay capa seleccionada.');
|
||||
return;
|
||||
}
|
||||
$geojson = json_decode($geojsonString, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !isset($geojson['features'])) {
|
||||
session()->flash('error', 'GeoJSON inválido.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Eliminar todos los features existentes de esta capa
|
||||
$this->selectedLayer->features()->delete();
|
||||
|
||||
// Crear nuevos features a partir del GeoJSON
|
||||
foreach ($geojson['features'] as $featureData) {
|
||||
Feature::create([
|
||||
'layer_id' => $this->selectedLayer->id,
|
||||
'name' => $featureData['properties']['name'] ?? null,
|
||||
'geometry' => $featureData['geometry'],
|
||||
'properties' => $featureData['properties'] ?? [],
|
||||
'template_id' => $featureData['properties']['template_id'] ?? null,
|
||||
'progress' => $featureData['properties']['progress'] ?? 0,
|
||||
'responsible' => $featureData['properties']['responsible'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->loadLayers();
|
||||
$this->selectLayer($this->selectedLayer->id);
|
||||
$this->emitInitialLayersData();
|
||||
session()->flash('message', 'Capa guardada con ' . count($geojson['features']) . ' elementos.');
|
||||
}
|
||||
|
||||
public function deleteLayer($layerId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->can('delete layers') && !$user->hasRole('Admin')) abort(403);
|
||||
$layer = Layer::find($layerId);
|
||||
if (!$layer) return;
|
||||
if ($layer->original_file) Storage::disk('public')->delete($layer->original_file);
|
||||
$layer->features()->delete(); // opcional, si no usas cascade
|
||||
$layer->delete();
|
||||
$this->loadLayers();
|
||||
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
|
||||
$this->selectedLayer = null;
|
||||
}
|
||||
$this->emitInitialLayersData();
|
||||
session()->flash('message', 'Capa eliminada.');
|
||||
}
|
||||
|
||||
public function cancelEditing()
|
||||
{
|
||||
$this->selectedLayer = null;
|
||||
$this->dispatch('layerSelectedForEdit', null);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.layers.layer-manager');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class LayerUpload extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.layer-upload');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use App\Models\Project;
|
||||
use App\Models\Phase;
|
||||
|
||||
class PhaseList extends Component
|
||||
{
|
||||
public Project $project;
|
||||
public $phases;
|
||||
|
||||
public function mount(Project $project)
|
||||
{
|
||||
$this->project = $project;
|
||||
$this->phases = $project->phases;
|
||||
}
|
||||
|
||||
public function addPhase()
|
||||
{
|
||||
$this->project->phases()->create([
|
||||
'name' => 'Nueva fase',
|
||||
'order' => $this->phases->count() + 1,
|
||||
'color' => '#'.substr(md5(rand()), 0, 6)
|
||||
]);
|
||||
$this->phases = $this->project->phases()->get();
|
||||
session()->flash('message', 'Fase agregada');
|
||||
}
|
||||
|
||||
public function deletePhase($phaseId)
|
||||
{
|
||||
Phase::find($phaseId)->delete();
|
||||
$this->phases = $this->project->phases()->get();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.phase-list');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use App\Models\Phase;
|
||||
|
||||
class PhaseProgress extends Component
|
||||
{
|
||||
public Phase $phase;
|
||||
public $progress;
|
||||
public $comment = '';
|
||||
|
||||
public function mount(Phase $phase)
|
||||
{
|
||||
$this->phase = $phase;
|
||||
$this->progress = $phase->progress_percent;
|
||||
}
|
||||
|
||||
public function updateProgressManual()
|
||||
{
|
||||
$this->validate(['progress' => 'required|integer|min:0|max:100']);
|
||||
$this->phase->progress_percent = $this->progress;
|
||||
$this->phase->save();
|
||||
|
||||
$this->phase->progressUpdates()->create([
|
||||
'user_id' => auth()->id(),
|
||||
'progress_percent' => $this->progress,
|
||||
'comment' => $this->comment,
|
||||
]);
|
||||
|
||||
$this->dispatch('progressUpdated', $this->phase->id, $this->progress);
|
||||
session()->flash('message', 'Progreso actualizado');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.phase-progress');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class ProjectForm extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.projects.project-form');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ProjectList extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public $search = '';
|
||||
public $statusFilter = '';
|
||||
|
||||
public function deleteProject($id)
|
||||
{
|
||||
$project = Project::findOrFail($id);
|
||||
if (Auth::user()->can('delete projects')) {
|
||||
$project->delete();
|
||||
session()->flash('message', 'Proyecto eliminado');
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$query = Project::accessibleBy(Auth::user());
|
||||
if ($this->search) {
|
||||
$query->where('name', 'like', '%' . $this->search . '%');
|
||||
}
|
||||
if ($this->statusFilter) {
|
||||
$query->where('status', $this->statusFilter);
|
||||
}
|
||||
$projects = $query->latest()->paginate(10);
|
||||
return view('livewire.projects.project-list', ['projects' => $projects]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Phase;
|
||||
use App\Models\Inspection;
|
||||
use App\Models\InspectionTemplate;
|
||||
use App\Models\Feature;
|
||||
|
||||
class ProjectMap extends Component
|
||||
{
|
||||
public Project $project;
|
||||
public $phases;
|
||||
public $activeLayers = []; // Array of phase IDs to show
|
||||
|
||||
// Editor properties
|
||||
public $selectedFeature = null;
|
||||
public $selectedPhaseId = null;
|
||||
public $editProgress = 0;
|
||||
public $editComment = '';
|
||||
public $editResponsible = '';
|
||||
public $editPhotos = [];
|
||||
public $formFullscreen = false;
|
||||
|
||||
// Propiedades para templates e inspecciones
|
||||
public $templates = [];
|
||||
public $selectedTemplateId = null;
|
||||
public $inspectionFormData = [];
|
||||
public $inspectionHistory = [];
|
||||
public $showInspectionForm = true;
|
||||
|
||||
public function mount(Project $project)
|
||||
{
|
||||
$this->project = $project;
|
||||
$this->phases = $project->phases()->with('currentLayer')->get();
|
||||
$this->activeLayers = $this->phases->pluck('id')->toArray();
|
||||
|
||||
$this->loadTemplates();
|
||||
}
|
||||
|
||||
public function toggleLayer($phaseId)
|
||||
{
|
||||
if (in_array($phaseId, $this->activeLayers)) {
|
||||
$this->activeLayers = array_diff($this->activeLayers, [$phaseId]);
|
||||
} else {
|
||||
$this->activeLayers[] = $phaseId;
|
||||
}
|
||||
$this->dispatch('layersUpdated', $this->activeLayers);
|
||||
}
|
||||
|
||||
public function updateProgress($featureId, $newProgress, $comment = null)
|
||||
{
|
||||
$feature = Feature::findOrFail($featureId);
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user->can('update progress') && !$user->hasRole('Admin')) {
|
||||
$this->dispatch('notify', 'Sin permisos');
|
||||
return;
|
||||
}
|
||||
|
||||
$feature->progress = min(100, max(0, $newProgress));
|
||||
$feature->save();
|
||||
|
||||
// Actualizar progreso de la fase (sumar promedio)
|
||||
$phase = Phase::find($feature->layer->phase_id);
|
||||
$phase->progress_percent = $phase->features()->avg('progress');
|
||||
$phase->save();
|
||||
|
||||
$phase->progressUpdates()->create([
|
||||
'user_id' => $user->id,
|
||||
'progress_percent' => $phase->progress_percent,
|
||||
'comment' => $comment,
|
||||
]);
|
||||
|
||||
$this->dispatch('progressUpdated', $featureId, $feature->progress);
|
||||
$this->dispatch('notify', 'Progreso actualizado');
|
||||
$this->editProgress = $feature->progress;
|
||||
}
|
||||
|
||||
public function loadTemplates()
|
||||
{
|
||||
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
|
||||
}
|
||||
|
||||
public function selectFeature($featureId, $featureProps)
|
||||
{
|
||||
$feature = Feature::with('template')->find($featureId);
|
||||
if (!$feature) return;
|
||||
|
||||
$this->selectedFeature = $feature;
|
||||
$this->selectedPhaseId = $feature->layer->phase_id;
|
||||
$this->editProgress = $feature->progress;
|
||||
$this->editResponsible = $feature->responsible;
|
||||
$this->selectedTemplateId = $feature->template_id;
|
||||
|
||||
$this->loadInspectionHistory();
|
||||
$this->resetInspectionForm();
|
||||
|
||||
$this->dispatch('featureSelected', $featureId);
|
||||
}
|
||||
|
||||
public function saveFeatureProgress()
|
||||
{
|
||||
if (!$this->selectedFeature || !$this->selectedPhaseId) {
|
||||
return;
|
||||
}
|
||||
$this->updateProgress($this->selectedPhaseId, $this->editProgress, $this->editComment);
|
||||
$this->editComment = '';
|
||||
}
|
||||
|
||||
public function resetInspectionForm()
|
||||
{
|
||||
$this->inspectionFormData = [];
|
||||
if ($this->selectedTemplateId) {
|
||||
$template = InspectionTemplate::find($this->selectedTemplateId);
|
||||
if ($template) {
|
||||
foreach ($template->fields as $field) {
|
||||
$this->inspectionFormData[$field['name']] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function loadInspectionHistory()
|
||||
{
|
||||
if (!$this->selectedFeature || !$this->selectedPhaseId) {
|
||||
$this->inspectionHistory = [];
|
||||
return;
|
||||
}
|
||||
$layer = Phase::find($this->selectedPhaseId)->currentLayer;
|
||||
if ($layer) {
|
||||
$this->inspectionHistory = Inspection::where('layer_id', $layer->id)
|
||||
->where('feature_id', $this->selectedFeature['id'])
|
||||
->with('user', 'template')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
||||
public function saveInspection()
|
||||
{
|
||||
if (!$this->selectedFeature || !$this->selectedPhaseId) {
|
||||
return;
|
||||
}
|
||||
$this->validate([
|
||||
'selectedTemplateId' => 'required|exists:inspection_templates,id',
|
||||
]);
|
||||
$layer = Phase::find($this->selectedPhaseId)->currentLayer;
|
||||
if (!$layer) return;
|
||||
|
||||
$inspection = Inspection::create([
|
||||
'project_id' => $this->project->id,
|
||||
'layer_id' => $this->selectedFeature->layer_id,
|
||||
'feature_id' => $this->selectedFeature->id,
|
||||
'template_id' => $this->selectedTemplateId,
|
||||
'user_id' => auth()->id(),
|
||||
'data' => $this->inspectionFormData,
|
||||
]);
|
||||
|
||||
// Opcional: actualizar el progreso del elemento en el GeoJSON
|
||||
if (isset($this->inspectionFormData['progress'])) {
|
||||
$this->updateProgress($this->selectedPhaseId, $this->inspectionFormData['progress'], 'Inspección registrada');
|
||||
}
|
||||
|
||||
$this->loadInspectionHistory();
|
||||
$this->resetInspectionForm();
|
||||
$this->dispatch('notify', 'Inspección guardada');
|
||||
}
|
||||
|
||||
public function updateFeatureTemplate($templateId)
|
||||
{
|
||||
// Actualizar el template asociado al elemento (podrías guardarlo en la capa GeoJSON)
|
||||
if ($this->selectedFeature && $this->selectedPhaseId) {
|
||||
$layer = Phase::find($this->selectedPhaseId)->currentLayer;
|
||||
if ($layer) {
|
||||
$geojson = $layer->geojson_data;
|
||||
foreach ($geojson['features'] as &$feature) {
|
||||
if ($feature['properties']['id'] == $this->selectedFeature['id']) {
|
||||
$feature['properties']['template_id'] = $templateId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$layer->geojson_data = $geojson;
|
||||
$layer->save();
|
||||
}
|
||||
}
|
||||
$this->selectedTemplateId = $templateId;
|
||||
$this->resetInspectionForm();
|
||||
}
|
||||
|
||||
public function toggleFullscreen()
|
||||
{
|
||||
$this->formFullscreen = !$this->formFullscreen;
|
||||
if (!$this->formFullscreen) {
|
||||
$this->dispatch('mapResize');
|
||||
}
|
||||
}
|
||||
|
||||
// Añadir al final de la clase ProjectMap
|
||||
public function refreshTemplates()
|
||||
{
|
||||
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
|
||||
}
|
||||
|
||||
// Asignar template ahora actualiza el campo template_id
|
||||
public function assignTemplateToFeature($templateId)
|
||||
{
|
||||
if (!$this->selectedFeature) return;
|
||||
$this->selectedFeature->template_id = $templateId;
|
||||
$this->selectedFeature->save();
|
||||
$this->selectedTemplateId = $templateId;
|
||||
$this->resetInspectionForm();
|
||||
$this->dispatch('notify', 'Template asignado');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.projects.project-map', [
|
||||
'project' => $this->project,
|
||||
'phases' => $this->phases,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use App\Models\Project;
|
||||
use App\Models\Phase;
|
||||
use App\Models\Layer;
|
||||
use App\Models\Feature;
|
||||
use App\Models\Inspection;
|
||||
use App\Models\InspectionTemplate;
|
||||
|
||||
class ProjectMap extends Component
|
||||
{
|
||||
public Project $project;
|
||||
public $phases;
|
||||
public $activeLayers = [];
|
||||
|
||||
// Editor properties
|
||||
public $selectedFeature = null; // será instancia de Feature
|
||||
public $selectedPhaseId = null;
|
||||
public $editProgress = 0;
|
||||
public $editComment = '';
|
||||
public $editResponsible = '';
|
||||
public $editPhotos = [];
|
||||
public $formFullscreen = false;
|
||||
|
||||
// Templates e inspecciones
|
||||
public $templates = [];
|
||||
public $selectedTemplateId = null;
|
||||
public $inspectionFormData = [];
|
||||
public $inspectionHistory = [];
|
||||
|
||||
public function mount(Project $project)
|
||||
{
|
||||
$this->project = $project;
|
||||
// Cargar fases con sus capas y los features de esas capas (para mostrarlos en el mapa)
|
||||
$this->phases = $project->phases()->with(['layers.features'])->get();
|
||||
// Por defecto mostrar todas las capas activas (todas las fases que tengan alguna capa con features)
|
||||
$this->activeLayers = $this->phases->pluck('id')->toArray();
|
||||
$this->loadTemplates();
|
||||
}
|
||||
|
||||
public function loadTemplates()
|
||||
{
|
||||
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
|
||||
}
|
||||
|
||||
public function toggleLayer($phaseId)
|
||||
{
|
||||
if (in_array($phaseId, $this->activeLayers)) {
|
||||
$this->activeLayers = array_diff($this->activeLayers, [$phaseId]);
|
||||
} else {
|
||||
$this->activeLayers[] = $phaseId;
|
||||
}
|
||||
$this->dispatch('layersUpdated', $this->activeLayers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar el progreso de un Feature y recalcular el progreso de la fase.
|
||||
*/
|
||||
public function updateProgress($featureId, $newProgress, $comment = null)
|
||||
{
|
||||
$feature = Feature::findOrFail($featureId);
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user->can('update progress') && !$user->hasRole('Admin')) {
|
||||
$this->dispatch('notify', 'Sin permisos');
|
||||
return;
|
||||
}
|
||||
|
||||
$oldProgress = $feature->progress;
|
||||
$feature->progress = min(100, max(0, $newProgress));
|
||||
$feature->save();
|
||||
|
||||
// Recalcular el progreso de la fase (promedio de todos sus features)
|
||||
$phase = Phase::find($feature->layer->phase_id);
|
||||
$phase->progress_percent = $phase->features()->avg('progress') ?: 0;
|
||||
$phase->save();
|
||||
|
||||
// Registrar la actualización en progress_updates
|
||||
$phase->progressUpdates()->create([
|
||||
'user_id' => $user->id,
|
||||
'progress_percent' => $phase->progress_percent,
|
||||
'comment' => $comment,
|
||||
]);
|
||||
|
||||
$this->dispatch('progressUpdated', $featureId, $feature->progress);
|
||||
$this->dispatch('notify', 'Progreso actualizado');
|
||||
|
||||
// Si el feature seleccionado es el mismo, actualizar la propiedad local
|
||||
if ($this->selectedFeature && $this->selectedFeature->id == $featureId) {
|
||||
$this->selectedFeature->progress = $feature->progress;
|
||||
$this->editProgress = $feature->progress;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seleccionar un Feature al hacer clic en el mapa.
|
||||
*/
|
||||
public function selectFeature($featureId)
|
||||
{
|
||||
$feature = Feature::with('template')->find($featureId);
|
||||
if (!$feature) return;
|
||||
|
||||
$this->selectedFeature = $feature;
|
||||
$this->selectedPhaseId = $feature->layer->phase_id;
|
||||
$this->editProgress = $feature->progress;
|
||||
$this->editResponsible = $feature->responsible ?? '';
|
||||
$this->editPhotos = $feature->properties['photos'] ?? [];
|
||||
$this->selectedTemplateId = $feature->template_id;
|
||||
|
||||
$this->loadInspectionHistory();
|
||||
$this->resetInspectionForm();
|
||||
|
||||
$this->dispatch('featureSelected', $featureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargar el historial de inspecciones del feature seleccionado.
|
||||
*/
|
||||
public function loadInspectionHistory()
|
||||
{
|
||||
if (!$this->selectedFeature) {
|
||||
$this->inspectionHistory = [];
|
||||
return;
|
||||
}
|
||||
$this->inspectionHistory = Inspection::where('feature_id', $this->selectedFeature->id)
|
||||
->with('user', 'template')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiniciar el formulario de inspección según el template seleccionado.
|
||||
*/
|
||||
public function resetInspectionForm()
|
||||
{
|
||||
$this->inspectionFormData = [];
|
||||
if ($this->selectedTemplateId) {
|
||||
$template = InspectionTemplate::find($this->selectedTemplateId);
|
||||
if ($template) {
|
||||
foreach ($template->fields as $field) {
|
||||
$this->inspectionFormData[$field['name']] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guardar una nueva inspección.
|
||||
*/
|
||||
public function saveInspection()
|
||||
{
|
||||
if (!$this->selectedFeature || !$this->selectedTemplateId) {
|
||||
$this->dispatch('notify', 'Selecciona un elemento y un template.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
'selectedTemplateId' => 'required|exists:inspection_templates,id',
|
||||
]);
|
||||
|
||||
$template = InspectionTemplate::find($this->selectedTemplateId);
|
||||
foreach ($template->fields as $field) {
|
||||
if (($field['required'] ?? false) && empty($this->inspectionFormData[$field['name']])) {
|
||||
$this->dispatch('notify', "El campo {$field['label']} es obligatorio.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$inspection = Inspection::create([
|
||||
'project_id' => $this->project->id,
|
||||
'layer_id' => $this->selectedFeature->layer_id,
|
||||
'feature_id' => $this->selectedFeature->id,
|
||||
'template_id' => $this->selectedTemplateId,
|
||||
'user_id' => auth()->id(),
|
||||
'data' => $this->inspectionFormData,
|
||||
]);
|
||||
|
||||
// Si el template tiene un campo llamado 'progress', actualizar el progreso del feature
|
||||
if (isset($this->inspectionFormData['progress'])) {
|
||||
$this->updateProgress($this->selectedFeature->id, (int)$this->inspectionFormData['progress'], 'Inspección registrada');
|
||||
}
|
||||
|
||||
$this->loadInspectionHistory();
|
||||
$this->resetInspectionForm();
|
||||
$this->dispatch('notify', 'Inspección guardada correctamente');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asignar un template al feature seleccionado.
|
||||
*/
|
||||
public function assignTemplateToFeature($templateId)
|
||||
{
|
||||
if (!$this->selectedFeature) return;
|
||||
|
||||
$this->selectedFeature->template_id = $templateId;
|
||||
$this->selectedFeature->save();
|
||||
|
||||
$this->selectedTemplateId = $templateId;
|
||||
$this->resetInspectionForm();
|
||||
$this->dispatch('notify', 'Template asignado al elemento');
|
||||
}
|
||||
|
||||
public function toggleFullscreen()
|
||||
{
|
||||
$this->formFullscreen = !$this->formFullscreen;
|
||||
if (!$this->formFullscreen) {
|
||||
$this->dispatch('mapResize');
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.projects.project-map', [
|
||||
'project' => $this->project,
|
||||
'phases' => $this->phases,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use App\Models\InspectionTemplate;
|
||||
use App\Models\Project;
|
||||
|
||||
class TemplateManager extends Component
|
||||
{
|
||||
public $project;
|
||||
public $templates;
|
||||
public $editingTemplate = null;
|
||||
public $showForm = false; // Controla si mostrar el formulario
|
||||
public $form = [
|
||||
'name' => '',
|
||||
'description' => '',
|
||||
'fields' => [],
|
||||
];
|
||||
public $fieldTypes = [
|
||||
'text' => 'Texto corto',
|
||||
'textarea' => 'Texto largo',
|
||||
'integer' => 'Número entero',
|
||||
'decimal' => 'Número decimal',
|
||||
'percentage' => 'Porcentaje (0-100)',
|
||||
'boolean' => 'Sí/No (checkbox)',
|
||||
'date' => 'Fecha',
|
||||
'select' => 'Lista desplegable',
|
||||
];
|
||||
|
||||
public function mount(Project $project)
|
||||
{
|
||||
$this->project = $project;
|
||||
$this->loadTemplates();
|
||||
}
|
||||
|
||||
public function loadTemplates()
|
||||
{
|
||||
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
|
||||
}
|
||||
|
||||
public function newTemplate()
|
||||
{
|
||||
$this->resetForm();
|
||||
$this->editingTemplate = null;
|
||||
$this->showForm = true;
|
||||
}
|
||||
|
||||
public function editTemplate($id)
|
||||
{
|
||||
$template = InspectionTemplate::find($id);
|
||||
$this->form = $template->only(['name', 'description', 'fields']);
|
||||
$this->editingTemplate = $id;
|
||||
$this->showForm = true;
|
||||
}
|
||||
|
||||
public function cancelForm()
|
||||
{
|
||||
$this->showForm = false;
|
||||
$this->resetForm();
|
||||
}
|
||||
|
||||
public function resetForm()
|
||||
{
|
||||
$this->form = [
|
||||
'name' => '',
|
||||
'description' => '',
|
||||
'fields' => [],
|
||||
];
|
||||
$this->editingTemplate = null;
|
||||
}
|
||||
|
||||
public function addField()
|
||||
{
|
||||
$this->form['fields'][] = [
|
||||
'name' => '',
|
||||
'label' => '',
|
||||
'type' => 'text',
|
||||
'options' => [],
|
||||
'required' => false,
|
||||
'min' => null,
|
||||
'max' => null,
|
||||
'step' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function removeField($index)
|
||||
{
|
||||
unset($this->form['fields'][$index]);
|
||||
$this->form['fields'] = array_values($this->form['fields']);
|
||||
}
|
||||
|
||||
public function saveTemplate()
|
||||
{
|
||||
$this->validate([
|
||||
'form.name' => 'required|string|max:255',
|
||||
'form.fields' => 'array',
|
||||
]);
|
||||
|
||||
if ($this->editingTemplate) {
|
||||
$template = InspectionTemplate::find($this->editingTemplate);
|
||||
$template->update($this->form);
|
||||
session()->flash('message', 'Template actualizado');
|
||||
} else {
|
||||
InspectionTemplate::create([
|
||||
'name' => $this->form['name'],
|
||||
'description' => $this->form['description'],
|
||||
'project_id' => $this->project->id,
|
||||
'fields' => $this->form['fields'],
|
||||
]);
|
||||
session()->flash('message', 'Template creado');
|
||||
}
|
||||
|
||||
$this->cancelForm();
|
||||
$this->loadTemplates();
|
||||
}
|
||||
|
||||
public function deleteTemplate($id)
|
||||
{
|
||||
InspectionTemplate::find($id)->delete();
|
||||
$this->loadTemplates();
|
||||
session()->flash('message', 'Template eliminado');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.template-manager');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Feature extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'layer_id', 'name', 'geometry', 'properties', 'template_id', 'progress', 'responsible'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'geometry' => 'array',
|
||||
'properties' => 'array',
|
||||
];
|
||||
|
||||
public function layer()
|
||||
{
|
||||
return $this->belongsTo(Layer::class);
|
||||
}
|
||||
|
||||
public function template()
|
||||
{
|
||||
return $this->belongsTo(InspectionTemplate::class);
|
||||
}
|
||||
|
||||
public function inspections()
|
||||
{
|
||||
return $this->hasMany(Inspection::class, 'feature_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Inspection extends Model
|
||||
{
|
||||
protected $fillable = ['project_id', 'layer_id', 'feature_id', 'template_id', 'user_id', 'data'];
|
||||
|
||||
protected $casts = ['data' => 'array'];
|
||||
|
||||
public function project()
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function layer()
|
||||
{
|
||||
return $this->belongsTo(Layer::class);
|
||||
}
|
||||
|
||||
public function template()
|
||||
{
|
||||
return $this->belongsTo(InspectionTemplate::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class InspectionTemplate extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'description', 'project_id', 'fields'];
|
||||
|
||||
protected $casts = ['fields' => 'array'];
|
||||
|
||||
public function project()
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function inspections()
|
||||
{
|
||||
return $this->hasMany(Inspection::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
|
||||
class Layer extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'project_id', 'phase_id', 'name', 'geojson_data', 'original_file', 'uploaded_by'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'geojson_data' => 'array',
|
||||
];
|
||||
|
||||
public function project()
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function phase()
|
||||
{
|
||||
return $this->belongsTo(Phase::class);
|
||||
}
|
||||
|
||||
public function uploader()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by');
|
||||
}
|
||||
public function features()
|
||||
{
|
||||
return $this->hasMany(Feature::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PendingSync extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'action', 'payload', 'synced_at'];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'synced_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Phase extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'project_id', 'name', 'description', 'order', 'color', 'progress_percent'
|
||||
];
|
||||
|
||||
public function project()
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function layers()
|
||||
{
|
||||
return $this->hasMany(Layer::class);
|
||||
}
|
||||
|
||||
public function progressUpdates()
|
||||
{
|
||||
return $this->hasMany(ProgressUpdate::class);
|
||||
}
|
||||
|
||||
// Get latest active layer (most recent upload)
|
||||
public function currentLayer()
|
||||
{
|
||||
return $this->hasOne(Layer::class)->latestOfMany();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ProgressUpdate extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'phase_id', 'user_id', 'progress_percent', 'comment', 'location'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'location' => 'array', // Store as [lat, lng]
|
||||
];
|
||||
|
||||
public function phase()
|
||||
{
|
||||
return $this->belongsTo(Phase::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Project extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'address', 'lat', 'lng', 'start_date', 'end_date_estimated', 'status', 'created_by'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'start_date' => 'date',
|
||||
'end_date_estimated' => 'date',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
public function phases()
|
||||
{
|
||||
return $this->hasMany(Phase::class)->orderBy('order');
|
||||
}
|
||||
|
||||
public function layers()
|
||||
{
|
||||
return $this->hasMany(Layer::class);
|
||||
}
|
||||
|
||||
public function users()
|
||||
{
|
||||
return $this->belongsToMany(User::class)->withPivot('role_in_project');
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
// Scope to filter accessible projects for non-admin users
|
||||
public function scopeAccessibleBy($query, User $user)
|
||||
{
|
||||
if ($user->hasRole('Admin')) {
|
||||
return $query;
|
||||
}
|
||||
return $query->whereHas('users', function ($q) use ($user) {
|
||||
$q->where('user_id', $user->id);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable, HasRoles;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
// Many-to-many with projects
|
||||
public function projects()
|
||||
{
|
||||
return $this->belongsToMany(Project::class)->withPivot('role_in_project')->withTimestamps();
|
||||
}
|
||||
|
||||
// Progress updates made
|
||||
public function progressUpdates()
|
||||
{
|
||||
return $this->hasMany(ProgressUpdate::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
class VoltServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Volt::mount([
|
||||
config('livewire.view_path', resource_path('views/livewire')),
|
||||
resource_path('views/pages'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Shapefile\ShapefileReader;
|
||||
|
||||
class SpatialFileConverter
|
||||
{
|
||||
public static function convertToGeoJson(UploadedFile $file): ?array
|
||||
{
|
||||
$ext = strtolower($file->getClientOriginalExtension());
|
||||
$path = $file->getPathname();
|
||||
|
||||
$geojson = match ($ext) {
|
||||
'geojson' => self::parseGeoJson($path),
|
||||
'kml' => self::kmlToGeoJson($path),
|
||||
'shp' => self::shapefileToGeoJson($path),
|
||||
'zip' => self::handleZip($path),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (!$geojson) return null;
|
||||
|
||||
return self::postProcess($geojson);
|
||||
}
|
||||
|
||||
/* =======================
|
||||
POST PROCESADO PRO
|
||||
======================= */
|
||||
|
||||
private static function postProcess(array $geojson): array
|
||||
{
|
||||
$features = [];
|
||||
|
||||
foreach ($geojson['features'] ?? [] as $feature) {
|
||||
|
||||
if (!isset($feature['geometry'])) continue;
|
||||
|
||||
$geometry = self::cleanGeometry($feature['geometry']);
|
||||
if (!$geometry) continue;
|
||||
|
||||
$features[] = [
|
||||
'type' => 'Feature',
|
||||
'geometry' => $geometry,
|
||||
'properties' => self::normalizeProperties($feature['properties'] ?? [])
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => $features,
|
||||
'bbox' => self::calculateBBox($features),
|
||||
'centroid' => self::calculateCentroid($features)
|
||||
];
|
||||
}
|
||||
|
||||
/* =======================
|
||||
NORMALIZACIÓN
|
||||
======================= */
|
||||
|
||||
private static function normalizeProperties(array $props): array
|
||||
{
|
||||
return array_merge([
|
||||
'name' => '',
|
||||
'description' => '',
|
||||
], $props);
|
||||
}
|
||||
|
||||
/* =======================
|
||||
GEOMETRY CLEAN
|
||||
======================= */
|
||||
|
||||
private static function cleanGeometry(array $geom): ?array
|
||||
{
|
||||
if (!isset($geom['type'], $geom['coordinates'])) return null;
|
||||
|
||||
if ($geom['type'] === 'Polygon') {
|
||||
$geom['coordinates'] = array_map(function ($ring) {
|
||||
if ($ring[0] !== end($ring)) {
|
||||
$ring[] = $ring[0];
|
||||
}
|
||||
return $ring;
|
||||
}, $geom['coordinates']);
|
||||
}
|
||||
|
||||
return $geom;
|
||||
}
|
||||
|
||||
/* =======================
|
||||
BBOX
|
||||
======================= */
|
||||
|
||||
private static function calculateBBox(array $features): ?array
|
||||
{
|
||||
$coords = [];
|
||||
|
||||
foreach ($features as $f) {
|
||||
$coords = array_merge($coords, self::flattenCoords($f['geometry']['coordinates']));
|
||||
}
|
||||
|
||||
if (empty($coords)) return null;
|
||||
|
||||
$lons = array_column($coords, 0);
|
||||
$lats = array_column($coords, 1);
|
||||
|
||||
return [
|
||||
min($lons),
|
||||
min($lats),
|
||||
max($lons),
|
||||
max($lats)
|
||||
];
|
||||
}
|
||||
|
||||
/* =======================
|
||||
CENTROIDE SIMPLE
|
||||
======================= */
|
||||
|
||||
private static function calculateCentroid(array $features): ?array
|
||||
{
|
||||
$coords = [];
|
||||
|
||||
foreach ($features as $f) {
|
||||
$coords = array_merge($coords, self::flattenCoords($f['geometry']['coordinates']));
|
||||
}
|
||||
|
||||
if (empty($coords)) return null;
|
||||
|
||||
$x = array_sum(array_column($coords, 0)) / count($coords);
|
||||
$y = array_sum(array_column($coords, 1)) / count($coords);
|
||||
|
||||
return [$x, $y];
|
||||
}
|
||||
|
||||
private static function flattenCoords($coords): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
$iterator = function ($c) use (&$result, &$iterator) {
|
||||
if (!is_array($c)) return;
|
||||
|
||||
if (isset($c[0]) && isset($c[1]) && is_numeric($c[0])) {
|
||||
$result[] = [$c[0], $c[1]];
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($c as $item) {
|
||||
$iterator($item);
|
||||
}
|
||||
};
|
||||
|
||||
$iterator($coords);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/* =======================
|
||||
GEOJSON
|
||||
======================= */
|
||||
|
||||
private static function parseGeoJson($path): ?array
|
||||
{
|
||||
$data = json_decode(file_get_contents($path), true);
|
||||
return json_last_error() === JSON_ERROR_NONE ? $data : null;
|
||||
}
|
||||
|
||||
/* =======================
|
||||
KML (MEJORADO)
|
||||
======================= */
|
||||
|
||||
private static function kmlToGeoJson($path): ?array
|
||||
{
|
||||
libxml_use_internal_errors(true);
|
||||
$xml = simplexml_load_file($path);
|
||||
|
||||
if (!$xml) return null;
|
||||
|
||||
$xml->registerXPathNamespace('kml', 'http://www.opengis.net/kml/2.2');
|
||||
|
||||
$placemarks = $xml->xpath('//kml:Placemark');
|
||||
|
||||
$features = [];
|
||||
|
||||
foreach ($placemarks as $pm) {
|
||||
$geom = self::parseKmlGeometry($pm);
|
||||
if (!$geom) continue;
|
||||
|
||||
$features[] = [
|
||||
'type' => 'Feature',
|
||||
'geometry' => $geom,
|
||||
'properties' => [
|
||||
'name' => (string)$pm->name,
|
||||
'description' => (string)$pm->description
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
return ['type' => 'FeatureCollection', 'features' => $features];
|
||||
}
|
||||
|
||||
private static function parseKmlGeometry($pm): ?array
|
||||
{
|
||||
if (isset($pm->MultiGeometry)) {
|
||||
$geoms = [];
|
||||
|
||||
foreach ($pm->MultiGeometry->children() as $g) {
|
||||
$parsed = self::parseKmlGeometry($g);
|
||||
if ($parsed) $geoms[] = $parsed;
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'GeometryCollection',
|
||||
'geometries' => $geoms
|
||||
];
|
||||
}
|
||||
|
||||
if (isset($pm->Point)) {
|
||||
return [
|
||||
'type' => 'Point',
|
||||
'coordinates' => self::parseKmlCoords((string)$pm->Point->coordinates)[0]
|
||||
];
|
||||
}
|
||||
|
||||
if (isset($pm->LineString)) {
|
||||
return [
|
||||
'type' => 'LineString',
|
||||
'coordinates' => self::parseKmlCoords((string)$pm->LineString->coordinates)
|
||||
];
|
||||
}
|
||||
|
||||
if (isset($pm->Polygon)) {
|
||||
return [
|
||||
'type' => 'Polygon',
|
||||
'coordinates' => [
|
||||
self::parseKmlCoords(
|
||||
(string)$pm->Polygon->outerBoundaryIs->LinearRing->coordinates
|
||||
)
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function parseKmlCoords(string $text): array
|
||||
{
|
||||
$coords = [];
|
||||
foreach (preg_split('/\s+/', trim($text)) as $pair) {
|
||||
$p = explode(',', $pair);
|
||||
if (count($p) >= 2) {
|
||||
$coords[] = [(float)$p[0], (float)$p[1]];
|
||||
}
|
||||
}
|
||||
return $coords;
|
||||
}
|
||||
|
||||
/* =======================
|
||||
SHP REAL
|
||||
======================= */
|
||||
|
||||
private static function shapefileToGeoJson($path): ?array
|
||||
{
|
||||
try {
|
||||
$reader = new ShapefileReader($path);
|
||||
|
||||
$features = [];
|
||||
|
||||
while ($record = $reader->fetchRecord()) {
|
||||
if ($record->isDeleted()) continue;
|
||||
|
||||
$geom = json_decode($record->getGeometry()->toGeoJSON(), true);
|
||||
|
||||
if (!$geom) continue;
|
||||
|
||||
$features[] = [
|
||||
'type' => 'Feature',
|
||||
'geometry' => $geom,
|
||||
'properties' => $record->getDataArray()
|
||||
];
|
||||
}
|
||||
|
||||
return ['type' => 'FeatureCollection', 'features' => $features];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================
|
||||
ZIP LIMPIO
|
||||
======================= */
|
||||
|
||||
private static function handleZip($zipPath): ?array
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
if ($zip->open($zipPath) !== true) return null;
|
||||
|
||||
$dir = sys_get_temp_dir() . '/geo_' . uniqid();
|
||||
mkdir($dir);
|
||||
|
||||
$zip->extractTo($dir);
|
||||
$zip->close();
|
||||
|
||||
$result = null;
|
||||
|
||||
foreach (scandir($dir) as $file) {
|
||||
$full = $dir . '/' . $file;
|
||||
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
|
||||
|
||||
if ($ext === 'shp') {
|
||||
$result = self::shapefileToGeoJson($full);
|
||||
break;
|
||||
}
|
||||
|
||||
if ($ext === 'kml') {
|
||||
$result = self::kmlToGeoJson($full);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
self::deleteDir($dir);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function deleteDir($dir)
|
||||
{
|
||||
foreach (glob("$dir/*") as $file) {
|
||||
is_dir($file) ? self::deleteDir($file) : unlink($file);
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AppLayout extends Component
|
||||
{
|
||||
/**
|
||||
* Get the view / contents that represents the component.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
return view('layouts.app');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class GuestLayout extends Component
|
||||
{
|
||||
/**
|
||||
* Get the view / contents that represents the component.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
return view('layouts.guest');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user