add: implementar escáner de códigos QR y agregar soporte en la interfaz de usuario

This commit is contained in:
Juan Felipe Zapata Moreno 2026-01-05 16:31:12 -06:00
parent 62cf6afc42
commit 58a0e5b89e
5 changed files with 242 additions and 0 deletions

64
package-lock.json generated
View File

@ -24,6 +24,7 @@
"vue": "^3.5.13",
"vue-i18n": "^11.1.1",
"vue-multiselect": "^3.2.0",
"vue-qrcode-reader": "^5.7.3",
"vue-router": "^4.5.0",
"ziggy-js": "^2.5.2"
},
@ -1234,6 +1235,18 @@
"vite": "^5.2.0 || ^6"
}
},
"node_modules/@types/dom-webcodecs": {
"version": "0.1.18",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz",
"integrity": "sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==",
"license": "MIT"
},
"node_modules/@types/emscripten": {
"version": "1.41.5",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
@ -1522,6 +1535,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/barcode-detector": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-2.2.2.tgz",
"integrity": "sha512-JcSekql+EV93evfzF9zBr+Y6aRfkR+QFvgyzbwQ0dbymZXoAI9+WgT7H1E429f+3RKNncHz2CW98VQtaaKpmfQ==",
"license": "MIT",
"dependencies": {
"@types/dom-webcodecs": "^0.1.11",
"zxing-wasm": "1.1.3"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
@ -3416,6 +3439,12 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/sdp": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
"license": "MIT"
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
@ -3867,6 +3896,19 @@
"npm": ">= 6.14.15"
}
},
"node_modules/vue-qrcode-reader": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/vue-qrcode-reader/-/vue-qrcode-reader-5.7.3.tgz",
"integrity": "sha512-iSGko42FsEvdHyizBMBs/X+HMO9Z5ONDxjW+mQdoraOR5emRNedmjC5SEJdYzGz8ZP5ME3lwB4iHy3S7MOt5Qw==",
"license": "MIT",
"dependencies": {
"barcode-detector": "2.2.2",
"webrtc-adapter": "8.2.3"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/vue-router": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
@ -3888,6 +3930,19 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/webrtc-adapter": {
"version": "8.2.3",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-8.2.3.tgz",
"integrity": "sha512-gnmRz++suzmvxtp3ehQts6s2JtAGPuDPjA1F3a9ckNpG1kYdYuHWYpazoAnL9FS5/B21tKlhkorbdCXat0+4xQ==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
@ -3937,6 +3992,15 @@
"@types/qs": "^6.9.17",
"qs": "~6.9.7"
}
},
"node_modules/zxing-wasm": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-1.1.3.tgz",
"integrity": "sha512-MYm9k/5YVs4ZOTIFwlRjfFKD0crhefgbnt1+6TEpmKUDFp3E2uwqGSKwQOd2hOIsta/7Usq4hnpNRYTLoljnfA==",
"license": "MIT",
"dependencies": {
"@types/emscripten": "^1.39.10"
}
}
}
}

View File

@ -26,6 +26,7 @@
"vue": "^3.5.13",
"vue-i18n": "^11.1.1",
"vue-multiselect": "^3.2.0",
"vue-qrcode-reader": "^5.7.3",
"vue-router": "^4.5.0",
"ziggy-js": "^2.5.2"
},

View File

@ -0,0 +1,130 @@
<script setup>
import { ref } from 'vue';
import { QrcodeStream } from 'vue-qrcode-reader';
/** Props y Emits */
const props = defineProps({
modelValue: {
type: String,
default: ''
}
});
const emit = defineEmits(['update:modelValue', 'qr-detected']);
/** Refs */
const error = ref('');
const isScanning = ref(true);
/** Métodos */
function paintBoundingBox(detectedCodes, ctx) {
for (const detectedCode of detectedCodes) {
const {
boundingBox: { x, y, width, height }
} = detectedCode;
ctx.lineWidth = 4;
ctx.strokeStyle = '#10b981';
ctx.strokeRect(x, y, width, height);
}
}
function onDetect(detectedCodes) {
if (detectedCodes.length > 0) {
const code = detectedCodes[0].rawValue;
// Emitir el código escaneado al componente padre
emit('update:modelValue', code);
// Pausar el escaneo después de detectar
isScanning.value = false;
// Emitir evento con el código detectado
emit('qr-detected', code);
// Reactivar el escaneo después de 2 segundos
setTimeout(() => {
isScanning.value = true;
error.value = '';
}, 2000);
}
}
function onError(err) {
error.value = `[${err.name}]: `;
if (err.name === 'NotAllowedError') {
error.value += 'Necesitas otorgar permiso de acceso a la cámara';
} else if (err.name === 'NotFoundError') {
error.value += 'No se encontró cámara en este dispositivo';
} else if (err.name === 'NotSupportedError') {
error.value += 'Se requiere contexto seguro';
} else if (err.name === 'NotReadableError') {
error.value += '¿La cámara ya está en uso?';
} else if (err.name === 'OverconstrainedError') {
error.value += 'Las cámaras instaladas no son adecuadas';
} else if (err.name === 'StreamApiNotSupportedError') {
error.value += 'No es compatible con este navegador';
} else if (err.name === 'InsecureContextError') {
error.value += 'El acceso a la cámara solo se permite en contexto seguro.';
} else {
error.value += err.message;
}
console.error('Error de cámara:', err);
}
</script>
<template>
<div class="relative w-full h-full">
<!-- Componente de escaneo QR -->
<QrcodeStream
v-if="isScanning"
@detect="onDetect"
@error="onError"
:track="paintBoundingBox"
class="w-full h-full rounded-lg overflow-hidden"
>
</QrcodeStream>
<!-- Mensaje de éxito -->
<div
v-else
class="w-full h-full rounded-lg bg-green-600 flex items-center justify-center"
>
<div class="text-center text-white">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 mx-auto mb-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<p class="text-lg font-semibold">¡Código detectado!</p>
</div>
</div>
<!-- Mensaje de error -->
<div
v-if="error"
class="absolute bottom-0 left-0 right-0 bg-red-500 text-white p-3 text-sm"
>
{{ error }}
</div>
</div>
</template>
<style scoped>
:deep(video) {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>

View File

@ -451,6 +451,7 @@ export default {
},
pos: {
title: 'Punto de Venta',
subtitle: 'Gestión de ventas y caja',
category: 'Categorías',
inventory: 'Inventario',
prices: 'Precios',

View File

@ -11,6 +11,7 @@ import GoogleIcon from '@Shared/GoogleIcon.vue';
import ProductCard from '@Components/POS/ProductCard.vue';
import CartItem from '@Components/POS/CartItem.vue';
import CheckoutModal from '@Components/POS/CheckoutModal.vue';
import QRscan from '@Components/POS/QRscan.vue';
/** i18n */
const { t } = useI18n();
@ -23,6 +24,7 @@ const products = ref([]);
const showCheckoutModal = ref(false);
const searchQuery = ref('');
const processingPayment = ref(false);
const scanMode = ref(false);
/** Buscador de productos */
const searcher = useSearcher({
@ -79,6 +81,15 @@ const closeCheckout = () => {
showCheckoutModal.value = false;
};
const toggleScanMode = () => {
scanMode.value = !scanMode.value;
if(scanMode.value) {
window.Notify.info('Modo escaneo activado');
} else {
window.Notify.info('Modo escaneo desactivado');
}
};
const handleConfirmSale = async (paymentData) => {
processingPayment.value = true;
@ -187,6 +198,19 @@ onMounted(() => {
</p>
</div>
</div>
<button
@click="toggleScanMode"
:class="[
'flex items-center gap-2 px-4 py-3 rounded-lg font-semibold transition-all',
scanMode
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-indigo-500 hover:bg-indigo-600 text-white'
]"
type="button"
>
<GoogleIcon :name="scanMode ? 'close' : 'qr_code_scanner'" class="text-xl" />
{{ scanMode ? 'Cerrar escáner' : 'Escanear código' }}
</button >
</div>
</div>
@ -194,6 +218,28 @@ onMounted(() => {
<div class="flex gap-6 h-[calc(100%-120px)] px-6">
<!-- Columna de Productos -->
<div class="flex-1 flex flex-col overflow-hidden">
<div v-if="scanMode" class="h-full">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 p-6 h-full">
<div class="flex flex-col h-full">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4 flex items-center gap-2">
<GoogleIcon name="qr_code_scanner" class="text-2xl text-indigo-600" />
Escáner de Códigos de Barras
</h2>
<div class="flex-1 relative bg-gray-100 dark:bg-gray-900 rounded-lg overflow-hidden">
<QRscan
@qr-detected="handleCodeDetected"
class="w-full h-full"
/>
</div>
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p class="text-sm text-blue-800 dark:text-blue-300">
<strong>Instrucciones:</strong> Coloca el código de barras frente a la cámara.
El producto se agregará automáticamente al carrito cuando se detecte.
</p>
</div>
</div>
</div>
</div>
<!-- Buscador -->
<div class="mb-4">
<div class="relative">