536 lines
20 KiB
React
536 lines
20 KiB
React
import React, { useState, useCallback, useRef } from 'react';
|
|
import { BarcodeScanner, BarcodeFormat } from '@capacitor-mlkit/barcode-scanning';
|
|
import { Capacitor } from '@capacitor/core';
|
|
import { BrowserMultiFormatReader } from '@zxing/browser';
|
|
import { BarcodeFormat as ZxingFormat, DecodeHintType } from '@zxing/library';
|
|
import './ScannerView.css';
|
|
|
|
const CIP_REGEX = /^[A-Z0-9]{8,30}$/i;
|
|
|
|
function playBeep() {
|
|
try {
|
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.type = 'sine';
|
|
osc.frequency.setValueAtTime(1046, ctx.currentTime);
|
|
osc.frequency.exponentialRampToValueAtTime(1318, ctx.currentTime + 0.08);
|
|
gain.gain.setValueAtTime(0.28, ctx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.35);
|
|
osc.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
osc.start();
|
|
osc.stop(ctx.currentTime + 0.38);
|
|
} catch (_) { }
|
|
}
|
|
|
|
function ScannerView({ onClose, onSelectMedicine }) {
|
|
const videoRef = useRef(null);
|
|
const streamRef = useRef(null);
|
|
const rafRef = useRef(null);
|
|
const detectorRef = useRef(null);
|
|
|
|
// Log de diagnóstico al montar
|
|
React.useEffect(() => {
|
|
console.log('[Scanner] BarcodeDetector global:', typeof BarcodeDetector);
|
|
console.log('[Scanner] Navegador:', navigator.userAgent);
|
|
}, []);
|
|
|
|
const [phase, setPhase] = useState('idle');
|
|
const [manualCip, setManualCip] = useState('');
|
|
const [prescriptions, setPrescriptions] = useState([]);
|
|
const [scannedCip, setScannedCip] = useState(null);
|
|
const [loadingPrescriptions, setLoadingPrescriptions] = useState(false);
|
|
const [errorMsg, setErrorMsg] = useState('');
|
|
|
|
// Photo upload state
|
|
const [previewUrl, setPreviewUrl] = useState(null);
|
|
const [ocrLoading, setOcrLoading] = useState(false);
|
|
const cameraInputRef = useRef(null);
|
|
const galleryInputRef = useRef(null);
|
|
|
|
const isNative = Capacitor.isNativePlatform();
|
|
|
|
const fetchPrescriptions = useCallback(async (cip) => {
|
|
setLoadingPrescriptions(true);
|
|
try {
|
|
const res = await fetch(`/api/tsi/${encodeURIComponent(cip)}/prescriptions`);
|
|
const data = await res.json();
|
|
setPrescriptions(data);
|
|
} catch {
|
|
setPrescriptions([]);
|
|
} finally {
|
|
setLoadingPrescriptions(false);
|
|
}
|
|
}, []);
|
|
|
|
function stopCamera() {
|
|
streamRef.current?.getTracks().forEach(t => t.stop());
|
|
streamRef.current = null;
|
|
if (rafRef.current) {
|
|
cancelAnimationFrame(rafRef.current);
|
|
rafRef.current = null;
|
|
}
|
|
}
|
|
|
|
async function handleNativeScan() {
|
|
setErrorMsg('');
|
|
try {
|
|
const supported = await BarcodeScanner.isSupported();
|
|
if (!supported) {
|
|
setErrorMsg('El escáner no está disponible en este dispositivo.');
|
|
setPhase('error');
|
|
return;
|
|
}
|
|
|
|
const permission = await BarcodeScanner.checkPermissions();
|
|
if (permission.camera !== 'granted') {
|
|
const request = await BarcodeScanner.requestPermissions();
|
|
if (request.camera !== 'granted') {
|
|
setErrorMsg('Permiso de cámara denegado. Actívalo en ajustes o introduce el código CIP manualmente.');
|
|
setPhase('error');
|
|
return;
|
|
}
|
|
}
|
|
|
|
setPhase('scanning');
|
|
|
|
const result = await BarcodeScanner.scan({
|
|
formats: [BarcodeFormat.PDF417, BarcodeFormat.Code128, BarcodeFormat.QrCode],
|
|
autoZoom: true,
|
|
});
|
|
|
|
const barcode = result.barcodes[0];
|
|
const rawValue = barcode?.rawValue;
|
|
|
|
if (!rawValue || !CIP_REGEX.test(rawValue)) {
|
|
setErrorMsg('Código de barras inválido. Intenta de nuevo o introduce el CIP manualmente.');
|
|
setPhase('error');
|
|
return;
|
|
}
|
|
|
|
playBeep();
|
|
setScannedCip(rawValue);
|
|
setPhase('prescriptions');
|
|
fetchPrescriptions(rawValue);
|
|
} catch (err) {
|
|
if (err?.message?.includes('cancel') || err?.message?.includes('User')) {
|
|
setPhase('idle');
|
|
return;
|
|
}
|
|
setErrorMsg(`Error al escanear: ${err.message || 'Error desconocido'}`);
|
|
setPhase('error');
|
|
}
|
|
}
|
|
|
|
async function handleWebScan() {
|
|
setErrorMsg('');
|
|
console.log('[Scanner] Iniciando escaneo web...');
|
|
try {
|
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
setErrorMsg('Cámara no disponible en este navegador.');
|
|
setPhase('error');
|
|
return;
|
|
}
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
|
|
});
|
|
console.log('[Scanner] Stream de cámara obtenido');
|
|
|
|
streamRef.current = stream;
|
|
setPhase('scanning');
|
|
|
|
await new Promise((resolve) => {
|
|
const waitForVideo = () => {
|
|
if (videoRef.current) {
|
|
videoRef.current.srcObject = stream;
|
|
videoRef.current.play().then(() => {
|
|
console.log('[Scanner] Video reproduciéndose');
|
|
console.log('[Scanner] Video dims:', videoRef.current.videoWidth, 'x', videoRef.current.videoHeight);
|
|
resolve();
|
|
}).catch((e) => {
|
|
console.error('[Scanner] Error al reproducir video:', e);
|
|
resolve();
|
|
});
|
|
} else {
|
|
requestAnimationFrame(waitForVideo);
|
|
}
|
|
};
|
|
requestAnimationFrame(waitForVideo);
|
|
});
|
|
|
|
console.log('[Scanner] Usando zxing local (@zxing/browser)...');
|
|
const hints = new Map();
|
|
hints.set(DecodeHintType.POSSIBLE_FORMATS, [
|
|
ZxingFormat.CODE_128,
|
|
ZxingFormat.CODE_39,
|
|
ZxingFormat.CODE_93,
|
|
ZxingFormat.PDF_417,
|
|
ZxingFormat.QR_CODE,
|
|
ZxingFormat.EAN_13,
|
|
ZxingFormat.EAN_8,
|
|
ZxingFormat.UPC_A,
|
|
ZxingFormat.UPC_E,
|
|
ZxingFormat.ITF,
|
|
]);
|
|
hints.set(DecodeHintType.TRY_HARDER, true);
|
|
const zxingReader = new BrowserMultiFormatReader(hints);
|
|
detectorRef.current = zxingReader;
|
|
|
|
const formatNames = { 0:'AZTEC', 1:'CODABAR', 2:'CODE_39', 3:'CODE_93', 4:'CODE_128', 5:'DATA_MATRIX', 6:'EAN_8', 7:'EAN_13', 8:'ITF', 10:'PDF_417', 11:'QR_CODE', 12:'RSS_14', 14:'UPC_A', 15:'UPC_E' };
|
|
|
|
console.log('[Scanner] Iniciando decodeFromVideoElement...');
|
|
const controls = await zxingReader.decodeFromVideoElement(videoRef.current, (result, error) => {
|
|
if (result) {
|
|
const rawValue = result.getText();
|
|
const cleaned = rawValue.replace(/[^A-Z0-9]/gi, '');
|
|
const format = result.getBarcodeFormat();
|
|
console.log('[Scanner] zxing detectó! raw:', rawValue, '| limpio:', cleaned, '| formato:', formatNames[format] || format);
|
|
if (cleaned && CIP_REGEX.test(cleaned)) {
|
|
console.log('[Scanner] CIP válido ✓');
|
|
controls.stop();
|
|
stopCamera();
|
|
playBeep();
|
|
setScannedCip(cleaned);
|
|
setPhase('prescriptions');
|
|
fetchPrescriptions(cleaned);
|
|
} else if (rawValue) {
|
|
console.log('[Scanner] Código no cumple regex:', cleaned);
|
|
}
|
|
}
|
|
});
|
|
console.log('[Scanner] Escaneando...');
|
|
|
|
} catch (err) {
|
|
console.error('[Scanner] Error general:', err);
|
|
stopCamera();
|
|
if (err.name === 'NotAllowedError') {
|
|
setErrorMsg('Permiso de cámara denegado. Permite el acceso e intenta de nuevo.');
|
|
} else if (err.name === 'NotFoundError') {
|
|
setErrorMsg('No se detectó ninguna cámara. Introduce el código CIP manualmente.');
|
|
} else {
|
|
setErrorMsg(`Error de cámara: ${err.message || 'Error desconocido'}`);
|
|
}
|
|
setPhase('error');
|
|
}
|
|
}
|
|
|
|
function handleStartScan() {
|
|
if (isNative) {
|
|
handleNativeScan();
|
|
} else {
|
|
handleWebScan();
|
|
}
|
|
}
|
|
|
|
function handleManualSubmit(e) {
|
|
e.preventDefault();
|
|
const cip = manualCip.trim();
|
|
if (!cip) {
|
|
setErrorMsg('Introduce un código CIP.');
|
|
setPhase('error');
|
|
return;
|
|
}
|
|
if (!CIP_REGEX.test(cip)) {
|
|
setErrorMsg('Formato CIP inválido. Debe tener 16 caracteres alfanuméricos.');
|
|
setPhase('error');
|
|
return;
|
|
}
|
|
playBeep();
|
|
setScannedCip(cip);
|
|
setPhase('prescriptions');
|
|
fetchPrescriptions(cip);
|
|
}
|
|
|
|
// Photo upload handlers
|
|
function handlePhotoSelect(e) {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setErrorMsg('');
|
|
setPreviewUrl(URL.createObjectURL(file));
|
|
setPhase('photo-preview');
|
|
// Reset input so selecting the same file again triggers onChange
|
|
e.target.value = '';
|
|
}
|
|
|
|
async function handlePhotoOcr() {
|
|
if (!previewUrl) return;
|
|
setOcrLoading(true);
|
|
setErrorMsg('');
|
|
try {
|
|
const res = await fetch(previewUrl);
|
|
const blob = await res.blob();
|
|
const formData = new FormData();
|
|
formData.append('photo', blob, 'tsi.jpg');
|
|
|
|
const ocrRes = await fetch('/api/tsi/ocr', { method: 'POST', body: formData });
|
|
const data = await ocrRes.json();
|
|
|
|
if (!ocrRes.ok) {
|
|
setErrorMsg(data.error || 'No se pudo leer la imagen. Intenta con otra foto.');
|
|
setPhase('error');
|
|
return;
|
|
}
|
|
|
|
const cip = data.cip;
|
|
if (!CIP_REGEX.test(cip)) {
|
|
setErrorMsg(`CIP detectado "${cip}" no tiene formato válido. Introduce el código manualmente.`);
|
|
setPhase('error');
|
|
return;
|
|
}
|
|
|
|
playBeep();
|
|
setScannedCip(cip);
|
|
setPreviewUrl(null);
|
|
setPhase('prescriptions');
|
|
fetchPrescriptions(cip);
|
|
} catch (err) {
|
|
setErrorMsg(`Error al procesar la imagen: ${err.message || 'Error desconocido'}`);
|
|
setPhase('error');
|
|
} finally {
|
|
setOcrLoading(false);
|
|
}
|
|
}
|
|
|
|
function handlePhotoDiscard() {
|
|
setPreviewUrl(null);
|
|
setPhase('idle');
|
|
}
|
|
|
|
function handlePickPrescription(rx) {
|
|
stopCamera();
|
|
onSelectMedicine(rx.name);
|
|
}
|
|
|
|
function handleBack() {
|
|
stopCamera();
|
|
if (phase === 'prescriptions') {
|
|
setPhase('idle');
|
|
setPrescriptions([]);
|
|
setScannedCip(null);
|
|
} else if (phase === 'photo-preview') {
|
|
setPreviewUrl(null);
|
|
setPhase('idle');
|
|
} else {
|
|
onClose();
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="scanner-view">
|
|
<div className="scanner-content">
|
|
<div className="scanner-hero">
|
|
<div className="scanner-icon-circle">
|
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
|
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
|
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
|
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
|
<path d="M7 8v8M11 8v8M15 8v8M19 8v8" />
|
|
</svg>
|
|
</div>
|
|
<h2 className="scanner-heading">Escanear TSI</h2>
|
|
<p className="scanner-desc">Escanea el código de barras de tu tarjeta sanitaria para ver tus recetas activas</p>
|
|
</div>
|
|
|
|
{phase !== 'prescriptions' && (
|
|
<>
|
|
{phase === 'error' && (
|
|
<div className="scan-error-card">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="scan-error-icon">
|
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
|
</svg>
|
|
<p className="scan-error-text">{errorMsg}</p>
|
|
<button className="scan-btn scan-btn--outline" onClick={() => { setErrorMsg(''); setPhase('idle'); }}>
|
|
Intentar de nuevo
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{phase === 'idle' && (
|
|
<>
|
|
<button className="scan-btn scan-btn--primary scan-btn--start" onClick={handleStartScan}>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
|
<circle cx="12" cy="13" r="4" />
|
|
</svg>
|
|
Abrir cámara
|
|
</button>
|
|
|
|
<div className="upload-section">
|
|
<label className="upload-label">O sube una foto de tu TSI</label>
|
|
<div className="upload-row">
|
|
<button className="upload-option" onClick={() => cameraInputRef.current?.click()}>
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
|
<circle cx="12" cy="13" r="4" />
|
|
</svg>
|
|
<span>Hacer foto</span>
|
|
</button>
|
|
<button className="upload-option" onClick={() => galleryInputRef.current?.click()}>
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
<polyline points="21 15 16 10 5 21" />
|
|
</svg>
|
|
<span>Subir de galería</span>
|
|
</button>
|
|
</div>
|
|
<input
|
|
ref={cameraInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
capture="environment"
|
|
onChange={handlePhotoSelect}
|
|
className="upload-hidden-input"
|
|
/>
|
|
<input
|
|
ref={galleryInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handlePhotoSelect}
|
|
className="upload-hidden-input"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{phase === 'scanning' && !isNative && (
|
|
<div className="scanner-camera-container">
|
|
<video ref={videoRef} className="scanner-video" autoPlay muted playsInline />
|
|
<div className="scanner-frame">
|
|
<div className="corner tl" />
|
|
<div className="corner tr" />
|
|
<div className="corner bl" />
|
|
<div className="corner br" />
|
|
</div>
|
|
<p className="scanner-hint">Apunta al código de barras</p>
|
|
</div>
|
|
)}
|
|
|
|
{phase === 'scanning' && isNative && (
|
|
<div className="scanning-active">
|
|
<div className="scanner-spinner" />
|
|
<p>Abriendo cámara…</p>
|
|
</div>
|
|
)}
|
|
|
|
{phase === 'photo-preview' && (
|
|
<div className="photo-preview-panel">
|
|
<div className="photo-preview-container">
|
|
<img src={previewUrl} alt="TSI capturada" className="photo-preview-img" />
|
|
</div>
|
|
{ocrLoading ? (
|
|
<div className="scanning-active">
|
|
<div className="scanner-spinner" />
|
|
<p>Procesando imagen…</p>
|
|
</div>
|
|
) : (
|
|
<div className="photo-preview-actions">
|
|
<button className="scan-btn scan-btn--primary scan-btn--start" onClick={handlePhotoOcr}>
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="4 7 4 4 20 4 20 7" />
|
|
<line x1="9" y1="20" x2="15" y2="20" />
|
|
<line x1="12" y1="4" x2="12" y2="20" />
|
|
</svg>
|
|
Escanear imagen
|
|
</button>
|
|
<button className="scan-btn scan-btn--ghost" onClick={handlePhotoDiscard}>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
Descartar
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<form className="cip-form" onSubmit={handleManualSubmit}>
|
|
<label className="cip-label" htmlFor="cip-input">O introduce el código CIP manualmente</label>
|
|
<div className="cip-row">
|
|
<input
|
|
id="cip-input"
|
|
className="cip-input"
|
|
type="text"
|
|
placeholder="Código CIP de 16 dígitos"
|
|
value={manualCip}
|
|
onChange={(e) => setManualCip(e.target.value)}
|
|
maxLength={16}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
/>
|
|
<button type="submit" className="scan-btn scan-btn--primary">Buscar</button>
|
|
</div>
|
|
</form>
|
|
</>
|
|
)}
|
|
|
|
{phase === 'prescriptions' && (
|
|
<div className="prescriptions-panel">
|
|
<div className="cip-badge">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm3 10c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" />
|
|
</svg>
|
|
<div>
|
|
<p className="cip-badge-label">TSI Escaneada</p>
|
|
<p className="cip-badge-value">{scannedCip}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<h3 className="rx-title">Recetas Activas</h3>
|
|
<p className="rx-subtitle">Toca un medicamento para ver disponibilidad en farmacias cercanas.</p>
|
|
|
|
{loadingPrescriptions && (
|
|
<div className="rx-loading">
|
|
<div className="scanner-spinner" />
|
|
<p>Cargando recetas…</p>
|
|
</div>
|
|
)}
|
|
|
|
{!loadingPrescriptions && prescriptions.length === 0 && (
|
|
<p className="rx-empty">No se encontraron recetas activas para esta tarjeta.</p>
|
|
)}
|
|
|
|
<ul className="rx-list">
|
|
{prescriptions.map((rx, i) => (
|
|
<li key={i}>
|
|
<button className="rx-item" onClick={() => handlePickPrescription(rx)}>
|
|
<div className="rx-item-info">
|
|
<span className="rx-name">{rx.name}</span>
|
|
<span className="rx-detail">{rx.dosage} · {rx.form}</span>
|
|
{rx.active_ingredient && (
|
|
<span className="rx-ingredient">{rx.active_ingredient}</span>
|
|
)}
|
|
</div>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="rx-arrow">
|
|
<polyline points="9 18 15 12 9 6" />
|
|
</svg>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
<button className="scan-btn scan-btn--ghost" onClick={() => {
|
|
stopCamera();
|
|
setPhase('idle');
|
|
setPrescriptions([]);
|
|
setScannedCip(null);
|
|
}}>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="1 4 1 10 7 10" />
|
|
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
|
</svg>
|
|
Escanear otra tarjeta
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default ScannerView;
|