up
This commit is contained in:
@@ -85,6 +85,9 @@ func Migrate() {
|
||||
// Sistema de notificaciones por evento
|
||||
&models.NotifEventoConfig{},
|
||||
&models.SistemaNotificacion{},
|
||||
// Submódulo Partner
|
||||
&models.PartnerRecurso{},
|
||||
&models.PartnerComunicado{},
|
||||
); err != nil {
|
||||
log.Fatalf("Error during main migration: %v", err)
|
||||
}
|
||||
@@ -128,6 +131,9 @@ func Migrate() {
|
||||
// Crear módulo y submódulos de Portal de Clientes
|
||||
SeedPortalClientes()
|
||||
|
||||
// Agregar submódulo "Partner Recursos" al módulo Portal de Clientes
|
||||
SeedPartnerRecursos()
|
||||
|
||||
log.Println("Migration Completed...")
|
||||
}
|
||||
|
||||
@@ -846,3 +852,42 @@ func SeedNotifDefaults() {
|
||||
func SeedShield() {
|
||||
log.Println("[SEED] SeedShield: sin entradas por defecto definidas aún.")
|
||||
}
|
||||
|
||||
// SeedPartnerRecursos agrega el submódulo "Partner Recursos" al módulo "Portal de Clientes". Es idempotente.
|
||||
func SeedPartnerRecursos() {
|
||||
db := app.Http.Database.DB
|
||||
|
||||
var modulo models.Modules
|
||||
if err := db.Where("title = ?", "Portal de Clientes").First(&modulo).Error; err != nil {
|
||||
log.Printf("[SEED] Módulo 'Portal de Clientes' no encontrado para SeedPartnerRecursos: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
url := "/app/partner-recursos"
|
||||
var sub models.Submodules
|
||||
if err := db.Where("url = ?", url).First(&sub).Error; err != nil {
|
||||
sub = models.Submodules{
|
||||
Title: "Partner Recursos",
|
||||
Description: "Documentación y comunicados exclusivos para partners",
|
||||
Url: url,
|
||||
ModuleId: modulo.ID,
|
||||
ModifiedAt: time.Now(),
|
||||
}
|
||||
if err2 := db.Create(&sub).Error; err2 != nil {
|
||||
log.Printf("[SEED] Error creando submódulo Partner Recursos: %v", err2)
|
||||
return
|
||||
}
|
||||
log.Printf("[SEED] Submódulo 'Partner Recursos' creado (ID %d)", sub.ID)
|
||||
} else {
|
||||
log.Printf("[SEED] Submódulo 'Partner Recursos' ya existe (ID %d)", sub.ID)
|
||||
}
|
||||
|
||||
var roles []models.Roles
|
||||
if err := db.Find(&roles).Error; err != nil {
|
||||
return
|
||||
}
|
||||
for _, rol := range roles {
|
||||
db.Model(&rol).Association("Submodules").Append(&sub)
|
||||
}
|
||||
log.Println("[SEED] Seed de Partner Recursos completado.")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ─── PartnerRecurso ───────────────────────────────────────────────────────────
|
||||
// Archivos/documentos que el admin sube para que los partners descarguen.
|
||||
|
||||
type PartnerRecurso struct {
|
||||
gorm.Model
|
||||
Nombre string `json:"nombre" gorm:"column:nombre"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
Categoria string `json:"categoria" gorm:"column:categoria;default:'general'"` // general|tecnico|comercial|marketing
|
||||
Archivo string `json:"archivo" gorm:"column:archivo"`
|
||||
NombreOrig string `json:"nombre_orig" gorm:"column:nombre_orig"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
}
|
||||
|
||||
func (PartnerRecurso) TableName() string { return "partner_recursos" }
|
||||
|
||||
// ─── PartnerComunicado ────────────────────────────────────────────────────────
|
||||
// Anuncios/novedades publicados por el admin exclusivamente para partners.
|
||||
|
||||
type PartnerComunicado struct {
|
||||
gorm.Model
|
||||
Titulo string `json:"titulo" gorm:"column:titulo"`
|
||||
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
|
||||
Archivo string `json:"archivo" gorm:"column:archivo"`
|
||||
NombreOrig string `json:"nombre_orig" gorm:"column:nombre_orig"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
}
|
||||
|
||||
func (PartnerComunicado) TableName() string { return "partner_comunicados" }
|
||||
|
||||
// ─── CRUD PartnerRecurso ──────────────────────────────────────────────────────
|
||||
|
||||
func GetAllPartnerRecursos(soloActivos bool) ([]PartnerRecurso, error) {
|
||||
var items []PartnerRecurso
|
||||
db := app.Http.Database.DB.Order("created_at DESC")
|
||||
if soloActivos {
|
||||
db = db.Where("activo = true")
|
||||
}
|
||||
return items, db.Find(&items).Error
|
||||
}
|
||||
|
||||
func GetPartnerRecursoByID(id uint) (*PartnerRecurso, error) {
|
||||
var item PartnerRecurso
|
||||
return &item, app.Http.Database.DB.First(&item, id).Error
|
||||
}
|
||||
|
||||
func CreatePartnerRecurso(r *PartnerRecurso) error {
|
||||
return app.Http.Database.DB.Create(r).Error
|
||||
}
|
||||
|
||||
func UpdatePartnerRecurso(r *PartnerRecurso) error {
|
||||
return app.Http.Database.DB.Save(r).Error
|
||||
}
|
||||
|
||||
func DeletePartnerRecurso(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&PartnerRecurso{}, id).Error
|
||||
}
|
||||
|
||||
// ─── CRUD PartnerComunicado ───────────────────────────────────────────────────
|
||||
|
||||
func GetAllPartnerComunicados(soloActivos bool) ([]PartnerComunicado, error) {
|
||||
var items []PartnerComunicado
|
||||
db := app.Http.Database.DB.Order("created_at DESC")
|
||||
if soloActivos {
|
||||
db = db.Where("activo = true")
|
||||
}
|
||||
return items, db.Find(&items).Error
|
||||
}
|
||||
|
||||
func GetPartnerComunicadoByID(id uint) (*PartnerComunicado, error) {
|
||||
var item PartnerComunicado
|
||||
return &item, app.Http.Database.DB.First(&item, id).Error
|
||||
}
|
||||
|
||||
func CreatePartnerComunicado(c *PartnerComunicado) error {
|
||||
return app.Http.Database.DB.Create(c).Error
|
||||
}
|
||||
|
||||
func UpdatePartnerComunicado(c *PartnerComunicado) error {
|
||||
return app.Http.Database.DB.Save(c).Error
|
||||
}
|
||||
|
||||
func DeletePartnerComunicado(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&PartnerComunicado{}, id).Error
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
<div x-data="partnerRecursos()" x-init="init()" class="p-6">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-slate-800">Partner Recursos</h1>
|
||||
<p class="text-sm text-slate-500 mt-0.5">Documentación y comunicados exclusivos para los usuarios partner del portal.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-slate-200 mb-6 gap-0">
|
||||
<button @click="tab='recursos'" class="px-5 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors"
|
||||
:class="tab==='recursos' ? 'border-[#8eb02f] text-[#8eb02f]' : 'border-transparent text-slate-500 hover:text-slate-700'">
|
||||
📁 Recursos
|
||||
<span class="ml-1.5 bg-slate-100 text-slate-600 text-xs px-1.5 py-0.5 rounded-full" x-text="recursos.length"></span>
|
||||
</button>
|
||||
<button @click="tab='comunicados'" class="px-5 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors"
|
||||
:class="tab==='comunicados' ? 'border-[#8eb02f] text-[#8eb02f]' : 'border-transparent text-slate-500 hover:text-slate-700'">
|
||||
📢 Comunicados
|
||||
<span class="ml-1.5 bg-slate-100 text-slate-600 text-xs px-1.5 py-0.5 rounded-full" x-text="comunicados.length"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ──── TAB RECURSOS ─────────────────────────────────────────────────────── -->
|
||||
<div x-show="tab==='recursos'" x-cloak>
|
||||
|
||||
<!-- Formulario nuevo recurso -->
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-5 mb-6">
|
||||
<h3 class="text-sm font-semibold text-slate-700 mb-4">Agregar recurso</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
|
||||
<input x-model="newR.nombre" type="text" placeholder="Nombre del recurso *" class="input-f col-span-1">
|
||||
<select x-model="newR.categoria" class="input-f">
|
||||
<option value="general">General</option>
|
||||
<option value="tecnico">Técnico</option>
|
||||
<option value="comercial">Comercial</option>
|
||||
<option value="marketing">Marketing</option>
|
||||
</select>
|
||||
<textarea x-model="newR.descripcion" placeholder="Descripción breve..." rows="2" class="input-f sm:col-span-2"></textarea>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-600 cursor-pointer">
|
||||
<input type="checkbox" x-model="newR.activo" class="rounded accent-[#8eb02f]">
|
||||
Visible para partners
|
||||
</label>
|
||||
</div>
|
||||
<button @click="crearRecurso()" class="btn-p text-sm" :disabled="!newR.nombre.trim()">Crear recurso</button>
|
||||
</div>
|
||||
|
||||
<!-- Lista de recursos -->
|
||||
<div class="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
<th class="px-4 py-3 text-left">Nombre</th>
|
||||
<th class="px-4 py-3 text-left hidden sm:table-cell">Categoría</th>
|
||||
<th class="px-4 py-3 text-left hidden md:table-cell">Descripción</th>
|
||||
<th class="px-4 py-3 text-center">Archivo</th>
|
||||
<th class="px-4 py-3 text-center">Visible</th>
|
||||
<th class="px-4 py-3 text-center w-24">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<template x-for="r in recursos" :key="r.ID">
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<!-- Nombre / edición inline -->
|
||||
<td class="px-4 py-3">
|
||||
<template x-if="editR?.ID === r.ID">
|
||||
<input x-model="editR.nombre" class="input-f text-xs w-full" placeholder="Nombre">
|
||||
</template>
|
||||
<template x-if="editR?.ID !== r.ID">
|
||||
<span class="font-medium text-slate-800" x-text="r.nombre"></span>
|
||||
</template>
|
||||
</td>
|
||||
<!-- Categoría -->
|
||||
<td class="px-4 py-3 hidden sm:table-cell">
|
||||
<template x-if="editR?.ID === r.ID">
|
||||
<select x-model="editR.categoria" class="input-f text-xs">
|
||||
<option value="general">General</option>
|
||||
<option value="tecnico">Técnico</option>
|
||||
<option value="comercial">Comercial</option>
|
||||
<option value="marketing">Marketing</option>
|
||||
</select>
|
||||
</template>
|
||||
<template x-if="editR?.ID !== r.ID">
|
||||
<span class="badge-cat" :class="catClass(r.categoria)" x-text="r.categoria"></span>
|
||||
</template>
|
||||
</td>
|
||||
<!-- Descripción -->
|
||||
<td class="px-4 py-3 hidden md:table-cell text-slate-500 text-xs max-w-xs truncate" x-text="r.descripcion"></td>
|
||||
<!-- Archivo -->
|
||||
<td class="px-4 py-3 text-center">
|
||||
<template x-if="r.archivo">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<a :href="`/app/partner-recursos/${r.ID}/download`" target="_blank"
|
||||
class="text-[#8eb02f] hover:underline text-xs flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
||||
<span x-text="r.nombre_orig || 'Descargar'"></span>
|
||||
</a>
|
||||
<label class="cursor-pointer text-slate-400 hover:text-slate-600">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l4-4m0 0l4 4m-4-4v12"/></svg>
|
||||
<input type="file" class="hidden" @change="subirArchivoRecurso(r.ID, $event)">
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!r.archivo">
|
||||
<label class="cursor-pointer inline-flex items-center gap-1 text-xs text-slate-500 hover:text-[#8eb02f]">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l4-4m0 0l4 4m-4-4v12"/></svg>
|
||||
Subir
|
||||
<input type="file" class="hidden" @change="subirArchivoRecurso(r.ID, $event)">
|
||||
</label>
|
||||
</template>
|
||||
</td>
|
||||
<!-- Toggle visible -->
|
||||
<td class="px-4 py-3 text-center">
|
||||
<button @click="toggleRecurso(r)"
|
||||
class="w-10 h-5 rounded-full transition-colors flex-shrink-0 relative"
|
||||
:class="r.activo ? 'bg-[#8eb02f]' : 'bg-slate-200'">
|
||||
<span class="absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform"
|
||||
:class="r.activo ? 'translate-x-5' : 'translate-x-0.5'"></span>
|
||||
</button>
|
||||
</td>
|
||||
<!-- Acciones -->
|
||||
<td class="px-4 py-3 text-center">
|
||||
<template x-if="editR?.ID === r.ID">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<button @click="guardarRecurso()" class="text-xs text-white bg-[#8eb02f] px-2 py-1 rounded hover:bg-[#6d8c24]">Guardar</button>
|
||||
<button @click="editR=null" class="text-xs text-slate-500 hover:text-slate-700">Cancelar</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="editR?.ID !== r.ID">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<button @click="editR={...r}" class="text-slate-400 hover:text-[#8eb02f]" title="Editar">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg>
|
||||
</button>
|
||||
<button @click="eliminarRecurso(r.ID)" class="text-slate-400 hover:text-red-500" title="Eliminar">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" 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>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="recursos.length === 0">
|
||||
<td colspan="6" class="px-4 py-10 text-center text-slate-400 text-sm">Sin recursos aún.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ──── TAB COMUNICADOS ──────────────────────────────────────────────────── -->
|
||||
<div x-show="tab==='comunicados'" x-cloak>
|
||||
|
||||
<!-- Formulario nuevo comunicado -->
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-5 mb-6">
|
||||
<h3 class="text-sm font-semibold text-slate-700 mb-4">Publicar comunicado</h3>
|
||||
<div class="space-y-3">
|
||||
<input x-model="newC.titulo" type="text" placeholder="Título del comunicado *" class="input-f w-full">
|
||||
<textarea x-model="newC.contenido" placeholder="Contenido del comunicado..." rows="4" class="input-f w-full"></textarea>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-600 cursor-pointer">
|
||||
<input type="checkbox" x-model="newC.activo" class="rounded accent-[#8eb02f]">
|
||||
Publicar inmediatamente
|
||||
</label>
|
||||
<button @click="crearComunicado()" class="btn-p text-sm" :disabled="!newC.titulo.trim()">Publicar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de comunicados -->
|
||||
<div class="space-y-3">
|
||||
<template x-for="com in comunicados" :key="com.ID">
|
||||
<div class="bg-white border rounded-xl p-5 transition-colors" :class="com.activo ? 'border-slate-200' : 'border-dashed border-slate-300 opacity-70'">
|
||||
<template x-if="editC?.ID === com.ID">
|
||||
<div class="space-y-3">
|
||||
<input x-model="editC.titulo" class="input-f w-full font-semibold" placeholder="Título">
|
||||
<textarea x-model="editC.contenido" rows="4" class="input-f w-full text-sm"></textarea>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-600 cursor-pointer">
|
||||
<input type="checkbox" x-model="editC.activo" class="rounded accent-[#8eb02f]">
|
||||
Publicado
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button @click="guardarComunicado()" class="btn-p text-sm">Guardar</button>
|
||||
<button @click="editC=null" class="text-sm text-slate-500 hover:text-slate-700">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="editC?.ID !== com.ID">
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 class="font-semibold text-slate-800 text-sm" x-text="com.titulo"></h3>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
:class="com.activo ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-500'"
|
||||
x-text="com.activo ? 'Publicado' : 'Borrador'"></span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-600 whitespace-pre-line leading-relaxed" x-text="com.contenido"></p>
|
||||
<p class="text-xs text-slate-400 mt-2" x-text="formatDate(com.CreatedAt)"></p>
|
||||
</div>
|
||||
<!-- Archivo adjunto -->
|
||||
<div class="flex flex-col items-end gap-2 flex-shrink-0">
|
||||
<template x-if="com.archivo">
|
||||
<div class="flex items-center gap-2">
|
||||
<a :href="`/app/partner-comunicados/${com.ID}/download`" target="_blank"
|
||||
class="text-xs text-[#8eb02f] hover:underline flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
||||
<span x-text="com.nombre_orig || 'Adjunto'"></span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<label class="cursor-pointer text-xs text-slate-400 hover:text-[#8eb02f] flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" 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="com.archivo ? 'Cambiar adjunto' : 'Adjuntar'"></span>
|
||||
<input type="file" class="hidden" @change="subirArchivoComunicado(com.ID, $event)">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-3 pt-3 border-t border-slate-100">
|
||||
<button @click="editC={...com}" class="text-xs text-slate-500 hover:text-[#8eb02f] flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg>
|
||||
Editar
|
||||
</button>
|
||||
<button @click="toggleComunicado(com)" class="text-xs flex items-center gap-1"
|
||||
:class="com.activo ? 'text-amber-500 hover:text-amber-600' : 'text-green-600 hover:text-green-700'"
|
||||
x-text="com.activo ? 'Despublicar' : 'Publicar'">
|
||||
</button>
|
||||
<button @click="eliminarComunicado(com.ID)" class="text-xs text-slate-400 hover:text-red-500 flex items-center gap-1 ml-auto">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" 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>
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="comunicados.length === 0" class="text-center text-slate-400 text-sm py-10">Sin comunicados publicados.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function partnerRecursos() {
|
||||
return {
|
||||
tab: 'recursos',
|
||||
recursos: [],
|
||||
comunicados: [],
|
||||
newR: { nombre:'', descripcion:'', categoria:'general', activo:true },
|
||||
newC: { titulo:'', contenido:'', activo:true },
|
||||
editR: null,
|
||||
editC: null,
|
||||
|
||||
async init() {
|
||||
await Promise.all([this.loadRecursos(), this.loadComunicados()]);
|
||||
},
|
||||
|
||||
async loadRecursos() {
|
||||
const r = await axios.get('/app/partner-recursos/data');
|
||||
this.recursos = r.data.recursos || [];
|
||||
},
|
||||
|
||||
async loadComunicados() {
|
||||
const r = await axios.get('/app/partner-comunicados/data');
|
||||
this.comunicados = r.data.comunicados || [];
|
||||
},
|
||||
|
||||
// ── Recursos ──────────────────────────────────────────────────────────────
|
||||
async crearRecurso() {
|
||||
try {
|
||||
const r = await axios.post('/app/partner-recursos', this.newR);
|
||||
this.recursos.unshift(r.data);
|
||||
this.newR = { nombre:'', descripcion:'', categoria:'general', activo:true };
|
||||
} catch(e) { alert(e.response?.data?.error || 'Error al crear'); }
|
||||
},
|
||||
|
||||
async guardarRecurso() {
|
||||
try {
|
||||
const r = await axios.put(`/app/partner-recursos/${this.editR.ID}`, this.editR);
|
||||
const idx = this.recursos.findIndex(x => x.ID === r.data.ID);
|
||||
if (idx >= 0) this.recursos[idx] = r.data;
|
||||
this.editR = null;
|
||||
} catch(e) { alert(e.response?.data?.error || 'Error al guardar'); }
|
||||
},
|
||||
|
||||
async toggleRecurso(r) {
|
||||
try {
|
||||
const updated = await axios.put(`/app/partner-recursos/${r.ID}`, {...r, activo: !r.activo});
|
||||
const idx = this.recursos.findIndex(x => x.ID === r.ID);
|
||||
if (idx >= 0) this.recursos[idx] = updated.data;
|
||||
} catch(e) { alert('Error'); }
|
||||
},
|
||||
|
||||
async eliminarRecurso(id) {
|
||||
if (!confirm('¿Eliminar este recurso?')) return;
|
||||
await axios.delete(`/app/partner-recursos/${id}`);
|
||||
this.recursos = this.recursos.filter(x => x.ID !== id);
|
||||
},
|
||||
|
||||
async subirArchivoRecurso(id, event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
fd.append('archivo', file);
|
||||
try {
|
||||
const r = await axios.post(`/app/partner-recursos/${id}/upload`, fd);
|
||||
const idx = this.recursos.findIndex(x => x.ID === id);
|
||||
if (idx >= 0) {
|
||||
this.recursos[idx].archivo = r.data.archivo;
|
||||
this.recursos[idx].nombre_orig = r.data.nombre_orig;
|
||||
}
|
||||
} catch(e) { alert(e.response?.data?.error || 'Error al subir'); }
|
||||
},
|
||||
|
||||
// ── Comunicados ───────────────────────────────────────────────────────────
|
||||
async crearComunicado() {
|
||||
try {
|
||||
const r = await axios.post('/app/partner-comunicados', this.newC);
|
||||
this.comunicados.unshift(r.data);
|
||||
this.newC = { titulo:'', contenido:'', activo:true };
|
||||
} catch(e) { alert(e.response?.data?.error || 'Error al publicar'); }
|
||||
},
|
||||
|
||||
async guardarComunicado() {
|
||||
try {
|
||||
const r = await axios.put(`/app/partner-comunicados/${this.editC.ID}`, this.editC);
|
||||
const idx = this.comunicados.findIndex(x => x.ID === r.data.ID);
|
||||
if (idx >= 0) this.comunicados[idx] = r.data;
|
||||
this.editC = null;
|
||||
} catch(e) { alert(e.response?.data?.error || 'Error al guardar'); }
|
||||
},
|
||||
|
||||
async toggleComunicado(com) {
|
||||
try {
|
||||
const r = await axios.put(`/app/partner-comunicados/${com.ID}`, {...com, activo: !com.activo});
|
||||
const idx = this.comunicados.findIndex(x => x.ID === com.ID);
|
||||
if (idx >= 0) this.comunicados[idx] = r.data;
|
||||
} catch(e) { alert('Error'); }
|
||||
},
|
||||
|
||||
async eliminarComunicado(id) {
|
||||
if (!confirm('¿Eliminar este comunicado?')) return;
|
||||
await axios.delete(`/app/partner-comunicados/${id}`);
|
||||
this.comunicados = this.comunicados.filter(x => x.ID !== id);
|
||||
},
|
||||
|
||||
async subirArchivoComunicado(id, event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
fd.append('archivo', file);
|
||||
try {
|
||||
const r = await axios.post(`/app/partner-comunicados/${id}/upload`, fd);
|
||||
const idx = this.comunicados.findIndex(x => x.ID === id);
|
||||
if (idx >= 0) {
|
||||
this.comunicados[idx].archivo = r.data.archivo;
|
||||
this.comunicados[idx].nombre_orig = r.data.nombre_orig;
|
||||
}
|
||||
} catch(e) { alert(e.response?.data?.error || 'Error al subir'); }
|
||||
},
|
||||
|
||||
catClass(cat) {
|
||||
return {
|
||||
general: 'cat-general',
|
||||
tecnico: 'cat-tecnico',
|
||||
comercial: 'cat-comercial',
|
||||
marketing: 'cat-marketing',
|
||||
}[cat] || 'cat-general';
|
||||
},
|
||||
|
||||
formatDate(d) {
|
||||
if (!d) return '';
|
||||
return new Date(d).toLocaleDateString('es-CO', { day:'2-digit', month:'short', year:'numeric' });
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.btn-p { background:#8eb02f; color:#fff; padding:.5rem 1.25rem; border-radius:.5rem; font-weight:600; cursor:pointer; }
|
||||
.btn-p:hover { background:#6d8c24; }
|
||||
.btn-p:disabled { opacity:.5; cursor:default; }
|
||||
.input-f { border:1px solid #e2e8f0; border-radius:.5rem; padding:.45rem .75rem; font-size:.875rem; outline:none; width:100%; }
|
||||
.input-f:focus { border-color:#8eb02f; }
|
||||
.badge-cat { display:inline-block; padding:.15rem .6rem; border-radius:9999px; font-size:.7rem; font-weight:600; text-transform:capitalize; }
|
||||
.cat-general { background:#f1f5f9; color:#475569; }
|
||||
.cat-tecnico { background:#dbeafe; color:#1d4ed8; }
|
||||
.cat-comercial { background:#fef9c3; color:#854d0e; }
|
||||
.cat-marketing { background:#fce7f3; color:#be185d; }
|
||||
</style>
|
||||
@@ -80,3 +80,135 @@
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
|
||||
{{ if .isPartner }}
|
||||
<!-- ─── Sección Partner: Recursos y Comunicados ─────────────────────────────── -->
|
||||
<div x-data="partnerHub()" x-init="init()" class="mt-10">
|
||||
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<span class="w-8 h-8 rounded-lg bg-[#8eb02f] text-white flex items-center justify-center text-sm font-bold">P</span>
|
||||
<div>
|
||||
<h2 class="text-base font-bold text-slate-800">Zona Partner</h2>
|
||||
<p class="text-xs text-slate-400">Recursos y comunicados exclusivos para ti</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-slate-200 mb-5 gap-0">
|
||||
<button @click="tab='comunicados'" class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors"
|
||||
:class="tab==='comunicados' ? 'border-[#8eb02f] text-[#8eb02f]' : 'border-transparent text-slate-500 hover:text-slate-700'">
|
||||
📢 Comunicados
|
||||
<span x-show="comunicados.length > 0" class="ml-1 bg-slate-100 text-slate-500 text-xs px-1.5 py-0.5 rounded-full" x-text="comunicados.length"></span>
|
||||
</button>
|
||||
<button @click="tab='recursos'" class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors"
|
||||
:class="tab==='recursos' ? 'border-[#8eb02f] text-[#8eb02f]' : 'border-transparent text-slate-500 hover:text-slate-700'">
|
||||
📁 Recursos
|
||||
<span x-show="recursos.length > 0" class="ml-1 bg-slate-100 text-slate-500 text-xs px-1.5 py-0.5 rounded-full" x-text="recursos.length"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comunicados -->
|
||||
<div x-show="tab==='comunicados'" x-cloak>
|
||||
<div class="space-y-4">
|
||||
<template x-for="com in comunicados" :key="com.ID">
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-slate-800 text-sm mb-1" x-text="com.titulo"></h3>
|
||||
<p class="text-sm text-slate-600 whitespace-pre-line leading-relaxed" x-text="com.contenido"></p>
|
||||
<p class="text-xs text-slate-400 mt-2" x-text="formatDate(com.CreatedAt)"></p>
|
||||
</div>
|
||||
<template x-if="com.archivo">
|
||||
<a :href="`/portal/partner-comunicados/${com.ID}/download`" target="_blank"
|
||||
class="flex-shrink-0 inline-flex items-center gap-1.5 text-xs text-[#8eb02f] hover:text-[#6d8c24] border border-[#8eb02f] px-2.5 py-1.5 rounded-lg hover:bg-[#f6ffe0] transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
||||
Adjunto
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="comunicados.length === 0 && !loading" class="text-slate-400 text-sm text-center py-10">Sin comunicados por ahora.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recursos -->
|
||||
<div x-show="tab==='recursos'" x-cloak>
|
||||
<!-- Filtro por categoría -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<template x-for="cat in ['todos','general','tecnico','comercial','marketing']" :key="cat">
|
||||
<button @click="filtro=cat" class="text-xs px-3 py-1 rounded-full border font-medium transition-colors capitalize"
|
||||
:class="filtro===cat ? 'bg-[#8eb02f] text-white border-[#8eb02f]' : 'border-slate-200 text-slate-500 hover:border-slate-400'" x-text="cat">
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<template x-for="r in recursosFiltrados" :key="r.ID">
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-4 flex flex-col gap-2">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium capitalize" :class="catClass(r.categoria)" x-text="r.categoria"></span>
|
||||
</div>
|
||||
<h4 class="font-semibold text-slate-800 text-sm" x-text="r.nombre"></h4>
|
||||
<p class="text-xs text-slate-500 mt-0.5 line-clamp-2" x-text="r.descripcion"></p>
|
||||
</div>
|
||||
</div>
|
||||
<template x-if="r.archivo">
|
||||
<a :href="`/portal/partner-recursos/${r.ID}/download`" target="_blank"
|
||||
class="mt-auto inline-flex items-center gap-2 text-xs font-medium text-white bg-[#8eb02f] hover:bg-[#6d8c24] px-3 py-1.5 rounded-lg transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
||||
Descargar
|
||||
</a>
|
||||
</template>
|
||||
<template x-if="!r.archivo">
|
||||
<span class="text-xs text-slate-400 italic">Sin archivo adjunto</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="recursosFiltrados.length === 0 && !loading" class="sm:col-span-2 lg:col-span-3 text-slate-400 text-sm text-center py-10">Sin recursos disponibles.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<script>
|
||||
function partnerHub() {
|
||||
return {
|
||||
tab: 'comunicados',
|
||||
filtro: 'todos',
|
||||
recursos: [],
|
||||
comunicados: [],
|
||||
loading: false,
|
||||
|
||||
async init() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await axios.get('/portal/partner-recursos');
|
||||
this.recursos = r.data.recursos || [];
|
||||
this.comunicados = r.data.comunicados || [];
|
||||
} catch(e) {} finally { this.loading = false; }
|
||||
},
|
||||
|
||||
get recursosFiltrados() {
|
||||
if (this.filtro === 'todos') return this.recursos;
|
||||
return this.recursos.filter(r => r.categoria === this.filtro);
|
||||
},
|
||||
|
||||
catClass(cat) {
|
||||
return {
|
||||
general: 'bg-slate-100 text-slate-600',
|
||||
tecnico: 'bg-blue-100 text-blue-700',
|
||||
comercial: 'bg-yellow-100 text-yellow-700',
|
||||
marketing: 'bg-pink-100 text-pink-700',
|
||||
}[cat] || 'bg-slate-100 text-slate-600';
|
||||
},
|
||||
|
||||
formatDate(d) {
|
||||
if (!d) return '';
|
||||
return new Date(d).toLocaleDateString('es-CO', { day:'2-digit', month:'short', year:'numeric' });
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// ─── Admin: vista principal ───────────────────────────────────────────────────
|
||||
|
||||
func PartnerRecursosIndex(c *fiber.Ctx) error {
|
||||
return c.Render("partner_recursos", fiber.Map{
|
||||
"user": c.Locals("user"),
|
||||
"modules": c.Locals("userModules"),
|
||||
}, "layouts/main")
|
||||
}
|
||||
|
||||
// ─── Recursos ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func LoadPartnerRecursos(c *fiber.Ctx) error {
|
||||
recursos, err := models.GetAllPartnerRecursos(false)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"recursos": recursos})
|
||||
}
|
||||
|
||||
func CreatePartnerRecurso(c *fiber.Ctx) error {
|
||||
type Req struct {
|
||||
Nombre string `json:"nombre"`
|
||||
Descripcion string `json:"descripcion"`
|
||||
Categoria string `json:"categoria"`
|
||||
Activo bool `json:"activo"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Datos inválidos"})
|
||||
}
|
||||
r := &models.PartnerRecurso{
|
||||
Nombre: req.Nombre,
|
||||
Descripcion: req.Descripcion,
|
||||
Categoria: req.Categoria,
|
||||
Activo: req.Activo,
|
||||
}
|
||||
if err := models.CreatePartnerRecurso(r); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(201).JSON(r)
|
||||
}
|
||||
|
||||
func UpdatePartnerRecurso(c *fiber.Ctx) error {
|
||||
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
type Req struct {
|
||||
Nombre string `json:"nombre"`
|
||||
Descripcion string `json:"descripcion"`
|
||||
Categoria string `json:"categoria"`
|
||||
Activo bool `json:"activo"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Datos inválidos"})
|
||||
}
|
||||
r, err := models.GetPartnerRecursoByID(uint(id))
|
||||
if err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
|
||||
}
|
||||
r.Nombre = req.Nombre
|
||||
r.Descripcion = req.Descripcion
|
||||
r.Categoria = req.Categoria
|
||||
r.Activo = req.Activo
|
||||
if err := models.UpdatePartnerRecurso(r); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(r)
|
||||
}
|
||||
|
||||
func DeletePartnerRecurso(c *fiber.Ctx) error {
|
||||
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
if err := models.DeletePartnerRecurso(uint(id)); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func UploadPartnerRecursoArchivo(c *fiber.Ctx) error {
|
||||
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
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": "Máximo 50MB"})
|
||||
}
|
||||
dir := "uploads/partner_recursos"
|
||||
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, fmt.Sprintf("%d_%s", id, safeFile))
|
||||
// Validar que la ruta quede dentro de uploads/
|
||||
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 al guardar archivo"})
|
||||
}
|
||||
r, err := models.GetPartnerRecursoByID(uint(id))
|
||||
if err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Recurso no encontrado"})
|
||||
}
|
||||
r.Archivo = savePath
|
||||
r.NombreOrig = file.Filename
|
||||
if err := models.UpdatePartnerRecurso(r); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true, "archivo": savePath, "nombre_orig": file.Filename})
|
||||
}
|
||||
|
||||
func DownloadPartnerRecurso(c *fiber.Ctx) error {
|
||||
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
r, err := models.GetPartnerRecursoByID(uint(id))
|
||||
if err != nil || r.Archivo == "" {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Archivo no encontrado"})
|
||||
}
|
||||
clean := filepath.Clean(r.Archivo)
|
||||
if !strings.HasPrefix(clean, "uploads/") {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"})
|
||||
}
|
||||
nombre := r.NombreOrig
|
||||
if nombre == "" {
|
||||
nombre = filepath.Base(r.Archivo)
|
||||
}
|
||||
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, nombre))
|
||||
return c.SendFile(clean)
|
||||
}
|
||||
|
||||
// ─── Comunicados ──────────────────────────────────────────────────────────────
|
||||
|
||||
func LoadPartnerComunicados(c *fiber.Ctx) error {
|
||||
items, err := models.GetAllPartnerComunicados(false)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"comunicados": items})
|
||||
}
|
||||
|
||||
func CreatePartnerComunicado(c *fiber.Ctx) error {
|
||||
type Req struct {
|
||||
Titulo string `json:"titulo"`
|
||||
Contenido string `json:"contenido"`
|
||||
Activo bool `json:"activo"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Datos inválidos"})
|
||||
}
|
||||
item := &models.PartnerComunicado{
|
||||
Titulo: req.Titulo,
|
||||
Contenido: req.Contenido,
|
||||
Activo: req.Activo,
|
||||
}
|
||||
if err := models.CreatePartnerComunicado(item); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(201).JSON(item)
|
||||
}
|
||||
|
||||
func UpdatePartnerComunicado(c *fiber.Ctx) error {
|
||||
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
type Req struct {
|
||||
Titulo string `json:"titulo"`
|
||||
Contenido string `json:"contenido"`
|
||||
Activo bool `json:"activo"`
|
||||
}
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Datos inválidos"})
|
||||
}
|
||||
item, err := models.GetPartnerComunicadoByID(uint(id))
|
||||
if err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
|
||||
}
|
||||
item.Titulo = req.Titulo
|
||||
item.Contenido = req.Contenido
|
||||
item.Activo = req.Activo
|
||||
if err := models.UpdatePartnerComunicado(item); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(item)
|
||||
}
|
||||
|
||||
func DeletePartnerComunicado(c *fiber.Ctx) error {
|
||||
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
if err := models.DeletePartnerComunicado(uint(id)); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func UploadPartnerComunicadoArchivo(c *fiber.Ctx) error {
|
||||
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
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": "Máximo 50MB"})
|
||||
}
|
||||
dir := "uploads/partner_comunicados"
|
||||
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, fmt.Sprintf("%d_%s", id, 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 al guardar archivo"})
|
||||
}
|
||||
item, err := models.GetPartnerComunicadoByID(uint(id))
|
||||
if err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Comunicado no encontrado"})
|
||||
}
|
||||
item.Archivo = savePath
|
||||
item.NombreOrig = file.Filename
|
||||
if err := models.UpdatePartnerComunicado(item); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true, "archivo": savePath, "nombre_orig": file.Filename})
|
||||
}
|
||||
|
||||
func DownloadPartnerComunicadoArchivo(c *fiber.Ctx) error {
|
||||
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
item, err := models.GetPartnerComunicadoByID(uint(id))
|
||||
if err != nil || item.Archivo == "" {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Archivo no encontrado"})
|
||||
}
|
||||
clean := filepath.Clean(item.Archivo)
|
||||
if !strings.HasPrefix(clean, "uploads/") {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"})
|
||||
}
|
||||
nombre := item.NombreOrig
|
||||
if nombre == "" {
|
||||
nombre = filepath.Base(item.Archivo)
|
||||
}
|
||||
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, nombre))
|
||||
return c.SendFile(clean)
|
||||
}
|
||||
|
||||
// ─── Portal: acceso para partners ────────────────────────────────────────────
|
||||
|
||||
func PortalPartnerRecursos(c *fiber.Ctx) error {
|
||||
recursos, _ := models.GetAllPartnerRecursos(true)
|
||||
comunicados, _ := models.GetAllPartnerComunicados(true)
|
||||
return c.JSON(fiber.Map{
|
||||
"recursos": recursos,
|
||||
"comunicados": comunicados,
|
||||
})
|
||||
}
|
||||
|
||||
func PortalDownloadPartnerRecurso(c *fiber.Ctx) error {
|
||||
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
r, err := models.GetPartnerRecursoByID(uint(id))
|
||||
if err != nil || r.Archivo == "" || !r.Activo {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Archivo no encontrado"})
|
||||
}
|
||||
clean := filepath.Clean(r.Archivo)
|
||||
if !strings.HasPrefix(clean, "uploads/") {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"})
|
||||
}
|
||||
nombre := r.NombreOrig
|
||||
if nombre == "" {
|
||||
nombre = filepath.Base(r.Archivo)
|
||||
}
|
||||
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, nombre))
|
||||
return c.SendFile(clean)
|
||||
}
|
||||
@@ -35,3 +35,14 @@ func PortalUserFromLocals(c *fiber.Ctx) *models.PortalUser {
|
||||
u, _ := c.Locals("portalUser").(*models.PortalUser)
|
||||
return u
|
||||
}
|
||||
|
||||
// PortalPartnerOnly permite el acceso solo a usuarios con rol "partner".
|
||||
func PortalPartnerOnly() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
u := PortalUserFromLocals(c)
|
||||
if u == nil || u.Rol != "partner" {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Acceso exclusivo para partners"})
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,4 +35,9 @@ func PortalRoutes(app fiber.Router) {
|
||||
portal.Get("/mis-notifs", controllers.PortalGetMisNotifs)
|
||||
portal.Put("/mis-notifs/:id/leida", controllers.PortalMarcarNotifLeida)
|
||||
portal.Post("/mis-notifs/marcar-todas", controllers.PortalMarcarTodasLeidas)
|
||||
|
||||
// Partner: recursos y comunicados (solo usuarios con rol partner)
|
||||
portal.Get("/partner-recursos", middlewares.PortalPartnerOnly(), controllers.PortalPartnerRecursos)
|
||||
portal.Get("/partner-recursos/:id/download", middlewares.PortalPartnerOnly(), controllers.PortalDownloadPartnerRecurso)
|
||||
portal.Get("/partner-comunicados/:id/download", middlewares.PortalPartnerOnly(), controllers.DownloadPartnerComunicadoArchivo)
|
||||
}
|
||||
|
||||
@@ -295,6 +295,22 @@ func UserRoutes(app fiber.Router) {
|
||||
protected.Get("/notif-config/data", controllers.GetNotifConfigs)
|
||||
protected.Post("/notif-config", controllers.SaveNotifConfig)
|
||||
|
||||
// Partner Recursos (documentación y comunicados para partners)
|
||||
protected.Get("/partner-recursos", middlewares.MenuMiddleware, controllers.PartnerRecursosIndex)
|
||||
protected.Get("/partner-recursos/data", controllers.LoadPartnerRecursos)
|
||||
protected.Post("/partner-recursos", controllers.CreatePartnerRecurso)
|
||||
protected.Put("/partner-recursos/:id", controllers.UpdatePartnerRecurso)
|
||||
protected.Delete("/partner-recursos/:id", controllers.DeletePartnerRecurso)
|
||||
protected.Post("/partner-recursos/:id/upload", controllers.UploadPartnerRecursoArchivo)
|
||||
protected.Get("/partner-recursos/:id/download", controllers.DownloadPartnerRecurso)
|
||||
|
||||
protected.Get("/partner-comunicados/data", controllers.LoadPartnerComunicados)
|
||||
protected.Post("/partner-comunicados", controllers.CreatePartnerComunicado)
|
||||
protected.Put("/partner-comunicados/:id", controllers.UpdatePartnerComunicado)
|
||||
protected.Delete("/partner-comunicados/:id", controllers.DeletePartnerComunicado)
|
||||
protected.Post("/partner-comunicados/:id/upload", controllers.UploadPartnerComunicadoArchivo)
|
||||
protected.Get("/partner-comunicados/:id/download", controllers.DownloadPartnerComunicadoArchivo)
|
||||
|
||||
// Notificaciones in-app del admin (bell)
|
||||
protected.Get("/mis-notifs", controllers.GetMisNotifs)
|
||||
protected.Put("/mis-notifs/:id/leida", controllers.MarcarNotifLeida)
|
||||
|
||||
Reference in New Issue
Block a user