- Add Task model with subtasks, priorities, status transitions, dates, hours - Add Comment polymorphic model for tasks/issues/projects - Livewire components: TaskManager (list+filters), TaskForm (modal), TaskDetail, TaskKanban (drag&drop), TaskCalendar (FullCalendar) - TaskPolicy with permissions (view/create/edit/delete/assign/manage all) - Notifications: assigned, status change, overdue, comment added - Daily overdue notification job scheduled - Dashboard widget unifies IssueTask + Task - i18n: en/es/fr/ru (393 keys each) - Routes, navigation, offline sync support - 101 tests passing, Pint compliant on new files
258 lines
18 KiB
PHP
258 lines
18 KiB
PHP
<div class="space-y-6">
|
|
<!-- Header -->
|
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
|
{{ $project ? 'Tareas del proyecto: ' . $project->name : 'Tareas' }}
|
|
</h1>
|
|
<p class="text-gray-500 dark:text-gray-400 mt-1">
|
|
{{ $project ? 'Gestiona las tareas de este proyecto' : 'Todas las tareas accesibles' }}
|
|
</p>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
@can('create tasks', $project ?? \App\Models\Project::first())
|
|
<a href="{{ $project ? route('projects.tasks.create', $project) : route('tasks.create') }}"
|
|
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
|
</svg>
|
|
Nueva Tarea
|
|
</a>
|
|
@endcan
|
|
<button wire:click="toggleViewMode"
|
|
class="inline-flex items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
|
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
|
:class="{'hidden': viewMode === 'table', 'inline': viewMode === 'kanban'}">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
|
</svg>
|
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
|
:class="{'inline': viewMode === 'table', 'hidden': viewMode === 'kanban'}">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
|
</svg>
|
|
{{ $viewMode === 'table' ? 'Vista Kanban' : 'Vista Tabla' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
|
<div class="flex flex-col lg:flex-row gap-4">
|
|
<div class="flex-1 min-w-[200px]">
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Buscar</label>
|
|
<input type="text" wire:model.debounce.300ms="search" placeholder="Título, descripción..."
|
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
|
</div>
|
|
|
|
<div class="min-w-[160px]">
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estado</label>
|
|
<select wire:model="statusFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
|
<option value="">Todos</option>
|
|
@foreach($statusOptions as $value => $label)
|
|
<option value="{{ $value }}">{{ $label }}</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
|
|
<div class="min-w-[160px]">
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Prioridad</label>
|
|
<select wire:model="priorityFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
|
<option value="">Todas</option>
|
|
@foreach($priorityOptions as $value => $label)
|
|
<option value="{{ $value }}">{{ $label }}</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
|
|
<div class="min-w-[180px]">
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Asignado</label>
|
|
<select wire:model="assigneeFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
|
<option value="">Todos</option>
|
|
@foreach($assignees as $assignee)
|
|
<option value="{{ $assignee->id }}">{{ $assignee->name }}</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
|
|
@if($project)
|
|
<div class="min-w-[180px]">
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Fase</label>
|
|
<select wire:model="phaseFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
|
<option value="">Todas</option>
|
|
@foreach($phases as $phase)
|
|
<option value="{{ $phase->id }}">{{ $phase->name }}</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
@endif
|
|
|
|
<div class="flex items-end">
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" wire:model="showOverdueOnly" class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">Solo vencidas</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="flex items-end">
|
|
<button wire:click="resetFilters" class="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm font-medium">
|
|
Limpiar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Table View -->
|
|
<div x-show="viewMode === 'table'" x-cloak class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
@if($tasks->isEmpty())
|
|
<div class="p-12 text-center">
|
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
|
</svg>
|
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No hay tareas</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
{{ $project ? 'Comienza creando la primera tarea para este proyecto.' : 'No se encontraron tareas con los filtros actuales.' }}
|
|
</p>
|
|
</div>
|
|
@else
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
|
<tr>
|
|
@foreach([
|
|
'title' => 'Tarea',
|
|
'project.name' => 'Proyecto',
|
|
'phase.name' => 'Fase',
|
|
'status' => 'Estado',
|
|
'priority' => 'Prioridad',
|
|
'assignee.name' => 'Asignado',
|
|
'due_date' => 'Vence',
|
|
'actions' => '',
|
|
] as $key => $label)
|
|
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50"
|
|
@if(in_array($key, ['title', 'status', 'priority', 'due_date']))
|
|
wire:click="sortBy('{{ $key }}')"
|
|
@endif>
|
|
<div class="flex items-center gap-1">
|
|
{{ $label }}
|
|
@if($sortField === $key)
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ $sortDirection === 'asc' ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7' }}"/>
|
|
</svg>
|
|
@else
|
|
<svg class="w-4 h-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l4-4m0 0l4 4m-4-4v12"/>
|
|
</svg>
|
|
@endif
|
|
</div>
|
|
</th>
|
|
@endforeach
|
|
</tr>
|
|
</thead>
|
|
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
@foreach($tasks as $task)
|
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
|
<td class="px-4 py-4">
|
|
<div>
|
|
<a href="{{ route('projects.tasks.show', [$task->project, $task]) }}"
|
|
class="font-medium text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400">
|
|
{{ $task->title }}
|
|
</a>
|
|
@if($task->description)
|
|
<p class="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs mt-1">{{ $task->description }}</p>
|
|
@endif
|
|
@if($task->subtasks->count() > 0)
|
|
<div class="mt-2 flex items-center gap-2">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
|
{{ $task->subtasks->where('status', 'completed')->count() }} / {{ $task->subtasks->count() }} subtareas
|
|
</span>
|
|
<div class="w-24 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
|
<div class="h-full bg-blue-500" style="width: {{ $task->progress }}%"></div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</td>
|
|
<td class="px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
|
|
{{ $task->project->name ?? '—' }}
|
|
</td>
|
|
<td class="px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
|
|
{{ $task->phase->name ?? '—' }}
|
|
</td>
|
|
<td class="px-4 py-4">
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
|
style="background-color: {{ $task->status_color }}20; color: {{ $task->status_color }}">
|
|
{{ $task->status_label }}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-4">
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
|
style="background-color: {{ $task->priority_color }}20; color: {{ $task->priority_color }}">
|
|
{{ $task->priority_label }}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
|
|
@if($task->assignee)
|
|
<div class="flex items-center gap-2">
|
|
<div class="w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-xs font-medium text-blue-700 dark:text-blue-300">
|
|
{{ strtoupper($task->assignee->name[0]) }}
|
|
</div>
|
|
{{ $task->assignee->name }}
|
|
</div>
|
|
@else
|
|
<span class="text-gray-400 dark:text-gray-500">—</span>
|
|
@endif
|
|
</td>
|
|
<td class="px-4 py-4 text-sm">
|
|
@if($task->due_date)
|
|
<span class="{{ $task->is_overdue ? 'text-red-600 dark:text-red-400 font-medium' : ($task->is_due_today ? 'text-amber-600 dark:text-amber-400 font-medium' : 'text-gray-700 dark:text-gray-300') }}">
|
|
{{ $task->due_date->format('d/m/Y') }}
|
|
@if($task->is_overdue)
|
|
<svg class="inline w-3.5 h-3.5 ml-1" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-6a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 4zm0 10a.75.75 0 01.75-.75.75.75 0 01.75.75v.75a.75.75 0 01-1.5 0v-.75z" clip-rule="evenodd"/></svg>
|
|
@elseif($task->is_due_today)
|
|
<svg class="inline w-3.5 h-3.5 ml-1" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
|
|
@endif
|
|
</span>
|
|
@else
|
|
<span class="text-gray-400 dark:text-gray-500">—</span>
|
|
@endif
|
|
</td>
|
|
<td class="px-4 py-4">
|
|
<div class="flex items-center gap-2">
|
|
@can('edit tasks', $task)
|
|
<a href="{{ route('projects.tasks.edit', [$task->project, $task]) }}"
|
|
class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium">
|
|
Editar
|
|
</a>
|
|
@endcan
|
|
@can('delete tasks', $task)
|
|
<button wire:click="deleteTask({{ $task->id }})"
|
|
wire:confirm="¿Eliminar la tarea \"{{ $task->title }}\"?"
|
|
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 text-sm font-medium">
|
|
Eliminar
|
|
</button>
|
|
@endcan
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700">
|
|
{{ $tasks->links() }}
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
<!-- Kanban View (placeholder) -->
|
|
<div x-show="viewMode === 'kanban'" x-cloak class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
|
<div class="text-center py-12">
|
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"/>
|
|
</svg>
|
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">Vista Kanban</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">En desarrollo - Próximamente</p>
|
|
</div>
|
|
</div>
|
|
</div> |