MOD: actualizar módulo de entrega de caja y escaneo de QR

This commit is contained in:
Juan Felipe Zapata Moreno 2025-11-24 16:40:50 -06:00
parent bd6edc59d6
commit 5e46e55d73
4 changed files with 580 additions and 153 deletions

View File

@ -1,114 +1,297 @@
<script setup>
import { ref } from "vue";
import { ref, computed } from "vue";
import { apiURL, useApi } from "@/services/Api.js";
import Input from "@Holos/Form/Input.vue";
import QRscan from "./QRscan.vue";
/** Eventos */
const emit = defineEmits(["fine-searched"]);
const emit = defineEmits(["cash-cut-found", "cash-cut-delivered"]);
/** Instancias */
const api = useApi();
/** Refs */
const numero_entrega = ref("");
const fineData = ref({
fecha: "",
numero_entrega: "",
nombre: "",
monto_entrega: "",
const qr_token = ref("");
const loading = ref(false);
const cashCutData = ref({
id: "",
closed_by: "",
start_at: "",
end_at: "",
total_amount: 0,
status: "",
received_at: "",
received_by: "",
isReceived: false,
});
const statusTranslations = {
undelivered: "Sin entregar",
received: "Entregado",
};
const statusInSpanish = computed(() => {
return (
statusTranslations[cashCutData.value.status] || cashCutData.value.status
);
});
const formattedStartDate = computed(() => {
if (!cashCutData.value.start_at) return "";
return cashCutData.value.start_at.slice(0, 16);
});
const formattedEndDate = computed(() => {
if (!cashCutData.value.end_at) return "";
return cashCutData.value.end_at.slice(0, 16);
});
const cashierName = computed(() => {
return cashCutData.value.closed_by?.full_name || "N/A";
});
const formattedAmount = computed(() => {
if (!cashCutData.value.total_amount) return "0.00";
return `$${parseFloat(cashCutData.value.total_amount).toLocaleString(
"es-MX",
{
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}
)}`;
});
/** Métodos */
const handleSearch = () => {
if (!numero_entrega.value.trim()) {
Notify.warning("Por favor ingresa un número de entrega");
const handleQRDetected = async (code) => {
qr_token.value = code;
await searchCashCut();
};
const searchCashCut = async () => {
if (!qr_token.value.trim()) {
Notify.warning("Por favor escanea un código QR o ingresa el token");
return;
}
fineData.value = {
fecha: "2025-11-11",
numero_entrega: numero_entrega.value,
nombre: "Juan Pérez García",
monto_entrega: "$1,500.00",
loading.value = true;
await api.get(apiURL(`cash-cuts/find-by-qr-token`), {
params: {
qr_token: qr_token.value,
},
onSuccess: (response) => {
const model = response.model;
if (model.status === "received") {
Notify.info("La caja ya ha sido entregada previamente");
cashCutData.value = {
id: model.id,
closed_by: model.closed_by,
start_at: model.start_at,
end_at: model.end_at,
total_amount: model.total_amount,
status: model.status,
received_at: model.received_at,
received_by: model.received_by,
isReceived: true,
};
emit("fine-searched", fineData.value);
return;
}
cashCutData.value = {
id: model.id,
closed_by: model.closed_by,
start_at: model.start_at,
end_at: model.end_at,
total_amount: model.total_amount,
status: model.status,
received_at: model.received_at,
received_by: model.received_by,
isReceived: false,
};
Notify.success("Corte de caja encontrado");
emit("cash-cut-found", cashCutData.value);
},
onFail: (error) => {
Notify.warning(error.message || "No se encontró el corte de caja");
},
onError: (error) => {
Notify.error("Error al buscar el corte de caja");
},
});
loading.value = false;
};
const handleDelivery = async () => {
if (!cashCutData.value.id) {
Notify.warning("No hay corte de caja para entregar");
return;
}
if (cashCutData.value.isReceived) {
Notify.info("El corte de caja ya ha sido entregado");
return;
}
await api.put(apiURL(`cash-cuts/${cashCutData.value.id}/mark-as-received`), {
onSuccess: (data) => {
Notify.success("Corte de caja entregado correctamente");
cashCutData.value.status = "received";
emit("cash-cut-delivered", cashCutData.value);
cashCutData.value.isReceived = true;
cashCutData.value.received_at = data.received_at;
cashCutData.value.received_by = data.received_by;
},
onFail: (error) => {
Notify.warning(error.message || "No se pudo entregar el corte de caja");
},
onError: () => {
Notify.error("Error al entregar el corte de caja");
},
});
loading.value = false;
};
const handleSearch = () => {
searchCashCut();
};
const clearForm = () => {
cashCutData.value = {
id: null,
closed_by: null,
start_at: "",
end_at: "",
total_amount: 0,
status: "",
received_at: "",
received_by: "",
isReceived: false,
};
qr_token.value = "";
};
</script>
<template>
<div class="space-y-6 p-6">
<div class="bg-white rounded-xl p-6 shadow-lg">
<h3 class="text-xl font-semibold mb-4 text-gray-800">Buscar Multa</h3>
<h3 class="text-xl font-semibold mb-4 text-gray-800">
Buscar Corte de Caja
</h3>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Scanner QR -->
<div class="mb-6">
<label class="block text-sm text-gray-600 font-medium mb-2">
Escanear Código QR
</label>
<div
class="w-full h-64 bg-gray-800 rounded-lg overflow-hidden"
>
<!-- QR -->
<QRscan
v-model:numero_entrega="numero_entrega"
@fine-searched="handleSearch"
/>
<div class="w-full h-80 bg-gray-800 rounded-lg overflow-hidden">
<QRscan v-model="qr_token" @qr-detected="handleQRDetected" />
</div>
</div>
<!-- Input manual -->
<div class="flex flex-col justify-center">
<div class="mb-5">
<Input
v-model="numero_entrega"
id="Número de entrega"
v-model="qr_token"
id="Token QR"
type="text"
placeholder="Ingrese el número de entrega"
placeholder="Ingrese el token del QR"
@keyup.enter="handleSearch"
/>
</div>
<button
@click="handleSearch"
:disabled="loading"
type="button"
class="w-full bg-[#7a0b3a] hover:bg-[#68082e] text-white font-medium py-3.5 rounded-lg transition-colors"
class="w-full bg-[#7a0b3a] hover:bg-[#68082e] text-white font-medium py-3.5 rounded-lg transition-colors disabled:opacity-50"
>
Buscar
{{ loading ? "Buscando..." : "Buscar" }}
</button>
</div>
</div>
</div>
<div class="bg-white rounded-xl p-6 shadow-lg">
<form @submit.prevent="handlePayment">
<!-- Fecha -->
<!-- Formulario con datos del corte -->
<div v-if="cashCutData.id" class="bg-white rounded-xl p-6 shadow-lg">
<h3 class="text-xl font-semibold mb-4 text-gray-800">
Datos del Corte de Caja
</h3>
<div class="grid grid-cols-2 gap-4 mb-5">
<Input
v-model="fineData.fecha"
id="Fecha de entrega"
type="date"
:model-value="formattedStartDate"
id="Fecha de inicio"
type="datetime-local"
disabled
/>
<Input
:model-value="formattedEndDate"
id="Fecha de cierre"
type="datetime-local"
disabled
/>
</div>
<!-- Nombre -->
<div class="mb-5">
<Input v-model="fineData.nombre" id="Nombre del cajero" type="text"/>
<Input :model-value="cashierName" id="Cajero" type="text" disabled />
</div>
<!-- Monto a Pagar -->
<div class="mb-5">
<Input
v-model="fineData.monto_entrega"
id="Monto a entregar"
:model-value="formattedAmount"
id="Monto total"
type="text"
class="text-lg font-semibold"
disabled
/>
</div>
<!-- Botón Cobrar -->
<div class="mb-5">
<Input
:model-value="statusInSpanish"
id="Estado"
type="text"
disabled
/>
</div>
<div class="space-y-6">
<div v-if="cashCutData.isReceived">
<button
type="button"
@click="clearForm"
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-3.5 rounded-lg transition-colors flex items-center justify-center gap-2"
>
Limpiar y buscar otro corte
</button>
</div>
<!-- Botón recibir -->
<div>
<button
type="submit"
class="w-full bg-green-700 hover:bg-green-600 text-white font-medium py-3.5 rounded-lg transition-colors"
:disabled="cashCutData.isReceived"
:class="[
'w-full font-medium py-3.5 rounded-lg transition-colors',
cashCutData.isReceived
? 'bg-gray-400 cursor-not-allowed'
: 'bg-green-700 hover:bg-green-600 text-white',
]"
>
Entregar caja
Entregar Corte de Caja
</button>
</form>
</div>
</div>
</div>
</div>
</template>

View File

@ -26,7 +26,7 @@ const fineData = ref({
/** Métodos */
const handleSearch = async () => {
if (!fineNumber.value.trim()) {
window.Notify.warning("Por favor ingresa o escanea una multa");
Notify.warning("Por favor ingresa o escanea una multa");
return;
}
@ -38,7 +38,7 @@ const handleSearch = async () => {
const model = data.model;
if (model.status === "paid") {
window.Notify.info("La multa ya ha sido pagada previamente");
Notify.info("La multa ya ha sido pagada previamente");
fineData.value = {
id: model.id,
@ -76,7 +76,7 @@ const handleSearch = async () => {
});
},
onFail: (error) => {
window.Notify.error(error.message || "Error al buscar la multa");
Notify.error(error.message || "Error al buscar la multa");
fineData.value = {
fecha: "",
placa: "",
@ -90,37 +90,37 @@ const handleSearch = async () => {
},
onError: (error) => {
console.error("Error al buscar multa:", error);
window.Notify.error("Ocurrió un error al buscar la multa");
Notify.error("Ocurrió un error al buscar la multa");
},
});
};
const handlePayment = async () => {
if (!fineData.value.monto) {
window.Notify.warning("No hay multa cargada para cobrar");
Notify.warning("No hay multa cargada para cobrar");
return;
}
if (fineData.value.isPaid) {
window.Notify.warning("Esta multa ya ha sido pagada previamente");
Notify.warning("Esta multa ya ha sido pagada previamente");
return;
}
await api.post(apiURL(`fines/${fineData.value.id}/mark-as-paid`), {
onSuccess: (data) => {
window.Notify.success("Multa cobrada exitosamente");
Notify.success("Multa cobrada exitosamente");
emit("payment-processed", { ...fineData.value, paymentData: data });
fineData.value.isPaid = true;
},
onFail: (error) => {
window.Notify.error(
Notify.error(
error.message || "Error al procesar el pago de la multa"
);
},
onError: (error) => {
window.Notify.error("Ocurrió un error al procesar el pago de la multa");
Notify.error("Ocurrió un error al procesar el pago de la multa");
},
});
};

View File

@ -1,75 +1,221 @@
<script setup>
import { ref } from "vue";
import { ref, computed } from "vue";
import { apiURL, useApi } from "@/services/Api.js";
import Input from "@Holos/Form/Input.vue";
import Selectable from "@Holos/Form/Selectable.vue";
/** Eventos */
const emit = defineEmits(["membership-paid"]);
/** Instancias */
const api = useApi();
/** Refs */
const searchForm = ref({
membershipNumber: "",
curp: "",
});
const loading = ref(false);
const curp = ref("");
const memberData = ref({
image: null,
name: "",
id: null,
curp: "",
tutorName: "",
name: "",
tutor: null,
photo: "",
photo_url: "",
memberships: [],
});
const paymentData = ref({
service: [],
membershipId: null,
quantity: 1,
totalAmount: "",
totalAmount: 0,
});
const serviceOptions = ref([
{ id: 1, description: "Membresía Mensual" },
{ id: 2, description: "Membresía Trimestral" },
{ id: 3, description: "Membresía Semestral" },
{ id: 4, description: "Membresía Anual" },
]);
/** Computed */
const activeMembership = computed(() => {
if (
!memberData.value.memberships ||
memberData.value.memberships.length === 0
) {
return null;
}
return memberData.value.memberships[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 = () => {
if (!searchForm.value.membershipNumber && !searchForm.value.curp) {
alert("Por favor ingresa al menos un criterio de búsqueda");
const handleSearch = async () => {
if (!curp.value.trim()) {
Notify.warning("Por favor ingresa un CURP");
return;
}
console.log("Buscando miembro:", searchForm.value);
loading.value = true;
await api.get(apiURL("members/search"), {
params: {
curp: curp.value,
},
onSuccess: (response) => {
const model = response.model;
// Simular datos encontrados
memberData.value = {
name: "Juan Pérez García",
curp: "PEGJ900101HDFRNN09",
tutorName: "María López Hernández",
image: null,
id: model.id,
curp: model.curp,
name: model.name,
tutor: model.tutor,
photo: model.photo,
photo_url: model.photo_url,
memberships: model.memberships || [],
};
};
const handlePayment = () => {
if (!paymentData.value.service) {
alert("Por favor selecciona un servicio");
return;
if (memberData.value.memberships.length === 0) {
Notify.warning("El miembro no tiene membresías activas");
} else {
Notify.success("Miembro encontrado");
// Pre-cargar datos del pago
if (activeMembership.value) {
paymentData.value.membershipId = activeMembership.value.id;
calculateTotal();
}
console.log("Procesando pago de membresía:", {
member: memberData.value,
payment: paymentData.value,
}
},
onFail: (error) => {
Notify.warning(error.message || "No se encontró el miembro con ese CURP");
clearMemberData();
},
onError: () => {
Notify.error("Error al buscar el miembro");
clearMemberData();
},
});
emit("membership-paid", {
member: memberData.value,
payment: paymentData.value,
});
loading.value = false;
};
const calculateTotal = () => {
paymentData.value.totalAmount = "0.00";
if (!activeMembership.value) {
paymentData.value.totalAmount = 0;
return;
}
const unitCost = parseFloat(
activeMembership.value.charge_concept.unit_cost_peso || 0
);
const quantity = parseInt(paymentData.value.quantity) || 1;
paymentData.value.totalAmount = unitCost * quantity;
};
const handlePayment = async () => {
if (!memberData.value.id) {
Notify.warning("Primero debes buscar un miembro");
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;
await api.put(apiURL(`members/${memberData.value.id}/charge-membership`), {
data: {
charge_concept_id: activeMembership.value.charge_concept.id,
quantity: paymentData.value.quantity,
},
onSuccess: (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();
},
onFail: (error) => {
Notify.warning(error.message || "No se pudo procesar el pago");
},
onError: () => {
Notify.error("Error al procesar el pago");
},
});
loading.value = false;
};
const isMembershipActive = computed(() => {
if (!activeMembership.value || !activeMembership.value.expires_at)
return false;
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 = "";
};
</script>
@ -79,36 +225,33 @@ const calculateTotal = () => {
<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>
<form @submit.prevent="handleSearch">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<!-- Número de Membresía -->
<Input
v-model="searchForm.membershipNumber"
id="Número de Membresía"
type="text"
placeholder=""
/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-end">
<!-- CURP -->
<Input
v-model="searchForm.curp"
v-model="curp"
id="CURP"
type="text"
placeholder=""
placeholder="Ej: AMWO0020923"
:disabled="loading"
/>
<!-- Botón Buscar -->
<button
type="submit"
class="py-3 px-8 rounded-lg transition-colors h-[42px] bg-[#7a0b3a] hover:bg-[#68082e] text-white font-medium"
: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"
>
Buscar
{{ loading ? "Buscando..." : "Buscar" }}
</button>
</div>
</form>
</div>
<!-- Información del Miembro -->
<div class="bg-white rounded-xl p-6 m-3 max-w-auto shadow-lg mb-6">
<div
v-if="memberData.id"
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">
Información del Miembro
</h3>
@ -120,9 +263,16 @@ const calculateTotal = () => {
Fotografía
</label>
<div
class="w-full h-64 bg-gray-200 rounded-lg flex items-center justify-center"
class="w-full h-80 bg-gray-200 rounded-lg flex items-center justify-center overflow-hidden"
>
<img
v-if="memberData.photo_url"
:src="memberData.photo_url"
:alt="memberData.name"
class="w-80 h-80 object-cover"
/>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
class="h-24 w-24 text-gray-400"
fill="none"
@ -141,60 +291,154 @@ const calculateTotal = () => {
<!-- Datos del miembro -->
<div class="space-y-5">
<Input v-model="memberData.name" id="Nombre" type="text" disabled />
<Input v-model="memberData.curp" id="CURP" type="text" disabled />
<Input
:model-value="memberData.name"
id="Nombre"
type="text"
disabled
/>
<Input
v-model="memberData.tutorName"
: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 }}
</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 class="bg-white rounded-xl p-6 m-3 max-w-auto shadow-lg">
<div
v-if="memberData.id && activeMembership"
class="bg-white rounded-xl p-6 m-3 max-w-auto 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 a Pagar -->
<Selectable
v-model="paymentData.service"
label="description"
title="Servicio a pagar"
:options="serviceOptions"
<!-- Servicio -->
<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
}}
</p>
</div>
<!-- Cantidad -->
<Input
v-model="paymentData.quantity"
id="Cantidad (días)"
v-model.number="paymentData.quantity"
id="Cantidad"
type="number"
min="1"
:disabled="loading"
@input="calculateTotal"
/>
<!-- Monto Total -->
<Input
v-model="paymentData.totalAmount"
:model-value="formattedAmount"
id="Monto Total"
type="text"
disabled
/>
</div>
<!-- Botón Cobrar -->
<!-- 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"
class="w-full bg-green-700 hover:bg-green-600 text-white font-medium py-3.5 rounded-lg transition-colors"
: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"
>
Pagar membresía
{{
loading
? "Procesando..."
: isMembershipActive
? "Membresía activa - No requiere pago"
: "Renovar membresía"
}}
</button>
</form>
</div>

View File

@ -3,14 +3,14 @@ import { ref } from 'vue';
import { QrcodeStream } from 'vue-qrcode-reader';
/** Props y Emits */
defineProps({
fineNumber: {
const props = defineProps({
modelValue: {
type: String,
default: ''
}
});
const emit = defineEmits(['update:fineNumber', 'fine-searched']);
const emit = defineEmits(['update:modelValue', 'qr-detected']);
/** Refs */
const error = ref('');
@ -34,13 +34,13 @@ function onDetect(detectedCodes) {
const code = detectedCodes[0].rawValue;
// Emitir el código escaneado al componente padre
emit('update:fineNumber', code);
emit('update:modelValue', code);
// Pausar el escaneo después de detectar
isScanning.value = false;
// Emitir evento para buscar la multa
emit('fine-searched');
// Emitir evento con el código detectado
emit('qr-detected', code);
// Reactivar el escaneo después de 2 segundos
setTimeout(() => {