FIX:Busqueda de membresías

This commit is contained in:
Rubi Almora 2026-03-06 14:59:25 -06:00
parent 104e2e327e
commit f903f74fe4
4 changed files with 1036 additions and 284 deletions

View File

@ -0,0 +1,338 @@
<script setup>
import { computed, ref, watch } from "vue";
import { apiURL, useApi } from "@/services/Api.js";
import ShowModal from "@Holos/Modal/Show.vue";
import Input from "@Holos/Form/Input.vue";
import Selectable from "@Holos/Form/Selectable.vue";
const props = defineProps({
show: Boolean,
memberId: [Number, String],
});
const emit = defineEmits(["close", "charged"]);
const api = useApi();
const loadingConcepts = ref(false);
const processing = ref(false);
const conceptOptions = ref([]);
const selectedConcept = ref(null);
const quantity = ref(1);
const unitAmountInPeso = ref(null);
const totalAmountInPeso = ref(null);
const loadingUnitAmountInPeso = ref(false);
const loadingTotalAmountInPeso = ref(false);
const umaConversionCache = ref({});
const unitAmount = computed(() => {
if (!selectedConcept.value) return 0;
return parseFloat(
selectedConcept.value.unit_cost_peso ?? selectedConcept.value.unit_cost_uma ?? 0
);
});
const isPesoCharge = computed(() =>
selectedConcept.value?.charge_type?.startsWith("peso_")
);
const unitAmountLabel = computed(() => {
if (!selectedConcept.value || !unitAmount.value) return "No definido";
if (isPesoCharge.value) {
return `$${unitAmount.value.toLocaleString("es-MX", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}
return `${unitAmount.value.toLocaleString("es-MX", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} UMA`;
});
const totalLabel = computed(() => {
const total = unitAmount.value * (parseInt(quantity.value, 10) || 1);
if (!selectedConcept.value || !total) return "No definido";
if (isPesoCharge.value) {
return `$${total.toLocaleString("es-MX", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}
return `${total.toLocaleString("es-MX", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} UMA`;
});
const totalUmaAmount = computed(
() => unitAmount.value * (parseInt(quantity.value, 10) || 1)
);
const unitAmountInPesoLabel = computed(() => {
if (unitAmountInPeso.value == null) return "";
return `$${Number(unitAmountInPeso.value).toLocaleString("es-MX", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
});
const totalAmountInPesoLabel = computed(() => {
if (totalAmountInPeso.value == null) return "";
return `$${Number(totalAmountInPeso.value).toLocaleString("es-MX", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
});
function resetForm() {
selectedConcept.value = null;
quantity.value = 1;
unitAmountInPeso.value = null;
totalAmountInPeso.value = null;
}
async function loadConceptOptions() {
loadingConcepts.value = true;
await api.get(apiURL("charge-concepts"), {
params: {
type: "membership",
},
onSuccess: (data) => {
conceptOptions.value =
data.models?.data || data.data || data.models || data || [];
},
onFail: (error) => {
Notify.warning(error.message || "No se pudieron cargar los conceptos");
conceptOptions.value = [];
},
onError: () => {
Notify.error("Error al cargar los conceptos");
conceptOptions.value = [];
},
onFinish: () => {
loadingConcepts.value = false;
},
});
}
watch(
() => props.show,
async (show) => {
if (show) {
resetForm();
await loadConceptOptions();
return;
}
resetForm();
}
);
async function calculateUmaToPeso(value, targetRef, loadingRef) {
const numericValue = Number(value);
if (!numericValue || Number.isNaN(numericValue)) {
targetRef.value = null;
return;
}
const cacheKey = numericValue.toString();
if (umaConversionCache.value[cacheKey] != null) {
targetRef.value = umaConversionCache.value[cacheKey];
return;
}
loadingRef.value = true;
await api.post(apiURL("charge-concepts/calculate-uma"), {
data: {
value: numericValue,
},
onSuccess: (data) => {
umaConversionCache.value[cacheKey] = data.result;
targetRef.value = data.result;
},
onFail: () => {
targetRef.value = null;
},
onError: () => {
targetRef.value = null;
},
onFinish: () => {
loadingRef.value = false;
},
});
}
watch(
() => [props.show, isPesoCharge.value, unitAmount.value],
async ([show, isPesoChargeValue]) => {
if (!show || isPesoChargeValue || !selectedConcept.value) {
unitAmountInPeso.value = null;
return;
}
await calculateUmaToPeso(
unitAmount.value,
unitAmountInPeso,
loadingUnitAmountInPeso
);
},
{ immediate: true }
);
watch(
() => [props.show, isPesoCharge.value, totalUmaAmount.value],
async ([show, isPesoChargeValue]) => {
if (!show || isPesoChargeValue || !selectedConcept.value) {
totalAmountInPeso.value = null;
return;
}
await calculateUmaToPeso(
totalUmaAmount.value,
totalAmountInPeso,
loadingTotalAmountInPeso
);
},
{ immediate: true }
);
async function submitCharge() {
if (!props.memberId) {
Notify.warning("Primero debes buscar un miembro");
return;
}
if (!selectedConcept.value?.id) {
Notify.warning("Selecciona un concepto");
return;
}
const numericQuantity = parseInt(quantity.value, 10) || 0;
if (numericQuantity <= 0) {
Notify.warning("La cantidad debe ser mayor a 0");
return;
}
processing.value = true;
await api.put(apiURL(`members/${props.memberId}/charge-membership`), {
data: {
charge_concept_id: selectedConcept.value.id,
quantity: numericQuantity,
},
onSuccess: (response) => {
Notify.success("Cobro registrado correctamente");
emit("charged", response);
emit("close");
},
onFail: (error) => {
Notify.warning(error.message || "No se pudo registrar el cobro");
},
onError: () => {
Notify.error("Error al registrar el cobro");
},
onFinish: () => {
processing.value = false;
},
});
}
</script>
<template>
<ShowModal
:show="show"
title="Agregar cobro"
@close="emit('close')"
>
<div class="space-y-4 p-4">
<Selectable
v-model="selectedConcept"
label="name"
:options="conceptOptions"
title="Concepto"
:disabled="loadingConcepts || processing"
:placeholder="
loadingConcepts ? 'Cargando conceptos...' : 'Selecciona un concepto'
"
required
/>
<Input
v-model="quantity"
id="Cantidad"
type="number"
min="1"
:disabled="processing"
required
/>
<div class="grid gap-4 md:grid-cols-2">
<div>
<Input
:model-value="unitAmountLabel"
id="Costo unitario"
type="text"
disabled
/>
<p
v-if="selectedConcept && !isPesoCharge && loadingUnitAmountInPeso"
class="mt-1 text-xs text-gray-500"
>
Calculando equivalente en pesos...
</p>
<p
v-else-if="selectedConcept && !isPesoCharge && unitAmountInPesoLabel"
class="mt-1 text-xs font-medium text-emerald-700"
>
Equivalente en pesos: {{ unitAmountInPesoLabel }}
</p>
</div>
<div>
<Input
:model-value="isPesoCharge ? totalLabel : totalAmountInPesoLabel || 'No definido'"
id="Monto estimado"
type="text"
disabled
/>
<p
v-if="selectedConcept && !isPesoCharge"
class="mt-1 text-xs text-gray-500"
>
<span v-if="loadingTotalAmountInPeso">
Calculando monto en pesos...
</span>
<span v-else>
Referencia en UMA: {{ totalLabel }}
</span>
</p>
</div>
</div>
</div>
<template #buttons>
<button
type="button"
class="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="processing || loadingConcepts"
@click="submitCharge"
>
{{ processing ? "Registrando..." : "Agregar cobro" }}
</button>
</template>
</ShowModal>
</template>

View File

@ -0,0 +1,388 @@
<script setup>
import { computed, ref, watch } from "vue";
import { apiURL, useApi } from "@/services/Api.js";
import ShowModal from "@Holos/Modal/Show.vue";
import Input from "@Holos/Form/Input.vue";
const props = defineProps({
show: Boolean,
membership: Object,
loading: Boolean,
});
const emit = defineEmits(["close", "pay"]);
const api = useApi();
const quantity = ref(1);
const unitAmountInPeso = ref(null);
const totalAmountInPeso = ref(null);
const loadingUnitAmountInPeso = ref(false);
const loadingTotalAmountInPeso = ref(false);
const umaConversionCache = ref({});
const hasExpiration = computed(() => Boolean(props.membership?.expires_at));
const isNoExpires = computed(() => props.membership?.status === "no_expires");
const isExpired = computed(() => {
if (!props.membership || isNoExpires.value || !props.membership.expires_at) {
return false;
}
return new Date(props.membership.expires_at) <= new Date();
});
const daysUntilExpiration = computed(() => {
if (!props.membership || isNoExpires.value || !props.membership.expires_at) {
return null;
}
const expirationDate = new Date(props.membership.expires_at);
const now = new Date();
const diffTime = expirationDate - now;
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
});
const statusLabel = computed(() => {
if (isNoExpires.value) return "Sin vencimiento";
if (isExpired.value || props.membership?.status === "expired") return "Vencida";
if (props.membership?.status === "active") return "Activa";
return props.membership?.status || "Sin estatus";
});
const statusClasses = computed(() => {
if (isNoExpires.value) return "bg-slate-100 text-slate-700";
if (isExpired.value || props.membership?.status === "expired") {
return "bg-red-100 text-red-700";
}
if (props.membership?.status === "active") {
return "bg-green-100 text-green-700";
}
return "bg-gray-100 text-gray-700";
});
const formattedExpiration = computed(() => {
if (!hasExpiration.value) return "No aplica";
return new Date(props.membership.expires_at).toLocaleDateString("es-MX", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
});
const unitAmount = computed(() => {
const concept = props.membership?.charge_concept;
if (!concept) return 0;
return parseFloat(concept.unit_cost_peso ?? concept.unit_cost_uma ?? 0);
});
const isPesoCharge = computed(() =>
props.membership?.charge_concept?.charge_type?.startsWith("peso_")
);
const unitAmountLabel = computed(() => {
if (!unitAmount.value) return "No definido";
if (isPesoCharge.value) {
return `$${unitAmount.value.toLocaleString("es-MX", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}
return `${unitAmount.value.toLocaleString("es-MX", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} UMA`;
});
const totalLabel = computed(() => {
const total = unitAmount.value * (parseInt(quantity.value, 10) || 1);
if (isPesoCharge.value) {
return `$${total.toLocaleString("es-MX", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}
return `${total.toLocaleString("es-MX", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} UMA`;
});
const totalUmaAmount = computed(
() => unitAmount.value * (parseInt(quantity.value, 10) || 1)
);
const paymentsCount = computed(() => props.membership?.payments?.length || 0);
const expirationHint = computed(() => {
if (!hasExpiration.value || isNoExpires.value || daysUntilExpiration.value == null) {
return "No requiere renovación";
}
if (daysUntilExpiration.value < 0) {
return "Membresía vencida";
}
if (daysUntilExpiration.value === 0) return "Vence hoy";
if (daysUntilExpiration.value === 1) return "Vence mañana";
return `Faltan ${daysUntilExpiration.value} días para que venza`;
});
const unitAmountInPesoLabel = computed(() => {
if (unitAmountInPeso.value == null) return "";
return `$${Number(unitAmountInPeso.value).toLocaleString("es-MX", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
});
const totalAmountInPesoLabel = computed(() => {
if (totalAmountInPeso.value == null) return "";
return `$${Number(totalAmountInPeso.value).toLocaleString("es-MX", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
});
async function calculateUmaToPeso(value, targetRef, loadingRef) {
const numericValue = Number(value);
if (!numericValue || Number.isNaN(numericValue)) {
targetRef.value = null;
return;
}
const cacheKey = numericValue.toString();
if (umaConversionCache.value[cacheKey] != null) {
targetRef.value = umaConversionCache.value[cacheKey];
return;
}
loadingRef.value = true;
await api.post(apiURL("charge-concepts/calculate-uma"), {
data: {
value: numericValue,
},
onSuccess: (data) => {
umaConversionCache.value[cacheKey] = data.result;
targetRef.value = data.result;
},
onFail: () => {
targetRef.value = null;
},
onError: () => {
targetRef.value = null;
},
onFinish: () => {
loadingRef.value = false;
},
});
}
watch(
() => [props.show, props.membership?.id],
() => {
quantity.value = 1;
unitAmountInPeso.value = null;
totalAmountInPeso.value = null;
}
);
watch(
() => [props.show, isPesoCharge.value, unitAmount.value],
async ([show, isPesoChargeValue]) => {
if (!show || isPesoChargeValue) {
unitAmountInPeso.value = null;
return;
}
await calculateUmaToPeso(
unitAmount.value,
unitAmountInPeso,
loadingUnitAmountInPeso
);
},
{ immediate: true }
);
watch(
() => [props.show, isPesoCharge.value, totalUmaAmount.value],
async ([show, isPesoChargeValue]) => {
if (!show || isPesoChargeValue) {
totalAmountInPeso.value = null;
return;
}
await calculateUmaToPeso(
totalUmaAmount.value,
totalAmountInPeso,
loadingTotalAmountInPeso
);
},
{ immediate: true }
);
function submitPayment() {
emit("pay", { quantity: parseInt(quantity.value, 10) || 1 });
}
</script>
<template>
<ShowModal
:show="show"
:title="membership?.charge_concept?.name || 'Detalle de membresía'"
@close="emit('close')"
>
<div v-if="membership" class="space-y-4 p-4">
<div class="flex items-center justify-between gap-4 rounded-lg bg-gray-50 p-4">
<div>
<p class="text-xs uppercase tracking-wide text-gray-500">Estatus</p>
<p class="mt-1 text-sm font-semibold text-gray-900">
{{ membership.status || "Sin estatus" }}
</p>
<p class="mt-1 text-xs" :class="isExpired ? 'text-red-600' : 'text-green-700'">
{{ expirationHint }}
</p>
</div>
<span
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="statusClasses"
>
{{ statusLabel }}
</span>
</div>
<div class="grid gap-4 md:grid-cols-2">
<Input
:model-value="membership.charge_concept?.name || ''"
id="Concepto"
type="text"
disabled
/>
<Input
:model-value="membership.charge_concept?.short_name || ''"
id="Clave"
type="text"
disabled
/>
<Input
:model-value="membership.charge_concept?.charge_type || ''"
id="Tipo de cobro"
type="text"
disabled
/>
<Input
:model-value="formattedExpiration"
id="Vencimiento"
type="text"
disabled
/>
<div>
<Input
:model-value="unitAmountLabel"
id="Costo unitario"
type="text"
disabled
/>
<p
v-if="!isPesoCharge && loadingUnitAmountInPeso"
class="mt-1 text-xs text-gray-500"
>
Calculando equivalente en pesos...
</p>
<p
v-else-if="!isPesoCharge && unitAmountInPesoLabel"
class="mt-1 text-xs font-medium text-emerald-700"
>
Equivalente en pesos: {{ unitAmountInPesoLabel }}
</p>
</div>
<Input
:model-value="String(paymentsCount)"
id="Pagos registrados"
type="text"
disabled
/>
</div>
<div
v-if="isExpired"
class="space-y-4 rounded-xl border border-red-200 bg-red-50 p-4"
>
<p class="text-sm font-medium text-red-700">
La membresía está vencida. Puedes registrar un nuevo pago para renovarla.
</p>
<div class="grid gap-4 md:grid-cols-2">
<Input
v-model="quantity"
id="Cantidad"
type="number"
min="1"
:disabled="loading"
/>
<div>
<Input
:model-value="isPesoCharge ? totalLabel : totalAmountInPesoLabel"
id="Monto estimado"
type="text"
disabled
/>
<p
v-if="!isPesoCharge"
class="mt-1 text-xs text-gray-500"
>
<span v-if="loadingTotalAmountInPeso">
Calculando monto en pesos...
</span>
<span v-else>
Referencia en UMA: {{ totalLabel }}
</span>
</p>
</div>
</div>
</div>
<div
v-else-if="isNoExpires"
class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700"
>
Esta membresía no tiene fecha de vencimiento, por lo que no requiere renovación.
</div>
<div
v-else
class="rounded-xl border border-green-200 bg-green-50 p-4 text-sm text-green-700"
>
Esta membresía sigue activa y no requiere pago por el momento.
</div>
</div>
<template #buttons>
<button
v-if="membership && isExpired"
type="button"
class="rounded-md bg-green-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-green-600 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="loading"
@click="submitPayment"
>
{{ loading ? "Procesando..." : "Renovar membresía" }}
</button>
</template>
</ShowModal>
</template>

View File

@ -0,0 +1,160 @@
<script setup>
import { computed } from "vue";
const props = defineProps({
membership: {
type: Object,
required: true,
},
});
const emit = defineEmits(["select"]);
const hasExpiration = computed(() => Boolean(props.membership?.expires_at));
const isNoExpires = computed(() => props.membership?.status === "no_expires");
const isExpired = computed(() => {
if (!props.membership || isNoExpires.value || !props.membership.expires_at) {
return false;
}
return new Date(props.membership.expires_at) <= new Date();
});
const daysUntilExpiration = computed(() => {
if (!props.membership || isNoExpires.value || !props.membership.expires_at) {
return null;
}
const expirationDate = new Date(props.membership.expires_at);
const now = new Date();
const diffTime = expirationDate - now;
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
});
const statusLabel = computed(() => {
if (isNoExpires.value) return "Sin vencimiento";
if (isExpired.value || props.membership?.status === "expired") return "Vencida";
if (props.membership?.status === "active") return "Activa";
return props.membership?.status || "Sin estatus";
});
const statusClasses = computed(() => {
if (isNoExpires.value) return "bg-slate-100 text-slate-700";
if (isExpired.value || props.membership?.status === "expired") {
return "bg-red-100 text-red-700";
}
if (props.membership?.status === "active") {
return "bg-green-100 text-green-700";
}
return "bg-gray-100 text-gray-700";
});
const cardClasses = computed(() => {
if (isNoExpires.value) {
return "border-slate-200 bg-slate-50 hover:border-slate-300";
}
if (isExpired.value || props.membership?.status === "expired") {
return "border-red-200 bg-red-50 hover:border-red-300";
}
if (props.membership?.status === "active") {
return "border-green-300 bg-gradient-to-r from-green-50 to-emerald-50 hover:border-green-400";
}
return "border-gray-200 bg-white hover:border-primary";
});
const expirationHint = computed(() => {
if (!hasExpiration.value || isNoExpires.value || daysUntilExpiration.value == null) {
return "No requiere renovación";
}
if (daysUntilExpiration.value < 0) {
return "Membresía vencida";
}
if (daysUntilExpiration.value === 0) {
return "Vence hoy";
}
if (daysUntilExpiration.value === 1) {
return "Vence mañana";
}
return `Faltan ${daysUntilExpiration.value} días`;
});
const expirationHintClasses = computed(() => {
if (isNoExpires.value) return "bg-slate-100 text-slate-600";
if (isExpired.value || props.membership?.status === "expired") {
return "bg-red-100 text-red-600";
}
if (props.membership?.status === "active") {
return daysUntilExpiration.value != null && daysUntilExpiration.value <= 7
? "bg-amber-100 text-amber-700"
: "bg-green-100 text-green-700";
}
return "bg-gray-100 text-gray-600";
});
const formattedExpiration = computed(() => {
if (!hasExpiration.value) return "No aplica";
return new Date(props.membership.expires_at).toLocaleDateString("es-MX", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
});
</script>
<template>
<button
type="button"
class="w-full rounded-xl border p-4 text-left shadow-sm transition hover:shadow-md"
:class="cardClasses"
@click="emit('select', membership)"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="truncate text-base font-semibold text-gray-900">
{{ membership.charge_concept?.name || "Membresía sin nombre" }}
</p>
<p class="mt-1 text-sm text-gray-500">
{{ membership.charge_concept?.short_name || "Sin clave corta" }}
</p>
</div>
<span
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="statusClasses"
>
{{ statusLabel }}
</span>
</div>
<div class="mt-3 inline-flex rounded-full px-3 py-1 text-sm font-medium" :class="expirationHintClasses">
{{ expirationHint }}
</div>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<div class="rounded-lg bg-white/70 p-3">
<p class="text-xs uppercase tracking-wide text-gray-500">Estatus</p>
<p class="mt-1 text-sm font-medium text-gray-800">
{{ statusLabel }}
</p>
</div>
<div class="rounded-lg bg-white/70 p-3">
<p class="text-xs uppercase tracking-wide text-gray-500">Vencimiento</p>
<p class="mt-1 text-sm font-medium text-gray-800">
{{ formattedExpiration }}
</p>
</div>
</div>
</button>
</template>

View File

@ -1,20 +1,24 @@
<script setup> <script setup>
import { ref, computed } from "vue"; import { computed, ref } from "vue";
import { apiURL, useApi } from "@/services/Api.js"; import { apiURL, useApi } from "@/services/Api.js";
import Input from "@Holos/Form/Input.vue"; import Input from "@Holos/Form/Input.vue";
import MembershipChargeModal from "@App/MembershipChargeModal.vue";
import MembershipDetailModal from "@App/MembershipDetailModal.vue";
import MembershipListItem from "@App/MembershipListItem.vue";
/** Eventos */
const emit = defineEmits(["membership-paid"]); const emit = defineEmits(["membership-paid"]);
/** Instancias */
const api = useApi(); const api = useApi();
/** Refs */ const searching = ref(false);
const loading = ref(false); const processingPayment = ref(false);
const curp = ref(""); const curp = ref("");
const showAddChargeModal = ref(false);
const showMembershipModal = ref(false);
const selectedMembership = ref(null);
const memberData = ref({ const emptyMemberData = () => ({
id: null, id: null,
curp: "", curp: "",
name: "", name: "",
@ -24,61 +28,14 @@ const memberData = ref({
memberships: [], memberships: [],
}); });
const paymentData = ref({ const memberData = ref(emptyMemberData());
membershipId: null,
quantity: 1,
totalAmount: 0,
});
/** Computed */ const hasMemberships = computed(
const activeMembership = computed(() => { () => memberData.value.memberships && memberData.value.memberships.length > 0
if ( );
!memberData.value.memberships ||
memberData.value.memberships.length === 0
) {
return null;
}
return memberData.value.memberships[0];
});
const expiresAt = computed(() => { function normalizeMember(model) {
if (!activeMembership.value || !activeMembership.value.expires_at) return {
return null;
const date = new Date(activeMembership.value.expires_at);
return date.toLocaleDateString("es-MX", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
});
const formattedAmount = computed(() => {
if (!paymentData.value.totalAmount) return "$0.00";
return `$${parseFloat(paymentData.value.totalAmount).toLocaleString("es-MX", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
});
/** Métodos */
const handleSearch = async () => {
if (!curp.value.trim()) {
Notify.warning("Por favor ingresa un CURP");
return;
}
loading.value = true;
await api.get(apiURL("members/search"), {
params: {
curp: curp.value,
},
onSuccess: (response) => {
const model = response.model;
memberData.value = {
id: model.id, id: model.id,
curp: model.curp, curp: model.curp,
name: model.name, name: model.name,
@ -87,16 +44,35 @@ const handleSearch = async () => {
photo_url: model.photo_url, photo_url: model.photo_url,
memberships: model.memberships || [], memberships: model.memberships || [],
}; };
}
function clearMemberData({ preserveQuery = true } = {}) {
memberData.value = emptyMemberData();
selectedMembership.value = null;
showAddChargeModal.value = false;
showMembershipModal.value = false;
if (!preserveQuery) {
curp.value = "";
}
}
async function searchMemberByCurp(searchCurp, { notify = true } = {}) {
searching.value = true;
await api.get(apiURL("members/search"), {
params: {
curp: searchCurp,
},
onSuccess: (response) => {
const model = response.model;
memberData.value = normalizeMember(model);
if (notify) {
if (memberData.value.memberships.length === 0) { if (memberData.value.memberships.length === 0) {
Notify.warning("El miembro no tiene membresías activas"); Notify.warning("El miembro no tiene membresías registradas");
} else { } else {
Notify.success("Miembro encontrado"); Notify.success("Miembro encontrado");
// Pre-cargar datos del pago
if (activeMembership.value) {
paymentData.value.membershipId = activeMembership.value.id;
calculateTotal();
} }
} }
}, },
@ -110,61 +86,60 @@ const handleSearch = async () => {
}, },
}); });
loading.value = false; searching.value = false;
}; }
const calculateTotal = () => { async function handleSearch() {
if (!activeMembership.value) { if (!curp.value.trim()) {
paymentData.value.totalAmount = 0; Notify.warning("Por favor ingresa un CURP");
return; return;
} }
const unitCost = parseFloat( await searchMemberByCurp(curp.value.trim());
activeMembership.value.charge_concept.unit_cost_peso || 0 }
);
const quantity = parseInt(paymentData.value.quantity) || 1;
paymentData.value.totalAmount = unitCost * quantity; function openMembershipDetails(membership) {
}; selectedMembership.value = membership;
showMembershipModal.value = true;
}
const handlePayment = async () => { function closeMembershipDetails() {
if (!memberData.value.id) { showMembershipModal.value = false;
Notify.warning("Primero debes buscar un miembro"); selectedMembership.value = null;
}
function openAddChargeModal() {
showAddChargeModal.value = true;
}
function closeAddChargeModal() {
showAddChargeModal.value = false;
}
async function handlePayment({ quantity }) {
if (!memberData.value.id || !selectedMembership.value) {
Notify.warning("No hay una membresía seleccionada");
return; return;
} }
if (!activeMembership.value) { processingPayment.value = true;
Notify.warning("El miembro no tiene membresías activas para pagar");
return;
}
if (paymentData.value.totalAmount <= 0) {
Notify.warning("El monto debe ser mayor a 0");
return;
}
loading.value = true;
await api.put(apiURL(`members/${memberData.value.id}/charge-membership`), { await api.put(apiURL(`members/${memberData.value.id}/charge-membership`), {
data: { data: {
charge_concept_id: activeMembership.value.charge_concept.id, charge_concept_id: selectedMembership.value.charge_concept.id,
quantity: paymentData.value.quantity, quantity,
}, },
onSuccess: (response) => { onSuccess: async (response) => {
Notify.success("Pago de membresía procesado correctamente"); Notify.success("Pago de membresía procesado correctamente");
// Actualizar la membresía con la nueva info
if (response.membership) {
memberData.value.memberships = [response.membership];
}
emit("membership-paid", { emit("membership-paid", {
member: response.member, member: response.member,
membership: response.membership, membership: response.membership,
payment: response.payment, payment: response.payment,
}); });
clearMemberData(); closeMembershipDetails();
await searchMemberByCurp(curp.value.trim(), { notify: false });
}, },
onFail: (error) => { onFail: (error) => {
Notify.warning(error.message || "No se pudo procesar el pago"); Notify.warning(error.message || "No se pudo procesar el pago");
@ -174,102 +149,66 @@ const handlePayment = async () => {
}, },
}); });
loading.value = false; processingPayment.value = false;
}; }
const isMembershipActive = computed(() => { async function handleChargeCreated(response) {
if (!activeMembership.value || !activeMembership.value.expires_at) emit("membership-paid", {
return false; member: response.member,
membership: response.membership,
const expirationDate = new Date(activeMembership.value.expires_at); payment: response.payment,
const now = new Date();
return expirationDate > now;
}); });
// Computed para mostrar días restantes await searchMemberByCurp(curp.value.trim(), { notify: false });
const daysUntilExpiration = computed(() => { }
if (!activeMembership.value || !activeMembership.value.expires_at)
return null;
const expirationDate = new Date(activeMembership.value.expires_at);
const now = new Date();
const diffTime = expirationDate - now;
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays > 0 ? diffDays : 0;
});
const clearMemberData = () => {
memberData.value = {
id: null,
curp: "",
name: "",
tutor: null,
photo: "",
photo_url: "",
memberships: [],
};
paymentData.value = {
membershipId: null,
quantity: 1,
totalAmount: 0,
};
curp.value = "";
};
</script> </script>
<template> <template>
<div class="p-6"> <div class="p-6">
<!-- Formulario de búsqueda --> <div class="mb-6 m-3 max-w-auto rounded-xl bg-white p-6 shadow-lg">
<div class="bg-white rounded-xl p-6 m-3 max-w-auto shadow-lg mb-6"> <h3 class="mb-6 text-xl font-semibold text-gray-800">Buscar miembro</h3>
<h3 class="text-xl font-semibold mb-6 text-gray-800">Buscar miembro</h3>
<form @submit.prevent="handleSearch"> <form @submit.prevent="handleSearch">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-end"> <div class="grid items-end gap-4 md:grid-cols-2">
<!-- CURP -->
<Input <Input
v-model="curp" v-model="curp"
id="CURP" id="CURP"
type="text" type="text"
placeholder="Ej: AMWO0020923" placeholder="Ej: AMWO0020923"
:disabled="loading" :disabled="searching"
/> />
<!-- Botón Buscar -->
<button <button
type="submit" type="submit"
:disabled="loading" :disabled="searching"
class="py-3 px-8 rounded-lg transition-colors h-[42px] bg-[#7a0b3a] hover:bg-[#68082e] text-white font-medium disabled:opacity-50" class="h-[42px] rounded-lg bg-[#7a0b3a] px-8 py-3 text-white font-medium transition-colors hover:bg-[#68082e] disabled:opacity-50"
> >
{{ loading ? "Buscando..." : "Buscar" }} {{ searching ? "Buscando..." : "Buscar" }}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
<!-- Información del Miembro -->
<div <div
v-if="memberData.id" v-if="memberData.id"
class="bg-white rounded-xl p-6 m-3 max-w-auto shadow-lg mb-6" class="mb-6 m-3 max-w-auto rounded-xl bg-white p-6 shadow-lg"
> >
<h3 class="text-xl font-semibold mb-6 text-gray-800"> <h3 class="mb-6 text-xl font-semibold text-gray-800">
Información del Miembro Información del Miembro
</h3> </h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Fotografía -->
<div> <div>
<label class="block text-sm text-gray-600 font-medium mb-2"> <label class="mb-2 block text-sm font-medium text-gray-600">
Fotografía Fotografía
</label> </label>
<div <div
class="w-full h-80 bg-gray-200 rounded-lg flex items-center justify-center overflow-hidden" class="flex h-80 w-full items-center justify-center overflow-hidden rounded-lg bg-gray-200"
> >
<img <img
v-if="memberData.photo_url" v-if="memberData.photo_url"
:src="memberData.photo_url" :src="memberData.photo_url"
:alt="memberData.name" :alt="memberData.name"
class="w-80 h-80 object-cover" class="h-80 w-80 object-cover"
/> />
<svg <svg
v-else v-else
@ -289,7 +228,6 @@ const clearMemberData = () => {
</div> </div>
</div> </div>
<!-- Datos del miembro -->
<div class="space-y-5"> <div class="space-y-5">
<Input <Input
:model-value="memberData.name" :model-value="memberData.name"
@ -297,150 +235,78 @@ const clearMemberData = () => {
type="text" type="text"
disabled disabled
/> />
<Input <Input
:model-value="memberData.curp" :model-value="memberData.curp"
id="CURP" id="CURP"
type="text" type="text"
disabled disabled
/> />
<Input <Input
:model-value="memberData.tutor || 'Sin tutor'" :model-value="memberData.tutor || 'Sin tutor'"
id="Nombre Tutor" id="Nombre Tutor"
type="text" type="text"
disabled disabled
/> />
<div class="rounded-lg bg-primary/5 p-4">
<!-- Información de membresía --> <p class="text-sm text-gray-600">Membresías encontradas</p>
<div v-if="activeMembership" class="space-y-3"> <p class="mt-1 text-2xl font-semibold text-primary">
<div class="p-4 bg-blue-50 rounded-lg"> {{ memberData.memberships.length }}
<p class="text-sm text-gray-600">Tipo de membresía:</p>
<p class="text-lg font-semibold text-gray-800">
{{ activeMembership.charge_concept.name }}
</p> </p>
</div> </div>
<div v-if="expiresAt" class="p-4 bg-yellow-50 rounded-lg">
<p class="text-sm text-gray-600">Expira:</p>
<p class="text-lg font-semibold text-gray-800">{{ expiresAt }}</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Realizar Cobro de Membresía -->
<div <div
v-if="memberData.id && activeMembership" v-if="memberData.id"
class="bg-white rounded-xl p-6 m-3 max-w-auto shadow-lg" class="m-3 max-w-auto rounded-xl bg-white p-6 shadow-lg"
> >
<h3 class="text-xl font-semibold mb-6 text-gray-800"> <div class="mb-6 flex items-center justify-between gap-4">
Realizar Cobro de Membresía
</h3>
<form @submit.prevent="handlePayment">
<div class="grid grid-cols-3 gap-4 mb-6">
<!-- Servicio -->
<div> <div>
<Input <h3 class="text-xl font-semibold text-gray-800">Membresías</h3>
:model-value="activeMembership.charge_concept.name" <p class="text-sm text-gray-500">
id="Servicio" Haz clic en una membresía para ver el detalle.
type="text"
disabled
/>
<p class="text-xs text-gray-500 mt-1">
Costo unitario: ${{
activeMembership.charge_concept.unit_cost_peso
}}
</p> </p>
</div> </div>
<!-- Cantidad -->
<Input
v-model.number="paymentData.quantity"
id="Cantidad"
type="number"
min="1"
:disabled="loading"
@input="calculateTotal"
/>
<!-- Monto Total -->
<Input
:model-value="formattedAmount"
id="Monto Total"
type="text"
disabled
/>
</div>
<!-- Información de membresía -->
<div v-if="activeMembership" class="space-y-3">
<!-- Badge de estado de membresía -->
<div
:class="[
'p-4 rounded-lg border-2',
isMembershipActive
? 'bg-green-50 border-green-500'
: 'bg-red-50 border-red-500',
]"
>
<p
class="text-sm font-semibold"
:class="isMembershipActive ? 'text-green-700' : 'text-red-700'"
>
{{
isMembershipActive
? "✓ Membresía activa"
: "✗ Membresía vencida"
}}
</p>
<p
v-if="isMembershipActive && daysUntilExpiration"
class="text-xs mt-1"
:class="
daysUntilExpiration <= 7 ? 'text-orange-600' : 'text-green-600'
"
>
{{
daysUntilExpiration === 1
? "Vence mañana"
: `Vence en ${daysUntilExpiration} días`
}}
</p>
</div>
<div class="p-4 bg-blue-50 rounded-lg">
<p class="text-sm text-gray-600">Tipo de membresía:</p>
<p class="text-lg font-semibold text-gray-800">
{{ activeMembership.charge_concept.name }}
</p>
</div>
<div v-if="expiresAt" class="p-4 bg-yellow-50 rounded-lg">
<p class="text-sm text-gray-600">
{{ isMembershipActive ? "Expira:" : "Expiró:" }}
</p>
<p class="text-lg font-semibold text-gray-800">{{ expiresAt }}</p>
</div>
</div>
<!-- Botón de pago -->
<button <button
type="submit" type="button"
:disabled="loading || !activeMembership || isMembershipActive" class="rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary/90"
class="w-full bg-green-700 hover:bg-green-600 text-white font-medium py-3.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed" @click="openAddChargeModal"
> >
{{ Agregar cobro
loading
? "Procesando..."
: isMembershipActive
? "Membresía activa - No requiere pago"
: "Renovar membresía"
}}
</button> </button>
</form>
</div> </div>
<div v-if="hasMemberships" class="space-y-4">
<MembershipListItem
v-for="membership in memberData.memberships"
:key="membership.id"
:membership="membership"
@select="openMembershipDetails"
/>
</div>
<div
v-else
class="rounded-xl border border-dashed border-gray-300 bg-gray-50 p-8 text-center text-gray-500"
>
El miembro no tiene membresías registradas.
</div>
</div>
<MembershipDetailModal
:show="showMembershipModal"
:membership="selectedMembership"
:loading="processingPayment"
@close="closeMembershipDetails"
@pay="handlePayment"
/>
<MembershipChargeModal
:show="showAddChargeModal"
:member-id="memberData.id"
@close="closeAddChargeModal"
@charged="handleChargeCreated"
/>
</div> </div>
</template> </template>