feat(tareas): módulo de tablero kanban con drag & drop, comentarios y Telegram

- Modelos Tarea y TareaComentario con GORM, migración automática
- Estados: por_hacer → en_progreso → revisión → hecho; prioridades: baja/media/alta/urgente
- Drag & drop entre columnas via SortableJS (PUT /tarea/:id/estado)
- CRUD completo: crear, editar, eliminar; asignación a usuario + fecha límite
- Panel lateral de detalle: historial de comentarios, adjuntar archivos (uploads/tareas/:id)
- Telegram: notifica asignación, cambio de estado y comentarios nuevos via sendTelegramAdmin
- Seed automático bajo módulo "Administración", solo rol Administrador

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-06 20:18:26 -05:00
co-authored by Claude Sonnet 4.6
parent 0901ed0d53
commit bc79553b7e
7 changed files with 1178 additions and 0 deletions
+1
View File
@@ -121,6 +121,7 @@ func main() {
migrations.SeedContabilidadMenu()
migrations.SeedWebSms()
migrations.SeedUrlMonitor()
migrations.SeedTareas()
// Iniciar cron de vencimientos
services.IniciarCron()
defer services.DetenerCron()
+39
View File
@@ -101,6 +101,9 @@ func Migrate() {
// Monitor de disponibilidad de URLs
&models.UrlMonitor{},
&models.UrlMonitorLog{},
// Tablero de tareas
&models.Tarea{},
&models.TareaComentario{},
); err != nil {
log.Fatalf("Error during main migration: %v", err)
}
@@ -1040,3 +1043,39 @@ func SeedUrlMonitor() {
}
log.Println("[SEED] Seed de Monitor de URLs completado.")
}
// SeedTareas agrega el submódulo "Tareas" al módulo "Administración" solo para el rol Administrador. Es idempotente.
func SeedTareas() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Administración").First(&modulo).Error; err != nil {
log.Println("[SEED] Módulo 'Administración' no encontrado, se omite SeedTareas")
return
}
url := "/app/tareas"
var sub models.Submodules
if err := db.Where("url = ?", url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: "Tareas",
Description: "Tablero kanban de tareas internas",
Url: url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo 'Tareas': %v", err)
return
}
log.Printf("[SEED] Submódulo 'Tareas' creado")
} else if sub.ModuleId != modulo.ID {
db.Model(&sub).Update("module_id", modulo.ID)
}
var rol models.Roles
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
db.Model(&rol).Association("Submodules").Append(&[]models.Submodules{sub})
log.Printf("[SEED] Submódulo 'Tareas' asignado al rol 'Administrador'")
}
log.Println("[SEED] Seed de Tareas completado.")
}
+85
View File
@@ -0,0 +1,85 @@
package models
import (
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
type Tarea struct {
gorm.Model
Titulo string `json:"titulo" gorm:"column:titulo;size:200"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
Estado string `json:"estado" gorm:"column:estado;default:'por_hacer';index"`
Prioridad string `json:"prioridad" gorm:"column:prioridad;default:'media'"`
AsignadoID *uint `json:"asignado_id" gorm:"column:asignado_id;index"`
Asignado *Users `json:"asignado" gorm:"foreignKey:AsignadoID"`
CreadoPorID uint `json:"creado_por_id" gorm:"column:creado_por_id"`
CreadoPor *Users `json:"creado_por" gorm:"foreignKey:CreadoPorID"`
FechaLimite *time.Time `json:"fecha_limite" gorm:"column:fecha_limite"`
Orden int `json:"orden" gorm:"column:orden;default:0"`
}
func (Tarea) TableName() string { return "tarea" }
type TareaComentario struct {
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
TareaID uint `json:"tarea_id" gorm:"column:tarea_id;index"`
AutorID uint `json:"autor_id" gorm:"column:autor_id"`
Autor *Users `json:"autor" gorm:"foreignKey:AutorID"`
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
Archivos string `json:"archivos" gorm:"column:archivos;type:text"`
CreatedAt time.Time `json:"created_at"`
}
func (TareaComentario) TableName() string { return "tarea_comentario" }
func GetAllTareas() ([]Tarea, error) {
var items []Tarea
err := app.Http.Database.DB.
Preload("Asignado").
Preload("CreadoPor").
Order("estado ASC, orden ASC, created_at ASC").
Find(&items).Error
return items, err
}
func GetTareaByID(id uint) (*Tarea, error) {
var t Tarea
err := app.Http.Database.DB.
Preload("Asignado").
Preload("CreadoPor").
First(&t, id).Error
return &t, err
}
func CreateTarea(t *Tarea) error {
return app.Http.Database.DB.Create(t).Error
}
func SaveTarea(t *Tarea) error {
return app.Http.Database.DB.Save(t).Error
}
func DeleteTarea(id uint) error {
return app.Http.Database.DB.Delete(&Tarea{}, id).Error
}
func CambiarEstadoTarea(id uint, estado string) error {
return app.Http.Database.DB.Model(&Tarea{}).Where("id = ?", id).Update("estado", estado).Error
}
func GetComentariosByTarea(tareaID uint) ([]TareaComentario, error) {
var items []TareaComentario
err := app.Http.Database.DB.
Preload("Autor").
Where("tarea_id = ?", tareaID).
Order("created_at ASC").
Find(&items).Error
return items, err
}
func CreateComentario(c *TareaComentario) error {
return app.Http.Database.DB.Create(c).Error
}
+53
View File
@@ -254,6 +254,59 @@ func NotificarReniceAction(servidorNombre, accion string) {
sendTelegramAdmin(msg)
}
// ─── Tareas ──────────────────────────────────────────────────────────────────
func NotificarTareaAsignada(t *models.Tarea) {
if t == nil || t.AsignadoID == nil {
return
}
asignado := ""
if t.Asignado != nil {
asignado = t.Asignado.Name
}
msg := fmt.Sprintf("📋 <b>Nueva tarea asignada</b>\n<b>%s</b>\nAsignado a: %s\nPrioridad: %s\n\n%s",
escapeTelegramHTML(t.Titulo),
escapeTelegramHTML(asignado),
escapeTelegramHTML(t.Prioridad),
escapeTelegramHTML(t.Descripcion))
sendTelegramAdmin(msg)
}
func NotificarTareaEstado(t *models.Tarea) {
if t == nil {
return
}
etiquetas := map[string]string{
"por_hacer": "📥 Por hacer",
"en_progreso": "🔄 En progreso",
"revision": "🔍 En revisión",
"hecho": "✅ Hecho",
}
label := etiquetas[t.Estado]
if label == "" {
label = t.Estado
}
asignado := ""
if t.Asignado != nil {
asignado = "\nAsignado: " + escapeTelegramHTML(t.Asignado.Name)
}
msg := fmt.Sprintf("🔀 <b>Tarea movida → %s</b>\n<b>%s</b>%s",
label,
escapeTelegramHTML(t.Titulo),
asignado)
sendTelegramAdmin(msg)
}
func NotificarTareaComentario(t *models.Tarea, contenido string) {
if t == nil {
return
}
msg := fmt.Sprintf("💬 <b>Nuevo comentario en tarea</b>\n<b>%s</b>\n\n%s",
escapeTelegramHTML(t.Titulo),
escapeTelegramHTML(contenido))
sendTelegramAdmin(msg)
}
func sendTelegramAdmin(mensaje string) {
configs, err := models.GetAllTelegramConfigs()
if err != nil {
+714
View File
@@ -0,0 +1,714 @@
<!-- 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-pointer 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 t = this.tareas.find(t => t.ID === id);
if (t && t.estado !== nuevoEstado) {
t.estado = nuevoEstado;
fetch(`/app/tarea/${id}/estado`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ estado: nuevoEstado }),
});
}
},
});
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>
+275
View File
@@ -0,0 +1,275 @@
package controllers
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
func TareasIndex(c *fiber.Ctx) error {
return c.Render("tareas", fiber.Map{
"user": c.Locals("user"),
"modules": c.Locals("userModules"),
}, "layouts/main")
}
func GetTareas(c *fiber.Ctx) error {
items, err := models.GetAllTareas()
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(items)
}
func GetUsuariosSistema(c *fiber.Ctx) error {
users, _, err := models.AllUsersSistema(100, 0, "")
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(users)
}
func CreateTarea(c *fiber.Ctx) error {
var req struct {
Titulo string `json:"titulo"`
Descripcion string `json:"descripcion"`
Prioridad string `json:"prioridad"`
AsignadoID *uint `json:"asignado_id"`
FechaLimite *string `json:"fecha_limite"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
if strings.TrimSpace(req.Titulo) == "" {
return c.Status(400).JSON(fiber.Map{"error": "titulo requerido"})
}
autorID := extraerUserID(c)
tarea := &models.Tarea{
Titulo: req.Titulo,
Descripcion: req.Descripcion,
Estado: "por_hacer",
Prioridad: prioPorDefecto(req.Prioridad),
AsignadoID: req.AsignadoID,
CreadoPorID: autorID,
}
if req.FechaLimite != nil && *req.FechaLimite != "" {
if t, err := time.Parse("2006-01-02", *req.FechaLimite); err == nil {
tarea.FechaLimite = &t
}
}
if err := models.CreateTarea(tarea); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
// reload con preloads
t, _ := models.GetTareaByID(tarea.ID)
go services.NotificarTareaAsignada(t)
return c.Status(201).JSON(t)
}
func UpdateTarea(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
tarea, err := models.GetTareaByID(uint(id))
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "no encontrado"})
}
var req struct {
Titulo string `json:"titulo"`
Descripcion string `json:"descripcion"`
Prioridad string `json:"prioridad"`
AsignadoID *uint `json:"asignado_id"`
FechaLimite *string `json:"fecha_limite"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
prevAsignado := tarea.AsignadoID
tarea.Titulo = req.Titulo
tarea.Descripcion = req.Descripcion
tarea.Prioridad = prioPorDefecto(req.Prioridad)
tarea.AsignadoID = req.AsignadoID
tarea.FechaLimite = nil
if req.FechaLimite != nil && *req.FechaLimite != "" {
if t, err := time.Parse("2006-01-02", *req.FechaLimite); err == nil {
tarea.FechaLimite = &t
}
}
if err := models.SaveTarea(tarea); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
t, _ := models.GetTareaByID(tarea.ID)
// notificar si cambió el asignado
if cambioPuntero(prevAsignado, req.AsignadoID) {
go services.NotificarTareaAsignada(t)
}
return c.JSON(t)
}
func CambiarEstadoTarea(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
var req struct {
Estado string `json:"estado"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
estados := map[string]bool{"por_hacer": true, "en_progreso": true, "revision": true, "hecho": true}
if !estados[req.Estado] {
return c.Status(400).JSON(fiber.Map{"error": "estado inválido"})
}
if err := models.CambiarEstadoTarea(uint(id), req.Estado); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
t, _ := models.GetTareaByID(uint(id))
go services.NotificarTareaEstado(t)
return c.JSON(fiber.Map{"ok": true})
}
func DeleteTareaHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
if err := models.DeleteTarea(uint(id)); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func GetTareaDetalle(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
tarea, err := models.GetTareaByID(uint(id))
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "no encontrado"})
}
comentarios, _ := models.GetComentariosByTarea(uint(id))
return c.JSON(fiber.Map{"tarea": tarea, "comentarios": comentarios})
}
func AddComentario(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
tarea, err := models.GetTareaByID(uint(id))
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "tarea no encontrada"})
}
contenido := strings.TrimSpace(c.FormValue("contenido"))
if contenido == "" {
return c.Status(400).JSON(fiber.Map{"error": "contenido requerido"})
}
autorID := extraerUserID(c)
archivosJSON := "[]"
// archivo adjunto opcional
file, fileErr := c.FormFile("archivo")
if fileErr == nil && file != nil {
if file.Size > 50*1024*1024 {
return c.Status(400).JSON(fiber.Map{"error": "Máximo 50MB por archivo"})
}
dir := fmt.Sprintf("uploads/tareas/%d", id)
if err := os.MkdirAll(dir, 0755); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Error creando directorio"})
}
safeFile := safeTareaFilename(file.Filename)
savePath := filepath.Join(dir, fmt.Sprintf("%d_%s", time.Now().UnixMilli(), safeFile))
clean := filepath.Clean(savePath)
if !strings.HasPrefix(clean, "uploads/") {
return c.Status(400).JSON(fiber.Map{"error": "Ruta inválida"})
}
if err := c.SaveFile(file, savePath); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Error guardando archivo"})
}
archivosJSON = fmt.Sprintf(`[{"nombre":"%s","ruta":"%s"}]`,
escapeJSON(file.Filename), escapeJSON(savePath))
}
comentario := &models.TareaComentario{
TareaID: uint(id),
AutorID: autorID,
Contenido: contenido,
Archivos: archivosJSON,
}
if err := models.CreateComentario(comentario); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
go services.NotificarTareaComentario(tarea, contenido)
return c.Status(201).JSON(comentario)
}
func extraerUserID(c *fiber.Ctx) uint {
userMap, _ := c.Locals("user").(map[string]interface{})
if userMap == nil {
return 0
}
switch v := userMap["id"].(type) {
case float64:
return uint(v)
case uint:
return v
}
return 0
}
func prioPorDefecto(p string) string {
valid := map[string]bool{"baja": true, "media": true, "alta": true, "urgente": true}
if valid[p] {
return p
}
return "media"
}
func cambioPuntero(a, b *uint) bool {
if a == nil && b == nil {
return false
}
if a == nil || b == nil {
return true
}
return *a != *b
}
func safeTareaFilename(name string) string {
base := filepath.Base(name)
var out []rune
for _, r := range base {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' || r == '-' || r == '_' {
out = append(out, r)
} else {
out = append(out, '_')
}
}
if len(out) == 0 {
return "archivo"
}
return string(out)
}
func escapeJSON(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
return s
}
+11
View File
@@ -84,6 +84,17 @@ func UserRoutes(app fiber.Router) {
protected.Get("/servidor/:id/metricas-history", controllers.GetMetricasHistory)
protected.Post("/servidor/:id/sync-hostinger", controllers.SyncServidorFromHostinger)
// Tareas (kanban)
protected.Get("/tareas", middlewares.MenuMiddleware, controllers.TareasIndex)
protected.Get("/tareas/data", controllers.GetTareas)
protected.Get("/tareas/usuarios", controllers.GetUsuariosSistema)
protected.Post("/tarea", controllers.CreateTarea)
protected.Get("/tarea/:id", controllers.GetTareaDetalle)
protected.Put("/tarea/:id", controllers.UpdateTarea)
protected.Put("/tarea/:id/estado", controllers.CambiarEstadoTarea)
protected.Delete("/tarea/:id", controllers.DeleteTareaHandler)
protected.Post("/tarea/:id/comentario", controllers.AddComentario)
// Monitor de URLs
protected.Get("/url-monitor", middlewares.MenuMiddleware, controllers.UrlMonitorIndex)
protected.Get("/url-monitors", controllers.GetUrlMonitors)