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
+40 -99
View File
@@ -13,27 +13,15 @@ use Illuminate\Support\Facades\Storage;
class OfflineSyncController extends Controller 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) public function storePending(Request $request)
{ {
$payload = $request->validate([ $payload = $request->validate([
'action' => 'required|in:progress_update,inspection,feature_create,media_upload,task_complete', 'action' => 'required|in:progress_update,inspection,feature_create,media_upload,task_complete',
'payload' => 'required|array', 'payload' => 'required|array',
]); ]);
PendingSync::create([ $pending = PendingSync::create([
'user_id' => Auth::id(), 'user_id' => Auth::id() ?? 1,
'action' => $payload['action'], 'action' => $payload['action'],
'payload' => $payload['payload'], 'payload' => $payload['payload'],
]); ]);
return response()->json(['queued' => true]); return response()->json(['queued' => true]);
@@ -41,114 +29,68 @@ class OfflineSyncController extends Controller
public function sync(Request $request) public function sync(Request $request)
{ {
$user = Auth::user(); $user = Auth::user();
$pendings = PendingSync::where('user_id', $user->id)->whereNull('synced_at')->get(); $pendings = PendingSync::where('user_id', $user->id)->whereNull('synced_at')->get();
$results = []; $results = [];
foreach ($pendings as $pending) { foreach ($pendings as $pending) {
$result = ['id' => $pending->id, 'action' => $pending->action, 'success' => false, 'error' => null]; $result = ['id' => $pending->id, 'action' => $pending->action, 'success' => false, 'error' => null];
try { try {
if ($pending->action === 'progress_update') { if ($pending->action === 'progress_update') {
$phaseId = (int) ($pending->payload['phase_id'] ?? 0); $phase = Phase::find($pending->payload['phase_id']);
$progress = (int) ($pending->payload['progress'] ?? 0);
$progress = max(0, min(100, $progress));
$phase = Phase::find($phaseId);
if ($phase) { if ($phase) {
// Verify user has access to this phase's project $phase->progress_percent = $pending->payload['progress'];
if (!$user->hasRole('Admin') && !$phase->project->users()->where('user_id', $user->id)->exists()) { $phase->save();
$result['error'] = 'Access denied to this project.'; $phase->progressUpdates()->create([
} else { 'user_id' => $user->id,
$phase->progress_percent = $progress; 'progress_percent' => $pending->payload['progress'],
$phase->save(); 'comment' => $pending->payload['comment'] ?? '',
$phase->progressUpdates()->create([ 'location' => $pending->payload['location'] ?? null,
'user_id' => $user->id, ]);
'progress_percent' => $progress,
'comment' => substr($pending->payload['comment'] ?? '', 0, 500),
]);
$result['success'] = true;
}
} else {
$result['error'] = 'Phase not found.';
} }
$result['success'] = true;
} elseif ($pending->action === 'inspection') { } elseif ($pending->action === 'inspection') {
$p = $pending->payload; $inspection = Inspection::create($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'] : [],
]);
$result['success'] = true; $result['success'] = true;
$result['data'] = ['inspection_id' => $inspection->id]; $result['data'] = ['inspection_id' => $inspection->id];
} elseif ($pending->action === 'feature_create') { } elseif ($pending->action === 'feature_create') {
$p = $pending->payload; $feature = Feature::create($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,
]);
$result['success'] = true; $result['success'] = true;
$result['data'] = ['feature_id' => $feature->id]; $result['data'] = ['feature_id' => $feature->id];
} elseif ($pending->action === 'media_upload') { } 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'])) { if (isset($pending->payload['file'], $pending->payload['path'])) {
// Restrict path to safe uploads directory $decoded = base64_decode($pending->payload['file']);
$safePath = 'uploads/' . ltrim(basename($pending->payload['path']), '/');
$decoded = base64_decode($pending->payload['file'], true);
if ($decoded !== false) { if ($decoded !== false) {
Storage::disk('public')->put($safePath, $decoded); $path = Storage::put($pending->payload['path'], $decoded);
// Attach to model if model_type and model_id are provided
// Whitelist-based model type resolution (prevents RCE)
if (isset($pending->payload['model_type'], $pending->payload['model_id'])) { if (isset($pending->payload['model_type'], $pending->payload['model_id'])) {
$typeKey = strtolower(trim($pending->payload['model_type'])); $model = new $pending->payload['model_type'];
if (array_key_exists($typeKey, self::ALLOWED_MEDIABLE_TYPES)) { $model = $model->find($pending->payload['model_id']);
$modelClass = self::ALLOWED_MEDIABLE_TYPES[$typeKey]; if ($model) {
$model = $modelClass::find((int) $pending->payload['model_id']); $model->media()->create([
if ($model) { 'name' => $pending->payload['name'] ?? 'unnamed',
$model->media()->create([ 'path' => $path,
'name' => substr($pending->payload['name'] ?? 'unnamed', 0, 255), 'mime_type' => $pending->payload['mime_type'] ?? 'application/octet-stream',
'file_path' => $safePath, 'disk' => 'public',
'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,
]);
}
} }
} }
$result['success'] = true; $result['success'] = true;
$result['data'] = ['path' => $safePath]; $result['data'] = ['path' => $path];
} else { } else {
$result['error'] = 'Failed to decode base64 file.'; $result['error'] = 'Failed to decode base64 file';
} }
} else { } else {
$result['error'] = 'Missing file or path in payload.'; $result['error'] = 'Missing file or path in payload';
} }
} elseif ($pending->action === 'task_complete') { } 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; $result['success'] = true;
} else { } else {
$result['error'] = 'Unknown action type.'; $result['error'] = 'Unknown action type';
} }
} catch (\Exception $e) { } catch (\Exception $e) {
$result['error'] = $e->getMessage(); $result['error'] = $e->getMessage();
@@ -161,7 +103,6 @@ class OfflineSyncController extends Controller
$results[] = $result; $results[] = $result;
} }
return response()->json(['synced' => $results]); return response()->json(['synced' => $results]);
} }
} }
@@ -11,25 +11,21 @@ use App\Exports\ProjectsExport;
use App\Exports\PhasesExport; use App\Exports\PhasesExport;
use App\Exports\InspectionsExport; use App\Exports\InspectionsExport;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class ExportController extends Controller class ExportController extends Controller
{ {
public function exportProjects(Request $request) public function exportProjects(Request $request)
{ {
Gate::authorize('manage all');
return Excel::download(new ProjectsExport, 'projects.xlsx'); return Excel::download(new ProjectsExport, 'projects.xlsx');
} }
public function exportPhases(Request $request) public function exportPhases(Request $request)
{ {
Gate::authorize('manage all');
return Excel::download(new PhasesExport, 'phases.xlsx'); return Excel::download(new PhasesExport, 'phases.xlsx');
} }
public function exportInspections(Request $request) public function exportInspections(Request $request)
{ {
Gate::authorize('manage all');
return Excel::download(new InspectionsExport, 'inspections.xlsx'); return Excel::download(new InspectionsExport, 'inspections.xlsx');
} }
} }
+15 -34
View File
@@ -9,57 +9,38 @@ use Illuminate\Support\Facades\Auth;
class AdminUsers extends Component class AdminUsers extends Component
{ {
public $users; public string $search = '';
public $roles; public $roles;
public function mount() public function mount(): void
{ {
if (!Auth::user()->hasRole('Admin')) { if (!Auth::user()->hasRole('Admin')) abort(403);
abort(403); $this->roles = Role::orderBy('name')->get();
}
$this->roles = Role::all();
$this->loadUsers();
} }
public function loadUsers() public function getUsersProperty()
{ {
$this->users = User::with('roles')->orderBy('name')->get(); return User::with('roles')
} ->when($this->search, fn($q) =>
$q->where(fn($q2) => $q2
public function updateRole($userId, $roleName) ->where('name', 'like', '%' . $this->search . '%')
{ ->orWhere('email', 'like', '%' . $this->search . '%')))
$user = Auth::user(); ->orderBy('name')
if (!$user->hasRole('Admin')) { ->get();
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.');
} }
public function deleteUser(int $userId): void public function deleteUser(int $userId): void
{ {
if (!Auth::user()->hasRole('Admin')) abort(403);
if ($userId === Auth::id()) { if ($userId === Auth::id()) {
session()->flash('error', 'No puedes eliminarte a ti mismo.'); $this->dispatch('notify', 'No puedes eliminarte a ti mismo.');
return; return;
} }
User::findOrFail($userId)->delete(); User::findOrFail($userId)->delete();
session()->flash('message', 'Usuario eliminado.'); $this->dispatch('notify', 'Usuario eliminado.');
$this->loadUsers();
} }
public function render() public function render()
{ {
return view('livewire.admin-users'); return view('livewire.admin-users');
} }
} }
+95 -87
View File
@@ -4,15 +4,19 @@ namespace App\Livewire\Client;
use Livewire\Component; use Livewire\Component;
use App\Models\Project; use App\Models\Project;
use App\Models\Phase;
use App\Models\Inspection;
use App\Models\Feature;
use App\Models\ChangeOrder; use App\Models\ChangeOrder;
use Carbon\Carbon;
class ClientProjects extends Component class ClientProjects extends Component
{ {
public $projects = []; public $projects = [];
public $selectedProject = null; public $selectedProject = null;
public $projectDetails = []; public $projectDetails = [];
public $galleryImages = []; public $galleryImages = [];
public $changeOrders = []; public $changeOrders = [];
public function mount() public function mount()
{ {
@@ -21,33 +25,20 @@ class ClientProjects extends Component
public function loadProjects() public function loadProjects()
{ {
// Get projects where the user has the 'client' role
$user = auth()->user(); $user = auth()->user();
$this->projects = $user->projects() $this->projects = $user->projects()
->wherePivot('role_in_project', 'client') ->wherePivot('role_in_project', 'client')
->with(['phases' => function ($query) { ->with(['phases' => function($query) {
$query->select('id', 'project_id', 'name', 'progress_percent'); $query->select('id', 'project_id', 'name', 'progress_percent');
}]) }])
->get() ->get()
->toArray(); ->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) public function selectProject($projectId)
{ {
// Verify the project is one the user is a client on $this->selectedProject = $projectId;
if (!$this->accessibleProjectIds()->contains((int) $projectId)) {
abort(403);
}
$this->selectedProject = (int) $projectId;
$this->loadProjectDetails(); $this->loadProjectDetails();
} }
@@ -57,14 +48,10 @@ class ClientProjects extends Component
return; return;
} }
// Re-verify ownership on every load
if (!$this->accessibleProjectIds()->contains($this->selectedProject)) {
abort(403);
}
$project = Project::with([ $project = Project::with([
'phases', 'phases.features',
'changeOrders', 'inspections.template',
'changeOrders' // Load change orders for this project
])->find($this->selectedProject); ])->find($this->selectedProject);
if (!$project) { if (!$project) {
@@ -72,91 +59,112 @@ class ClientProjects extends Component
} }
$this->projectDetails = [ $this->projectDetails = [
'id' => $project->id, 'id' => $project->id,
'name' => $project->name, 'name' => $project->name,
'description'=> $project->description ?? '', 'description' => $project->description,
'start_date' => $project->start_date, 'start_date' => $project->start_date,
'end_date' => $project->end_date_estimated, 'end_date' => $project->end_date,
'status' => $project->status, '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() $mediaImages = $project->media()
->where('category', 'image') ->where('category', 'image')
->latest() ->latest()
->take(3) ->take(3)
->get() ->get()
->map(fn ($media) => [ ->map(function($media) {
'url' => $media->url, return [
'title' => $media->name, 'url' => $media->url,
'date' => $media->created_at->format('d/m/Y'), 'title' => $media->name,
]) 'date' => $media->created_at->format('d/m/Y')
];
})
->toArray(); ->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 $this->changeOrders = $project->changeOrders
->sortByDesc('requested_at') ->orderBy('requested_at', 'desc')
->map(fn ($order) => [ ->get()
'id' => $order->id, ->map(function($order) {
'title' => $order->title, return [
'description' => $order->description, 'id' => $order->id,
'status' => $order->status, 'title' => $order->title,
'requested_at' => $order->requested_at?->format('d/m/Y') ?? '', 'description' => $order->description,
'amount' => $order->amount, 'status' => $order->status,
]) 'requested_at' => $order->requested_at->format('d/m/Y'),
->values() 'amount' => $order->amount
];
})
->toArray(); ->toArray();
} }
public function approveChangeOrder($orderId) public function approveChangeOrder($orderId)
{ {
$changeOrder = ChangeOrder::where('id', $orderId) // Update the change order in the database
->where('project_id', $this->selectedProject) $changeOrder = ChangeOrder::find($orderId);
->first(); if ($changeOrder) {
// Check that the change order belongs to the selected project (security)
if ($changeOrder->project_id == $this->selectedProject) {
$changeOrder->status = 'approved';
$changeOrder->responded_at = now()->toDateString();
$changeOrder->responded_by = auth()->id();
$changeOrder->save();
if (!$changeOrder) { // Refresh the change orders list
abort(403); $this->loadProjectDetails();
// Notify any listeners (optional)
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'approved']);
}
} }
// Verify this project is accessible by the current user
if (!$this->accessibleProjectIds()->contains($this->selectedProject)) {
abort(403);
}
$changeOrder->update([
'status' => 'approved',
'responded_at' => now()->toDateString(),
'responded_by' => auth()->id(),
]);
$this->loadProjectDetails();
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'approved']);
} }
public function rejectChangeOrder($orderId) public function rejectChangeOrder($orderId)
{ {
$changeOrder = ChangeOrder::where('id', $orderId) // Update the change order in the database
->where('project_id', $this->selectedProject) $changeOrder = ChangeOrder::find($orderId);
->first(); if ($changeOrder) {
// Check that the change order belongs to the selected project (security)
if ($changeOrder->project_id == $this->selectedProject) {
$changeOrder->status = 'rejected';
$changeOrder->responded_at = now()->toDateString();
$changeOrder->responded_by = auth()->id();
$changeOrder->save();
if (!$changeOrder) { // Refresh the change orders list
abort(403); $this->loadProjectDetails();
// Notify any listeners (optional)
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'rejected']);
}
} }
// Verify this project is accessible by the current user
if (!$this->accessibleProjectIds()->contains($this->selectedProject)) {
abort(403);
}
$changeOrder->update([
'status' => 'rejected',
'responded_at' => now()->toDateString(),
'responded_by' => auth()->id(),
]);
$this->loadProjectDetails();
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'rejected']);
} }
public function render() public function render()
+41 -215
View File
@@ -3,239 +3,65 @@
namespace App\Livewire; namespace App\Livewire;
use Livewire\Component; use Livewire\Component;
use Livewire\WithFileUploads; use Livewire\Attributes\Layout;
use App\Models\Company; use App\Models\Company;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Http\Response;
#[Layout('layouts.app')]
class CompanyManagement extends Component class CompanyManagement extends Component
{ {
use WithFileUploads; public string $search = '';
public string $filterType = '';
// Form state public string $filterEstado = '';
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 function getCompaniesProperty() public function getCompaniesProperty()
{ {
return Company::when($this->search, function ($query) { return Company::when($this->search, function ($q) {
$query->where('name', 'like', '%' . $this->search . '%') $s = '%' . $this->search . '%';
->orWhere('apodo', 'like', '%' . $this->search . '%') $q->where(fn($q2) => $q2
->orWhere('tax_id', 'like', '%' . $this->search . '%'); ->where('name', 'like', $s)
}) ->orWhere('apodo', 'like', $s)
->when($this->filterType, function ($query) { ->orWhere('tax_id', 'like', $s));
$query->where('type', $this->filterType); })
}) ->when($this->filterType, fn($q) => $q->where('type', $this->filterType))
->when($this->filterEstado, function ($query) { ->when($this->filterEstado, fn($q) => $q->where('estado', $this->filterEstado))
$query->where('estado', $this->filterEstado); ->withCount('projects')
}) ->orderBy('name')
->withCount('projects') // Eager load project count ->get();
->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() public function exportCsv()
{ {
$companies = $this->getCompaniesProperty(); $companies = $this->getCompaniesProperty();
// Create CSV content return response()->streamDownload(function () use ($companies) {
$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) {
$handle = fopen('php://output', 'w'); $handle = fopen('php://output', 'w');
// Add BOM for UTF-8 in Excel fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF));
fprintf($handle, chr(0xEF).chr(0xBB).chr(0xBF)); fputcsv($handle, ['Nombre', 'Apodo', 'NIF/Tax ID', 'Tipo', 'Estado', 'Dirección', 'Teléfono', 'Email', 'Website', 'Proyectos', 'Creación']);
foreach ($companies as $c) {
// 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, [ fputcsv($handle, [
$company->name, $c->name, $c->apodo ?? '', $c->tax_id ?? '',
$company->apodo ?? '', $c->type, $c->estado, $c->address ?? '',
$company->tax_id ?? '', $c->phone ?? '', $c->email ?? '', $c->website ?? '',
$company->type, $c->projects_count ?? 0,
$company->estado, $c->created_at?->format('d/m/Y'),
$company->address ?? '',
$company->phone ?? '',
$company->email ?? '',
$company->website ?? '',
$company->projects_count ?? 0,
$company->created_at ? $company->created_at->format('d/m/Y H:i') : ''
]); ]);
} }
fclose($handle); fclose($handle);
}; }, 'empresas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
return response()->stream($callback, 200, $headers);
} }
public function render() public function render()
{ {
return view('livewire.company-management'); return view('livewire.company-management');
} }
} }
+237 -157
View File
@@ -8,9 +8,11 @@ use Livewire\Attributes\Layout;
use App\Models\Project; use App\Models\Project;
use App\Models\Phase; use App\Models\Phase;
use App\Models\Layer; use App\Models\Layer;
use App\Services\SpatialFileConverter;
use App\Models\Feature; use App\Models\Feature;
use App\Models\InspectionTemplate;
use App\Services\SpatialFileConverter;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
#[Layout('layouts.app')] #[Layout('layouts.app')]
@@ -19,104 +21,109 @@ class LayerManager extends Component
use WithFileUploads; use WithFileUploads;
public Project $project; public Project $project;
public Phase $phase; public Phase $phase;
public $layers; public $layers;
public $selectedLayer = null; public $selectedLayer = null;
public $visibleLayers = []; // IDs de capas visibles public $visibleLayers = [];
public $uploadFile = null; public $uploadFile = null;
public $layerName = ''; public $layerName = '';
public $layerColor = '#3b82f6'; public $layerColor = '#3b82f6';
public $manualGeojson = null;
public $drawingMode = false;
protected $rules = [ // Batch assign
'uploadFile' => 'required|file|mimes:geojson,kmz,kml,shp,dwg,zip|max:51200', public $templates = [];
'layerName' => 'required|string|max:255', public $batchTemplateId = null;
'layerColor' => 'nullable|string|size:7', public $batchStatus = '';
];
public function mount(Project $project, Phase $phase) public function mount(Project $project, Phase $phase)
{ {
$this->project = $project; $this->project = $project;
$this->phase = $phase; $this->phase = $phase;
if ($this->phase->project_id !== $this->project->id) { if ($this->phase->project_id !== $this->project->id) abort(404);
abort(404);
}
$user = Auth::user(); $user = Auth::user();
if (!$user->hasRole('Admin') && !$project->users()->where('user_id', $user->id)->exists()) { if (!$user->hasRole('Admin') && !$project->users()->where('user_id', $user->id)->exists()) {
abort(403); abort(403);
} }
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
$this->loadLayers(); $this->loadLayers();
// Por defecto todas visibles
$this->visibleLayers = $this->layers->pluck('id')->toArray(); $this->visibleLayers = $this->layers->pluck('id')->toArray();
$this->emitInitialLayersData(); $this->emitInitialLayersData();
} }
// ── Data loaders ──────────────────────────────────────────────────────────
public function loadLayers() public function loadLayers()
{ {
$this->layers = Layer::with('features')->where('phase_id', $this->phase->id)->latest()->get(); $this->layers = Layer::withCount('features')
$this->visibleLayers = array_intersect($this->visibleLayers, $this->layers->pluck('id')->toArray()); ->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() private function emitInitialLayersData()
{ {
$layersData = $this->layers->map(function($layer) { $this->layers->loadMissing('features');
// 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->dispatch('initialLayersData', [ $this->dispatch('initialLayersData', [
'layers' => $layersData, 'layers' => $this->layers->map(fn($l) => $this->buildLayerPayload($l)),
'visibleLayers' => $this->visibleLayers, 'visibleLayers' => $this->visibleLayers,
'selectedLayerId' => $this->selectedLayer?->id, 'selectedLayerId' => $this->selectedLayer?->id,
]); ]);
} }
// ── Visibility ────────────────────────────────────────────────────────────
public function toggleLayerVisibility($layerId) public function toggleLayerVisibility($layerId)
{ {
if ($this->selectedLayer && $this->selectedLayer->id == $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; return;
} }
if (in_array($layerId, $this->visibleLayers)) { if (in_array($layerId, $this->visibleLayers)) {
$this->visibleLayers = array_diff($this->visibleLayers, [$layerId]); $this->visibleLayers = array_values(array_diff($this->visibleLayers, [$layerId]));
} else { } else {
$this->visibleLayers[] = $layerId; $this->visibleLayers[] = $layerId;
} }
$this->dispatch('visibilityChanged', $this->visibleLayers); $this->dispatch('visibilityChanged', $this->visibleLayers);
} }
// ── Select ────────────────────────────────────────────────────────────────
public function selectLayer($layerId) public function selectLayer($layerId)
{ {
$this->selectedLayer = Layer::with('features')->find($layerId); $this->selectedLayer = Layer::with('features')->find($layerId);
@@ -127,186 +134,259 @@ class LayerManager extends Component
$this->dispatch('visibilityChanged', $this->visibleLayers); $this->dispatch('visibilityChanged', $this->visibleLayers);
} }
// Construir el GeoJSON desde los features de la capa seleccionada $payload = $this->buildLayerPayload($this->selectedLayer);
$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]
];
$this->dispatch('layerSelectedForEdit', [ $this->dispatch('layerSelectedForEdit', [
'layerId' => $layerId, 'layerId' => $layerId,
'geojson' => $geojson, 'geojson' => $payload['geojson'],
'color' => $color, 'color' => $payload['color'],
]); ]);
session()->flash('info', 'Editando capa: ' . $this->selectedLayer->name); $this->dispatch('notify', 'Editando: ' . $this->selectedLayer->name);
} }
// ── Import file ───────────────────────────────────────────────────────────
public function importFile() public function importFile()
{ {
$user = Auth::user(); $user = Auth::user();
if (!$user->can('upload layers') && !$user->hasRole('Admin')) { if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
session()->flash('error', 'Sin permisos.'); $this->dispatch('notify', 'Sin permisos para subir capas');
return; return;
} }
// Validar campos obligatorios y tamaño máximo
$this->validate([ $this->validate([
'uploadFile' => 'required|file|max:51200', 'uploadFile' => 'required|file|max:51200',
'layerName' => 'required|string|max:255', 'layerName' => 'required|string|max:255',
'layerColor' => 'nullable|string|size:7', 'layerColor' => 'nullable|string|size:7',
]); ]);
$extension = strtolower($this->uploadFile->getClientOriginalExtension()); $ext = strtolower($this->uploadFile->getClientOriginalExtension());
$mime = $this->uploadFile->getMimeType(); $allowed = ['geojson', 'json', 'kmz', 'kml', 'shp', 'dwg', 'zip'];
if (!in_array($ext, $allowed)) {
$allowedExtensions = ['geojson', 'kmz', 'kml', 'shp', 'dwg', 'zip']; $this->dispatch('notify', 'Extensión no permitida. Válidas: ' . implode(', ', $allowed));
$allowedMimes = [
'application/vnd.google-earth.kml+xml',
'application/vnd.google-earth.kmz',
'application/zip',
'application/x-zip-compressed',
'application/x-shapefile',
'image/vnd.dwg',
'application/acad',
'application/geo+json',
'text/xml', // ✅ Aceptar KML con text/xml
'application/xml', // ✅ Alternativa
];
if (!in_array($extension, $allowedExtensions) && !in_array($mime, $allowedMimes)) {
session()->flash('error', 'Tipo de archivo no permitido. Extensiones válidas: ' . implode(', ', $allowedExtensions));
return; return;
} }
$projectDir = "uploads/projects/{$this->project->id}/layers";
$originalPath = $this->uploadFile->store($projectDir, 'public');
$geojson = SpatialFileConverter::convertToGeoJson($this->uploadFile); $geojson = SpatialFileConverter::convertToGeoJson($this->uploadFile);
if (!$geojson) { 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; return;
} }
$layerColor = $this->layerColor ?: '#3b82f6'; $layerColor = $this->layerColor ?: '#3b82f6';
$geojson['style'] = ['color' => $layerColor]; $layerName = $this->layerName;
$layer = Layer::create([ try {
'project_id' => $this->project->id, DB::transaction(function () use ($geojson, $layerColor, $layerName, $user) {
'phase_id' => $this->phase->id, $path = $this->uploadFile->store(
'name' => $this->layerName, "uploads/projects/{$this->project->id}/layers", 'public'
'color' => $layerColor, );
'original_file' => $originalPath,
'uploaded_by' => $user->id,
]);
// Crear features a partir del GeoJSON $layer = Layer::create([
if (isset($geojson['features'])) { 'project_id' => $this->project->id,
foreach ($geojson['features'] as $featureData) { 'phase_id' => $this->phase->id,
Feature::create([ 'name' => $layerName,
'layer_id' => $layer->id, 'color' => $layerColor,
'name' => $featureData['properties']['name'] ?? null, 'original_file' => $path,
'geometry' => $featureData['geometry'], 'uploaded_by' => $user->id,
'properties' => $featureData['properties'] ?? [],
'template_id' => $featureData['properties']['template_id'] ?? null,
'progress' => $featureData['properties']['progress'] ?? 0,
'responsible' => $featureData['properties']['responsible'] ?? null,
]); ]);
}
$idx = 0;
foreach ($geojson['features'] ?? [] as $fd) {
$idx++;
$name = trim($fd['properties']['name'] ?? '');
if ($name === '') $name = $layerName . ' — Elemento ' . $idx;
Feature::create([
'layer_id' => $layer->id,
'name' => $name,
'geometry' => $fd['geometry'],
'properties' => $fd['properties'] ?? [],
'template_id' => $fd['properties']['template_id'] ?? null,
'progress' => $fd['properties']['progress'] ?? 0,
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
? $fd['properties']['status']
: 'planned',
'responsible' => $fd['properties']['responsible'] ?? null,
]);
}
$this->visibleLayers[] = $layer->id;
});
} catch (\Throwable $e) {
$this->dispatch('notify', 'Error al importar: ' . $e->getMessage());
return;
} }
$this->loadLayers(); $this->loadLayers();
$this->visibleLayers[] = $layer->id;
$this->reset(['uploadFile', 'layerName']); $this->reset(['uploadFile', 'layerName']);
$this->emitInitialLayersData(); $this->emitInitialLayersData();
session()->flash('message', 'Capa importada correctamente.'); $this->dispatch('notify', 'Capa importada correctamente');
} }
// ── Create empty layer ────────────────────────────────────────────────────
public function createEmptyLayer() public function createEmptyLayer()
{ {
$user = Auth::user(); $user = Auth::user();
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
$this->dispatch('notify', 'Sin permisos para crear capas');
return;
}
$layer = Layer::create([ $layer = Layer::create([
'project_id' => $this->project->id, 'project_id' => $this->project->id,
'phase_id' => $this->phase->id, 'phase_id' => $this->phase->id,
'name' => $this->layerName ?: 'Nueva capa', 'name' => $this->layerName ?: 'Nueva capa',
'color' => $this->layerColor ?: '#3b82f6', 'color' => $this->layerColor ?: '#3b82f6',
'original_file' => null, 'original_file' => null,
'uploaded_by' => $user->id, 'uploaded_by' => $user->id,
]); ]);
$this->loadLayers(); $this->loadLayers();
$this->visibleLayers[] = $layer->id; $this->visibleLayers[] = $layer->id;
$this->selectLayer($layer->id); $this->selectLayer($layer->id);
$this->emitInitialLayersData(); $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) public function saveManualGeojson($geojsonString)
{ {
if (!$this->selectedLayer) { if (!$this->selectedLayer) {
session()->flash('error', 'No hay capa seleccionada.'); $this->dispatch('notify', 'No hay capa seleccionada');
return; return;
} }
$geojson = json_decode($geojsonString, true); $geojson = json_decode($geojsonString, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($geojson['features'])) { if (json_last_error() !== JSON_ERROR_NONE || !isset($geojson['features'])) {
session()->flash('error', 'GeoJSON inválido.'); $this->dispatch('notify', 'GeoJSON inválido');
return; return;
} }
// Eliminar todos los features existentes de esta capa $layerId = $this->selectedLayer->id;
$this->selectedLayer->features()->delete(); $layerName = $this->selectedLayer->name;
// Crear nuevos features a partir del GeoJSON try {
foreach ($geojson['features'] as $featureData) { DB::transaction(function () use ($geojson, $layerId, $layerName) {
Feature::create([ // forceDelete: reemplazamos completamente los elementos de la capa
'layer_id' => $this->selectedLayer->id, Feature::where('layer_id', $layerId)->forceDelete();
'name' => $featureData['properties']['name'] ?? null,
'geometry' => $featureData['geometry'], $idx = 0;
'properties' => $featureData['properties'] ?? [], foreach ($geojson['features'] as $fd) {
'template_id' => $featureData['properties']['template_id'] ?? null, $idx++;
'progress' => $featureData['properties']['progress'] ?? 0, $name = trim($fd['properties']['name'] ?? '');
'responsible' => $featureData['properties']['responsible'] ?? null, if ($name === '') $name = $layerName . ' — Elemento ' . $idx;
]);
Feature::create([
'layer_id' => $layerId,
'name' => $name,
'geometry' => $fd['geometry'],
'properties' => $fd['properties'] ?? [],
'template_id' => $fd['properties']['template_id'] ?? null,
'progress' => $fd['properties']['progress'] ?? 0,
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
? $fd['properties']['status']
: 'planned',
'responsible' => $fd['properties']['responsible'] ?? null,
]);
}
});
} catch (\Throwable $e) {
$this->dispatch('notify', 'Error al guardar: ' . $e->getMessage());
return;
} }
$this->loadLayers(); $this->loadLayers();
$this->selectLayer($this->selectedLayer->id); $this->selectLayer($this->selectedLayer->id);
$this->emitInitialLayersData(); $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) public function deleteLayer($layerId)
{ {
$user = Auth::user(); $user = Auth::user();
if (!$user->can('delete layers') && !$user->hasRole('Admin')) abort(403); 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(); $layer = Layer::where('id', $layerId)->where('phase_id', $this->phase->id)->first();
if (!$layer) return; if (!$layer) return;
if ($layer->original_file) Storage::disk('public')->delete($layer->original_file); if ($layer->original_file) Storage::disk('public')->delete($layer->original_file);
$layer->features()->delete(); // opcional, si no usas cascade $layer->features()->delete();
$layer->delete(); $layer->delete();
$this->loadLayers(); $this->loadLayers();
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) { if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
$this->selectedLayer = null; $this->selectedLayer = null;
$this->dispatch('layerSelectedForEdit', null);
} }
$this->emitInitialLayersData(); $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() public function cancelEditing()
{ {
$this->selectedLayer = null; $this->selectedLayer = null;
@@ -317,4 +397,4 @@ class LayerManager extends Component
{ {
return view('livewire.layers.layer-manager'); return view('livewire.layers.layer-manager');
} }
} }
+42 -84
View File
@@ -4,6 +4,7 @@ namespace App\Livewire;
use Livewire\Component; use Livewire\Component;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
use Livewire\Attributes\On;
use App\Models\Media; use App\Models\Media;
use App\Models\Project; use App\Models\Project;
use App\Models\Phase; use App\Models\Phase;
@@ -11,60 +12,44 @@ use App\Models\Layer;
use App\Models\Feature; use App\Models\Feature;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class MediaManager extends Component class MediaManager extends Component
{ {
use WithFileUploads; use WithFileUploads;
/** // Polimórfico: a qué entidad pertenece
* 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,
];
public $mediableType; public $mediableType;
public $mediableId; public $mediableId;
public $entity; public $entity; // instancia cargada
public $mediaItems = []; public $mediaItems = [];
public $uploadFiles = []; // Subida
public $uploadFiles = [];
public $uploadDescription = ''; public $uploadDescription = '';
public $uploadCategory = 'image'; public $uploadCategory = 'image';
public $showViewer = false; // Modal visor
public $showViewer = false;
public $viewingMedia = null; public $viewingMedia = null;
protected $rules = [ protected $rules = [
'uploadFiles.*' => 'required|file|max:51200', // 50 MB per file 'uploadFiles.*' => 'required|file|max:102400', // 100MB total
'uploadDescription' => 'nullable|string|max:500', 'uploadDescription' => 'nullable|string|max:500',
'uploadCategory' => 'required|in:image,document,other', 'uploadCategory' => 'required|in:image,document,other',
]; ];
protected $messages = [ 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) 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->mediableType = $mediableType;
$this->mediableId = (int) $mediableId; $this->mediableId = $mediableId;
$modelClass = self::ALLOWED_TYPES[$mediableType];
$this->entity = $modelClass::findOrFail($this->mediableId);
$this->entity = $mediableType::findOrFail($mediableId);
$this->loadMedia(); $this->loadMedia();
} }
@@ -92,58 +77,37 @@ class MediaManager extends Component
return; 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; $uploaded = 0;
foreach ($this->uploadFiles as $file) { foreach ($this->uploadFiles as $file) {
$mime = $file->getMimeType(); $mime = $file->getMimeType();
$ext = $file->getClientOriginalExtension();
$size = $file->getSize();
$name = $file->getClientOriginalName();
if (!in_array($mime, $allowedMimes, true)) { // Determinar categoría automática
session()->flash('error', "Tipo de archivo no permitido: {$mime}");
continue;
}
$ext = $file->getClientOriginalExtension();
$size = $file->getSize();
$name = substr($file->getClientOriginalName(), 0, 255);
$category = $this->uploadCategory; $category = $this->uploadCategory;
if (str_starts_with($mime, 'image/')) { if (str_starts_with($mime, 'image/')) {
$category = 'image'; $category = 'image';
} elseif (in_array($mime, [ } elseif (in_array($mime, ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])) {
'application/pdf', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
], true)) {
$category = 'document'; $category = 'document';
} }
// Guardar en disco
$entityType = class_basename($this->entity); $entityType = class_basename($this->entity);
$dir = "uploads/{$entityType}s/{$this->mediableId}/media"; $dir = "uploads/{$entityType}s/{$this->mediableId}/media";
$path = $file->store($dir, 'public'); $path = $file->store($dir, 'public');
Media::create([ Media::create([
'mediable_type' => $this->mediableType, 'mediable_type' => $this->mediableType,
'mediable_id' => $this->mediableId, 'mediable_id' => $this->mediableId,
'name' => $name, 'name' => $name,
'file_path' => $path, 'file_path' => $path,
'file_type' => $mime, 'file_type' => $mime,
'file_extension' => $ext, 'file_extension' => $ext,
'file_size' => $size, 'file_size' => $size,
'category' => $category, 'category' => $category,
'description' => $this->uploadDescription, 'description' => $this->uploadDescription,
'uploaded_by' => $user->id, 'uploaded_by' => $user->id,
]); ]);
$uploaded++; $uploaded++;
@@ -152,21 +116,18 @@ class MediaManager extends Component
$this->reset(['uploadFiles', 'uploadDescription']); $this->reset(['uploadFiles', 'uploadDescription']);
$this->loadMedia(); $this->loadMedia();
// Notificar al mapa si corresponde
$this->dispatch('mediaUploaded', [ $this->dispatch('mediaUploaded', [
'mediableType' => $this->mediableType, 'mediableType' => $this->mediableType,
'mediableId' => $this->mediableId, 'mediableId' => $this->mediableId,
]); ]);
session()->flash('message', "{$uploaded} archivo(s) subido(s) correctamente."); session()->flash('message', "$uploaded archivo(s) subido(s) correctamente.");
} }
public function deleteMedia($mediaId) public function deleteMedia($mediaId)
{ {
// Ensure the media belongs to the entity this component manages (IDOR prevention) $media = Media::findOrFail($mediaId);
$media = Media::where('id', $mediaId)
->where('mediable_type', $this->mediableType)
->where('mediable_id', $this->mediableId)
->firstOrFail();
$user = Auth::user(); $user = Auth::user();
if (!$user->hasRole('Admin') && $media->uploaded_by !== $user->id) { if (!$user->hasRole('Admin') && $media->uploaded_by !== $user->id) {
@@ -181,31 +142,28 @@ class MediaManager extends Component
public function viewMedia($mediaId) public function viewMedia($mediaId)
{ {
$media = Media::where('id', $mediaId) $media = Media::findOrFail($mediaId);
->where('mediable_type', $this->mediableType)
->where('mediable_id', $this->mediableId)
->firstOrFail();
if (!$media->is_image) { if (!$media->is_image) {
// Si no es imagen, abrir en nueva pestaña
$this->dispatch('openUrl', $media->url); $this->dispatch('openUrl', $media->url);
return; return;
} }
$this->viewingMedia = $media; $this->viewingMedia = $media;
$this->showViewer = true; $this->showViewer = true;
} }
public function closeViewer() public function closeViewer()
{ {
$this->showViewer = false; $this->showViewer = false;
$this->viewingMedia = null; $this->viewingMedia = null;
} }
public function render() public function render()
{ {
return view('livewire.media-manager', [ return view('livewire.media-manager', [
'entityName' => class_basename($this->entity) . ': ' . ($this->entity->name ?? $this->entity->id), 'entityName' => class_basename($this->entity) . ': ' . ($this->entity->name ?? $this->entity->id),
'images' => $this->mediaItems->filter(fn ($m) => $m->is_image), 'images' => $this->mediaItems->filter(fn($m) => $m->is_image),
'documents' => $this->mediaItems->filter(fn ($m) => !$m->is_image), 'documents' => $this->mediaItems->filter(fn($m) => !$m->is_image),
]); ]);
} }
} }
+5 -18
View File
@@ -5,8 +5,6 @@ namespace App\Livewire;
use Livewire\Component; use Livewire\Component;
use App\Models\Project; use App\Models\Project;
use App\Models\Phase; use App\Models\Phase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
class PhaseList extends Component class PhaseList extends Component
{ {
@@ -15,19 +13,16 @@ class PhaseList extends Component
public function mount(Project $project) public function mount(Project $project)
{ {
Gate::authorize('edit projects', $project);
$this->project = $project; $this->project = $project;
$this->phases = $project->phases; $this->phases = $project->phases;
} }
public function addPhase() public function addPhase()
{ {
Gate::authorize('edit projects', $this->project);
$this->project->phases()->create([ $this->project->phases()->create([
'name' => 'Nueva fase', 'name' => 'Nueva fase',
'order' => $this->phases->count() + 1, '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(); $this->phases = $this->project->phases()->get();
session()->flash('message', 'Fase agregada'); session()->flash('message', 'Fase agregada');
@@ -35,20 +30,12 @@ class PhaseList extends Component
public function deletePhase($phaseId) public function deletePhase($phaseId)
{ {
Gate::authorize('edit projects', $this->project); Phase::find($phaseId)->delete();
// 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();
$this->phases = $this->project->phases()->get(); $this->phases = $this->project->phases()->get();
session()->flash('message', 'Fase eliminada');
} }
public function render() public function render()
{ {
return view('livewire.phase-list'); return view('livewire.phase-list');
} }
} }
-10
View File
@@ -4,7 +4,6 @@ namespace App\Livewire;
use Livewire\Component; use Livewire\Component;
use App\Models\Phase; use App\Models\Phase;
use Illuminate\Support\Facades\Auth;
class PhaseProgress extends Component class PhaseProgress extends Component
{ {
@@ -14,21 +13,12 @@ class PhaseProgress extends Component
public function mount(Phase $phase) 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->phase = $phase->load('progressUpdates');
$this->progress = $phase->progress_percent; $this->progress = $phase->progress_percent;
} }
public function updateProgressManual() 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->validate(['progress' => 'required|integer|min:0|max:100']);
$this->phase->progress_percent = $this->progress; $this->phase->progress_percent = $this->progress;
$this->phase->save(); $this->phase->save();
-9
View File
@@ -17,10 +17,6 @@ class ProjectCompanies extends Component
public function mount(Project $project) 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->project = $project;
$this->loadCompanies(); $this->loadCompanies();
} }
@@ -69,11 +65,6 @@ class ProjectCompanies extends Component
public function changeRole($companyId, $role) 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; if (!in_array($role, ['owner', 'constructor', 'subcontractor', 'consultant', 'supplier', 'other'])) return;
$this->project->companies()->updateExistingPivot($companyId, [ $this->project->companies()->updateExistingPivot($companyId, [
+1 -4
View File
@@ -4,7 +4,6 @@ namespace App\Livewire;
use Livewire\Component; use Livewire\Component;
use App\Models\Project; use App\Models\Project;
use Illuminate\Support\Facades\Gate;
class ProjectEditTabs extends Component class ProjectEditTabs extends Component
{ {
@@ -13,7 +12,6 @@ class ProjectEditTabs extends Component
public function mount(Project $project) public function mount(Project $project)
{ {
Gate::authorize('edit projects', $project);
$this->project = $project; $this->project = $project;
} }
@@ -31,9 +29,8 @@ class ProjectEditTabs extends Component
public function updateProject() public function updateProject()
{ {
Gate::authorize('edit projects', $this->project);
$this->project->save(); $this->project->save();
session()->flash('message', __('Project updated successfully.')); session()->flash('message', __('Project updated successfully.'));
$this->dispatch('project-updated'); $this->dispatch('project-updated');
} }
+4 -8
View File
@@ -16,15 +16,11 @@ class ProjectList extends Component
public function deleteProject($id) public function deleteProject($id)
{ {
$user = Auth::user(); $project = Project::findOrFail($id);
if (!$user->can('delete projects')) { if (Auth::user()->can('delete projects')) {
session()->flash('error', 'Sin permisos para eliminar proyectos.'); $project->delete();
return; session()->flash('message', 'Proyecto eliminado');
} }
// Scope to accessible projects to prevent IDOR (deleting another user's project by ID)
$project = Project::accessibleBy($user)->findOrFail($id);
$project->delete();
session()->flash('message', 'Proyecto eliminado');
} }
public function render() public function render()
+228 -124
View File
@@ -10,27 +10,28 @@ use App\Models\Layer;
use App\Models\Feature; use App\Models\Feature;
use App\Models\Inspection; use App\Models\Inspection;
use App\Models\InspectionTemplate; use App\Models\InspectionTemplate;
use App\Models\Issue;
class ProjectMap extends Component class ProjectMap extends Component
{ {
public Project $project; public Project $project;
public $phases; public $phases;
public $activeLayers = []; public $activeLayers = []; // Now stores Layer IDs (not Phase IDs)
public $showLayerModal = false; public $showLayerModal = false;
// Editor properties // Editor properties
public $selectedFeature = null; // será instancia de Feature public $selectedFeature = null;
public $selectedPhaseId = null; public $selectedPhaseId = null;
public $editProgress = 0; public $editProgress = 0;
public $editComment = ''; public $editComment = '';
public $editResponsible = ''; public $editResponsible = '';
public $editPhotos = []; public $editPhotos = [];
public $formFullscreen = false; 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 // Templates e inspecciones
public $templates = []; public $templates = [];
@@ -42,19 +43,61 @@ class ProjectMap extends Component
public $showFeatureImages = false; public $showFeatureImages = false;
public $featureImageMarkers = []; 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) 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->project = $project;
$this->phases = $project->phases()->with(['layers' => function ($q) { $this->authorizeProjectAccess();
$q->withCount('features');
}, 'layers.features'])->get(); $this->phases = $project->phases()->with([
$this->activeLayers = $this->phases->pluck('id')->toArray(); '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->loadTemplates();
$this->allFeatures = Feature::whereHas('layer.phase', function($q) use ($project) {
$q->where('project_id', $project->id);
})->with(['layer.phase', 'template'])->get();
$this->allInspections = Inspection::where('project_id', $project->id)
->with(['feature.layer.phase', 'template', 'user'])
->orderBy('created_at', 'desc')
->get();
$this->openIssuesCount = Issue::where('project_id', $project->id)
->where('status', 'open')
->count();
}
private function authorizeProjectAccess(): void
{
$user = Auth::user();
if ($user->hasRole('Admin')) return;
if (!$this->project->users()->where('user_id', $user->id)->exists()) abort(403);
} }
public function loadTemplates() public function loadTemplates()
@@ -62,92 +105,129 @@ class ProjectMap extends Component
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get(); $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)) { $layerId = (int) $layerId;
$this->activeLayers = array_diff($this->activeLayers, [$phaseId]); if (in_array($layerId, $this->activeLayers)) {
$this->activeLayers = array_values(array_diff($this->activeLayers, [$layerId]));
} else { } else {
$this->activeLayers[] = $phaseId; $this->activeLayers[] = $layerId;
} }
$this->dispatch('layersUpdated', $this->activeLayers); $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) public function updateProgress($featureId, $newProgress, $comment = null)
{ {
$feature = Feature::with('layer.phase')->findOrFail($featureId); $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(); $user = Auth::user();
if (!$user->can('update progress') && !$user->hasRole('Admin')) { if (!$user->can('update progress') && !$user->hasRole('Admin')) {
$this->dispatch('notify', 'Sin permisos'); $this->dispatch('notify', 'Sin permisos');
return; return;
} }
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
$feature->progress = min(100, max(0, $newProgress)); $feature->progress = min(100, max(0, $newProgress));
$feature->save(); $feature->save();
$phase = $feature->layer->phase;
$phase = Phase::find($feature->layer->phase_id);
$phase->progress_percent = $phase->features()->avg('progress') ?: 0; $phase->progress_percent = $phase->features()->avg('progress') ?: 0;
$phase->save(); $phase->save();
// Registrar la actualización en progress_updates
$phase->progressUpdates()->create([ $phase->progressUpdates()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'progress_percent' => $phase->progress_percent, 'progress_percent' => $phase->progress_percent,
'comment' => $comment, 'comment' => $comment,
]); ]);
$this->dispatch('progressUpdated', $featureId, $feature->progress); $this->dispatch('progressUpdated', $featureId, $feature->progress);
$this->dispatch('notify', 'Progreso actualizado'); $this->dispatch('notify', 'Progreso actualizado');
// Si el feature seleccionado es el mismo, actualizar la propiedad local
if ($this->selectedFeature && $this->selectedFeature->id == $featureId) { if ($this->selectedFeature && $this->selectedFeature->id == $featureId) {
$this->selectedFeature->progress = $feature->progress; $this->selectedFeature->progress = $feature->progress;
$this->editProgress = $feature->progress; $this->editProgress = $feature->progress;
} }
} }
/**
* Seleccionar un Feature al hacer clic en el mapa.
*/
public function selectFeature($featureId) public function selectFeature($featureId)
{ {
$this->selectedFeature = null; $this->selectedFeature = null;
$feature = Feature::with(['template', 'layer.phase'])->find($featureId); $feature = Feature::with(['template', 'layer.phase'])->find($featureId);
if (!$feature) return; if (!$feature) return;
// Verify feature belongs to this project
if ($feature->layer->phase->project_id !== $this->project->id) abort(403); if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
$this->selectedFeature = $feature; $this->selectedFeature = $feature;
$this->selectedPhaseId = $feature->layer->phase_id; $this->selectedPhaseId = $feature->layer->phase_id;
$this->editProgress = $feature->progress; $this->editProgress = $feature->progress;
$this->editResponsible = $feature->responsible ?? ''; $this->editResponsible = $feature->responsible ?? '';
$this->editPhotos = $feature->properties['photos'] ?? []; $this->editPhotos = $feature->properties['photos'] ?? [];
$this->selectedTemplateId = $feature->template_id; $this->selectedTemplateId = $feature->template_id;
$this->activeTab = 'edit';
$this->loadInspectionHistory(); $this->loadInspectionHistory();
$this->resetInspectionForm(); $this->resetInspectionForm();
$this->dispatch('featureSelected', $featureId); $this->dispatch('featureSelected', $featureId, $feature->name);
} }
/**
* Cargar el historial de inspecciones del feature seleccionado.
*/
public function loadInspectionHistory() public function loadInspectionHistory()
{ {
if (!$this->selectedFeature) { if (!$this->selectedFeature) {
@@ -160,12 +240,11 @@ class ProjectMap extends Component
->get(); ->get();
} }
/**
* Reiniciar el formulario de inspección según el template seleccionado.
*/
public function resetInspectionForm() public function resetInspectionForm()
{ {
$this->inspectionFormData = []; $this->inspectionFormData = [];
$this->inspectionResult = '';
$this->inspectionNotes = '';
if ($this->selectedTemplateId) { if ($this->selectedTemplateId) {
$template = InspectionTemplate::find($this->selectedTemplateId); $template = InspectionTemplate::find($this->selectedTemplateId);
if ($template) { if ($template) {
@@ -176,20 +255,18 @@ class ProjectMap extends Component
} }
} }
/**
* Guardar una nueva inspección.
*/
public function saveInspection() public function saveInspection()
{ {
if (!$this->selectedFeature || !$this->selectedTemplateId) { if (!$this->selectedFeature || !$this->selectedTemplateId) {
$this->dispatch('notify', 'Selecciona un elemento y un template.'); $this->dispatch('notify', 'Selecciona un elemento y un template.');
return; 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 $this->validate(['selectedTemplateId' => 'required|exists:inspection_templates,id']);
$template = InspectionTemplate::where('id', $this->selectedTemplateId)
->where('project_id', $this->project->id) $template = InspectionTemplate::find($this->selectedTemplateId);
->firstOrFail();
foreach ($template->fields as $field) { foreach ($template->fields as $field) {
if (($field['required'] ?? false) && empty($this->inspectionFormData[$field['name']])) { if (($field['required'] ?? false) && empty($this->inspectionFormData[$field['name']])) {
$this->dispatch('notify', "El campo {$field['label']} es obligatorio."); $this->dispatch('notify', "El campo {$field['label']} es obligatorio.");
@@ -198,38 +275,57 @@ class ProjectMap extends Component
} }
$inspection = Inspection::create([ $inspection = Inspection::create([
'project_id' => $this->project->id, 'project_id' => $this->project->id,
'layer_id' => $this->selectedFeature->layer_id, 'layer_id' => $this->selectedFeature->layer_id,
'feature_id' => $this->selectedFeature->id, 'feature_id' => $this->selectedFeature->id,
'template_id' => $this->selectedTemplateId, 'template_id' => $this->selectedTemplateId,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'data' => $this->inspectionFormData, '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') {
if (isset($this->inspectionFormData['progress'])) { Issue::create([
$this->updateProgress($this->selectedFeature->id, (int)$this->inspectionFormData['progress'], 'Inspección registrada'); 'project_id' => $this->project->id,
'feature_id' => $this->selectedFeature->id,
'inspection_id' => $inspection->id,
'title' => 'Fallo en inspección: ' . ($template->name ?? 'Sin nombre'),
'description' => $this->inspectionNotes,
'priority' => 'high',
'status' => 'open',
'reported_by' => auth()->id(),
]);
$this->openIssuesCount = Issue::where('project_id', $this->project->id)
->where('status', 'open')->count();
$this->dispatch('notify', 'Inspección fallida — Issue creado automáticamente');
} else {
if (isset($this->inspectionFormData['progress'])) {
$this->updateProgress($this->selectedFeature->id, (int)$this->inspectionFormData['progress'], 'Inspección registrada');
}
$this->dispatch('notify', 'Inspección guardada correctamente');
} }
// Reload global list
$this->allInspections = Inspection::where('project_id', $this->project->id)
->with(['feature.layer.phase', 'template', 'user'])
->orderBy('created_at', 'desc')
->get();
$this->loadInspectionHistory(); $this->loadInspectionHistory();
$this->resetInspectionForm(); $this->resetInspectionForm();
$this->dispatch('notify', 'Inspección guardada correctamente');
} }
/**
* Asignar un template al feature seleccionado.
*/
public function assignTemplateToFeature($templateId) public function assignTemplateToFeature($templateId)
{ {
if (!$this->selectedFeature) return; if (!$this->selectedFeature) return;
// Verify template belongs to this project (IDOR prevention)
$template = InspectionTemplate::where('id', $templateId) $template = InspectionTemplate::where('id', $templateId)
->where('project_id', $this->project->id) ->where('project_id', $this->project->id)->first();
->firstOrFail(); if (!$template) abort(403);
$feature = Feature::findOrFail($this->selectedFeature->id);
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
$feature->template_id = $templateId; $feature->template_id = $templateId;
$feature->save(); $feature->save();
$this->selectedFeature = $feature; $this->selectedFeature = $feature;
@@ -238,40 +334,58 @@ class ProjectMap extends Component
$this->dispatch('notify', 'Template asignado al elemento'); $this->dispatch('notify', 'Template asignado al elemento');
} }
/**
* Guardar progreso y responsable del feature seleccionado.
*/
public function saveFeatureProgress() public function saveFeatureProgress()
{ {
if (!$this->selectedFeature) return; if (!$this->selectedFeature) return;
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id); $feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
if ($feature->layer->phase->project_id !== $this->project->id) abort(403); if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
$feature->progress = min(100, max(0, (int)$this->editProgress));
$feature->progress = min(100, max(0, (int) $this->editProgress));
$feature->responsible = $this->editResponsible; $feature->responsible = $this->editResponsible;
$feature->save(); $feature->save();
$this->selectedFeature = $feature; $this->selectedFeature = $feature;
$phase = $feature->layer->phase;
$phase = Phase::find($feature->layer->phase_id);
$phase->progress_percent = $phase->features()->avg('progress') ?: 0; $phase->progress_percent = $phase->features()->avg('progress') ?: 0;
$phase->save(); $phase->save();
$this->dispatch('progressUpdated', $phase->id, $phase->progress_percent); $this->dispatch('progressUpdated', $phase->id, $phase->progress_percent);
$this->dispatch('notify', 'Progreso guardado'); $this->dispatch('notify', 'Progreso guardado');
} }
/**
* Cuando cambia el template seleccionado, reiniciar el formulario.
*/
public function onTemplateChange() public function onTemplateChange()
{ {
$this->resetInspectionForm(); $this->resetInspectionForm();
} }
/** // ─── Inspection viewer ───────────────────────────────────────────────────────
* Toggle mostrar imágenes en el mapa.
*/ 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() public function toggleFeatureImages()
{ {
$this->showFeatureImages = !$this->showFeatureImages; $this->showFeatureImages = !$this->showFeatureImages;
@@ -279,44 +393,31 @@ class ProjectMap extends Component
$this->dispatch('featureImagesToggled', $this->showFeatureImages, $this->featureImageMarkers); $this->dispatch('featureImagesToggled', $this->showFeatureImages, $this->featureImageMarkers);
} }
/**
* Cargar marcadores de imágenes para el mapa.
*/
public function loadFeatureImageMarkers() public function loadFeatureImageMarkers()
{ {
if (!$this->showFeatureImages) { if (!$this->showFeatureImages) { $this->featureImageMarkers = []; return; }
$this->featureImageMarkers = [];
return;
}
$markers = []; $markers = [];
foreach ($this->phases as $phase) { foreach ($this->phases as $phase) {
foreach ($phase->layers as $layer) { foreach ($phase->layers as $layer) {
foreach ($layer->features as $feature) { foreach ($layer->features as $feature) {
$image = $feature->images()->first(); $image = $feature->images->first();
if ($image) { if ($image) {
$geo = $feature->geometry; $geo = $feature->geometry;
$coords = null; $coords = null;
if ($geo && isset($geo['coordinates'])) { if ($geo && isset($geo['coordinates'])) {
if ($geo['type'] === 'Point') { if ($geo['type'] === 'Point') {
$coords = [ $coords = ['lat' => $geo['coordinates'][1], 'lng' => $geo['coordinates'][0]];
'lat' => $geo['coordinates'][1],
'lng' => $geo['coordinates'][0],
];
} elseif (in_array($geo['type'], ['Polygon', 'LineString'])) { } elseif (in_array($geo['type'], ['Polygon', 'LineString'])) {
$coords = [ $coords = ['lat' => $geo['coordinates'][0][1] ?? null, 'lng' => $geo['coordinates'][0][0] ?? null];
'lat' => $geo['coordinates'][0][1] ?? null,
'lng' => $geo['coordinates'][0][0] ?? null,
];
} }
} }
if ($coords && $coords['lat'] && $coords['lng']) { if ($coords && $coords['lat'] && $coords['lng']) {
$markers[] = [ $markers[] = [
'feature_id' => $feature->id, 'feature_id' => $feature->id,
'name' => $feature->name, 'name' => $feature->name,
'lat' => $coords['lat'], 'lat' => $coords['lat'],
'lng' => $coords['lng'], 'lng' => $coords['lng'],
'image_url' => $image->url, 'image_url' => $image->url,
'image_name' => $image->name, 'image_name' => $image->name,
]; ];
} }
@@ -330,16 +431,19 @@ class ProjectMap extends Component
public function toggleFullscreen() public function toggleFullscreen()
{ {
$this->formFullscreen = !$this->formFullscreen; $this->formFullscreen = !$this->formFullscreen;
if (!$this->formFullscreen) { if (!$this->formFullscreen) $this->dispatch('mapResize');
$this->dispatch('mapResize'); }
}
public function setActiveTab($tab)
{
$this->activeTab = $tab;
} }
public function render() public function render()
{ {
return view('livewire.projects.project-map', [ return view('livewire.projects.project-map', [
'project' => $this->project, 'project' => $this->project,
'phases' => $this->phases, 'phases' => $this->phases,
]); ]);
} }
} }
-9
View File
@@ -17,10 +17,6 @@ class ProjectUsers extends Component
public function mount(Project $project) 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->project = $project;
$this->loadUsers(); $this->loadUsers();
} }
@@ -69,11 +65,6 @@ class ProjectUsers extends Component
public function changeRole($userId, $role) 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; if (!in_array($role, ['supervisor', 'consultant', 'client', 'viewer'])) return;
$this->project->users()->updateExistingPivot($userId, [ $this->project->users()->updateExistingPivot($userId, [
@@ -7,7 +7,6 @@ use App\Models\Project;
use App\Models\Phase; use App\Models\Phase;
use App\Models\Inspection; use App\Models\Inspection;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
class ReportsDashboard extends Component class ReportsDashboard extends Component
{ {
@@ -16,7 +15,6 @@ class ReportsDashboard extends Component
public function mount() public function mount()
{ {
if (!Auth::user()->hasRole('Admin')) abort(403);
$this->loadChartData(); $this->loadChartData();
} }
+335 -60
View File
@@ -3,43 +3,58 @@
namespace App\Livewire; namespace App\Livewire;
use Livewire\Component; use Livewire\Component;
use Livewire\WithFileUploads;
use App\Models\InspectionTemplate; use App\Models\InspectionTemplate;
use App\Models\Project; use App\Models\Project;
use App\Models\Phase; use App\Models\Phase;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use PhpOffice\PhpSpreadsheet\IOFactory;
class TemplateManager extends Component class TemplateManager extends Component
{ {
use WithFileUploads;
public $project; public $project;
public $templates; public $templates;
public $phases; public $phases;
// ── Formulario principal ───────────────────────────────────────────────
public $editingTemplate = null; public $editingTemplate = null;
public $showForm = false; // Controla si mostrar el formulario public $showForm = false;
public $form = [ public $form = [
'name' => '', 'name' => '',
'description' => '', 'description' => '',
'phase_id' => null, 'phase_id' => null,
'fields' => [], 'fields' => [],
];
public $fieldTypes = [
'text' => 'Texto corto',
'textarea' => 'Texto largo',
'integer' => 'Número entero',
'decimal' => 'Número decimal',
'percentage' => 'Porcentaje (0-100)',
'boolean' => 'Sí/No (checkbox)',
'date' => 'Fecha',
'select' => 'Lista desplegable',
]; ];
protected $listeners = ['showTemplateForm' => 'newTemplate']; // ── Importar desde CSV/Excel ───────────────────────────────────────────
public $showImportFileModal = false;
public $importFile = null;
public $importPreviewFields = [];
public $importTemplateName = '';
public $importError = '';
// ── Importar desde otro proyecto ──────────────────────────────────────
public $showImportProjectModal = false;
public $availableProjects = [];
public $importProjectId = null;
public $importableTemplates = [];
public $selectedImportTemplateIds = [];
public $fieldTypes = [
'text' => 'Texto corto',
'textarea' => 'Texto largo',
'integer' => 'Número entero',
'decimal' => 'Número decimal',
'percentage' => 'Porcentaje (0-100)',
'boolean' => 'Sí/No (checkbox)',
'date' => 'Fecha',
'select' => 'Lista desplegable',
];
public function mount(Project $project) 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->project = $project;
$this->loadPhases(); $this->loadPhases();
$this->loadTemplates(); $this->loadTemplates();
@@ -52,22 +67,28 @@ class TemplateManager extends Component
public function loadTemplates() 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() public function newTemplate()
{ {
$this->resetForm(); $this->resetForm();
$this->editingTemplate = null;
$this->showForm = true; $this->showForm = true;
} }
public function editTemplate($id) public function editTemplate($id)
{ {
$template = InspectionTemplate::where('id', $id) $template = InspectionTemplate::findOrFail($id);
->where('project_id', $this->project->id) $this->form = [
->firstOrFail(); 'name' => $template->name,
$this->form = $template->only(['name', 'description', 'phase_id', 'fields']); 'description' => $template->description ?? '',
'phase_id' => $template->phase_id,
'fields' => $template->fields ?? [],
];
$this->editingTemplate = $id; $this->editingTemplate = $id;
$this->showForm = true; $this->showForm = true;
} }
@@ -81,10 +102,10 @@ class TemplateManager extends Component
public function resetForm() public function resetForm()
{ {
$this->form = [ $this->form = [
'name' => '', 'name' => '',
'description' => '', 'description' => '',
'phase_id' => null, 'phase_id' => null,
'fields' => [], 'fields' => [],
]; ];
$this->editingTemplate = null; $this->editingTemplate = null;
} }
@@ -92,14 +113,14 @@ class TemplateManager extends Component
public function addField() public function addField()
{ {
$this->form['fields'][] = [ $this->form['fields'][] = [
'name' => '', 'name' => '',
'label' => '', 'label' => '',
'type' => 'text', 'type' => 'text',
'options' => [], 'options' => '',
'required' => false, 'required' => false,
'min' => null, 'min' => null,
'max' => null, 'max' => null,
'step' => null, 'step' => null,
]; ];
} }
@@ -112,31 +133,25 @@ class TemplateManager extends Component
public function saveTemplate() public function saveTemplate()
{ {
$this->validate([ $this->validate([
'form.name' => 'required|string|max:255', 'form.name' => 'required|string|max:255',
'form.phase_id' => 'nullable|exists:phases,id', 'form.phase_id' => 'nullable|exists:phases,id',
'form.fields' => 'array', 'form.fields' => 'array',
]); ]);
$data = [
'name' => $this->form['name'],
'description' => $this->form['description'],
'project_id' => $this->project->id,
'phase_id' => $this->form['phase_id'] ?: null,
'fields' => array_values($this->form['fields']),
];
if ($this->editingTemplate) { if ($this->editingTemplate) {
$template = InspectionTemplate::where('id', $this->editingTemplate) InspectionTemplate::findOrFail($this->editingTemplate)->update($data);
->where('project_id', $this->project->id) $this->dispatch('notify', 'Template actualizado correctamente');
->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 { } else {
InspectionTemplate::create([ InspectionTemplate::create($data);
'name' => $this->form['name'], $this->dispatch('notify', 'Template creado correctamente');
'description' => $this->form['description'],
'project_id' => $this->project->id,
'phase_id' => $this->form['phase_id'],
'fields' => $this->form['fields'],
]);
session()->flash('message', 'Template creado');
} }
$this->cancelForm(); $this->cancelForm();
@@ -145,12 +160,272 @@ class TemplateManager extends Component
public function deleteTemplate($id) public function deleteTemplate($id)
{ {
InspectionTemplate::where('id', $id) InspectionTemplate::findOrFail($id)->delete();
->where('project_id', $this->project->id)
->firstOrFail()
->delete();
$this->loadTemplates(); $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() public function render()
+12 -6
View File
@@ -20,11 +20,10 @@ class User extends Authenticatable
* @var list<string> * @var list<string>
*/ */
protected $fillable = [ protected $fillable = [
'name', 'name', 'title', 'first_name', 'last_name',
'email', 'email', 'password',
'password', // Intentionally kept: required for registration factory and seeding. 'status', 'valid_from', 'valid_until',
// Sensitive — never pass unvalidated user input directly. 'company_id', 'phone', 'address', 'notes',
// email_verified_at and remember_token are intentionally excluded.
]; ];
/** /**
@@ -46,9 +45,16 @@ class User extends Authenticatable
{ {
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'valid_from' => 'date',
'valid_until' => 'date',
]; ];
} }
public function company()
{
return $this->belongsTo(\App\Models\Company::class);
}
// Many-to-many with projects // Many-to-many with projects
public function 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", "My Projects": "My Projects",
"Editable": "Editable", "Editable": "Editable",
"Name of responsible": "Name of responsible", "Name of responsible": "Name of responsible",
"Select template...": "Select template...", "Select template...": "Select template..."
"View all": "View all",
"View on map": "View on map"
} }
+1 -3
View File
@@ -395,7 +395,5 @@
"My Projects": "Mis proyectos", "My Projects": "Mis proyectos",
"Editable": "Editable", "Editable": "Editable",
"Name of responsible": "Nombre del responsable", "Name of responsible": "Nombre del responsable",
"Select template...": "Seleccionar plantilla...", "Select template...": "Seleccionar plantilla..."
"View all": "Ver todos",
"View on map": "Ver en mapa"
} }
@@ -22,7 +22,7 @@
<input type="color" wire:model="layerColor" class="input input-bordered"> <input type="color" wire:model="layerColor" class="input input-bordered">
</div> </div>
<div class="form-control"> <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"> <input type="file" wire:model="uploadFile" class="file-input file-input-bordered">
@error('uploadFile') <span class="text-error">{{ $message }}</span> @enderror @error('uploadFile') <span class="text-error">{{ $message }}</span> @enderror
</div> </div>
@@ -49,13 +49,13 @@
</span> </span>
</div> </div>
<div> <div>
<button wire:click="selectLayer({{ $layer->id }})" class="btn btn-xs btn-info">✏️ Editar</button> <button wire:click="selectLayer({{ $layer->id }})" class="btn btn-xs btn-info">✏️ {{ __('Edit') }}</button>
<button wire:click="deleteLayer({{ $layer->id }})" class="btn btn-xs btn-error" onclick="return confirm('¿{{ __("Delete layer") }}?')">🗑️</button> <button wire:click="deleteLayer({{ $layer->id }})" wire:confirm="{{ __('Delete layer') }}?" class="btn btn-xs btn-error">🗑️</button>
</div> </div>
</div> </div>
@endforeach @endforeach
@if($layers->isEmpty()) @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 @endif
</div> </div>
</div> </div>
@@ -69,8 +69,8 @@
<h2 class="card-title">{{ __("Edit") }}</h2> <h2 class="card-title">{{ __("Edit") }}</h2>
@if($selectedLayer) @if($selectedLayer)
<div class="mt-3 flex gap-2"> <div class="mt-3 flex gap-2">
<button id="saveDrawingBtn" class="btn btn-primary">💾 Guardar cambios</button> <button id="saveDrawingBtn" class="btn btn-primary">💾 {{ __('Save changes') }}</button>
<button wire:click="cancelEditing" class="btn btn-outline">Cancelar edición</button> <button wire:click="cancelEditing" class="btn btn-outline">{{ __('Cancel') }}</button>
</div> </div>
@endif @endif
<div id="permanentMap" style="flex: 1; min-height: 500px; width: 100%; background: #e2e8f0;" wire:ignore></div> <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 allLayersData = {}; // id -> {geojson, color}
let visibleLayerIds = []; 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 // Inicialización del mapa
function initMap() { function initMap() {
if (map) return; if (map) return;
@@ -148,9 +137,9 @@
style: { color: data.color, weight: 3, opacity: 0.8, fillOpacity: 0.3 }, style: { color: data.color, weight: 3, opacity: 0.8, fillOpacity: 0.3 },
onEachFeature: (feature, layer) => { onEachFeature: (feature, layer) => {
const props = feature.properties; const props = feature.properties;
const content = `<b>${escapeHtml(props.name) || 'Elemento'}</b><br> const content = `<b>${props.name || 'Elemento'}</b><br>
Progreso: ${escapeHtml(props.progress) || 0}%<br> Progreso: ${props.progress || 0}%<br>
Responsable: ${escapeHtml(props.responsible) || '-'}`; Responsable: ${props.responsible || '-'}`;
layer.bindPopup(content); layer.bindPopup(content);
} }
}).addTo(displayGroup); }).addTo(displayGroup);
@@ -169,10 +158,10 @@
onEachFeature: (f, l) => { onEachFeature: (f, l) => {
l.feature = f; l.feature = f;
const props = f.properties; const props = f.properties;
const content = `<b>${escapeHtml(props.name) || 'Elemento'}</b><br> const content = `<b>${props.name || @js(__('Feature'))}</b><br>
Progreso: ${escapeHtml(props.progress) || 0}%<br> @js(__('Progress')): ${props.progress || 0}%<br>
Responsable: ${escapeHtml(props.responsible) || '-'}<br> @js(__('Responsible')): ${props.responsible || '-'}<br>
<em>Editable</em>`; <em>@js(__('Editable'))</em>`;
l.bindPopup(content); l.bindPopup(content);
} }
}); });
@@ -1,4 +1,4 @@
<div> <div>
<x-slot name="header"> <x-slot name="header">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -25,19 +25,15 @@
<div class="flex gap-2"> <div class="flex gap-2">
<a href="{{ route('projects.map', $project) }}" class="btn btn-outline btn-sm gap-1"> <a href="{{ route('projects.map', $project) }}" class="btn btn-outline btn-sm gap-1">
<x-heroicon-o-map class="w-4 h-4" /> <x-heroicon-o-map class="w-4 h-4" />
{{ __('Map') }} Mapa
</a> </a>
<a href="{{ route('projects.gantt', $project) }}" class="btn btn-outline btn-sm gap-1"> <a href="{{ route('projects.gantt', $project) }}" class="btn btn-outline btn-sm gap-1">
<x-heroicon-o-calendar-days class="w-4 h-4" /> <x-heroicon-o-calendar-days class="w-4 h-4" />
{{ __('Gantt') }} 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') }}
</a> </a>
<a href="{{ route('projects.issues', $project) }}" class="btn btn-outline btn-sm gap-1"> <a href="{{ route('projects.issues', $project) }}" class="btn btn-outline btn-sm gap-1">
<x-heroicon-o-exclamation-triangle class="w-4 h-4" /> <x-heroicon-o-exclamation-triangle class="w-4 h-4" />
{{ __('Issues') }} Issues
</a> </a>
</div> </div>
</div> </div>
@@ -307,7 +303,7 @@
<x-heroicon-o-exclamation-triangle class="w-4 h-4 text-orange-500" /> <x-heroicon-o-exclamation-triangle class="w-4 h-4 text-orange-500" />
Issues abiertos Issues abiertos
</h3> </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> </div>
@if($recentIssues->isEmpty()) @if($recentIssues->isEmpty())
<div class="text-center py-4 text-gray-400"> <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" /> <x-heroicon-o-clipboard-document-list class="w-4 h-4 text-yellow-500" />
Inspecciones recientes Inspecciones recientes
</h3> </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> </div>
@if($recentInspections->isEmpty()) @if($recentInspections->isEmpty())
<div class="text-center py-4 text-gray-400"> <div class="text-center py-4 text-gray-400">
@@ -2,40 +2,27 @@
<div class="max-w-2xl mx-auto p-4"> <div class="max-w-2xl mx-auto p-4">
<h1 class="text-2xl font-bold mb-6">{{ $projectId ? __('Edit Project') : __('New Project') }}</h1> <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"> <form wire:submit.prevent="save" class="space-y-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div> <div>
<label class="block text-sm font-medium mb-2">{{ __('Name') }}</label> <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> <input type="text" wire:model="name" class="input input-bordered w-full" placeholder="{{ __('Project name') }}" required>
@error('name') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-2">{{ __('Address') }}</label> <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.') }}"> <input type="text" wire:model="address" class="input input-bordered w-full" placeholder="{{ __('Address') }}">
@error('address') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-2">{{ __('Country') }}</label> <label class="block text-sm font-medium mb-2">{{ __('Coordinates') }}</label>
<input type="text" wire:model="country" class="input input-bordered w-full" placeholder="{{ __('Country (auto-filled)') }}" readonly> <input type="text" wire:model="country" class="input input-bordered w-full" placeholder="{{ __('No country') }}" readonly>
</div> </div>
<div class="md:col-span-2"> <div class="md:col-span-2">
<label class="block text-sm font-medium mb-2">{{ __('Start Date') }}</label> <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> <input type="date" wire:model="start_date" class="input input-bordered w-full" required>
@error('start_date') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
</div> </div>
<div class="md:col-span-2"> <div class="md:col-span-2">
<label class="block text-sm font-medium mb-2">{{ __('End Date (estimated)') }}</label> <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 {{ $errors->has('end_date_estimated') ? 'input-error' : '' }}"> <input type="date" wire:model="end_date_estimated" class="input input-bordered w-full">
@error('end_date_estimated') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
</div> </div>
<div class="md:col-span-2"> <div class="md:col-span-2">
<label class="block text-sm font-medium mb-2">{{ __('Status') }}</label> <label class="block text-sm font-medium mb-2">{{ __('Status') }}</label>
@@ -45,14 +32,13 @@
<option value="paused">{{ __('Paused') }}</option> <option value="paused">{{ __('Paused') }}</option>
<option value="completed">{{ __('Completed') }}</option> <option value="completed">{{ __('Completed') }}</option>
</select> </select>
@error('status') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
</div> </div>
</div> </div>
<div class="border rounded-lg p-4"> <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"> <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> </p>
<div id="projectMap" style="height: 400px; width: 100%; background: #e2e8f0; border-radius: 0.5rem;"></div> <div id="projectMap" style="height: 400px; width: 100%; background: #e2e8f0; border-radius: 0.5rem;"></div>
<input type="hidden" wire:model="lat"> <input type="hidden" wire:model="lat">
@@ -61,7 +47,7 @@
<div class="flex justify-end space-x-4"> <div class="flex justify-end space-x-4">
<button type="button" wire:click="resetForm" class="btn btn-outline"> <button type="button" wire:click="resetForm" class="btn btn-outline">
{{ __('Reset') }} {{ __('Cancel') }}
</button> </button>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
{{ $projectId ? __('Update') : __('Create') }} {{ $projectId ? __('Update') : __('Create') }}
@@ -1,4 +1,4 @@
<div x-data="{ formFullscreen: $wire.entangle('formFullscreen') }" <div x-data="{ formFullscreen: $wire.entangle('formFullscreen') }"
class="flex flex-col lg:flex-row gap-0 h-screen p-1"> class="flex flex-col lg:flex-row gap-0 h-screen p-1">
<!-- Columna izquierda: Mapa --> <!-- Columna izquierda: Mapa -->
<div x-show="!formFullscreen" x-cloak class="w-full lg:w-2/3 flex-1 relative"> <div x-show="!formFullscreen" x-cloak class="w-full lg:w-2/3 flex-1 relative">
@@ -6,7 +6,7 @@
<!-- Panel lateral de capas --> <!-- 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"> <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"> <div class="space-y-3">
@foreach($phases as $phase) @foreach($phases as $phase)
<div class="border rounded-lg p-2 {{ in_array($phase->id, $activeLayers) ? 'bg-base-200' : '' }}"> <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"> <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="w-2 h-2 rounded-full inline-block" style="background: {{ $layer->color ?? '#ccc' }}"></span>
<span class="flex-1 truncate">{{ $layer->name }}</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> </div>
@endforeach @endforeach
</div> </div>
@@ -36,10 +36,10 @@
{{-- Botón para ir a gestión de capas de esta fase --}} {{-- Botón para ir a gestión de capas de esta fase --}}
<div class="mt-1 ml-7"> <div class="mt-1 ml-7">
<a href="{{ route('layers.manage', [$project, $phase]) }}" class="btn btn-xs btn-outline btn-primary"> <a href="{{ route('layers.manage', [$project, $phase]) }}" class="btn btn-xs btn-outline btn-primary">
✏️ {{ __("Manage Layers") }} ✏️ {{ __('Manage Layers') }}
</a> </a>
<a href="{{ route('phases.progress', $phase) }}" class="btn btn-xs btn-outline"> <a href="{{ route('phases.progress', $phase) }}" class="btn btn-xs btn-outline">
📊 {{ __("Progress") }} 📊 {{ __('Progress') }}
</a> </a>
</div> </div>
</div> </div>
@@ -50,7 +50,7 @@
<div class="mt-3"> <div class="mt-3">
<label class="flex items-center gap-2 text-xs cursor-pointer"> <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" /> <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) @if($featureImageMarkers)
<span class="badge badge-xs">{{ count($featureImageMarkers) }}</span> <span class="badge badge-xs">{{ count($featureImageMarkers) }}</span>
@endif @endif
@@ -60,67 +60,34 @@
{{-- Botones generales --}} {{-- Botones generales --}}
<div class="mt-2 space-y-1"> <div class="mt-2 space-y-1">
<a href="{{ route('projects.media', $project) }}" class="btn btn-sm btn-outline w-full"> <a href="{{ route('projects.media', $project) }}" class="btn btn-sm btn-outline w-full">
📁 {{ __("Project files") }} 📁 {{ __('Project files') }}
</button> </a>
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full"> <button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full">
📍 {{ __("Centered in project") }} 📍 {{ __('Centered in project') }}
</button> </button>
<button onclick="getUserLocation()" class="btn btn-sm btn-outline w-full"> <button onclick="getUserLocation()" class="btn btn-sm btn-outline w-full">
🧭 {{ __("My location") }} 🧭 {{ __('My location') }}
</button> </button>
</div> </div>
</div> </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="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 bg-base-100 shadow-xl h-full flex flex-col">
<div class="card-body overflow-y-auto flex-1"> <div class="card-body overflow-y-auto flex-1">
<div class="flex justify-between items-center mb-2"> <div class="flex justify-between items-center mb-2">
<h2 class="card-title">{{ __("Project Map") }}</h2> <h2 class="card-title">{{ __('Map') }}</h2>
<button wire:click="toggleFullscreen" class="btn btn-circle btn-sm" title="Pantalla completa"> <button wire:click="toggleFullscreen" class="btn btn-circle btn-sm" title="{{ __('Fullscreen') }}">
<span x-text="formFullscreen ? '✕' : '⤢'"></span> <span x-text="formFullscreen ? '✕' : '⤢'"></span>
</button> </button>
</div> </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 --> <!-- Tabs -->
<div class="tabs box mb-4"> <div class="tabs box mb-4">
<button wire:click="setActiveTab('edit')" class="tab {{ $activeTab === 'edit' ? 'tab-active' : '' }}">{{ __("Edit") }}</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('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('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>
</div> </div>
<!-- Tab Content --> <!-- Tab Content -->
@@ -129,14 +96,14 @@
@if($selectedFeature) @if($selectedFeature)
<!-- Feature seleccionado --> <!-- Feature seleccionado -->
<div class="border rounded-lg p-3 mb-3 bg-base-200"> <div class="border rounded-lg p-3 mb-3 bg-base-200">
<h3 class="font-bold text-sm">{{ $selectedFeature->name ?? 'Elemento' }}</h3> <h3 class="font-bold text-sm">{{ $selectedFeature->name ?? __('Feature') }}</h3>
<p class="text-xs text-gray-500">Fase: {{ $selectedFeature->layer?->phase?->name ?? '—' }}</p> <p class="text-xs text-gray-500">{{ __('Phase') }}: {{ $selectedFeature->layer?->phase?->name ?? '—' }}</p>
<p class="text-xs text-gray-500">Capa: {{ $selectedFeature->layer?->name ?? '—' }}</p> <p class="text-xs text-gray-500">{{ __('Layer') }}: {{ $selectedFeature->layer?->name ?? '—' }}</p>
</div> </div>
{{-- {{ __("Progress") }} --}} {{-- Progreso --}}
<div class="form-control mb-3"> <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" /> <input type="range" min="0" max="100" wire:model.live="editProgress" class="range range-primary range-sm" />
<div class="flex justify-between text-xs"> <div class="flex justify-between text-xs">
<span>0%</span><span>50%</span><span>100%</span> <span>0%</span><span>50%</span><span>100%</span>
@@ -144,18 +111,18 @@
</div> </div>
<div class="form-control mb-3"> <div class="form-control mb-3">
<label class="label-text">{{ __("Responsible") }}</label> <label class="label-text">{{ __('Responsible') }}</label>
<input type="text" wire:model="editResponsible" class="input input-bordered input-sm" placeholder="Nombre" /> <input type="text" wire:model="editResponsible" class="input input-bordered input-sm" placeholder="{{ __('Name of responsible') }}" />
</div> </div>
<button wire:click="saveFeatureProgress" class="btn btn-primary btn-sm w-full mb-3"> <button wire:click="saveFeatureProgress" class="btn btn-primary btn-sm w-full mb-3">
💾 {{ __("Save progress") }} 💾 {{ __('Save progress') }}
</button> </button>
{{-- Gestor de archivos del feature --}} {{-- Gestor de archivos del feature --}}
<details class="mb-3 border rounded-lg"> <details class="mb-3 border rounded-lg">
<summary class="text-xs font-semibold cursor-pointer p-2 bg-base-200 rounded-t-lg"> <summary class="text-xs font-semibold cursor-pointer p-2 bg-base-200 rounded-t-lg">
📎 {{ __("Files of element") }} 📎 {{ __('Files of element') }}
</summary> </summary>
<div class="p-2"> <div class="p-2">
@livewire('media-manager', [ @livewire('media-manager', [
@@ -167,11 +134,11 @@
{{-- Templates / Inspecciones --}} {{-- Templates / Inspecciones --}}
@if($templates->isNotEmpty()) @if($templates->isNotEmpty())
<div class="divider text-xs">{{ __("Inspection") }}</div> <div class="divider text-xs">{{ __('Inspection') }}</div>
<div class="form-control mb-2"> <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"> <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) @foreach($templates as $t)
<option value="{{ $t->id }}">{{ $t->name }}</option> <option value="{{ $t->id }}">{{ $t->name }}</option>
@endforeach @endforeach
@@ -197,7 +164,7 @@
@break @break
@case('select') @case('select')
<select wire:model="inspectionFormData.{{ $field['name'] }}" class="select select-bordered select-sm w-full"> <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) @foreach(explode(',', $field['options'] ?? '') as $opt)
<option value="{{ trim($opt) }}">{{ trim($opt) }}</option> <option value="{{ trim($opt) }}">{{ trim($opt) }}</option>
@endforeach @endforeach
@@ -211,21 +178,21 @@
@endswitch @endswitch
</div> </div>
@endforeach @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
@endif @endif
{{-- {{ __("History") }} de inspecciones --}} {{-- Historial de inspecciones --}}
@if($inspectionHistory->isNotEmpty()) @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"> <div class="space-y-1 max-h-40 overflow-y-auto">
@foreach($inspectionHistory as $ins) @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"> <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> <span class="text-gray-400">{{ $ins->created_at->diffForHumans() }}</span>
</div> </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> </div>
@endforeach @endforeach
</div> </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> <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> </svg>
<div> <div>
<h3 class="font-bold">{{ __("No templates yet") }}</h3> <h3 class="font-bold">{{ __('No templates yet') }}</h3>
<div class="text-xs">{{ __("Create an inspection template") }}.</div> <div class="text-xs">{{ __('Create an inspection template') }}.</div>
</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> </div>
@endif @endif
@else @else
<div class="text-center text-gray-400 py-8"> <div class="text-center text-gray-400 py-8">
<p class="text-lg">👆</p> <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> </div>
@endif @endif
@elseif($activeTab === 'features') @elseif($activeTab === 'features')
@@ -255,12 +222,12 @@
<table class="table table-sm table-compact"> <table class="table table-sm table-compact">
<thead> <thead>
<tr> <tr>
<th>{{ __("Feature") }}</th> <th>{{ __('Feature') }}</th>
<th>{{ __("Layer") }}</th> <th>{{ __('Layer') }}</th>
<th>{{ __("Phase") }}</th> <th>{{ __('Phase') }}</th>
<th>{{ __("Progress") }}</th> <th>{{ __('Progress') }}</th>
<th>{{ __("Responsible") }}</th> <th>{{ __('Responsible') }}</th>
<th>{{ __("Template") }}</th> <th>{{ __('Template') }}</th>
<th class="w-16"></th> <th class="w-16"></th>
</tr> </tr>
</thead> </thead>
@@ -284,7 +251,7 @@
@else @else
<div class="text-center text-gray-400 py-8"> <div class="text-center text-gray-400 py-8">
<p class="text-lg">📋</p> <p class="text-lg">📋</p>
<p>{{ __("No features found") }}</p> <p>{{ __('No elements in this project') }}</p>
</div> </div>
@endif @endif
@elseif($activeTab === 'inspections') @elseif($activeTab === 'inspections')
@@ -294,10 +261,10 @@
<table class="table table-sm table-compact"> <table class="table table-sm table-compact">
<thead> <thead>
<tr> <tr>
<th>{{ __("Date") }}</th> <th>{{ __('Date') }}</th>
<th>{{ __("Feature") }}</th> <th>{{ __('Feature') }}</th>
<th>{{ __("Template") }}</th> <th>{{ __('Template') }}</th>
<th>{{ __("User") }}</th> <th>{{ __('User') }}</th>
<th class="w-16"></th> <th class="w-16"></th>
</tr> </tr>
</thead> </thead>
@@ -319,16 +286,14 @@
@else @else
<div class="text-center text-gray-400 py-8"> <div class="text-center text-gray-400 py-8">
<p class="text-lg">📋</p> <p class="text-lg">📋</p>
<p>{{ __("No inspections found") }}</p> <p>{{ __('No inspections registered') }}</p>
</div> </div>
@endif @endif
@elseif($activeTab === 'issues')
<!-- Issues tab: render embedded IssueManager component -->
@livewire('issue-manager', ['project' => $project], key('issues-tab-' . $project->id))
@endif @endif
</div> </div>
</div> </div>
</div> </div>
</div>
@push('styles') @push('styles')
<style> <style>
@@ -370,7 +335,7 @@
// Prevent multiple initializations // Prevent multiple initializations
if (mapInitialized || map) return; if (mapInitialized || map) return;
mapInitialized = true; mapInitialized = true;
const center = [{{ $project->lat }}, {{ $project->lng }}]; const center = [{{ $project->lat }}, {{ $project->lng }}];
map = L.map('map').setView(center, 16); map = L.map('map').setView(center, 16);
@@ -408,14 +373,13 @@
onEachFeature: function(feature, layer) { onEachFeature: function(feature, layer) {
const props = feature.properties || {}; const props = feature.properties || {};
const featId = props._feature_id || feature.id; const featId = props._feature_id || feature.id;
// Escape all user-generated content for HTML context const safeName = escapeHtml(props.name || '{{ __('Feature') }}');
const safeName = escapeHtml(props.name || 'Elemento');
const safeProgress = escapeHtml(props.progress || 0); const safeProgress = escapeHtml(props.progress || 0);
const safeResponsible = escapeHtml(props.responsible || '-'); const safeResponsible = escapeHtml(props.responsible || '-');
let content = `<b>${safeName}</b><br> let content = `<b>${safeName}</b><br>
{{ __("Progress") }}: ${safeProgress}%<br> {{ __('Progress') }}: ${safeProgress}%<br>
{{ __("Responsible") }}: ${safeResponsible}<br> {{ __('Responsible') }}: ${safeResponsible}<br>
<button class="btn btn-xs btn-primary mt-1" onclick="selectFeature('${featId}')">✏️ Editar</button>`; <button class="btn btn-xs btn-primary mt-1" onclick="selectFeature('${featId}')">✏️ {{ __('Edit') }}</button>`;
layer.bindPopup(content); layer.bindPopup(content);
layer.on('click', function() { selectFeature(featId); }); layer.on('click', function() { selectFeature(featId); });
} }
@@ -430,11 +394,11 @@
// Initialize combined bounds // Initialize combined bounds
updateCombinedBounds(); updateCombinedBounds();
setTimeout(() => { setTimeout(() => {
map.invalidateSize(); map.invalidateSize();
zoomToAllFeatures(); zoomToAllFeatures();
}, 100); // Reduced from 200ms to 100ms }, 100);
} }
function updateCombinedBounds() { function updateCombinedBounds() {
@@ -445,9 +409,9 @@
const layer = layers[id]; const layer = layers[id];
if (map.hasLayer(layer) && typeof layer.getBounds === 'function') { if (map.hasLayer(layer) && typeof layer.getBounds === 'function') {
const b = layer.getBounds(); const b = layer.getBounds();
if (b.isValid()) { if (b.isValid()) {
combinedBounds.extend(b); combinedBounds.extend(b);
hasBounds = true; hasBounds = true;
} }
} }
} }
@@ -456,10 +420,7 @@
function zoomToAllFeatures() { function zoomToAllFeatures() {
if (!map) return; if (!map) return;
// Update combined bounds if needed
updateCombinedBounds(); updateCombinedBounds();
if (combinedBounds && combinedBounds.isValid()) { if (combinedBounds && combinedBounds.isValid()) {
map.fitBounds(combinedBounds, { padding: [20, 20] }); map.fitBounds(combinedBounds, { padding: [20, 20] });
} else { } else {
@@ -475,32 +436,29 @@
if (navigator.geolocation) { if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition((pos) => { navigator.geolocation.getCurrentPosition((pos) => {
const latlng = [pos.coords.latitude, pos.coords.longitude]; 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); map.setView(latlng, 16);
}, () => alert('No se pudo obtener la ubicación')); }, () => alert('{{ __('No results') }}'));
} else { } else {
alert('Geolocalización no soportada'); alert('{{ __('No results') }}');
} }
} }
document.addEventListener('livewire:init', function () { document.addEventListener('livewire:init', function () {
setTimeout(initMap, 50); // Reduced from 100ms to 50ms setTimeout(initMap, 50);
Livewire.on('layersUpdated', (activeIds) => { 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; const ids = Array.isArray(activeIds) ? activeIds[0] : activeIds;
for (let id in layers) { for (let id in layers) {
const lid = parseInt(id); const lid = parseInt(id);
if (ids.includes(lid)) { if (ids.includes(lid)) {
if (!map.hasLayer(layers[id])) { if (!map.hasLayer(layers[id])) {
layers[id].addTo(map); layers[id].addTo(map);
// Update combined bounds when adding a layer
updateCombinedBounds(); updateCombinedBounds();
} }
} else { } else {
if (map.hasLayer(layers[id])) { if (map.hasLayer(layers[id])) {
map.removeLayer(layers[id]); map.removeLayer(layers[id]);
// Update combined bounds when removing a layer
updateCombinedBounds(); updateCombinedBounds();
} }
} }
@@ -509,9 +467,8 @@
}); });
Livewire.on('centerMap', zoomToAllFeatures); Livewire.on('centerMap', zoomToAllFeatures);
Livewire.on('mapResize', () => { Livewire.on('mapResize', () => {
if (map) { if (map) {
// Throttle resize events to prevent excessive calls
if (!this.resizeTimeout) { if (!this.resizeTimeout) {
this.resizeTimeout = setTimeout(() => { this.resizeTimeout = setTimeout(() => {
map.invalidateSize(); map.invalidateSize();
@@ -521,14 +478,12 @@
} }
}); });
// Toggle imágenes en mapa
Livewire.on('featureImagesToggled', (show, markers) => { Livewire.on('featureImagesToggled', (show, markers) => {
const m = Array.isArray(markers) ? markers : markers[1]; const m = Array.isArray(markers) ? markers : markers[1];
const s = Array.isArray(show) ? show[0] : show; const s = Array.isArray(show) ? show[0] : show;
if (imageMarkersLayer) { if (imageMarkersLayer) {
map.removeLayer(imageMarkersLayer); map.removeLayer(imageMarkersLayer);
imageMarkersLayer = null; imageMarkersLayer = null;
// Update bounds when removing image markers layer
updateCombinedBounds(); updateCombinedBounds();
} }
if (s && m && m.length > 0) { if (s && m && m.length > 0) {
@@ -540,10 +495,9 @@
iconAnchor: [10, 10] iconAnchor: [10, 10]
}); });
m.forEach(marker => { m.forEach(marker => {
// Validate URL and sanitize name for security
const safeUrl = isValidUrl(marker.image_url) ? marker.image_url : ''; const safeUrl = isValidUrl(marker.image_url) ? marker.image_url : '';
const safeName = escapeHtml(marker.image_name || ''); const safeName = escapeHtml(marker.image_name || '');
if (safeUrl) { // Only add marker if URL is valid if (safeUrl) {
const popupContent = `<b>${safeName}</b><br> const popupContent = `<b>${safeName}</b><br>
<img src="${safeUrl}" class="max-w-[200px] max-h-[150px] rounded cursor-pointer" <img src="${safeUrl}" class="max-w-[200px] max-h-[150px] rounded cursor-pointer"
onclick="window.openViewer('${safeUrl}', '${safeName}')" />`; onclick="window.openViewer('${safeUrl}', '${safeName}')" />`;
@@ -552,21 +506,16 @@
.addTo(imageMarkersLayer); .addTo(imageMarkersLayer);
} }
}); });
// Update bounds when adding image markers layer
updateCombinedBounds(); updateCombinedBounds();
} }
}); });
// Modal para ver imagen al hacer clic
window.openViewer = function(url, name) { window.openViewer = function(url, name) {
// Validate URL and sanitize name for security
if (!isValidUrl(url)) { if (!isValidUrl(url)) {
console.error('Invalid URL provided to openViewer:', url); console.error('Invalid URL provided to openViewer:', url);
return; return;
} }
const safeName = escapeHtml(name); const safeName = escapeHtml(name);
if (imageViewerModal) imageViewerModal.remove(); if (imageViewerModal) imageViewerModal.remove();
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.id = 'imageViewerModal'; overlay.id = 'imageViewerModal';
@@ -582,4 +531,4 @@
}; };
}); });
</script> </script>
@endpush @endpush
@@ -1,10 +1,10 @@
<div> <div>
<div class="bg-base-100 p-4 rounded shadow"> <div class="bg-base-100 p-4 rounded shadow">
<div class="flex justify-between items-center mb-4"> <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> <div>
<button wire:click="newTemplate" class="btn btn-primary btn-sm"> <button wire:click="newTemplate" class="btn btn-primary btn-sm">
Nuevo template {{ __('New template') }}
</button> </button>
</div> </div>
</div> </div>
@@ -21,20 +21,19 @@
{{-- Nombre del template --}} {{-- Nombre del template --}}
<tr> <tr>
<td class="w-1/4 py-3 pr-4 align-top"> <td class="w-1/4 py-3 pr-4 align-top">
{{__('Template name')}} {{ __('Template name') }}
</td> </td>
<td class="py-3"> <td class="py-3">
<input type="text" wire:model="form.name" <input type="text" wire:model="form.name"
class="input w-full {{ $errors->has('form.name') ? 'input-error' : '' }}" class="input w-full"
required> required>
@error('form.name') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
</td> </td>
</tr> </tr>
{{-- Descripción --}} {{-- Descripción --}}
<tr> <tr>
<td class="w-1/4 py-3 pr-4 align-top"> <td class="w-1/4 py-3 pr-4 align-top">
{{__('Descripción')}} {{ __('Description') }}
</td> </td>
<td class="py-3"> <td class="py-3">
<textarea wire:model="form.description" class="textarea textarea-bordered w-full" rows="2"></textarea> <textarea wire:model="form.description" class="textarea textarea-bordered w-full" rows="2"></textarea>
@@ -44,11 +43,11 @@
{{-- Fase asociada (opcional) --}} {{-- Fase asociada (opcional) --}}
<tr> <tr>
<td class="w-1/4 py-3 pr-4 align-top"> <td class="w-1/4 py-3 pr-4 align-top">
{{__('Fase asociada (opcional)')}} {{ __('Associated phase (optional)') }}
</td> </td>
<td class="py-3"> <td class="py-3">
<select wire:model="form.phase_id" class="select select-bordered w-full"> <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) @foreach($phases as $phase)
<option value="{{ $phase->id }}" {{ old('form.phase_id') == $phase->id ? 'selected' : '' }}> <option value="{{ $phase->id }}" {{ old('form.phase_id') == $phase->id ? 'selected' : '' }}>
{{ $phase->name }} {{ $phase->name }}
@@ -62,22 +61,22 @@
{{-- Campos dinámicos --}} {{-- Campos dinámicos --}}
<div class="border-t pt-4 mt-2"> <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) @foreach($form['fields'] as $index => $field)
<div class="border p-3 rounded mb-3 bg-base-100"> <div class="border p-3 rounded mb-3 bg-base-100">
{{-- Fila: nombre interno --}} {{-- Fila: nombre interno --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2"> <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><input type="text" wire:model="form.fields.{{ $index }}.name" placeholder="ej: altura_medida" class="input input-sm w-full"></div>
</div> </div>
{{-- Fila: etiqueta --}} {{-- Fila: etiqueta --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2"> <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><input type="text" wire:model="form.fields.{{ $index }}.label" placeholder="ej: Altura medida (m)" class="input input-sm w-full"></div>
</div> </div>
{{-- Fila: tipo --}} {{-- Fila: tipo --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2"> <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> <div>
<select wire:model="form.fields.{{ $index }}.type" class="select select-sm w-full"> <select wire:model="form.fields.{{ $index }}.type" class="select select-sm w-full">
@foreach($fieldTypes as $typeValue => $typeLabel) @foreach($fieldTypes as $typeValue => $typeLabel)
@@ -88,43 +87,36 @@
</div> </div>
{{-- Fila: requerido y botón eliminar --}} {{-- Fila: requerido y botón eliminar --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2"> <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"> <div class="flex justify-between items-center">
<input type="checkbox" wire:model="form.fields.{{ $index }}.required" class="checkbox checkbox-sm"> <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>
</div> </div>
{{-- Campos adicionales según tipo --}} {{-- Campos adicionales según tipo --}}
@if(in_array($field['type'], ['integer', 'decimal', 'percentage'])) @if(in_array($field['type'], ['integer', 'decimal', 'percentage']))
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2"> <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"> <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 }}.min" placeholder="{{ __('Min') }}" 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" 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="Paso" 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>
</div> </div>
@elseif($field['type'] === 'select') @elseif($field['type'] === 'select')
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2"> <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><input type="text" wire:model="form.fields.{{ $index }}.options" placeholder="ej: Bueno,Regular,Malo" class="input input-sm w-full"></div>
</div> </div>
@endif @endif
</div> </div>
@endforeach @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> </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"> <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> <button type="button" wire:click="cancelForm" class="btn">{{ __('Cancel') }}</button>
</div> </div>
</form> </form>
@@ -135,11 +127,11 @@
<table class="table table-zebra"> <table class="table table-zebra">
<thead> <thead>
<tr> <tr>
<th>Nombre</th> <th>{{ __('Name') }}</th>
<th>Descripción</th> <th>{{ __('Description') }}</th>
<th>Fase</th> <th>{{ __('Phase') }}</th>
<th>Campos</th> <th>{{ __('Fields') }}</th>
<th>Acciones</th> <th>{{ __('Actions') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -147,22 +139,24 @@
<tr> <tr>
<td>{{ $template->name }}</td> <td>{{ $template->name }}</td>
<td>{{ $template->description ?? '-' }}</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>{{ count($template->fields) }}</td>
<td> <td>
<button wire:click="editTemplate({{ $template->id }})" class="btn btn-xs btn-warning"> <button wire:click="editTemplate({{ $template->id }})" class="btn btn-xs btn-warning">
Editar {{ __('Edit') }}
</button> </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> </td>
</tr> </tr>
@empty @empty
<tr> <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> </tr>
@endforelse @endforelse
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
+4 -6
View File
@@ -95,12 +95,10 @@ Route::middleware(['auth'])->group(function () {
'recentIssues' => $recentIssues, 'recentIssues' => $recentIssues,
]); ]);
})->name('dashboard'); })->name('dashboard');
Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboard');
// Reports — Admin only Route::prefix('reports')->name('reports.')->group(function () {
Route::middleware(['can:manage all'])->prefix('reports')->name('reports.')->group(function () { Route::get('export/projects', [App\Http\Controllers\Reports\ExportController::class, 'exportProjects'])->name('export.projects');
Route::get('/dashboard', ReportsDashboard::class)->name('dashboard'); Route::get('export/phases', [App\Http\Controllers\Reports\ExportController::class, 'exportPhases'])->name('export.phases');
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'); Route::get('export/inspections', [App\Http\Controllers\Reports\ExportController::class, 'exportInspections'])->name('export.inspections');
}); });