Es la base del cobro por uso: hasta ahora no había ninguna medición de consumo en todo el repo. - UmindUso registra cada evento facturable con el costo YA calculado al precio vigente del plan. Congelarlo evita que subir un precio revalúe consumo pasado, que haría indefendible una factura ante un reclamo. - callAI devuelve los tokens que reportó el proveedor (campo usage, igual en todos los OpenAI-compatibles; input+output en Anthropic). Se mide cada ronda de tool-calling, no solo la última: todas gastan tokens. - ExtraerTextoOCR y TranscribirAudioSelfHosted reciben agenteID; 0 = no medir, que es lo que pasan los botones "Probar" del panel de staff. - Aviso al superar el tope del plan, una vez por mes y sin cortar el servicio. El flag de "ya avisé" es en memoria a propósito. - GET /app/umind/uso con filtros de fecha: resumen por tipo + detalle. - Test del cálculo de costo por tipo, incluida fracción de 1k tokens y tenant sin plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
666 lines
21 KiB
Go
666 lines
21 KiB
Go
package controllers
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"mime/multipart"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
|
)
|
|
|
|
// ─── Admin pages ──────────────────────────────────────────────────────────────
|
|
|
|
func ProyectosIndex(c *fiber.Ctx) error {
|
|
return c.Render("proyectos", fiber.Map{
|
|
"user": c.Locals("user"),
|
|
"modules": c.Locals("userModules"),
|
|
}, "layouts/main")
|
|
}
|
|
|
|
func ProyectoDetalleIndex(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
|
|
if err != nil {
|
|
return c.Redirect("/app/proyectos")
|
|
}
|
|
proy, err := models.GetProyectoByID(uint(id))
|
|
if err != nil {
|
|
return c.Redirect("/app/proyectos")
|
|
}
|
|
return c.Render("proyecto_detalle", fiber.Map{
|
|
"user": c.Locals("user"),
|
|
"modules": c.Locals("userModules"),
|
|
"proyecto": proy,
|
|
}, "layouts/main")
|
|
}
|
|
|
|
// ─── CRUD Proyecto ─────────────────────────────────────────────────────────────
|
|
|
|
func LoadProyectos(c *fiber.Ctx) error {
|
|
page, _ := strconv.Atoi(c.Query("page", "1"))
|
|
search := c.Query("search", "")
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
limit := 20
|
|
offset := (page - 1) * limit
|
|
items, total, err := models.GetAllProyectos(limit, offset, search)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
// Obtener clientes para el formulario
|
|
clientes, _, _ := models.GetAllClientes(200, 0, "")
|
|
return c.JSON(fiber.Map{
|
|
"items": items,
|
|
"total": total,
|
|
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
|
"page": page,
|
|
"clientes": clientes,
|
|
})
|
|
}
|
|
|
|
func CreateProyecto(c *fiber.Ctx) error {
|
|
type Req struct {
|
|
ClienteID interface{} `json:"cliente_id"`
|
|
Nombre string `json:"nombre"`
|
|
Slug string `json:"slug"`
|
|
Descripcion string `json:"descripcion"`
|
|
Color string `json:"color"`
|
|
Estado string `json:"estado"`
|
|
}
|
|
var req Req
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
clienteID := parseUintField(req.ClienteID)
|
|
if clienteID == 0 || strings.TrimSpace(req.Nombre) == "" {
|
|
return c.Status(400).JSON(fiber.Map{"error": "cliente y nombre son requeridos"})
|
|
}
|
|
if req.Slug == "" {
|
|
req.Slug = slugify(req.Nombre)
|
|
}
|
|
if req.Color == "" {
|
|
req.Color = "#8eb02f"
|
|
}
|
|
if req.Estado == "" {
|
|
req.Estado = "activo"
|
|
}
|
|
p := &models.Proyecto{
|
|
ClienteID: clienteID,
|
|
Nombre: req.Nombre,
|
|
Slug: req.Slug,
|
|
Descripcion: req.Descripcion,
|
|
Color: req.Color,
|
|
Estado: req.Estado,
|
|
}
|
|
if err := models.CreateProyecto(p); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.Status(201).JSON(p)
|
|
}
|
|
|
|
func UpdateProyecto(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"})
|
|
}
|
|
type Req struct {
|
|
ClienteID interface{} `json:"cliente_id"`
|
|
Nombre string `json:"nombre"`
|
|
Slug string `json:"slug"`
|
|
Descripcion string `json:"descripcion"`
|
|
Color string `json:"color"`
|
|
Estado string `json:"estado"`
|
|
Progreso int `json:"progreso"`
|
|
}
|
|
var req Req
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
clienteID := parseUintField(req.ClienteID)
|
|
p := &models.Proyecto{
|
|
ClienteID: clienteID,
|
|
Nombre: req.Nombre,
|
|
Slug: req.Slug,
|
|
Descripcion: req.Descripcion,
|
|
Color: req.Color,
|
|
Estado: req.Estado,
|
|
Progreso: req.Progreso,
|
|
}
|
|
p.ID = uint(id)
|
|
if err := models.UpdateProyecto(p); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
func DeleteProyecto(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.DeleteProyecto(uint(id)); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// ─── Fases ────────────────────────────────────────────────────────────────────
|
|
|
|
func GetFases(c *fiber.Ctx) error {
|
|
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
|
fases, err := models.GetFasesByProyecto(uint(id))
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fases)
|
|
}
|
|
|
|
func CreateFase(c *fiber.Ctx) error {
|
|
proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
|
type Req struct {
|
|
Nombre string `json:"nombre"`
|
|
Descripcion string `json:"descripcion"`
|
|
Estado string `json:"estado"`
|
|
Orden int `json:"orden"`
|
|
FechaEstimada string `json:"fecha_estimada"`
|
|
Entregables []string `json:"entregables"`
|
|
}
|
|
var req Req
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
f := &models.ProyectoFase{
|
|
ProyectoID: uint(proyID),
|
|
Nombre: req.Nombre,
|
|
Descripcion: req.Descripcion,
|
|
Estado: req.Estado,
|
|
Orden: req.Orden,
|
|
Entregables: req.Entregables,
|
|
}
|
|
if req.FechaEstimada != "" {
|
|
t, err := time.Parse("2006-01-02", req.FechaEstimada)
|
|
if err == nil {
|
|
f.FechaEstimada = &t
|
|
}
|
|
}
|
|
if err := models.CreateProyectoFase(f); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
f.ParseEntregables()
|
|
_ = models.ActualizarProgresoProyecto(uint(proyID))
|
|
return c.Status(201).JSON(f)
|
|
}
|
|
|
|
func UpdateFase(c *fiber.Ctx) error {
|
|
faseID, _ := strconv.ParseUint(c.Params("faseID"), 10, 32)
|
|
proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
|
type Req struct {
|
|
Nombre string `json:"nombre"`
|
|
Descripcion string `json:"descripcion"`
|
|
Estado string `json:"estado"`
|
|
Orden int `json:"orden"`
|
|
FechaEstimada string `json:"fecha_estimada"`
|
|
FechaCompletado string `json:"fecha_completado"`
|
|
Entregables []string `json:"entregables"`
|
|
}
|
|
var req Req
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
f := &models.ProyectoFase{
|
|
Nombre: req.Nombre,
|
|
Descripcion: req.Descripcion,
|
|
Estado: req.Estado,
|
|
Orden: req.Orden,
|
|
Entregables: req.Entregables,
|
|
}
|
|
f.ID = uint(faseID)
|
|
if req.FechaEstimada != "" {
|
|
t, err := time.Parse("2006-01-02", req.FechaEstimada)
|
|
if err == nil {
|
|
f.FechaEstimada = &t
|
|
}
|
|
}
|
|
if req.FechaCompletado != "" {
|
|
t, err := time.Parse("2006-01-02", req.FechaCompletado)
|
|
if err == nil {
|
|
f.FechaCompletado = &t
|
|
}
|
|
}
|
|
// Auto-set fecha_completado when estado = completado
|
|
if req.Estado == "completado" && f.FechaCompletado == nil {
|
|
now := time.Now()
|
|
f.FechaCompletado = &now
|
|
}
|
|
if err := models.UpdateProyectoFase(f); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
_ = models.ActualizarProgresoProyecto(uint(proyID))
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
func DeleteFase(c *fiber.Ctx) error {
|
|
faseID, _ := strconv.ParseUint(c.Params("faseID"), 10, 32)
|
|
proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
|
if err := models.DeleteProyectoFase(uint(faseID)); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
_ = models.ActualizarProgresoProyecto(uint(proyID))
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
func ApplyTemplate(c *fiber.Ctx) error {
|
|
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
|
if err := models.ApplyFaseTemplate(uint(id)); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
_ = models.ActualizarProgresoProyecto(uint(id))
|
|
fases, _ := models.GetFasesByProyecto(uint(id))
|
|
return c.JSON(fases)
|
|
}
|
|
|
|
// ─── Avances ──────────────────────────────────────────────────────────────────
|
|
|
|
func GetAvances(c *fiber.Ctx) error {
|
|
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
|
avances, err := models.GetAvancesByProyecto(uint(id), false)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(avances)
|
|
}
|
|
|
|
func CreateAvance(c *fiber.Ctx) error {
|
|
proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
|
var a models.ProyectoAvance
|
|
if err := c.BodyParser(&a); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
a.ProyectoID = uint(proyID)
|
|
if a.Tipo == "" {
|
|
a.Tipo = "update"
|
|
}
|
|
a.Visible = true
|
|
if err := models.CreateProyectoAvance(&a); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.Status(201).JSON(a)
|
|
}
|
|
|
|
func UpdateAvance(c *fiber.Ctx) error {
|
|
avID, _ := strconv.ParseUint(c.Params("avID"), 10, 32)
|
|
var a models.ProyectoAvance
|
|
if err := c.BodyParser(&a); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
a.ID = uint(avID)
|
|
if err := models.UpdateProyectoAvance(&a); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
func DeleteAvance(c *fiber.Ctx) error {
|
|
avID, _ := strconv.ParseUint(c.Params("avID"), 10, 32)
|
|
if err := models.DeleteProyectoAvance(uint(avID)); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// ─── Entregables ──────────────────────────────────────────────────────────────
|
|
|
|
func GetEntregables(c *fiber.Ctx) error {
|
|
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
|
items, err := models.GetEntregablesByProyecto(uint(id), false)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(items)
|
|
}
|
|
|
|
func UploadEntregable(c *fiber.Ctx) error {
|
|
proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
|
if _, err := c.MultipartForm(); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Formato multipart requerido"})
|
|
}
|
|
nombre := c.FormValue("nombre")
|
|
descripcion := c.FormValue("descripcion")
|
|
version := c.FormValue("version", "1.0")
|
|
|
|
file, err := c.FormFile("archivo")
|
|
if err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Archivo requerido"})
|
|
}
|
|
if file.Size > 50*1024*1024 {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Archivo máximo 50MB"})
|
|
}
|
|
|
|
dir := fmt.Sprintf("uploads/proyectos/%d", proyID)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "Error al crear directorio"})
|
|
}
|
|
|
|
safeFile := safeFilenameProyecto(file.Filename)
|
|
savePath := filepath.Join(dir, safeFile)
|
|
if err := c.SaveFile(file, savePath); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "Error al guardar archivo"})
|
|
}
|
|
|
|
e := &models.ProyectoEntregable{
|
|
ProyectoID: uint(proyID),
|
|
Nombre: nombre,
|
|
Descripcion: descripcion,
|
|
Archivo: savePath,
|
|
OriginalName: file.Filename,
|
|
Version: version,
|
|
TipoMime: mimeFromHeader(file),
|
|
Tamanio: file.Size,
|
|
Visible: true,
|
|
}
|
|
if err := models.CreateProyectoEntregable(e); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.Status(201).JSON(e)
|
|
}
|
|
|
|
func DeleteEntregable(c *fiber.Ctx) error {
|
|
entID, _ := strconv.ParseUint(c.Params("entID"), 10, 32)
|
|
item, err := models.GetProyectoEntregableByID(uint(entID))
|
|
if err != nil {
|
|
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
|
|
}
|
|
_ = os.Remove(item.Archivo)
|
|
if err := models.DeleteProyectoEntregable(uint(entID)); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
func DownloadEntregable(c *fiber.Ctx) error {
|
|
entID, _ := strconv.ParseUint(c.Params("entID"), 10, 32)
|
|
item, err := models.GetProyectoEntregableByID(uint(entID))
|
|
if err != nil {
|
|
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
|
|
}
|
|
clean := filepath.Clean(item.Archivo)
|
|
if !strings.HasPrefix(clean, "uploads/") {
|
|
return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"})
|
|
}
|
|
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, item.OriginalName))
|
|
return c.SendFile(clean)
|
|
}
|
|
|
|
func UpdateEntregableVisibilidad(c *fiber.Ctx) error {
|
|
entID, _ := strconv.ParseUint(c.Params("entID"), 10, 32)
|
|
type Req struct {
|
|
Visible bool `json:"visible"`
|
|
}
|
|
var req Req
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
if err := models.UpdateEntregableVisibilidad(uint(entID), req.Visible); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// ─── Documentos (contratos / órdenes de servicio) ─────────────────────────────
|
|
|
|
func GetDocumentos(c *fiber.Ctx) error {
|
|
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
|
items, err := models.GetDocumentosByProyecto(uint(id))
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(items)
|
|
}
|
|
|
|
func UploadDocumento(c *fiber.Ctx) error {
|
|
proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
|
if _, err := c.MultipartForm(); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Formato multipart requerido"})
|
|
}
|
|
nombre := strings.TrimSpace(c.FormValue("nombre"))
|
|
descripcion := c.FormValue("descripcion")
|
|
tipo := c.FormValue("tipo", "contrato")
|
|
|
|
validTipos := map[string]bool{"contrato": true, "orden_servicio": true, "otro": true}
|
|
if !validTipos[tipo] {
|
|
tipo = "otro"
|
|
}
|
|
|
|
file, err := c.FormFile("archivo")
|
|
if err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Archivo requerido"})
|
|
}
|
|
if file.Size > 50*1024*1024 {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Archivo máximo 50MB"})
|
|
}
|
|
if nombre == "" {
|
|
nombre = file.Filename
|
|
}
|
|
|
|
dir := fmt.Sprintf("uploads/proyectos/%d/docs", proyID)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "Error al crear directorio"})
|
|
}
|
|
|
|
safeFile := safeFilenameProyecto(file.Filename)
|
|
savePath := filepath.Join(dir, safeFile)
|
|
if err := c.SaveFile(file, savePath); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "Error al guardar archivo"})
|
|
}
|
|
|
|
d := &models.ProyectoDocumento{
|
|
ProyectoID: uint(proyID),
|
|
Tipo: tipo,
|
|
Nombre: nombre,
|
|
Descripcion: descripcion,
|
|
Archivo: savePath,
|
|
OriginalName: file.Filename,
|
|
TipoMime: mimeFromHeader(file),
|
|
Tamanio: file.Size,
|
|
}
|
|
if err := models.CreateProyectoDocumento(d); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.Status(201).JSON(d)
|
|
}
|
|
|
|
func DeleteDocumento(c *fiber.Ctx) error {
|
|
docID, _ := strconv.ParseUint(c.Params("docID"), 10, 32)
|
|
item, err := models.GetProyectoDocumentoByID(uint(docID))
|
|
if err != nil {
|
|
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
|
|
}
|
|
_ = os.Remove(item.Archivo)
|
|
if err := models.DeleteProyectoDocumento(uint(docID)); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
func DownloadDocumento(c *fiber.Ctx) error {
|
|
docID, _ := strconv.ParseUint(c.Params("docID"), 10, 32)
|
|
item, err := models.GetProyectoDocumentoByID(uint(docID))
|
|
if err != nil {
|
|
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
|
|
}
|
|
clean := filepath.Clean(item.Archivo)
|
|
if !strings.HasPrefix(clean, "uploads/") {
|
|
return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"})
|
|
}
|
|
nombre := item.OriginalName
|
|
if nombre == "" {
|
|
nombre = item.Nombre
|
|
}
|
|
c.Set("Content-Disposition", attachmentDisposition(nombre))
|
|
return c.SendFile(clean)
|
|
}
|
|
|
|
// attachmentDisposition genera un header Content-Disposition con fallback ASCII y encoding RFC 5987.
|
|
func attachmentDisposition(name string) string {
|
|
safe := strings.Map(func(r rune) rune {
|
|
if r > 127 || r == '"' || r == '\\' || r == '/' || r == '\n' || r == '\r' {
|
|
return '_'
|
|
}
|
|
return r
|
|
}, name)
|
|
return fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, safe, url.PathEscape(name))
|
|
}
|
|
|
|
// ─── Tickets (admin) ──────────────────────────────────────────────────────────
|
|
|
|
func GetTickets(c *fiber.Ctx) error {
|
|
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
|
items, err := models.GetTicketsByProyecto(uint(id))
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(items)
|
|
}
|
|
|
|
func UpdateTicketEstadoAdmin(c *fiber.Ctx) error {
|
|
ticketID, _ := strconv.ParseUint(c.Params("ticketID"), 10, 32)
|
|
type Req struct {
|
|
Estado string `json:"estado"`
|
|
}
|
|
var req Req
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
if err := models.UpdateTicketEstado(uint(ticketID), req.Estado); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
func AdminResponderTicket(c *fiber.Ctx) error {
|
|
ticketID, _ := strconv.ParseUint(c.Params("ticketID"), 10, 32)
|
|
type Req struct {
|
|
Contenido string `json:"contenido"`
|
|
}
|
|
var req Req
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
if strings.TrimSpace(req.Contenido) == "" {
|
|
return c.Status(400).JSON(fiber.Map{"error": "El mensaje no puede estar vacío"})
|
|
}
|
|
// Obtener nombre del admin
|
|
adminNombre := "Soporte"
|
|
if u := c.Locals("user"); u != nil {
|
|
if m, ok := u.(map[string]interface{}); ok {
|
|
if n, ok := m["Name"].(string); ok && n != "" {
|
|
adminNombre = n
|
|
}
|
|
}
|
|
}
|
|
msg := &models.TicketMensaje{
|
|
TicketID: uint(ticketID),
|
|
Contenido: req.Contenido,
|
|
EsAdmin: true,
|
|
AutorNombre: adminNombre,
|
|
}
|
|
if err := models.CreateTicketMensaje(msg); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
// Cambiar estado a en_progreso si estaba abierto
|
|
_ = models.UpdateTicketEstado(uint(ticketID), "en_progreso")
|
|
// Notificar al cliente
|
|
go func() {
|
|
ticket, err := models.GetTicketByID(uint(ticketID))
|
|
if err != nil {
|
|
return
|
|
}
|
|
proyNombre := ""
|
|
if ticket.ProyectoID != nil {
|
|
if proy, err := models.GetProyectoByID(*ticket.ProyectoID); err == nil {
|
|
proyNombre = proy.Nombre
|
|
}
|
|
}
|
|
services.DispatchTicketRespuestaAdmin(ticket, req.Contenido, proyNombre)
|
|
}()
|
|
return c.Status(201).JSON(msg)
|
|
}
|
|
|
|
// ─── Tickets global (admin) ──────────────────────────────────────────────────
|
|
|
|
func TicketsAdminIndex(c *fiber.Ctx) error {
|
|
return c.Render("tickets_admin", fiber.Map{
|
|
"user": c.Locals("user"),
|
|
"modules": c.Locals("userModules"),
|
|
}, "layouts/main")
|
|
}
|
|
|
|
func GetAllTicketsAdmin(c *fiber.Ctx) error {
|
|
estado := c.Query("estado", "todos")
|
|
items, err := models.GetAllTickets(estado)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(items)
|
|
}
|
|
|
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
func slugify(s string) string {
|
|
s = strings.ToLower(s)
|
|
replacer := strings.NewReplacer(
|
|
" ", "-", "á", "a", "é", "e", "í", "i", "ó", "o", "ú", "u",
|
|
"ñ", "n", "ü", "u",
|
|
)
|
|
s = replacer.Replace(s)
|
|
var b strings.Builder
|
|
for _, r := range s {
|
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return strings.Trim(b.String(), "-")
|
|
}
|
|
|
|
// parseUintField convierte interface{} (string o float64 del JSON) a uint de forma segura.
|
|
func parseUintField(v interface{}) uint {
|
|
switch val := v.(type) {
|
|
case float64:
|
|
return uint(val)
|
|
case string:
|
|
n, _ := strconv.ParseUint(strings.TrimSpace(val), 10, 64)
|
|
return uint(n)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func safeFilenameProyecto(name string) string {
|
|
base := filepath.Base(name)
|
|
ext := filepath.Ext(base)
|
|
stem := strings.TrimSuffix(base, ext)
|
|
safe := strings.Map(func(r rune) rune {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
|
return r
|
|
}
|
|
return '_'
|
|
}, stem)
|
|
ts := strconv.FormatInt(time.Now().UnixNano(), 36)
|
|
return safe + "_" + ts + ext
|
|
}
|
|
|
|
func mimeFromHeader(fh *multipart.FileHeader) string {
|
|
ct := fh.Header.Get("Content-Type")
|
|
if ct == "" {
|
|
return "application/octet-stream"
|
|
}
|
|
return ct
|
|
}
|