This commit is contained in:
Lizandro Guarnizo
2026-05-16 20:01:36 -05:00
parent 4c14f4a641
commit 2b7458b612
8 changed files with 972 additions and 0 deletions
+45
View File
@@ -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.")
}
+91
View File
@@ -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
}
+387
View File
@@ -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>
+132
View File
@@ -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)
}
+11
View File
@@ -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()
}
}
+5
View File
@@ -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)
}
+16
View File
@@ -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)