Files
soft_usite/resources/views/docs/paginas.html
T
2026-05-06 18:26:33 -05:00

376 lines
20 KiB
HTML

<!-- Vista: Gestión de Páginas de Documentación -->
<!-- EasyMDE para editor Markdown -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
<!-- Marked.js para preview de Markdown en la vista de lectura -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<div x-data="paginasApp()" x-init="init()" @keydown.escape="closeModal()" class="bg-white rounded-lg shadow">
<div class="container mx-auto p-6 w-full">
<h1 class="text-2xl font-bold mb-1">Páginas de Documentación</h1>
<p class="text-sm text-gray-500 mb-4">Crea y gestiona el contenido de documentación para cada producto SaaS.</p>
<!-- Filtros -->
<div class="flex flex-col md:flex-row flex-wrap gap-3 mb-4">
<input type="text" x-model="search" @input="load(1)" placeholder="Buscar título..."
class="border border-gray-300 rounded p-2 text-sm w-full md:w-48">
<select x-model.number="filterSaas" @change="load(1)"
class="border border-gray-300 rounded p-2 text-sm w-full md:w-48">
<option :value="0">— Todos los SaaS —</option>
<template x-for="s in saasList" :key="s.ID">
<option :value="s.ID" x-text="s.nombre"></option>
</template>
</select>
<select x-model.number="filterCat" @change="load(1)"
class="border border-gray-300 rounded p-2 text-sm w-full md:w-48">
<option :value="0">— Todas las categorías —</option>
<template x-for="c in catList" :key="c.ID">
<option :value="c.ID" x-text="c.nombre"></option>
</template>
</select>
<div class="flex-1"></div>
<button @click="openAdd()" class="bg-[#8eb02f] text-white px-4 py-2 rounded text-sm whitespace-nowrap">+ Nueva Página</button>
</div>
<!-- Tabla -->
<div class="overflow-x-auto">
<table class="table-auto w-full text-sm">
<thead class="border-b border-gray-200 text-left font-semibold text-gray-600">
<tr>
<th class="py-2 px-4 border-b">Título</th>
<th class="py-2 px-4 border-b">SaaS</th>
<th class="py-2 px-4 border-b">Categoría</th>
<th class="py-2 px-4 border-b">Tipo</th>
<th class="py-2 px-4 border-b">Visibilidad</th>
<th class="py-2 px-4 border-b w-20">Estado</th>
<th class="py-2 px-4 border-b w-20"></th>
</tr>
</thead>
<tbody class="text-gray-500">
<template x-if="items.length === 0">
<tr><td colspan="7" class="py-4 text-center text-gray-400">Sin páginas registradas</td></tr>
</template>
<template x-for="item in items" :key="item.ID">
<tr class="hover:bg-gray-50 border-b border-gray-100">
<td class="py-2 px-4">
<span class="font-medium text-gray-800" x-text="item.titulo"></span>
<span class="block font-mono text-xs text-gray-400" x-text="item.slug"></span>
</td>
<td class="py-2 px-4 text-xs" x-text="item.saas ? item.saas.nombre : '—'"></td>
<td class="py-2 px-4 text-xs" x-text="item.categoria ? item.categoria.nombre : '—'"></td>
<td class="py-2 px-4">
<span :class="item.tipo_contenido === 'markdown' ? 'bg-blue-100 text-blue-700' : 'bg-purple-100 text-purple-700'"
class="text-xs px-2 py-0.5 rounded-full" x-text="item.tipo_contenido"></span>
</td>
<td class="py-2 px-4">
<span :class="item.visibilidad === 'public' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'"
class="text-xs px-2 py-0.5 rounded-full" x-text="item.visibilidad"></span>
</td>
<td class="py-2 px-4">
<span :class="item.publicado ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
class="text-xs px-2 py-0.5 rounded-full" x-text="item.publicado ? 'Publicado' : 'Borrador'"></span>
</td>
<td class="py-2 px-4 flex gap-2 justify-end">
<a :href="item.saas ? (item.visibilidad === 'private' ? `/app/docs/${item.saas.slug}/${item.slug}` : `/docs/${item.saas.slug}/${item.slug}`) : '#'" target="_blank" title="Ver página" class="text-green-500 hover:text-green-700">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</a>
<button @click="openEdit(item.ID)" title="Editar" class="text-blue-500 hover:text-blue-700">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button @click="confirmDelete(item.ID)" title="Eliminar" class="text-red-400 hover:text-red-600">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<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>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Paginación -->
<div class="flex items-center justify-between mt-4 text-sm">
<span class="text-gray-500">Total: <strong x-text="total"></strong></span>
<div class="flex gap-1">
<template x-for="p in totalPages" :key="p">
<button @click="load(p)"
:class="p === page ? 'bg-[#8eb02f] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
class="px-3 py-1 rounded text-xs" x-text="p"></button>
</template>
</div>
</div>
</div>
<!-- ─── Modal Editor (ocupa pantalla completa) ─────────────────────────── -->
<div x-cloak x-show="showModal" x-transition
class="fixed inset-0 bg-gray-900 bg-opacity-80 flex z-40 justify-center items-start pt-6 px-4 text-sm overflow-y-auto">
<div class="bg-white rounded shadow-lg w-full max-w-4xl mb-6">
<div class="flex items-center justify-between p-4 border-b">
<h2 class="text-lg font-bold" x-text="editId ? 'Editar Página' : 'Nueva Página'"></h2>
<button @click="closeModal()" class="text-gray-400 hover:text-gray-600 text-xl font-bold"></button>
</div>
<form @submit.prevent="submitForm()" class="p-4 space-y-4">
<!-- Fila 1: Título + Slug -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium mb-1">Título *</label>
<input type="text" x-model="form.titulo" @input="autoSlug()" required
class="border border-gray-300 rounded w-full p-2 text-sm">
</div>
<div>
<label class="block text-xs font-medium mb-1">Slug *</label>
<input type="text" x-model="form.slug" required
class="border border-gray-300 rounded w-full p-2 text-sm font-mono">
</div>
</div>
<!-- Fila 2: SaaS + Categoría -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium mb-1">Producto SaaS *</label>
<select x-model.number="form.saas_id" required class="border border-gray-300 rounded w-full p-2 text-sm">
<option :value="0" disabled>Seleccionar SaaS...</option>
<template x-for="s in saasList" :key="s.ID">
<option :value="s.ID" x-text="s.nombre"></option>
</template>
</select>
</div>
<div>
<label class="block text-xs font-medium mb-1">Categoría *</label>
<select x-model.number="form.categoria_id" required class="border border-gray-300 rounded w-full p-2 text-sm">
<option :value="0" disabled>Seleccionar categoría...</option>
<template x-for="c in catList" :key="c.ID">
<option :value="c.ID" x-text="c.nombre"></option>
</template>
</select>
</div>
</div>
<!-- Fila 3: Tipo contenido + Visibilidad + Orden + Publicado -->
<div class="grid grid-cols-4 gap-3">
<div>
<label class="block text-xs font-medium mb-1">Tipo de Contenido</label>
<select x-model="form.tipo_contenido" @change="toggleEditor()"
class="border border-gray-300 rounded w-full p-2 text-sm">
<option value="markdown">Markdown</option>
<option value="html">HTML (builder)</option>
</select>
</div>
<div>
<label class="block text-xs font-medium mb-1">Visibilidad</label>
<select x-model="form.visibilidad" class="border border-gray-300 rounded w-full p-2 text-sm">
<option value="public">Pública</option>
<option value="private">Privada</option>
</select>
</div>
<div>
<label class="block text-xs font-medium mb-1">Orden</label>
<input type="number" x-model.number="form.orden" min="0"
class="border border-gray-300 rounded w-full p-2 text-sm">
</div>
<div class="flex items-center gap-2 mt-4">
<input type="checkbox" id="publicado" x-model="form.publicado" class="w-4 h-4">
<label for="publicado" class="text-xs font-medium">Publicado</label>
</div>
</div>
<!-- Roles (sólo cuando visibilidad = private) -->
<div x-show="form.visibilidad === 'private'" class="space-y-1">
<label class="block text-xs font-medium mb-1">Roles con acceso <span class="text-gray-400 font-normal">(dejar vacío = todos los usuarios logueados)</span></label>
<div class="flex flex-wrap gap-2">
<template x-for="r in rolesList" :key="r.ID">
<label class="flex items-center gap-1 bg-gray-100 px-2 py-1 rounded cursor-pointer text-xs">
<input type="checkbox" :value="r.ID"
:checked="form.rol_ids.includes(r.ID)"
@change="toggleRol(r.ID)">
<span x-text="r.name"></span>
</label>
</template>
</div>
</div>
<!-- Editor Markdown -->
<div x-show="form.tipo_contenido === 'markdown'">
<label class="block text-xs font-medium mb-1">Contenido (Markdown)</label>
<textarea id="mdEditor"></textarea>
</div>
<!-- Editor HTML -->
<div x-show="form.tipo_contenido === 'html'">
<label class="block text-xs font-medium mb-1">Contenido (HTML)</label>
<textarea x-model="form.contenido" rows="16"
class="border border-gray-300 rounded w-full p-2 text-sm font-mono"
placeholder="Escribe HTML aquí..."></textarea>
</div>
<div class="flex justify-between pt-2 border-t">
<button type="submit" class="bg-[#8eb02f] text-white px-5 py-2 rounded text-sm">Guardar</button>
<button type="button" @click="closeModal()" class="bg-gray-500 text-white px-4 py-2 rounded text-sm">Cancelar</button>
</div>
</form>
</div>
</div>
<!-- Modal Confirmar Eliminación -->
<div x-cloak x-show="showDeleteModal" x-transition
class="fixed inset-0 bg-gray-800 bg-opacity-75 flex z-50 justify-center items-center text-sm">
<div class="bg-white p-6 rounded shadow-lg max-w-sm w-full">
<h2 class="text-lg font-bold mb-3">Confirmar eliminación</h2>
<p class="text-gray-600 mb-4">¿Eliminar esta página de documentación? Esta acción no se puede deshacer.</p>
<div class="flex justify-end gap-2">
<button @click="doDelete()" class="bg-red-500 text-white px-4 py-2 rounded text-sm">Eliminar</button>
<button @click="showDeleteModal = false" class="bg-gray-500 text-white px-4 py-2 rounded text-sm">Cancelar</button>
</div>
</div>
</div>
</div>
<script>
let mdEditorInstance = null;
function paginasApp() {
return {
items: [], saasList: [], catList: [], rolesList: [],
total: 0, totalPages: 1, page: 1,
search: '', filterSaas: 0, filterCat: 0,
loading: false,
showModal: false, showDeleteModal: false,
editId: null, deleteId: null,
form: {
titulo: '', slug: '', saas_id: 0, categoria_id: 0,
contenido: '', tipo_contenido: 'markdown', visibilidad: 'public',
orden: 0, publicado: false, rol_ids: []
},
init() {
const params = new URLSearchParams(window.location.search);
const sid = parseInt(params.get('saas_id') || '0', 10);
if (sid) this.filterSaas = sid;
this.load(1);
},
load(p = 1) {
this.page = p; this.loading = true;
const qs = new URLSearchParams({
page: p, search: this.search,
saas_id: this.filterSaas, categoria_id: this.filterCat
});
fetch(`/app/doc/loadpaginas?${qs}`)
.then(r => r.json())
.then(d => {
this.items = d.items || [];
this.saasList = d.saas || [];
this.catList = d.categorias || [];
this.rolesList = d.roles || [];
this.total = d.total;
this.totalPages = Array.from({ length: d.totalPages }, (_, i) => i + 1);
this.loading = false;
});
},
autoSlug() {
if (!this.editId) {
this.form.slug = this.form.titulo
.toLowerCase().trim()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
},
toggleRol(id) {
const idx = this.form.rol_ids.indexOf(id);
if (idx === -1) this.form.rol_ids.push(id);
else this.form.rol_ids.splice(idx, 1);
},
openAdd() {
this.editId = null;
this.form = {
titulo: '', slug: '', saas_id: 0, categoria_id: 0,
contenido: '', tipo_contenido: 'markdown', visibilidad: 'public',
orden: 0, publicado: false, rol_ids: []
};
this.showModal = true;
this.$nextTick(() => this.initMde());
},
openEdit(id) {
fetch(`/app/doc/paginas/${id}`)
.then(r => r.json())
.then(item => {
this.editId = item.ID;
this.form = {
titulo: item.titulo, slug: item.slug,
saas_id: item.saas_id, categoria_id: item.categoria_id,
contenido: item.contenido, tipo_contenido: item.tipo_contenido,
visibilidad: item.visibilidad, orden: item.orden,
publicado: item.publicado,
rol_ids: (item.roles || []).map(r => r.ID)
};
this.showModal = true;
this.$nextTick(() => this.initMde(item.contenido));
});
},
initMde(val = '') {
if (mdEditorInstance) { mdEditorInstance.toTextArea(); mdEditorInstance = null; }
if (this.form.tipo_contenido !== 'markdown') return;
const ta = document.getElementById('mdEditor');
if (!ta) return;
mdEditorInstance = new EasyMDE({
element: ta,
initialValue: val || this.form.contenido,
spellChecker: false,
autosave: { enabled: false },
toolbar: ['bold','italic','heading','|','quote','code','|','unordered-list','ordered-list','|','link','image','|','preview','side-by-side','fullscreen','|','guide']
});
mdEditorInstance.codemirror.on('change', () => {
this.form.contenido = mdEditorInstance.value();
});
},
toggleEditor() {
this.$nextTick(() => {
if (this.form.tipo_contenido === 'markdown') {
this.initMde(this.form.contenido);
} else {
if (mdEditorInstance) { mdEditorInstance.toTextArea(); mdEditorInstance = null; }
}
});
},
submitForm() {
// Sincronizar contenido del editor Markdown antes de enviar
if (mdEditorInstance) this.form.contenido = mdEditorInstance.value();
const url = this.editId ? `/app/doc/paginas/${this.editId}` : '/app/doc/paginas';
const method = this.editId ? 'PUT' : 'POST';
fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.form) })
.then(r => r.json())
.then(d => { if (d.error) { alert(d.error); return; } this.closeModal(); this.load(this.page); });
},
confirmDelete(id) { this.deleteId = id; this.showDeleteModal = true; },
doDelete() {
fetch(`/app/doc/paginas/${this.deleteId}`, { method: 'DELETE' })
.then(r => r.json())
.then(() => { this.showDeleteModal = false; this.load(this.page); });
},
closeModal() {
if (mdEditorInstance) { mdEditorInstance.toTextArea(); mdEditorInstance = null; }
this.showModal = false;
this.showDeleteModal = false;
}
};
}
</script>