- 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
243 lines
14 KiB
PHP
243 lines
14 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 ? 'Kanban: ' . $project->name : 'Kanban - Todas las tareas' }}
|
|
</h1>
|
|
<p class="text-gray-500 dark:text-gray-400 mt-1">
|
|
Arrastra y suelta para cambiar estado
|
|
</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
|
|
<a href="{{ $project ? route('projects.tasks', $project) : route('tasks.index') }}"
|
|
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">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
|
</svg>
|
|
Vista Tabla
|
|
</a>
|
|
</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-[250px]">
|
|
<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-[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="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="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>
|
|
|
|
<!-- Kanban Board -->
|
|
<div class="flex gap-4 overflow-x-auto pb-4" id="kanban-board" x-data="kanbanBoard()">
|
|
@foreach($columns as $status => $column)
|
|
<div class="flex-shrink-0 w-80 min-h-[500px]"
|
|
style="background-color: {{ $column['color'] }}15; border: 1px solid {{ $column['color'] }}40;"
|
|
x-ref="column-{{ $status }}">
|
|
<!-- Column Header -->
|
|
<div class="p-3 sticky top-0 z-10 flex items-center justify-between"
|
|
style="background-color: {{ $column['color'] }}; color: white; border-radius: 0.5rem 0.5rem 0 0;">
|
|
<div class="flex items-center gap-2">
|
|
<h3 class="font-semibold text-sm">{{ $column['label'] }}</h3>
|
|
<span class="px-2 py-0.5 text-xs font-medium bg-white/20 rounded-full">
|
|
{{ $column['count'] }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Column Content / Drop Zone -->
|
|
<div class="p-3 space-y-3 min-h-[400px] kanban-column"
|
|
x-data="{ columnStatus: '{{ $status }}' }"
|
|
@dragover.prevent="onDragOver($event)"
|
|
@drop="onDrop($event)"
|
|
data-status="{{ $status }}"
|
|
style="min-height: 400px;">
|
|
@if($column['tasks']->isEmpty())
|
|
<div class="text-center py-8 text-gray-400 dark:text-gray-500 text-sm border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg"
|
|
style="border-color: {{ $column['color'] }}80;">
|
|
<svg class="mx-auto h-8 w-8 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
|
</svg>
|
|
<p>Sin tareas</p>
|
|
</div>
|
|
@endif
|
|
|
|
@foreach($column['tasks'] as $task)
|
|
<div class="kanban-card bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-3 hover:shadow-md transition-shadow cursor-pointer"
|
|
draggable="true"
|
|
wire:click="$dispatch('open-task-detail', { taskId: {{ $task->id }}, projectId: {{ $task->project_id }} })"
|
|
@dragstart="onDragStart($event, {{ $task->id }}, '{{ $status }}')"
|
|
@dragend="onDragEnd($event)"
|
|
data-task-id="{{ $task->id }}"
|
|
data-status="{{ $status }}">
|
|
<!-- Priority indicator -->
|
|
<div class="w-full h-1 rounded-t-lg mb-2"
|
|
style="background-color: {{ $task->priority_color }};"></div>
|
|
|
|
<h4 class="font-medium text-gray-900 dark:text-white text-sm mb-1 line-clamp-2">
|
|
{{ $task->title }}
|
|
</h4>
|
|
|
|
@if($task->description)
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2 line-clamp-2">{{ $task->description }}</p>
|
|
@endif
|
|
|
|
<div class="flex flex-wrap gap-2 mb-2">
|
|
@if($task->phase)
|
|
<span class="px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-700 rounded text-gray-700 dark:text-gray-300">
|
|
{{ $task->phase->name }}
|
|
</span>
|
|
@endif
|
|
@if($task->due_date)
|
|
<span class="px-2 py-0.5 text-xs rounded
|
|
{{ $task->is_overdue ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300' : ($task->is_due_today ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300') }}">
|
|
{{ $task->due_date->format('d/m') }}
|
|
@if($task->is_overdue)
|
|
<svg class="inline w-3 h-3 ml-0.5" 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 h-3 ml-0.5" 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>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-700">
|
|
@if($task->assignee)
|
|
<div class="flex items-center gap-1.5">
|
|
<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>
|
|
<span class="text-xs text-gray-600 dark:text-gray-400">{{ $task->assignee->name }}</span>
|
|
</div>
|
|
@else
|
|
<span class="text-xs text-gray-400 dark:text-gray-500">Sin asignar</span>
|
|
@endif
|
|
|
|
@if($task->subtasks->count() > 0)
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
|
{{ $task->subtasks->where('status', 'completed')->count() }}/{{ $task->subtasks->count() }}
|
|
</span>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
|
|
<!-- Add Task Button -->
|
|
@can('create tasks', $project ?? \App\Models\Project::first())
|
|
<div class="p-3">
|
|
<button wire:click="$dispatch('open-task-modal', { mode: 'create', projectId: {{ $project->id ?? 'null'}}, status: '{{ $status }}' })"
|
|
class="w-full py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg transition-colors">
|
|
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
|
</svg>
|
|
Añadir tarea
|
|
</button>
|
|
</div>
|
|
@endcan
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
|
|
@push('scripts')
|
|
<script>
|
|
// Simple Alpine-based Kanban drag & drop
|
|
function kanbanBoard() {
|
|
return {
|
|
draggedTask: null,
|
|
draggedFromStatus: null,
|
|
|
|
onDragStart(event, taskId, status) {
|
|
this.draggedTask = taskId;
|
|
this.draggedFromStatus = status;
|
|
event.target.classList.add('opacity-50', 'rotate-1');
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
event.dataTransfer.setData('text/plain', taskId);
|
|
},
|
|
|
|
onDragEnd(event) {
|
|
event.target.classList.remove('opacity-50', 'rotate-1');
|
|
this.draggedTask = null;
|
|
this.draggedFromStatus = null;
|
|
},
|
|
|
|
onDragOver(event) {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'move';
|
|
event.currentTarget.classList.add('bg-blue-50', 'dark:bg-blue-900/20');
|
|
},
|
|
|
|
onDrop(event) {
|
|
event.preventDefault();
|
|
event.currentTarget.classList.remove('bg-blue-50', 'dark:bg-blue-900/20');
|
|
|
|
const taskId = event.dataTransfer.getData('text/plain');
|
|
const targetStatus = event.currentTarget.dataset.status;
|
|
|
|
if (taskId && this.draggedFromStatus !== targetStatus) {
|
|
// Call Livewire to update status
|
|
@this.call('updateTaskStatus', taskId, targetStatus);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
@endpush |