Compare commits

...
10 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Sonnet 4.6 8a9235631c feat: mostrar API Key en panel WebSMS
Agrega sección visible en /app/websms con el secret_key
y botón de copiar, para facilitar el uso del endpoint externo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 23:17:45 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 704664afe7 feat: API pública para envío de SMS vía WebSMS
Agrega POST /api/sms/send autenticado con Bearer token (secret_key
auto-generado en WebSmsConfig). Recibe numero y mensaje, llama a
LabsMobile y registra el resultado en websms_log.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 23:13:57 -05:00
Lizandro Guarnizo df1eb75bdd feat: importar datos de BALANCE.numbers (facturas, IVA, retencion, cobros, pagos, consolidado) 2026-06-23 00:43:27 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2136c46f42 fix: tratar code != 0 de LabsMobile como error
La API devuelve HTTP 200 con code:"401" para credenciales inválidas.
Ahora el service retorna error cuando code != "0".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 17:16:24 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f3d9794096 fix: corregir URL del API de LabsMobile
URL incorrecta: https://websms.labsmobile.com/SY0204/api
URL correcta:   https://api.labsmobile.com/json/send

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 17:05:20 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e5c6dba787 fix: exponer cuerpo real de respuesta en error de websms
El error "unmarshal: invalid character '<'" ocultaba qué devolvía
LabsMobile. Ahora el error incluye Content-Type y los primeros 200
bytes del cuerpo para diagnóstico.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 16:59:49 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ab4e3252b1 fix: fidelizar websms_config con el diseño del resto del proyecto
Agrega bloque <style> con btn-primary, input-field, label, badge-* que
faltaban. Badge de status ahora muestra "Enviado"/"Error" en vez del
código numérico. Botones de copiar en la sección de webhooks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 16:50:53 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f94d01c089 fix: agregar bloque <style> faltante en todas las vistas de contabilidad
Los modales y controles no tenían estilos (btn-primary, btn-secondary,
btn-danger, btn-icon, input-field, label, badge-*) porque el bloque
<style> nunca fue incluido al crear estas vistas. También corregido
text-slate-800 en h2 de modales para alinearse con el patrón del proyecto.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 16:37:27 -05:00
Lizandro Guarnizo 24c4f6b26f fix: modales contabilidad/websms alineados con diseño del proyecto (text-slate-800, gap-4, SVG, mt-5, color input) 2026-06-22 16:04:22 -05:00
Lizandro Guarnizo 39f56372c3 fix: tickets filtrados por proyecto, vistas contabilidad/websms en resources/view 2026-06-22 10:23:28 -05:00
23 changed files with 1908 additions and 29 deletions
+1
View File
@@ -117,6 +117,7 @@ func main() {
migrations.SeedShield()
migrations.SeedPartnerRecursos()
models.SeedContabilidad()
models.SeedBalanceData()
migrations.SeedContabilidadMenu()
migrations.SeedWebSms()
// Iniciar cron de vencimientos
+194
View File
@@ -1,6 +1,8 @@
package models
import (
"fmt"
"log"
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
@@ -276,6 +278,196 @@ func GetAllCuentasCobro(limit, offset int, search string, estado string) ([]Cuen
return items, total, nil
}
func SeedBalanceData() {
db := app.Http.Database.DB
parseDate := func(s string) time.Time {
t, _ := time.Parse("2006-01-02 15:04:05", s)
return t
}
getEntidad := func(nombre string) *uint {
var e Entidad
if err := db.Where("nombre = ?", nombre).First(&e).Error; err == nil {
return &e.ID
}
e = Entidad{Nombre: nombre, Tipo: "cliente", Activo: true}
db.Create(&e)
return &e.ID
}
getCuenta := func(codigo string) *uint {
var c Cuenta
if err := db.Where("codigo = ?", codigo).First(&c).Error; err == nil {
return &c.ID
}
return nil
}
// ─── FACTURAS → Transacciones (ingresos) ────────────────────────────────
facturas := []struct{ factura float64; valor float64; fecha string }{
{54, 450000, "2026-01-01 00:00:00"},
{55, 9000000, "2026-01-01 00:00:00"},
{56, 119000, "2026-01-01 00:00:00"},
{57, 330000, "2026-01-01 00:00:00"},
{58, 1000000, "2026-01-01 00:00:00"},
{59, 1000000, "2026-01-01 00:00:00"},
{60, 900000, "2026-01-01 00:00:00"},
{0, 0, "2026-01-01 00:00:00"}, // ajuste -130000
{61, 580000, "2026-02-01 00:00:00"},
{62, 330000, "2026-02-01 00:00:00"},
{63, 1785000, "2026-02-01 00:00:00"},
{64, 120000, "2026-02-01 00:00:00"},
{65, 350000, "2026-02-01 00:00:00"},
{66, 550000, "2026-02-01 00:00:00"},
{67, 1300000, "2026-02-01 00:00:00"},
{68, 270000, "2026-02-01 00:00:00"},
{69, 32000, "2026-02-01 00:00:00"},
{70, 3153500, "2026-02-01 00:00:00"},
{71, 349000, "2026-03-01 00:00:00"},
{72, 0, "2026-03-01 00:00:00"},
{73, 1380000, "2026-03-01 00:00:00"},
{74, 380000, "2026-03-01 00:00:00"},
{75, 2650000, "2026-04-01 00:00:00"},
{76, 390000, "2026-04-01 00:00:00"},
{77, 2200000, "2026-04-01 00:00:00"},
{78, 4400000, "2026-04-01 00:00:00"},
{79, 142000, "2026-04-01 00:00:00"},
}
cuentaIng := getCuenta("ING-FAC")
docuxer := getEntidad("DOCUXER")
giaf := getEntidad("GIAF SAS")
for _, f := range facturas {
if f.factura == 0 && f.valor == 0 {
// ajuste negativo: nota crédito
var existing Transaccion
if db.Where("descripcion = ?", "Ajuste/NC ene 2026").First(&existing).Error != nil {
db.Create(&Transaccion{
Fecha: parseDate("2026-01-01 00:00:00"),
Tipo: "egreso", Descripcion: "Ajuste/NC ene 2026",
Valor: 130000, CuentaID: cuentaIng, Estado: "registrada",
})
}
continue
}
desc := fmt.Sprintf("Factura #%.0f", f.factura)
var existing Transaccion
if db.Where("descripcion = ? AND fecha = ?", desc, parseDate(f.fecha)).First(&existing).Error != nil {
eid := docuxer
if f.factura == 56 {
eid = giaf
}
db.Create(&Transaccion{
Fecha: parseDate(f.fecha), Tipo: "ingreso",
Descripcion: desc, Valor: f.valor,
CuentaID: cuentaIng, EntidadID: eid, Estado: "registrada",
})
}
}
// ─── IVA → Transacciones (egresos) ──────────────────────────────────────
ivas := []struct{ valor float64; entidad string; desc string }{
{285000, "DOCUXER", "IVA Fact #63"},
{503500, "DOCUXER", "IVA Fact #70"},
{19000, "GIAF SAS", "IVA Fact #56"},
{503500, "DOCUXER", "IVA Fact #75"},
{418000, "DOCUXER", "IVA Fact #77"},
}
cuentaImp := getCuenta("EGR-IMPU")
for _, iv := range ivas {
var existing Transaccion
if db.Where("descripcion = ?", iv.desc).First(&existing).Error != nil {
eid := getEntidad(iv.entidad)
db.Create(&Transaccion{
Fecha: parseDate("2026-01-01 00:00:00"),
Tipo: "egreso", Descripcion: iv.desc,
Valor: iv.valor, CuentaID: cuentaImp,
EntidadID: eid, Estado: "registrada",
})
}
}
// ─── RETENCION → Transacciones (egresos) ────────────────────────────────
rets := []struct{ valor float64; fecha string; estado string }{
{195000, "2026-03-01 00:00:00", "pagado"},
{257000, "2026-02-01 00:00:00", "pagado"},
{431000, "2026-01-01 00:00:00", "pendiente"},
}
for _, r := range rets {
desc := fmt.Sprintf("Retención %s", r.fecha[:7])
var existing Transaccion
if db.Where("descripcion = ?", desc).First(&existing).Error != nil {
db.Create(&Transaccion{
Fecha: parseDate(r.fecha),
Tipo: "egreso", Descripcion: desc,
Valor: r.valor, CuentaID: cuentaImp,
Estado: "registrada",
})
}
}
// ─── CUENTAS DE COBRO ───────────────────────────────────────────────────
cobros := []struct{ entidad string; valor float64; fecha string }{
{"NATALIA", 2000000, "2026-01-01 00:00:00"},
{"FELIPE", 2000000, "2026-01-01 00:00:00"},
{"CONTADORA", 1780000, "2026-01-01 00:00:00"},
{"FELIPE", 2300000, "2026-02-01 00:00:00"},
{"NATALIA", 2000000, "2026-02-01 00:00:00"},
{"FELIPE", 2300000, "2026-03-01 00:00:00"},
{"NATALIA", 2000000, "2026-03-01 00:00:00"},
{"FELIPE", 520000, "2026-04-01 00:00:00"},
{"ANDREMER", 80000, "2026-04-01 00:00:00"},
{"NATALIA", 2000000, "2026-04-01 00:00:00"},
}
for _, cb := range cobros {
eid := getEntidad(cb.entidad)
desc := fmt.Sprintf("Cobro %s %s", cb.entidad, cb.fecha[:7])
var existing CuentaCobro
if db.Where("descripcion = ?", desc).First(&existing).Error != nil {
df := parseDate(cb.fecha)
db.Create(&CuentaCobro{
EntidadID: *eid, Fecha: df,
Descripcion: desc, Valor: cb.valor,
Estado: "pendiente",
FechaVencimiento: &df,
})
}
}
// ─── FAC POR PAGAR ──────────────────────────────────────────────────────
var existingCp CuentaPagar
if db.Where("descripcion = ?", "TECZONE ene 2026").First(&existingCp).Error != nil {
eid := getEntidad("TECZONE")
f := parseDate("2026-01-01 00:00:00")
db.Create(&CuentaPagar{
EntidadID: *eid, Fecha: f,
Descripcion: "TECZONE ene 2026",
Valor: 2687849, Estado: "pendiente",
})
}
// ─── CONSOLIDADO POR MES ───────────────────────────────────────────────
consols := []struct{ mes int; ing, egre, resul float64 }{
{1, 12669000, 8467849, 4201151},
{2, 8470500, 4300000, 4170500},
{3, 2109000, 4300000, -2191000},
{4, 9782000, 2600000, 7182000},
}
for _, cs := range consols {
var existing ConsolidadoMensual
if db.Where("anio = 2026 AND mes = ?", cs.mes).First(&existing).Error != nil {
db.Create(&ConsolidadoMensual{
Anio: 2026, Mes: cs.mes,
TotalIngresos: cs.ing, TotalEgresos: cs.egre,
Resultado: cs.resul,
})
}
}
log.Println("[SEED] Balance data imported from BALANCE.numbers")
}
func CreateCuentaCobro(cc *CuentaCobro) error {
return app.Http.Database.DB.Create(cc).Error
}
@@ -494,6 +686,8 @@ func SeedContabilidad() {
{Nombre: "DOCUXER", Tipo: "cliente"},
{Nombre: "FELIPE", Tipo: "cliente"},
{Nombre: "NATALIA", Tipo: "cliente"},
{Nombre: "CONTADORA", Tipo: "cliente"},
{Nombre: "ANDREMER", Tipo: "cliente"},
}
for _, e := range entidades {
var existing Entidad
+23 -5
View File
@@ -1,6 +1,9 @@
package models
import (
"crypto/rand"
"encoding/hex"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
@@ -12,6 +15,13 @@ type WebSmsConfig struct {
Sender string `json:"sender" gorm:"column:sender;size:30"`
Notas string `json:"notas" gorm:"column:notas;type:text"`
Activo bool `json:"activo" gorm:"column:activo;default:true"`
SecretKey string `json:"secret_key" gorm:"column:secret_key;size:64;uniqueIndex"`
}
func generateWebSmsSecret() string {
b := make([]byte, 24)
rand.Read(b)
return "sms_" + hex.EncodeToString(b)
}
func (WebSmsConfig) TableName() string { return "websms_config" }
@@ -30,14 +40,22 @@ func SaveWebSmsConfig(s WebSmsConfig) error {
Update("activo", false)
s.Activo = true
if s.ID > 0 {
// preserve existing secret_key
var existing WebSmsConfig
app.Http.Database.DB.First(&existing, s.ID)
if existing.SecretKey == "" {
existing.SecretKey = generateWebSmsSecret()
}
return app.Http.Database.DB.Model(&s).Updates(map[string]interface{}{
"username": s.Username,
"api_token": s.ApiToken,
"sender": s.Sender,
"notas": s.Notas,
"activo": true,
"username": s.Username,
"api_token": s.ApiToken,
"sender": s.Sender,
"notas": s.Notas,
"activo": true,
"secret_key": existing.SecretKey,
}).Error
}
s.SecretKey = generateWebSmsSecret()
return app.Http.Database.DB.Create(&s).Error
}
+7 -2
View File
@@ -12,7 +12,7 @@ import (
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
const websmsAPIBase = "https://websms.labsmobile.com/SY0204/api"
const websmsAPIBase = "https://api.labsmobile.com/json/send"
type WebSmsRequest struct {
Message string `json:"message"`
@@ -92,9 +92,14 @@ func SendWebSms(cfg *models.WebSmsConfig, para, mensaje string) (*WebSmsResponse
return nil, fmt.Errorf("websms API status %d: %s", resp.StatusCode, string(respBody))
}
ct := resp.Header.Get("Content-Type")
var result WebSmsResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("websms: unmarshal: %w", err)
return nil, fmt.Errorf("websms: respuesta no JSON (Content-Type: %s, cuerpo: %.200s)", ct, string(respBody))
}
if result.Code != "0" {
return nil, fmt.Errorf("websms: %s (code %s)", result.Message, result.Code)
}
return &result, nil
@@ -0,0 +1,140 @@
<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>
<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-slate { background:#f1f5f9; color:#475569; }
.badge-red { background:#fee2e2; color:#991b1b; }
.badge-blue { background:#dbeafe; color:#1d4ed8; }
</style>
+176
View File
@@ -0,0 +1,176 @@
<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">
<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 categoría
</button>
</div>
<div class="mb-4">
<input x-model="search" @input.debounce.400ms="page=1;load()" type="text" placeholder="Buscar por nombre o código..." 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">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-lg mx-4 p-6">
<h2 class="text-lg font-bold text-slate-800 mb-4" x-text="editId ? 'Editar categoría' : 'Nueva categoría'"></h2>
<form @submit.prevent="save()">
<div class="grid grid-cols-2 gap-4">
<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="h-10 w-full rounded border border-slate-200 cursor-pointer">
</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 text-slate-800 mb-2">¿Eliminar categoría?</h2>
<p class="text-slate-600 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 cuentasApp() {
return {
items:[], total:0, totalPages:1, page:1, search:'',
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}&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={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>
<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-slate { background:#f1f5f9; color:#475569; }
.badge-red { background:#fee2e2; color:#991b1b; }
.badge-blue { background:#dbeafe; color:#1d4ed8; }
</style>
@@ -0,0 +1,220 @@
<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">
<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>
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'">
<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="M5 13l4 4L19 7"/></svg>
</button>
<button @click="confirmDelete(c)" class="btn-icon text-red-500" title="Eliminar">
<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>
<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 text-slate-800 mb-4">Nuevo cobro pendiente</h2>
<form @submit.prevent="save()">
<div class="grid grid-cols-2 gap-4">
<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 text-slate-800 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 mt-5">
<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 text-slate-800 mb-2">¿Eliminar cobro?</h2>
<p class="text-slate-600 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 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>
<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-slate { background:#f1f5f9; color:#475569; }
.badge-red { background:#fee2e2; color:#991b1b; }
.badge-blue { background:#dbeafe; color:#1d4ed8; }
</style>
@@ -0,0 +1,220 @@
<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">
<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>
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'">
<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="M5 13l4 4L19 7"/></svg>
</button>
<button @click="confirmDelete(c)" class="btn-icon text-red-500" title="Eliminar">
<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>
<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 text-slate-800 mb-4">Nuevo pendiente de pago</h2>
<form @submit.prevent="save()">
<div class="grid grid-cols-2 gap-4">
<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 text-slate-800 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 mt-5">
<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 text-slate-800 mb-2">¿Eliminar pendiente?</h2>
<p class="text-slate-600 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 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>
<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-slate { background:#f1f5f9; color:#475569; }
.badge-red { background:#fee2e2; color:#991b1b; }
.badge-blue { background:#dbeafe; color:#1d4ed8; }
</style>
+195
View File
@@ -0,0 +1,195 @@
<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">
<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 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" title="Editar">
<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(e)" class="btn-icon text-red-500" title="Eliminar">
<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>
<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 text-slate-800 mb-4" x-text="editId?'Editar entidad':'Nueva entidad'"></h2>
<form @submit.prevent="save()">
<div class="grid grid-cols-2 gap-4">
<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 text-slate-800 mb-2">¿Eliminar entidad?</h2>
<p class="text-slate-600 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 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>
<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-slate { background:#f1f5f9; color:#475569; }
.badge-red { background:#fee2e2; color:#991b1b; }
.badge-blue { background:#dbeafe; color:#1d4ed8; }
</style>
@@ -0,0 +1,262 @@
<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 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-4">
<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-600 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-slate { background:#f1f5f9; color:#475569; }
.badge-red { background:#fee2e2; color:#991b1b; }
.badge-blue { background:#dbeafe; color:#1d4ed8; }
</style>
+189
View File
@@ -0,0 +1,189 @@
<div x-data="websmsApp()" 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">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>
<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-slate { background:#f1f5f9; color:#475569; }
.badge-red { background:#fee2e2; color:#991b1b; }
.badge-blue { background:#dbeafe; color:#1d4ed8; }
</style>
@@ -120,3 +120,22 @@ function contabilidadApp() {
}
}
</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>
+21 -2
View File
@@ -58,7 +58,7 @@
<!-- 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>
<h2 class="text-lg font-bold text-slate-800 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>
@@ -96,7 +96,7 @@
<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>
<h2 class="text-lg font-bold text-slate-800 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>
@@ -148,3 +148,22 @@ function cuentasApp() {
}
}
</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>
@@ -69,7 +69,7 @@
<!-- 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>
<h2 class="text-lg font-bold text-slate-800 mb-4">Nuevo cobro pendiente</h2>
<form @submit.prevent="save()">
<div class="grid grid-cols-2 gap-3">
<div class="col-span-2">
@@ -114,7 +114,7 @@
<!-- 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>
<h2 class="text-lg font-bold text-slate-800 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>
@@ -128,7 +128,7 @@
<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>
<h2 class="text-lg font-bold text-slate-800 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>
@@ -192,3 +192,22 @@ function cobroApp() {
}
}
</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>
@@ -69,7 +69,7 @@
<!-- 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>
<h2 class="text-lg font-bold text-slate-800 mb-4">Nuevo pendiente de pago</h2>
<form @submit.prevent="save()">
<div class="grid grid-cols-2 gap-3">
<div class="col-span-2">
@@ -114,7 +114,7 @@
<!-- 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>
<h2 class="text-lg font-bold text-slate-800 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>
@@ -128,7 +128,7 @@
<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>
<h2 class="text-lg font-bold text-slate-800 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>
@@ -192,3 +192,22 @@ function pagarApp() {
}
}
</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>
+21 -2
View File
@@ -63,7 +63,7 @@
<!-- 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>
<h2 class="text-lg font-bold text-slate-800 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">
@@ -115,7 +115,7 @@
<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>
<h2 class="text-lg font-bold text-slate-800 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>
@@ -167,3 +167,22 @@ function entidadesApp() {
}
}
</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>
@@ -89,7 +89,7 @@
<!-- 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>
<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">
@@ -164,7 +164,7 @@
<!-- 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>
<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>
@@ -242,3 +242,22 @@ function transaccionesApp() {
}
}
</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>
+1 -1
View File
@@ -103,7 +103,7 @@
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center text-xs font-bold"
style="background:#8eb02f;color:#fff">
{{ if eq .Tipo "milestone" }}🎯{{ else if eq .Tipo "alerta" }}⚠️{{ else }}✓{{ end }}
{{ if eq .Tipo "milestone" }}🎯{{ else if eq .Tipo "alerta" }}⚠️{{ else if eq .Tipo "nota" }}📝{{ else if eq .Tipo "update" }}✨{{ else }}✓{{ end }}
</div>
<div>
<h3 class="font-semibold text-slate-800 text-sm">{{ .Titulo }}</h3>
+54 -6
View File
@@ -41,6 +41,19 @@
</form>
</div>
<!-- API Key -->
<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-1">API Key (envío externo)</h2>
<p class="text-sm text-slate-500 mb-4">Usa este token en <code class="bg-slate-100 px-1 rounded">Authorization: Bearer &lt;key&gt;</code> para llamar a <code class="bg-slate-100 px-1 rounded">POST /api/sms/send</code> desde sistemas externos.</p>
<div x-show="secretKey" class="flex items-center gap-2">
<code class="flex-1 text-xs bg-slate-50 p-2 rounded border border-slate-200 text-slate-700 break-all font-mono" x-text="secretKey"></code>
<button type="button" @click="copy(secretKey)" class="btn-icon text-slate-400 hover:text-slate-600 flex-shrink-0" title="Copiar">
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
<p x-show="!secretKey" class="text-sm text-slate-400">Guarda la configuración para generar la API Key.</p>
</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>
@@ -69,17 +82,33 @@
<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 class="flex items-center gap-2">
<code class="flex-1 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>
<button type="button" @click="copy('https://admin.u-site.app/webhooks/websms/delivery')" class="btn-icon text-slate-400 hover:text-slate-600 flex-shrink-0" title="Copiar">
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
</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 class="flex items-center gap-2">
<code class="flex-1 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>
<button type="button" @click="copy('https://admin.u-site.app/webhooks/websms/click')" class="btn-icon text-slate-400 hover:text-slate-600 flex-shrink-0" title="Copiar">
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
</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 class="flex items-center gap-2">
<code class="flex-1 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>
<button type="button" @click="copy('https://admin.u-site.app/webhooks/websms/incoming')" class="btn-icon text-slate-400 hover:text-slate-600 flex-shrink-0" title="Copiar">
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
</div>
</div>
<p x-show="copied" x-text="copied" class="text-green-600 text-xs mt-3"></p>
</div>
<!-- Logs -->
@@ -103,7 +132,7 @@
<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>
<span class="badge" :class="l.status==='0'?'badge-green':'badge-red'" x-text="l.status==='0'?'Enviado':'Error'"></span>
</td>
<td class="px-3 py-2 text-xs font-mono text-slate-500" x-text="l.msg_id||'-'"></td>
</tr>
@@ -122,10 +151,10 @@
function websmsApp() {
return {
form:{ username:'', api_token:'', sender:'', notas:'' },
showToken:false,
showToken:false, secretKey:'',
logs:[],
testPara:'', testMsg:'', testResult:'', testOk:false,
saving:false, error:'',
saving:false, error:'', copied:'',
async init(){
await this.loadConfig();
@@ -137,6 +166,7 @@ function websmsApp() {
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||''};
this.secretKey=r.data.data.secret_key||'';
}
} catch(e){}
},
@@ -166,6 +196,24 @@ function websmsApp() {
},
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'}); },
copy(text){ navigator.clipboard.writeText(text); this.copied='URL copiada'; setTimeout(()=>this.copied='', 2000); },
}
}
</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-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-red { background:#fee2e2; color:#991b1b; }
.badge-slate { background:#f1f5f9; color:#475569; }
.text-primary { color:#8eb02f; }
</style>
+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)
+53
View File
@@ -1,6 +1,8 @@
package controllers
import (
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
@@ -146,6 +148,57 @@ func WebSmsClickWebhook(c *fiber.Ctx) error {
return c.SendStatus(200)
}
// ─── API pública ────────────────────────────────────────────────────────────
// ApiSendSms envía un SMS. Requiere Authorization: Bearer <secret_key>.
// POST /api/sms/send { "numero": "573001234567", "mensaje": "Hola" }
func ApiSendSms(c *fiber.Ctx) error {
bearer := strings.TrimPrefix(c.Get("Authorization"), "Bearer ")
if bearer == "" {
return c.Status(401).JSON(fiber.Map{"error": "token requerido"})
}
cfg, err := models.GetWebSmsConfig()
if err != nil {
return c.Status(503).JSON(fiber.Map{"error": "WebSMS no configurado"})
}
if cfg.SecretKey == "" || cfg.SecretKey != bearer {
return c.Status(401).JSON(fiber.Map{"error": "token inválido"})
}
type body struct {
Numero string `json:"numero"`
Mensaje string `json:"mensaje"`
}
var b body
if err := c.BodyParser(&b); err != nil || b.Numero == "" || b.Mensaje == "" {
return c.Status(400).JSON(fiber.Map{"error": "numero y mensaje son requeridos"})
}
resp, err := services.SendWebSms(cfg, b.Numero, b.Mensaje)
errStr := ""
status := "ok"
if err != nil {
errStr = err.Error()
status = "error"
} else if resp != nil {
status = resp.Code
}
models.CreateWebSmsLog(&models.WebSmsLog{
Para: b.Numero,
Mensaje: b.Mensaje,
Status: status,
MsgID: func() string { if resp != nil { return resp.ID }; return "" }(),
Error: errStr,
})
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": errStr})
}
return c.JSON(fiber.Map{"ok": true, "id": resp.ID})
}
func WebSmsIncomingWebhook(c *fiber.Ctx) error {
var payload services.WebSmsIncomingPayload
if err := c.BodyParser(&payload); err != nil {
+2 -1
View File
@@ -37,7 +37,8 @@ func RutasPublicas(web fiber.Router) {
// Configurar en el bot: POST https://api.telegram.org/bot{TOKEN}/setWebhook?url={HOST}/webhooks/telegram-portal
web.Post("/webhooks/telegram-portal", controllers.TelegramPortalWebhook)
// ─── WebSMS (LabsMobile) — ACK de entrega, clics y mensajes entrantes ──
// ─── WebSMS (LabsMobile) — API de envío + webhooks ──────────────────
web.Post("/api/sms/send", controllers.ApiSendSms)
web.Post("/webhooks/websms/delivery", controllers.WebSmsDeliveryWebhook)
web.Post("/webhooks/websms/click", controllers.WebSmsClickWebhook)
web.Post("/webhooks/websms/incoming", controllers.WebSmsIncomingWebhook)
+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)