feat(vcard-api): modales Ver/Editar Plan con todos los campos

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Lizandro Guarnizo
2026-05-20 10:48:29 -05:00
co-authored by Copilot
3 changed files with 305 additions and 0 deletions
+295
View File
@@ -225,6 +225,20 @@
</div>
<!-- ID -->
<p class="text-xs text-gray-300 font-mono mt-3 truncate" x-text="p._id || p.id"></p>
<!-- Acciones -->
<div class="flex gap-2 mt-3 pt-3 border-t">
<button @click="openVerPlanModal(p)"
class="flex-1 px-3 py-1.5 text-xs border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-600">
Ver
</button>
<button @click="openEditPlanModal(p)"
class="flex-1 px-3 py-1.5 text-xs text-white rounded-lg"
style="background-color:#8eb02f"
onmouseover="this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
Editar
</button>
</div>
</div>
</template>
</div>
@@ -1232,6 +1246,213 @@
</div>
</div>
<!-- ─── Modal: Ver Plan ──────────────────────────────────────────────────── -->
<div x-show="verPlanModal" 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-xl shadow-2xl w-full max-w-2xl mx-4 flex flex-col max-h-[90vh]">
<div class="flex justify-between items-center px-5 py-4 border-b shrink-0">
<div class="flex items-center gap-3">
<img x-show="verPlanData && verPlanData.img" :src="verPlanData ? 'https://vcard.u-site.app/' + verPlanData.img : ''"
class="h-10 w-10 rounded-lg object-cover border" onerror="this.style.display='none'" />
<div>
<h2 class="text-lg font-semibold" x-text="verPlanData ? verPlanData.nombre : ''"></h2>
<span x-show="verPlanData"
:class="verPlanData && verPlanData.estado ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
class="text-xs px-2 py-0.5 rounded-full"
x-text="verPlanData && verPlanData.estado ? 'Activo' : 'Inactivo'"></span>
</div>
</div>
<button @click="verPlanModal = false" class="text-gray-400 hover:text-gray-600 text-xl shrink-0"></button>
</div>
<div class="overflow-y-auto p-5 space-y-5 flex-1" x-show="verPlanData">
<!-- Info general -->
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-xs font-medium text-gray-500 uppercase mb-1">VCards permitidas</p>
<p x-text="verPlanData && verPlanData.cantidad || '—'"></p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase mb-1">ID</p>
<p class="font-mono text-xs truncate" x-text="verPlanData && (verPlanData._id || verPlanData.id) || '—'"></p>
</div>
<div x-show="verPlanData && verPlanData.plan_id_anual">
<p class="text-xs font-medium text-gray-500 uppercase mb-1">Plan anual (ID externo)</p>
<p class="font-mono text-xs" x-text="verPlanData && verPlanData.plan_id_anual || '—'"></p>
</div>
<div x-show="verPlanData && verPlanData.plan_id_mensual">
<p class="text-xs font-medium text-gray-500 uppercase mb-1">Plan mensual (ID externo)</p>
<p class="font-mono text-xs" x-text="verPlanData && verPlanData.plan_id_mensual || '—'"></p>
</div>
<div x-show="verPlanData && verPlanData.plan_anual_paypal">
<p class="text-xs font-medium text-gray-500 uppercase mb-1">PayPal anual</p>
<p class="font-mono text-xs" x-text="verPlanData && verPlanData.plan_anual_paypal || '—'"></p>
</div>
<div x-show="verPlanData && verPlanData.plan_mensual_paypal">
<p class="text-xs font-medium text-gray-500 uppercase mb-1">PayPal mensual</p>
<p class="font-mono text-xs" x-text="verPlanData && verPlanData.plan_mensual_paypal || '—'"></p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase mb-1">Creado</p>
<p class="text-xs" x-text="verPlanData && verPlanData.created_at ? verPlanData.created_at.substring(0,10) : '—'"></p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase mb-1">Actualizado</p>
<p class="text-xs" x-text="verPlanData && verPlanData.updated_at ? verPlanData.updated_at.substring(0,10) : '—'"></p>
</div>
</div>
<!-- Descripción -->
<div>
<p class="text-xs font-medium text-gray-500 uppercase mb-1">Descripción</p>
<p class="text-sm text-gray-700" x-text="verPlanData && verPlanData.descripcion || '—'"></p>
</div>
<!-- Características (HTML) -->
<div x-show="verPlanData && verPlanData.caracteristicas">
<p class="text-xs font-medium text-gray-500 uppercase mb-1">Características</p>
<ul class="list-disc list-inside text-sm text-gray-700 space-y-0.5"
x-html="verPlanData && verPlanData.caracteristicas || ''"></ul>
</div>
<!-- Tarifas por país -->
<div x-show="verPlanData && verPlanData.tarifas && verPlanData.tarifas.length">
<p class="text-xs font-medium text-gray-500 uppercase mb-2">Tarifas por país</p>
<div class="overflow-x-auto border rounded-lg">
<table class="w-full text-xs">
<thead class="bg-gray-50 border-b">
<tr>
<th class="py-2 px-3 text-left font-semibold text-gray-500">#</th>
<th class="py-2 px-3 text-left font-semibold text-gray-500">País ID</th>
<th class="py-2 px-3 text-left font-semibold text-gray-500">Monto</th>
<th class="py-2 px-3 text-left font-semibold text-gray-500">Actualizado</th>
</tr>
</thead>
<tbody>
<template x-for="(t, i) in (verPlanData ? verPlanData.tarifas : [])" :key="t._id">
<tr class="border-b last:border-0 hover:bg-gray-50">
<td class="py-1.5 px-3 text-gray-400" x-text="i+1"></td>
<td class="py-1.5 px-3 font-mono text-gray-500 truncate max-w-[120px]" x-text="t.pais_id || '—'"></td>
<td class="py-1.5 px-3 font-medium">
<span :class="t.monto === 'Gratis' || t.monto === '0' ? 'text-green-600' : 'text-gray-800'"
x-text="t.monto === 'Gratis' || t.monto === '0' ? 'Gratis' : ('$' + t.monto)"></span>
</td>
<td class="py-1.5 px-3 text-gray-400" x-text="t.updated_at ? t.updated_at.substring(0,10) : '—'"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<div class="flex justify-end gap-3 px-5 py-4 border-t shrink-0">
<button @click="verPlanModal = false" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">Cerrar</button>
<button @click="verPlanModal = false; openEditPlanModal(verPlanData)"
class="px-4 py-2 text-sm text-white rounded-lg"
style="background-color:#8eb02f"
onmouseover="this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
Editar
</button>
</div>
</div>
</div>
<!-- ─── Modal: Editar Plan ────────────────────────────────────────────────── -->
<div x-show="editPlanModal" 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-xl shadow-2xl w-full max-w-xl mx-4 flex flex-col max-h-[90vh]">
<div class="flex justify-between items-center px-5 py-4 border-b shrink-0">
<h2 class="text-lg font-semibold">Editar Plan</h2>
<button @click="editPlanModal = false" class="text-gray-400 hover:text-gray-600 text-xl"></button>
</div>
<div class="overflow-y-auto p-5 flex-1">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-700 mb-1">Nombre del plan</label>
<input x-model="editPlanForm.nombre" type="text"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-700 mb-1">Descripción</label>
<textarea x-model="editPlanForm.descripcion" rows="2"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]"></textarea>
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-700 mb-1">Características (HTML)</label>
<textarea x-model="editPlanForm.caracteristicas" rows="4"
placeholder="<li>1 vCard</li>&#10;<li>Información personal</li>"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]"></textarea>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">VCards permitidas</label>
<input x-model="editPlanForm.cantidad" type="text"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Imagen (ruta storage)</label>
<input x-model="editPlanForm.img" type="text" placeholder="storage/archivo.png"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Plan ID anual (externo)</label>
<input x-model="editPlanForm.plan_id_anual" type="text"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Plan ID mensual (externo)</label>
<input x-model="editPlanForm.plan_id_mensual" type="text"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">PayPal plan anual</label>
<input x-model="editPlanForm.plan_anual_paypal" type="text"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">PayPal plan mensual</label>
<input x-model="editPlanForm.plan_mensual_paypal" type="text"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div class="sm:col-span-2 flex items-center gap-2 pt-1">
<input x-model="editPlanForm.estado" type="checkbox" id="planEstado" class="rounded" />
<label for="planEstado" class="text-sm text-gray-700 cursor-pointer">Plan activo</label>
</div>
</div>
<!-- Tarifas (sólo lectura) -->
<div x-show="editPlanData && editPlanData.tarifas && editPlanData.tarifas.length" class="mt-5">
<p class="text-xs font-medium text-gray-500 uppercase mb-2">Tarifas por país (sólo lectura)</p>
<div class="max-h-40 overflow-y-auto border rounded-lg">
<table class="w-full text-xs">
<thead class="bg-gray-50 border-b sticky top-0">
<tr>
<th class="py-1.5 px-3 text-left text-gray-500">País ID</th>
<th class="py-1.5 px-3 text-left text-gray-500">Monto</th>
</tr>
</thead>
<tbody>
<template x-for="t in (editPlanData ? editPlanData.tarifas : [])" :key="t._id">
<tr class="border-b last:border-0">
<td class="py-1 px-3 font-mono text-gray-500 truncate max-w-[140px]" x-text="t.pais_id"></td>
<td class="py-1 px-3 font-medium"
:class="t.monto === 'Gratis' || t.monto === '0' ? 'text-green-600' : ''"
x-text="t.monto === 'Gratis' || t.monto === '0' ? 'Gratis' : ('$' + t.monto)"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<div x-show="editPlanError" class="mt-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-600" x-text="editPlanError"></div>
<div x-show="editPlanSuccess" class="mt-4 p-3 bg-green-50 border border-green-200 rounded text-sm text-green-700">✓ Plan actualizado correctamente.</div>
</div>
<div class="flex justify-end gap-3 px-5 py-4 border-t shrink-0">
<button @click="editPlanModal = false" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">Cancelar</button>
<button @click="saveEditPlan()" :disabled="saving"
class="px-4 py-2 text-sm text-white rounded-lg disabled:opacity-50"
style="background-color:#8eb02f"
onmouseover="if(!this.disabled)this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
<span x-text="saving ? 'Guardando...' : 'Guardar'"></span>
</button>
</div>
</div>
</div>
</div>
<script>
@@ -1301,6 +1522,21 @@ function vcardApiApp() {
editVcardError: '',
editVcardSuccess: false,
// Modal ver plan
verPlanModal: false,
verPlanData: null,
// Modal editar plan
editPlanModal: false,
editPlanData: null,
editPlanForm: {
nombre: '', descripcion: '', caracteristicas: '', cantidad: '', img: '',
estado: true, plan_id_anual: '', plan_id_mensual: '',
plan_anual_paypal: '', plan_mensual_paypal: '',
},
editPlanError: '',
editPlanSuccess: false,
// Modal ver usuario (detalle completo)
verModal: false,
verData: null,
@@ -1333,6 +1569,8 @@ function vcardApiApp() {
this.editVcardModal = false;
this.verModal = false;
this.verVcardModal = false;
this.verPlanModal = false;
this.editPlanModal = false;
},
async loadConfig() {
@@ -1478,6 +1716,63 @@ function vcardApiApp() {
return montos.slice(0, 5); // max 5 precios únicos
},
// ─── Planes ──────────────────────────────────────────────────────────
openVerPlanModal(p) {
this.verPlanData = p;
this.verPlanModal = true;
},
openEditPlanModal(p) {
this.editPlanData = p;
this.editPlanForm = {
nombre: p.nombre || '',
descripcion: p.descripcion || '',
caracteristicas: p.caracteristicas || '',
cantidad: p.cantidad || '',
img: p.img || '',
estado: p.estado ?? true,
plan_id_anual: p.plan_id_anual || '',
plan_id_mensual: p.plan_id_mensual || '',
plan_anual_paypal: p.plan_anual_paypal || '',
plan_mensual_paypal: p.plan_mensual_paypal || '',
};
this.editPlanError = '';
this.editPlanSuccess = false;
this.editPlanModal = true;
},
async saveEditPlan() {
this.editPlanError = '';
this.editPlanSuccess = false;
const id = this.editPlanData._id || this.editPlanData.id;
const body = { ...this.editPlanForm };
// Limpiar campos vacíos opcionales
['plan_id_anual','plan_id_mensual','plan_anual_paypal','plan_mensual_paypal'].forEach(k => {
if (!body[k]) delete body[k];
});
this.saving = true;
try {
const r = await fetch(`/app/vcard-api/planes/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const d = await r.json();
if (!r.ok) {
this.editPlanError = d.error || d.message || 'Error al guardar el plan.';
} else {
this.editPlanSuccess = true;
// Actualiza la card local
const idx = this.items.findIndex(p => (p._id || p.id) === id);
if (idx > -1) this.items[idx] = { ...this.items[idx], ...body };
this.loadPlanes();
}
} catch(e) {
this.editPlanError = e.message;
}
this.saving = false;
},
async loadPlanes() {
try {
const r = await fetch('/app/vcard-api/planes');
+8
View File
@@ -216,6 +216,14 @@ func VcardApiPlanes(c *fiber.Ctx) error {
return proxyVcard(c, http.MethodGet, "/api/admin/planes")
}
func VcardApiPlan(c *fiber.Ctx) error {
return proxyVcard(c, http.MethodGet, "/api/admin/planes/"+c.Params("id"))
}
func VcardApiPlanUpdate(c *fiber.Ctx) error {
return proxyVcardMutate(c, http.MethodPut, "/api/admin/planes/"+c.Params("id"))
}
func VcardApiMembresia(c *fiber.Ctx) error {
return proxyVcard(c, http.MethodGet, "/api/admin/usuarios/"+c.Params("id")+"/membresia")
}
+2
View File
@@ -242,6 +242,8 @@ func UserRoutes(app fiber.Router) {
protected.Put("/vcard-api/vcards/:id", controllers.VcardApiVcardUpdate)
// Resto (solo GET)
protected.Get("/vcard-api/planes", controllers.VcardApiPlanes)
protected.Get("/vcard-api/planes/:id", controllers.VcardApiPlan)
protected.Put("/vcard-api/planes/:id", controllers.VcardApiPlanUpdate)
protected.Get("/vcard-api/pagos", controllers.VcardApiPagos)
protected.Get("/vcard-api/transacciones", controllers.VcardApiTransacciones)
protected.Get("/vcard-api/logs", controllers.VcardApiLogs)