diff --git a/main.go b/main.go
index 85dfe55..90ffbcf 100755
--- a/main.go
+++ b/main.go
@@ -121,6 +121,7 @@ func main() {
migrations.SeedContabilidadMenu()
migrations.SeedWebSms()
migrations.SeedUrlMonitor()
+ migrations.SeedTareas()
// Iniciar cron de vencimientos
services.IniciarCron()
defer services.DetenerCron()
diff --git a/migrations/migrate.go b/migrations/migrate.go
index a21652c..0bd28bc 100755
--- a/migrations/migrate.go
+++ b/migrations/migrate.go
@@ -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.")
+}
diff --git a/pkg/models/tarea.go b/pkg/models/tarea.go
new file mode 100644
index 0000000..6374134
--- /dev/null
+++ b/pkg/models/tarea.go
@@ -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
+}
diff --git a/pkg/services/notif_dispatch.go b/pkg/services/notif_dispatch.go
index f9d3cea..afefdb7 100644
--- a/pkg/services/notif_dispatch.go
+++ b/pkg/services/notif_dispatch.go
@@ -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("📋 Nueva tarea asignada\n%s\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("🔀 Tarea movida → %s\n%s%s",
+ label,
+ escapeTelegramHTML(t.Titulo),
+ asignado)
+ sendTelegramAdmin(msg)
+}
+
+func NotificarTareaComentario(t *models.Tarea, contenido string) {
+ if t == nil {
+ return
+ }
+ msg := fmt.Sprintf("💬 Nuevo comentario en tarea\n%s\n\n%s",
+ escapeTelegramHTML(t.Titulo),
+ escapeTelegramHTML(contenido))
+ sendTelegramAdmin(msg)
+}
+
func sendTelegramAdmin(mensaje string) {
configs, err := models.GetAllTelegramConfigs()
if err != nil {
diff --git a/resources/views/tareas.html b/resources/views/tareas.html
new file mode 100644
index 0000000..6aba72f
--- /dev/null
+++ b/resources/views/tareas.html
@@ -0,0 +1,714 @@
+
+
+
+
+
+
+
+
Tablero de Tareas
+
Organiza y asigna tareas al equipo.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cargando tablero...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sin asignar
+
+
+
+
+
+
+
+
+
+
+ Sin tareas
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Comentarios
+
+
+
+
+
+ Sin comentarios aún
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/rest/controllers/tarea_controller.go b/rest/controllers/tarea_controller.go
new file mode 100644
index 0000000..47a9201
--- /dev/null
+++ b/rest/controllers/tarea_controller.go
@@ -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
+}
diff --git a/rest/routes/user.go b/rest/routes/user.go
index a9a6745..02cd0b1 100755
--- a/rest/routes/user.go
+++ b/rest/routes/user.go
@@ -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)