restore: roll back to 7d854ff (stable pre-security state)

Full restore of the 7d854ff snapshot (2026-06-16 18:05, before the security
review). Forward commit, no history rewrite — f8a1310 and all later commits
remain recoverable in history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 10:56:25 +02:00
parent 941dbd5997
commit 6e66f707d5
26 changed files with 1196 additions and 1163 deletions
+28 -87
View File
@@ -13,26 +13,14 @@ use Illuminate\Support\Facades\Storage;
class OfflineSyncController extends Controller
{
/**
* Allowed mediable model types (whitelist to prevent RCE via dynamic instantiation).
*/
private const ALLOWED_MEDIABLE_TYPES = [
'project' => \App\Models\Project::class,
'phase' => \App\Models\Phase::class,
'layer' => \App\Models\Layer::class,
'feature' => \App\Models\Feature::class,
'inspection' => \App\Models\Inspection::class,
'issue' => \App\Models\Issue::class,
];
public function storePending(Request $request)
{
$payload = $request->validate([
'action' => 'required|in:progress_update,inspection,feature_create,media_upload,task_complete',
'payload' => 'required|array',
]);
PendingSync::create([
'user_id' => Auth::id(),
$pending = PendingSync::create([
'user_id' => Auth::id() ?? 1,
'action' => $payload['action'],
'payload' => $payload['payload'],
]);
@@ -44,111 +32,65 @@ class OfflineSyncController extends Controller
$user = Auth::user();
$pendings = PendingSync::where('user_id', $user->id)->whereNull('synced_at')->get();
$results = [];
foreach ($pendings as $pending) {
$result = ['id' => $pending->id, 'action' => $pending->action, 'success' => false, 'error' => null];
try {
if ($pending->action === 'progress_update') {
$phaseId = (int) ($pending->payload['phase_id'] ?? 0);
$progress = (int) ($pending->payload['progress'] ?? 0);
$progress = max(0, min(100, $progress));
$phase = Phase::find($phaseId);
$phase = Phase::find($pending->payload['phase_id']);
if ($phase) {
// Verify user has access to this phase's project
if (!$user->hasRole('Admin') && !$phase->project->users()->where('user_id', $user->id)->exists()) {
$result['error'] = 'Access denied to this project.';
} else {
$phase->progress_percent = $progress;
$phase->progress_percent = $pending->payload['progress'];
$phase->save();
$phase->progressUpdates()->create([
'user_id' => $user->id,
'progress_percent' => $progress,
'comment' => substr($pending->payload['comment'] ?? '', 0, 500),
'progress_percent' => $pending->payload['progress'],
'comment' => $pending->payload['comment'] ?? '',
'location' => $pending->payload['location'] ?? null,
]);
}
$result['success'] = true;
}
} else {
$result['error'] = 'Phase not found.';
}
} elseif ($pending->action === 'inspection') {
$p = $pending->payload;
$inspection = Inspection::create([
'project_id' => (int) ($p['project_id'] ?? 0),
'feature_id' => isset($p['feature_id']) ? (int) $p['feature_id'] : null,
'layer_id' => isset($p['layer_id']) ? (int) $p['layer_id'] : null,
'template_id' => isset($p['template_id'])? (int) $p['template_id']: null,
'user_id' => $user->id,
'inspector_user_id' => $user->id,
'status' => 'completed',
'completed_at' => now(),
'result' => in_array($p['result'] ?? '', Inspection::RESULTS) ? $p['result'] : null,
'notes' => substr($p['notes'] ?? '', 0, 2000),
'data' => is_array($p['data'] ?? null) ? $p['data'] : [],
]);
$inspection = Inspection::create($pending->payload);
$result['success'] = true;
$result['data'] = ['inspection_id' => $inspection->id];
} elseif ($pending->action === 'feature_create') {
$p = $pending->payload;
$feature = Feature::create([
'layer_id' => (int) ($p['layer_id'] ?? 0),
'name' => substr($p['name'] ?? 'Elemento', 0, 255),
'geometry' => is_array($p['geometry'] ?? null) ? $p['geometry'] : null,
'properties' => is_array($p['properties'] ?? null) ? $p['properties'] : [],
'template_id' => isset($p['template_id']) ? (int) $p['template_id'] : null,
'progress' => max(0, min(100, (int) ($p['progress'] ?? 0))),
'status' => in_array($p['status'] ?? '', Feature::STATUSES) ? $p['status'] : 'planned',
'responsible' => isset($p['responsible']) ? substr($p['responsible'], 0, 255) : null,
]);
$feature = Feature::create($pending->payload);
$result['success'] = true;
$result['data'] = ['feature_id' => $feature->id];
} elseif ($pending->action === 'media_upload') {
// Assuming payload has: 'file' (base64), 'path', 'model_type', 'model_id'
// We'll decode the base64 and store the file
if (isset($pending->payload['file'], $pending->payload['path'])) {
// Restrict path to safe uploads directory
$safePath = 'uploads/' . ltrim(basename($pending->payload['path']), '/');
$decoded = base64_decode($pending->payload['file'], true);
$decoded = base64_decode($pending->payload['file']);
if ($decoded !== false) {
Storage::disk('public')->put($safePath, $decoded);
// Whitelist-based model type resolution (prevents RCE)
$path = Storage::put($pending->payload['path'], $decoded);
// Attach to model if model_type and model_id are provided
if (isset($pending->payload['model_type'], $pending->payload['model_id'])) {
$typeKey = strtolower(trim($pending->payload['model_type']));
if (array_key_exists($typeKey, self::ALLOWED_MEDIABLE_TYPES)) {
$modelClass = self::ALLOWED_MEDIABLE_TYPES[$typeKey];
$model = $modelClass::find((int) $pending->payload['model_id']);
$model = new $pending->payload['model_type'];
$model = $model->find($pending->payload['model_id']);
if ($model) {
$model->media()->create([
'name' => substr($pending->payload['name'] ?? 'unnamed', 0, 255),
'file_path' => $safePath,
'file_type' => substr($pending->payload['mime_type'] ?? 'application/octet-stream', 0, 100),
'file_extension' => pathinfo($safePath, PATHINFO_EXTENSION),
'file_size' => strlen($decoded),
'category' => 'other',
'uploaded_by' => $user->id,
'name' => $pending->payload['name'] ?? 'unnamed',
'path' => $path,
'mime_type' => $pending->payload['mime_type'] ?? 'application/octet-stream',
'disk' => 'public',
]);
}
}
}
$result['success'] = true;
$result['data'] = ['path' => $safePath];
$result['data'] = ['path' => $path];
} else {
$result['error'] = 'Failed to decode base64 file.';
$result['error'] = 'Failed to decode base64 file';
}
} else {
$result['error'] = 'Missing file or path in payload.';
$result['error'] = 'Missing file or path in payload';
}
} elseif ($pending->action === 'task_complete') {
// No-op placeholder, just mark as synced
// Example: mark a task as complete (you can adjust as needed)
// For now, just log and mark as success
\Log::info('Task completed offline', $pending->payload);
$result['success'] = true;
} else {
$result['error'] = 'Unknown action type.';
$result['error'] = 'Unknown action type';
}
} catch (\Exception $e) {
$result['error'] = $e->getMessage();
@@ -161,7 +103,6 @@ class OfflineSyncController extends Controller
$results[] = $result;
}
return response()->json(['synced' => $results]);
}
}
@@ -11,25 +11,21 @@ use App\Exports\ProjectsExport;
use App\Exports\PhasesExport;
use App\Exports\InspectionsExport;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class ExportController extends Controller
{
public function exportProjects(Request $request)
{
Gate::authorize('manage all');
return Excel::download(new ProjectsExport, 'projects.xlsx');
}
public function exportPhases(Request $request)
{
Gate::authorize('manage all');
return Excel::download(new PhasesExport, 'phases.xlsx');
}
public function exportInspections(Request $request)
{
Gate::authorize('manage all');
return Excel::download(new InspectionsExport, 'inspections.xlsx');
}
}
+14 -33
View File
@@ -9,53 +9,34 @@ 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()
+79 -71
View File
@@ -4,7 +4,11 @@ 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
{
@@ -21,6 +25,7 @@ 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')
@@ -31,23 +36,9 @@ class ClientProjects extends Component
->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) {
@@ -74,90 +61,111 @@ class ClientProjects extends Component
$this->projectDetails = [
'id' => $project->id,
'name' => $project->name,
'description'=> $project->description ?? '',
'description' => $project->description,
'start_date' => $project->start_date,
'end_date' => $project->end_date_estimated,
'end_date' => $project->end_date,
'status' => $project->status,
'progress' => round($project->phases->avg('progress_percent') ?? 0),
'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) => [
->map(function($media) {
return [
'url' => $media->url,
'title' => $media->name,
'date' => $media->created_at->format('d/m/Y'),
])
'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) => [
->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,
])
->values()
'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();
if (!$changeOrder) {
abort(403);
}
// 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(),
]);
// 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();
// Refresh the change orders list
$this->loadProjectDetails();
// Notify any listeners (optional)
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'approved']);
}
}
}
public function rejectChangeOrder($orderId)
{
$changeOrder = ChangeOrder::where('id', $orderId)
->where('project_id', $this->selectedProject)
->first();
if (!$changeOrder) {
abort(403);
}
// 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(),
]);
// 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();
// Refresh the change orders list
$this->loadProjectDetails();
// Notify any listeners (optional)
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'rejected']);
}
}
}
public function render()
{
+32 -206
View File
@@ -3,235 +3,61 @@
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 . '%');
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, function ($query) {
$query->where('type', $this->filterType);
})
->when($this->filterEstado, function ($query) {
$query->where('estado', $this->filterEstado);
})
->withCount('projects') // Eager load project count
->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) {
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()
+212 -132
View File
@@ -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')]
@@ -22,101 +24,106 @@ class LayerManager extends Component
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;
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;
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,
'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,119 +134,105 @@ 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',
'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;
try {
DB::transaction(function () use ($geojson, $layerColor, $layerName, $user) {
$path = $this->uploadFile->store(
"uploads/projects/{$this->project->id}/layers", 'public'
);
$layer = Layer::create([
'project_id' => $this->project->id,
'phase_id' => $this->phase->id,
'name' => $this->layerName,
'name' => $layerName,
'color' => $layerColor,
'original_file' => $originalPath,
'original_file' => $path,
'uploaded_by' => $user->id,
]);
// Crear features a partir del GeoJSON
if (isset($geojson['features'])) {
foreach ($geojson['features'] as $featureData) {
$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' => $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,
'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,
@@ -248,65 +241,152 @@ class LayerManager extends Component
'original_file' => null,
'uploaded_by' => $user->id,
]);
$this->loadLayers();
$this->visibleLayers[] = $layer->id;
$this->selectLayer($layer->id);
$this->emitInitialLayersData();
session()->flash('message', 'Capa vacía creada. 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;
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;
// 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,
'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;
+19 -61
View File
@@ -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 = [];
// Subida
public $uploadFiles = [];
public $uploadDescription = '';
public $uploadCategory = 'image';
// 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',
];
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,43 +77,22 @@ 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();
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);
$name = $file->getClientOriginalName();
// 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";
$path = $file->store($dir, 'public');
@@ -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,
]);
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,12 +142,9 @@ 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;
}
+2 -15
View File
@@ -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;
}
public function addPhase()
{
Gate::authorize('edit projects', $this->project);
$this->project->phases()->create([
'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,16 +30,8 @@ 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()
-10
View File
@@ -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();
-9
View File
@@ -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, [
-3
View File
@@ -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,7 +29,6 @@ class ProjectEditTabs extends Component
public function updateProject()
{
Gate::authorize('edit projects', $this->project);
$this->project->save();
session()->flash('message', __('Project updated successfully.'));
+3 -7
View File
@@ -16,16 +16,12 @@ 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;
}
// Scope to accessible projects to prevent IDOR (deleting another user's project by ID)
$project = Project::accessibleBy($user)->findOrFail($id);
$project = Project::findOrFail($id);
if (Auth::user()->can('delete projects')) {
$project->delete();
session()->flash('message', 'Proyecto eliminado');
}
}
public function render()
{
+206 -102
View File
@@ -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->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();
}
$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->loadTemplates();
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,74 +105,113 @@ 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,
'progress_percent' => $phase->progress_percent,
'comment' => $comment,
]);
$this->dispatch('progressUpdated', $featureId, $feature->progress);
$this->dispatch('notify', 'Progreso actualizado');
// Si el feature seleccionado es el mismo, actualizar la propiedad local
if ($this->selectedFeature && $this->selectedFeature->id == $featureId) {
$this->selectedFeature->progress = $feature->progress;
$this->editProgress = $feature->progress;
}
}
/**
* Seleccionar un Feature al hacer clic en el mapa.
*/
public function selectFeature($featureId)
{
$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;
@@ -138,16 +220,14 @@ class ProjectMap extends Component
$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.");
@@ -203,33 +280,52 @@ class ProjectMap extends Component
'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 ($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->loadInspectionHistory();
$this->resetInspectionForm();
$this->dispatch('notify', 'Inspección guardada correctamente');
}
/**
* Asignar un template al feature seleccionado.
*/
// 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();
}
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->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,35 +393,22 @@ 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;
$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']) {
@@ -330,9 +431,12 @@ 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()
-9
View File
@@ -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();
}
+311 -36
View File
@@ -3,24 +3,45 @@
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' => '',
'description' => '',
'phase_id' => null,
'fields' => [],
];
// ── 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',
@@ -32,14 +53,8 @@ class TemplateManager extends Component
'select' => 'Lista desplegable',
];
protected $listeners = ['showTemplateForm' => 'newTemplate'];
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;
}
@@ -95,7 +116,7 @@ class TemplateManager extends Component
'name' => '',
'label' => '',
'type' => 'text',
'options' => [],
'options' => '',
'required' => false,
'min' => null,
'max' => null,
@@ -117,26 +138,20 @@ class TemplateManager extends Component
'form.fields' => 'array',
]);
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');
} else {
InspectionTemplate::create([
$data = [
'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');
'phase_id' => $this->form['phase_id'] ?: null,
'fields' => array_values($this->form['fields']),
];
if ($this->editingTemplate) {
InspectionTemplate::findOrFail($this->editingTemplate)->update($data);
$this->dispatch('notify', 'Template actualizado correctamente');
} else {
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()
+11 -5
View File
@@ -20,11 +20,10 @@ class User extends Authenticatable
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password', // Intentionally kept: required for registration factory and seeding.
// Sensitive — never pass unvalidated user input directly.
// email_verified_at and remember_token are intentionally excluded.
'name', 'title', 'first_name', 'last_name',
'email', 'password',
'status', 'valid_from', 'valid_until',
'company_id', 'phone', 'address', 'notes',
];
/**
@@ -47,8 +46,15 @@ class User extends Authenticatable
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'valid_from' => 'date',
'valid_until' => 'date',
];
}
public function company()
{
return $this->belongsTo(\App\Models\Company::class);
}
// Many-to-many with projects
public function projects()
{
+1 -1
View File
@@ -169,7 +169,7 @@ return [
|
*/
'secure' => env('SESSION_SECURE_COOKIE', env('APP_ENV', 'production') === 'production'),
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
+1 -3
View File
@@ -395,7 +395,5 @@
"My Projects": "My Projects",
"Editable": "Editable",
"Name of responsible": "Name of responsible",
"Select template...": "Select template...",
"View all": "View all",
"View on map": "View on map"
"Select template...": "Select template..."
}
+1 -3
View File
@@ -395,7 +395,5 @@
"My Projects": "Mis proyectos",
"Editable": "Editable",
"Name of responsible": "Nombre del responsable",
"Select template...": "Seleccionar plantilla...",
"View all": "Ver todos",
"View on map": "Ver en mapa"
"Select template...": "Seleccionar plantilla..."
}
@@ -22,7 +22,7 @@
<input type="color" wire:model="layerColor" class="input input-bordered">
</div>
<div class="form-control">
<label class="label">Archivo (GeoJSON, KML, Shapefile.zip, DWG)</label></br>
<label class="label">{{ __('File') }} (GeoJSON, KML, Shapefile.zip, DWG)</label></br>
<input type="file" wire:model="uploadFile" class="file-input file-input-bordered">
@error('uploadFile') <span class="text-error">{{ $message }}</span> @enderror
</div>
@@ -49,13 +49,13 @@
</span>
</div>
<div>
<button wire:click="selectLayer({{ $layer->id }})" class="btn btn-xs btn-info">✏️ Editar</button>
<button wire:click="deleteLayer({{ $layer->id }})" class="btn btn-xs btn-error" onclick="return confirm('¿{{ __("Delete layer") }}?')">🗑️</button>
<button wire:click="selectLayer({{ $layer->id }})" class="btn btn-xs btn-info">✏️ {{ __('Edit') }}</button>
<button wire:click="deleteLayer({{ $layer->id }})" wire:confirm="{{ __('Delete layer') }}?" class="btn btn-xs btn-error">🗑️</button>
</div>
</div>
@endforeach
@if($layers->isEmpty())
<p class="text-center">{{ __("No results") }}. Crea una o importa.</p>
<p class="text-center">{{ __("No layers. Create or import one.") }}</p>
@endif
</div>
</div>
@@ -69,8 +69,8 @@
<h2 class="card-title">{{ __("Edit") }}</h2>
@if($selectedLayer)
<div class="mt-3 flex gap-2">
<button id="saveDrawingBtn" class="btn btn-primary">💾 Guardar cambios</button>
<button wire:click="cancelEditing" class="btn btn-outline">Cancelar edición</button>
<button id="saveDrawingBtn" class="btn btn-primary">💾 {{ __('Save changes') }}</button>
<button wire:click="cancelEditing" class="btn btn-outline">{{ __('Cancel') }}</button>
</div>
@endif
<div id="permanentMap" style="flex: 1; min-height: 500px; width: 100%; background: #e2e8f0;" wire:ignore></div>
@@ -88,17 +88,6 @@
let allLayersData = {}; // id -> {geojson, color}
let visibleLayerIds = [];
// XSS-safe HTML escaping for user-supplied data rendered in Leaflet popups
function escapeHtml(text) {
if (text === null || text === undefined) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Inicialización del mapa
function initMap() {
if (map) return;
@@ -148,9 +137,9 @@
style: { color: data.color, weight: 3, opacity: 0.8, fillOpacity: 0.3 },
onEachFeature: (feature, layer) => {
const props = feature.properties;
const content = `<b>${escapeHtml(props.name) || 'Elemento'}</b><br>
Progreso: ${escapeHtml(props.progress) || 0}%<br>
Responsable: ${escapeHtml(props.responsible) || '-'}`;
const content = `<b>${props.name || 'Elemento'}</b><br>
Progreso: ${props.progress || 0}%<br>
Responsable: ${props.responsible || '-'}`;
layer.bindPopup(content);
}
}).addTo(displayGroup);
@@ -169,10 +158,10 @@
onEachFeature: (f, l) => {
l.feature = f;
const props = f.properties;
const content = `<b>${escapeHtml(props.name) || 'Elemento'}</b><br>
Progreso: ${escapeHtml(props.progress) || 0}%<br>
Responsable: ${escapeHtml(props.responsible) || '-'}<br>
<em>Editable</em>`;
const content = `<b>${props.name || @js(__('Feature'))}</b><br>
@js(__('Progress')): ${props.progress || 0}%<br>
@js(__('Responsible')): ${props.responsible || '-'}<br>
<em>@js(__('Editable'))</em>`;
l.bindPopup(content);
}
});
@@ -1,4 +1,4 @@
<div>
<div>
<x-slot name="header">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
@@ -25,19 +25,15 @@
<div class="flex gap-2">
<a href="{{ route('projects.map', $project) }}" class="btn btn-outline btn-sm gap-1">
<x-heroicon-o-map class="w-4 h-4" />
{{ __('Map') }}
Mapa
</a>
<a href="{{ route('projects.gantt', $project) }}" class="btn btn-outline btn-sm gap-1">
<x-heroicon-o-calendar-days class="w-4 h-4" />
{{ __('Gantt') }}
</a>
<a href="{{ route('projects.report', $project) }}" class="btn btn-outline btn-sm gap-1">
<x-heroicon-o-document-chart-bar class="w-4 h-4" />
{{ __('Report') }}
Gantt
</a>
<a href="{{ route('projects.issues', $project) }}" class="btn btn-outline btn-sm gap-1">
<x-heroicon-o-exclamation-triangle class="w-4 h-4" />
{{ __('Issues') }}
Issues
</a>
</div>
</div>
@@ -307,7 +303,7 @@
<x-heroicon-o-exclamation-triangle class="w-4 h-4 text-orange-500" />
Issues abiertos
</h3>
<a href="{{ route('projects.issues', $project) }}" class="btn btn-xs btn-outline">{{ __('View all') }}</a>
<a href="{{ route('projects.issues', $project) }}" class="btn btn-xs btn-outline">Ver todos</a>
</div>
@if($recentIssues->isEmpty())
<div class="text-center py-4 text-gray-400">
@@ -350,7 +346,7 @@
<x-heroicon-o-clipboard-document-list class="w-4 h-4 text-yellow-500" />
Inspecciones recientes
</h3>
<a href="{{ route('projects.map', $project) }}?tab=inspections" class="btn btn-xs btn-outline">{{ __('View on map') }}</a>
<a href="{{ route('projects.map', $project) }}?tab=inspections" class="btn btn-xs btn-outline">Ver en mapa</a>
</div>
@if($recentInspections->isEmpty())
<div class="text-center py-4 text-gray-400">
@@ -2,40 +2,27 @@
<div class="max-w-2xl mx-auto p-4">
<h1 class="text-2xl font-bold mb-6">{{ $projectId ? __('Edit Project') : __('New Project') }}</h1>
@if($errors->any())
<div class="alert alert-error text-sm mb-4">
<x-heroicon-o-exclamation-circle class="w-5 h-5 shrink-0" />
<ul class="list-disc pl-3 space-y-0.5">
@foreach($errors->all() as $e) <li>{{ $e }}</li> @endforeach
</ul>
</div>
@endif
<form wire:submit.prevent="save" class="space-y-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium mb-2">{{ __('Name') }}</label>
<input type="text" wire:model="name" class="input input-bordered w-full {{ $errors->has('name') ? 'input-error' : '' }}" placeholder="{{ __('Project name') }}" required>
@error('name') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
<input type="text" wire:model="name" class="input input-bordered w-full" placeholder="{{ __('Project name') }}" required>
</div>
<div>
<label class="block text-sm font-medium mb-2">{{ __('Address') }}</label>
<input type="text" wire:model="address" class="input input-bordered w-full" placeholder="{{ __('Street address, city, etc.') }}">
@error('address') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
<input type="text" wire:model="address" class="input input-bordered w-full" placeholder="{{ __('Address') }}">
</div>
<div>
<label class="block text-sm font-medium mb-2">{{ __('Country') }}</label>
<input type="text" wire:model="country" class="input input-bordered w-full" placeholder="{{ __('Country (auto-filled)') }}" readonly>
<label class="block text-sm font-medium mb-2">{{ __('Coordinates') }}</label>
<input type="text" wire:model="country" class="input input-bordered w-full" placeholder="{{ __('No country') }}" readonly>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium mb-2">{{ __('Start Date') }}</label>
<input type="date" wire:model="start_date" class="input input-bordered w-full {{ $errors->has('start_date') ? 'input-error' : '' }}" required>
@error('start_date') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
<label class="block text-sm font-medium mb-2">{{ __('Start date') }}</label>
<input type="date" wire:model="start_date" class="input input-bordered w-full" required>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium mb-2">{{ __('End Date (estimated)') }}</label>
<input type="date" wire:model="end_date_estimated" class="input input-bordered w-full {{ $errors->has('end_date_estimated') ? 'input-error' : '' }}">
@error('end_date_estimated') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
<label class="block text-sm font-medium mb-2">{{ __('Estimated end date') }}</label>
<input type="date" wire:model="end_date_estimated" class="input input-bordered w-full">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium mb-2">{{ __('Status') }}</label>
@@ -45,14 +32,13 @@
<option value="paused">{{ __('Paused') }}</option>
<option value="completed">{{ __('Completed') }}</option>
</select>
@error('status') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
</div>
</div>
<div class="border rounded-lg p-4">
<h2 class="text-xl font-bold mb-4">{{ __('Project Location') }}</h2>
<h2 class="text-xl font-bold mb-4">{{ __('Location') }}</h2>
<p class="text-sm text-gray-500 mb-2">
{{ __('Click on the map to set the project location. The address and country will be filled automatically.') }}
{{ __('Click on the map or drag the marker to update the location') }}
</p>
<div id="projectMap" style="height: 400px; width: 100%; background: #e2e8f0; border-radius: 0.5rem;"></div>
<input type="hidden" wire:model="lat">
@@ -61,7 +47,7 @@
<div class="flex justify-end space-x-4">
<button type="button" wire:click="resetForm" class="btn btn-outline">
{{ __('Reset') }}
{{ __('Cancel') }}
</button>
<button type="submit" class="btn btn-primary">
{{ $projectId ? __('Update') : __('Create') }}
@@ -6,7 +6,7 @@
<!-- Panel lateral de capas -->
<div class="absolute top-2 right-2 z-[1000] bg-base-100 rounded-box shadow-xl p-4 w-72 border border-base-300 text-sm max-h-[calc(100vh-4rem)] overflow-y-auto">
<h3 class="font-semibold text-base mb-2">{{ __("Fases and layers") }}</h3>
<h3 class="font-semibold text-base mb-2">{{ __('Phases and layers') }}</h3>
<div class="space-y-3">
@foreach($phases as $phase)
<div class="border rounded-lg p-2 {{ in_array($phase->id, $activeLayers) ? 'bg-base-200' : '' }}">
@@ -27,7 +27,7 @@
<div class="flex items-center gap-1 text-xs text-gray-600">
<span class="w-2 h-2 rounded-full inline-block" style="background: {{ $layer->color ?? '#ccc' }}"></span>
<span class="flex-1 truncate">{{ $layer->name }}</span>
<span class="badge badge-xs">{{ $layer->features_count ?? $layer->features->count() }} elem.</span>
<span class="badge badge-xs">{{ $layer->features_count ?? $layer->features->count() }} {{ __('elem.') }}</span>
</div>
@endforeach
</div>
@@ -36,10 +36,10 @@
{{-- Botón para ir a gestión de capas de esta fase --}}
<div class="mt-1 ml-7">
<a href="{{ route('layers.manage', [$project, $phase]) }}" class="btn btn-xs btn-outline btn-primary">
✏️ {{ __("Manage Layers") }}
✏️ {{ __('Manage Layers') }}
</a>
<a href="{{ route('phases.progress', $phase) }}" class="btn btn-xs btn-outline">
📊 {{ __("Progress") }}
📊 {{ __('Progress') }}
</a>
</div>
</div>
@@ -50,7 +50,7 @@
<div class="mt-3">
<label class="flex items-center gap-2 text-xs cursor-pointer">
<input type="checkbox" wire:change="toggleFeatureImages" @if($showFeatureImages) checked @endif class="checkbox checkbox-xs checkbox-primary" />
🖼️ {{ __("Show images on map") }}
🖼️ {{ __('Show images on map') }}
@if($featureImageMarkers)
<span class="badge badge-xs">{{ count($featureImageMarkers) }}</span>
@endif
@@ -60,67 +60,34 @@
{{-- Botones generales --}}
<div class="mt-2 space-y-1">
<a href="{{ route('projects.media', $project) }}" class="btn btn-sm btn-outline w-full">
📁 {{ __("Project files") }}
</button>
📁 {{ __('Project files') }}
</a>
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full">
📍 {{ __("Centered in project") }}
📍 {{ __('Centered in project') }}
</button>
<button onclick="getUserLocation()" class="btn btn-sm btn-outline w-full">
🧭 {{ __("My location") }}
🧭 {{ __('My location') }}
</button>
</div>
</div>
</div>
<!-- Columna derecha: {{ __("Edit") }} de progreso / inspecciones -->
<!-- Columna derecha: {{ __('Edit') }} de progreso / inspecciones -->
<div class="w-full lg:w-1/3 transition-all duration-300" :class="{'lg:w-full': formFullscreen}">
<div class="card bg-base-100 shadow-xl h-full flex flex-col">
<div class="card-body overflow-y-auto flex-1">
<div class="flex justify-between items-center mb-2">
<h2 class="card-title">{{ __("Project Map") }}</h2>
<button wire:click="toggleFullscreen" class="btn btn-circle btn-sm" title="Pantalla completa">
<h2 class="card-title">{{ __('Map') }}</h2>
<button wire:click="toggleFullscreen" class="btn btn-circle btn-sm" title="{{ __('Fullscreen') }}">
<span x-text="formFullscreen ? '✕' : '⤢'"></span>
</button>
</div>
<!-- Project navigation bar -->
<div class="flex flex-wrap gap-1 mb-3">
<a href="{{ route('projects.dashboard', $project) }}"
class="btn btn-xs {{ request()->routeIs('projects.dashboard') ? 'btn-primary' : 'btn-outline' }}">
📊 {{ __('Dashboard') }}
</a>
<a href="{{ route('projects.map', $project) }}"
class="btn btn-xs {{ request()->routeIs('projects.map') ? 'btn-primary' : 'btn-outline' }}">
🗺️ {{ __('Map') }}
</a>
<a href="{{ route('projects.gantt', $project) }}"
class="btn btn-xs {{ request()->routeIs('projects.gantt') ? 'btn-primary' : 'btn-outline' }}">
📅 {{ __('Gantt') }}
</a>
<a href="{{ route('projects.report', $project) }}"
class="btn btn-xs {{ request()->routeIs('projects.report') ? 'btn-primary' : 'btn-outline' }}">
📄 {{ __('Report') }}
</a>
<a href="{{ route('projects.issues', $project) }}"
class="btn btn-xs {{ request()->routeIs('projects.issues') ? 'btn-error' : 'btn-outline' }} gap-1">
⚠️ {{ __('Issues') }}
@if($openIssuesCount > 0)
<span class="badge badge-error badge-xs">{{ $openIssuesCount }}</span>
@endif
</a>
</div>
<!-- Tabs -->
<div class="tabs box mb-4">
<button wire:click="setActiveTab('edit')" class="tab {{ $activeTab === 'edit' ? 'tab-active' : '' }}">{{ __("Edit") }}</button>
<button wire:click="setActiveTab('features')" class="tab {{ $activeTab === 'features' ? 'tab-active' : '' }}">{{ __("Features") }}</button>
<button wire:click="setActiveTab('inspections')" class="tab {{ $activeTab === 'inspections' ? 'tab-active' : '' }}">{{ __("Inspections") }}</button>
<button wire:click="setActiveTab('issues')" class="tab {{ $activeTab === 'issues' ? 'tab-active' : '' }} gap-1">
{{ __('Issues') }}
@if($openIssuesCount > 0)
<span class="badge badge-error badge-xs">{{ $openIssuesCount }}</span>
@endif
</button>
<button wire:click="setActiveTab('edit')" class="tab {{ $activeTab === 'edit' ? 'tab-active' : '' }}">{{ __('Edit') }}</button>
<button wire:click="setActiveTab('features')" class="tab {{ $activeTab === 'features' ? 'tab-active' : '' }}">{{ __('Features') }}</button>
<button wire:click="setActiveTab('inspections')" class="tab {{ $activeTab === 'inspections' ? 'tab-active' : '' }}">{{ __('Inspections') }}</button>
</div>
<!-- Tab Content -->
@@ -129,14 +96,14 @@
@if($selectedFeature)
<!-- Feature seleccionado -->
<div class="border rounded-lg p-3 mb-3 bg-base-200">
<h3 class="font-bold text-sm">{{ $selectedFeature->name ?? 'Elemento' }}</h3>
<p class="text-xs text-gray-500">Fase: {{ $selectedFeature->layer?->phase?->name ?? '—' }}</p>
<p class="text-xs text-gray-500">Capa: {{ $selectedFeature->layer?->name ?? '—' }}</p>
<h3 class="font-bold text-sm">{{ $selectedFeature->name ?? __('Feature') }}</h3>
<p class="text-xs text-gray-500">{{ __('Phase') }}: {{ $selectedFeature->layer?->phase?->name ?? '—' }}</p>
<p class="text-xs text-gray-500">{{ __('Layer') }}: {{ $selectedFeature->layer?->name ?? '—' }}</p>
</div>
{{-- {{ __("Progress") }} --}}
{{-- Progreso --}}
<div class="form-control mb-3">
<label class="label-text">{{ __("Progress") }}: {{ $editProgress }}%</label>
<label class="label-text">{{ __('Progress') }}: {{ $editProgress }}%</label>
<input type="range" min="0" max="100" wire:model.live="editProgress" class="range range-primary range-sm" />
<div class="flex justify-between text-xs">
<span>0%</span><span>50%</span><span>100%</span>
@@ -144,18 +111,18 @@
</div>
<div class="form-control mb-3">
<label class="label-text">{{ __("Responsible") }}</label>
<input type="text" wire:model="editResponsible" class="input input-bordered input-sm" placeholder="Nombre" />
<label class="label-text">{{ __('Responsible') }}</label>
<input type="text" wire:model="editResponsible" class="input input-bordered input-sm" placeholder="{{ __('Name of responsible') }}" />
</div>
<button wire:click="saveFeatureProgress" class="btn btn-primary btn-sm w-full mb-3">
💾 {{ __("Save progress") }}
💾 {{ __('Save progress') }}
</button>
{{-- Gestor de archivos del feature --}}
<details class="mb-3 border rounded-lg">
<summary class="text-xs font-semibold cursor-pointer p-2 bg-base-200 rounded-t-lg">
📎 {{ __("Files of element") }}
📎 {{ __('Files of element') }}
</summary>
<div class="p-2">
@livewire('media-manager', [
@@ -167,11 +134,11 @@
{{-- Templates / Inspecciones --}}
@if($templates->isNotEmpty())
<div class="divider text-xs">{{ __("Inspection") }}</div>
<div class="divider text-xs">{{ __('Inspection') }}</div>
<div class="form-control mb-2">
<label class="label-text">Plantilla</label>
<label class="label-text">{{ __('Template') }}</label>
<select wire:model.live="selectedTemplateId" wire:change="onTemplateChange" class="select select-bordered select-sm">
<option value="">Seleccionar plantilla...</option>
<option value="">{{ __('Select template...') }}</option>
@foreach($templates as $t)
<option value="{{ $t->id }}">{{ $t->name }}</option>
@endforeach
@@ -197,7 +164,7 @@
@break
@case('select')
<select wire:model="inspectionFormData.{{ $field['name'] }}" class="select select-bordered select-sm w-full">
<option value="">Seleccionar</option>
<option value="">{{ __('Select') }}</option>
@foreach(explode(',', $field['options'] ?? '') as $opt)
<option value="{{ trim($opt) }}">{{ trim($opt) }}</option>
@endforeach
@@ -211,21 +178,21 @@
@endswitch
</div>
@endforeach
<button wire:click="saveInspection" class="btn btn-primary btn-xs w-full mt-1">{{ __("Register inspection") }}</button>
<button wire:click="saveInspection" class="btn btn-primary btn-xs w-full mt-1">{{ __('Register inspection') }}</button>
@endif
@endif
{{-- {{ __("History") }} de inspecciones --}}
{{-- Historial de inspecciones --}}
@if($inspectionHistory->isNotEmpty())
<div class="divider text-xs">{{ __("History") }}</div>
<div class="divider text-xs">{{ __('History') }}</div>
<div class="space-y-1 max-h-40 overflow-y-auto">
@foreach($inspectionHistory as $ins)
<div class="border rounded p-2 text-xs">
<div class="border rounded p-2 text-xs" wire:key="hist-{{ $ins->id }}">
<div class="flex justify-between">
<span class="font-medium">{{ $ins->template?->name ?? __("Inspection") }}</span>
<span class="font-medium">{{ $ins->template?->name ?? __('Inspection') }}</span>
<span class="text-gray-400">{{ $ins->created_at->diffForHumans() }}</span>
</div>
@if($ins->user)<span class="text-gray-500">por {{ $ins->user->name }}</span>@endif
@if($ins->user)<span class="text-gray-500">{{ __('by') }} {{ $ins->user->name }}</span>@endif
</div>
@endforeach
</div>
@@ -236,16 +203,16 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="font-bold">{{ __("No templates yet") }}</h3>
<div class="text-xs">{{ __("Create an inspection template") }}.</div>
<h3 class="font-bold">{{ __('No templates yet') }}</h3>
<div class="text-xs">{{ __('Create an inspection template') }}.</div>
</div>
<a href="{{ route('projects.templates', $project) }}" class="btn btn-primary btn-sm">{{ __("Create") }}</a>
<a href="{{ route('projects.templates', $project) }}" class="btn btn-primary btn-sm">{{ __('Create') }}</a>
</div>
@endif
@else
<div class="text-center text-gray-400 py-8">
<p class="text-lg">👆</p>
<p>Haz clic en un elemento del mapa para editarlo</p>
<p>{{ __('Click on a map element or search above to edit it') }}</p>
</div>
@endif
@elseif($activeTab === 'features')
@@ -255,12 +222,12 @@
<table class="table table-sm table-compact">
<thead>
<tr>
<th>{{ __("Feature") }}</th>
<th>{{ __("Layer") }}</th>
<th>{{ __("Phase") }}</th>
<th>{{ __("Progress") }}</th>
<th>{{ __("Responsible") }}</th>
<th>{{ __("Template") }}</th>
<th>{{ __('Feature') }}</th>
<th>{{ __('Layer') }}</th>
<th>{{ __('Phase') }}</th>
<th>{{ __('Progress') }}</th>
<th>{{ __('Responsible') }}</th>
<th>{{ __('Template') }}</th>
<th class="w-16"></th>
</tr>
</thead>
@@ -284,7 +251,7 @@
@else
<div class="text-center text-gray-400 py-8">
<p class="text-lg">📋</p>
<p>{{ __("No features found") }}</p>
<p>{{ __('No elements in this project') }}</p>
</div>
@endif
@elseif($activeTab === 'inspections')
@@ -294,10 +261,10 @@
<table class="table table-sm table-compact">
<thead>
<tr>
<th>{{ __("Date") }}</th>
<th>{{ __("Feature") }}</th>
<th>{{ __("Template") }}</th>
<th>{{ __("User") }}</th>
<th>{{ __('Date') }}</th>
<th>{{ __('Feature') }}</th>
<th>{{ __('Template') }}</th>
<th>{{ __('User') }}</th>
<th class="w-16"></th>
</tr>
</thead>
@@ -319,16 +286,14 @@
@else
<div class="text-center text-gray-400 py-8">
<p class="text-lg">📋</p>
<p>{{ __("No inspections found") }}</p>
<p>{{ __('No inspections registered') }}</p>
</div>
@endif
@elseif($activeTab === 'issues')
<!-- Issues tab: render embedded IssueManager component -->
@livewire('issue-manager', ['project' => $project], key('issues-tab-' . $project->id))
@endif
</div>
</div>
</div>
</div>
@push('styles')
<style>
@@ -408,14 +373,13 @@
onEachFeature: function(feature, layer) {
const props = feature.properties || {};
const featId = props._feature_id || feature.id;
// Escape all user-generated content for HTML context
const safeName = escapeHtml(props.name || 'Elemento');
const safeName = escapeHtml(props.name || '{{ __('Feature') }}');
const safeProgress = escapeHtml(props.progress || 0);
const safeResponsible = escapeHtml(props.responsible || '-');
let content = `<b>${safeName}</b><br>
{{ __("Progress") }}: ${safeProgress}%<br>
{{ __("Responsible") }}: ${safeResponsible}<br>
<button class="btn btn-xs btn-primary mt-1" onclick="selectFeature('${featId}')">✏️ Editar</button>`;
{{ __('Progress') }}: ${safeProgress}%<br>
{{ __('Responsible') }}: ${safeResponsible}<br>
<button class="btn btn-xs btn-primary mt-1" onclick="selectFeature('${featId}')">✏️ {{ __('Edit') }}</button>`;
layer.bindPopup(content);
layer.on('click', function() { selectFeature(featId); });
}
@@ -434,7 +398,7 @@
setTimeout(() => {
map.invalidateSize();
zoomToAllFeatures();
}, 100); // Reduced from 200ms to 100ms
}, 100);
}
function updateCombinedBounds() {
@@ -456,10 +420,7 @@
function zoomToAllFeatures() {
if (!map) return;
// Update combined bounds if needed
updateCombinedBounds();
if (combinedBounds && combinedBounds.isValid()) {
map.fitBounds(combinedBounds, { padding: [20, 20] });
} else {
@@ -475,32 +436,29 @@
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition((pos) => {
const latlng = [pos.coords.latitude, pos.coords.longitude];
L.marker(latlng).addTo(map).bindPopup('Tu ubicación').openPopup();
L.marker(latlng).addTo(map).bindPopup('{{ __('My location') }}').openPopup();
map.setView(latlng, 16);
}, () => alert('No se pudo obtener la ubicación'));
}, () => alert('{{ __('No results') }}'));
} else {
alert('Geolocalización no soportada');
alert('{{ __('No results') }}');
}
}
document.addEventListener('livewire:init', function () {
setTimeout(initMap, 50); // Reduced from 100ms to 50ms
setTimeout(initMap, 50);
Livewire.on('layersUpdated', (activeIds) => {
// Livewire wraps single parameters in an array, so we need to extract the actual data
const ids = Array.isArray(activeIds) ? activeIds[0] : activeIds;
for (let id in layers) {
const lid = parseInt(id);
if (ids.includes(lid)) {
if (!map.hasLayer(layers[id])) {
layers[id].addTo(map);
// Update combined bounds when adding a layer
updateCombinedBounds();
}
} else {
if (map.hasLayer(layers[id])) {
map.removeLayer(layers[id]);
// Update combined bounds when removing a layer
updateCombinedBounds();
}
}
@@ -511,7 +469,6 @@
Livewire.on('centerMap', zoomToAllFeatures);
Livewire.on('mapResize', () => {
if (map) {
// Throttle resize events to prevent excessive calls
if (!this.resizeTimeout) {
this.resizeTimeout = setTimeout(() => {
map.invalidateSize();
@@ -521,14 +478,12 @@
}
});
// Toggle imágenes en mapa
Livewire.on('featureImagesToggled', (show, markers) => {
const m = Array.isArray(markers) ? markers : markers[1];
const s = Array.isArray(show) ? show[0] : show;
if (imageMarkersLayer) {
map.removeLayer(imageMarkersLayer);
imageMarkersLayer = null;
// Update bounds when removing image markers layer
updateCombinedBounds();
}
if (s && m && m.length > 0) {
@@ -540,10 +495,9 @@
iconAnchor: [10, 10]
});
m.forEach(marker => {
// Validate URL and sanitize name for security
const safeUrl = isValidUrl(marker.image_url) ? marker.image_url : '';
const safeName = escapeHtml(marker.image_name || '');
if (safeUrl) { // Only add marker if URL is valid
if (safeUrl) {
const popupContent = `<b>${safeName}</b><br>
<img src="${safeUrl}" class="max-w-[200px] max-h-[150px] rounded cursor-pointer"
onclick="window.openViewer('${safeUrl}', '${safeName}')" />`;
@@ -552,21 +506,16 @@
.addTo(imageMarkersLayer);
}
});
// Update bounds when adding image markers layer
updateCombinedBounds();
}
});
// Modal para ver imagen al hacer clic
window.openViewer = function(url, name) {
// Validate URL and sanitize name for security
if (!isValidUrl(url)) {
console.error('Invalid URL provided to openViewer:', url);
return;
}
const safeName = escapeHtml(name);
if (imageViewerModal) imageViewerModal.remove();
const overlay = document.createElement('div');
overlay.id = 'imageViewerModal';
@@ -1,10 +1,10 @@
<div>
<div class="bg-base-100 p-4 rounded shadow">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">📋 Templates de inspección</h2>
<h2 class="text-xl font-bold">📋 {{ __('Inspection templates') }}</h2>
<div>
<button wire:click="newTemplate" class="btn btn-primary btn-sm">
Nuevo template
{{ __('New template') }}
</button>
</div>
</div>
@@ -25,16 +25,15 @@
</td>
<td class="py-3">
<input type="text" wire:model="form.name"
class="input w-full {{ $errors->has('form.name') ? 'input-error' : '' }}"
class="input w-full"
required>
@error('form.name') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
</td>
</tr>
{{-- Descripción --}}
<tr>
<td class="w-1/4 py-3 pr-4 align-top">
{{__('Descripción')}}
{{ __('Description') }}
</td>
<td class="py-3">
<textarea wire:model="form.description" class="textarea textarea-bordered w-full" rows="2"></textarea>
@@ -44,11 +43,11 @@
{{-- Fase asociada (opcional) --}}
<tr>
<td class="w-1/4 py-3 pr-4 align-top">
{{__('Fase asociada (opcional)')}}
{{ __('Associated phase (optional)') }}
</td>
<td class="py-3">
<select wire:model="form.phase_id" class="select select-bordered w-full">
<option value="">Ninguna (global para el proyecto)</option>
<option value="">{{ __('Global project') }}</option>
@foreach($phases as $phase)
<option value="{{ $phase->id }}" {{ old('form.phase_id') == $phase->id ? 'selected' : '' }}>
{{ $phase->name }}
@@ -62,22 +61,22 @@
{{-- Campos dinámicos --}}
<div class="border-t pt-4 mt-2">
<h3 class="font-bold mb-3">Campos del formulario</h3>
<h3 class="font-bold mb-3">{{ __('Form fields') }}</h3>
@foreach($form['fields'] as $index => $field)
<div class="border p-3 rounded mb-3 bg-base-100">
{{-- Fila: nombre interno --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Nombre interno</div>
<div class="font-medium">{{ __('Internal name') }}</div>
<div><input type="text" wire:model="form.fields.{{ $index }}.name" placeholder="ej: altura_medida" class="input input-sm w-full"></div>
</div>
{{-- Fila: etiqueta --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Etiqueta visible</div>
<div class="font-medium">{{ __('Visible label') }}</div>
<div><input type="text" wire:model="form.fields.{{ $index }}.label" placeholder="ej: Altura medida (m)" class="input input-sm w-full"></div>
</div>
{{-- Fila: tipo --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Tipo de campo</div>
<div class="font-medium">{{ __('Field type') }}</div>
<div>
<select wire:model="form.fields.{{ $index }}.type" class="select select-sm w-full">
@foreach($fieldTypes as $typeValue => $typeLabel)
@@ -88,43 +87,36 @@
</div>
{{-- Fila: requerido y botón eliminar --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Requerido</div>
<div class="font-medium">{{ __('Required') }}</div>
<div class="flex justify-between items-center">
<input type="checkbox" wire:model="form.fields.{{ $index }}.required" class="checkbox checkbox-sm">
<button type="button" wire:click="removeField({{ $index }})" class="btn btn-xs btn-error">Eliminar campo</button>
<button type="button" wire:click="removeField({{ $index }})" class="btn btn-xs btn-error">{{ __('Remove field') }}</button>
</div>
</div>
{{-- Campos adicionales según tipo --}}
@if(in_array($field['type'], ['integer', 'decimal', 'percentage']))
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Mínimo / Máximo / Paso</div>
<div class="font-medium">{{ __('Min') }} / {{ __('Max') }} / {{ __('Step') }}</div>
<div class="flex gap-2">
<input type="number" wire:model="form.fields.{{ $index }}.min" placeholder="Mín" class="input input-xs w-20">
<input type="number" wire:model="form.fields.{{ $index }}.max" placeholder="Máx" class="input input-xs w-20">
<input type="number" step="any" wire:model="form.fields.{{ $index }}.step" placeholder="Paso" class="input input-xs w-20">
<input type="number" wire:model="form.fields.{{ $index }}.min" placeholder="{{ __('Min') }}" class="input input-xs w-20">
<input type="number" wire:model="form.fields.{{ $index }}.max" placeholder="{{ __('Max') }}" class="input input-xs w-20">
<input type="number" step="any" wire:model="form.fields.{{ $index }}.step" placeholder="{{ __('Step') }}" class="input input-xs w-20">
</div>
</div>
@elseif($field['type'] === 'select')
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Opciones (separadas por coma)</div>
<div class="font-medium">{{ __('Options (comma separated)') }}</div>
<div><input type="text" wire:model="form.fields.{{ $index }}.options" placeholder="ej: Bueno,Regular,Malo" class="input input-sm w-full"></div>
</div>
@endif
</div>
@endforeach
<button type="button" wire:click="addField" class="btn btn-sm btn-secondary mt-2">+ Agregar campo</button>
<button type="button" wire:click="addField" class="btn btn-sm btn-secondary mt-2">+ {{ __('Add field') }}</button>
</div>
@if($errors->any())
<div class="alert alert-error text-sm mt-3">
<ul class="list-disc pl-3 space-y-0.5">
@foreach($errors->all() as $e) <li>{{ $e }}</li> @endforeach
</ul>
</div>
@endif
<div class="flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">{{ __('Save template') }}</button>
<button type="submit" class="btn btn-primary">{{ $editingTemplate ? __('Update') : __('Save template') }}</button>
<button type="button" wire:click="cancelForm" class="btn">{{ __('Cancel') }}</button>
</div>
</form>
@@ -135,11 +127,11 @@
<table class="table table-zebra">
<thead>
<tr>
<th>Nombre</th>
<th>Descripción</th>
<th>Fase</th>
<th>Campos</th>
<th>Acciones</th>
<th>{{ __('Name') }}</th>
<th>{{ __('Description') }}</th>
<th>{{ __('Phase') }}</th>
<th>{{ __('Fields') }}</th>
<th>{{ __('Actions') }}</th>
</tr>
</thead>
<tbody>
@@ -147,18 +139,20 @@
<tr>
<td>{{ $template->name }}</td>
<td>{{ $template->description ?? '-' }}</td>
<td>{{ $template->phase ? $template->phase->name : 'Global' }}</td>
<td>{{ $template->phase ? $template->phase->name : __('Global project') }}</td>
<td>{{ count($template->fields) }}</td>
<td>
<button wire:click="editTemplate({{ $template->id }})" class="btn btn-xs btn-warning">
Editar
{{ __('Edit') }}
</button>
<button wire:click="deleteTemplate({{ $template->id }})" class="btn btn-xs btn-error" onclick="return confirm('¿Eliminar template?')">Eliminar</button>
<button wire:click="deleteTemplate({{ $template->id }})"
wire:confirm="{{ __('Delete template confirmation') }}"
class="btn btn-xs btn-error">{{ __('Delete') }}</button>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center">No hay templates creados. Presiona "Nuevo template" para comenzar.</td>
<td colspan="5" class="text-center">{{ __('No templates yet (table)') }}</td>
</tr>
@endforelse
</tbody>
+2 -4
View File
@@ -95,10 +95,8 @@ Route::middleware(['auth'])->group(function () {
'recentIssues' => $recentIssues,
]);
})->name('dashboard');
// Reports — Admin only
Route::middleware(['can:manage all'])->prefix('reports')->name('reports.')->group(function () {
Route::get('/dashboard', ReportsDashboard::class)->name('dashboard');
Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboard');
Route::prefix('reports')->name('reports.')->group(function () {
Route::get('export/projects', [App\Http\Controllers\Reports\ExportController::class, 'exportProjects'])->name('export.projects');
Route::get('export/phases', [App\Http\Controllers\Reports\ExportController::class, 'exportPhases'])->name('export.phases');
Route::get('export/inspections', [App\Http\Controllers\Reports\ExportController::class, 'exportInspections'])->name('export.inspections');