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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user