Files
soft_usite/resources/views/saas.html
T
2026-05-14 22:30:31 -05:00

269 lines
16 KiB
HTML

<!-- Vista: Gestión de Productos SaaS -->
<div x-data="saasApp()" 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">Productos SaaS</h1>
<p class="text-sm text-gray-500 mb-4">Gestiona los productos SaaS a los que se asociará documentación.</p>
<div class="flex flex-col md:flex-row md:justify-between md:items-center gap-3 mb-4">
<!-- Búsqueda -->
<div class="relative">
<input type="text" x-model="search" @input="load(1)" placeholder="Buscar..."
class="border border-gray-300 rounded w-full md:w-60 p-2 text-sm">
<div x-show="loading" class="absolute right-2 top-2">
<svg class="animate-spin h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
</div>
</div>
<button @click="openAdd()" class="bg-[#8eb02f] text-white px-4 py-2 rounded text-sm">+ Nuevo SaaS</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">Nombre</th>
<th class="py-2 px-4 border-b">Slug</th>
<th class="py-2 px-4 border-b">Servicio vinculado</th>
<th class="py-2 px-4 border-b">Orden</th>
<th class="py-2 px-4 border-b w-10">Estado</th>
<th class="py-2 px-4 border-b w-20 text-center">Health</th>
<th class="py-2 px-4 border-b w-24"></th>
</tr>
</thead>
<tbody class="text-gray-500">
<template x-if="items.length === 0">
<tr><td colspan="6" class="py-4 text-center text-gray-400">Sin registros</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 font-medium text-gray-800" x-text="item.nombre"></td>
<td class="py-2 px-4 font-mono text-xs text-gray-500" x-text="item.slug"></td>
<td class="py-2 px-4 text-xs" x-text="item.servicio ? item.servicio.nombre : '-'"></td>
<td class="py-2 px-4 text-xs" x-text="item.orden"></td>
<td class="py-2 px-4">
<span x-text="item.activo ? 'Activo' : 'Inactivo'"
:class="item.activo ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
class="text-xs px-2 py-0.5 rounded-full"></span>
</td>
<!-- Health check -->
<td class="py-2 px-4 text-center">
<template x-if="!item.health_url">
<span class="inline-block w-3 h-3 rounded-full bg-gray-300" title="Sin URL de health"></span>
</template>
<template x-if="item.health_url">
<div class="flex flex-col items-center gap-1">
<span :class="healthStatus[item.ID] === 'ok' ? 'bg-green-400' : healthStatus[item.ID] === 'fail' ? 'bg-red-400' : 'bg-yellow-300'"
class="inline-block w-3 h-3 rounded-full"
:title="healthInfo[item.ID] || 'Click para verificar'"></span>
<button @click="checkHealth(item)" class="text-xs text-gray-400 hover:text-[#8eb02f]" title="Verificar">
<span x-show="healthLoading[item.ID]"></span>
<span x-show="!healthLoading[item.ID]">check</span>
</button>
</div>
</template>
</td>
<td class="py-2 px-4 flex gap-2 justify-end">
<a :href="`/docs/${item.slug}`" target="_blank" title="Ver documentación" 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)" 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 Añadir / Editar -->
<div x-cloak x-show="showModal" x-transition
class="fixed inset-0 bg-gray-800 bg-opacity-75 flex z-40 justify-center items-center text-sm">
<div class="bg-white p-6 rounded shadow-lg w-full max-w-md overflow-y-auto max-h-[90vh]">
<h2 class="text-lg font-bold mb-4" x-text="editId ? 'Editar SaaS' : 'Nuevo SaaS'"></h2>
<form @submit.prevent="submitForm()">
<div class="grid grid-cols-2 gap-3">
<div class="col-span-2">
<label class="block text-xs font-medium mb-1">Nombre *</label>
<input type="text" x-model="form.nombre" @input="autoSlug()" required
class="border border-gray-300 rounded w-full p-2 text-sm">
</div>
<div class="col-span-2">
<label class="block text-xs font-medium mb-1">Slug * <span class="text-gray-400 font-normal">(URL amigable)</span></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 class="col-span-2">
<label class="block text-xs font-medium mb-1">Descripción</label>
<textarea x-model="form.descripcion" rows="2"
class="border border-gray-300 rounded w-full p-2 text-sm"></textarea>
</div>
<div class="col-span-2">
<label class="block text-xs font-medium mb-1">URL del Logo</label>
<input type="text" x-model="form.logo_url"
class="border border-gray-300 rounded w-full p-2 text-sm">
</div>
<div class="col-span-2">
<label class="block text-xs font-medium mb-1">Health Check URL <span class="text-gray-400 font-normal">(opcional)</span></label>
<input type="url" x-model="form.health_url" placeholder="https://api.tuservicio.com/health"
class="border border-gray-300 rounded w-full p-2 text-sm font-mono">
<p class="text-xs text-gray-400 mt-0.5">Se usará para verificar que el servicio está activo. Debe devolver HTTP 2xx.</p>
</div>
<div class="col-span-2">
<label class="block text-xs font-medium mb-1">Servicio vinculado <span class="text-gray-400 font-normal">(opcional)</span></label>
<select x-model.number="form.servicio_id" class="border border-gray-300 rounded w-full p-2 text-sm">
<option :value="null">— Sin vincular —</option>
<template x-for="s in servicios" :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">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="activo" x-model="form.activo" class="w-4 h-4">
<label for="activo" class="text-xs font-medium">Activo</label>
</div>
</div>
<div class="flex justify-between mt-5">
<button type="submit" class="bg-[#8eb02f] text-white px-4 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 este producto SaaS? También se eliminará su documentación asociada.</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>
function saasApp() {
return {
items: [], servicios: [],
total: 0, totalPages: 1, page: 1, search: '',
loading: false,
showModal: false, showDeleteModal: false,
editId: null, deleteId: null,
form: { nombre: '', slug: '', descripcion: '', logo_url: '', health_url: '', servicio_id: null, activo: true, orden: 0 },
healthStatus: {}, healthInfo: {}, healthLoading: {},
init() { this.load(1); },
load(p = 1) {
this.page = p;
this.loading = true;
const qs = new URLSearchParams({ page: p, search: this.search });
fetch(`/app/loadsaas?${qs}`)
.then(r => r.json())
.then(d => {
this.items = d.items || [];
this.servicios = d.servicios || [];
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.nombre
.toLowerCase().trim()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
},
openAdd() {
this.editId = null;
this.form = { nombre: '', slug: '', descripcion: '', logo_url: '', health_url: '', servicio_id: null, activo: true, orden: 0 };
this.showModal = true;
},
openEdit(item) {
this.editId = item.ID;
this.form = {
nombre: item.nombre, slug: item.slug, descripcion: item.descripcion,
logo_url: item.logo_url, health_url: item.health_url || '',
servicio_id: item.servicio_id,
activo: item.activo, orden: item.orden
};
this.showModal = true;
},
async checkHealth(item) {
this.$set(this.healthLoading, item.ID, true);
const res = await fetch(`/app/saas/${item.ID}/health`);
const d = await res.json();
this.$set(this.healthStatus, item.ID, d.ok ? 'ok' : 'fail');
const info = d.ok
? `HTTP ${d.http_status} · ${d.latency_ms}ms`
: (d.error || `HTTP ${d.http_status}`);
this.$set(this.healthInfo, item.ID, info);
this.$set(this.healthLoading, item.ID, false);
},
submitForm() {
const url = this.editId ? `/app/saas/${this.editId}` : '/app/saas';
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/saas/${this.deleteId}`, { method: 'DELETE' })
.then(r => r.json())
.then(() => { this.showDeleteModal = false; this.load(this.page); });
},
closeModal() { this.showModal = false; this.showDeleteModal = false; }
};
}
</script>