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.SeedWebSms()
|
||||
migrations.SeedUrlMonitor()
|
||||
migrations.SeedTareas()
|
||||
// Iniciar cron de vencimientos
|
||||
services.IniciarCron()
|
||||
defer services.DetenerCron()
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ─── 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 {
|
||||
|
||||
@@ -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.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)
|
||||
|
||||
Reference in New Issue
Block a user