restore: roll back to 7d854ff (stable pre-security state)
Full restore of the7d854ffsnapshot (2026-06-16 18:05, before the security review). Forward commit, no history rewrite —f8a1310and all later commits remain recoverable in history. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+15
-34
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+237
-157
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
+228
-124
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user