Files
soft_usite/resources/views/partner_recursos.html
T
Lizandro GuarnizoandClaude Sonnet 5 e5544cb241 fix(responsive): ninguna vista hace scrollear la página de lado en móvil
Primera tanda de la auditoría responsive, sobre el panel y el portal.

De 59 vistas con tabla, 55 ya envolvían la tabla en un contenedor con
scroll propio; las 4 que faltaban (url_monitor, servidor_dashboard,
partner_recursos, notif_config) hacían scrollear la página entera de lado
en un teléfono. Ahora tienen el mismo contenedor que el resto.

Y una red por debajo en los dos layouts: en pantallas chicas el desborde
se contiene en vez de romper el layout, y pre/code scrollean en su caja.
Es para lo que se agregue después y se olvide el contenedor — el mismo
criterio que se usó con los modales, porque revisar 59 vistas a mano cada
vez que se suma una garantiza que alguna se escape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 18:18:36 -05:00

404 lines
22 KiB
HTML

<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>
<input x-model="newR.enlace" type="url" placeholder="Enlace externo (opcional, ej: https://...)" class="input-f sm:col-span-2">
<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">
<div class="overflow-x-auto"><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">
<template x-if="editR?.ID === r.ID">
<div class="space-y-1">
<div x-text="r.descripcion" class="truncate"></div>
<input x-model="editR.enlace" type="url" placeholder="Enlace externo..." class="input-f text-xs w-full">
</div>
</template>
<template x-if="editR?.ID !== r.ID">
<div class="space-y-0.5">
<span x-text="r.descripcion"></span>
<template x-if="r.enlace">
<a :href="r.enlace" target="_blank" class="block text-[#8eb02f] hover:underline text-xs truncate" x-text="r.enlace"></a>
</template>
</div>
</template>
</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>
</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', enlace:'', 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', enlace:'', 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>