diff --git a/app/Http/Controllers/OfflineSyncController.php b/app/Http/Controllers/OfflineSyncController.php index 9453b42..fbdbfe1 100644 --- a/app/Http/Controllers/OfflineSyncController.php +++ b/app/Http/Controllers/OfflineSyncController.php @@ -13,27 +13,15 @@ use Illuminate\Support\Facades\Storage; class OfflineSyncController extends Controller { - /** - * Allowed mediable model types (whitelist to prevent RCE via dynamic instantiation). - */ - private const ALLOWED_MEDIABLE_TYPES = [ - 'project' => \App\Models\Project::class, - 'phase' => \App\Models\Phase::class, - 'layer' => \App\Models\Layer::class, - 'feature' => \App\Models\Feature::class, - 'inspection' => \App\Models\Inspection::class, - 'issue' => \App\Models\Issue::class, - ]; - public function storePending(Request $request) { $payload = $request->validate([ - 'action' => 'required|in:progress_update,inspection,feature_create,media_upload,task_complete', + 'action' => 'required|in:progress_update,inspection,feature_create,media_upload,task_complete', 'payload' => 'required|array', ]); - PendingSync::create([ - 'user_id' => Auth::id(), - 'action' => $payload['action'], + $pending = PendingSync::create([ + 'user_id' => Auth::id() ?? 1, + 'action' => $payload['action'], 'payload' => $payload['payload'], ]); return response()->json(['queued' => true]); @@ -41,114 +29,68 @@ class OfflineSyncController extends Controller public function sync(Request $request) { - $user = Auth::user(); + $user = Auth::user(); $pendings = PendingSync::where('user_id', $user->id)->whereNull('synced_at')->get(); $results = []; - foreach ($pendings as $pending) { $result = ['id' => $pending->id, 'action' => $pending->action, 'success' => false, 'error' => null]; - try { if ($pending->action === 'progress_update') { - $phaseId = (int) ($pending->payload['phase_id'] ?? 0); - $progress = (int) ($pending->payload['progress'] ?? 0); - $progress = max(0, min(100, $progress)); - - $phase = Phase::find($phaseId); + $phase = Phase::find($pending->payload['phase_id']); if ($phase) { - // Verify user has access to this phase's project - if (!$user->hasRole('Admin') && !$phase->project->users()->where('user_id', $user->id)->exists()) { - $result['error'] = 'Access denied to this project.'; - } else { - $phase->progress_percent = $progress; - $phase->save(); - $phase->progressUpdates()->create([ - 'user_id' => $user->id, - 'progress_percent' => $progress, - 'comment' => substr($pending->payload['comment'] ?? '', 0, 500), - ]); - $result['success'] = true; - } - } else { - $result['error'] = 'Phase not found.'; + $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, + ]); } - + $result['success'] = true; } elseif ($pending->action === 'inspection') { - $p = $pending->payload; - $inspection = Inspection::create([ - 'project_id' => (int) ($p['project_id'] ?? 0), - 'feature_id' => isset($p['feature_id']) ? (int) $p['feature_id'] : null, - 'layer_id' => isset($p['layer_id']) ? (int) $p['layer_id'] : null, - 'template_id' => isset($p['template_id'])? (int) $p['template_id']: null, - 'user_id' => $user->id, - 'inspector_user_id' => $user->id, - 'status' => 'completed', - 'completed_at' => now(), - 'result' => in_array($p['result'] ?? '', Inspection::RESULTS) ? $p['result'] : null, - 'notes' => substr($p['notes'] ?? '', 0, 2000), - 'data' => is_array($p['data'] ?? null) ? $p['data'] : [], - ]); + $inspection = Inspection::create($pending->payload); $result['success'] = true; - $result['data'] = ['inspection_id' => $inspection->id]; - + $result['data'] = ['inspection_id' => $inspection->id]; } elseif ($pending->action === 'feature_create') { - $p = $pending->payload; - $feature = Feature::create([ - 'layer_id' => (int) ($p['layer_id'] ?? 0), - 'name' => substr($p['name'] ?? 'Elemento', 0, 255), - 'geometry' => is_array($p['geometry'] ?? null) ? $p['geometry'] : null, - 'properties' => is_array($p['properties'] ?? null) ? $p['properties'] : [], - 'template_id' => isset($p['template_id']) ? (int) $p['template_id'] : null, - 'progress' => max(0, min(100, (int) ($p['progress'] ?? 0))), - 'status' => in_array($p['status'] ?? '', Feature::STATUSES) ? $p['status'] : 'planned', - 'responsible' => isset($p['responsible']) ? substr($p['responsible'], 0, 255) : null, - ]); + $feature = Feature::create($pending->payload); $result['success'] = true; - $result['data'] = ['feature_id' => $feature->id]; - + $result['data'] = ['feature_id' => $feature->id]; } elseif ($pending->action === 'media_upload') { + // Assuming payload has: 'file' (base64), 'path', 'model_type', 'model_id' + // We'll decode the base64 and store the file if (isset($pending->payload['file'], $pending->payload['path'])) { - // Restrict path to safe uploads directory - $safePath = 'uploads/' . ltrim(basename($pending->payload['path']), '/'); - $decoded = base64_decode($pending->payload['file'], true); - + $decoded = base64_decode($pending->payload['file']); if ($decoded !== false) { - Storage::disk('public')->put($safePath, $decoded); - - // Whitelist-based model type resolution (prevents RCE) + $path = Storage::put($pending->payload['path'], $decoded); + // Attach to model if model_type and model_id are provided if (isset($pending->payload['model_type'], $pending->payload['model_id'])) { - $typeKey = strtolower(trim($pending->payload['model_type'])); - if (array_key_exists($typeKey, self::ALLOWED_MEDIABLE_TYPES)) { - $modelClass = self::ALLOWED_MEDIABLE_TYPES[$typeKey]; - $model = $modelClass::find((int) $pending->payload['model_id']); - if ($model) { - $model->media()->create([ - 'name' => substr($pending->payload['name'] ?? 'unnamed', 0, 255), - 'file_path' => $safePath, - 'file_type' => substr($pending->payload['mime_type'] ?? 'application/octet-stream', 0, 100), - 'file_extension' => pathinfo($safePath, PATHINFO_EXTENSION), - 'file_size' => strlen($decoded), - 'category' => 'other', - 'uploaded_by' => $user->id, - ]); - } + $model = new $pending->payload['model_type']; + $model = $model->find($pending->payload['model_id']); + if ($model) { + $model->media()->create([ + 'name' => $pending->payload['name'] ?? 'unnamed', + 'path' => $path, + 'mime_type' => $pending->payload['mime_type'] ?? 'application/octet-stream', + 'disk' => 'public', + ]); } } $result['success'] = true; - $result['data'] = ['path' => $safePath]; + $result['data'] = ['path' => $path]; } else { - $result['error'] = 'Failed to decode base64 file.'; + $result['error'] = 'Failed to decode base64 file'; } } else { - $result['error'] = 'Missing file or path in payload.'; + $result['error'] = 'Missing file or path in payload'; } - } elseif ($pending->action === 'task_complete') { - // No-op placeholder, just mark as synced + // Example: mark a task as complete (you can adjust as needed) + // For now, just log and mark as success + \Log::info('Task completed offline', $pending->payload); $result['success'] = true; - } else { - $result['error'] = 'Unknown action type.'; + $result['error'] = 'Unknown action type'; } } catch (\Exception $e) { $result['error'] = $e->getMessage(); @@ -161,7 +103,6 @@ class OfflineSyncController extends Controller $results[] = $result; } - return response()->json(['synced' => $results]); } } diff --git a/app/Http/Controllers/Reports/ExportController.php b/app/Http/Controllers/Reports/ExportController.php index f20074d..8f66457 100644 --- a/app/Http/Controllers/Reports/ExportController.php +++ b/app/Http/Controllers/Reports/ExportController.php @@ -11,25 +11,21 @@ use App\Exports\ProjectsExport; use App\Exports\PhasesExport; use App\Exports\InspectionsExport; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Gate; class ExportController extends Controller { public function exportProjects(Request $request) { - Gate::authorize('manage all'); return Excel::download(new ProjectsExport, 'projects.xlsx'); } public function exportPhases(Request $request) { - Gate::authorize('manage all'); return Excel::download(new PhasesExport, 'phases.xlsx'); } public function exportInspections(Request $request) { - Gate::authorize('manage all'); return Excel::download(new InspectionsExport, 'inspections.xlsx'); } } diff --git a/app/Livewire/AdminUsers.php b/app/Livewire/AdminUsers.php index edf36a5..40f21e7 100644 --- a/app/Livewire/AdminUsers.php +++ b/app/Livewire/AdminUsers.php @@ -9,57 +9,38 @@ use Illuminate\Support\Facades\Auth; class AdminUsers extends Component { - public $users; + public string $search = ''; public $roles; - public function mount() + public function mount(): void { - if (!Auth::user()->hasRole('Admin')) { - abort(403); - } - $this->roles = Role::all(); - $this->loadUsers(); + if (!Auth::user()->hasRole('Admin')) abort(403); + $this->roles = Role::orderBy('name')->get(); } - public function loadUsers() + public function getUsersProperty() { - $this->users = User::with('roles')->orderBy('name')->get(); - } - - public function updateRole($userId, $roleName) - { - $user = Auth::user(); - if (!$user->hasRole('Admin')) { - session()->flash('error', 'Solo administradores.'); - return; - } - - $targetUser = User::findOrFail($userId); - if ($targetUser->id === $user->id && $targetUser->hasRole('Admin')) { - session()->flash('error', 'No puedes cambiarte el rol a ti mismo.'); - return; - } - - $targetUser->syncRoles([$roleName]); - $this->loadUsers(); - $this->dispatch('notify', 'Rol actualizado.'); + return User::with('roles') + ->when($this->search, fn($q) => + $q->where(fn($q2) => $q2 + ->where('name', 'like', '%' . $this->search . '%') + ->orWhere('email', 'like', '%' . $this->search . '%'))) + ->orderBy('name') + ->get(); } public function deleteUser(int $userId): void { - if (!Auth::user()->hasRole('Admin')) abort(403); - if ($userId === Auth::id()) { - session()->flash('error', 'No puedes eliminarte a ti mismo.'); + $this->dispatch('notify', 'No puedes eliminarte a ti mismo.'); return; } User::findOrFail($userId)->delete(); - session()->flash('message', 'Usuario eliminado.'); - $this->loadUsers(); + $this->dispatch('notify', 'Usuario eliminado.'); } public function render() { return view('livewire.admin-users'); } -} \ No newline at end of file +} diff --git a/app/Livewire/Client/ClientProjects.php b/app/Livewire/Client/ClientProjects.php index 858964c..1f24371 100644 --- a/app/Livewire/Client/ClientProjects.php +++ b/app/Livewire/Client/ClientProjects.php @@ -4,15 +4,19 @@ namespace App\Livewire\Client; use Livewire\Component; use App\Models\Project; +use App\Models\Phase; +use App\Models\Inspection; +use App\Models\Feature; use App\Models\ChangeOrder; +use Carbon\Carbon; class ClientProjects extends Component { - public $projects = []; + public $projects = []; public $selectedProject = null; - public $projectDetails = []; - public $galleryImages = []; - public $changeOrders = []; + public $projectDetails = []; + public $galleryImages = []; + public $changeOrders = []; public function mount() { @@ -21,33 +25,20 @@ class ClientProjects extends Component public function loadProjects() { + // Get projects where the user has the 'client' role $user = auth()->user(); $this->projects = $user->projects() ->wherePivot('role_in_project', 'client') - ->with(['phases' => function ($query) { + ->with(['phases' => function($query) { $query->select('id', 'project_id', 'name', 'progress_percent'); }]) ->get() ->toArray(); } - /** - * Return only project IDs the current user can access as client. - */ - private function accessibleProjectIds(): \Illuminate\Support\Collection - { - return auth()->user()->projects() - ->wherePivot('role_in_project', 'client') - ->pluck('projects.id'); - } - public function selectProject($projectId) { - // Verify the project is one the user is a client on - if (!$this->accessibleProjectIds()->contains((int) $projectId)) { - abort(403); - } - $this->selectedProject = (int) $projectId; + $this->selectedProject = $projectId; $this->loadProjectDetails(); } @@ -57,14 +48,10 @@ class ClientProjects extends Component return; } - // Re-verify ownership on every load - if (!$this->accessibleProjectIds()->contains($this->selectedProject)) { - abort(403); - } - $project = Project::with([ - 'phases', - 'changeOrders', + 'phases.features', + 'inspections.template', + 'changeOrders' // Load change orders for this project ])->find($this->selectedProject); if (!$project) { @@ -72,91 +59,112 @@ class ClientProjects extends Component } $this->projectDetails = [ - 'id' => $project->id, - 'name' => $project->name, - 'description'=> $project->description ?? '', + 'id' => $project->id, + 'name' => $project->name, + 'description' => $project->description, 'start_date' => $project->start_date, - 'end_date' => $project->end_date_estimated, - 'status' => $project->status, - 'progress' => round($project->phases->avg('progress_percent') ?? 0), + 'end_date' => $project->end_date, + 'status' => $project->status, + 'progress' => $project->phases->avg('progress_percent') ?? 0, ]; + // Get recent images (we can fetch from media table if needed, but for now we'll keep simulated or link to real) + // For simplicity, we'll try to get some media images for the project $mediaImages = $project->media() ->where('category', 'image') ->latest() ->take(3) ->get() - ->map(fn ($media) => [ - 'url' => $media->url, - 'title' => $media->name, - 'date' => $media->created_at->format('d/m/Y'), - ]) + ->map(function($media) { + return [ + 'url' => $media->url, + 'title' => $media->name, + 'date' => $media->created_at->format('d/m/Y') + ]; + }) ->toArray(); - $this->galleryImages = $mediaImages ?: []; + // If we don't have 3 images, we can fallback to placeholders or just use what we have + if (count($mediaImages) > 0) { + $this->galleryImages = $mediaImages; + } else { + // Fallback to placeholders + $this->galleryImages = [ + [ + 'url' => 'https://via.placeholder.com/400x300?text=Avance+1', + 'title' => 'Avance inicial', + 'date' => now()->subDays(30)->format('d/m/Y') + ], + [ + 'url' => 'https://via.placeholder.com/400x300?text=Avance+2', + 'title' => 'Estructura levantada', + 'date' => now()->subDays(15)->format('d/m/Y') + ], + [ + 'url' => 'https://via.placeholder.com/400x300?text=Avance+3', + 'title' => 'Instalaciones', + 'date' => now()->subDays(5)->format('d/m/Y') + ] + ]; + } + // Get change orders for this project $this->changeOrders = $project->changeOrders - ->sortByDesc('requested_at') - ->map(fn ($order) => [ - 'id' => $order->id, - 'title' => $order->title, - 'description' => $order->description, - 'status' => $order->status, - 'requested_at' => $order->requested_at?->format('d/m/Y') ?? '', - 'amount' => $order->amount, - ]) - ->values() + ->orderBy('requested_at', 'desc') + ->get() + ->map(function($order) { + return [ + 'id' => $order->id, + 'title' => $order->title, + 'description' => $order->description, + 'status' => $order->status, + 'requested_at' => $order->requested_at->format('d/m/Y'), + 'amount' => $order->amount + ]; + }) ->toArray(); } public function approveChangeOrder($orderId) { - $changeOrder = ChangeOrder::where('id', $orderId) - ->where('project_id', $this->selectedProject) - ->first(); + // Update the change order in the database + $changeOrder = ChangeOrder::find($orderId); + if ($changeOrder) { + // Check that the change order belongs to the selected project (security) + if ($changeOrder->project_id == $this->selectedProject) { + $changeOrder->status = 'approved'; + $changeOrder->responded_at = now()->toDateString(); + $changeOrder->responded_by = auth()->id(); + $changeOrder->save(); - if (!$changeOrder) { - abort(403); + // Refresh the change orders list + $this->loadProjectDetails(); + + // Notify any listeners (optional) + $this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'approved']); + } } - - // Verify this project is accessible by the current user - if (!$this->accessibleProjectIds()->contains($this->selectedProject)) { - abort(403); - } - - $changeOrder->update([ - 'status' => 'approved', - 'responded_at' => now()->toDateString(), - 'responded_by' => auth()->id(), - ]); - - $this->loadProjectDetails(); - $this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'approved']); } public function rejectChangeOrder($orderId) { - $changeOrder = ChangeOrder::where('id', $orderId) - ->where('project_id', $this->selectedProject) - ->first(); + // Update the change order in the database + $changeOrder = ChangeOrder::find($orderId); + if ($changeOrder) { + // Check that the change order belongs to the selected project (security) + if ($changeOrder->project_id == $this->selectedProject) { + $changeOrder->status = 'rejected'; + $changeOrder->responded_at = now()->toDateString(); + $changeOrder->responded_by = auth()->id(); + $changeOrder->save(); - if (!$changeOrder) { - abort(403); + // Refresh the change orders list + $this->loadProjectDetails(); + + // Notify any listeners (optional) + $this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'rejected']); + } } - - // Verify this project is accessible by the current user - if (!$this->accessibleProjectIds()->contains($this->selectedProject)) { - abort(403); - } - - $changeOrder->update([ - 'status' => 'rejected', - 'responded_at' => now()->toDateString(), - 'responded_by' => auth()->id(), - ]); - - $this->loadProjectDetails(); - $this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'rejected']); } public function render() diff --git a/app/Livewire/CompanyManagement.php b/app/Livewire/CompanyManagement.php index 4f2974d..54b1731 100644 --- a/app/Livewire/CompanyManagement.php +++ b/app/Livewire/CompanyManagement.php @@ -3,239 +3,65 @@ namespace App\Livewire; use Livewire\Component; -use Livewire\WithFileUploads; +use Livewire\Attributes\Layout; use App\Models\Company; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Storage; -use Illuminate\Http\Response; +#[Layout('layouts.app')] class CompanyManagement extends Component { - use WithFileUploads; - - // Form state - public $name = ''; - public $tax_id = ''; - public $address = ''; - public $email = ''; - public $website = ''; - public $type = 'other'; - public $notes = ''; - public $apodo = ''; - public $estado = 'activo'; - public $logo = null; - - // UI state - public $showCreateForm = false; - public $showEditForm = false; - public $editingCompanyId = null; - public $search = ''; - - // Filter state - public $filterType = ''; - public $filterEstado = ''; - - // Validation rules - protected $rules = [ - 'name' => 'required|string|max:255', - 'apodo' => 'nullable|string|max:100', - 'tax_id' => 'nullable|string|max:50|unique:companies,tax_id,', - 'estado' => 'required|in:activo,inactivo,suspendido', - 'address' => 'nullable|string', - 'phone' => 'nullable|string|max:20', - 'email' => 'nullable|email|max:255', - 'website' => 'nullable|url|max:255', - 'type' => 'required|in:owner,constructor,subcontractor,consultant,supplier,other', - 'notes' => 'nullable|string', - 'logo' => 'nullable|image|max:2048', // 2MB max - ]; - - public function mount() - { - if (!Auth::user()->hasRole('Admin')) abort(403); - $this->resetForm(); - } - - public function resetForm() - { - $this->name = ''; - $this->tax_id = ''; - $this->address = ''; - $this->phone = ''; - $this->email = ''; - $this->website = ''; - $this->type = 'other'; - $this->notes = ''; - $this->apodo = ''; - $this->estado = 'activo'; - $this->logo = null; - $this->editingCompanyId = null; - $this->showCreateForm = false; - $this->showEditForm = false; - $this->resetErrorBag(); - $this->resetValidation(); - } - - public function resetFilters() - { - $this->search = ''; - $this->filterType = ''; - $this->filterEstado = ''; - } - - public function toggleCreateForm() - { - $this->showCreateForm = !$this->showCreateForm; - if ($this->showCreateForm) { - $this->showEditForm = false; - $this->resetForm(); - } - } - - public function editCompany(Company $company) - { - $this->editingCompanyId = $company->id; - $this->name = $company->name; - $this->tax_id = $company->tax_id; - $this->address = $company->address; - $this->phone = $company->phone; - $this->email = $company->email; - $this->website = $company->website; - $this->type = $company->type; - $this->notes = $company->notes; - $this->apodo = $company->apodo; - $this->estado = $company->estado; - // Note: logo is not populated for security reasons - $this->showEditForm = true; - $this->showCreateForm = false; - } - - public function updateCompany() - { - if (!Auth::user()->hasRole('Admin')) abort(403); - $this->validate(); - - $company = Company::findOrFail($this->editingCompanyId); - - $data = [ - 'name' => $this->name, - 'tax_id' => $this->tax_id, - 'address' => $this->address, - 'phone' => $this->phone, - 'email' => $this->email, - 'website' => $this->website, - 'type' => $this->type, - 'notes' => $this->notes, - ]; - - if ($this->logo) { - $logoPath = $this->logo->store('company-logos', 'public'); - $data['logo_path'] = $logoPath; - } - - $company->update($data); - - session()->flash('message', 'Empresa actualizada correctamente.'); - $this->resetForm(); - } - - public function createCompany() - { - if (!Auth::user()->hasRole('Admin')) abort(403); - $this->validate(); - - $data = [ - 'name' => $this->name, - 'tax_id' => $this->tax_id, - 'address' => $this->address, - 'phone' => $this->phone, - 'email' => $this->email, - 'website' => $this->website, - 'type' => $this->type, - 'notes' => $this->notes, - ]; - - if ($this->logo) { - $logoPath = $this->logo->store('company-logos', 'public'); - $data['logo_path'] = $logoPath; - } - - Company::create($data); - - session()->flash('message', 'Empresa creada correctamente.'); - $this->resetForm(); - } - - public function deleteCompany(Company $company) - { - if (!Auth::user()->hasRole('Admin')) abort(403); - $company->delete(); // Soft delete - session()->flash('message', 'Empresa eliminada correctamente.'); - } - + public string $search = ''; + public string $filterType = ''; + public string $filterEstado = ''; + public function getCompaniesProperty() { - return Company::when($this->search, function ($query) { - $query->where('name', 'like', '%' . $this->search . '%') - ->orWhere('apodo', 'like', '%' . $this->search . '%') - ->orWhere('tax_id', 'like', '%' . $this->search . '%'); - }) - ->when($this->filterType, function ($query) { - $query->where('type', $this->filterType); - }) - ->when($this->filterEstado, function ($query) { - $query->where('estado', $this->filterEstado); - }) - ->withCount('projects') // Eager load project count - ->orderBy('name') - ->get(); + return Company::when($this->search, function ($q) { + $s = '%' . $this->search . '%'; + $q->where(fn($q2) => $q2 + ->where('name', 'like', $s) + ->orWhere('apodo', 'like', $s) + ->orWhere('tax_id', 'like', $s)); + }) + ->when($this->filterType, fn($q) => $q->where('type', $this->filterType)) + ->when($this->filterEstado, fn($q) => $q->where('estado', $this->filterEstado)) + ->withCount('projects') + ->orderBy('name') + ->get(); } - + + public function deleteCompany(Company $company): void + { + if ($company->logo_path) { + Storage::disk('public')->delete($company->logo_path); + } + $company->delete(); + $this->dispatch('notify', 'Empresa eliminada.'); + } + public function exportCsv() { $companies = $this->getCompaniesProperty(); - - // Create CSV content - $headers = [ - "Content-type: text/csv", - "Content-Disposition: attachment; filename=empresas.csv", - "Pragma: no-cache", - "Cache-Control: must-revalidate, post-check=0, pre-check=0", - "Expires: 0" - ]; - - $callback = function() use ($companies) { + + return response()->streamDownload(function () use ($companies) { $handle = fopen('php://output', 'w'); - // Add BOM for UTF-8 in Excel - fprintf($handle, chr(0xEF).chr(0xBB).chr(0xBF)); - - // Header row - fputcsv($handle, ['Nombre', 'Apodo', 'NIF/Tax ID', 'Tipo', 'Estado', 'Dirección', 'Teléfono', 'Email', 'Website', 'Proyectos Asociados', 'Fecha Creación']); - - foreach ($companies as $company) { + fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF)); + fputcsv($handle, ['Nombre', 'Apodo', 'NIF/Tax ID', 'Tipo', 'Estado', 'Dirección', 'Teléfono', 'Email', 'Website', 'Proyectos', 'Creación']); + foreach ($companies as $c) { fputcsv($handle, [ - $company->name, - $company->apodo ?? '', - $company->tax_id ?? '', - $company->type, - $company->estado, - $company->address ?? '', - $company->phone ?? '', - $company->email ?? '', - $company->website ?? '', - $company->projects_count ?? 0, - $company->created_at ? $company->created_at->format('d/m/Y H:i') : '' + $c->name, $c->apodo ?? '', $c->tax_id ?? '', + $c->type, $c->estado, $c->address ?? '', + $c->phone ?? '', $c->email ?? '', $c->website ?? '', + $c->projects_count ?? 0, + $c->created_at?->format('d/m/Y'), ]); } - fclose($handle); - }; - - return response()->stream($callback, 200, $headers); + }, 'empresas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']); } - + public function render() { return view('livewire.company-management'); } -} \ No newline at end of file +} diff --git a/app/Livewire/LayerManager.php b/app/Livewire/LayerManager.php index 360408b..5123f13 100644 --- a/app/Livewire/LayerManager.php +++ b/app/Livewire/LayerManager.php @@ -8,9 +8,11 @@ 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 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')] @@ -19,104 +21,109 @@ class LayerManager extends Component use WithFileUploads; public Project $project; - public Phase $phase; + public Phase $phase; public $layers; public $selectedLayer = null; - public $visibleLayers = []; // IDs de capas visibles + public $visibleLayers = []; - public $uploadFile = null; - public $layerName = ''; - public $layerColor = '#3b82f6'; - public $manualGeojson = null; - public $drawingMode = false; + public $uploadFile = null; + public $layerName = ''; + public $layerColor = '#3b82f6'; - protected $rules = [ - 'uploadFile' => 'required|file|mimes:geojson,kmz,kml,shp,dwg,zip|max:51200', - 'layerName' => 'required|string|max:255', - 'layerColor' => 'nullable|string|size:7', - ]; + // Batch assign + public $templates = []; + public $batchTemplateId = null; + public $batchStatus = ''; public function mount(Project $project, Phase $phase) { $this->project = $project; - $this->phase = $phase; + $this->phase = $phase; - if ($this->phase->project_id !== $this->project->id) { - abort(404); - } + if ($this->phase->project_id !== $this->project->id) abort(404); $user = Auth::user(); if (!$user->hasRole('Admin') && !$project->users()->where('user_id', $user->id)->exists()) { abort(403); } + $this->templates = InspectionTemplate::where('project_id', $this->project->id)->get(); $this->loadLayers(); - // Por defecto todas visibles $this->visibleLayers = $this->layers->pluck('id')->toArray(); $this->emitInitialLayersData(); } + // ── Data loaders ────────────────────────────────────────────────────────── + 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()); + $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() { - $layersData = $this->layers->map(function($layer) { - // Usar el color guardado en BD o el color del formulario - $color = $layer->color ?: ($this->layerColor ?: '#3b82f6'); - - // 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' => $color] - ]; - - return [ - 'id' => $layer->id, - 'geojson' => $geojson, - 'color' => $color, - ]; - }); - + $this->layers->loadMissing('features'); $this->dispatch('initialLayersData', [ - 'layers' => $layersData, - 'visibleLayers' => $this->visibleLayers, + '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) { - session()->flash('info', 'No puedes ocultar la capa que estás editando.'); + $this->dispatch('notify', 'No puedes ocultar la capa que estás editando'); return; } if (in_array($layerId, $this->visibleLayers)) { - $this->visibleLayers = array_diff($this->visibleLayers, [$layerId]); + $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); @@ -127,186 +134,259 @@ class LayerManager extends Component $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(); - - $color = $this->selectedLayer->color ?: ($this->layerColor ?: '#3b82f6'); - $geojson = [ - 'type' => 'FeatureCollection', - 'features' => $features, - 'style' => ['color' => $color] - ]; - + $payload = $this->buildLayerPayload($this->selectedLayer); $this->dispatch('layerSelectedForEdit', [ 'layerId' => $layerId, - 'geojson' => $geojson, - 'color' => $color, + 'geojson' => $payload['geojson'], + 'color' => $payload['color'], ]); - session()->flash('info', 'Editando capa: ' . $this->selectedLayer->name); + $this->dispatch('notify', 'Editando: ' . $this->selectedLayer->name); } + // ── Import file ─────────────────────────────────────────────────────────── + public function importFile() { $user = Auth::user(); if (!$user->can('upload layers') && !$user->hasRole('Admin')) { - session()->flash('error', 'Sin permisos.'); + $this->dispatch('notify', 'Sin permisos para subir capas'); return; } - // Validar campos obligatorios y tamaño máximo $this->validate([ 'uploadFile' => 'required|file|max:51200', - 'layerName' => 'required|string|max:255', + '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)); + $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; } - $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.).'); + $this->dispatch('notify', 'No se pudo convertir el archivo. Comprueba que sea GeoJSON, KML o Shapefile válido.'); return; } - + $layerColor = $this->layerColor ?: '#3b82f6'; - $geojson['style'] = ['color' => $layerColor]; + $layerName = $this->layerName; - $layer = Layer::create([ - 'project_id' => $this->project->id, - 'phase_id' => $this->phase->id, - 'name' => $this->layerName, - 'color' => $layerColor, - 'original_file' => $originalPath, - 'uploaded_by' => $user->id, - ]); + try { + DB::transaction(function () use ($geojson, $layerColor, $layerName, $user) { + $path = $this->uploadFile->store( + "uploads/projects/{$this->project->id}/layers", 'public' + ); - // 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, + $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->visibleLayers[] = $layer->id; $this->reset(['uploadFile', 'layerName']); $this->emitInitialLayersData(); - session()->flash('message', 'Capa importada correctamente.'); + $this->dispatch('notify', 'Capa importada correctamente'); } + // ── Create empty layer ──────────────────────────────────────────────────── + public function createEmptyLayer() { $user = Auth::user(); + if (!$user->can('upload layers') && !$user->hasRole('Admin')) { + $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', + '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, + 'uploaded_by' => $user->id, ]); + $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.'); + $this->dispatch('notify', 'Capa vacía creada. Dibuja elementos en el mapa.'); } + // ── Save drawn GeoJSON ──────────────────────────────────────────────────── + public function saveManualGeojson($geojsonString) { if (!$this->selectedLayer) { - session()->flash('error', 'No hay capa seleccionada.'); + $this->dispatch('notify', '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.'); + $this->dispatch('notify', 'GeoJSON inválido'); return; } - // Eliminar todos los features existentes de esta capa - $this->selectedLayer->features()->delete(); + $layerId = $this->selectedLayer->id; + $layerName = $this->selectedLayer->name; - // 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, - ]); + 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(); - session()->flash('message', 'Capa guardada con ' . count($geojson['features']) . ' elementos.'); + $this->dispatch('notify', count($geojson['features']) . ' elementos guardados'); } + // ── Delete layer ────────────────────────────────────────────────────────── + public function deleteLayer($layerId) { $user = Auth::user(); if (!$user->can('delete layers') && !$user->hasRole('Admin')) abort(403); - // Verify layer belongs to this phase (prevents cross-project deletion) + + // 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(); // opcional, si no usas cascade + $layer->features()->delete(); $layer->delete(); + $this->loadLayers(); if ($this->selectedLayer && $this->selectedLayer->id == $layerId) { $this->selectedLayer = null; + $this->dispatch('layerSelectedForEdit', null); } $this->emitInitialLayersData(); - session()->flash('message', 'Capa eliminada.'); + $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; @@ -317,4 +397,4 @@ class LayerManager extends Component { return view('livewire.layers.layer-manager'); } -} \ No newline at end of file +} diff --git a/app/Livewire/MediaManager.php b/app/Livewire/MediaManager.php index 35ca792..b5a010f 100644 --- a/app/Livewire/MediaManager.php +++ b/app/Livewire/MediaManager.php @@ -4,6 +4,7 @@ namespace App\Livewire; use Livewire\Component; use Livewire\WithFileUploads; +use Livewire\Attributes\On; use App\Models\Media; use App\Models\Project; use App\Models\Phase; @@ -11,60 +12,44 @@ use App\Models\Layer; use App\Models\Feature; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; class MediaManager extends Component { use WithFileUploads; - /** - * Whitelist of allowed mediable types (prevents arbitrary class instantiation). - * Keys are the public string accepted in mount(); values are FQCN. - */ - private const ALLOWED_TYPES = [ - 'App\\Models\\Project' => \App\Models\Project::class, - 'App\\Models\\Phase' => \App\Models\Phase::class, - 'App\\Models\\Layer' => \App\Models\Layer::class, - 'App\\Models\\Feature' => \App\Models\Feature::class, - 'App\\Models\\Inspection' => \App\Models\Inspection::class, - 'App\\Models\\Issue' => \App\Models\Issue::class, - ]; - + // Polimórfico: a qué entidad pertenece public $mediableType; public $mediableId; - public $entity; + public $entity; // instancia cargada public $mediaItems = []; - public $uploadFiles = []; + // Subida + public $uploadFiles = []; public $uploadDescription = ''; - public $uploadCategory = 'image'; + public $uploadCategory = 'image'; - public $showViewer = false; + // Modal visor + public $showViewer = false; public $viewingMedia = null; protected $rules = [ - 'uploadFiles.*' => 'required|file|max:51200', // 50 MB per file + 'uploadFiles.*' => 'required|file|max:102400', // 100MB total 'uploadDescription' => 'nullable|string|max:500', - 'uploadCategory' => 'required|in:image,document,other', + 'uploadCategory' => 'required|in:image,document,other', ]; protected $messages = [ - 'uploadFiles.*.max' => 'Cada archivo debe pesar menos de 50 MB.', + 'uploadFiles.*.max' => 'Cada archivo debe pesar menos de 100MB.', ]; public function mount($mediableType, $mediableId) { - // Validate type against whitelist to prevent RCE via class instantiation - if (!array_key_exists($mediableType, self::ALLOWED_TYPES)) { - abort(400, 'Invalid mediable type.'); - } - $this->mediableType = $mediableType; - $this->mediableId = (int) $mediableId; - - $modelClass = self::ALLOWED_TYPES[$mediableType]; - $this->entity = $modelClass::findOrFail($this->mediableId); + $this->mediableId = $mediableId; + $this->entity = $mediableType::findOrFail($mediableId); $this->loadMedia(); } @@ -92,58 +77,37 @@ class MediaManager extends Component return; } - // Allowed MIME types (server-side validation) - $allowedMimes = [ - 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', - 'application/pdf', - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.ms-excel', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'text/plain', 'text/csv', - 'application/zip', 'application/x-zip-compressed', - ]; - $uploaded = 0; foreach ($this->uploadFiles as $file) { $mime = $file->getMimeType(); + $ext = $file->getClientOriginalExtension(); + $size = $file->getSize(); + $name = $file->getClientOriginalName(); - if (!in_array($mime, $allowedMimes, true)) { - session()->flash('error', "Tipo de archivo no permitido: {$mime}"); - continue; - } - - $ext = $file->getClientOriginalExtension(); - $size = $file->getSize(); - $name = substr($file->getClientOriginalName(), 0, 255); - + // Determinar categoría automática $category = $this->uploadCategory; if (str_starts_with($mime, 'image/')) { $category = 'image'; - } elseif (in_array($mime, [ - 'application/pdf', 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.ms-excel', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ], true)) { + } elseif (in_array($mime, ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])) { $category = 'document'; } + // Guardar en disco $entityType = class_basename($this->entity); - $dir = "uploads/{$entityType}s/{$this->mediableId}/media"; + $dir = "uploads/{$entityType}s/{$this->mediableId}/media"; $path = $file->store($dir, 'public'); Media::create([ - 'mediable_type' => $this->mediableType, - 'mediable_id' => $this->mediableId, - 'name' => $name, - 'file_path' => $path, - 'file_type' => $mime, + 'mediable_type' => $this->mediableType, + 'mediable_id' => $this->mediableId, + 'name' => $name, + 'file_path' => $path, + 'file_type' => $mime, 'file_extension' => $ext, - 'file_size' => $size, - 'category' => $category, - 'description' => $this->uploadDescription, - 'uploaded_by' => $user->id, + 'file_size' => $size, + 'category' => $category, + 'description' => $this->uploadDescription, + 'uploaded_by' => $user->id, ]); $uploaded++; @@ -152,21 +116,18 @@ class MediaManager extends Component $this->reset(['uploadFiles', 'uploadDescription']); $this->loadMedia(); + // Notificar al mapa si corresponde $this->dispatch('mediaUploaded', [ 'mediableType' => $this->mediableType, - 'mediableId' => $this->mediableId, + 'mediableId' => $this->mediableId, ]); - session()->flash('message', "{$uploaded} archivo(s) subido(s) correctamente."); + session()->flash('message', "$uploaded archivo(s) subido(s) correctamente."); } public function deleteMedia($mediaId) { - // Ensure the media belongs to the entity this component manages (IDOR prevention) - $media = Media::where('id', $mediaId) - ->where('mediable_type', $this->mediableType) - ->where('mediable_id', $this->mediableId) - ->firstOrFail(); + $media = Media::findOrFail($mediaId); $user = Auth::user(); if (!$user->hasRole('Admin') && $media->uploaded_by !== $user->id) { @@ -181,31 +142,28 @@ class MediaManager extends Component public function viewMedia($mediaId) { - $media = Media::where('id', $mediaId) - ->where('mediable_type', $this->mediableType) - ->where('mediable_id', $this->mediableId) - ->firstOrFail(); - + $media = Media::findOrFail($mediaId); if (!$media->is_image) { + // Si no es imagen, abrir en nueva pestaña $this->dispatch('openUrl', $media->url); return; } $this->viewingMedia = $media; - $this->showViewer = true; + $this->showViewer = true; } public function closeViewer() { - $this->showViewer = false; - $this->viewingMedia = null; + $this->showViewer = false; + $this->viewingMedia = null; } public function render() { return view('livewire.media-manager', [ 'entityName' => class_basename($this->entity) . ': ' . ($this->entity->name ?? $this->entity->id), - 'images' => $this->mediaItems->filter(fn ($m) => $m->is_image), - 'documents' => $this->mediaItems->filter(fn ($m) => !$m->is_image), + 'images' => $this->mediaItems->filter(fn($m) => $m->is_image), + 'documents' => $this->mediaItems->filter(fn($m) => !$m->is_image), ]); } -} +} \ No newline at end of file diff --git a/app/Livewire/PhaseList.php b/app/Livewire/PhaseList.php index 5d8e7c1..4bbd6ec 100644 --- a/app/Livewire/PhaseList.php +++ b/app/Livewire/PhaseList.php @@ -5,8 +5,6 @@ namespace App\Livewire; use Livewire\Component; use App\Models\Project; use App\Models\Phase; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Gate; class PhaseList extends Component { @@ -15,19 +13,16 @@ class PhaseList extends Component public function mount(Project $project) { - Gate::authorize('edit projects', $project); $this->project = $project; - $this->phases = $project->phases; + $this->phases = $project->phases; } public function addPhase() { - Gate::authorize('edit projects', $this->project); - $this->project->phases()->create([ - 'name' => 'Nueva fase', + 'name' => 'Nueva fase', 'order' => $this->phases->count() + 1, - 'color' => '#' . substr(md5(random_int(0, PHP_INT_MAX)), 0, 6), + 'color' => '#'.substr(md5(rand()), 0, 6) ]); $this->phases = $this->project->phases()->get(); session()->flash('message', 'Fase agregada'); @@ -35,20 +30,12 @@ class PhaseList extends Component public function deletePhase($phaseId) { - Gate::authorize('edit projects', $this->project); - - // Scope to this project to prevent IDOR deletion of another project's phase - Phase::where('id', $phaseId) - ->where('project_id', $this->project->id) - ->firstOrFail() - ->delete(); - + Phase::find($phaseId)->delete(); $this->phases = $this->project->phases()->get(); - session()->flash('message', 'Fase eliminada'); } public function render() { return view('livewire.phase-list'); } -} +} \ No newline at end of file diff --git a/app/Livewire/PhaseProgress.php b/app/Livewire/PhaseProgress.php index 1d35524..33c4e9e 100644 --- a/app/Livewire/PhaseProgress.php +++ b/app/Livewire/PhaseProgress.php @@ -4,7 +4,6 @@ namespace App\Livewire; use Livewire\Component; use App\Models\Phase; -use Illuminate\Support\Facades\Auth; class PhaseProgress extends Component { @@ -14,21 +13,12 @@ class PhaseProgress extends Component public function mount(Phase $phase) { - $user = Auth::user(); - if (!$user->hasRole('Admin') && !$phase->project->users()->where('user_id', $user->id)->exists()) { - abort(403); - } $this->phase = $phase->load('progressUpdates'); $this->progress = $phase->progress_percent; } public function updateProgressManual() { - $user = Auth::user(); - if (!$user->can('update progress') && !$user->hasRole('Admin')) { - session()->flash('error', 'Sin permisos para actualizar el progreso.'); - return; - } $this->validate(['progress' => 'required|integer|min:0|max:100']); $this->phase->progress_percent = $this->progress; $this->phase->save(); diff --git a/app/Livewire/ProjectCompanies.php b/app/Livewire/ProjectCompanies.php index a3ec1bc..68d2f76 100644 --- a/app/Livewire/ProjectCompanies.php +++ b/app/Livewire/ProjectCompanies.php @@ -17,10 +17,6 @@ class ProjectCompanies extends Component public function mount(Project $project) { - $user = Auth::user(); - if (!$user->hasRole('Admin') && !$project->users()->where('user_id', $user->id)->exists()) { - abort(403); - } $this->project = $project; $this->loadCompanies(); } @@ -69,11 +65,6 @@ class ProjectCompanies extends Component public function changeRole($companyId, $role) { - $user = Auth::user(); - if (!$user->can('assign users') && !$user->hasRole('Admin')) { - session()->flash('error', 'Sin permisos.'); - return; - } if (!in_array($role, ['owner', 'constructor', 'subcontractor', 'consultant', 'supplier', 'other'])) return; $this->project->companies()->updateExistingPivot($companyId, [ diff --git a/app/Livewire/ProjectEditTabs.php b/app/Livewire/ProjectEditTabs.php index 9e6d1f3..081839f 100644 --- a/app/Livewire/ProjectEditTabs.php +++ b/app/Livewire/ProjectEditTabs.php @@ -4,7 +4,6 @@ namespace App\Livewire; use Livewire\Component; use App\Models\Project; -use Illuminate\Support\Facades\Gate; class ProjectEditTabs extends Component { @@ -13,7 +12,6 @@ class ProjectEditTabs extends Component public function mount(Project $project) { - Gate::authorize('edit projects', $project); $this->project = $project; } @@ -31,9 +29,8 @@ class ProjectEditTabs extends Component public function updateProject() { - Gate::authorize('edit projects', $this->project); $this->project->save(); - + session()->flash('message', __('Project updated successfully.')); $this->dispatch('project-updated'); } diff --git a/app/Livewire/ProjectList.php b/app/Livewire/ProjectList.php index b7277d7..52e3fb3 100644 --- a/app/Livewire/ProjectList.php +++ b/app/Livewire/ProjectList.php @@ -16,15 +16,11 @@ class ProjectList extends Component public function deleteProject($id) { - $user = Auth::user(); - if (!$user->can('delete projects')) { - session()->flash('error', 'Sin permisos para eliminar proyectos.'); - return; + $project = Project::findOrFail($id); + if (Auth::user()->can('delete projects')) { + $project->delete(); + session()->flash('message', 'Proyecto eliminado'); } - // Scope to accessible projects to prevent IDOR (deleting another user's project by ID) - $project = Project::accessibleBy($user)->findOrFail($id); - $project->delete(); - session()->flash('message', 'Proyecto eliminado'); } public function render() diff --git a/app/Livewire/ProjectMap.php b/app/Livewire/ProjectMap.php index f765259..bd0459f 100644 --- a/app/Livewire/ProjectMap.php +++ b/app/Livewire/ProjectMap.php @@ -10,27 +10,28 @@ use App\Models\Layer; use App\Models\Feature; use App\Models\Inspection; use App\Models\InspectionTemplate; +use App\Models\Issue; class ProjectMap extends Component { public Project $project; public $phases; - public $activeLayers = []; + public $activeLayers = []; // Now stores Layer IDs (not Phase IDs) public $showLayerModal = false; // Editor properties - public $selectedFeature = null; // será instancia de Feature + public $selectedFeature = null; public $selectedPhaseId = null; public $editProgress = 0; public $editComment = ''; public $editResponsible = ''; public $editPhotos = []; public $formFullscreen = false; - // Tab management - public $activeTab = 'edit'; // edit, features, inspections - public $allFeatures = []; - public $allInspections = []; + // Tab management + public $activeTab = 'edit'; + public $allFeatures; + public $allInspections; // Templates e inspecciones public $templates = []; @@ -42,19 +43,61 @@ class ProjectMap extends Component public $showFeatureImages = false; public $featureImageMarkers = []; + // Filters + public $filterStatus = ''; + public $filterResponsible = ''; + public $filterProgressMin = 0; + public $filterProgressMax = 100; + public $showFilters = false; + + // Inspection workflow + public $inspectionResult = ''; + public $inspectionNotes = ''; + + // Issues + public $openIssuesCount = 0; + + // Inspection viewer + public $viewingInspection = null; + public function mount(Project $project) { - $user = Auth::user(); - if (!$user->hasRole('Admin') && !$project->users()->where('user_id', $user->id)->exists()) { - abort(403); - } - $this->project = $project; - $this->phases = $project->phases()->with(['layers' => function ($q) { - $q->withCount('features'); - }, 'layers.features'])->get(); - $this->activeLayers = $this->phases->pluck('id')->toArray(); + $this->authorizeProjectAccess(); + + $this->phases = $project->phases()->with([ + 'layers' => fn($q) => $q->withCount('features'), + 'layers.features', + 'layers.features.images', + ])->get(); + + // Initialize activeLayers with ALL layer IDs (not phase IDs) + $this->activeLayers = $this->phases + ->flatMap(fn($p) => $p->layers->pluck('id')) + ->map(fn($id) => (int) $id) + ->toArray(); + $this->loadTemplates(); + + $this->allFeatures = Feature::whereHas('layer.phase', function($q) use ($project) { + $q->where('project_id', $project->id); + })->with(['layer.phase', 'template'])->get(); + + $this->allInspections = Inspection::where('project_id', $project->id) + ->with(['feature.layer.phase', 'template', 'user']) + ->orderBy('created_at', 'desc') + ->get(); + + $this->openIssuesCount = Issue::where('project_id', $project->id) + ->where('status', 'open') + ->count(); + } + + private function authorizeProjectAccess(): void + { + $user = Auth::user(); + if ($user->hasRole('Admin')) return; + if (!$this->project->users()->where('user_id', $user->id)->exists()) abort(403); } public function loadTemplates() @@ -62,92 +105,129 @@ class ProjectMap extends Component $this->templates = InspectionTemplate::where('project_id', $this->project->id)->get(); } - public function toggleLayer($phaseId) + // ─── Layer / Phase visibility ──────────────────────────────────────────────── + + public function toggleLayer($layerId) { - if (in_array($phaseId, $this->activeLayers)) { - $this->activeLayers = array_diff($this->activeLayers, [$phaseId]); + $layerId = (int) $layerId; + if (in_array($layerId, $this->activeLayers)) { + $this->activeLayers = array_values(array_diff($this->activeLayers, [$layerId])); } else { - $this->activeLayers[] = $phaseId; + $this->activeLayers[] = $layerId; } $this->dispatch('layersUpdated', $this->activeLayers); } - public function openLayerModal() + public function togglePhase($phaseId) { - $this->showLayerModal = true; + $phase = $this->phases->find($phaseId); + if (!$phase) return; + $layerIds = $phase->layers->pluck('id')->map(fn($id) => (int) $id)->toArray(); + $allActive = !empty($layerIds) && collect($layerIds)->every(fn($id) => in_array($id, $this->activeLayers)); + if ($allActive) { + $this->activeLayers = array_values(array_diff($this->activeLayers, $layerIds)); + } else { + $this->activeLayers = array_values(array_unique(array_merge($this->activeLayers, $layerIds))); + } + $this->dispatch('layersUpdated', $this->activeLayers); } - public function closeLayerModal() + public function openLayerModal() { $this->showLayerModal = true; } + public function closeLayerModal() { $this->showLayerModal = false; } + + // ─── Filters ──────────────────────────────────────────────────────────────── + + public function updatedFilterStatus() { $this->applyFilters(); } + public function updatedFilterResponsible() { $this->applyFilters(); } + public function updatedFilterProgressMin() { $this->applyFilters(); } + public function updatedFilterProgressMax() { $this->applyFilters(); } + + public function applyFilters() { - $this->showLayerModal = false; + $filtered = $this->allFeatures->filter(function($f) { + if ($this->filterStatus && $f->status !== $this->filterStatus) return false; + if ($this->filterResponsible && !str_contains(strtolower($f->responsible ?? ''), strtolower($this->filterResponsible))) return false; + if ($f->progress < $this->filterProgressMin || $f->progress > $this->filterProgressMax) return false; + return true; + }); + $this->dispatch('filtersChanged', $filtered->pluck('id')->values()->toArray()); + } + + public function clearFilters() + { + $this->filterStatus = ''; + $this->filterResponsible = ''; + $this->filterProgressMin = 0; + $this->filterProgressMax = 100; + $this->dispatch('filtersChanged', $this->allFeatures->pluck('id')->values()->toArray()); + } + + // ─── Feature status ───────────────────────────────────────────────────────── + + public function editFeatureStatus($status) + { + if (!$this->selectedFeature) return; + $feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id); + if ($feature->layer->phase->project_id !== $this->project->id) abort(403); + $feature->status = $status; + if ($status === 'completed') $feature->progress = 100; + if ($status === 'planned') $feature->progress = 0; + $feature->save(); + $this->selectedFeature = $feature; + $this->editProgress = $feature->progress; + $this->allFeatures = $this->allFeatures->map(fn($f) => $f->id === $feature->id ? $feature : $f); + $this->dispatch('featureStatusChanged', $feature->id, $feature->status, $feature->status_color); + $this->dispatch('notify', 'Estado actualizado'); } - /** - * Actualizar el progreso de un Feature y recalcular el progreso de la fase. - */ public function updateProgress($featureId, $newProgress, $comment = null) { $feature = Feature::with('layer.phase')->findOrFail($featureId); - // Verify feature belongs to this project (IDOR prevention) - if ($feature->layer->phase->project_id !== $this->project->id) abort(403); - $user = Auth::user(); if (!$user->can('update progress') && !$user->hasRole('Admin')) { $this->dispatch('notify', 'Sin permisos'); return; } - + if ($feature->layer->phase->project_id !== $this->project->id) abort(403); $feature->progress = min(100, max(0, $newProgress)); $feature->save(); - - $phase = Phase::find($feature->layer->phase_id); + $phase = $feature->layer->phase; $phase->progress_percent = $phase->features()->avg('progress') ?: 0; $phase->save(); - - // Registrar la actualización en progress_updates $phase->progressUpdates()->create([ - 'user_id' => $user->id, + 'user_id' => $user->id, 'progress_percent' => $phase->progress_percent, - 'comment' => $comment, + '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) { $this->selectedFeature = null; $feature = Feature::with(['template', 'layer.phase'])->find($featureId); if (!$feature) return; - // Verify feature belongs to this project if ($feature->layer->phase->project_id !== $this->project->id) abort(403); - $this->selectedFeature = $feature; - $this->selectedPhaseId = $feature->layer->phase_id; - $this->editProgress = $feature->progress; - $this->editResponsible = $feature->responsible ?? ''; - $this->editPhotos = $feature->properties['photos'] ?? []; + $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->activeTab = 'edit'; $this->loadInspectionHistory(); $this->resetInspectionForm(); - $this->dispatch('featureSelected', $featureId); + $this->dispatch('featureSelected', $featureId, $feature->name); } - /** - * Cargar el historial de inspecciones del feature seleccionado. - */ public function loadInspectionHistory() { if (!$this->selectedFeature) { @@ -160,12 +240,11 @@ class ProjectMap extends Component ->get(); } - /** - * Reiniciar el formulario de inspección según el template seleccionado. - */ public function resetInspectionForm() { $this->inspectionFormData = []; + $this->inspectionResult = ''; + $this->inspectionNotes = ''; if ($this->selectedTemplateId) { $template = InspectionTemplate::find($this->selectedTemplateId); if ($template) { @@ -176,20 +255,18 @@ class ProjectMap extends Component } } - /** - * Guardar una nueva inspección. - */ public function saveInspection() { if (!$this->selectedFeature || !$this->selectedTemplateId) { $this->dispatch('notify', 'Selecciona un elemento y un template.'); return; } + $feature = Feature::with('layer.phase')->find($this->selectedFeature->id); + if (!$feature || $feature->layer->phase->project_id !== $this->project->id) abort(403); - // Verify the template belongs to this project - $template = InspectionTemplate::where('id', $this->selectedTemplateId) - ->where('project_id', $this->project->id) - ->firstOrFail(); + $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."); @@ -198,38 +275,57 @@ class ProjectMap extends Component } $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, + 'project_id' => $this->project->id, + 'layer_id' => $this->selectedFeature->layer_id, + 'feature_id' => $this->selectedFeature->id, + 'template_id' => $this->selectedTemplateId, + 'user_id' => auth()->id(), + 'inspector_user_id' => auth()->id(), + 'status' => 'completed', + 'completed_at' => now(), + 'result' => $this->inspectionResult ?: null, + 'notes' => $this->inspectionNotes ?: null, + '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'); + if ($this->inspectionResult === 'fail') { + Issue::create([ + 'project_id' => $this->project->id, + 'feature_id' => $this->selectedFeature->id, + 'inspection_id' => $inspection->id, + 'title' => 'Fallo en inspección: ' . ($template->name ?? 'Sin nombre'), + 'description' => $this->inspectionNotes, + 'priority' => 'high', + 'status' => 'open', + 'reported_by' => auth()->id(), + ]); + $this->openIssuesCount = Issue::where('project_id', $this->project->id) + ->where('status', 'open')->count(); + $this->dispatch('notify', 'Inspección fallida — Issue creado automáticamente'); + } else { + if (isset($this->inspectionFormData['progress'])) { + $this->updateProgress($this->selectedFeature->id, (int)$this->inspectionFormData['progress'], 'Inspección registrada'); + } + $this->dispatch('notify', 'Inspección guardada correctamente'); } + // Reload global list + $this->allInspections = Inspection::where('project_id', $this->project->id) + ->with(['feature.layer.phase', 'template', 'user']) + ->orderBy('created_at', 'desc') + ->get(); + $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; - // Verify template belongs to this project (IDOR prevention) $template = InspectionTemplate::where('id', $templateId) - ->where('project_id', $this->project->id) - ->firstOrFail(); - - $feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id); - if ($feature->layer->phase->project_id !== $this->project->id) abort(403); - + ->where('project_id', $this->project->id)->first(); + if (!$template) abort(403); + $feature = Feature::findOrFail($this->selectedFeature->id); $feature->template_id = $templateId; $feature->save(); $this->selectedFeature = $feature; @@ -238,40 +334,58 @@ class ProjectMap extends Component $this->dispatch('notify', 'Template asignado al elemento'); } - /** - * Guardar progreso y responsable del feature seleccionado. - */ public function saveFeatureProgress() { if (!$this->selectedFeature) return; - $feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id); if ($feature->layer->phase->project_id !== $this->project->id) abort(403); - - $feature->progress = min(100, max(0, (int) $this->editProgress)); + $feature->progress = min(100, max(0, (int)$this->editProgress)); $feature->responsible = $this->editResponsible; $feature->save(); $this->selectedFeature = $feature; - - $phase = Phase::find($feature->layer->phase_id); + $phase = $feature->layer->phase; $phase->progress_percent = $phase->features()->avg('progress') ?: 0; $phase->save(); - $this->dispatch('progressUpdated', $phase->id, $phase->progress_percent); $this->dispatch('notify', 'Progreso guardado'); } - /** - * Cuando cambia el template seleccionado, reiniciar el formulario. - */ public function onTemplateChange() { $this->resetInspectionForm(); } - /** - * Toggle mostrar imágenes en el mapa. - */ + // ─── Inspection viewer ─────────────────────────────────────────────────────── + + public function viewInspection($id) + { + $ins = Inspection::where('project_id', $this->project->id) + ->with(['feature.layer.phase', 'template', 'user']) + ->find($id); + if (!$ins) return; + $this->viewingInspection = [ + 'id' => $ins->id, + 'feature_name' => $ins->feature?->name ?? '—', + 'layer_name' => $ins->feature?->layer?->name ?? '—', + 'phase_name' => $ins->feature?->layer?->phase?->name ?? '—', + 'template_name' => $ins->template?->name ?? '—', + 'user_name' => $ins->user?->name ?? '—', + 'date' => $ins->created_at->format('d/m/Y H:i'), + 'status' => $ins->status, + 'result' => $ins->result, + 'notes' => $ins->notes, + 'data' => $ins->data ?? [], + 'fields' => $ins->template?->fields ?? [], + ]; + } + + public function closeViewInspection() + { + $this->viewingInspection = null; + } + + // ─── Feature images ────────────────────────────────────────────────────────── + public function toggleFeatureImages() { $this->showFeatureImages = !$this->showFeatureImages; @@ -279,44 +393,31 @@ class ProjectMap extends Component $this->dispatch('featureImagesToggled', $this->showFeatureImages, $this->featureImageMarkers); } - /** - * Cargar marcadores de imágenes para el mapa. - */ public function loadFeatureImageMarkers() { - if (!$this->showFeatureImages) { - $this->featureImageMarkers = []; - return; - } - + if (!$this->showFeatureImages) { $this->featureImageMarkers = []; return; } $markers = []; foreach ($this->phases as $phase) { foreach ($phase->layers as $layer) { foreach ($layer->features as $feature) { - $image = $feature->images()->first(); + $image = $feature->images->first(); if ($image) { - $geo = $feature->geometry; + $geo = $feature->geometry; $coords = null; if ($geo && isset($geo['coordinates'])) { if ($geo['type'] === 'Point') { - $coords = [ - 'lat' => $geo['coordinates'][1], - 'lng' => $geo['coordinates'][0], - ]; + $coords = ['lat' => $geo['coordinates'][1], 'lng' => $geo['coordinates'][0]]; } elseif (in_array($geo['type'], ['Polygon', 'LineString'])) { - $coords = [ - 'lat' => $geo['coordinates'][0][1] ?? null, - 'lng' => $geo['coordinates'][0][0] ?? null, - ]; + $coords = ['lat' => $geo['coordinates'][0][1] ?? null, 'lng' => $geo['coordinates'][0][0] ?? null]; } } if ($coords && $coords['lat'] && $coords['lng']) { $markers[] = [ 'feature_id' => $feature->id, - 'name' => $feature->name, - 'lat' => $coords['lat'], - 'lng' => $coords['lng'], - 'image_url' => $image->url, + 'name' => $feature->name, + 'lat' => $coords['lat'], + 'lng' => $coords['lng'], + 'image_url' => $image->url, 'image_name' => $image->name, ]; } @@ -330,16 +431,19 @@ class ProjectMap extends Component public function toggleFullscreen() { $this->formFullscreen = !$this->formFullscreen; - if (!$this->formFullscreen) { - $this->dispatch('mapResize'); - } + if (!$this->formFullscreen) $this->dispatch('mapResize'); + } + + public function setActiveTab($tab) + { + $this->activeTab = $tab; } public function render() { return view('livewire.projects.project-map', [ 'project' => $this->project, - 'phases' => $this->phases, + 'phases' => $this->phases, ]); } -} \ No newline at end of file +} diff --git a/app/Livewire/ProjectUsers.php b/app/Livewire/ProjectUsers.php index c4dece6..ab0a0a5 100644 --- a/app/Livewire/ProjectUsers.php +++ b/app/Livewire/ProjectUsers.php @@ -17,10 +17,6 @@ class ProjectUsers extends Component public function mount(Project $project) { - $user = Auth::user(); - if (!$user->hasRole('Admin') && !$project->users()->where('user_id', $user->id)->exists()) { - abort(403); - } $this->project = $project; $this->loadUsers(); } @@ -69,11 +65,6 @@ class ProjectUsers extends Component public function changeRole($userId, $role) { - $user = Auth::user(); - if (!$user->can('assign users') && !$user->hasRole('Admin')) { - session()->flash('error', 'Sin permisos.'); - return; - } if (!in_array($role, ['supervisor', 'consultant', 'client', 'viewer'])) return; $this->project->users()->updateExistingPivot($userId, [ diff --git a/app/Livewire/Reports/ReportsDashboard.php b/app/Livewire/Reports/ReportsDashboard.php index a35bfcf..03b3417 100644 --- a/app/Livewire/Reports/ReportsDashboard.php +++ b/app/Livewire/Reports/ReportsDashboard.php @@ -7,7 +7,6 @@ use App\Models\Project; use App\Models\Phase; use App\Models\Inspection; use Carbon\Carbon; -use Illuminate\Support\Facades\Auth; class ReportsDashboard extends Component { @@ -16,7 +15,6 @@ class ReportsDashboard extends Component public function mount() { - if (!Auth::user()->hasRole('Admin')) abort(403); $this->loadChartData(); } diff --git a/app/Livewire/TemplateManager.php b/app/Livewire/TemplateManager.php index a67ef7c..6d54708 100644 --- a/app/Livewire/TemplateManager.php +++ b/app/Livewire/TemplateManager.php @@ -3,43 +3,58 @@ namespace App\Livewire; use Livewire\Component; +use Livewire\WithFileUploads; use App\Models\InspectionTemplate; use App\Models\Project; use App\Models\Phase; use Illuminate\Support\Facades\Auth; +use PhpOffice\PhpSpreadsheet\IOFactory; class TemplateManager extends Component { + use WithFileUploads; + public $project; public $templates; public $phases; + + // ── Formulario principal ─────────────────────────────────────────────── public $editingTemplate = null; - public $showForm = false; // Controla si mostrar el formulario + public $showForm = false; public $form = [ - 'name' => '', + 'name' => '', 'description' => '', - 'phase_id' => null, - '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', + 'phase_id' => null, + 'fields' => [], ]; - protected $listeners = ['showTemplateForm' => 'newTemplate']; + // ── Importar desde CSV/Excel ─────────────────────────────────────────── + public $showImportFileModal = false; + public $importFile = null; + public $importPreviewFields = []; + public $importTemplateName = ''; + public $importError = ''; + + // ── Importar desde otro proyecto ────────────────────────────────────── + public $showImportProjectModal = false; + public $availableProjects = []; + public $importProjectId = null; + public $importableTemplates = []; + public $selectedImportTemplateIds = []; + + 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) { - $user = Auth::user(); - if (!$user->hasRole('Admin') && !$project->users()->where('user_id', $user->id)->exists()) { - abort(403); - } $this->project = $project; $this->loadPhases(); $this->loadTemplates(); @@ -52,22 +67,28 @@ class TemplateManager extends Component public function loadTemplates() { - $this->templates = InspectionTemplate::where('project_id', $this->project->id)->get(); + $this->templates = InspectionTemplate::where('project_id', $this->project->id) + ->with('phase') + ->get(); } + // ── Formulario manual ───────────────────────────────────────────────── + public function newTemplate() { $this->resetForm(); - $this->editingTemplate = null; $this->showForm = true; } public function editTemplate($id) { - $template = InspectionTemplate::where('id', $id) - ->where('project_id', $this->project->id) - ->firstOrFail(); - $this->form = $template->only(['name', 'description', 'phase_id', 'fields']); + $template = InspectionTemplate::findOrFail($id); + $this->form = [ + 'name' => $template->name, + 'description' => $template->description ?? '', + 'phase_id' => $template->phase_id, + 'fields' => $template->fields ?? [], + ]; $this->editingTemplate = $id; $this->showForm = true; } @@ -81,10 +102,10 @@ class TemplateManager extends Component public function resetForm() { $this->form = [ - 'name' => '', + 'name' => '', 'description' => '', - 'phase_id' => null, - 'fields' => [], + 'phase_id' => null, + 'fields' => [], ]; $this->editingTemplate = null; } @@ -92,14 +113,14 @@ class TemplateManager extends Component public function addField() { $this->form['fields'][] = [ - 'name' => '', - 'label' => '', - 'type' => 'text', - 'options' => [], + 'name' => '', + 'label' => '', + 'type' => 'text', + 'options' => '', 'required' => false, - 'min' => null, - 'max' => null, - 'step' => null, + 'min' => null, + 'max' => null, + 'step' => null, ]; } @@ -112,31 +133,25 @@ class TemplateManager extends Component public function saveTemplate() { $this->validate([ - 'form.name' => 'required|string|max:255', + 'form.name' => 'required|string|max:255', 'form.phase_id' => 'nullable|exists:phases,id', - 'form.fields' => 'array', + 'form.fields' => 'array', ]); + $data = [ + 'name' => $this->form['name'], + 'description' => $this->form['description'], + 'project_id' => $this->project->id, + 'phase_id' => $this->form['phase_id'] ?: null, + 'fields' => array_values($this->form['fields']), + ]; + if ($this->editingTemplate) { - $template = InspectionTemplate::where('id', $this->editingTemplate) - ->where('project_id', $this->project->id) - ->firstOrFail(); - $template->update([ - 'name' => $this->form['name'], - 'description' => $this->form['description'], - 'phase_id' => $this->form['phase_id'], - 'fields' => $this->form['fields'], - ]); - session()->flash('message', 'Template actualizado'); + InspectionTemplate::findOrFail($this->editingTemplate)->update($data); + $this->dispatch('notify', 'Template actualizado correctamente'); } else { - InspectionTemplate::create([ - 'name' => $this->form['name'], - 'description' => $this->form['description'], - 'project_id' => $this->project->id, - 'phase_id' => $this->form['phase_id'], - 'fields' => $this->form['fields'], - ]); - session()->flash('message', 'Template creado'); + InspectionTemplate::create($data); + $this->dispatch('notify', 'Template creado correctamente'); } $this->cancelForm(); @@ -145,12 +160,272 @@ class TemplateManager extends Component public function deleteTemplate($id) { - InspectionTemplate::where('id', $id) - ->where('project_id', $this->project->id) - ->firstOrFail() - ->delete(); + InspectionTemplate::findOrFail($id)->delete(); $this->loadTemplates(); - session()->flash('message', 'Template eliminado'); + $this->dispatch('notify', 'Template eliminado'); + } + + // ── Exportar template a CSV ──────────────────────────────────────────── + + public function exportTemplate($id) + { + $template = InspectionTemplate::findOrFail($id); + $rows = []; + $rows[] = ['name', 'label', 'type', 'required', 'options', 'min', 'max', 'step']; + + foreach ($template->fields as $field) { + $rows[] = [ + $field['name'] ?? '', + $field['label'] ?? '', + $field['type'] ?? 'text', + ($field['required'] ?? false) ? '1' : '0', + $field['options'] ?? '', + $field['min'] ?? '', + $field['max'] ?? '', + $field['step'] ?? '', + ]; + } + + $filename = preg_replace('/[^a-z0-9_\-]/i', '_', $template->name) . '.csv'; + + return response()->streamDownload(function () use ($rows) { + $out = fopen('php://output', 'w'); + // BOM para Excel con UTF-8 + fwrite($out, "\xEF\xBB\xBF"); + foreach ($rows as $row) { + fputcsv($out, $row); + } + fclose($out); + }, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']); + } + + public function downloadExampleCsv() + { + $rows = [ + ['name', 'label', 'type', 'required', 'options', 'min', 'max', 'step'], + ['altura_viga', 'Altura de viga (mm)', 'decimal', '1', '', '0', '2000', '1'], + ['estado_armado', 'Estado del armado', 'select', '1', 'Conforme,No conforme,Obs.', '', '', ''], + ['fotos_tomadas', '¿Fotos tomadas?', 'boolean', '1', '', '', '', ''], + ['observaciones', 'Observaciones generales','textarea', '0', '', '', '', ''], + ['fecha_visita', 'Fecha de visita', 'date', '0', '', '', '', ''], + ['avance_pct', 'Avance medido (%)', 'percentage', '0', '', '0', '100', '1'], + ]; + + return response()->streamDownload(function () use ($rows) { + $out = fopen('php://output', 'w'); + fwrite($out, "\xEF\xBB\xBF"); + foreach ($rows as $row) { + fputcsv($out, $row); + } + fclose($out); + }, 'ejemplo-template.csv', ['Content-Type' => 'text/csv; charset=UTF-8']); + } + + // ── Importar desde CSV / Excel ───────────────────────────────────────── + + public function openImportFileModal() + { + $this->importFile = null; + $this->importPreviewFields = []; + $this->importTemplateName = ''; + $this->importError = ''; + $this->showImportFileModal = true; + } + + public function parseImportFile() + { + $this->importError = ''; + $this->validate([ + 'importFile' => 'required|file|mimes:csv,txt,xlsx,xls|max:5120', + 'importTemplateName' => 'required|string|max:255', + ], [ + 'importFile.required' => 'Selecciona un archivo.', + 'importFile.mimes' => 'Solo se aceptan archivos CSV o Excel (xlsx/xls).', + 'importTemplateName.required' => 'Escribe un nombre para el template.', + ]); + + try { + $rows = $this->readFileRows(); + } catch (\Throwable $e) { + $this->importError = 'No se pudo leer el archivo: ' . $e->getMessage(); + return; + } + + $fields = $this->parseRows($rows); + + if (empty($fields)) { + $this->importError = 'No se encontraron filas de datos válidas. Revisa que el archivo tenga el formato correcto.'; + return; + } + + $this->importPreviewFields = $fields; + $this->dispatch('notify', count($fields) . ' campos detectados. Revisa la vista previa.'); + } + + public function confirmImportFile() + { + if (empty($this->importPreviewFields) || empty($this->importTemplateName)) return; + + InspectionTemplate::create([ + 'name' => $this->importTemplateName, + 'description' => 'Importado desde archivo', + 'project_id' => $this->project->id, + 'phase_id' => null, + 'fields' => array_values($this->importPreviewFields), + ]); + + $this->showImportFileModal = false; + $this->importPreviewFields = []; + $this->importTemplateName = ''; + $this->importFile = null; + $this->loadTemplates(); + $this->dispatch('notify', 'Template importado correctamente desde archivo'); + } + + private function readFileRows(): array + { + $ext = strtolower($this->importFile->getClientOriginalExtension()); + $path = $this->importFile->getRealPath(); + + if ($ext === 'xlsx' || $ext === 'xls') { + $spreadsheet = IOFactory::load($path); + $sheet = $spreadsheet->getActiveSheet(); + $rows = $sheet->toArray(null, true, true, false); + array_shift($rows); // quitar cabecera + return array_filter($rows, fn($r) => !empty($r[0])); + } + + // CSV / TXT + $rows = []; + $handle = fopen($path, 'r'); + // Detectar y descartar BOM UTF-8 + $bom = fread($handle, 3); + if ($bom !== "\xEF\xBB\xBF") rewind($handle); + + fgetcsv($handle); // cabecera + while (($row = fgetcsv($handle)) !== false) { + if (!empty($row[0])) $rows[] = $row; + } + fclose($handle); + return $rows; + } + + private function parseRows(array $rows): array + { + $fields = []; + foreach ($rows as $row) { + $row = array_values((array) $row); + $rawName = trim($row[0] ?? ''); + if ($rawName === '') continue; + + $fields[] = [ + 'name' => $this->slugify($rawName), + 'label' => trim($row[1] ?? $rawName), + 'type' => $this->normalizeType($row[2] ?? 'text'), + 'required' => in_array(strtolower(trim($row[3] ?? '0')), ['1', 'si', 'sí', 'yes', 'true']), + 'options' => trim($row[4] ?? ''), + 'min' => $row[5] !== '' && $row[5] !== null ? $row[5] : null, + 'max' => $row[6] !== '' && $row[6] !== null ? $row[6] : null, + 'step' => $row[7] !== '' && $row[7] !== null ? $row[7] : null, + ]; + } + return $fields; + } + + private function slugify(string $str): string + { + $str = mb_strtolower(trim($str)); + $str = preg_replace('/\s+/', '_', $str); + $str = preg_replace('/[^a-z0-9_]/i', '', $str); + return trim($str, '_') ?: 'campo'; + } + + private function normalizeType(string $type): string + { + $map = [ + 'texto' => 'text', 'text' => 'text', 'string' => 'text', 'corto' => 'text', + 'textarea' => 'textarea', 'largo' => 'textarea', 'parrafo' => 'textarea', + 'integer' => 'integer', 'entero' => 'integer', 'int' => 'integer', 'numero' => 'integer', + 'decimal' => 'decimal', 'float' => 'decimal', 'number' => 'decimal', 'numerico' => 'decimal', + 'percentage' => 'percentage', 'porcentaje' => 'percentage', 'pct' => 'percentage', '%' => 'percentage', + 'boolean' => 'boolean', 'bool' => 'boolean', 'checkbox' => 'boolean', 'sino' => 'boolean', 'si/no' => 'boolean', + 'date' => 'date', 'fecha' => 'date', + 'select' => 'select', 'lista' => 'select', 'dropdown' => 'select', 'opciones' => 'select', + ]; + return $map[strtolower(trim($type))] ?? 'text'; + } + + // ── Importar desde otro proyecto ────────────────────────────────────── + + public function openImportProjectModal() + { + $user = Auth::user(); + $this->availableProjects = Project::accessibleBy($user) + ->where('id', '!=', $this->project->id) + ->orderBy('name') + ->get(); + + $this->importProjectId = null; + $this->importableTemplates = []; + $this->selectedImportTemplateIds = []; + $this->showImportProjectModal = true; + } + + public function updatedImportProjectId() + { + $this->selectedImportTemplateIds = []; + if (!$this->importProjectId) { + $this->importableTemplates = []; + return; + } + // Solo mostrar templates de proyectos accesibles + $user = Auth::user(); + $allowed = Project::accessibleBy($user)->pluck('id'); + if (!$allowed->contains($this->importProjectId)) { + $this->importableTemplates = []; + return; + } + $this->importableTemplates = InspectionTemplate::where('project_id', $this->importProjectId)->get(); + } + + public function importFromProject() + { + if (empty($this->selectedImportTemplateIds)) { + $this->dispatch('notify', 'Selecciona al menos un template.'); + return; + } + + // Verificar que los templates pertenecen a un proyecto accesible + $user = Auth::user(); + $allowed = Project::accessibleBy($user)->pluck('id'); + + $imported = 0; + foreach ($this->selectedImportTemplateIds as $templateId) { + $source = InspectionTemplate::find($templateId); + if (!$source || !$allowed->contains($source->project_id)) continue; + + // Evitar duplicados por nombre + $name = $source->name; + if (InspectionTemplate::where('project_id', $this->project->id)->where('name', $name)->exists()) { + $name .= ' (copia)'; + } + + InspectionTemplate::create([ + 'name' => $name, + 'description' => $source->description, + 'project_id' => $this->project->id, + 'phase_id' => null, + 'fields' => $source->fields, + ]); + $imported++; + } + + $this->showImportProjectModal = false; + $this->importProjectId = null; + $this->importableTemplates = []; + $this->selectedImportTemplateIds = []; + $this->loadTemplates(); + $this->dispatch('notify', "$imported template(s) importado(s) desde otro proyecto"); } public function render() diff --git a/app/Models/User.php b/app/Models/User.php index 832f15d..ce8163a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -20,11 +20,10 @@ class User extends Authenticatable * @var list */ protected $fillable = [ - 'name', - 'email', - 'password', // Intentionally kept: required for registration factory and seeding. - // Sensitive — never pass unvalidated user input directly. - // email_verified_at and remember_token are intentionally excluded. + 'name', 'title', 'first_name', 'last_name', + 'email', 'password', + 'status', 'valid_from', 'valid_until', + 'company_id', 'phone', 'address', 'notes', ]; /** @@ -46,9 +45,16 @@ class User extends Authenticatable { return [ 'email_verified_at' => 'datetime', - 'password' => 'hashed', + 'password' => 'hashed', + 'valid_from' => 'date', + 'valid_until' => 'date', ]; } + + public function company() + { + return $this->belongsTo(\App\Models\Company::class); + } // Many-to-many with projects public function projects() { diff --git a/config/session.php b/config/session.php index 1c530fa..5b541b7 100644 --- a/config/session.php +++ b/config/session.php @@ -169,7 +169,7 @@ return [ | */ - 'secure' => env('SESSION_SECURE_COOKIE', env('APP_ENV', 'production') === 'production'), + 'secure' => env('SESSION_SECURE_COOKIE'), /* |-------------------------------------------------------------------------- diff --git a/lang/en.json b/lang/en.json index ba6c8aa..8d6a390 100644 --- a/lang/en.json +++ b/lang/en.json @@ -395,7 +395,5 @@ "My Projects": "My Projects", "Editable": "Editable", "Name of responsible": "Name of responsible", - "Select template...": "Select template...", - "View all": "View all", - "View on map": "View on map" + "Select template...": "Select template..." } diff --git a/lang/es.json b/lang/es.json index 29a93b7..c1aff74 100644 --- a/lang/es.json +++ b/lang/es.json @@ -395,7 +395,5 @@ "My Projects": "Mis proyectos", "Editable": "Editable", "Name of responsible": "Nombre del responsable", - "Select template...": "Seleccionar plantilla...", - "View all": "Ver todos", - "View on map": "Ver en mapa" + "Select template...": "Seleccionar plantilla..." } diff --git a/resources/views/livewire/layers/layer-manager.blade.php b/resources/views/livewire/layers/layer-manager.blade.php index a68127c..8462c3a 100644 --- a/resources/views/livewire/layers/layer-manager.blade.php +++ b/resources/views/livewire/layers/layer-manager.blade.php @@ -22,7 +22,7 @@
-
+
@error('uploadFile') {{ $message }} @enderror
@@ -49,13 +49,13 @@
- - + +
@endforeach @if($layers->isEmpty()) -

{{ __("No results") }}. Crea una o importa.

+

{{ __("No layers. Create or import one.") }}

@endif @@ -69,8 +69,8 @@

{{ __("Edit") }}

@if($selectedLayer)
- - + +
@endif
@@ -88,17 +88,6 @@ let allLayersData = {}; // id -> {geojson, color} let visibleLayerIds = []; - // XSS-safe HTML escaping for user-supplied data rendered in Leaflet popups - function escapeHtml(text) { - if (text === null || text === undefined) return ''; - return String(text) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - // Inicialización del mapa function initMap() { if (map) return; @@ -148,9 +137,9 @@ style: { color: data.color, weight: 3, opacity: 0.8, fillOpacity: 0.3 }, onEachFeature: (feature, layer) => { const props = feature.properties; - const content = `${escapeHtml(props.name) || 'Elemento'}
- Progreso: ${escapeHtml(props.progress) || 0}%
- Responsable: ${escapeHtml(props.responsible) || '-'}`; + const content = `${props.name || 'Elemento'}
+ Progreso: ${props.progress || 0}%
+ Responsable: ${props.responsible || '-'}`; layer.bindPopup(content); } }).addTo(displayGroup); @@ -169,10 +158,10 @@ onEachFeature: (f, l) => { l.feature = f; const props = f.properties; - const content = `${escapeHtml(props.name) || 'Elemento'}
- Progreso: ${escapeHtml(props.progress) || 0}%
- Responsable: ${escapeHtml(props.responsible) || '-'}
- Editable`; + const content = `${props.name || @js(__('Feature'))}
+ @js(__('Progress')): ${props.progress || 0}%
+ @js(__('Responsible')): ${props.responsible || '-'}
+ @js(__('Editable'))`; l.bindPopup(content); } }); diff --git a/resources/views/livewire/projects/project-dashboard.blade.php b/resources/views/livewire/projects/project-dashboard.blade.php index a1f4600..b776b2a 100644 --- a/resources/views/livewire/projects/project-dashboard.blade.php +++ b/resources/views/livewire/projects/project-dashboard.blade.php @@ -1,4 +1,4 @@ -
+
@if($recentIssues->isEmpty())
@@ -350,7 +346,7 @@ Inspecciones recientes - {{ __('View on map') }} + Ver en mapa
@if($recentInspections->isEmpty())
diff --git a/resources/views/livewire/projects/project-form.blade.php b/resources/views/livewire/projects/project-form.blade.php index 6d2324e..3d944ae 100644 --- a/resources/views/livewire/projects/project-form.blade.php +++ b/resources/views/livewire/projects/project-form.blade.php @@ -2,40 +2,27 @@

{{ $projectId ? __('Edit Project') : __('New Project') }}

- @if($errors->any()) -
- -
    - @foreach($errors->all() as $e)
  • {{ $e }}
  • @endforeach -
-
- @endif -
- - @error('name') {{ $message }} @enderror +
- - @error('address') {{ $message }} @enderror +
- - + +
- - - @error('start_date') {{ $message }} @enderror + +
- - - @error('end_date_estimated') {{ $message }} @enderror + +
@@ -45,14 +32,13 @@ - @error('status') {{ $message }} @enderror
-

{{ __('Project Location') }}

+

{{ __('Location') }}

- {{ __('Click on the map to set the project location. The address and country will be filled automatically.') }} + {{ __('Click on the map or drag the marker to update the location') }}

@@ -61,7 +47,7 @@
+ 📁 {{ __('Project files') }} +
- +
-

{{ __("Project Map") }}

-
- - -
- - - - + + +
@@ -129,14 +96,14 @@ @if($selectedFeature)
-

{{ $selectedFeature->name ?? 'Elemento' }}

-

Fase: {{ $selectedFeature->layer?->phase?->name ?? '—' }}

-

Capa: {{ $selectedFeature->layer?->name ?? '—' }}

+

{{ $selectedFeature->name ?? __('Feature') }}

+

{{ __('Phase') }}: {{ $selectedFeature->layer?->phase?->name ?? '—' }}

+

{{ __('Layer') }}: {{ $selectedFeature->layer?->name ?? '—' }}

- {{-- {{ __("Progress") }} --}} + {{-- Progreso --}}
- +
0%50%100% @@ -144,18 +111,18 @@
- - + +
{{-- Gestor de archivos del feature --}}
- 📎 {{ __("Files of element") }} + 📎 {{ __('Files of element') }}
@livewire('media-manager', [ @@ -167,11 +134,11 @@ {{-- Templates / Inspecciones --}} @if($templates->isNotEmpty()) -
{{ __("Inspection") }}
+
{{ __('Inspection') }}
- + - + @foreach(explode(',', $field['options'] ?? '') as $opt) @endforeach @@ -211,21 +178,21 @@ @endswitch
@endforeach - + @endif @endif - {{-- {{ __("History") }} de inspecciones --}} + {{-- Historial de inspecciones --}} @if($inspectionHistory->isNotEmpty()) -
{{ __("History") }}
+
{{ __('History') }}
@foreach($inspectionHistory as $ins) -
+
- {{ $ins->template?->name ?? __("Inspection") }} + {{ $ins->template?->name ?? __('Inspection') }} {{ $ins->created_at->diffForHumans() }}
- @if($ins->user)por {{ $ins->user->name }}@endif + @if($ins->user){{ __('by') }} {{ $ins->user->name }}@endif
@endforeach
@@ -236,16 +203,16 @@
-

{{ __("No templates yet") }}

-
{{ __("Create an inspection template") }}.
+

{{ __('No templates yet') }}

+
{{ __('Create an inspection template') }}.
- {{ __("Create") }} + {{ __('Create') }}
@endif @else

👆

-

Haz clic en un elemento del mapa para editarlo

+

{{ __('Click on a map element or search above to edit it') }}

@endif @elseif($activeTab === 'features') @@ -255,12 +222,12 @@ - - - - - - + + + + + + @@ -284,7 +251,7 @@ @else

📋

-

{{ __("No features found") }}

+

{{ __('No elements in this project') }}

@endif @elseif($activeTab === 'inspections') @@ -294,10 +261,10 @@
{{ __("Feature") }}{{ __("Layer") }}{{ __("Phase") }}{{ __("Progress") }}{{ __("Responsible") }}{{ __("Template") }}{{ __('Feature') }}{{ __('Layer') }}{{ __('Phase') }}{{ __('Progress') }}{{ __('Responsible') }}{{ __('Template') }}
- - - - + + + + @@ -319,16 +286,14 @@ @else

📋

-

{{ __("No inspections found") }}

+

{{ __('No inspections registered') }}

@endif - @elseif($activeTab === 'issues') - - @livewire('issue-manager', ['project' => $project], key('issues-tab-' . $project->id)) @endif + @push('styles')
{{ __("Date") }}{{ __("Feature") }}{{ __("Template") }}{{ __("User") }}{{ __('Date') }}{{ __('Feature') }}{{ __('Template') }}{{ __('User') }}