225 lines
12 KiB
HTML
225 lines
12 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-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>
|
|
<td class="py-2 px-4 flex gap-2 justify-end">
|
|
<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">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: '', servicio_id: null, activo: true, orden: 0 },
|
|
|
|
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: '', 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, servicio_id: item.servicio_id,
|
|
activo: item.activo, orden: item.orden
|
|
};
|
|
this.showModal = true;
|
|
},
|
|
|
|
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>
|