Files
soft_usite/resources/views/automatizacion/tarifas.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

222 lines
12 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">Tarifas</h1>
<p class="text-sm text-gray-500">Valor por hora, licencias, VMs y márgenes usados por la IA al cotizar</p>
</div>
<div class="flex items-center gap-2 mt-3 md:mt-0">
<input type="text" placeholder="Buscar..." class="border border-gray-300 rounded p-2 text-sm"
x-model="search" @input.debounce.400ms="loadData()" />
<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">Categoría</th>
<th class="py-2 px-3">Valor</th>
<th class="py-2 px-3">Unidad</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.categoria"></span>
</td>
<td class="py-2 px-3" x-text="new Intl.NumberFormat('es-CO').format(d.valor) + ' ' + d.moneda"></td>
<td class="py-2 px-3" x-text="d.unidad"></td>
<td class="py-2 px-3">
<span class="px-2 py-0.5 rounded text-xs font-medium"
:class="d.activo ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
x-text="d.activo ? '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="6" class="text-center text-gray-400 py-8">Sin tarifas</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-lg mx-4 p-6" @click.stop>
<h2 class="text-lg font-semibold mb-4" x-text="editModal ? 'Editar Tarifa' : 'Nueva Tarifa'"></h2>
<form @submit.prevent="save()">
<div class="grid grid-cols-2 gap-3 mb-3">
<div class="col-span-2">
<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" placeholder="ej: M365 Business Premium" />
</div>
<div>
<label class="text-xs font-medium text-gray-600">Categoría</label>
<select x-model="form.categoria" class="mt-1 w-full border rounded px-3 py-2 text-sm">
<option value="hora_servicio">Hora de servicio</option>
<option value="licencia">Licencia</option>
<option value="vm_azure">VM Azure</option>
<option value="margen">Margen</option>
<option value="otro">Otro</option>
</select>
</div>
<div>
<label class="text-xs font-medium text-gray-600">Unidad</label>
<select x-model="form.unidad" class="mt-1 w-full border rounded px-3 py-2 text-sm">
<option value="hora">Hora</option>
<option value="mes">Mes</option>
<option value="unico">Único</option>
<option value="porcentaje">Porcentaje</option>
</select>
</div>
<div>
<label class="text-xs font-medium text-gray-600">Valor *</label>
<input x-model.number="form.valor" type="number" step="0.01" 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">Moneda</label>
<input x-model="form.moneda" class="mt-1 w-full border rounded px-3 py-2 text-sm" placeholder="COP" />
</div>
<div class="col-span-2">
<label class="text-xs font-medium text-gray-600">Notas</label>
<textarea x-model="form.notas" rows="2" class="mt-1 w-full border rounded px-3 py-2 text-sm"></textarea>
</div>
<div class="col-span-2">
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" x-model="form.activo" /> Activa
</label>
</div>
</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 tarifa?</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: 50, search: '',
addModal: false, editModal: false, deleteModal: false,
selectedId: null,
form: { nombre:'', categoria:'hora_servicio', valor:0, moneda:'COP', unidad:'hora', notas:'', activo: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/tarifas?page=${this.page}&limit=${this.limit}&search=${this.search}`);
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, categoria: d.categoria, valor: d.valor, moneda: d.moneda, unidad: d.unidad, notas: d.notas, activo: d.activo };
this.selectedId = d.ID;
this.editModal = true;
},
openDelete(d) { this.selectedId = d.ID; this.deleteModal = true; },
closeModals() {
this.addModal = this.editModal = this.deleteModal = false;
this.selectedId = null;
this.form = { nombre:'', categoria:'hora_servicio', valor:0, moneda:'COP', unidad:'hora', notas:'', activo:true };
},
async save() {
this.loading = true;
try {
if (this.editModal) {
await axios.put(`/app/api/tarifas/${this.selectedId}`, this.form);
} else {
await axios.post('/app/api/tarifas', this.form);
}
this.showToast('Guardado');
this.closeModals();
await this.loadData();
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
this.loading = false;
},
async deleteItem() {
this.loading = true;
try {
await axios.delete(`/app/api/tarifas/${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>