Files
soft_usite/resources/views/automatizacion/plantillas_documento.html
T
Lizandro GuarnizoandClaude Opus 5 ac47769c27 fix(plantillas): "Leer con IA" fallaba con la config de IA que ya está en producción
Escribí una segunda implementación de "llamar al proveedor" en vez de usar la
que ya existe (callAI, la del bot de Telegram). Esa copia no hablaba Anthropic
—se lo mandaba todo al endpoint de OpenAI— y, si la config tenía la URL base
vacía (lo normal: la vista dice que es opcional), armaba una URL relativa que
ni siquiera es una petición válida. Cualquiera de las dos cosas terminaba en
el 502.

Ahora usa callAI, que despacha por proveedor y completa la URL base. El error
del proveedor queda en el log del servidor y el mensaje llega entero a la vista;
si la respuesta no trae mensaje, la vista muestra el status para distinguir un
fallo de la app de uno del proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:07:03 -05:00

274 lines
15 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>
<div class="mb-4 border border-dashed border-gray-300 rounded p-3 bg-gray-50">
<p class="text-xs font-medium text-gray-600 mb-1">¿Ya tenés el documento hecho?</p>
<p class="text-xs text-gray-500 mb-2">
Subí el archivo (PDF, Word .docx, .html, .txt o una foto/captura del documento) y la IA lo devuelve
armado como plantilla, con las variables puestas. Después lo editás acá abajo antes de guardar.
</p>
<div class="flex flex-wrap items-center gap-2">
<input type="file" x-ref="archivo" accept=".pdf,.docx,.html,.htm,.txt,.md,image/*"
class="text-xs" />
<button type="button" @click="importar()" :disabled="importando"
class="px-3 py-1.5 border rounded text-xs disabled:opacity-50">
<span x-text="importando ? 'Leyendo…' : 'Leer con IA'"></span>
</button>
<span x-show="avisoImport" x-text="avisoImport" class="text-xs"
:class="errorImport ? 'text-red-600' : 'text-green-600'"></span>
</div>
</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: '',
importando: false, avisoImport: '', errorImport: false,
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 importar() {
const f = this.$refs.archivo?.files?.[0];
if (!f) { this.errorImport = true; this.avisoImport = 'Elegí un archivo primero'; return; }
this.importando = true; this.avisoImport = ''; this.errorImport = false;
try {
const fd = new FormData();
fd.append('archivo', f);
fd.append('tipo', this.form.tipo);
const { data } = await axios.post('/app/api/plantillas-documento/importar', fd);
this.form.contenido_html = data.contenido_html || '';
if (!this.form.nombre) this.form.nombre = f.name.replace(/\.[^.]+$/, '');
this.errorImport = !!data.aviso;
this.avisoImport = data.aviso || 'Listo, revisá el HTML abajo';
} catch(e) {
this.errorImport = true;
// Sin data.error el fallo no vino de la app sino del proxy
// (timeout, por ejemplo); mostrar el status ayuda a distinguirlo.
this.avisoImport = e.response?.data?.error
|| (e.response ? `El servidor respondió ${e.response.status}` : e.message);
}
this.importando = false;
},
async validar() {
this.templateError = '';
},
closeModals() {
this.addModal = this.editModal = this.deleteModal = false;
this.selectedId = null;
this.templateError = '';
this.avisoImport = ''; this.errorImport = false;
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>