Files
soft_usite/resources/views/automatizacion/plantillas_documento.html
T
Lizandro GDandClaude Sonnet 5 c6227150a3 fix: evitar spinner de carga infinito si falla la API en plantillas/tarifas
loadData() no tenía try/catch: si la petición fallaba (deploy sin el código
nuevo, tabla inexistente, etc.) el estado "loading" nunca volvía a false y la
pantalla quedaba trabada en el overlay de carga sin ningún mensaje de error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 01:00:56 +00:00

231 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<div x-data="app" class="bg-white rounded-lg shadow">
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
</div>
<div class="container mx-auto p-6 w-full">
<div class="justify-between items-center w-full md:flex mb-4">
<div>
<h1 class="text-2xl font-bold mb-1">Plantillas de Documento</h1>
<p class="text-sm text-gray-500">Fuente de verdad de cotizaciones, contratos, actas y cuentas de cobro generadas por IA</p>
</div>
<div class="flex items-center gap-2 mt-3 md:mt-0">
<select x-model="filtroTipo" @change="loadData()" class="border border-gray-300 rounded p-2 text-sm">
<option value="">Todos los tipos</option>
<option value="cotizacion">Cotización</option>
<option value="contrato">Contrato</option>
<option value="acta">Acta</option>
<option value="cuenta_cobro">Cuenta de cobro</option>
</select>
<button @click="addModal = true" class="bg-[#8eb02f] text-white px-4 py-2 rounded text-sm">+ Nueva</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="table-auto w-full text-sm">
<thead class="text-left border-b border-gray-200 bg-gray-50">
<tr>
<th class="py-2 px-3">Nombre</th>
<th class="py-2 px-3">Tipo</th>
<th class="py-2 px-3">Versión</th>
<th class="py-2 px-3">Estado</th>
<th class="py-2 px-3 w-24"></th>
</tr>
</thead>
<tbody class="text-gray-600">
<template x-for="d in datos" :key="d.ID">
<tr class="hover:bg-gray-50 border-b border-gray-100">
<td class="py-2 px-3 font-medium" x-text="d.nombre"></td>
<td class="py-2 px-3">
<span class="px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-700" x-text="d.tipo"></span>
</td>
<td class="py-2 px-3" x-text="'v'+d.version"></td>
<td class="py-2 px-3">
<span class="px-2 py-0.5 rounded text-xs font-medium"
:class="d.activa ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
x-text="d.activa ? 'Activa' : 'Inactiva'"></span>
</td>
<td class="py-2 px-3">
<div class="flex gap-2">
<button @click="openEdit(d)" title="Editar" class="text-gray-400 hover:text-[#8eb02f]">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
</button>
<button @click="openDelete(d)" title="Eliminar" class="text-gray-400 hover:text-red-500">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"/></svg>
</button>
</div>
</td>
</tr>
</template>
<tr x-show="!loading && datos.length===0">
<td colspan="5" class="text-center text-gray-400 py-8">Sin plantillas</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between mt-4 text-sm text-gray-500">
<span>Total: <span x-text="total"></span></span>
<div class="flex gap-1">
<button @click="prevPage()" :disabled="page===1" class="px-3 py-1 rounded border disabled:opacity-40"></button>
<span class="px-3 py-1" x-text="'Pág. '+page+' / '+totalPages"></span>
<button @click="nextPage()" :disabled="page>=totalPages" class="px-3 py-1 rounded border disabled:opacity-40"></button>
</div>
</div>
</div>
<!-- Modal Crear / Editar -->
<div x-show="addModal || editModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-4xl mx-4 p-6 max-h-[90vh] overflow-y-auto" @click.stop>
<h2 class="text-lg font-semibold mb-4" x-text="editModal ? 'Editar Plantilla' : 'Nueva Plantilla'"></h2>
<div class="mb-4 p-3 bg-blue-50 rounded text-xs text-blue-700 leading-6">
<strong>Variables comunes:</strong>
<code>&#123;&#123;.Cliente&#125;&#125;</code>,
<code>&#123;&#123;.Alcance&#125;&#125;</code>,
<code>&#123;&#123;.Fecha&#125;&#125;</code>,
<code>&#123;&#123;.EmpresaNombre&#125;&#125;</code>,
<code>&#123;&#123;.Total&#125;&#125;</code>,
<code>&#123;&#123;range .Items&#125;&#125;...&#123;&#123;.Descripcion&#125;&#125;...&#123;&#123;end&#125;&#125;</code>
<span class="block mt-1 text-blue-500">Cada tipo de documento pasa campos adicionales propios (ver /api/v2/spec).</span>
</div>
<form @submit.prevent="save()">
<div class="grid grid-cols-3 gap-3 mb-3">
<div>
<label class="text-xs font-medium text-gray-600">Nombre *</label>
<input x-model="form.nombre" required class="mt-1 w-full border rounded px-3 py-2 text-sm" />
</div>
<div>
<label class="text-xs font-medium text-gray-600">Tipo</label>
<select x-model="form.tipo" class="mt-1 w-full border rounded px-3 py-2 text-sm">
<option value="cotizacion">Cotización</option>
<option value="contrato">Contrato</option>
<option value="acta">Acta</option>
<option value="cuenta_cobro">Cuenta de cobro</option>
</select>
</div>
<div class="flex items-end gap-2">
<label class="flex items-center gap-2 text-sm mb-2">
<input type="checkbox" x-model="form.activa" /> Activa
</label>
</div>
</div>
<div>
<label class="text-xs font-medium text-gray-600 block mb-1">HTML de la plantilla (Go text/template) *</label>
<textarea x-model="form.contenido_html" rows="16"
@input.debounce.600ms="validar()"
:class="templateError ? 'border-red-400' : ''"
class="w-full border rounded px-3 py-2 text-xs font-mono" required></textarea>
<p x-show="templateError" x-text="templateError" class="text-red-500 text-xs mt-1"></p>
</div>
<div class="flex justify-end gap-2 mt-5">
<button type="button" @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
<button type="submit" class="px-4 py-2 bg-[#8eb02f] text-white rounded text-sm" :disabled="loading">
<span x-text="loading ? 'Guardando…' : 'Guardar'"></span>
</button>
</div>
</form>
</div>
</div>
<!-- Modal Eliminar -->
<div x-show="deleteModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-sm mx-4 text-center" @click.stop>
<p class="font-semibold mb-1">¿Eliminar plantilla?</p>
<p class="text-sm text-gray-400 mb-5">Esta acción no se puede deshacer.</p>
<div class="flex justify-center gap-3">
<button @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
<button @click="deleteItem()" class="px-4 py-2 bg-red-500 text-white rounded text-sm">Eliminar</button>
</div>
</div>
</div>
<div x-show="toast.show" x-cloak x-transition
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-sm text-white"
:class="toast.type==='error' ? 'bg-red-500' : 'bg-[#8eb02f]'"
x-text="toast.msg"></div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('app', () => ({
loading: false,
datos: [], total: 0, totalPages: 1, page: 1, limit: 20, filtroTipo: '',
addModal: false, editModal: false, deleteModal: false,
selectedId: null, templateError: '',
form: { nombre:'', tipo:'cotizacion', contenido_html:'', version:1, activa:true },
toast: { show:false, msg:'', type:'ok' },
async init() { await this.loadData(); },
async loadData() {
this.loading = true;
try {
const { data } = await axios.get(`/app/api/plantillas-documento?page=${this.page}&limit=${this.limit}&tipo=${this.filtroTipo}`);
this.datos = data.registros || [];
this.total = data.total || 0;
this.totalPages = data.totalPages || 1;
} catch(e) {
this.showToast(e.response?.data?.error || 'No se pudo cargar la lista', 'error');
}
this.loading = false;
},
openEdit(d) {
this.form = { nombre: d.nombre, tipo: d.tipo, contenido_html: d.contenido_html, version: d.version, activa: d.activa };
this.selectedId = d.ID;
this.editModal = true;
},
openDelete(d) { this.selectedId = d.ID; this.deleteModal = true; },
async validar() {
this.templateError = '';
},
closeModals() {
this.addModal = this.editModal = this.deleteModal = false;
this.selectedId = null;
this.templateError = '';
this.form = { nombre:'', tipo:'cotizacion', contenido_html:'', version:1, activa:true };
},
async save() {
this.loading = true;
try {
if (this.editModal) {
await axios.put(`/app/api/plantillas-documento/${this.selectedId}`, this.form);
} else {
await axios.post('/app/api/plantillas-documento', this.form);
}
this.showToast('Guardado');
this.closeModals();
await this.loadData();
} catch(e) {
this.templateError = e.response?.data?.error || '';
this.showToast(e.response?.data?.error||'Error', 'error');
}
this.loading = false;
},
async deleteItem() {
this.loading = true;
try {
await axios.delete(`/app/api/plantillas-documento/${this.selectedId}`);
this.showToast('Eliminado');
this.closeModals();
await this.loadData();
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
this.loading = false;
},
prevPage() { if(this.page>1){ this.page--; this.loadData(); } },
nextPage() { if(this.page<this.totalPages){ this.page++; this.loadData(); } },
showToast(msg, type='ok') {
this.toast = { show:true, msg, type };
setTimeout(() => this.toast.show = false, 3000);
}
}));
});
</script>