feat: desactivar usuario + VCards desde tab usuarios

- Botón '🚫 + VCards' abre modal de confirmación
- Modal carga y lista las VCards activas del usuario
- Al confirmar: inactiva cada VCard (PUT estado:false) y desactiva el usuario
- Muestra resumen del resultado antes de cerrar
- Botón 'Ordenar por vencimiento' con colores en filas (rojo/amarillo/verde)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Lizandro Guarnizo
2026-05-23 08:57:18 -05:00
co-authored by Copilot
parent 2d6b4d8271
commit 661e02ef9c
+236 -1
View File
@@ -71,6 +71,27 @@
<!-- ─── TAB: Usuarios ─── -->
<div x-show="tab === 'usuarios'">
<div x-show="items.length === 0 && !loading" class="text-sm text-gray-400 py-8 text-center">Sin datos. Haz clic en "Cargar".</div>
<!-- Toolbar: ordenar por vencimiento -->
<div class="flex flex-wrap items-center gap-3 mb-3" x-show="items.length > 0">
<button @click="toggleSortVenc()" :disabled="loadingVenc"
:class="sortByVenc ? 'bg-[#8eb02f] text-white border-[#8eb02f]' : 'text-gray-600 border-gray-300 hover:bg-gray-50'"
class="flex items-center gap-1.5 text-xs px-3 py-1.5 border rounded-lg transition disabled:opacity-50">
<svg x-show="loadingVenc" class="w-3 h-3 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<svg x-show="!loadingVenc" class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<span x-text="loadingVenc ? 'Cargando membresías...' : (sortByVenc ? '✓ Ordenado por vencimiento' : 'Ordenar por vencimiento')"></span>
</button>
<div class="flex items-center gap-2 text-xs" x-show="sortByVenc">
<span class="px-2 py-0.5 rounded-full bg-red-100 text-red-700">Vencido / ≤7 días</span>
<span class="px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700">830 días</span>
<span class="px-2 py-0.5 rounded-full bg-green-100 text-green-700">&gt;30 días</span>
</div>
</div>
<div class="overflow-x-auto">
<table class="table-auto w-full text-sm" x-show="items.length > 0">
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
@@ -79,13 +100,14 @@
<th class="py-2 px-3">Email</th>
<th class="py-2 px-3">Rol</th>
<th class="py-2 px-3">Plan</th>
<th class="py-2 px-3" x-show="sortByVenc">Vence</th>
<th class="py-2 px-3">Estado</th>
<th class="py-2 px-3 text-right">Acciones</th>
</tr>
</thead>
<tbody>
<template x-for="u in items" :key="u.id || u._id">
<tr class="hover:bg-gray-50 border-b border-gray-100">
<tr :class="sortByVenc ? vencimientoRowClass(u) : 'hover:bg-gray-50'" class="border-b border-gray-100 transition-colors">
<td class="py-2 px-3 font-medium" x-text="u.name || u.nombre || '—'"></td>
<td class="py-2 px-3 text-gray-500 text-xs" x-text="u.email || '—'"></td>
<td class="py-2 px-3">
@@ -93,6 +115,9 @@
class="text-xs px-2 py-0.5 rounded-full" x-text="(u.rol && u.rol.nombre) ? u.rol.nombre : '—'"></span>
</td>
<td class="py-2 px-3 text-xs" x-text="(u.plan && u.plan.nombre) ? u.plan.nombre : '—'"></td>
<td class="py-2 px-3 text-xs" x-show="sortByVenc">
<span :class="vencimientoBadgeClass(u)" class="px-2 py-0.5 rounded-full whitespace-nowrap" x-text="vencimientoTexto(u)"></span>
</td>
<td class="py-2 px-3">
<span :class="(u.estado === true || u.estado === 'true') ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
class="text-xs px-2 py-0.5 rounded-full"
@@ -108,6 +133,10 @@
class="text-green-500 hover:text-green-700 text-xs px-2 py-1 border border-green-300 rounded">Activar</button>
<button x-show="u.estado === true || u.estado === 'true'" @click="toggleUsuario(u, 'desactivar')"
class="text-red-400 hover:text-red-600 text-xs px-2 py-1 border border-red-200 rounded">Desactivar</button>
<button x-show="u.estado === true || u.estado === 'true'" @click="openDesactivarConVcardsModal(u)"
class="text-orange-500 hover:text-orange-700 text-xs px-2 py-1 border border-orange-300 rounded" title="Desactiva usuario e inactiva todas sus VCards">
🚫 + VCards
</button>
<button @click="openMembresiaModal(u)"
class="text-blue-500 hover:text-blue-700 text-xs px-2 py-1 border border-blue-200 rounded">Membresía</button>
</div>
@@ -1182,6 +1211,65 @@
</div>
</div>
<!-- ─── Modal: Desactivar Usuario + VCards ───────────────────────────── -->
<div x-show="desactivarVcardsModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" style="display:none">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div class="flex justify-between items-center p-5 border-b">
<div>
<h3 class="text-base font-semibold text-red-700">Desactivar usuario + VCards</h3>
<p class="text-xs text-gray-500 mt-0.5" x-text="desactivarVcardsUser ? (desactivarVcardsUser.name || desactivarVcardsUser.email) : ''"></p>
</div>
<button @click="desactivarVcardsModal = false" class="text-gray-400 hover:text-gray-600 text-xl"></button>
</div>
<div class="p-5 space-y-4">
<!-- Cargando vcards -->
<div x-show="desactivarVcardsLoading" class="flex items-center gap-2 text-sm text-gray-500">
<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Cargando VCards del usuario...
</div>
<!-- Resumen de vcards encontradas -->
<div x-show="!desactivarVcardsLoading && desactivarVcardsItems.length > 0" class="p-3 bg-orange-50 border border-orange-200 rounded-lg text-sm">
<p class="font-medium text-orange-800 mb-2">
Se inactivarán <span class="font-bold" x-text="desactivarVcardsItems.length"></span> VCard(s):
</p>
<ul class="space-y-1 max-h-40 overflow-y-auto">
<template x-for="v in desactivarVcardsItems" :key="v.id || v._id">
<li class="flex items-center gap-2 text-xs text-orange-700">
<span class="text-orange-400"></span>
<span class="font-medium" x-text="v.nombre || v.name || '—'"></span>
<span class="text-orange-500 font-mono" x-text="v.unico ? '/' + v.unico : ''"></span>
<span :class="v.estado ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-400'"
class="ml-auto px-1.5 py-0.5 rounded-full text-xs"
x-text="v.estado ? 'Activa' : 'Inactiva'"></span>
</li>
</template>
</ul>
</div>
<!-- Sin vcards -->
<div x-show="!desactivarVcardsLoading && desactivarVcardsItems.length === 0" class="p-3 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-500">
Este usuario no tiene VCards. Solo se desactivará la cuenta.
</div>
<p class="text-sm text-gray-700">¿Confirmas desactivar la cuenta del usuario y todas sus VCards?</p>
<!-- Error -->
<div x-show="desactivarVcardsError" class="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-600" x-text="desactivarVcardsError"></div>
<!-- Resultado -->
<div x-show="desactivarVcardsResult" class="p-3 bg-green-50 border border-green-200 rounded text-sm text-green-700" x-text="desactivarVcardsResult"></div>
</div>
<div class="flex justify-end gap-3 px-5 pb-5">
<button @click="desactivarVcardsModal = false" class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">Cancelar</button>
<button @click="confirmarDesactivarConVcards()" :disabled="saving || desactivarVcardsLoading"
class="text-sm px-4 py-2 rounded-lg text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 flex items-center gap-2">
<svg x-show="saving" class="w-3.5 h-3.5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<span x-text="saving ? 'Desactivando...' : 'Confirmar desactivación'"></span>
</button>
</div>
</div>
</div>
<!-- ─── Modal: Membresía ──────────────────────────────────────────────── -->
<div x-show="membresiaModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" style="display:none">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
@@ -1483,6 +1571,19 @@ function vcardApiApp() {
membresiaError: '',
planes: [],
// Ordenar usuarios por vencimiento
sortByVenc: false,
loadingVenc: false,
membresiaMap: {}, // { userId: { fecha_fin, activo, plan } }
// Desactivar usuario + VCards
desactivarVcardsModal: false,
desactivarVcardsUser: null,
desactivarVcardsItems: [],
desactivarVcardsLoading: false,
desactivarVcardsError: '',
desactivarVcardsResult: '',
// Filtros por tab
filters: {
pagos: { estado: '', moneda: '', desde: '', hasta: '' },
@@ -1571,6 +1672,7 @@ function vcardApiApp() {
this.verVcardModal = false;
this.verPlanModal = false;
this.editPlanModal = false;
this.desactivarVcardsModal = false;
},
async loadConfig() {
@@ -1690,6 +1792,10 @@ function vcardApiApp() {
}
} catch(e) { this.errorMsg = e.message; }
this.loading = false;
// Si el sort por vencimiento estaba activo, re-aplicarlo con los nuevos items
if (this.sortByVenc && this.tab === 'usuarios' && this.items.length > 0) {
await this._fetchAndSortVenc();
}
},
_collectRoles(users) {
@@ -1703,6 +1809,82 @@ function vcardApiApp() {
});
},
// ─── Ordenar por vencimiento ─────────────────────────────────────────
diasHastaVencer(u) {
const id = u.id || u._id;
const m = this.membresiaMap[id];
if (!m || !m.fecha_fin) return null;
const diff = new Date(m.fecha_fin) - new Date();
return Math.ceil(diff / (1000 * 60 * 60 * 24));
},
vencimientoRowClass(u) {
const dias = this.diasHastaVencer(u);
if (dias === null) return 'hover:bg-gray-50';
if (dias <= 7) return 'bg-red-50 hover:bg-red-100';
if (dias <= 30) return 'bg-yellow-50 hover:bg-yellow-100';
return 'bg-green-50 hover:bg-green-100';
},
vencimientoBadgeClass(u) {
const dias = this.diasHastaVencer(u);
if (dias === null) return 'bg-gray-100 text-gray-400';
if (dias <= 0) return 'bg-red-200 text-red-800 font-semibold';
if (dias <= 7) return 'bg-red-100 text-red-700 font-semibold';
if (dias <= 30) return 'bg-yellow-100 text-yellow-700';
return 'bg-green-100 text-green-700';
},
vencimientoTexto(u) {
const id = u.id || u._id;
const m = this.membresiaMap[id];
if (this.loadingVenc && !m) return '...';
if (!m || !m.fecha_fin) return '—';
const dias = this.diasHastaVencer(u);
const fecha = m.fecha_fin.substring(0, 10);
if (dias <= 0) return `Vencido · ${fecha}`;
if (dias === 1) return `Mañana · ${fecha}`;
return `${dias}d · ${fecha}`;
},
async _fetchAndSortVenc() {
this.loadingVenc = true;
const batchSize = 5;
for (let i = 0; i < this.items.length; i += batchSize) {
const batch = this.items.slice(i, i + batchSize);
await Promise.all(batch.map(async u => {
const id = u.id || u._id;
if (this.membresiaMap[id]) return;
try {
const r = await fetch(`/app/vcard-api/usuarios/${id}/membresia`);
if (r.ok) { this.membresiaMap[id] = await r.json(); }
} catch(e) { /* ignorar errores individuales */ }
}));
}
this.loadingVenc = false;
this.items = [...this.items].sort((a, b) => {
const ma = this.membresiaMap[a.id || a._id];
const mb = this.membresiaMap[b.id || b._id];
const fa = ma?.fecha_fin ? new Date(ma.fecha_fin) : null;
const fb = mb?.fecha_fin ? new Date(mb.fecha_fin) : null;
if (!fa && !fb) return 0;
if (!fa) return 1;
if (!fb) return -1;
return fa - fb;
});
},
async toggleSortVenc() {
if (this.sortByVenc) {
this.sortByVenc = false;
this.loadTab();
return;
}
this.sortByVenc = true;
await this._fetchAndSortVenc();
},
clearFilters(tab) {
if (tab === 'pagos') this.filters.pagos = { estado: '', moneda: '', desde: '', hasta: '' };
if (tab === 'transacciones') this.filters.transacciones = { type: '', status: '' };
@@ -1801,6 +1983,57 @@ function vcardApiApp() {
this.loading = false;
},
async openDesactivarConVcardsModal(u) {
this.desactivarVcardsUser = u;
this.desactivarVcardsItems = [];
this.desactivarVcardsError = '';
this.desactivarVcardsResult = '';
this.desactivarVcardsLoading = true;
this.desactivarVcardsModal = true;
try {
const id = u.id || u._id;
const r = await fetch(`/app/vcard-api/usuarios/${id}/vcards`);
const d = await r.json();
if (d.data && Array.isArray(d.data)) this.desactivarVcardsItems = d.data;
else if (Array.isArray(d)) this.desactivarVcardsItems = d;
} catch(e) { this.desactivarVcardsError = 'No se pudieron cargar las VCards: ' + e.message; }
this.desactivarVcardsLoading = false;
},
async confirmarDesactivarConVcards() {
this.desactivarVcardsError = '';
this.desactivarVcardsResult = '';
this.saving = true;
const id = this.desactivarVcardsUser.id || this.desactivarVcardsUser._id;
try {
// 1. Inactivar cada VCard activa
let inactivadas = 0;
const vcards = this.desactivarVcardsItems.filter(v => v.estado === true || v.estado === 1);
for (const v of vcards) {
const vid = v.id || v._id;
const r = await fetch(`/app/vcard-api/vcards/${vid}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ estado: false })
});
if (r.ok) inactivadas++;
}
// 2. Desactivar usuario
const rU = await fetch(`/app/vcard-api/usuarios/${id}/desactivar`, { method: 'POST' });
const dU = await rU.json();
if (!rU.ok) {
this.desactivarVcardsError = dU.error || 'Error al desactivar el usuario.';
} else {
this.desactivarVcardsResult = `Usuario desactivado. VCards inactivadas: ${inactivadas} de ${vcards.length}.`;
setTimeout(async () => {
this.desactivarVcardsModal = false;
await this.loadTab();
}, 1800);
}
} catch(e) { this.desactivarVcardsError = e.message; }
this.saving = false;
},
// ─── Edit Usuario ────────────────────────────────────────────────────
openEditUsuarioModal(u) {
@@ -1987,6 +2220,8 @@ function vcardApiApp() {
const r = await fetch(`/app/vcard-api/usuarios/${id}/membresia`);
const d = await r.json();
this.membresiaInfo = d;
// Cachear para el sort por vencimiento
this.membresiaMap[id] = d;
} catch(e) {}
},