- {{-- Tabs --}}
-
-
-
- Resumen
-
-
-
- Personas
- {{ $usersCount }}
-
-
-
- Proyectos
- {{ $projectsCount }}
-
-
-
- Notas
- @if($company->notes)
- •
- @endif
-
-
+
+
+
+
+ {{ $project ? 'Calendario: ' . $project->name : 'Calendario - Todas las tareas' }}
+
+
+ Vista de calendario de tareas
+
+
+
+
+
+
+
+
+
+ Buscar
+
+
+
+
+ Vista
+
+ Mes
+ Semana
+ Día
+ Lista
+
+
+
+
+ Asignado
+
+ Todos
+ @foreach($assignees as $assignee)
+ {{ $assignee->name }}
+ @endforeach
+
+
+
+ @if($project)
+
+ Fase
+
+ Todas
+ @foreach($phases as $phase)
+ {{ $phase->name }}
+ @endforeach
+
+
+ @endif
+
+
+ Prioridad
+
+ Todas
+ @foreach($priorityOptions as $value => $label)
+ {{ $label }}
+ @endforeach
+
+
+
+
+ Estado
+
+ Todos
+ @foreach($statusOptions as $value => $label)
+ {{ $label }}
+ @endforeach
+
+
+
+
+
+
+
+
+
+
+
Leyenda
+
+ @foreach(['low' => '#6b7280', 'medium' => '#f59e0b', 'high' => '#ef4444', 'critical' => '#7c3aed'] as $key => $color)
+
+
+ {{ \App\Models\Task::priorityOptions()[$key] ?? ucfirst($key) }}
+
+ @endforeach
+
+
+ Tareas vencidas
+
+
+
+ Vence hoy
+
+
+
+
+
+@push('scripts')
+
+
+
+
+
+@endpush
\ No newline at end of file
diff --git a/resources/views/livewire/tasks/task-detail.blade.php b/resources/views/livewire/tasks/task-detail.blade.php
new file mode 100644
index 0000000..f06a718
--- /dev/null
+++ b/resources/views/livewire/tasks/task-detail.blade.php
@@ -0,0 +1,319 @@
+
+
+
+
+
+
+
+ {{ $task->status_label }}
+
+
+ {{ $task->priority_label }}
+
+ @if($task->phase)
+
+ {{ $task->phase->name }}
+
+ @endif
+
+
+
+ @can('edit tasks', $task)
+
+
+
+
+ Editar
+
+ @endcan
+
+
+
+
+
+ Acciones
+
+
+ @foreach($statusOptions as $value => $label)
+
+ @if($task->status === $value)
+
+ @endif
+ {{ $label }}
+
+ @endforeach
+
+
+
+
+
+
+
+
+
+
+
Descripción
+ @if($task->description)
+
{{ $task->description }}
+ @else
+
Sin descripción
+ @endif
+
+
+
+
+
+
+ Subtareas ({{ $task->subtasks->count() }})
+
+ @can('create tasks', $task)
+
+
+ Añadir
+
+ @endcan
+
+
+ @if($task->subtasks->isEmpty())
+
+ No hay subtareas. {{ auth()->user()->can('create tasks', $task) ? 'Crea la primera arriba.' : '' }}
+
+ @else
+
+ @foreach($task->subtasks as $subtask)
+
+
+
+ @foreach($statusOptions as $value => $label)
+ status === $value ? 'selected' : '' }}>{{ $label }}
+ @endforeach
+
+ {{ $subtask->title }}
+
+
+ @can('delete tasks', $subtask)
+
title }}\"?" class="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 p-1">
+
+
+ @endcan
+
+ @endforeach
+
+ @endif
+
+
+
+
+
+
Comentarios
+
+
+
+
+
+ @forelse($task->comments->where('parent_id', null) as $comment)
+
+
+
+ {{ strtoupper($comment->user->name[0]) }}
+
+
+
+ {{ $comment->user->name }}
+ {{ $comment->created_at->diffForHumans() }}
+ @if($comment->user_id === auth()->id() || auth()->user()->can('manage all tasks'))
+ Editar
+ Eliminar
+ @endif
+
+ @if($editing)
+
+
+ Guardar
+ Cancelar
+
+ @else
+
{{ $comment->body }}
+ @endif
+
+
+ @if($comment->replies->isNotEmpty())
+
+ @foreach($comment->replies as $reply)
+
+
+ {{ strtoupper($reply->user->name[0]) }}
+
+
+
+ {{ $reply->user->name }}
+ {{ $reply->created_at->diffForHumans() }}
+
+
{{ $reply->body }}
+
+
+ @endforeach
+
+ @endif
+
+
+
+ @empty
+
No hay comentarios aún
+ @endforelse
+
+
+
+
+
+
+
+
+
Información
+
+
+
Proyecto
+ {{ $task->project->name }}
+
+ @if($task->phase)
+
+
Fase
+ {{ $task->phase->name }}
+
+ @endif
+ @if($task->parent_task_id)
+
+ @endif
+
+
Creada por
+ {{ $task->creator->name }}
+
+
+
Fecha creación
+ {{ $task->created_at->format('d/m/Y H:i') }}
+
+ @if($task->due_date)
+
+
Vence
+
+ {{ $task->due_date->format('d/m/Y') }}
+ @if($task->is_overdue) (Vencida) @endif
+ @if($task->is_due_today) (Hoy) @endif
+
+
+ @endif
+ @if($task->start_date)
+
+
Inicio
+ {{ $task->start_date->format('d/m/Y') }}
+
+ @endif
+ @if($task->completed_at)
+
+
Completada
+ {{ $task->completed_at->format('d/m/Y H:i') }}
+
+
+
Completada por
+ {{ $task->completer->name ?? '—' }}
+
+ @endif
+ @if($task->estimated_hours || $task->actual_hours)
+
+
Horas
+
+ {{ $task->estimated_hours ? $task->estimated_hours . 'h estimadas' : '' }}
+ @if($task->estimated_hours && $task->actual_hours) / @endif
+ {{ $task->actual_hours ? $task->actual_hours . 'h reales' : '' }}
+
+
+ @endif
+
+
+
+
+
+
Asignación
+ @if($task->assignee)
+
+
+ {{ strtoupper($task->assignee->name[0]) }}
+
+
+
{{ $task->assignee->name }}
+
{{ $task->assignee->email }}
+
+
+ @else
+
Sin asignar
+ @endif
+
+ @can('assign tasks', $task)
+
+ @endcan
+
+
+
+ @if($task->subtasks->count() > 0)
+
+
Progreso
+
+
+ Completado
+ {{ $task->progress }}%
+
+
+
+
+ {{ $task->subtasks->where('status', 'completed')->count() }} de {{ $task->subtasks->count() }} subtareas completadas
+
+
+ @endif
+
+
+
+
+@push('scripts')
+
+@endpush
\ No newline at end of file
diff --git a/resources/views/livewire/tasks/task-form.blade.php b/resources/views/livewire/tasks/task-form.blade.php
new file mode 100644
index 0000000..5fa93bb
--- /dev/null
+++ b/resources/views/livewire/tasks/task-form.blade.php
@@ -0,0 +1,177 @@
+
+
+@push('scripts')
+
+@endpush
\ No newline at end of file
diff --git a/resources/views/livewire/tasks/task-kanban.blade.php b/resources/views/livewire/tasks/task-kanban.blade.php
new file mode 100644
index 0000000..4276745
--- /dev/null
+++ b/resources/views/livewire/tasks/task-kanban.blade.php
@@ -0,0 +1,243 @@
+
+
+
+
+
+ {{ $project ? 'Kanban: ' . $project->name : 'Kanban - Todas las tareas' }}
+
+
+ Arrastra y suelta para cambiar estado
+
+
+
+
+
+
+
+
+
+ Buscar
+
+
+
+
+ Asignado
+
+ Todos
+ @foreach($assignees as $assignee)
+ {{ $assignee->name }}
+ @endforeach
+
+
+
+ @if($project)
+
+ Fase
+
+ Todas
+ @foreach($phases as $phase)
+ {{ $phase->name }}
+ @endforeach
+
+
+ @endif
+
+
+ Prioridad
+
+ Todas
+ @foreach($priorityOptions as $value => $label)
+ {{ $label }}
+ @endforeach
+
+
+
+
+
+
+ Solo vencidas
+
+
+
+
+
+ Limpiar
+
+
+
+
+
+
+
+ @foreach($columns as $status => $column)
+
+
+
+
+
{{ $column['label'] }}
+
+ {{ $column['count'] }}
+
+
+
+
+
+
+ @if($column['tasks']->isEmpty())
+
+ @endif
+
+ @foreach($column['tasks'] as $task)
+
id }}, '{{ $status }}')"
+ @dragend="onDragEnd($event)"
+ data-task-id="{{ $task->id }}"
+ data-status="{{ $status }}">
+
+
+
+
+ {{ $task->title }}
+
+
+ @if($task->description)
+
{{ $task->description }}
+ @endif
+
+
+ @if($task->phase)
+
+ {{ $task->phase->name }}
+
+ @endif
+ @if($task->due_date)
+
+ {{ $task->due_date->format('d/m') }}
+ @if($task->is_overdue)
+
+ @elseif($task->is_due_today)
+
+ @endif
+
+ @endif
+
+
+
+ @if($task->assignee)
+
+
+ {{ strtoupper($task->assignee->name[0]) }}
+
+
{{ $task->assignee->name }}
+
+ @else
+
Sin asignar
+ @endif
+
+ @if($task->subtasks->count() > 0)
+
+ {{ $task->subtasks->where('status', 'completed')->count() }}/{{ $task->subtasks->count() }}
+
+ @endif
+
+
+ @endforeach
+
+
+
+ @can('create tasks', $project ?? \App\Models\Project::first())
+
+
+
+
+
+ Añadir tarea
+
+
+ @endcan
+
+ @endforeach
+
+
+
+@push('scripts')
+
+@endpush
\ No newline at end of file
diff --git a/resources/views/livewire/tasks/task-manager.blade.php b/resources/views/livewire/tasks/task-manager.blade.php
new file mode 100644
index 0000000..8010221
--- /dev/null
+++ b/resources/views/livewire/tasks/task-manager.blade.php
@@ -0,0 +1,258 @@
+
+
+
+
+
+ {{ $project ? 'Tareas del proyecto: ' . $project->name : 'Tareas' }}
+
+
+ {{ $project ? 'Gestiona las tareas de este proyecto' : 'Todas las tareas accesibles' }}
+
+
+
+ @can('create tasks', $project ?? \App\Models\Project::first())
+
+
+
+
+ Nueva Tarea
+
+ @endcan
+
+
+
+
+
+
+
+ {{ $viewMode === 'table' ? 'Vista Kanban' : 'Vista Tabla' }}
+
+
+
+
+
+
+
+
+ Buscar
+
+
+
+
+ Estado
+
+ Todos
+ @foreach($statusOptions as $value => $label)
+ {{ $label }}
+ @endforeach
+
+
+
+
+ Prioridad
+
+ Todas
+ @foreach($priorityOptions as $value => $label)
+ {{ $label }}
+ @endforeach
+
+
+
+
+ Asignado
+
+ Todos
+ @foreach($assignees as $assignee)
+ {{ $assignee->name }}
+ @endforeach
+
+
+
+ @if($project)
+
+ Fase
+
+ Todas
+ @foreach($phases as $phase)
+ {{ $phase->name }}
+ @endforeach
+
+
+ @endif
+
+
+
+
+ Solo vencidas
+
+
+
+
+
+ Limpiar
+
+
+
+
+
+
+
+ @if($tasks->isEmpty())
+
+
+
+
+
No hay tareas
+
+ {{ $project ? 'Comienza creando la primera tarea para este proyecto.' : 'No se encontraron tareas con los filtros actuales.' }}
+
+
+ @else
+
+
+
+
+ @foreach([
+ 'title' => 'Tarea',
+ 'project.name' => 'Proyecto',
+ 'phase.name' => 'Fase',
+ 'status' => 'Estado',
+ 'priority' => 'Prioridad',
+ 'assignee.name' => 'Asignado',
+ 'due_date' => 'Vence',
+ 'actions' => '',
+ ] as $key => $label)
+
+
+ {{ $label }}
+ @if($sortField === $key)
+
+
+
+ @else
+
+
+
+ @endif
+
+
+ @endforeach
+
+
+
+ @foreach($tasks as $task)
+
+
+
+
+ {{ $task->title }}
+
+ @if($task->description)
+
{{ $task->description }}
+ @endif
+ @if($task->subtasks->count() > 0)
+
+
+ {{ $task->subtasks->where('status', 'completed')->count() }} / {{ $task->subtasks->count() }} subtareas
+
+
+
+ @endif
+
+
+
+ {{ $task->project->name ?? '—' }}
+
+
+ {{ $task->phase->name ?? '—' }}
+
+
+
+ {{ $task->status_label }}
+
+
+
+
+ {{ $task->priority_label }}
+
+
+
+ @if($task->assignee)
+
+
+ {{ strtoupper($task->assignee->name[0]) }}
+
+ {{ $task->assignee->name }}
+
+ @else
+ —
+ @endif
+
+
+ @if($task->due_date)
+
+ {{ $task->due_date->format('d/m/Y') }}
+ @if($task->is_overdue)
+
+ @elseif($task->is_due_today)
+
+ @endif
+
+ @else
+ —
+ @endif
+
+
+
+ @can('edit tasks', $task)
+
+ Editar
+
+ @endcan
+ @can('delete tasks', $task)
+
title }}\"?"
+ class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 text-sm font-medium">
+ Eliminar
+
+ @endcan
+
+
+
+ @endforeach
+
+
+
+
+
+
+ {{ $tasks->links() }}
+
+ @endif
+
+
+
+
+
+
+
+
+
Vista Kanban
+
En desarrollo - Próximamente
+
+
+
\ No newline at end of file
diff --git a/routes/console.php b/routes/console.php
index f6b454f..852446d 100644
--- a/routes/console.php
+++ b/routes/console.php
@@ -1,5 +1,6 @@
dailyAt('07:00');
+
+// Avisar de tareas vencidas (tareas independientes)
+Schedule::job(new NotifyOverdueTasks)->dailyAt('07:00');
diff --git a/routes/web.php b/routes/web.php
index 217f378..34f6d98 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,14 +1,43 @@
group(function () {
// Home: vista ligera (proyectos, tareas e incidencias del usuario + notificaciones)
Route::get('/dashboard', function () {
- $user = \Illuminate\Support\Facades\Auth::user();
+ $user = Auth::user();
- $projects = \App\Models\Project::accessibleBy($user)
+ $projects = Project::accessibleBy($user)
->with(['phases' => fn ($q) => $q->select('id', 'project_id', 'progress_percent')])
->orderBy('name')->take(8)->get();
- $projectsCount = \App\Models\Project::accessibleBy($user)->count();
+ $projectsCount = Project::accessibleBy($user)->count();
- $myTasks = \App\Models\IssueTask::where('assigned_to', $user->id)
+ $myTasks = IssueTask::where('assigned_to', $user->id)
->where('is_done', false)
->with('issue.project')
->orderByRaw('due_date IS NULL, due_date ASC')
->take(8)->get();
- $myTasksCount = \App\Models\IssueTask::where('assigned_to', $user->id)->where('is_done', false)->count();
- $myIssues = \App\Models\Issue::where('assigned_to', $user->id)
+ $myTasksFromTasks = Task::where('assigned_to', $user->id)
+ ->where('status', '!=', 'completed')
+ ->with('project')
+ ->orderByRaw('due_date IS NULL, due_date ASC')
+ ->take(8)->get();
+
+ $myTasksCount = IssueTask::where('assigned_to', $user->id)->where('is_done', false)->count()
+ + Task::where('assigned_to', $user->id)->where('status', '!=', 'completed')->count();
+
+ $myIssues = Issue::where('assigned_to', $user->id)
->whereIn('status', ['open', 'in_review'])
->with('project')
->latest()->take(6)->get();
- $myIssuesCount = \App\Models\Issue::where('assigned_to', $user->id)->whereIn('status', ['open', 'in_review'])->count();
+ $myIssuesCount = Issue::where('assigned_to', $user->id)->whereIn('status', ['open', 'in_review'])->count();
$notifications = $user->notifications()->latest()->take(6)->get();
- $unreadCount = $user->unreadNotifications()->count();
+ $unreadCount = $user->unreadNotifications()->count();
return view('dashboard', compact(
- 'user', 'projects', 'projectsCount', 'myTasks', 'myTasksCount',
+ 'user', 'projects', 'projectsCount', 'myTasks', 'myTasksFromTasks', 'myTasksCount',
'myIssues', 'myIssuesCount', 'notifications', 'unreadCount'
));
})->name('dashboard');
-
-Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboard');
+
+ Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboard');
Route::prefix('reports')->name('reports.')->group(function () {
- Route::get('export/projects', [App\Http\Controllers\Reports\ExportController::class, 'exportProjects'])->name('export.projects');
- Route::get('export/phases', [App\Http\Controllers\Reports\ExportController::class, 'exportPhases'])->name('export.phases');
- Route::get('export/inspections', [App\Http\Controllers\Reports\ExportController::class, 'exportInspections'])->name('export.inspections');
+ Route::get('export/projects', [ExportController::class, 'exportProjects'])->name('export.projects');
+ Route::get('export/phases', [ExportController::class, 'exportPhases'])->name('export.phases');
+ Route::get('export/inspections', [ExportController::class, 'exportInspections'])->name('export.inspections');
});
// ------------------------------------------------------------
// Gestión de proyectos
// ------------------------------------------------------------
// Create/Edit handled by unified Livewire component
- Route::get('/projects/create', \App\Livewire\Projects\ProjectForm::class)->name('projects.create');
- Route::get('/projects/{project}/edit', \App\Livewire\Projects\ProjectForm::class)->name('projects.edit');
+ Route::get('/projects/create', ProjectForm::class)->name('projects.create');
+ Route::get('/projects/{project}/edit', ProjectForm::class)->name('projects.edit');
Route::resource('projects', ProjectController::class)->except(['create', 'edit']);
// Ruta personalizada para ver el mapa de un proyecto específico
Route::get('/projects/{project}/map', [ProjectController::class, 'map'])->name('projects.map');
// Ruta para que el componente Livewire muestre/gestione el progreso de una fase
Route::get('/phases/{phase}/progress', PhaseProgress::class)->name('phases.progress');
Route::get('/projects-list', ProjectList::class)->name('projects.list');
-
+
// Ruta para templates
// Plantillas globales (catálogo único)
- Route::get('/inspection-templates', \App\Livewire\Inspections\GlobalTemplateManager::class)
+ Route::get('/inspection-templates', GlobalTemplateManager::class)
->middleware('can:manage templates')->name('inspection-templates');
// Plantillas asignadas a un proyecto (picker sobre el catálogo global)
- Route::get('/projects/{project}/templates', \App\Livewire\Projects\ProjectTemplatesPicker::class)
+ Route::get('/projects/{project}/templates', ProjectTemplatesPicker::class)
->middleware('can:edit projects')->name('projects.templates');
-
+
// Rutas para el LayerManager:
- Route::get('/projects/{project}/phases/{phase}/layers/manage', \App\Livewire\Layers\LayerManager::class)->name('layers.manage');
+ Route::get('/projects/{project}/phases/{phase}/layers/manage', LayerManager::class)->name('layers.manage');
// Elementos del proyecto y catálogo de tipos de elemento
- Route::get('/projects/{project}/features', \App\Livewire\Projects\FeatureManager::class)->middleware('can:edit layers')->name('projects.features');
- Route::get('/feature-types', \App\Livewire\Projects\FeatureTypeManager::class)->middleware('can:edit layers')->name('feature-types');
+ Route::get('/projects/{project}/features', FeatureManager::class)->middleware('can:edit layers')->name('projects.features');
+ Route::get('/feature-types', FeatureTypeManager::class)->middleware('can:edit layers')->name('feature-types');
// Cronograma Gantt y reporte del proyecto
Route::get('/projects/{project}/gantt', PhaseGantt::class)->name('projects.gantt');
Route::get('/projects/{project}/report', [ProjectReportController::class, 'show'])->name('projects.report');
// Issues del proyecto
- Route::get('/projects/{project}/issues', \App\Livewire\Issues\IssueManager::class)->name('projects.issues');
- Route::get('/projects/{project}/issues/create', \App\Livewire\Issues\IssueForm::class)->name('projects.issues.create');
- Route::get('/projects/{project}/issues/checklists', \App\Livewire\Issues\IssueChecklistManager::class)->name('projects.issues.checklists');
- Route::get('/projects/{project}/issues/{issue}', \App\Livewire\Issues\IssueDetail::class)->name('projects.issues.show');
- Route::get('/projects/{project}/issues/{issue}/edit', \App\Livewire\Issues\IssueForm::class)->name('projects.issues.edit');
+ Route::get('/projects/{project}/issues', IssueManager::class)->name('projects.issues');
+ Route::get('/projects/{project}/issues/create', IssueForm::class)->name('projects.issues.create');
+ Route::get('/projects/{project}/issues/checklists', IssueChecklistManager::class)->name('projects.issues.checklists');
+ Route::get('/projects/{project}/issues/{issue}', IssueDetail::class)->name('projects.issues.show');
+ Route::get('/projects/{project}/issues/{issue}/edit', IssueForm::class)->name('projects.issues.edit');
+
+ // Tasks del proyecto
+ Route::get('/projects/{project}/tasks', TaskManager::class)->name('projects.tasks');
+ Route::get('/projects/{project}/tasks/create', TaskForm::class)->name('projects.tasks.create');
+ Route::get('/projects/{project}/tasks/{task}', TaskDetail::class)->name('projects.tasks.show');
+ Route::get('/projects/{project}/tasks/{task}/edit', TaskForm::class)->name('projects.tasks.edit');
+ Route::get('/projects/{project}/tasks/kanban', TaskKanban::class)->name('projects.tasks.kanban');
+ Route::get('/projects/{project}/tasks/calendar', TaskCalendar::class)->name('projects.tasks.calendar');
// Dashboard por proyecto
- Route::get('/projects/{project}/dashboard', \App\Livewire\Projects\ProjectDashboard::class)->name('projects.dashboard');
+ Route::get('/projects/{project}/dashboard', ProjectDashboard::class)->name('projects.dashboard');
// Cliente: portal cliente
Route::middleware(['auth', 'role:client'])->prefix('client')->name('client.')->group(function () {
@@ -115,25 +160,29 @@ Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboa
// Admin: gestión de usuarios y roles (cada ruta protegida por su permiso)
Route::prefix('admin')->name('admin.')->group(function () {
- Route::get('/users', function () { return view('admin.users'); })->middleware('can:view users')->name('users');
- Route::get('/users/create', \App\Livewire\Users\UserForm::class)->middleware('can:create users')->name('users.create');
- Route::get('/users/{user}', \App\Livewire\Users\UserView::class)->middleware('can:view users')->name('users.show');
- Route::get('/users/{user}/edit', \App\Livewire\Users\UserForm::class)->middleware('can:edit users')->name('users.edit');
- Route::get('/roles', function () { return view('admin.roles'); })->middleware('can:manage roles')->name('roles');
- Route::get('/roles/create', \App\Livewire\Admin\RoleForm::class)->middleware('can:manage roles')->name('roles.create');
- Route::get('/roles/{role}/edit', \App\Livewire\Admin\RoleForm::class)->middleware('can:manage roles')->name('roles.edit');
- Route::get('/roles/{role}', \App\Livewire\Admin\RoleView::class)->middleware('can:manage roles')->name('roles.show');
- Route::get('/permissions', \App\Livewire\Admin\RolePermissionManager::class)->middleware('can:manage roles')->name('permissions');
+ Route::get('/users', function () {
+ return view('admin.users');
+ })->middleware('can:view users')->name('users');
+ Route::get('/users/create', UserForm::class)->middleware('can:create users')->name('users.create');
+ Route::get('/users/{user}', UserView::class)->middleware('can:view users')->name('users.show');
+ Route::get('/users/{user}/edit', UserForm::class)->middleware('can:edit users')->name('users.edit');
+ Route::get('/roles', function () {
+ return view('admin.roles');
+ })->middleware('can:manage roles')->name('roles');
+ Route::get('/roles/create', RoleForm::class)->middleware('can:manage roles')->name('roles.create');
+ Route::get('/roles/{role}/edit', RoleForm::class)->middleware('can:manage roles')->name('roles.edit');
+ Route::get('/roles/{role}', RoleView::class)->middleware('can:manage roles')->name('roles.show');
+ Route::get('/permissions', RolePermissionManager::class)->middleware('can:manage roles')->name('permissions');
});
// Gestor de medios
- Route::get('/projects/{project}/media', function (\App\Models\Project $project) {
+ Route::get('/projects/{project}/media', function (Project $project) {
return view('projects.media', compact('project'));
})->name('projects.media');
- Route::get('/companies', \App\Livewire\Companies\CompanyManagement::class)->name('companies.manage');
- Route::get('/companies/create', \App\Livewire\Companies\CompanyForm::class)->name('companies.create');
- Route::get('/companies/{company}', \App\Livewire\Companies\CompanyView::class)->name('companies.show');
- Route::get('/companies/{company}/edit', \App\Livewire\Companies\CompanyForm::class)->name('companies.edit');
+ Route::get('/companies', CompanyManagement::class)->name('companies.manage');
+ Route::get('/companies/create', CompanyForm::class)->name('companies.create');
+ Route::get('/companies/{company}', CompanyView::class)->name('companies.show');
+ Route::get('/companies/{company}/edit', CompanyForm::class)->name('companies.edit');
// ------------------------------------------------------------
// Sincronización offline (para trabajadores en campo)
@@ -145,7 +194,7 @@ Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboa
// Perfil de usuario (proporcionado por Laravel Breeze)
// ------------------------------------------------------------
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile');
- //Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
+ // Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
// Logout se gestiona vía la acción Volt en el componente de navegación
@@ -153,4 +202,4 @@ Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboa
});
// Incluir rutas de autenticación (login, registro, recuperación de contraseña, logout)
-require __DIR__ . '/auth.php';
+require __DIR__.'/auth.php';
diff --git a/tests/Feature/InspectionFormTest.php b/tests/Feature/InspectionFormTest.php
index d39aa4d..c755d97 100644
--- a/tests/Feature/InspectionFormTest.php
+++ b/tests/Feature/InspectionFormTest.php
@@ -7,6 +7,7 @@ use App\Models\Feature;
use App\Models\Inspection;
use App\Models\InspectionTemplate;
use App\Models\Layer;
+use App\Models\Media;
use App\Models\Phase;
use App\Models\Project;
use App\Models\User;
@@ -20,14 +21,16 @@ class InspectionFormTest extends TestCase
{
use RefreshDatabase;
- public function test_register_inspection_with_comments_and_photo(): void
+ protected function setUp(): void
{
+ parent::setUp();
Storage::fake('public');
-
- // Seed roles and permissions
$this->seed(\Database\Seeders\RolesAndPermissionsSeeder::class);
$this->seed(\Database\Seeders\PermissionCatalogSeeder::class);
+ }
+ private function createTestData(): array
+ {
$user = User::factory()->create();
$user->assignRole('Supervisor');
@@ -50,10 +53,15 @@ class InspectionFormTest extends TestCase
['group' => 'Geometría', 'name' => 'altura', 'label' => 'Altura', 'question' => '¿Cota OK?', 'type' => 'text', 'required' => false, 'help' => 'Medir'],
],
]);
-
- // Attach template to project (pivot required for loadTemplates)
$project->inspectionTemplates()->attach($template->id);
+ return [$user, $project, $feature, $template];
+ }
+
+ public function test_register_inspection_with_comments_and_photo(): void
+ {
+ [$user, $project, $feature, $template] = $this->createTestData();
+
Livewire::actingAs($user)
->test(ProjectMap::class, ['project' => $project])
->call('selectFeature', $feature->id)
@@ -69,4 +77,269 @@ class InspectionFormTest extends TestCase
$this->assertEquals('5.2', $ins->data['altura']);
$this->assertCount(1, $ins->media);
}
+
+ public function test_edit_inspection_updates_data_and_photos(): void
+ {
+ [$user, $project, $feature, $template] = $this->createTestData();
+
+ // Create initial inspection
+ $inspection = Inspection::create([
+ 'project_id' => $project->id,
+ 'layer_id' => $feature->layer_id,
+ 'feature_id' => $feature->id,
+ 'template_id' => $template->id,
+ 'user_id' => $user->id,
+ 'inspector_user_id' => $user->id,
+ 'status' => 'completed',
+ 'completed_at' => now(),
+ 'result' => 'pass',
+ 'notes' => 'Original note',
+ 'data' => ['altura' => '5.0'],
+ ]);
+ $photo1 = Media::create([
+ 'mediable_type' => Inspection::class,
+ 'mediable_id' => $inspection->id,
+ 'name' => 'original.jpg',
+ 'file_path' => 'uploads/inspections/' . $inspection->id . '/original.jpg',
+ 'file_type' => 'image/jpeg',
+ 'file_extension' => 'jpg',
+ 'file_size' => 1000,
+ 'category' => 'image',
+ 'uploaded_by' => $user->id,
+ ]);
+
+ Livewire::actingAs($user)
+ ->test(ProjectMap::class, ['project' => $project])
+ ->call('selectFeature', $feature->id)
+ ->call('editInspection', $inspection->id)
+ ->assertSet('editingInspection.id', $inspection->id)
+ ->assertSet('editInspectionFormData.altura', '5.0')
+ ->assertSet('editInspectionResult', 'pass')
+ ->assertSet('editInspectionNotes', 'Original note')
+ ->set('editInspectionFormData.altura', '5.5')
+ ->set('editInspectionResult', 'fail')
+ ->set('editInspectionNotes', 'Updated note')
+ ->set('editInspectionPhotos', [UploadedFile::fake()->image('new.jpg')])
+ ->call('saveEditInspection');
+
+ $ins = $inspection->fresh();
+ $this->assertEquals('5.5', $ins->data['altura']);
+ $this->assertEquals('fail', $ins->result);
+ $this->assertEquals('Updated note', $ins->notes);
+ $this->assertCount(2, $ins->media); // original + new
+ }
+
+ public function test_edit_inspection_delete_existing_photo(): void
+ {
+ [$user, $project, $feature, $template] = $this->createTestData();
+
+ $inspection = Inspection::create([
+ 'project_id' => $project->id,
+ 'layer_id' => $feature->layer_id,
+ 'feature_id' => $feature->id,
+ 'template_id' => $template->id,
+ 'user_id' => $user->id,
+ 'inspector_user_id' => $user->id,
+ 'status' => 'completed',
+ 'completed_at' => now(),
+ 'result' => 'pass',
+ 'notes' => 'Note',
+ 'data' => ['altura' => '5.0'],
+ ]);
+ $photo1 = Media::create([
+ 'mediable_type' => Inspection::class,
+ 'mediable_id' => $inspection->id,
+ 'name' => 'to_delete.jpg',
+ 'file_path' => 'uploads/inspections/' . $inspection->id . '/to_delete.jpg',
+ 'file_type' => 'image/jpeg',
+ 'file_extension' => 'jpg',
+ 'file_size' => 1000,
+ 'category' => 'image',
+ 'uploaded_by' => $user->id,
+ ]);
+ $photo2 = Media::create([
+ 'mediable_type' => Inspection::class,
+ 'mediable_id' => $inspection->id,
+ 'name' => 'keep.jpg',
+ 'file_path' => 'uploads/inspections/' . $inspection->id . '/keep.jpg',
+ 'file_type' => 'image/jpeg',
+ 'file_extension' => 'jpg',
+ 'file_size' => 1000,
+ 'category' => 'image',
+ 'uploaded_by' => $user->id,
+ ]);
+
+ Livewire::actingAs($user)
+ ->test(ProjectMap::class, ['project' => $project])
+ ->call('selectFeature', $feature->id)
+ ->call('editInspection', $inspection->id)
+ ->call('deleteEditPhoto', 0) // delete first photo (to_delete.jpg)
+ ->call('saveEditInspection');
+
+ $this->assertCount(1, $inspection->fresh()->media);
+ $this->assertEquals('keep.jpg', $inspection->fresh()->media->first()->name);
+ $this->assertDatabaseMissing('media', ['id' => $photo1->id]);
+ }
+
+ public function test_edit_inspection_forbidden_without_permission(): void
+ {
+ [$user, $project, $feature, $template] = $this->createTestData();
+ $user->syncRoles([]);
+ $user->givePermissionTo(['view projects', 'view inspections']); // only view
+
+ $inspection = Inspection::create([
+ 'project_id' => $project->id,
+ 'layer_id' => $feature->layer_id,
+ 'feature_id' => $feature->id,
+ 'template_id' => $template->id,
+ 'user_id' => $user->id,
+ 'inspector_user_id' => $user->id,
+ 'status' => 'completed',
+ 'completed_at' => now(),
+ 'result' => 'pass',
+ 'notes' => 'Note',
+ 'data' => ['altura' => '5.0'],
+ ]);
+
+ // Modal opens but save is blocked
+ Livewire::actingAs($user)
+ ->test(ProjectMap::class, ['project' => $project])
+ ->call('selectFeature', $feature->id)
+ ->call('editInspection', $inspection->id)
+ ->set('editInspectionNotes', 'Intento editar')
+ ->call('saveEditInspection')
+ ->assertDispatched('notify', 'Sin permisos para editar inspecciones.');
+
+ // Data should not have changed
+ $this->assertEquals('Note', $inspection->fresh()->notes);
+ }
+
+ public function test_create_inspection_forbidden_without_permission(): void
+ {
+ [$user, $project, $feature, $template] = $this->createTestData();
+ $user->syncRoles([]);
+ $user->givePermissionTo(['view projects', 'view inspections']);
+
+ Livewire::actingAs($user)
+ ->test(ProjectMap::class, ['project' => $project])
+ ->call('selectFeature', $feature->id)
+ ->set('selectedTemplateId', $template->id)
+ ->set('inspectionFormData.altura', '5.2')
+ ->set('inspectionPhotos', [UploadedFile::fake()->image('foto.jpg')])
+ ->call('saveInspection')
+ ->assertDispatched('notify', 'Sin permisos para crear inspecciones.');
+
+ $this->assertDatabaseMissing('inspections', ['feature_id' => $feature->id]);
+ }
+
+ public function test_delete_inspection_removes_inspection_and_media(): void
+ {
+ [$user, $project, $feature, $template] = $this->createTestData();
+
+ $inspection = Inspection::create([
+ 'project_id' => $project->id,
+ 'layer_id' => $feature->layer_id,
+ 'feature_id' => $feature->id,
+ 'template_id' => $template->id,
+ 'user_id' => $user->id,
+ 'inspector_user_id' => $user->id,
+ 'status' => 'completed',
+ 'completed_at' => now(),
+ 'result' => 'pass',
+ 'notes' => 'Note',
+ 'data' => ['altura' => '5.0'],
+ ]);
+ $photo = Media::create([
+ 'mediable_type' => Inspection::class,
+ 'mediable_id' => $inspection->id,
+ 'name' => 'test.jpg',
+ 'file_path' => 'uploads/inspections/' . $inspection->id . '/test.jpg',
+ 'file_type' => 'image/jpeg',
+ 'file_extension' => 'jpg',
+ 'file_size' => 1000,
+ 'category' => 'image',
+ 'uploaded_by' => $user->id,
+ ]);
+
+ // Test direct model delete first
+ $inspection->delete();
+ $this->assertSoftDeleted('inspections', ['id' => $inspection->id]);
+ $this->assertDatabaseMissing('media', ['id' => $photo->id]);
+
+ // Restore for the actual test
+ $inspection->restore();
+ $this->assertDatabaseHas('inspections', ['id' => $inspection->id, 'deleted_at' => null]);
+
+ // Now test through Livewire component
+ $inspection2 = Inspection::create([
+ 'project_id' => $project->id,
+ 'layer_id' => $feature->layer_id,
+ 'feature_id' => $feature->id,
+ 'template_id' => $template->id,
+ 'user_id' => $user->id,
+ 'inspector_user_id' => $user->id,
+ 'status' => 'completed',
+ 'completed_at' => now(),
+ 'result' => 'pass',
+ 'notes' => 'Note',
+ 'data' => ['altura' => '5.0'],
+ ]);
+ $photo2 = Media::create([
+ 'mediable_type' => Inspection::class,
+ 'mediable_id' => $inspection2->id,
+ 'name' => 'test2.jpg',
+ 'file_path' => 'uploads/inspections/' . $inspection2->id . '/test2.jpg',
+ 'file_type' => 'image/jpeg',
+ 'file_extension' => 'jpg',
+ 'file_size' => 1000,
+ 'category' => 'image',
+ 'uploaded_by' => $user->id,
+ ]);
+
+ $component = Livewire::actingAs($user)
+ ->test(ProjectMap::class, ['project' => $project])
+ ->call('selectFeature', $feature->id);
+
+ // Debug: check if component is instantiated
+ $component->assertSet('project.id', $project->id);
+ $component->assertSet('selectedFeature.id', $feature->id);
+
+ // Call deleteInspection and capture events
+ $component->call('deleteInspection', $inspection2->id);
+
+ // Check if notify was dispatched
+ $component->assertDispatched('notify', 'Inspección eliminada correctamente');
+
+ $this->assertSoftDeleted('inspections', ['id' => $inspection2->id]);
+ $this->assertDatabaseMissing('media', ['id' => $photo2->id]);
+ }
+
+ public function test_delete_inspection_forbidden_without_permission(): void
+ {
+ [$user, $project, $feature, $template] = $this->createTestData();
+ $user->syncRoles([]);
+ $user->givePermissionTo(['view projects', 'view inspections']);
+
+ $inspection = Inspection::create([
+ 'project_id' => $project->id,
+ 'layer_id' => $feature->layer_id,
+ 'feature_id' => $feature->id,
+ 'template_id' => $template->id,
+ 'user_id' => $user->id,
+ 'inspector_user_id' => $user->id,
+ 'status' => 'completed',
+ 'completed_at' => now(),
+ 'result' => 'pass',
+ 'notes' => 'Note',
+ 'data' => ['altura' => '5.0'],
+ ]);
+
+ Livewire::actingAs($user)
+ ->test(ProjectMap::class, ['project' => $project])
+ ->call('selectFeature', $feature->id)
+ ->call('deleteInspection', $inspection->id)
+ ->assertDispatched('notify', 'Sin permisos para eliminar inspecciones.');
+
+ $this->assertDatabaseHas('inspections', ['id' => $inspection->id, 'deleted_at' => null]);
+ }
}
\ No newline at end of file