Files
soft_usite/resources/views/tareas.html
T
Lizandro GuarnizoandClaude Sonnet 4.6 9305cd6eb2 fix: drag-and-drop en tablero de tareas (SortableJS + Alpine x-for)
SortableJS mueve el nodo DOM físicamente antes de que Alpine re-renderice,
causando conflicto: la tarjeta desaparecía o volvía a su columna original.
Se revierte el movimiento DOM de SortableJS en onEnd y se deja que Alpine
re-renderice solo al actualizar t.estado; luego se re-inicia Sortable.
Se agrega cursor grab/grabbing como indicador visual de arrastre.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 20:04:48 -05:00

730 lines
34 KiB
HTML

<!-- Tablero de Tareas -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.3/Sortable.min.js"></script>
<div x-data="tareasApp()" x-init="init()" @keydown.escape.window="cerrarDetalle(); cerrarModal()">
<!-- Header -->
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-slate-800">Tablero de Tareas</h1>
<p class="text-sm text-slate-500 mt-0.5">Organiza y asigna tareas al equipo.</p>
</div>
<div class="flex items-center gap-2">
<!-- Filtro asignado -->
<select x-model="filtroUsuario"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
<option value="">Todos los usuarios</option>
<template x-for="u in usuarios" :key="u.ID">
<option :value="u.ID" x-text="u.name"></option>
</template>
</select>
<button @click="abrirNueva()"
class="flex items-center gap-2 text-white text-sm font-medium px-4 py-2 rounded-lg shrink-0"
style="background-color:#8eb02f"
onmouseover="this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
<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="M12 4v16m8-8H4"/>
</svg>
Nueva tarea
</button>
</div>
</div>
<!-- Alertas -->
<div x-show="errorMsg" x-cloak class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="errorMsg"></div>
<div x-show="successMsg" x-cloak class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700" x-text="successMsg"></div>
<!-- Loading -->
<div x-show="cargando" class="flex items-center justify-center py-20 text-slate-400 gap-2">
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Cargando tablero...
</div>
<!-- Kanban Board -->
<div x-show="!cargando" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<template x-for="col in columnas" :key="col.id">
<div class="flex flex-col min-h-[200px]">
<!-- Column header -->
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span x-text="col.icon" class="text-base"></span>
<h2 class="text-sm font-bold text-slate-700" x-text="col.label"></h2>
<span class="bg-slate-200 text-slate-600 text-xs font-bold px-2 py-0.5 rounded-full"
x-text="tareasFiltradas(col.id).length"></span>
</div>
<button @click="abrirNueva(col.id)" title="Agregar en esta columna"
class="text-slate-400 hover:text-[#8eb02f] transition p-1">
<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="M12 4v16m8-8H4"/>
</svg>
</button>
</div>
<!-- Column drop zone -->
<div :id="'col-' + col.id"
class="flex-1 rounded-xl p-2 space-y-2 min-h-[120px] transition-colors"
:class="col.bg"
data-estado="">
<template x-for="t in tareasFiltradas(col.id)" :key="t.ID">
<div :data-id="t.ID" @click="abrirDetalle(t)"
class="bg-white rounded-xl border border-slate-200 shadow-sm p-3 cursor-grab active:cursor-grabbing hover:shadow-md transition-all group">
<!-- Prioridad strip -->
<div class="h-1 rounded-full mb-2 -mt-1 -mx-1"
:class="prioBg(t.prioridad)"></div>
<!-- Título -->
<p class="text-sm font-semibold text-slate-800 leading-snug mb-2" x-text="t.titulo"></p>
<!-- Tags row -->
<div class="flex flex-wrap gap-1 mb-2">
<span class="px-1.5 py-0.5 rounded text-xs font-semibold"
:class="prioClass(t.prioridad)" x-text="prioLabel(t.prioridad)"></span>
<template x-if="t.fecha_limite">
<span class="px-1.5 py-0.5 rounded text-xs font-semibold"
:class="fechaClass(t.fecha_limite)"
x-text="formatFecha(t.fecha_limite)"></span>
</template>
</div>
<!-- Footer -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<template x-if="t.asignado">
<div class="flex items-center gap-1">
<div class="w-6 h-6 rounded-full bg-[#8eb02f] flex items-center justify-center text-white text-xs font-bold"
x-text="iniciales(t.asignado.name)"></div>
<span class="text-xs text-slate-400" x-text="t.asignado.name"></span>
</div>
</template>
<template x-if="!t.asignado">
<span class="text-xs text-slate-300">Sin asignar</span>
</template>
</div>
<!-- Acciones rápidas (aparecen al hover) -->
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
<button @click.stop="abrirEditar(t)" title="Editar"
class="p-1 rounded text-slate-400 hover:text-[#8eb02f] hover:bg-[#f5f8e8] transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button @click.stop="eliminar(t)" title="Eliminar"
class="p-1 rounded text-slate-400 hover:text-red-500 hover:bg-red-50 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</div>
</div>
</template>
<!-- Empty column placeholder -->
<div x-show="tareasFiltradas(col.id).length === 0"
class="text-center py-8 text-slate-300 text-xs select-none pointer-events-none">
Sin tareas
</div>
</div>
</div>
</template>
</div>
<!-- ═══════════════════════════════════════════════════ -->
<!-- Modal Crear / Editar -->
<!-- ═══════════════════════════════════════════════════ -->
<div x-show="modal.open" x-cloak class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div @click.outside="cerrarModal()" class="bg-white rounded-2xl shadow-2xl w-full max-w-lg">
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200">
<h2 class="text-lg font-bold text-slate-800"
x-text="modal.modo === 'crear' ? 'Nueva tarea' : 'Editar tarea'"></h2>
<button @click="cerrarModal()" class="text-slate-400 hover:text-slate-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="px-6 py-5 space-y-4">
<div>
<label class="block text-xs font-semibold text-slate-600 mb-1">Título</label>
<input x-model="modal.form.titulo" type="text" placeholder="¿Qué hay que hacer?"
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8eb02f]"/>
</div>
<div>
<label class="block text-xs font-semibold text-slate-600 mb-1">Descripción</label>
<textarea x-model="modal.form.descripcion" rows="3" placeholder="Detalles opcionales..."
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8eb02f] resize-none"></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-600 mb-1">Prioridad</label>
<select x-model="modal.form.prioridad"
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8eb02f]">
<option value="baja">🟢 Baja</option>
<option value="media">🟡 Media</option>
<option value="alta">🟠 Alta</option>
<option value="urgente">🔴 Urgente</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-600 mb-1">Fecha límite</label>
<input x-model="modal.form.fecha_limite" type="date"
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8eb02f]"/>
</div>
</div>
<div>
<label class="block text-xs font-semibold text-slate-600 mb-1">Asignar a</label>
<select x-model.number="modal.form.asignado_id"
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8eb02f]">
<option :value="null">Sin asignar</option>
<template x-for="u in usuarios" :key="u.ID">
<option :value="u.ID" x-text="u.name"></option>
</template>
</select>
</div>
<template x-if="modal.modo === 'crear'">
<div>
<label class="block text-xs font-semibold text-slate-600 mb-1">Estado inicial</label>
<select x-model="modal.form.estado"
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8eb02f]">
<option value="por_hacer">📥 Por hacer</option>
<option value="en_progreso">🔄 En progreso</option>
<option value="revision">🔍 En revisión</option>
<option value="hecho">✅ Hecho</option>
</select>
</div>
</template>
</div>
<div class="px-6 py-4 border-t border-slate-100 flex justify-end gap-3">
<button @click="cerrarModal()"
class="px-4 py-2 text-sm rounded-lg border border-slate-300 text-slate-600 hover:bg-slate-50 transition">
Cancelar
</button>
<button @click="guardar()" :disabled="guardando"
class="px-4 py-2 text-sm rounded-lg text-white font-medium transition disabled:opacity-50"
style="background-color:#8eb02f"
onmouseover="if(!this.disabled)this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
<span x-text="guardando ? 'Guardando...' : 'Guardar'"></span>
</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════ -->
<!-- Panel de Detalle / Comentarios (slide-in) -->
<!-- ═══════════════════════════════════════════════════ -->
<div x-show="detalle.open" x-cloak class="fixed inset-0 z-50 flex justify-end bg-black/40">
<div @click.outside="cerrarDetalle()"
class="bg-white w-full max-w-xl h-full overflow-y-auto shadow-2xl flex flex-col">
<div x-show="detalle.cargando" class="flex-1 flex items-center justify-center text-slate-400">
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</div>
<template x-if="detalle.tarea && !detalle.cargando">
<div class="flex flex-col flex-1">
<!-- Header -->
<div class="sticky top-0 bg-white z-10 px-6 py-4 border-b border-slate-200">
<div class="flex items-start gap-3">
<div class="flex-1 min-w-0">
<!-- Estado chips -->
<div class="flex flex-wrap gap-1 mb-2">
<template x-for="col in columnas" :key="col.id">
<button @click="moverDesdeDetalle(col.id)"
:class="detalle.tarea.estado === col.id
? 'ring-2 ring-offset-1 ring-[#8eb02f] ' + col.activeBg
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'"
class="px-2 py-0.5 rounded-full text-xs font-semibold transition"
x-text="col.icon + ' ' + col.label"></button>
</template>
</div>
<h2 class="text-base font-bold text-slate-800 leading-snug" x-text="detalle.tarea.titulo"></h2>
</div>
<div class="flex items-center gap-1 shrink-0">
<button @click="abrirEditar(detalle.tarea)" title="Editar"
class="p-1.5 rounded text-slate-400 hover:text-[#8eb02f] hover:bg-[#f5f8e8] transition">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button @click="cerrarDetalle()" class="p-1.5 rounded text-slate-400 hover:text-slate-600 transition">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</div>
<div class="flex-1 px-6 py-5 space-y-5 overflow-y-auto">
<!-- Meta info -->
<div class="grid grid-cols-2 gap-3">
<div class="bg-slate-50 rounded-xl p-3">
<p class="text-xs text-slate-400 mb-1">Prioridad</p>
<span class="px-2 py-0.5 rounded text-xs font-semibold"
:class="prioClass(detalle.tarea.prioridad)"
x-text="prioLabel(detalle.tarea.prioridad)"></span>
</div>
<div class="bg-slate-50 rounded-xl p-3">
<p class="text-xs text-slate-400 mb-1">Fecha límite</p>
<span class="text-sm font-semibold"
:class="detalle.tarea.fecha_limite ? fechaClass(detalle.tarea.fecha_limite) : 'text-slate-400'"
x-text="detalle.tarea.fecha_limite ? formatFecha(detalle.tarea.fecha_limite) : 'Sin fecha'"></span>
</div>
<div class="bg-slate-50 rounded-xl p-3">
<p class="text-xs text-slate-400 mb-1">Asignado a</p>
<div class="flex items-center gap-1.5">
<template x-if="detalle.tarea.asignado">
<div class="w-5 h-5 rounded-full bg-[#8eb02f] flex items-center justify-center text-white text-xs font-bold"
x-text="iniciales(detalle.tarea.asignado.name)"></div>
</template>
<span class="text-sm font-semibold text-slate-700"
x-text="detalle.tarea.asignado ? detalle.tarea.asignado.name : 'Sin asignar'"></span>
</div>
</div>
<div class="bg-slate-50 rounded-xl p-3">
<p class="text-xs text-slate-400 mb-1">Creado por</p>
<span class="text-sm font-semibold text-slate-700"
x-text="detalle.tarea.creado_por ? detalle.tarea.creado_por.name : '—'"></span>
</div>
</div>
<!-- Descripción -->
<template x-if="detalle.tarea.descripcion">
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">Descripción</p>
<p class="text-sm text-slate-700 whitespace-pre-line bg-slate-50 rounded-xl p-3"
x-text="detalle.tarea.descripcion"></p>
</div>
</template>
<!-- Comentarios -->
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">
Comentarios
<span class="ml-1 bg-slate-100 text-slate-600 px-1.5 rounded-full"
x-text="detalle.comentarios.length"></span>
</p>
<div class="space-y-3 mb-4">
<template x-if="detalle.comentarios.length === 0">
<p class="text-xs text-slate-400 text-center py-4">Sin comentarios aún</p>
</template>
<template x-for="(cm, idx) in detalle.comentarios" :key="cm.id || idx">
<div class="flex gap-3">
<div class="w-7 h-7 rounded-full bg-slate-300 flex items-center justify-center text-white text-xs font-bold shrink-0"
x-text="iniciales(cm.autor ? cm.autor.name : '?')"></div>
<div class="flex-1 bg-slate-50 rounded-xl p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-slate-600"
x-text="cm.autor ? cm.autor.name : 'Usuario'"></span>
<span class="text-xs text-slate-400" x-text="formatFechaCorta(cm.created_at)"></span>
</div>
<p class="text-sm text-slate-700 whitespace-pre-line" x-text="cm.contenido"></p>
<!-- Archivos adjuntos -->
<template x-if="tieneArchivos(cm.archivos)">
<div class="mt-2">
<template x-for="(arch, i) in parsearArchivos(cm.archivos)" :key="i">
<a :href="'/' + arch.ruta" target="_blank"
class="inline-flex items-center gap-1 text-xs text-blue-500 hover:underline mt-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/>
</svg>
<span x-text="arch.nombre"></span>
</a>
</template>
</div>
</template>
</div>
</div>
</template>
</div>
<!-- Nuevo comentario -->
<div class="border border-slate-200 rounded-xl p-3 bg-white">
<textarea x-model="detalle.nuevoComentario" rows="2"
placeholder="Agregar comentario o actualización..."
class="w-full text-sm text-slate-700 resize-none focus:outline-none placeholder-slate-400"></textarea>
<!-- Archivo adjunto -->
<div class="flex items-center justify-between mt-2 pt-2 border-t border-slate-100">
<div class="flex items-center gap-2">
<label class="cursor-pointer flex items-center gap-1 text-xs text-slate-400 hover:text-slate-600 transition">
<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="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/>
</svg>
Adjuntar
<input type="file" class="hidden" x-ref="fileInput" @change="seleccionarArchivo($event)"/>
</label>
<span x-show="detalle.archivoSeleccionado" class="text-xs text-slate-500 truncate max-w-[150px]"
x-text="detalle.archivoSeleccionado"></span>
</div>
<button @click="enviarComentario()" :disabled="detalle.enviando"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold text-white transition disabled:opacity-40"
style="background-color:#8eb02f"
onmouseover="if(!this.disabled)this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
</svg>
<span x-text="detalle.enviando ? 'Enviando...' : 'Enviar'"></span>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
<script>
function tareasApp() {
return {
tareas: [],
usuarios: [],
cargando: true,
errorMsg: '',
successMsg: '',
guardando: false,
filtroUsuario: '',
sortables: [],
columnas: [
{ id: 'por_hacer', label: 'Por hacer', icon: '📥', bg: 'bg-slate-100', activeBg: 'bg-slate-200 text-slate-700' },
{ id: 'en_progreso', label: 'En progreso', icon: '🔄', bg: 'bg-blue-50', activeBg: 'bg-blue-100 text-blue-700' },
{ id: 'revision', label: 'En revisión', icon: '🔍', bg: 'bg-amber-50', activeBg: 'bg-amber-100 text-amber-700' },
{ id: 'hecho', label: 'Hecho', icon: '✅', bg: 'bg-green-50', activeBg: 'bg-green-100 text-green-700' },
],
modal: {
open: false,
modo: 'crear',
editId: null,
form: { titulo: '', descripcion: '', prioridad: 'media', asignado_id: null, fecha_limite: '', estado: 'por_hacer' },
},
detalle: {
open: false,
cargando: false,
tarea: null,
comentarios: [],
nuevoComentario: '',
archivoSeleccionado: '',
_archivo: null,
enviando: false,
},
async init() {
await Promise.all([this.cargar(), this.cargarUsuarios()]);
await this.$nextTick();
this.iniciarSortable();
},
async cargar() {
this.cargando = true;
try {
const r = await fetch('/app/tareas/data');
this.tareas = await r.json() || [];
} catch (e) {
this.mostrarError('Error cargando tareas');
} finally {
this.cargando = false;
}
},
async cargarUsuarios() {
try {
const r = await fetch('/app/tareas/usuarios');
this.usuarios = await r.json() || [];
} catch {}
},
tareasFiltradas(estado) {
return this.tareas.filter(t => {
if (t.estado !== estado) return false;
if (this.filtroUsuario && String(t.asignado_id) !== String(this.filtroUsuario)) return false;
return true;
});
},
iniciarSortable() {
this.sortables.forEach(s => s.destroy());
this.sortables = [];
this.columnas.forEach(col => {
const el = document.getElementById('col-' + col.id);
if (!el) return;
const s = Sortable.create(el, {
group: 'tareas',
animation: 150,
ghostClass: 'opacity-30',
chosenClass: 'ring-2 ring-[#8eb02f]',
dragClass: 'rotate-1',
filter: 'button',
onEnd: (evt) => {
const id = parseInt(evt.item.dataset.id);
const nuevoEstado = evt.to.id.replace('col-', '');
const estadoAnterior = evt.from.id.replace('col-', '');
if (nuevoEstado === estadoAnterior) return;
// Revertir el movimiento DOM de SortableJS para que Alpine
// pueda re-renderizar el x-for sin conflicto de nodos.
evt.from.insertBefore(evt.item, evt.from.children[evt.oldIndex] || null);
const t = this.tareas.find(t => t.ID === id);
if (!t) return;
t.estado = nuevoEstado; // dispara el re-render de Alpine
// Re-iniciar Sortable después de que Alpine actualice el DOM
this.$nextTick(() => this.iniciarSortable());
fetch(`/app/tarea/${id}/estado`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ estado: nuevoEstado }),
}).catch(() => {
// Revertir estado local si la API falla
const tRev = this.tareas.find(t => t.ID === id);
if (tRev) tRev.estado = estadoAnterior;
this.$nextTick(() => this.iniciarSortable());
});
},
});
this.sortables.push(s);
});
},
abrirNueva(estado) {
this.modal.modo = 'crear';
this.modal.editId = null;
this.modal.form = {
titulo: '', descripcion: '', prioridad: 'media',
asignado_id: null, fecha_limite: '',
estado: estado || 'por_hacer',
};
this.modal.open = true;
},
abrirEditar(t) {
this.modal.modo = 'editar';
this.modal.editId = t.ID;
this.modal.form = {
titulo: t.titulo,
descripcion: t.descripcion || '',
prioridad: t.prioridad || 'media',
asignado_id: t.asignado_id || null,
fecha_limite: t.fecha_limite ? t.fecha_limite.slice(0, 10) : '',
};
this.modal.open = true;
},
cerrarModal() { this.modal.open = false; },
async guardar() {
if (!this.modal.form.titulo.trim()) {
this.mostrarError('El título es requerido');
return;
}
this.guardando = true;
try {
const esEditar = this.modal.modo === 'editar';
const url = esEditar ? `/app/tarea/${this.modal.editId}` : '/app/tarea';
const body = { ...this.modal.form };
if (!body.asignado_id) body.asignado_id = null;
if (!body.fecha_limite) body.fecha_limite = null;
const r = await fetch(url, {
method: esEditar ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
const err = await r.json();
throw new Error(err.error || 'Error al guardar');
}
const data = await r.json();
if (esEditar) {
const idx = this.tareas.findIndex(t => t.ID === this.modal.editId);
if (idx >= 0) this.tareas[idx] = data;
if (this.detalle.tarea?.ID === this.modal.editId) this.detalle.tarea = data;
} else {
this.tareas.push(data);
}
this.mostrarExito(esEditar ? 'Tarea actualizada' : 'Tarea creada');
this.cerrarModal();
await this.$nextTick();
this.iniciarSortable();
} catch (e) {
this.mostrarError(e.message);
} finally {
this.guardando = false;
}
},
async eliminar(t) {
if (!confirm(`¿Eliminar la tarea "${t.titulo}"?`)) return;
try {
const r = await fetch(`/app/tarea/${t.ID}`, { method: 'DELETE' });
if (!r.ok) throw new Error('Error al eliminar');
this.tareas = this.tareas.filter(x => x.ID !== t.ID);
this.mostrarExito('Tarea eliminada');
if (this.detalle.tarea?.ID === t.ID) this.cerrarDetalle();
} catch (e) {
this.mostrarError(e.message);
}
},
async abrirDetalle(t) {
this.detalle.open = true;
this.detalle.cargando = true;
this.detalle.tarea = null;
this.detalle.comentarios = [];
this.detalle.nuevoComentario = '';
this.detalle.archivoSeleccionado = '';
this.detalle._archivo = null;
try {
const r = await fetch(`/app/tarea/${t.ID}`);
const data = await r.json();
this.detalle.tarea = data.tarea;
this.detalle.comentarios = data.comentarios || [];
} catch {
this.mostrarError('Error cargando detalle');
} finally {
this.detalle.cargando = false;
}
},
cerrarDetalle() {
this.detalle.open = false;
this.detalle.tarea = null;
},
async moverDesdeDetalle(nuevoEstado) {
if (!this.detalle.tarea || this.detalle.tarea.estado === nuevoEstado) return;
const id = this.detalle.tarea.ID;
try {
await fetch(`/app/tarea/${id}/estado`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ estado: nuevoEstado }),
});
this.detalle.tarea.estado = nuevoEstado;
const t = this.tareas.find(x => x.ID === id);
if (t) t.estado = nuevoEstado;
} catch {
this.mostrarError('Error cambiando estado');
}
},
seleccionarArchivo(evt) {
const file = evt.target.files[0];
if (file) {
this.detalle._archivo = file;
this.detalle.archivoSeleccionado = file.name;
}
},
async enviarComentario() {
if (!this.detalle.nuevoComentario.trim() && !this.detalle._archivo) return;
if (!this.detalle.tarea) return;
this.detalle.enviando = true;
try {
const fd = new FormData();
fd.append('contenido', this.detalle.nuevoComentario.trim() || ' ');
if (this.detalle._archivo) fd.append('archivo', this.detalle._archivo);
const r = await fetch(`/app/tarea/${this.detalle.tarea.ID}/comentario`, {
method: 'POST',
body: fd,
});
if (!r.ok) {
const err = await r.json();
throw new Error(err.error || 'Error al enviar');
}
const cm = await r.json();
this.detalle.comentarios.push(cm);
this.detalle.nuevoComentario = '';
this.detalle.archivoSeleccionado = '';
this.detalle._archivo = null;
if (this.$refs.fileInput) this.$refs.fileInput.value = '';
} catch (e) {
this.mostrarError(e.message);
} finally {
this.detalle.enviando = false;
}
},
tieneArchivos(json) {
try { return JSON.parse(json || '[]').length > 0; } catch { return false; }
},
parsearArchivos(json) {
try { return JSON.parse(json || '[]'); } catch { return []; }
},
iniciales(nombre) {
if (!nombre) return '?';
return nombre.split(' ').slice(0, 2).map(w => w[0]).join('').toUpperCase();
},
prioLabel(p) {
return { baja: 'Baja', media: 'Media', alta: 'Alta', urgente: 'Urgente' }[p] || p;
},
prioClass(p) {
return {
baja: 'bg-green-100 text-green-700',
media: 'bg-yellow-100 text-yellow-700',
alta: 'bg-orange-100 text-orange-700',
urgente: 'bg-red-100 text-red-600',
}[p] || 'bg-slate-100 text-slate-500';
},
prioBg(p) {
return {
baja: 'bg-green-400',
media: 'bg-yellow-400',
alta: 'bg-orange-500',
urgente: 'bg-red-500',
}[p] || 'bg-slate-300';
},
formatFecha(ts) {
if (!ts) return '';
const d = new Date(ts);
return d.toLocaleDateString('es', { day: '2-digit', month: 'short', year: 'numeric' });
},
formatFechaCorta(ts) {
if (!ts) return '';
const d = new Date(ts);
const hoy = new Date();
const diff = Math.floor((hoy - d) / 1000);
if (diff < 60) return 'hace ' + diff + 's';
if (diff < 3600) return 'hace ' + Math.floor(diff / 60) + 'min';
if (diff < 86400) return 'hace ' + Math.floor(diff / 3600) + 'h';
return d.toLocaleDateString('es', { day: '2-digit', month: 'short' });
},
fechaClass(ts) {
if (!ts) return 'text-slate-400';
const d = new Date(ts);
const hoy = new Date(); hoy.setHours(0, 0, 0, 0);
if (d < hoy) return 'bg-red-100 text-red-600 px-1.5 py-0.5 rounded text-xs font-semibold';
const diff = (d - hoy) / 86400000;
if (diff <= 2) return 'bg-amber-100 text-amber-600 px-1.5 py-0.5 rounded text-xs font-semibold';
return 'bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded text-xs font-semibold';
},
mostrarError(msg) {
this.errorMsg = msg; this.successMsg = '';
setTimeout(() => this.errorMsg = '', 5000);
},
mostrarExito(msg) {
this.successMsg = msg; this.errorMsg = '';
setTimeout(() => this.successMsg = '', 4000);
},
};
}
</script>
</div>