// ==============================================================================
// 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}
window.location.reload()}
className="px-6 py-2.5 bg-slate-900 hover:bg-slate-800 text-white text-xs font-bold rounded-xl transition cursor-pointer shadow-sm"
>
Reintentar Conexión
);
}
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.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 && (
setShowSidebar(!showSidebar)}
className="hidden md:inline-flex items-center gap-1.5 px-3 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-xs font-bold transition border border-slate-200/80 cursor-pointer"
>
Actividad ({todayLogs.length})
)}
{/* 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 */}
{isFullscreen ? (
) : (
)}
{/* 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 */}
{/* Gradiente sutil superior e inferior para legibilidad */}
{/* MARCO DE ENFOQUE FACIAL PROPORCIONADO Y REACTIVO */}
{/* Esquinas arquitectónicas */}
{/* Badge superior del marco */}
{cooldownUser ? 'Marca Registrada' : (isFaceDetected ? 'Rostro Detectado' : 'Terminal de Marcaje')}
{detectedGesture && (
Gesto: {detectedGesture.label}
)}
{/* Aviso dinámico central al detectar (solo si no está en enfriamiento ni recién marcado) */}
{isFaceDetected && !lastPunch && !cooldownUser && (
Identificando Colaborador...
)}
{/* Prompt inferior del marco */}
{cooldownUser ? 'Puede continuar. Siguiente colaborador.' : (isFaceDetected ? 'Manténgase frente a la cámara' : 'Posicione su rostro frente a la terminal')}
{/* Barra de estado en esquina inferior de la cámara */}
{cameraStatus === 'active' ? 'Terminal Lista' : 'Iniciando Cámara...'}
{registeredUsersRef.current.length} colaboradores activos
{/* ESTADO DE CONEXIÓN O REINTENTO MANUAL DE CÁMARA */}
{cameraStatus !== 'active' && (
Cámara en Espera
{cameraError || "Presione el botón para conectar y activar la cámara frontal de la terminal."}
{cameraStatus === 'starting' ? (
<>
Conectando Terminal...
>
) : (
Iniciar Cámara de Marcaje
)}
)}
{/* CONFIRMACIÓN FLOTANTE NO BLOQUEANTE DE MARCAJE EXITOSO (ALTO IMPACTO Y FLUJO RÁPIDO) */}
{lastPunch && (
{lastPunch.action === 'entrada'
? 'Entrada Registrada'
: (lastPunch.action === 'inicio_almuerzo'
? 'Salida a Almuerzo'
: (lastPunch.action === 'fin_almuerzo' ? 'Retorno de Almuerzo' : 'Salida Registrada'))}
{lastPunch.user?.name || 'Colaborador'}
{lastPunch.user?.positionTitle || lastPunch.user?.roleName || 'Personal'}
{lastPunch.gesture && (
Marcaje asistido por gesto: {lastPunch.gesture === 'hand_raise' ? 'Palma Levantada' : 'Señal V'}
)}
Hora Oficial
{lastPunch.timeStr}
Puntualidad
{lastPunch.isTardy ? `Tardía (+${lastPunch.minutesLate} min)` : 'A Tiempo'}
)}
{/* AVISO FLOTANTE CLARO SI YA MARCÓ HACE INSTANTES (COOLDOWN) */}
{cooldownUser && !lastPunch && (
{cooldownUser.name}
Tu marca ya fue registrada. Continúa el siguiente compañero.
)}
{/* 3. BITÁCORA LATERAL OFICIAL SHIFT (ADAPTABLE A CUALQUIER RESOLUCIÓN) */}
{showLogPanel && showSidebar && (
Actividad de Hoy
Bitácora pública en tiempo real
{todayLogs.length}
{todayLogs.length === 0 ? (
Sin marcajes registrados hoy
Las marcas del personal se reflejarán aquí de inmediato.
) : (
todayLogs.map((log) => {
let badgeLabel = 'Salida';
let badgeColor = 'bg-slate-100 text-slate-700 border border-slate-200';
if (log.type === 'entrada') {
badgeLabel = 'Entrada';
badgeColor = 'bg-emerald-100 text-emerald-800 border border-emerald-200/80';
} else if (log.type === 'inicio_almuerzo') {
badgeLabel = 'Almuerzo';
badgeColor = 'bg-amber-100 text-amber-800 border border-amber-200/80';
} else if (log.type === 'fin_almuerzo') {
badgeLabel = 'Fin Almuerzo';
badgeColor = 'bg-sky-100 text-sky-800 border border-sky-200/80';
}
return (
{log.avatarUrl ? (
) : (
{log.name ? log.name.substring(0, 2).toUpperCase() : 'CO'}
)}
{log.name}
{log.positionTitle || log.roleName || 'Colaborador'}
{badgeLabel}
{log.timeStr}
);
})
)}
)}
);
}
window.KioskView = KioskView;