fix: tickets filtrados por proyecto, vistas contabilidad/websms en resources/view

This commit is contained in:
Lizandro Guarnizo
2026-06-22 10:23:28 -05:00
parent 1d152b6de0
commit 39f56372c3
9 changed files with 1289 additions and 2 deletions
@@ -0,0 +1,122 @@
<div x-data="contabilidadApp()" 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">Contabilidad</h1>
<p class="text-sm text-slate-500 mt-1">Consolidado mensual de ingresos y egresos</p>
</div>
<div class="flex items-center gap-3">
<select x-model="mes" @change="loadDashboard()" class="input-field">
<template x-for="m in 12" :key="m">
<option :value="m" x-text="meses[m-1]"></option>
</template>
</select>
<select x-model="anio" @change="loadDashboard()" class="input-field">
<option x-text="anio-2" :value="anio-2"></option>
<option x-text="anio-1" :value="anio-1"></option>
<option x-text="anio" :value="anio" selected></option>
</select>
</div>
</div>
<!-- Tarjetas de resumen -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
<p class="text-xs uppercase tracking-wide text-slate-500 font-semibold mb-1">Ingresos</p>
<p class="text-2xl font-bold text-green-600" x-text="formatoCOP(data.total_ingresos)"></p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
<p class="text-xs uppercase tracking-wide text-slate-500 font-semibold mb-1">Egresos</p>
<p class="text-2xl font-bold text-red-600" x-text="formatoCOP(data.total_egresos)"></p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
<p class="text-xs uppercase tracking-wide text-slate-500 font-semibold mb-1">Resultado</p>
<p class="text-2xl font-bold" :class="data.resultado >= 0 ? 'text-green-600' : 'text-red-600'" x-text="formatoCOP(data.resultado)"></p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
<p class="text-xs uppercase tracking-wide text-slate-500 font-semibold mb-1">Transacciones</p>
<p class="text-2xl font-bold text-blue-600" x-text="data.cant_transacciones"></p>
</div>
</div>
<!-- Pendientes -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
<div class="flex items-center justify-between mb-2">
<p class="text-sm font-semibold text-slate-700">Por cobrar</p>
<a href="/app/contabilidad/cuentas-cobro" class="text-xs text-blue-500 hover:underline">Ver todas</a>
</div>
<p class="text-xl font-bold text-amber-600" x-text="formatoCOP(data.pendientes_cobro)"></p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
<div class="flex items-center justify-between mb-2">
<p class="text-sm font-semibold text-slate-700">Por pagar</p>
<a href="/app/contabilidad/cuentas-pagar" class="text-xs text-blue-500 hover:underline">Ver todas</a>
</div>
<p class="text-xl font-bold text-red-600" x-text="formatoCOP(data.pendientes_pago)"></p>
</div>
</div>
<!-- Últimas transacciones -->
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-bold text-slate-800">Últimas transacciones</h2>
<a href="/app/contabilidad/transacciones" class="text-sm text-blue-500 hover:underline">Ver todas</a>
</div>
<div class="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-3 py-2 text-left">Fecha</th>
<th class="px-3 py-2 text-left">Tipo</th>
<th class="px-3 py-2 text-left">Descripción</th>
<th class="px-3 py-2 text-left">Categoría</th>
<th class="px-3 py-2 text-left">Entidad</th>
<th class="px-3 py-2 text-right">Valor</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<template x-for="t in data.transacciones" :key="t.ID">
<tr class="hover:bg-slate-50">
<td class="px-3 py-2 text-xs text-slate-500" x-text="formatoFecha(t.fecha)"></td>
<td class="px-3 py-2">
<span class="badge" :class="t.tipo==='ingreso'?'badge-green':'badge-red'" x-text="t.tipo"></span>
</td>
<td class="px-3 py-2 text-slate-700" x-text="t.descripcion"></td>
<td class="px-3 py-2 text-slate-600" x-text="t.cuenta?.nombre||'-'"></td>
<td class="px-3 py-2 text-slate-600" x-text="t.entidad?.nombre||'-'"></td>
<td class="px-3 py-2 text-right font-semibold" :class="t.tipo==='ingreso'?'text-green-600':'text-red-600'" x-text="formatoCOP(t.valor)"></td>
</tr>
</template>
<template x-if="!data.transacciones?.length">
<tr><td colspan="6" class="text-center py-6 text-slate-400">Sin transacciones este mes</td></tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<script>
function contabilidadApp() {
return {
data:{transacciones:[]},
mes:new Date().getMonth()+1,
anio:new Date().getFullYear(),
meses:['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'],
async init(){ await this.loadDashboard(); },
async loadDashboard(){
try {
const r=await axios.get(`/app/contabilidad/dashboard?mes=${this.mes}&anio=${this.anio}`);
this.data=r.data;
} catch(e){ console.error(e); }
},
formatoCOP(n){ if(n==null) return '$0'; return '$ '+Number(n).toLocaleString('es-CO',{minimumFractionDigits:0}); },
formatoFecha(d){ if(!d) return ''; return new Date(d).toLocaleDateString('es-CO',{day:'2-digit',month:'short',year:'numeric'}); },
}
}
</script>
+150
View File
@@ -0,0 +1,150 @@
<div x-data="cuentasApp()" 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">Categorías contables</h1>
<p class="text-sm text-slate-500 mt-1">Plan de cuentas para clasificar transacciones</p>
</div>
<button @click="openCreate()" class="btn-primary flex items-center gap-2">Nueva categoría</button>
</div>
<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">Código</th>
<th class="px-4 py-3 text-left">Nombre</th>
<th class="px-4 py-3 text-left">Tipo</th>
<th class="px-4 py-3 text-left">Color</th>
<th class="px-4 py-3 text-left">Activo</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="6" class="text-center py-10 text-slate-400">Cargando...</td></tr>
</template>
<template x-for="c in items" :key="c.ID">
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-mono text-xs text-slate-500" x-text="c.codigo||'-'"></td>
<td class="px-4 py-3 text-slate-700">
<span class="inline-block w-3 h-3 rounded-full mr-2" :style="'background:'+(c.color||'#ccc')"></span>
<span x-text="c.nombre"></span>
</td>
<td class="px-4 py-3">
<span class="badge" :class="c.tipo==='ingreso'?'badge-green':'badge-red'" x-text="c.tipo"></span>
</td>
<td class="px-4 py-3 font-mono text-xs text-slate-500" x-text="c.color||'-'"></td>
<td class="px-4 py-3">
<span x-show="c.activo" class="text-green-500">Activo</span>
<span x-show="!c.activo" class="text-red-400">Inactivo</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<button @click="openEdit(c)" 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(c)" 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>
<!-- Modal -->
<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-md mx-4 p-6">
<h2 class="text-lg font-bold mb-4" x-text="editId ? 'Editar categoría' : 'Nueva categoría'"></h2>
<form @submit.prevent="save()">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="label">Código</label>
<input x-model="form.codigo" class="input-field w-full" placeholder="ING-FAC">
</div>
<div>
<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">Nombre</label>
<input x-model="form.nombre" class="input-field w-full" required>
</div>
<div>
<label class="label">Color</label>
<input x-model="form.color" type="color" class="input-field w-full h-10">
</div>
<div class="flex items-center gap-2 pt-5">
<input type="checkbox" x-model="form.activo" id="ca" class="w-4 h-4">
<label for="ca" class="text-sm text-slate-600">Activo</label>
</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>
<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 mb-2">¿Eliminar categoría?</h2>
<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 cuentasApp() {
return {
items:[], total:0, totalPages:1, page:1,
loading:false, saving:false, showModal:false, showDelete:false,
editId:null, deleteId:null, error:'',
form:{ codigo:'', nombre:'', tipo:'egreso', color:'#3b82f6', activo:true },
async init(){ await this.load(); },
async load(){
this.loading=true;
try {
const r=await axios.get(`/app/contabilidad/cuentas/list?page=${this.page}`);
this.items=r.data.items; this.total=r.data.total; this.totalPages=r.data.totalPages;
} finally{ this.loading=false; }
},
openCreate(){ this.editId=null; this.error=''; this.form={codigo:'',nombre:'',tipo:'egreso',color:'#3b82f6',activo:true}; this.showModal=true; },
openEdit(c){
this.editId=c.ID; this.error='';
this.form={codigo:c.codigo||'',nombre:c.nombre,tipo:c.tipo,color:c.color||'#3b82f6',activo:c.activo};
this.showModal=true;
},
async save(){
this.saving=true; this.error='';
try {
if(this.editId) await axios.put(`/app/contabilidad/cuentas/${this.editId}`, this.form);
else await axios.post('/app/contabilidad/cuentas', this.form);
this.showModal=false; await this.load();
} catch(e){ this.error=e.response?.data?.error||'Error al guardar'; }
finally{ this.saving=false; }
},
confirmDelete(c){ this.deleteId=c.ID; this.showDelete=true; },
async doDelete(){
this.saving=true;
try{ await axios.delete(`/app/contabilidad/cuentas/${this.deleteId}`); this.showDelete=false; await this.load(); }
finally{ this.saving=false; }
},
}
}
</script>
@@ -0,0 +1,194 @@
<div x-data="cobroApp()" 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">Cuentas por cobrar</h1>
<p class="text-sm text-slate-500 mt-1">Facturas y montos pendientes de cobro</p>
</div>
<button @click="openCreate()" class="btn-primary flex items-center gap-2">Nuevo cobro</button>
</div>
<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="filtroEstado" @change="page=1;load()" class="input-field">
<option value="">Todos</option>
<option value="pendiente">Pendiente</option>
<option value="pagado">Pagado</option>
<option value="parcial">Parcial</option>
</select>
</div>
<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">Entidad</th>
<th class="px-4 py-3 text-left">Descripción</th>
<th class="px-4 py-3 text-right">Valor</th>
<th class="px-4 py-3 text-left">Vence</th>
<th class="px-4 py-3 text-left">Estado</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="7" class="text-center py-10 text-slate-400">Cargando...</td></tr>
</template>
<template x-for="c in items" :key="c.ID">
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 text-xs text-slate-500" x-text="formatDate(c.fecha)"></td>
<td class="px-4 py-3 text-slate-700 font-medium" x-text="c.entidad?.nombre||'-'"></td>
<td class="px-4 py-3 text-slate-600" x-text="c.descripcion"></td>
<td class="px-4 py-3 text-right font-semibold text-amber-600" x-text="formatoCOP(c.valor)"></td>
<td class="px-4 py-3 text-xs text-slate-500" x-text="formatDate(c.fecha_vencimiento)"></td>
<td class="px-4 py-3">
<span class="badge" :class="{'badge-yellow':c.estado==='pendiente','badge-green':c.estado==='pagado','badge-blue':c.estado==='parcial'}" x-text="c.estado"></span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<button @click="marcarPagado(c)" class="btn-icon text-green-500" title="Marcar pagado" x-show="c.estado!=='pagado'"></button>
<button @click="confirmDelete(c)" class="btn-icon text-red-500">🗑️</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<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 -->
<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">
<h2 class="text-lg font-bold mb-4">Nuevo cobro pendiente</h2>
<form @submit.prevent="save()">
<div class="grid grid-cols-2 gap-3">
<div class="col-span-2">
<label class="label">Entidad (quien debe)</label>
<select x-model.number="form.entidad_id" class="input-field w-full" required>
<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 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">Fecha vencimiento</label>
<input x-model="form.fecha_vencimiento" type="date" class="input-field w-full">
</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 pagado -->
<div x-show="showPagarModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div @click.outside="showPagarModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 p-6">
<h2 class="text-lg font-bold mb-4">Marcar como pagado</h2>
<form @submit.prevent="doPagar()">
<label class="label">Fecha de pago</label>
<input x-model="pagoFecha" type="date" class="input-field w-full mb-4" required>
<div class="flex justify-end gap-3">
<button type="button" @click="showPagarModal=false" class="btn-secondary">Cancelar</button>
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Confirmar'"></button>
</div>
</form>
</div>
</div>
<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 mb-2">¿Eliminar?</h2>
<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 cobroApp() {
return {
items:[], entidades:[], total:0, totalPages:1, page:1, search:'', filtroEstado:'',
loading:false, saving:false, showModal:false, showDelete:false, showPagarModal:false,
deleteId:null, pagarId:null, pagoFecha:'', error:'',
form:{ entidad_id:'', descripcion:'', valor:0, fecha:'', fecha_vencimiento:'', notas:'' },
async init(){ await this.load(); },
async load(){
this.loading=true;
try {
const params=new URLSearchParams({page:this.page,search:this.search});
if(this.filtroEstado) params.set('estado',this.filtroEstado);
const r=await axios.get('/app/contabilidad/cuentas-cobro/list?'+params.toString());
this.items=r.data.items; this.total=r.data.total; this.totalPages=r.data.totalPages;
const er=await axios.get('/app/contabilidad/entidades/select');
this.entidades=er.data;
} finally{ this.loading=false; }
},
openCreate(){ this.editId=null; this.error=''; this.form={entidad_id:'',descripcion:'',valor:0,fecha:'',fecha_vencimiento:'',notas:''}; this.showModal=true; },
async save(){
this.saving=true; this.error='';
try {
await axios.post('/app/contabilidad/cuentas-cobro', this.form);
this.showModal=false; await this.load();
} catch(e){ this.error=e.response?.data?.error||'Error al guardar'; }
finally{ this.saving=false; }
},
marcarPagado(c){ this.pagarId=c.ID; this.pagoFecha=new Date().toISOString().substring(0,10); this.showPagarModal=true; },
async doPagar(){
this.saving=true;
try {
await axios.put(`/app/contabilidad/cuentas-cobro/${this.pagarId}`, {estado:'pagado',fecha_pago:this.pagoFecha});
this.showPagarModal=false; await this.load();
} catch(e){ this.error=e.response?.data?.error||'Error'; }
finally{ this.saving=false; }
},
confirmDelete(c){ this.deleteId=c.ID; this.showDelete=true; },
async doDelete(){
this.saving=true;
try{ await axios.delete(`/app/contabilidad/cuentas-cobro/${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>
@@ -0,0 +1,194 @@
<div x-data="pagarApp()" 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">Cuentas por pagar</h1>
<p class="text-sm text-slate-500 mt-1">Facturas y montos pendientes de pago</p>
</div>
<button @click="openCreate()" class="btn-primary flex items-center gap-2">Nuevo pendiente</button>
</div>
<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="filtroEstado" @change="page=1;load()" class="input-field">
<option value="">Todos</option>
<option value="pendiente">Pendiente</option>
<option value="pagado">Pagado</option>
<option value="parcial">Parcial</option>
</select>
</div>
<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">Entidad</th>
<th class="px-4 py-3 text-left">Descripción</th>
<th class="px-4 py-3 text-right">Valor</th>
<th class="px-4 py-3 text-left">Vence</th>
<th class="px-4 py-3 text-left">Estado</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="7" class="text-center py-10 text-slate-400">Cargando...</td></tr>
</template>
<template x-for="c in items" :key="c.ID">
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 text-xs text-slate-500" x-text="formatDate(c.fecha)"></td>
<td class="px-4 py-3 text-slate-700 font-medium" x-text="c.entidad?.nombre||'-'"></td>
<td class="px-4 py-3 text-slate-600" x-text="c.descripcion"></td>
<td class="px-4 py-3 text-right font-semibold text-red-600" x-text="formatoCOP(c.valor)"></td>
<td class="px-4 py-3 text-xs text-slate-500" x-text="formatDate(c.vencimiento)"></td>
<td class="px-4 py-3">
<span class="badge" :class="{'badge-yellow':c.estado==='pendiente','badge-green':c.estado==='pagado','badge-blue':c.estado==='parcial'}" x-text="c.estado"></span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<button @click="marcarPagado(c)" class="btn-icon text-green-500" title="Marcar pagado" x-show="c.estado!=='pagado'"></button>
<button @click="confirmDelete(c)" class="btn-icon text-red-500">🗑️</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<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 -->
<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">
<h2 class="text-lg font-bold mb-4">Nuevo pendiente de pago</h2>
<form @submit.prevent="save()">
<div class="grid grid-cols-2 gap-3">
<div class="col-span-2">
<label class="label">Entidad (a quién debemos)</label>
<select x-model.number="form.entidad_id" class="input-field w-full" required>
<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 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">Fecha vencimiento</label>
<input x-model="form.fecha_vencimiento" type="date" class="input-field w-full">
</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 pagado -->
<div x-show="showPagarModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div @click.outside="showPagarModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 p-6">
<h2 class="text-lg font-bold mb-4">Marcar como pagado</h2>
<form @submit.prevent="doPagar()">
<label class="label">Fecha de pago</label>
<input x-model="pagoFecha" type="date" class="input-field w-full mb-4" required>
<div class="flex justify-end gap-3">
<button type="button" @click="showPagarModal=false" class="btn-secondary">Cancelar</button>
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Confirmar'"></button>
</div>
</form>
</div>
</div>
<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 mb-2">¿Eliminar?</h2>
<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 pagarApp() {
return {
items:[], entidades:[], total:0, totalPages:1, page:1, search:'', filtroEstado:'',
loading:false, saving:false, showModal:false, showDelete:false, showPagarModal:false,
deleteId:null, pagarId:null, pagoFecha:'', error:'',
form:{ entidad_id:'', descripcion:'', valor:0, fecha:'', fecha_vencimiento:'', notas:'' },
async init(){ await this.load(); },
async load(){
this.loading=true;
try {
const params=new URLSearchParams({page:this.page,search:this.search});
if(this.filtroEstado) params.set('estado',this.filtroEstado);
const r=await axios.get('/app/contabilidad/cuentas-pagar/list?'+params.toString());
this.items=r.data.items; this.total=r.data.total; this.totalPages=r.data.totalPages;
const er=await axios.get('/app/contabilidad/entidades/select');
this.entidades=er.data;
} finally{ this.loading=false; }
},
openCreate(){ this.editId=null; this.error=''; this.form={entidad_id:'',descripcion:'',valor:0,fecha:'',fecha_vencimiento:'',notas:''}; this.showModal=true; },
async save(){
this.saving=true; this.error='';
try {
await axios.post('/app/contabilidad/cuentas-pagar', this.form);
this.showModal=false; await this.load();
} catch(e){ this.error=e.response?.data?.error||'Error al guardar'; }
finally{ this.saving=false; }
},
marcarPagado(c){ this.pagarId=c.ID; this.pagoFecha=new Date().toISOString().substring(0,10); this.showPagarModal=true; },
async doPagar(){
this.saving=true;
try {
await axios.put(`/app/contabilidad/cuentas-pagar/${this.pagarId}`, {estado:'pagado',fecha_pago:this.pagoFecha});
this.showPagarModal=false; await this.load();
} catch(e){ this.error=e.response?.data?.error||'Error'; }
finally{ this.saving=false; }
},
confirmDelete(c){ this.deleteId=c.ID; this.showDelete=true; },
async doDelete(){
this.saving=true;
try{ await axios.delete(`/app/contabilidad/cuentas-pagar/${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>
+169
View File
@@ -0,0 +1,169 @@
<div x-data="entidadesApp()" 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">Entidades</h1>
<p class="text-sm text-slate-500 mt-1">Clientes, proveedores y empresas</p>
</div>
<button @click="openCreate()" class="btn-primary flex items-center gap-2">Nueva entidad</button>
</div>
<div class="mb-4">
<input x-model="search" @input.debounce.400ms="page=1;load()" type="text" placeholder="Buscar por nombre, documento..." class="input-field w-full max-w-sm">
</div>
<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">Nombre</th>
<th class="px-4 py-3 text-left">Tipo</th>
<th class="px-4 py-3 text-left">Documento</th>
<th class="px-4 py-3 text-left">Email</th>
<th class="px-4 py-3 text-left">Teléfono</th>
<th class="px-4 py-3 text-left">Contacto</th>
<th class="px-4 py-3 text-left">Activo</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-for="e in items" :key="e.ID">
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 text-slate-700 font-medium" x-text="e.nombre"></td>
<td class="px-4 py-3"><span class="badge" :class="{'badge-green':e.tipo==='cliente','badge-blue':e.tipo==='proveedor','badge-slate':e.tipo==='ambos'}" x-text="e.tipo||'-'"></span></td>
<td class="px-4 py-3 text-slate-600 text-xs font-mono" x-text="e.documento||'-'"></td>
<td class="px-4 py-3 text-slate-600" x-text="e.email||'-'"></td>
<td class="px-4 py-3 text-slate-600" x-text="e.telefono||'-'"></td>
<td class="px-4 py-3 text-slate-600" x-text="e.contacto||'-'"></td>
<td class="px-4 py-3"><span x-show="e.activo" class="text-green-500">Activo</span><span x-show="!e.activo" class="text-red-400">Inactivo</span></td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<button @click="openEdit(e)" class="btn-icon text-yellow-500">✏️</button>
<button @click="confirmDelete(e)" class="btn-icon text-red-500">🗑️</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<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 -->
<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">
<h2 class="text-lg font-bold mb-4" x-text="editId?'Editar entidad':'Nueva entidad'"></h2>
<form @submit.prevent="save()">
<div class="grid grid-cols-2 gap-3">
<div class="col-span-2">
<label class="label">Nombre</label>
<input x-model="form.nombre" class="input-field w-full" required>
</div>
<div>
<label class="label">Tipo</label>
<select x-model="form.tipo" class="input-field w-full">
<option value="">Seleccionar...</option>
<option value="cliente">Cliente</option>
<option value="proveedor">Proveedor</option>
<option value="ambos">Ambos</option>
</select>
</div>
<div>
<label class="label">Documento/NIT</label>
<input x-model="form.documento" class="input-field w-full">
</div>
<div>
<label class="label">Email</label>
<input x-model="form.email" type="email" class="input-field w-full">
</div>
<div>
<label class="label">Teléfono</label>
<input x-model="form.telefono" class="input-field w-full">
</div>
<div>
<label class="label">Contacto persona</label>
<input x-model="form.contacto" class="input-field w-full">
</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 class="col-span-2 flex items-center gap-2">
<input type="checkbox" x-model="form.activo" id="ea" class="w-4 h-4">
<label for="ea" class="text-sm text-slate-600">Activo</label>
</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>
<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 mb-2">¿Eliminar entidad?</h2>
<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 entidadesApp() {
return {
items:[], total:0, totalPages:1, page:1, search:'',
loading:false, saving:false, showModal:false, showDelete:false,
editId:null, deleteId:null, error:'',
form:{ nombre:'', tipo:'', documento:'', email:'', telefono:'', contacto:'', notas:'', activo:true },
async init(){ await this.load(); },
async load(){
this.loading=true;
try {
const r=await axios.get(`/app/contabilidad/entidades?page=${this.page}&search=${encodeURIComponent(this.search)}`);
this.items=r.data.items; this.total=r.data.total; this.totalPages=r.data.totalPages;
} finally{ this.loading=false; }
},
openCreate(){ this.editId=null; this.error=''; this.form={nombre:'',tipo:'',documento:'',email:'',telefono:'',contacto:'',notas:'',activo:true}; this.showModal=true; },
openEdit(e){
this.editId=e.ID; this.error='';
this.form={nombre:e.nombre,tipo:e.tipo||'',documento:e.documento||'',email:e.email||'',telefono:e.telefono||'',contacto:e.contacto||'',notas:e.notas||'',activo:e.activo};
this.showModal=true;
},
async save(){
this.saving=true; this.error='';
try {
if(this.editId) await axios.put(`/app/contabilidad/entidades/${this.editId}`, this.form);
else await axios.post('/app/contabilidad/entidades', this.form);
this.showModal=false; await this.load();
} catch(e){ this.error=e.response?.data?.error||'Error al guardar'; }
finally{ this.saving=false; }
},
confirmDelete(e){ this.deleteId=e.ID; this.showDelete=true; },
async doDelete(){
this.saving=true;
try{ await axios.delete(`/app/contabilidad/entidades/${this.deleteId}`); this.showDelete=false; await this.load(); }
finally{ this.saving=false; }
},
}
}
</script>
@@ -0,0 +1,244 @@
<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-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 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 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>
+171
View File
@@ -0,0 +1,171 @@
<div x-data="websmsApp()" x-init="init()" class="p-6 max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-slate-800">WebSMS (LabsMobile)</h1>
<p class="text-sm text-slate-500 mt-1">Integración de SMS para notificaciones</p>
</div>
</div>
<!-- Configuración -->
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 mb-6">
<h2 class="text-lg font-bold text-slate-800 mb-4">Configuración API</h2>
<form @submit.prevent="save()">
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="label">Usuario (email)</label>
<input x-model="form.username" class="input-field w-full" required placeholder="lizandro.guarnizo@u-site.app">
</div>
<div class="col-span-2">
<label class="label">API Token</label>
<div class="relative">
<input x-model="form.api_token" type="password" class="input-field w-full pr-10" required>
<button type="button" @click="showToken=!showToken; $el.previousElementSibling.type=showToken?'text':'password'" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</button>
</div>
</div>
<div>
<label class="label">Remitente (TPOA)</label>
<input x-model="form.sender" class="input-field w-full" placeholder="U-SITE">
</div>
<div>
<label class="label">Notas</label>
<input x-model="form.notas" class="input-field w-full" placeholder="Opcional">
</div>
</div>
<p x-show="error" x-text="error" class="text-red-500 text-sm mt-3"></p>
<div class="flex justify-end mt-5">
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Guardar configuración'"></button>
</div>
</form>
</div>
<!-- Probar envío -->
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 mb-6">
<h2 class="text-lg font-bold text-slate-800 mb-4">Probar envío</h2>
<form @submit.prevent="test()">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">Número destino</label>
<input x-model="testPara" class="input-field w-full" placeholder="57300xxxxxx" required>
</div>
<div>
<label class="label">Mensaje (opcional)</label>
<input x-model="testMsg" class="input-field w-full" placeholder="Texto de prueba">
</div>
</div>
<p x-show="testResult" x-text="testResult" class="text-sm mt-3" :class="testOk?'text-green-600':'text-red-500'"></p>
<div class="flex justify-end mt-4">
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Enviando...':'Enviar SMS de prueba'"></button>
</div>
</form>
</div>
<!-- Webhooks URLs -->
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 mb-6">
<h2 class="text-lg font-bold text-slate-800 mb-4">Webhooks</h2>
<p class="text-sm text-slate-500 mb-4">Configura estas URLs en el panel de WebSMS para recibir notificaciones:</p>
<div class="space-y-3">
<div>
<label class="label">Confirmaciones de entrega (ACK)</label>
<code class="block text-xs bg-slate-50 p-2 rounded border border-slate-200 text-slate-700 break-all">https://admin.u-site.app/webhooks/websms/delivery</code>
</div>
<div>
<label class="label">Clics en shortlinks</label>
<code class="block text-xs bg-slate-50 p-2 rounded border border-slate-200 text-slate-700 break-all">https://admin.u-site.app/webhooks/websms/click</code>
</div>
<div>
<label class="label">Mensajes entrantes</label>
<code class="block text-xs bg-slate-50 p-2 rounded border border-slate-200 text-slate-700 break-all">https://admin.u-site.app/webhooks/websms/incoming</code>
</div>
</div>
</div>
<!-- Logs -->
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
<h2 class="text-lg font-bold text-slate-800 mb-4">Últimos envíos</h2>
<div class="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-3 py-2 text-left">Fecha</th>
<th class="px-3 py-2 text-left">Para</th>
<th class="px-3 py-2 text-left">Mensaje</th>
<th class="px-3 py-2 text-left">Estado</th>
<th class="px-3 py-2 text-left">ID</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<template x-for="l in logs" :key="l.ID">
<tr class="hover:bg-slate-50">
<td class="px-3 py-2 text-xs text-slate-500" x-text="formatDate(l.CreatedAt)"></td>
<td class="px-3 py-2 text-slate-700" x-text="l.para"></td>
<td class="px-3 py-2 text-slate-600 max-w-xs truncate" x-text="l.mensaje"></td>
<td class="px-3 py-2">
<span class="badge" :class="l.status==='0'?'badge-green':'badge-red'" x-text="l.status"></span>
</td>
<td class="px-3 py-2 text-xs font-mono text-slate-500" x-text="l.msg_id||'-'"></td>
</tr>
</template>
<template x-if="!logs.length">
<tr><td colspan="5" class="text-center py-6 text-slate-400">Sin envíos aún</td></tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<script>
function websmsApp() {
return {
form:{ username:'', api_token:'', sender:'', notas:'' },
showToken:false,
logs:[],
testPara:'', testMsg:'', testResult:'', testOk:false,
saving:false, error:'',
async init(){
await this.loadConfig();
await this.loadLogs();
},
async loadConfig(){
try {
const r=await axios.get('/app/websms/config');
if(r.data.data){
this.form={id:r.data.data.ID, username:r.data.data.username, api_token:r.data.data.api_token, sender:r.data.data.sender||'', notas:r.data.data.notas||''};
}
} catch(e){}
},
async loadLogs(){
try { const r=await axios.get('/app/websms/logs'); this.logs=r.data; } catch(e){}
},
async save(){
this.saving=true; this.error='';
try {
await axios.post('/app/websms/save', this.form);
await this.loadConfig();
} catch(e){ this.error=e.response?.data?.error||'Error al guardar'; }
finally{ this.saving=false; }
},
async test(){
this.saving=true; this.testResult=''; this.testOk=false;
try {
const r=await axios.post('/app/websms/test', {para:this.testPara, mensaje:this.testMsg});
this.testResult='SMS enviado correctamente. Código: '+r.data.response.code;
this.testOk=true;
await this.loadLogs();
} catch(e){ this.testResult=e.response?.data?.error||'Error al enviar'; this.testOk=false; }
finally{ this.saving=false; }
},
formatDate(d){ if(!d) return ''; return new Date(d).toLocaleDateString('es-CO',{day:'2-digit',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'}); },
}
}
</script>
+2 -2
View File
@@ -210,7 +210,7 @@ func PortalProyecto(c *fiber.Ctx) error {
fases, _ := models.GetFasesByProyecto(proy.ID)
avances, _ := models.GetAvancesByProyecto(proy.ID, true)
entregables, _ := models.GetEntregablesByProyecto(proy.ID, true)
tickets, _ := models.GetTicketsByPortalUser(u.ID)
tickets, _ := models.GetTicketsByProyecto(proy.ID)
_ = models.ActualizarProgresoProyecto(proy.ID)
return c.Render("portal/proyecto", fiber.Map{
@@ -268,7 +268,7 @@ func PortalGetProyectoData(c *fiber.Ctx) error {
fases, _ := models.GetFasesByProyecto(proy.ID)
avances, _ := models.GetAvancesByProyecto(proy.ID, true)
entregables, _ := models.GetEntregablesByProyecto(proy.ID, true)
tickets, _ := models.GetTicketsByPortalUser(u.ID)
tickets, _ := models.GetTicketsByProyecto(proy.ID)
facturas, _ := models.GetFacturasByCliente(proy.ClienteID, true)
facturasNoLeidas := models.CountUnreadFacturaNotifs(u.ID)
documentos, _ := models.GetDocumentosByProyecto(proy.ID)
+43
View File
@@ -453,6 +453,49 @@ func UserRoutes(app fiber.Router) {
protected.Put("/mis-notifs/:id/leida", controllers.MarcarNotifLeida)
protected.Post("/mis-notifs/marcar-todas", controllers.MarcarTodasLeidas)
// ─── Contabilidad ─────────────────────────────────────────────────────────
protected.Get("/contabilidad", middlewares.MenuMiddleware, controllers.ContabilidadIndex)
protected.Get("/contabilidad/dashboard", controllers.ContabilidadDashboard)
protected.Get("/contabilidad/consolidado", controllers.ContabilidadConsolidado)
protected.Get("/contabilidad/consolidados", controllers.ContabilidadListConsolidados)
// Transacciones
protected.Get("/contabilidad/transacciones", middlewares.MenuMiddleware, controllers.ContabilidadTransaccionesView)
protected.Get("/contabilidad/transacciones/list", controllers.GetTransacciones)
protected.Post("/contabilidad/transacciones", controllers.CreateTransaccion)
protected.Put("/contabilidad/transacciones/:id", controllers.UpdateTransaccion)
protected.Delete("/contabilidad/transacciones/:id", controllers.DeleteTransaccion)
// Cuentas (categorías contables)
protected.Get("/contabilidad/cuentas", middlewares.MenuMiddleware, controllers.ContabilidadCuentasView)
protected.Get("/contabilidad/cuentas/list", controllers.GetCuentas)
protected.Get("/contabilidad/cuentas/select", controllers.GetCuentasSelect)
protected.Post("/contabilidad/cuentas", controllers.CreateCuenta)
protected.Put("/contabilidad/cuentas/:id", controllers.UpdateCuenta)
protected.Delete("/contabilidad/cuentas/:id", controllers.DeleteCuenta)
// Entidades
protected.Get("/contabilidad/entidades", middlewares.MenuMiddleware, controllers.ContabilidadEntidadesView)
protected.Get("/contabilidad/entidades/list", controllers.GetEntidades)
protected.Get("/contabilidad/entidades/select", controllers.GetEntidadesSelect)
protected.Post("/contabilidad/entidades", controllers.CreateEntidad)
protected.Put("/contabilidad/entidades/:id", controllers.UpdateEntidad)
protected.Delete("/contabilidad/entidades/:id", controllers.DeleteEntidad)
// Cuentas por cobrar
protected.Get("/contabilidad/cuentas-cobro", middlewares.MenuMiddleware, controllers.ContabilidadCobroView)
protected.Get("/contabilidad/cuentas-cobro/list", controllers.GetCuentasCobro)
protected.Post("/contabilidad/cuentas-cobro", controllers.CreateCuentaCobro)
protected.Put("/contabilidad/cuentas-cobro/:id", controllers.UpdateCuentaCobro)
protected.Delete("/contabilidad/cuentas-cobro/:id", controllers.DeleteCuentaCobro)
// Cuentas por pagar
protected.Get("/contabilidad/cuentas-pagar", middlewares.MenuMiddleware, controllers.ContabilidadPagarView)
protected.Get("/contabilidad/cuentas-pagar/list", controllers.GetCuentasPagar)
protected.Post("/contabilidad/cuentas-pagar", controllers.CreateCuentaPagar)
protected.Put("/contabilidad/cuentas-pagar/:id", controllers.UpdateCuentaPagar)
protected.Delete("/contabilidad/cuentas-pagar/:id", controllers.DeleteCuentaPagar)
// ─── WebSMS (LabsMobile) ───────────────────────────────────────────────────
protected.Get("/websms", middlewares.MenuMiddleware, controllers.WebSmsConfigPage)
protected.Get("/websms/config", controllers.GetWebSmsConfig)