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:
co-authored by
Claude Sonnet 4.6
parent
0901ed0d53
commit
bc79553b7e
@@ -121,6 +121,7 @@ func main() {
|
|||||||
migrations.SeedContabilidadMenu()
|
migrations.SeedContabilidadMenu()
|
||||||
migrations.SeedWebSms()
|
migrations.SeedWebSms()
|
||||||
migrations.SeedUrlMonitor()
|
migrations.SeedUrlMonitor()
|
||||||
|
migrations.SeedTareas()
|
||||||
// Iniciar cron de vencimientos
|
// Iniciar cron de vencimientos
|
||||||
services.IniciarCron()
|
services.IniciarCron()
|
||||||
defer services.DetenerCron()
|
defer services.DetenerCron()
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ func Migrate() {
|
|||||||
// Monitor de disponibilidad de URLs
|
// Monitor de disponibilidad de URLs
|
||||||
&models.UrlMonitor{},
|
&models.UrlMonitor{},
|
||||||
&models.UrlMonitorLog{},
|
&models.UrlMonitorLog{},
|
||||||
|
// Tablero de tareas
|
||||||
|
&models.Tarea{},
|
||||||
|
&models.TareaComentario{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
log.Fatalf("Error during main migration: %v", err)
|
log.Fatalf("Error during main migration: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1040,3 +1043,39 @@ func SeedUrlMonitor() {
|
|||||||
}
|
}
|
||||||
log.Println("[SEED] Seed de Monitor de URLs completado.")
|
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.")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -254,6 +254,59 @@ func NotificarReniceAction(servidorNombre, accion string) {
|
|||||||
sendTelegramAdmin(msg)
|
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) {
|
func sendTelegramAdmin(mensaje string) {
|
||||||
configs, err := models.GetAllTelegramConfigs()
|
configs, err := models.GetAllTelegramConfigs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -84,6 +84,17 @@ func UserRoutes(app fiber.Router) {
|
|||||||
protected.Get("/servidor/:id/metricas-history", controllers.GetMetricasHistory)
|
protected.Get("/servidor/:id/metricas-history", controllers.GetMetricasHistory)
|
||||||
protected.Post("/servidor/:id/sync-hostinger", controllers.SyncServidorFromHostinger)
|
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
|
// Monitor de URLs
|
||||||
protected.Get("/url-monitor", middlewares.MenuMiddleware, controllers.UrlMonitorIndex)
|
protected.Get("/url-monitor", middlewares.MenuMiddleware, controllers.UrlMonitorIndex)
|
||||||
protected.Get("/url-monitors", controllers.GetUrlMonitors)
|
protected.Get("/url-monitors", controllers.GetUrlMonitors)
|
||||||
|
|||||||
Reference in New Issue
Block a user