En /app/contabilidad/cuentas-cobro la petición respondía bien pero la tabla quedaba en blanco, sin nada que dijera qué había pasado. Probando la vista real con axios simulado quedó claro que el problema no era el renderizado: con datos pinta bien, y que falle /clientes/select tampoco la rompe. Lo que faltaba era distinguir el caso vacío. Dos arreglos, uno en cada punta: - Las 8 consultas de contabilidad declaraban `var items []T`, que sin filas se serializa como null y no como []. La vista hacía x-for sobre null y no pintaba nada; peor, con null se colaba además una fila basura. - Ninguna de las 5 tablas tenía estado vacío. Ahora dicen "No hay ... registradas", así que cero filas se lee como cero filas. El x-for además queda blindado con (items || []) para no depender de que todos los endpoints devuelvan siempre una lista. Verificado con la vista real en tres escenarios: con datos pinta la fila, vacío y null muestran el mensaje y ninguna fila de más. Si la pantalla sigue sin traer registros después de esto, ya no es la vista: es que la consulta no encuentra filas, y el mensaje lo va a decir. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
267 lines
13 KiB
HTML
267 lines
13 KiB
HTML
<div x-data="transaccionesApp()" x-init="init()" class="p-6">
|
|
|
|
<div class="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-slate-800">Transacciones</h1>
|
|
<p class="text-sm text-slate-500 mt-1">Registro de ingresos y egresos</p>
|
|
</div>
|
|
<button @click="openCreate()" class="btn-primary flex items-center gap-2">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/></svg>
|
|
Nueva transacción
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Filtros -->
|
|
<div class="flex flex-wrap items-center gap-3 mb-4">
|
|
<input x-model="search" @input.debounce.400ms="page=1;load()" type="text" placeholder="Buscar..." class="input-field w-full max-w-xs">
|
|
<select x-model="filtroTipo" @change="page=1;load()" class="input-field">
|
|
<option value="">Todos</option>
|
|
<option value="ingreso">Ingresos</option>
|
|
<option value="egreso">Egresos</option>
|
|
</select>
|
|
<select x-model="filtroMes" @change="page=1;load()" class="input-field">
|
|
<option value="0">Todos los meses</option>
|
|
<template x-for="(m,i) in meses" :key="i+1">
|
|
<option :value="i+1" x-text="m"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Tabla -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
|
|
<tr>
|
|
<th class="px-4 py-3 text-left">Fecha</th>
|
|
<th class="px-4 py-3 text-left">Tipo</th>
|
|
<th class="px-4 py-3 text-left">Descripción</th>
|
|
<th class="px-4 py-3 text-left">Categoría</th>
|
|
<th class="px-4 py-3 text-left">Entidad</th>
|
|
<th class="px-4 py-3 text-right">Valor</th>
|
|
<th class="px-4 py-3 text-left">Pago</th>
|
|
<th class="px-4 py-3 text-left">Acciones</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-100">
|
|
<template x-if="loading">
|
|
<tr><td colspan="8" class="text-center py-10 text-slate-400">Cargando...</td></tr>
|
|
</template>
|
|
<template x-if="!loading && (!items || items.length===0)">
|
|
<tr><td colspan="8" class="text-center py-10 text-slate-400">No hay transacciones registradas.</td></tr>
|
|
</template>
|
|
<template x-for="t in (items || [])" :key="t.ID">
|
|
<tr class="hover:bg-slate-50">
|
|
<td class="px-4 py-3 text-xs text-slate-500" x-text="formatDate(t.fecha)"></td>
|
|
<td class="px-4 py-3">
|
|
<span class="badge" :class="t.tipo==='ingreso'?'badge-green':'badge-red'" x-text="t.tipo"></span>
|
|
</td>
|
|
<td class="px-4 py-3 text-slate-700 max-w-xs truncate" x-text="t.descripcion"></td>
|
|
<td class="px-4 py-3 text-slate-600">
|
|
<span class="inline-block w-2 h-2 rounded-full mr-1" :style="'background:'+(t.cuenta?.color||'#ccc')"></span>
|
|
<span x-text="t.cuenta?.nombre||'-'"></span>
|
|
</td>
|
|
<td class="px-4 py-3 text-slate-600" x-text="t.entidad?.nombre||'-'"></td>
|
|
<td class="px-4 py-3 text-right font-semibold" :class="t.tipo==='ingreso'?'text-green-600':'text-red-600'" x-text="formatoCOP(t.valor)"></td>
|
|
<td class="px-4 py-3 text-xs text-slate-500" x-text="t.forma_pago||'-'"></td>
|
|
<td class="px-4 py-3">
|
|
<div class="flex items-center gap-2">
|
|
<button @click="openEdit(t)" class="btn-icon text-yellow-500">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><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(t)" class="btn-icon text-red-500">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><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>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Paginación -->
|
|
<div class="flex justify-between items-center mt-4 text-sm text-slate-500">
|
|
<span>Total: <strong x-text="total"></strong></span>
|
|
<div class="flex gap-1">
|
|
<button @click="page--;load()" :disabled="page<=1" class="px-3 py-1 rounded border border-slate-200 disabled:opacity-40">Ant</button>
|
|
<span class="px-3 py-1" x-text="`${page} / ${totalPages||1}`"></span>
|
|
<button @click="page++;load()" :disabled="page>=totalPages" class="px-3 py-1 rounded border border-slate-200 disabled:opacity-40">Sig</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal crear/editar -->
|
|
<div x-show="showModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
|
<div @click.outside="showModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6 max-h-screen overflow-y-auto">
|
|
<h2 class="text-lg font-bold text-slate-800 mb-4" x-text="editId ? 'Editar transacción' : 'Nueva transacción'"></h2>
|
|
<form @submit.prevent="save()">
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div class="col-span-2">
|
|
<label class="label">Tipo</label>
|
|
<select x-model="form.tipo" class="input-field w-full" required>
|
|
<option value="ingreso">Ingreso</option>
|
|
<option value="egreso">Egreso</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-span-2">
|
|
<label class="label">Descripción</label>
|
|
<input x-model="form.descripcion" class="input-field w-full" required>
|
|
</div>
|
|
<div>
|
|
<label class="label">Valor ($)</label>
|
|
<input x-model.number="form.valor" type="number" step="0.01" class="input-field w-full" required>
|
|
</div>
|
|
<div>
|
|
<label class="label">Fecha</label>
|
|
<input x-model="form.fecha" type="date" class="input-field w-full">
|
|
</div>
|
|
<div>
|
|
<label class="label">Categoría</label>
|
|
<select x-model.number="form.cuenta_id" class="input-field w-full">
|
|
<option value="">Seleccionar...</option>
|
|
<template x-for="c in cuentas" :key="c.ID">
|
|
<option :value="c.ID" x-text="c.nombre"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="label">Entidad</label>
|
|
<select x-model.number="form.entidad_id" class="input-field w-full">
|
|
<option value="">Seleccionar...</option>
|
|
<template x-for="e in entidades" :key="e.ID">
|
|
<option :value="e.ID" x-text="e.nombre"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="label">Forma de pago</label>
|
|
<select x-model="form.forma_pago" class="input-field w-full">
|
|
<option value="">Seleccionar...</option>
|
|
<option value="transferencia">Transferencia</option>
|
|
<option value="efectivo">Efectivo</option>
|
|
<option value="tarjeta">Tarjeta</option>
|
|
<option value="cheque">Cheque</option>
|
|
<option value="otro">Otro</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="label">Estado</label>
|
|
<select x-model="form.estado" class="input-field w-full">
|
|
<option value="registrada">Registrada</option>
|
|
<option value="conciliada">Conciliada</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-span-2">
|
|
<label class="label">Notas</label>
|
|
<textarea x-model="form.notas" class="input-field w-full" rows="2"></textarea>
|
|
</div>
|
|
</div>
|
|
<p x-show="error" x-text="error" class="text-red-500 text-sm mt-3"></p>
|
|
<div class="flex justify-end gap-3 mt-5">
|
|
<button type="button" @click="showModal=false" class="btn-secondary">Cancelar</button>
|
|
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Guardar'"></button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal eliminar -->
|
|
<div x-show="showDelete" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
|
<div @click.outside="showDelete=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 p-6">
|
|
<h2 class="text-lg font-bold text-slate-800 mb-2">¿Eliminar transacción?</h2>
|
|
<p class="text-slate-500 text-sm mb-5">Esta acción no se puede deshacer.</p>
|
|
<div class="flex justify-end gap-3">
|
|
<button @click="showDelete=false" class="btn-secondary">Cancelar</button>
|
|
<button @click="doDelete()" :disabled="saving" class="btn-danger" x-text="saving?'Eliminando...':'Eliminar'"></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
function transaccionesApp() {
|
|
return {
|
|
items:[], cuentas:[], entidades:[], total:0, totalPages:1, page:1, search:'',
|
|
filtroTipo:'', filtroMes:0,
|
|
loading:false, saving:false, showModal:false, showDelete:false,
|
|
editId:null, deleteId:null, error:'',
|
|
meses:['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'],
|
|
form:{ tipo:'ingreso', descripcion:'', valor:0, fecha:'', cuenta_id:'', entidad_id:'', forma_pago:'', estado:'registrada', notas:'' },
|
|
|
|
async init(){ await this.load(); await this.loadSelects(); },
|
|
|
|
async load(){
|
|
this.loading=true;
|
|
try {
|
|
const params = new URLSearchParams({page:this.page, search:this.search});
|
|
if(this.filtroTipo) params.set('tipo', this.filtroTipo);
|
|
if(this.filtroMes) { params.set('mes', this.filtroMes); params.set('anio', new Date().getFullYear()); }
|
|
const r=await axios.get('/app/contabilidad/transacciones/list?'+params.toString());
|
|
this.items=r.data.items; this.total=r.data.total; this.totalPages=r.data.totalPages;
|
|
} finally{ this.loading=false; }
|
|
},
|
|
|
|
async loadSelects(){
|
|
try {
|
|
const [cr, er] = await Promise.all([
|
|
axios.get('/app/contabilidad/cuentas/select'),
|
|
axios.get('/app/contabilidad/entidades/select')
|
|
]);
|
|
this.cuentas=cr.data; this.entidades=er.data;
|
|
} catch(e){}
|
|
},
|
|
|
|
openCreate(){
|
|
this.editId=null; this.error='';
|
|
this.form={tipo:'ingreso',descripcion:'',valor:0,fecha:'',cuenta_id:'',entidad_id:'',forma_pago:'',estado:'registrada',notas:''};
|
|
this.showModal=true;
|
|
},
|
|
openEdit(t){
|
|
this.editId=t.ID; this.error='';
|
|
const f=t.fecha?t.fecha.substring(0,10):'';
|
|
this.form={tipo:t.tipo,descripcion:t.descripcion,valor:t.valor,fecha:f,cuenta_id:t.cuenta_id||'',entidad_id:t.entidad_id||'',forma_pago:t.forma_pago||'',estado:t.estado||'registrada',notas:t.notas||''};
|
|
this.showModal=true;
|
|
},
|
|
async save(){
|
|
this.saving=true; this.error='';
|
|
const payload={...this.form};
|
|
try {
|
|
if(this.editId) await axios.put(`/app/contabilidad/transacciones/${this.editId}`, payload);
|
|
else await axios.post('/app/contabilidad/transacciones', payload);
|
|
this.showModal=false; await this.load();
|
|
} catch(e){ this.error=e.response?.data?.error||'Error al guardar'; }
|
|
finally{ this.saving=false; }
|
|
},
|
|
|
|
confirmDelete(t){ this.deleteId=t.ID; this.showDelete=true; },
|
|
async doDelete(){
|
|
this.saving=true;
|
|
try{ await axios.delete(`/app/contabilidad/transacciones/${this.deleteId}`); this.showDelete=false; await this.load(); }
|
|
finally{ this.saving=false; }
|
|
},
|
|
|
|
formatoCOP(n){ if(n==null) return '$0'; return '$ '+Number(n).toLocaleString('es-CO',{minimumFractionDigits:0}); },
|
|
formatDate(d){ if(!d) return ''; return new Date(d).toLocaleDateString('es-CO',{day:'2-digit',month:'short',year:'numeric'}); },
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
.btn-primary { background:#8eb02f; color:#fff; padding:0.5rem 1rem; border-radius:0.5rem; font-weight:600; font-size:.875rem; transition:background .15s; }
|
|
.btn-primary:hover { background:#6d8c24; }
|
|
.btn-secondary { background:#f1f5f9; color:#475569; padding:0.5rem 1rem; border-radius:0.5rem; font-weight:500; font-size:.875rem; border:1px solid #e2e8f0; }
|
|
.btn-danger { background:#ef4444; color:#fff; padding:0.5rem 1rem; border-radius:0.5rem; font-weight:600; font-size:.875rem; }
|
|
.btn-icon { display:inline-flex; align-items:center; padding:0.25rem; border-radius:0.375rem; }
|
|
.btn-icon:hover { background:#f1f5f9; }
|
|
.input-field { border:1px solid #e2e8f0; border-radius:0.5rem; padding:0.5rem 0.75rem; font-size:.875rem; outline:none; transition:border-color .15s; }
|
|
.input-field:focus { border-color:#8eb02f; }
|
|
.label { display:block; font-size:.75rem; font-weight:600; color:#475569; margin-bottom:.25rem; text-transform:uppercase; letter-spacing:.05em; }
|
|
.badge { display:inline-block; padding:.15rem .6rem; border-radius:9999px; font-size:.7rem; font-weight:600; text-transform:capitalize; }
|
|
.badge-green { background:#dcfce7; color:#15803d; }
|
|
.badge-yellow { background:#fef9c3; color:#854d0e; }
|
|
.badge-blue { background:#dbeafe; color:#1d4ed8; }
|
|
.badge-slate { background:#f1f5f9; color:#475569; }
|
|
.badge-red { background:#fee2e2; color:#991b1b; }
|
|
.text-primary { color:#8eb02f; }
|
|
</style>
|