Files
construprogress/resources/views/livewire/projects/project-dashboard.blade.php
T
javier f8a1310c0f security: fix 27 vulnerabilities + UI integration (Issues tab, project nav, validation)
Security fixes (27 vulnerabilities across 20 files):
CRITICAL:
- MediaManager: whitelist mediable types prevents RCE via class instantiation
- MediaManager/OfflineSyncController: IDOR fixes, remove Auth::id()??1 fallback
- ClientProjects: verify project ownership on all mutations (IDOR)
- CompanyManagement: Admin role check on mount() and mutations (auth bypass)
- ProjectMap: scope feature/template lookups to current project (IDOR x5)
- PhaseList/TemplateManager/LayerManager: scope mutations to owned resources (IDOR)
- ProjectEditTabs: Gate::authorize on mount() and updateProject()
- routes/web.php: reports routes moved inside can:manage all middleware (auth bypass)

MEDIUM:
- layer-manager: escapeHtml() on Leaflet popup interpolations (XSS)
- MediaManager: server-side MIME validation + 50MB limit
- ProjectList/ProjectUsers/ProjectCompanies/PhaseProgress: auth checks added
- AdminUsers/ReportsDashboard/ExportController: role/permission checks added

LOW:
- config/session.php: secure cookie tied to production env
- OfflineSyncController: sanitize storage path (path traversal)

UI integration:
- project-map: Issues tab (4th) with open-count badge
- project-map: project navigation bar (Dashboard/Map/Gantt/Report/Issues)
- project-dashboard: action buttons for Map/Gantt/Report/Issues
- project-form: validation error summary + per-field @error spans
- template-manager: validation error display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 18:25:36 +02:00

401 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<div>
<x-slot name="header">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="{{ route('projects.list') }}" class="btn btn-ghost btn-sm px-2">
<x-heroicon-o-arrow-left class="w-4 h-4" />
</a>
<div>
<h2 class="font-bold text-xl leading-tight">{{ $project->name }}</h2>
@if($project->description)
<p class="text-sm text-gray-500 leading-tight mt-0.5">{{ Str::limit($project->description, 80) }}</p>
@endif
</div>
@php
$statusCfg = match($project->status) {
'in_progress' => ['badge-primary', 'En progreso'],
'completed' => ['badge-success', 'Completado'],
'paused' => ['badge-warning', 'Pausado'],
'planning' => ['badge-ghost', 'Planificación'],
default => ['badge-ghost', ucfirst(str_replace('_',' ',$project->status))],
};
@endphp
<span class="badge {{ $statusCfg[0] }} badge-sm">{{ $statusCfg[1] }}</span>
</div>
<div class="flex gap-2">
<a href="{{ route('projects.map', $project) }}" class="btn btn-outline btn-sm gap-1">
<x-heroicon-o-map class="w-4 h-4" />
{{ __('Map') }}
</a>
<a href="{{ route('projects.gantt', $project) }}" class="btn btn-outline btn-sm gap-1">
<x-heroicon-o-calendar-days class="w-4 h-4" />
{{ __('Gantt') }}
</a>
<a href="{{ route('projects.report', $project) }}" class="btn btn-outline btn-sm gap-1">
<x-heroicon-o-document-chart-bar class="w-4 h-4" />
{{ __('Report') }}
</a>
<a href="{{ route('projects.issues', $project) }}" class="btn btn-outline btn-sm gap-1">
<x-heroicon-o-exclamation-triangle class="w-4 h-4" />
{{ __('Issues') }}
</a>
</div>
</div>
</x-slot>
<div class="py-6">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
{{-- ── KPIs fila 1 ────────────────────────────────────────────────────── --}}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
{{-- Avance global --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Avance global</p>
<p class="mt-1 text-3xl font-bold text-green-600">{{ $stats['global_progress'] }}%</p>
<div class="mt-2 w-full bg-gray-200 rounded-full h-1.5">
<div class="bg-green-500 h-1.5 rounded-full transition-all" style="width: {{ $stats['global_progress'] }}%"></div>
</div>
</div>
<div class="p-3 bg-green-100 rounded-full ml-3 shrink-0">
<x-heroicon-o-chart-bar class="w-5 h-5 text-green-600" />
</div>
</div>
</div>
</div>
{{-- Fases --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Fases</p>
<p class="mt-1 text-3xl font-bold text-blue-600">{{ $stats['total_phases'] }}</p>
@if($stats['delayed_phases'] > 0)
<p class="text-xs text-red-500 mt-0.5">{{ $stats['delayed_phases'] }} con retraso</p>
@else
<p class="text-xs text-gray-400 mt-0.5">Sin retrasos</p>
@endif
</div>
<div class="p-3 {{ $stats['delayed_phases'] > 0 ? 'bg-red-100' : 'bg-blue-100' }} rounded-full">
<x-heroicon-o-rectangle-stack class="w-5 h-5 {{ $stats['delayed_phases'] > 0 ? 'text-red-500' : 'text-blue-600' }}" />
</div>
</div>
</div>
</div>
{{-- Elementos --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Elementos</p>
<p class="mt-1 text-3xl font-bold text-indigo-600">{{ $stats['total_features'] }}</p>
<p class="text-xs text-gray-400 mt-0.5">
{{ $stats['completed_features'] }} completados
· {{ $stats['verified_features'] }} verificados
</p>
</div>
<div class="p-3 bg-indigo-100 rounded-full">
<x-heroicon-o-map-pin class="w-5 h-5 text-indigo-600" />
</div>
</div>
</div>
</div>
{{-- Issues --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Issues abiertos</p>
<p class="mt-1 text-3xl font-bold {{ $stats['open_issues'] > 0 ? 'text-orange-600' : 'text-gray-400' }}">
{{ $stats['open_issues'] }}
</p>
@if($stats['critical_issues'] > 0)
<p class="text-xs text-red-600 font-semibold mt-0.5">{{ $stats['critical_issues'] }} críticos</p>
@else
<p class="text-xs text-gray-400 mt-0.5">0 críticos</p>
@endif
</div>
<div class="p-3 {{ $stats['open_issues'] > 0 ? 'bg-orange-100' : 'bg-gray-100' }} rounded-full">
<x-heroicon-o-exclamation-triangle class="w-5 h-5 {{ $stats['open_issues'] > 0 ? 'text-orange-600' : 'text-gray-400' }}" />
</div>
</div>
</div>
</div>
</div>
{{-- ── KPIs fila 2: Inspecciones ────────────────────────────────────────── --}}
<div class="grid grid-cols-3 gap-4">
<div class="card bg-base-100 shadow">
<div class="card-body p-4 flex-row items-center gap-4">
<div class="p-3 bg-gray-100 rounded-full shrink-0">
<x-heroicon-o-clipboard-document-list class="w-5 h-5 text-gray-600" />
</div>
<div>
<p class="text-xs text-gray-500 uppercase tracking-wider">Total inspecciones</p>
<p class="text-2xl font-bold">{{ $stats['total_inspections'] }}</p>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body p-4 flex-row items-center gap-4">
<div class="p-3 bg-green-100 rounded-full shrink-0">
<x-heroicon-o-check-circle class="w-5 h-5 text-green-600" />
</div>
<div>
<p class="text-xs text-gray-500 uppercase tracking-wider">Aprobadas</p>
<p class="text-2xl font-bold text-green-600">{{ $stats['passed_inspections'] }}</p>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body p-4 flex-row items-center gap-4">
<div class="p-3 {{ $stats['failed_inspections'] > 0 ? 'bg-red-100' : 'bg-gray-100' }} rounded-full shrink-0">
<x-heroicon-o-x-circle class="w-5 h-5 {{ $stats['failed_inspections'] > 0 ? 'text-red-600' : 'text-gray-400' }}" />
</div>
<div>
<p class="text-xs text-gray-500 uppercase tracking-wider">Rechazadas</p>
<p class="text-2xl font-bold {{ $stats['failed_inspections'] > 0 ? 'text-red-600' : 'text-gray-400' }}">
{{ $stats['failed_inspections'] }}
</p>
</div>
</div>
</div>
</div>
{{-- ── Main grid: fases + actividad reciente ───────────────────────────── --}}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- LEFT 2/3: Fases con progreso --}}
<div class="lg:col-span-2 space-y-4">
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<div class="flex justify-between items-center mb-4">
<h3 class="font-semibold text-base flex items-center gap-2">
<x-heroicon-o-rectangle-stack class="w-4 h-4" />
Fases del proyecto
</h3>
<a href="{{ route('projects.gantt', $project) }}" class="btn btn-xs btn-outline gap-1">
<x-heroicon-o-calendar-days class="w-3.5 h-3.5" />
Gantt
</a>
</div>
@if($phases->isEmpty())
<p class="text-sm text-gray-400 text-center py-6">Sin fases aún.</p>
@else
<div class="space-y-3">
@foreach($phases as $phase)
@php
$pct = round($phase->progress_percent ?? 0);
$isDelayed = $phase->planned_end && $phase->planned_end < now() && $pct < 100;
$barColor = $isDelayed ? 'bg-red-500' : ($pct >= 100 ? 'bg-green-500' : 'bg-blue-500');
$featureCount = $phase->layers->sum('features_count');
@endphp
<div class="border border-base-200 rounded-lg p-3 hover:border-primary/40 transition-colors">
<div class="flex items-start justify-between gap-2 mb-2">
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm truncate">{{ $phase->name }}</p>
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-500 mt-0.5">
<span>{{ $phase->layers_count }} capa(s)</span>
<span>·</span>
<span>{{ $featureCount }} elementos</span>
@if($phase->planned_start && $phase->planned_end)
<span>·</span>
<span>{{ $phase->planned_start->format('d/m/y') }} {{ $phase->planned_end->format('d/m/y') }}</span>
@endif
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
@if($isDelayed)
<span class="badge badge-error badge-xs">Retraso</span>
@elseif($pct >= 100)
<span class="badge badge-success badge-xs">Completada</span>
@endif
<span class="text-sm font-bold {{ $isDelayed ? 'text-red-600' : ($pct >= 100 ? 'text-green-600' : 'text-blue-600') }}">
{{ $pct }}%
</span>
</div>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="{{ $barColor }} h-2 rounded-full transition-all" style="width: {{ $pct }}%"></div>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
{{-- Empresas participantes --}}
@if($companies->isNotEmpty())
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<h3 class="font-semibold text-base flex items-center gap-2 mb-3">
<x-heroicon-o-building-office-2 class="w-4 h-4" />
Empresas participantes
</h3>
<div class="flex flex-wrap gap-3">
@foreach($companies as $company)
<div class="flex items-center gap-2 border border-base-200 rounded-lg px-3 py-2">
@if($company->logo_path && \Illuminate\Support\Facades\Storage::disk('public')->exists($company->logo_path))
<img src="{{ \Illuminate\Support\Facades\Storage::disk('public')->url($company->logo_path) }}"
alt="" class="w-7 h-7 object-contain rounded" />
@else
<x-heroicon-o-building-office class="w-5 h-5 opacity-40" />
@endif
<div>
<p class="text-xs font-semibold leading-tight">{{ $company->apodo ?: $company->name }}</p>
@if($company->pivot->role_in_project)
<p class="text-xs text-gray-400">{{ $company->pivot->role_in_project }}</p>
@endif
</div>
</div>
@endforeach
</div>
</div>
</div>
@endif
</div>
{{-- RIGHT 1/3: Actividad reciente --}}
<div class="space-y-5">
{{-- Equipo --}}
@if($teamMembers->isNotEmpty())
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<h3 class="font-semibold text-base flex items-center gap-2 mb-3">
<x-heroicon-o-users class="w-4 h-4" />
Equipo ({{ $teamMembers->count() }})
</h3>
<div class="space-y-2">
@foreach($teamMembers->take(8) as $member)
<div class="flex items-center gap-2">
<div class="avatar placeholder shrink-0">
<div class="bg-neutral text-neutral-content rounded-full w-7">
<span class="text-xs">{{ strtoupper(substr($member->name, 0, 1)) }}</span>
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ $member->name }}</p>
@if($member->pivot->role_in_project)
<p class="text-xs text-gray-400 truncate">{{ $member->pivot->role_in_project }}</p>
@endif
</div>
@foreach($member->roles->take(1) as $role)
<span class="badge badge-xs {{ $role->name === 'Admin' ? 'badge-error' : 'badge-ghost' }}">{{ $role->name }}</span>
@endforeach
</div>
@endforeach
</div>
</div>
</div>
@endif
{{-- Issues recientes --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-base flex items-center gap-2">
<x-heroicon-o-exclamation-triangle class="w-4 h-4 text-orange-500" />
Issues abiertos
</h3>
<a href="{{ route('projects.issues', $project) }}" class="btn btn-xs btn-outline">{{ __('View all') }}</a>
</div>
@if($recentIssues->isEmpty())
<div class="text-center py-4 text-gray-400">
<x-heroicon-o-check-circle class="w-7 h-7 mx-auto mb-1 opacity-25" />
<p class="text-xs">Sin issues abiertos</p>
</div>
@else
<div class="space-y-2">
@foreach($recentIssues as $issue)
@php
$pCfg = match($issue->priority ?? 'medium') {
'critical' => 'badge-error',
'high' => 'badge-warning',
'medium' => 'badge-info',
default => 'badge-ghost',
};
@endphp
<div class="p-2.5 rounded-lg bg-base-200">
<div class="flex items-start gap-1.5">
<span class="badge badge-xs {{ $pCfg }} shrink-0 mt-0.5">{{ ucfirst($issue->priority ?? 'medium') }}</span>
<p class="text-xs font-medium truncate">{{ $issue->title }}</p>
</div>
@if($issue->feature)
<p class="text-xs text-gray-400 mt-0.5 truncate">
<x-heroicon-o-map-pin class="w-2.5 h-2.5 inline" /> {{ $issue->feature->name }}
</p>
@endif
</div>
@endforeach
</div>
@endif
</div>
</div>
{{-- Inspecciones recientes --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-base flex items-center gap-2">
<x-heroicon-o-clipboard-document-list class="w-4 h-4 text-yellow-500" />
Inspecciones recientes
</h3>
<a href="{{ route('projects.map', $project) }}?tab=inspections" class="btn btn-xs btn-outline">{{ __('View on map') }}</a>
</div>
@if($recentInspections->isEmpty())
<div class="text-center py-4 text-gray-400">
<x-heroicon-o-clipboard-document-list class="w-7 h-7 mx-auto mb-1 opacity-25" />
<p class="text-xs">Sin inspecciones</p>
</div>
@else
<div class="space-y-2">
@foreach($recentInspections as $ins)
@php
$iCfg = match($ins->result ?? '') {
'pass' => ['badge-success', 'OK'],
'fail' => ['badge-error', 'Fallo'],
default => ['badge-ghost', 'Pendiente'],
};
@endphp
<div class="p-2.5 rounded-lg bg-base-200">
<div class="flex items-start justify-between gap-1">
<p class="text-xs font-medium truncate flex-1">
{{ $ins->template?->name ?? 'Inspección' }}
</p>
<span class="badge badge-xs {{ $iCfg[0] }} shrink-0">{{ $iCfg[1] }}</span>
</div>
<div class="flex items-center justify-between mt-0.5">
@if($ins->feature)
<p class="text-xs text-gray-400 truncate">
<x-heroicon-o-map-pin class="w-2.5 h-2.5 inline" /> {{ $ins->feature->name }}
</p>
@endif
<p class="text-xs text-gray-400 shrink-0 ml-1">{{ $ins->created_at->diffForHumans() }}</p>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
</div>
{{-- end right --}}
</div>
{{-- end main grid --}}
</div>
</div>
</div>{{-- end root --}}