Files
soft_usite/resources/views/renovaciones/contratos.html
T
2026-05-14 22:30:31 -05:00

566 lines
37 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<div x-data="app" class="bg-white rounded-lg shadow">
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
</div>
<div class="container mx-auto p-6 w-full">
<div class="justify-between items-center w-full md:flex mb-4">
<div>
<h1 class="text-2xl font-bold mb-1">Contratos</h1>
<p class="text-sm text-gray-500">Gestión de contratos y vencimientos</p>
</div>
<div class="flex items-center gap-2 mt-3 md:mt-0 flex-wrap">
<select x-model="filtroEstado" @change="loadData()" class="border border-gray-300 rounded p-2 text-sm">
<option value="">Todos los estados</option>
<option value="activo">Activo</option>
<option value="vencido">Vencido</option>
<option value="cancelado">Cancelado</option>
<option value="renovado">Renovado</option>
</select>
<input type="text" placeholder="Buscar cliente..." class="border border-gray-300 rounded p-2 text-sm"
x-model="search" @input.debounce.400ms="loadData()" />
<button @click="addModal = true" class="bg-[#8eb02f] text-white px-4 py-2 rounded text-sm">+ Nuevo</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="table-auto w-full text-sm">
<thead class="text-left border-b border-gray-200 bg-gray-50">
<tr>
<th class="py-2 px-3">Cliente</th>
<th class="py-2 px-3">Servicios</th>
<th class="py-2 px-3">Vencimiento</th>
<th class="py-2 px-3">Días</th>
<th class="py-2 px-3">Precio</th>
<th class="py-2 px-3">Estado</th>
<th class="py-2 px-3 w-32"></th>
</tr>
</thead>
<tbody class="text-gray-600">
<template x-for="d in datos" :key="d.ID">
<tr class="hover:bg-gray-50 border-b border-gray-100">
<td class="py-2 px-3">
<div class="font-medium" x-text="d.cliente?.nombre||'—'"></div>
<div class="text-xs text-gray-400" x-text="d.cliente?.empresa||''"></div>
</td>
<td class="py-2 px-3">
<template x-for="s in (d.servicios||[])" :key="s.ID">
<span class="inline-block text-xs bg-gray-100 rounded px-1.5 py-0.5 mr-1 mb-0.5" x-text="s.nombre"></span>
</template>
<span x-show="!d.servicios||d.servicios.length===0" class="text-gray-400"></span>
</td>
<td class="py-2 px-3" x-text="fmtDate(d.fecha_vencimiento)"></td>
<td class="py-2 px-3">
<span class="px-2 py-0.5 rounded text-xs font-semibold"
:class="d.urgencia==='rojo' ? 'bg-red-100 text-red-700' : d.urgencia==='amarillo' ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'"
x-text="d.dias_restantes <= 0 ? 'Vencido' : d.dias_restantes+' días'"></span>
</td>
<td class="py-2 px-3" x-text="fmtPrecio(d.precio_acordado, d.moneda)"></td>
<td class="py-2 px-3">
<span class="px-2 py-0.5 rounded text-xs font-medium"
:class="{
'bg-green-100 text-green-700': d.estado==='activo',
'bg-red-100 text-red-700': d.estado==='vencido',
'bg-gray-100 text-gray-600': d.estado==='cancelado',
'bg-blue-100 text-blue-700': d.estado==='renovado'
}"
x-text="d.estado"></span>
<!-- Indicador de pago -->
<template x-if="d.pago_confirmado">
<span class="ml-1 px-1.5 py-0.5 rounded text-xs bg-green-100 text-green-700" title="Pago confirmado">✓ Pagado</span>
</template>
<template x-if="d.enlace_pago && !d.pago_confirmado">
<span class="ml-1 px-1.5 py-0.5 rounded text-xs bg-yellow-100 text-yellow-700" title="Enlace de pago activo">⏳ Pendiente</span>
</template>
</td>
<td class="py-2 px-3">
<div class="flex gap-1.5">
<button @click="openEdit(d)" title="Editar" class="text-gray-400 hover:text-[#8eb02f]">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
</button>
<button @click="renovar(d)" title="Renovar" x-show="d.estado==='activo'" class="text-gray-400 hover:text-blue-500">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"/></svg>
</button>
<button @click="enviarCorreo(d)" title="Enviar correo" class="text-gray-400 hover:text-purple-500">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
</button>
<!-- Abrir enlace de pago (solo si tiene enlace pendiente) -->
<a x-show="d.enlace_pago && !d.pago_confirmado" :href="d.enlace_pago" target="_blank" rel="noopener" title="Abrir enlace de pago" class="text-gray-400 hover:text-orange-500">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"/></svg>
</a>
<!-- Verificar pago manualmente (solo si tiene enlace pendiente) -->
<button x-show="d.enlace_pago_link_id && !d.pago_confirmado" @click="verificarPago(d)" :disabled="verificandoID===d.ID" title="Verificar pago en Bold" class="text-gray-400 hover:text-teal-500 disabled:opacity-40">
<svg class="w-4 h-4" :class="verificandoID===d.ID ? 'animate-spin' : ''" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"/></svg>
</button>
<button @click="openHistorial(d)" title="Ver historial" class="text-gray-400 hover:text-indigo-500">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
</button>
<button @click="openDelete(d)" title="Eliminar" class="text-gray-400 hover:text-red-500">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"/></svg>
</button>
</div>
</td>
</tr>
</template>
<tr x-show="!loading && datos.length===0">
<td colspan="7" class="text-center text-gray-400 py-8">Sin registros</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between mt-4 text-sm text-gray-500">
<span>Total: <span x-text="total"></span></span>
<div class="flex gap-1">
<button @click="prevPage()" :disabled="page===1" class="px-3 py-1 rounded border disabled:opacity-40"></button>
<span class="px-3 py-1" x-text="'Pág. '+page+' / '+totalPages"></span>
<button @click="nextPage()" :disabled="page>=totalPages" class="px-3 py-1 rounded border disabled:opacity-40"></button>
</div>
</div>
</div>
<!-- Modal Crear / Editar -->
<div x-show="addModal || editModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 p-6" @click.stop>
<h2 class="text-lg font-semibold mb-4" x-text="editModal ? 'Editar Contrato' : 'Nuevo Contrato'"></h2>
<form @submit.prevent="save()">
<div class="grid grid-cols-2 gap-3">
<div class="col-span-2" x-show="!editModal">
<label class="text-xs font-medium text-gray-600">Cliente *</label>
<select x-model="form.cliente_id" required class="mt-1 w-full border rounded px-3 py-2 text-sm">
<option value="">Seleccionar...</option>
<template x-for="c in clientes" :key="c.ID">
<option :value="c.ID" x-text="c.nombre + (c.empresa ? ' — '+c.empresa : '')"></option>
</template>
</select>
</div>
<div class="col-span-2">
<label class="text-xs font-medium text-gray-600">Servicios <span class="font-normal text-gray-400">(uno o varios)</span></label>
<div class="mt-1 border rounded max-h-36 overflow-y-auto divide-y text-sm">
<template x-for="s in servicios" :key="s.ID">
<label class="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" :value="s.ID"
:checked="form.servicio_ids.includes(s.ID)"
@change="toggleServicio(s.ID)" />
<span class="flex-1" x-text="s.nombre"></span>
<span class="text-xs text-gray-400" x-text="s.moneda+' '+Number(s.precio).toFixed(2)"></span>
</label>
</template>
<div x-show="servicios.length===0" class="text-xs text-gray-400 px-3 py-2">Sin servicios disponibles</div>
</div>
</div>
<div>
<label class="text-xs font-medium text-gray-600">Fecha inicio *</label>
<input x-model="form.fecha_inicio" type="date" required class="mt-1 w-full border rounded px-3 py-2 text-sm" />
</div>
<div>
<label class="text-xs font-medium text-gray-600">Fecha vencimiento *</label>
<input x-model="form.fecha_vencimiento" type="date" required class="mt-1 w-full border rounded px-3 py-2 text-sm" />
</div>
<div>
<label class="text-xs font-medium text-gray-600">Precio acordado</label>
<input x-model="form.precio_acordado" type="number" step="0.01" class="mt-1 w-full border rounded px-3 py-2 text-sm" />
<p class="text-xs text-gray-400 mt-0.5" x-show="precioSugerido > 0">
Sugerido: <span class="font-medium text-gray-600" x-text="fmtPrecio(precioSugerido, form.moneda)"></span>
</p>
</div>
<div>
<label class="text-xs font-medium text-gray-600">Moneda</label>
<select x-model="form.moneda" class="mt-1 w-full border rounded px-3 py-2 text-sm">
<option value="COP">COP — Pesos colombianos</option>
<option value="USD">USD — Dólares</option>
<option value="MXN">MXN — Pesos mexicanos</option>
<option value="EUR">EUR — Euros</option>
</select>
</div>
<div x-show="editModal">
<label class="text-xs font-medium text-gray-600">Estado</label>
<select x-model="form.estado" class="mt-1 w-full border rounded px-3 py-2 text-sm">
<option value="activo">Activo</option>
<option value="vencido">Vencido</option>
<option value="cancelado">Cancelado</option>
<option value="renovado">Renovado</option>
</select>
</div>
<div class="col-span-2">
<label class="text-xs font-medium text-gray-600">Notas</label>
<textarea x-model="form.notas" rows="2" class="mt-1 w-full border rounded px-3 py-2 text-sm"></textarea>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" x-model="form.auto_renovar" id="auto_ren" />
<label for="auto_ren" class="text-sm">Auto-renovar</label>
</div>
</div>
<div class="flex justify-end gap-2 mt-5">
<button type="button" @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
<button type="submit" class="px-4 py-2 bg-[#8eb02f] text-white rounded text-sm" :disabled="loading">
<span x-text="loading ? 'Guardando…' : 'Guardar'"></span>
</button>
</div>
</form>
</div>
</div>
<!-- Modal Historial -->
<div x-show="historialModal" x-cloak class="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50">
<div class="bg-white rounded-t-2xl sm:rounded-xl shadow-2xl w-full sm:max-w-lg mx-0 sm:mx-4 flex flex-col max-h-[85vh]" @click.stop>
<div class="flex items-center justify-between px-5 py-4 border-b">
<div>
<h2 class="text-base font-semibold" x-text="historialTitulo"></h2>
<p class="text-xs text-gray-400">Línea de tiempo de actividad</p>
</div>
<button @click="historialModal=false" class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="overflow-y-auto p-5 flex-1">
<!-- Loading -->
<div x-show="historialLoading" class="flex justify-center py-8">
<svg class="animate-spin w-6 h-6 text-indigo-500" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
</div>
<!-- Timeline -->
<div x-show="!historialLoading">
<div x-show="historialTimeline.length===0" class="text-center text-gray-400 py-8 text-sm">Sin actividad registrada</div>
<ol class="relative border-l border-gray-200 ml-3">
<template x-for="(ev, i) in historialTimeline" :key="i">
<li class="mb-6 ml-5">
<!-- Dot -->
<span class="absolute -left-3 flex items-center justify-center w-6 h-6 rounded-full ring-4 ring-white"
:class="{
'bg-indigo-100': ev.tipo==='creacion',
'bg-blue-100': ev.tipo==='renovacion',
'bg-purple-100': ev.tipo==='notificacion',
'bg-green-100': ev.tipo==='pago' && ev.estado==='ok',
'bg-red-100': ev.estado==='error'
}">
<!-- document -->
<template x-if="ev.icono==='document'">
<svg class="w-3 h-3 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/></svg>
</template>
<!-- refresh -->
<template x-if="ev.icono==='refresh'">
<svg class="w-3 h-3 text-blue-600" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"/></svg>
</template>
<!-- mail -->
<template x-if="ev.icono==='mail'">
<svg class="w-3 h-3 text-purple-600" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
</template>
<!-- currency -->
<template x-if="ev.icono==='currency'">
<svg class="w-3 h-3 text-green-600" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0 1 15.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 0 1 3 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 0 0-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 0 1-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 0 0 3 15h-.75M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm3 0h.008v.008H18V10.5Zm-12 0h.008v.008H6V10.5Z"/></svg>
</template>
</span>
<div class="ml-1">
<p class="text-sm font-medium text-gray-800" x-text="ev.titulo"></p>
<p class="text-xs text-gray-500 mt-0.5" x-text="ev.detalle"></p>
<template x-if="ev.enlace">
<a :href="ev.enlace" target="_blank" rel="noopener"
class="inline-flex items-center gap-1 text-xs text-indigo-600 hover:underline mt-0.5 break-all">
<svg class="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"/></svg>
<span x-text="ev.enlace.length > 50 ? ev.enlace.slice(0,50)+'…' : ev.enlace"></span>
</a>
</template>
<time class="block text-xs text-gray-400 mt-0.5" x-text="fmtDatetime(ev.fecha)"></time>
<span x-show="ev.estado==='error'" class="ml-2 text-xs text-red-500 font-medium">Fallido</span>
</div>
</li>
</template>
</ol>
</div>
</div>
</div>
</div>
<!-- Modal Enviar Notificación -->
<div x-show="notifModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-sm mx-4 p-6" @click.stop>
<h2 class="text-lg font-semibold mb-1">Enviar notificación</h2>
<p class="text-sm text-gray-400 mb-4" x-text="notifCliente ? 'Para: '+notifCliente : ''" ></p>
<label class="text-xs font-medium text-gray-600">Selecciona la plantilla / regla *</label>
<select x-model="notifReglaId" class="mt-1 w-full border rounded px-3 py-2 text-sm mb-4">
<option value="">Seleccionar...</option>
<template x-for="r in reglas" :key="r.ID">
<option :value="r.ID" x-text="r.nombre + ' — ' + tipoLabel(r.tipo_evento)"></option>
</template>
</select>
<div class="flex justify-end gap-2">
<button @click="notifModal=false" class="px-4 py-2 border rounded text-sm">Cancelar</button>
<button @click="confirmarEnvioNotif()" :disabled="!notifReglaId||loading"
class="px-4 py-2 bg-purple-600 text-white rounded text-sm disabled:opacity-40">
<span x-text="loading ? 'Enviando…' : 'Enviar'"></span>
</button>
</div>
</div>
</div>
<!-- Modal Bienvenida (post-creación) -->
<div x-show="bienvenidaModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-sm mx-4 p-6 text-center" @click.stop>
<div class="text-3xl mb-3">🎉</div>
<p class="font-semibold mb-1">¿Enviar correo de bienvenida?</p>
<p class="text-sm text-gray-400 mb-5">El contrato fue creado. ¿Deseas enviar el correo de bienvenida al cliente ahora?</p>
<template x-if="reglasBienvenida.length > 0">
<div class="mb-4 text-left">
<label class="text-xs font-medium text-gray-600">Plantilla de bienvenida</label>
<select x-model="bienvenidaReglaId" class="mt-1 w-full border rounded px-3 py-2 text-sm">
<template x-for="r in reglasBienvenida" :key="r.ID">
<option :value="r.ID" x-text="r.nombre"></option>
</template>
</select>
</div>
</template>
<div class="flex justify-center gap-3">
<button @click="bienvenidaModal=false" class="px-4 py-2 border rounded text-sm">No, omitir</button>
<button @click="enviarBienvenida()" :disabled="loading"
class="px-4 py-2 bg-[#8eb02f] text-white rounded text-sm disabled:opacity-40">
<span x-text="loading ? 'Enviando…' : 'Sí, enviar'"></span>
</button>
</div>
</div>
</div>
<!-- Modal Eliminar -->
<div x-show="deleteModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-sm mx-4 text-center" @click.stop>
<svg class="w-12 h-12 text-red-400 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"/></svg>
<p class="font-semibold mb-1">¿Eliminar contrato?</p>
<p class="text-sm text-gray-400 mb-5">Esta acción no se puede deshacer.</p>
<div class="flex justify-center gap-3">
<button @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
<button @click="deleteItem()" class="px-4 py-2 bg-red-500 text-white rounded text-sm">Eliminar</button>
</div>
</div>
</div>
<div x-show="toast.show" x-cloak x-transition
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-sm text-white"
:class="toast.type==='error' ? 'bg-red-500' : 'bg-[#8eb02f]'"
x-text="toast.msg"></div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('app', () => ({
loading: false,
datos: [], total: 0, totalPages: 1, page: 1, limit: 10,
search: '', filtroEstado: '',
addModal: false, editModal: false, deleteModal: false,
notifModal: false, notifReglaId: '', notifContratoId: null, notifCliente: '',
bienvenidaModal: false, bienvenidaReglaId: '', bienvenidaContratoId: null,
historialModal: false, historialTitulo: '', historialTimeline: [], historialLoading: false,
clientes: [], servicios: [], reglas: [], reglasBienvenida: [],
selectedId: null,
verificandoID: null,
form: { cliente_id:'', servicio_ids:[], fecha_inicio:'', fecha_vencimiento:'', precio_acordado:0, moneda:'COP', estado:'activo', auto_renovar:false, notas:'' },
toast: { show: false, msg: '', type: 'ok' },
async init() {
await Promise.all([this.loadData(), this.loadSelects()]);
},
async loadData() {
this.loading = true;
const { data } = await axios.get(`/app/api/contratos?page=${this.page}&limit=${this.limit}&search=${this.search}&estado=${this.filtroEstado}`);
this.datos = data.registros || [];
this.total = data.total || 0;
this.totalPages = data.totalPages || 1;
this.loading = false;
},
async loadSelects() {
try {
const [c, s, r] = await Promise.all([
axios.get('/app/api/clientes/select'),
axios.get('/app/api/servicios/select'),
axios.get('/app/api/reglas-notificacion'),
]);
this.clientes = Array.isArray(c.data) ? c.data : (c.data.registros || []);
this.servicios = Array.isArray(s.data) ? s.data : (s.data.registros || []);
const todasReglas = r.data.registros || [];
this.reglas = todasReglas.filter(x => x.activo);
this.reglasBienvenida = todasReglas.filter(x => x.activo && x.tipo_evento === 'bienvenida');
if (this.reglasBienvenida.length > 0) this.bienvenidaReglaId = this.reglasBienvenida[0].ID;
} catch(e) {
this.showToast('Error cargando listas: ' + (e.response?.data?.error || e.message), 'error');
}
},
get precioSugerido() {
return this.servicios
.filter(s => (this.form.servicio_ids || []).includes(s.ID))
.reduce((acc, s) => acc + (parseFloat(s.precio) || 0), 0);
},
toggleServicio(id) {
const idx = (this.form.servicio_ids || []).indexOf(id);
if (idx === -1) {
this.form.servicio_ids.push(id);
} else {
this.form.servicio_ids.splice(idx, 1);
}
this.form.precio_acordado = this.precioSugerido;
},
openEdit(d) {
this.form = {
cliente_id: d.cliente_id,
servicio_ids: (d.servicios || []).map(s => s.ID),
fecha_inicio: d.fecha_inicio ? d.fecha_inicio.substring(0,10) : '',
fecha_vencimiento: d.fecha_vencimiento ? d.fecha_vencimiento.substring(0,10) : '',
precio_acordado: d.precio_acordado,
moneda: d.moneda || 'COP',
estado: d.estado,
auto_renovar: d.auto_renovar,
notas: d.notas
};
this.selectedId = d.ID;
this.editModal = true;
},
openDelete(d) { this.selectedId = d.ID; this.deleteModal = true; },
closeModals() {
this.addModal = this.editModal = this.deleteModal = false;
this.selectedId = null;
this.form = { cliente_id:'', servicio_ids:[], fecha_inicio:'', fecha_vencimiento:'', precio_acordado:0, moneda:'COP', estado:'activo', auto_renovar:false, notas:'' };
},
async save() {
this.loading = true;
try {
const payload = {
...this.form,
cliente_id: parseInt(this.form.cliente_id) || 0,
servicio_id: parseInt(this.form.servicio_id) || 0,
precio_acordado: parseFloat(this.form.precio_acordado) || 0,
};
if (this.editModal) {
await axios.put(`/app/api/contratos/${this.selectedId}`, payload);
this.showToast('Guardado correctamente');
this.closeModals();
await this.loadData();
} else {
const res = await axios.post('/app/api/contratos', payload);
this.closeModals();
await this.loadData();
// Guardar el ID del contrato recién creado para bienvenida
// La API devuelve el ID o lo obtenemos del último registro
const contratoId = res.data.id || (this.datos.length > 0 ? this.datos[0].ID : null);
this.bienvenidaContratoId = contratoId;
this.bienvenidaModal = true;
}
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
this.loading = false;
},
async deleteItem() {
this.loading = true;
try {
await axios.delete(`/app/api/contratos/${this.selectedId}`);
this.showToast('Eliminado');
this.closeModals();
await this.loadData();
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
this.loading = false;
},
async renovar(d) {
if (!confirm(`¿Renovar el contrato de ${d.cliente?.nombre}?`)) return;
try {
await axios.post(`/app/api/contratos/${d.ID}/renovar`);
this.showToast('Contrato renovado');
await this.loadData();
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
},
async verificarPago(d) {
this.verificandoID = d.ID;
try {
const { data } = await axios.post(`/app/api/contratos/${d.ID}/verificar-pago`);
if (data.confirmado) {
this.showToast(data.mensaje || '¡Pago confirmado!', 'success');
await this.loadData();
} else {
this.showToast(data.mensaje || 'Pago aún pendiente', 'info');
}
} catch(e) {
this.showToast(e.response?.data?.error || 'Error al verificar', 'error');
} finally {
this.verificandoID = null;
}
},
enviarCorreo(d) {
this.notifContratoId = d.ID;
this.notifCliente = (d.cliente?.nombre || '') + (d.cliente?.email ? ' <'+d.cliente.email+'>' : '');
this.notifReglaId = '';
this.notifModal = true;
},
async confirmarEnvioNotif() {
if (!this.notifReglaId) return;
this.loading = true;
try {
await axios.post(`/app/api/contratos/${this.notifContratoId}/enviar-correo`, { regla_id: parseInt(this.notifReglaId) });
this.showToast('Correo enviado correctamente');
this.notifModal = false;
} catch(e) { this.showToast(e.response?.data?.error||'Error al enviar', 'error'); }
this.loading = false;
},
async enviarBienvenida() {
this.loading = true;
try {
const payload = this.bienvenidaReglaId ? { regla_id: parseInt(this.bienvenidaReglaId) } : {};
await axios.post(`/app/api/contratos/${this.bienvenidaContratoId}/enviar-correo`, payload);
this.showToast('Correo de bienvenida enviado');
} catch(e) { this.showToast(e.response?.data?.error||'Error al enviar', 'error'); }
this.bienvenidaModal = false;
this.loading = false;
},
tipoLabel(tipo) {
const m = { vencimiento_proximo:'Próximo a vencer', ya_vencido:'Ya vencido', bienvenida:'Bienvenida', pago_recibido:'Pago recibido', manual:'Manual' };
return m[tipo] || tipo;
},
async openHistorial(d) {
this.historialTitulo = 'Historial — ' + (d.cliente?.nombre || '#'+d.ID);
this.historialTimeline = [];
this.historialLoading = true;
this.historialModal = true;
try {
const res = await axios.get(`/app/api/contratos/${d.ID}/historial`);
this.historialTimeline = res.data.timeline || [];
} catch(e) { this.showToast('Error cargando historial', 'error'); }
this.historialLoading = false;
},
fmtDatetime(raw) {
if (!raw) return '—';
return new Date(raw).toLocaleString('es-ES', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
},
fmtDate(raw) {
if (!raw) return '—';
return new Date(raw).toLocaleDateString('es-ES', { day:'2-digit', month:'2-digit', year:'numeric' });
},
fmtPrecio(valor, moneda) {
const v = parseFloat(valor) || 0;
const cur = moneda || 'COP';
// COP y MXN: sin decimales, separador de miles
const sinDecimales = ['COP', 'MXN'];
const opts = sinDecimales.includes(cur)
? { minimumFractionDigits: 0, maximumFractionDigits: 0 }
: { minimumFractionDigits: 2, maximumFractionDigits: 2 };
const num = v.toLocaleString('es-CO', opts);
return cur + ' ' + num;
},
prevPage() { if(this.page>1){ this.page--; this.loadData(); } },
nextPage() { if(this.page<this.totalPages){ this.page++; this.loadData(); } },
showToast(msg, type='ok') {
this.toast = { show: true, msg, type };
setTimeout(() => this.toast.show = false, 3000);
}
}));
});
</script>