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:
co-authored by
Copilot
parent
2d6b4d8271
commit
661e02ef9c
@@ -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">8–30 días</span>
|
||||
<span class="px-2 py-0.5 rounded-full bg-green-100 text-green-700">>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) {}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user