FIX:Busqueda de membresías
This commit is contained in:
parent
104e2e327e
commit
f903f74fe4
338
src/components/App/MembershipChargeModal.vue
Normal file
338
src/components/App/MembershipChargeModal.vue
Normal 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>
|
||||
388
src/components/App/MembershipDetailModal.vue
Normal file
388
src/components/App/MembershipDetailModal.vue
Normal 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>
|
||||
160
src/components/App/MembershipListItem.vue
Normal file
160
src/components/App/MembershipListItem.vue
Normal 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>
|
||||
@ -1,20 +1,24 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { apiURL, useApi } from "@/services/Api.js";
|
||||
|
||||
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"]);
|
||||
|
||||
/** Instancias */
|
||||
const api = useApi();
|
||||
|
||||
/** Refs */
|
||||
const loading = ref(false);
|
||||
const searching = ref(false);
|
||||
const processingPayment = ref(false);
|
||||
const curp = ref("");
|
||||
const showAddChargeModal = ref(false);
|
||||
const showMembershipModal = ref(false);
|
||||
const selectedMembership = ref(null);
|
||||
|
||||
const memberData = ref({
|
||||
const emptyMemberData = () => ({
|
||||
id: null,
|
||||
curp: "",
|
||||
name: "",
|
||||
@ -24,61 +28,14 @@ const memberData = ref({
|
||||
memberships: [],
|
||||
});
|
||||
|
||||
const paymentData = ref({
|
||||
membershipId: null,
|
||||
quantity: 1,
|
||||
totalAmount: 0,
|
||||
});
|
||||
const memberData = ref(emptyMemberData());
|
||||
|
||||
/** Computed */
|
||||
const activeMembership = computed(() => {
|
||||
if (
|
||||
!memberData.value.memberships ||
|
||||
memberData.value.memberships.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return memberData.value.memberships[0];
|
||||
});
|
||||
const hasMemberships = computed(
|
||||
() => memberData.value.memberships && memberData.value.memberships.length > 0
|
||||
);
|
||||
|
||||
const expiresAt = computed(() => {
|
||||
if (!activeMembership.value || !activeMembership.value.expires_at)
|
||||
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 = {
|
||||
function normalizeMember(model) {
|
||||
return {
|
||||
id: model.id,
|
||||
curp: model.curp,
|
||||
name: model.name,
|
||||
@ -87,16 +44,35 @@ const handleSearch = async () => {
|
||||
photo_url: model.photo_url,
|
||||
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) {
|
||||
Notify.warning("El miembro no tiene membresías activas");
|
||||
Notify.warning("El miembro no tiene membresías registradas");
|
||||
} else {
|
||||
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 = () => {
|
||||
if (!activeMembership.value) {
|
||||
paymentData.value.totalAmount = 0;
|
||||
async function handleSearch() {
|
||||
if (!curp.value.trim()) {
|
||||
Notify.warning("Por favor ingresa un CURP");
|
||||
return;
|
||||
}
|
||||
|
||||
const unitCost = parseFloat(
|
||||
activeMembership.value.charge_concept.unit_cost_peso || 0
|
||||
);
|
||||
const quantity = parseInt(paymentData.value.quantity) || 1;
|
||||
await searchMemberByCurp(curp.value.trim());
|
||||
}
|
||||
|
||||
paymentData.value.totalAmount = unitCost * quantity;
|
||||
};
|
||||
function openMembershipDetails(membership) {
|
||||
selectedMembership.value = membership;
|
||||
showMembershipModal.value = true;
|
||||
}
|
||||
|
||||
const handlePayment = async () => {
|
||||
if (!memberData.value.id) {
|
||||
Notify.warning("Primero debes buscar un miembro");
|
||||
function closeMembershipDetails() {
|
||||
showMembershipModal.value = false;
|
||||
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;
|
||||
}
|
||||
|
||||
if (!activeMembership.value) {
|
||||
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;
|
||||
processingPayment.value = true;
|
||||
|
||||
await api.put(apiURL(`members/${memberData.value.id}/charge-membership`), {
|
||||
data: {
|
||||
charge_concept_id: activeMembership.value.charge_concept.id,
|
||||
quantity: paymentData.value.quantity,
|
||||
charge_concept_id: selectedMembership.value.charge_concept.id,
|
||||
quantity,
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
onSuccess: async (response) => {
|
||||
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", {
|
||||
member: response.member,
|
||||
membership: response.membership,
|
||||
payment: response.payment,
|
||||
});
|
||||
|
||||
clearMemberData();
|
||||
closeMembershipDetails();
|
||||
await searchMemberByCurp(curp.value.trim(), { notify: false });
|
||||
},
|
||||
onFail: (error) => {
|
||||
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(() => {
|
||||
if (!activeMembership.value || !activeMembership.value.expires_at)
|
||||
return false;
|
||||
async function handleChargeCreated(response) {
|
||||
emit("membership-paid", {
|
||||
member: response.member,
|
||||
membership: response.membership,
|
||||
payment: response.payment,
|
||||
});
|
||||
|
||||
const expirationDate = new Date(activeMembership.value.expires_at);
|
||||
const now = new Date();
|
||||
|
||||
return expirationDate > now;
|
||||
});
|
||||
|
||||
// Computed para mostrar días restantes
|
||||
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 = "";
|
||||
};
|
||||
await searchMemberByCurp(curp.value.trim(), { notify: false });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<!-- Formulario de búsqueda -->
|
||||
<div class="bg-white rounded-xl p-6 m-3 max-w-auto shadow-lg mb-6">
|
||||
<h3 class="text-xl font-semibold mb-6 text-gray-800">Buscar miembro</h3>
|
||||
<div class="mb-6 m-3 max-w-auto rounded-xl bg-white p-6 shadow-lg">
|
||||
<h3 class="mb-6 text-xl font-semibold text-gray-800">Buscar miembro</h3>
|
||||
<form @submit.prevent="handleSearch">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-end">
|
||||
<!-- CURP -->
|
||||
<div class="grid items-end gap-4 md:grid-cols-2">
|
||||
<Input
|
||||
v-model="curp"
|
||||
id="CURP"
|
||||
type="text"
|
||||
placeholder="Ej: AMWO0020923"
|
||||
:disabled="loading"
|
||||
:disabled="searching"
|
||||
/>
|
||||
|
||||
<!-- Botón Buscar -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="py-3 px-8 rounded-lg transition-colors h-[42px] bg-[#7a0b3a] hover:bg-[#68082e] text-white font-medium disabled:opacity-50"
|
||||
:disabled="searching"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Información del Miembro -->
|
||||
<div
|
||||
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
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Fotografía -->
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<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
|
||||
</label>
|
||||
<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
|
||||
v-if="memberData.photo_url"
|
||||
:src="memberData.photo_url"
|
||||
:alt="memberData.name"
|
||||
class="w-80 h-80 object-cover"
|
||||
class="h-80 w-80 object-cover"
|
||||
/>
|
||||
<svg
|
||||
v-else
|
||||
@ -289,7 +228,6 @@ const clearMemberData = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Datos del miembro -->
|
||||
<div class="space-y-5">
|
||||
<Input
|
||||
:model-value="memberData.name"
|
||||
@ -297,150 +235,78 @@ const clearMemberData = () => {
|
||||
type="text"
|
||||
disabled
|
||||
/>
|
||||
|
||||
<Input
|
||||
:model-value="memberData.curp"
|
||||
id="CURP"
|
||||
type="text"
|
||||
disabled
|
||||
/>
|
||||
|
||||
<Input
|
||||
:model-value="memberData.tutor || 'Sin tutor'"
|
||||
id="Nombre Tutor"
|
||||
type="text"
|
||||
disabled
|
||||
/>
|
||||
|
||||
<!-- Información de membresía -->
|
||||
<div v-if="activeMembership" class="space-y-3">
|
||||
<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 }}
|
||||
<div class="rounded-lg bg-primary/5 p-4">
|
||||
<p class="text-sm text-gray-600">Membresías encontradas</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-primary">
|
||||
{{ memberData.memberships.length }}
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<!-- Realizar Cobro de Membresía -->
|
||||
<div
|
||||
v-if="memberData.id && activeMembership"
|
||||
class="bg-white rounded-xl p-6 m-3 max-w-auto shadow-lg"
|
||||
v-if="memberData.id"
|
||||
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">
|
||||
Realizar Cobro de Membresía
|
||||
</h3>
|
||||
|
||||
<form @submit.prevent="handlePayment">
|
||||
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||
<!-- Servicio -->
|
||||
<div class="mb-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<Input
|
||||
:model-value="activeMembership.charge_concept.name"
|
||||
id="Servicio"
|
||||
type="text"
|
||||
disabled
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Costo unitario: ${{
|
||||
activeMembership.charge_concept.unit_cost_peso
|
||||
}}
|
||||
<h3 class="text-xl font-semibold text-gray-800">Membresías</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
Haz clic en una membresía para ver el detalle.
|
||||
</p>
|
||||
</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
|
||||
type="submit"
|
||||
:disabled="loading || !activeMembership || isMembershipActive"
|
||||
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"
|
||||
type="button"
|
||||
class="rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary/90"
|
||||
@click="openAddChargeModal"
|
||||
>
|
||||
{{
|
||||
loading
|
||||
? "Procesando..."
|
||||
: isMembershipActive
|
||||
? "Membresía activa - No requiere pago"
|
||||
: "Renovar membresía"
|
||||
}}
|
||||
Agregar cobro
|
||||
</button>
|
||||
</form>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user