// ============================================================================== // SHIFT SaaS - KIOSKVIEW.JSX // TERMINAL DE MARCAJE BIOMÉTRICO CONTINUO 24/7 (QUIOSCO / TÓTEM) // SISTEMA DE DISEÑO OFICIAL SHIFT (SLATE & WHITE, ALTO CONTRASTE, ZERO EMOJIS) // ============================================================================== function KioskView() { const [kioskData, setKioskData] = React.useState(null); const [loading, setLoading] = React.useState(true); const [errorMsg, setErrorMsg] = React.useState(''); const [currentTime, setCurrentTime] = React.useState(new Date()); const [cameraStatus, setCameraStatus] = React.useState('idle'); // 'idle' | 'starting' | 'active' | 'error' const [cameraError, setCameraError] = React.useState(''); const [isFaceDetected, setIsFaceDetected] = React.useState(false); const [lastPunch, setLastPunch] = React.useState(null); const [todayLogs, setTodayLogs] = React.useState([]); const [cooldownUser, setCooldownUser] = React.useState(null); const [isFullscreen, setIsFullscreen] = React.useState(false); const [showSidebar, setShowSidebar] = React.useState(true); const [detectedGesture, setDetectedGesture] = React.useState(null); const videoRef = React.useRef(null); const streamRef = React.useRef(null); const scanIntervalRef = React.useRef(null); const faceMatcherRef = React.useRef(null); const registeredUsersRef = React.useRef([]); const cooldownMapRef = React.useRef({}); const wakeLockRef = React.useRef(null); const isScanningRef = React.useRef(false); const gestureRef = React.useRef(null); const handsModelRef = React.useRef(null); const handsInitAttemptedRef = React.useRef(false); // Parámetros de URL const urlParams = new URLSearchParams(window.location.search); const kioskToken = urlParams.get('kiosk_token') || ''; const forceClientId = urlParams.get('client_id') || (window.SHIFT_CONFIG ? window.SHIFT_CONFIG.clientId : 0); // Reloj digital en vivo sincronizado con zona horaria React.useEffect(() => { const timer = setInterval(() => setCurrentTime(new Date()), 1000); return () => clearInterval(timer); }, []); // Screen Wake Lock API (Mantiene encendida la pantalla de recepción) React.useEffect(() => { const requestWakeLock = async () => { try { if ('wakeLock' in navigator) { wakeLockRef.current = await navigator.wakeLock.request('screen'); } } catch (err) {} }; requestWakeLock(); const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { requestWakeLock(); } }; document.addEventListener('visibilitychange', handleVisibilityChange); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); if (wakeLockRef.current) { try { wakeLockRef.current.release(); } catch(e) {} } }; }, []); // Listener para estado de pantalla completa React.useEffect(() => { const handleFsChange = () => { setIsFullscreen(Boolean(document.fullscreenElement)); }; document.addEventListener('fullscreenchange', handleFsChange); return () => document.removeEventListener('fullscreenchange', handleFsChange); }, []); // Sintetizador acústico de confirmación (Chime armónico) const playChime = React.useCallback((type = 'success') => { try { const AudioCtx = window.AudioContext || window.webkitAudioContext; if (!AudioCtx) return; const ctx = new AudioCtx(); const now = ctx.currentTime; const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'sine'; if (type === 'success') { osc.frequency.setValueAtTime(880, now); osc.frequency.exponentialRampToValueAtTime(1320, now + 0.12); gain.gain.setValueAtTime(0.25, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.45); osc.connect(gain); gain.connect(ctx.destination); osc.start(now); osc.stop(now + 0.45); } else { osc.frequency.setValueAtTime(440, now); gain.gain.setValueAtTime(0.15, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.25); osc.connect(gain); gain.connect(ctx.destination); osc.start(now); osc.stop(now + 0.25); } } catch(e) {} }, []); // Reconstruir descriptores biométricos para FaceMatcher const rebuildFaceMatcher = React.useCallback((users) => { if (!window.faceapi || !users || users.length === 0) return; try { const labeled = users.map(u => { try { let desc = u.face_descriptor; if (typeof desc === 'string') desc = JSON.parse(desc); if (Array.isArray(desc) && desc.length === 128) { return new window.faceapi.LabeledFaceDescriptors(String(u.id), [new Float32Array(desc)]); } } catch (e) {} return null; }).filter(Boolean); if (labeled.length > 0) { faceMatcherRef.current = new window.faceapi.FaceMatcher(labeled, 0.58); } } catch (err) { console.error("Error reconstruyendo matcher biométrico:", err); } }, []); // Cargar datos de la terminal desde la API const loadKioskData = React.useCallback(async (isSilent = false) => { if (!isSilent) setLoading(true); try { const res = await window.apiCall('get_kiosk_data', { kiosk_token: kioskToken, client_id: forceClientId }); if (res && res.success) { setKioskData(res); setTodayLogs(res.today_logs || []); registeredUsersRef.current = res.users || []; rebuildFaceMatcher(res.users || []); setErrorMsg(''); } else { setErrorMsg(res.error || "Terminal de marcaje no identificada o inactiva."); } } catch(err) { if (!isSilent) setErrorMsg("Error de conexión con el servidor."); } finally { if (!isSilent) setLoading(false); } }, [kioskToken, forceClientId, rebuildFaceMatcher]); React.useEffect(() => { loadKioskData(); }, [loadKioskData]); // Sincronización en segundo plano cada 5 minutos React.useEffect(() => { const syncInterval = setInterval(() => { loadKioskData(true); }, 300000); return () => clearInterval(syncInterval); }, [loadKioskData]); // Esperar a que la librería Face-API esté disponible en window const waitForFaceApi = async () => { if (window.faceapi) return window.faceapi; for (let i = 0; i < 40; i++) { await new Promise(r => setTimeout(r, 150)); if (window.faceapi) return window.faceapi; } return null; }; // Cargar modelos neuronales desde /models const ensureModelsLoaded = async () => { const api = await waitForFaceApi(); if (!api) throw new Error("Librería biométrica no disponible."); const MODEL_URL = '/models'; if (!api.nets.ssdMobilenetv1.params) { await Promise.all([ api.nets.ssdMobilenetv1.loadFromUri(MODEL_URL), api.nets.faceLandmark68Net.loadFromUri(MODEL_URL), api.nets.faceRecognitionNet.loadFromUri(MODEL_URL) ]); } return api; }; // Vincular stream de cámara al elemento video DOM const attachStreamToVideo = async (stream) => { if (!videoRef.current) return; const video = videoRef.current; video.srcObject = stream; video.muted = true; video.setAttribute('playsinline', 'true'); video.setAttribute('muted', 'true'); try { await video.play(); } catch (ePlay) { console.warn("Reproducción de video pausada por el navegador:", ePlay); } }; // Cargar dinámicamente MediaPipe Hands si los gestos están habilitados en los ajustes const initGestureRecognition = React.useCallback(async () => { if (handsInitAttemptedRef.current) return; handsInitAttemptedRef.current = true; try { if (!window.Hands) { await new Promise((resolve) => { const script = document.createElement('script'); script.src = "https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js"; script.crossOrigin = "anonymous"; script.onload = () => resolve(true); script.onerror = () => { console.warn("No fue posible cargar el módulo de gestos."); resolve(false); }; document.head.appendChild(script); }); } if (!window.Hands) return; const hands = new window.Hands({ locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}` }); hands.setOptions({ maxNumHands: 1, modelComplexity: 0, minDetectionConfidence: 0.55, minTrackingConfidence: 0.5 }); hands.onResults((results) => { if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) { const lm = results.multiHandLandmarks[0]; const isIndexUp = lm[8].y < lm[6].y; const isMiddleUp = lm[12].y < lm[10].y; const isRingUp = lm[16].y < lm[14].y; const isPinkyUp = lm[20].y < lm[18].y; if (isIndexUp && isMiddleUp && isRingUp && isPinkyUp) { gestureRef.current = 'hand_raise'; setDetectedGesture({ type: 'hand_raise', label: 'Salida (Palma Levantada)' }); } else if (isIndexUp && isMiddleUp && !isRingUp && !isPinkyUp) { gestureRef.current = 'peace_sign'; setDetectedGesture({ type: 'peace_sign', label: 'Almuerzo (Señal V)' }); } else { gestureRef.current = null; setDetectedGesture(null); } } else { gestureRef.current = null; setDetectedGesture(null); } }); handsModelRef.current = hands; } catch (err) { console.warn("Fallo al inicializar detector de gestos:", err); } }, []); // Iniciar bucle de detección facial continuo const startDetectionLoop = React.useCallback((api) => { if (scanIntervalRef.current) clearInterval(scanIntervalRef.current); let frameCounter = 0; scanIntervalRef.current = setInterval(async () => { if (!videoRef.current || videoRef.current.paused || videoRef.current.ended) return; if (videoRef.current.readyState < 2) return; if (!faceMatcherRef.current) return; if (isScanningRef.current) return; try { isScanningRef.current = true; frameCounter++; // Procesamiento de gestos en segundo plano (cada 2 ciclos para optimizar recursos) if (handsModelRef.current && frameCounter % 2 === 0) { try { await handsModelRef.current.send({ image: videoRef.current }); } catch(eH) {} } const detection = await api.detectSingleFace(videoRef.current) .withFaceLandmarks() .withFaceDescriptor(); if (detection) { setIsFaceDetected(true); const bestMatch = faceMatcherRef.current.findBestMatch(detection.descriptor); if (bestMatch && bestMatch.label !== 'unknown') { const matchedId = Number(bestMatch.label); const now = Date.now(); const cooldownExpiry = cooldownMapRef.current[matchedId] || 0; if (now < cooldownExpiry) { const u = registeredUsersRef.current.find(x => Number(x.id) === matchedId); if (u) { setCooldownUser({ name: u.name, secondsLeft: Math.ceil((cooldownExpiry - now) / 1000) }); } } else { const currentGesture = gestureRef.current; await executeKioskPunch(matchedId, currentGesture); gestureRef.current = null; setDetectedGesture(null); } } } else { setIsFaceDetected(false); setCooldownUser(null); gestureRef.current = null; setDetectedGesture(null); } } catch(e) { } finally { isScanningRef.current = false; } }, 320); }, []); // Inicializar Cámara y Reconocimiento Facial const initCameraAndDetection = React.useCallback(async () => { setCameraStatus('starting'); setCameraError(''); try { // 1. Iniciar captura de video primero para feedback visual instantáneo let stream; try { stream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' }, audio: false }); } catch (eIdeal) { stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); } streamRef.current = stream; await attachStreamToVideo(stream); setCameraStatus('active'); // 2. Cargar modelos biométricos const api = await ensureModelsLoaded(); // 3. Reconstruir descriptores biométricos if (registeredUsersRef.current.length > 0) { rebuildFaceMatcher(registeredUsersRef.current); } // 4. Si la empresa tiene habilitados los gestos, inicializar MediaPipe Hands if (kioskData?.settings?.kiosk_gesture_control) { initGestureRecognition(); } // 5. Iniciar bucle de escaneo continuo startDetectionLoop(api); } catch (err) { console.error("Fallo al inicializar terminal:", err); setCameraStatus('error'); setCameraError(err.message || 'No fue posible acceder a la cámara del dispositivo.'); } }, [rebuildFaceMatcher, startDetectionLoop, initGestureRecognition, kioskData]); // Intentar iniciar la cámara automáticamente cuando los datos estén listos React.useEffect(() => { if (!loading && kioskData && cameraStatus === 'idle') { initCameraAndDetection(); } }, [loading, kioskData, cameraStatus, initCameraAndDetection]); // Habilitar gestos si la configuración del quiosco los activa React.useEffect(() => { if (kioskData?.settings?.kiosk_gesture_control && cameraStatus === 'active') { initGestureRecognition(); } }, [kioskData, cameraStatus, initGestureRecognition]); // Asegurar que si el stream está vivo y el ref de video se monta, se vincule React.useEffect(() => { if (streamRef.current && videoRef.current && videoRef.current.srcObject !== streamRef.current) { attachStreamToVideo(streamRef.current); } }); // Limpieza al desmontar el componente React.useEffect(() => { return () => { if (scanIntervalRef.current) { clearInterval(scanIntervalRef.current); scanIntervalRef.current = null; } if (streamRef.current) { streamRef.current.getTracks().forEach(t => t.stop()); streamRef.current = null; } }; }, []); // Registrar marcación en servidor const executeKioskPunch = async (userId, gesture = null) => { const cooldownSeconds = kioskData?.settings?.kiosk_cooldown_seconds || 60; cooldownMapRef.current[userId] = Date.now() + (cooldownSeconds * 1000); try { const res = await window.apiCall('kiosk_punch', { kiosk_token: kioskToken, client_id: kioskData?.company?.id || forceClientId, user_id: userId, cooldown_seconds: cooldownSeconds, gesture: gesture || undefined }); if (res && res.success) { if (kioskData?.settings?.kiosk_audio_feedback !== false) { playChime('success'); } const punchInfo = { action: res.action, user: res.user, timeStr: res.timeStr, isTardy: res.isTardy, minutesLate: res.minutesLate, gesture: gesture }; setLastPunch(punchInfo); setCooldownUser(null); setTodayLogs(prev => [ { id: res.logId || Date.now(), userId: res.user.id, name: res.user.name, alias: res.user.alias, avatarUrl: res.user.avatarUrl, roleName: res.user.roleName, positionTitle: res.user.positionTitle, type: res.action, timeStr: res.timeStr, timestamp: res.timestamp || Date.now() }, ...prev ]); // Mantener el mensaje de confirmación visible por 4 segundos setTimeout(() => { setLastPunch(curr => (curr === punchInfo ? null : curr)); }, 4000); } else if (res && res.is_cooldown) { if (kioskData?.settings?.kiosk_audio_feedback !== false) { playChime('info'); } setCooldownUser({ name: res.user?.name || 'Colaborador', secondsLeft: cooldownSeconds }); } } catch(e) {} }; const toggleFullScreen = () => { if (!document.fullscreenElement) { document.documentElement.requestFullscreen().catch(() => {}); } else { if (document.exitFullscreen) { document.exitFullscreen().catch(() => {}); } } }; if (loading) { return (

Shift Terminal

Iniciando sistema de marcaje 24/7...

); } if (errorMsg && !kioskData) { return (

Terminal No Disponible

{errorMsg}

); } const showLogPanel = kioskData?.settings?.kiosk_show_recent_log !== false; const company = kioskData?.company || {}; const settings = kioskData?.settings || {}; return (
{/* 1. ENCABEZADO SUPERIOR OFICIAL SHIFT */}
{/* Identidad de Empresa */}
{company.logo ? ( {company.name} ) : (
{company.name ? company.name.substring(0, 2).toUpperCase() : 'SH'}
)}

{company.name || 'Terminal de Asistencia'}

{settings.kiosk_name || 'Recepción Principal'} • 24/7
{/* Acciones y Reloj en Vivo */}
{/* Botón de alternancia de bitácora en pantallas no gigantes */} {showLogPanel && ( )} {/* Reloj y Fecha Oficial de Nicaragua */}
{currentTime.toLocaleTimeString('es-NI', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true })}
{currentTime.toLocaleDateString('es-NI', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
{/* Botón Pantalla Completa */}
{/* 2. ÁREA DE CÁMARA Y MARCAJE */}
{/* CONTENEDOR DE CÁMARA BIOMÉTRICA (SIEMPRE 100% DE ALTO) */}
{/* Video de Cámara en Vivo con Espejado */}
); } window.KioskView = KioskView;