Files
clio/frontend/src/components/Scanner.tsx
2026-02-10 23:05:41 +00:00

274 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useRef, useCallback, useEffect } from 'react';
import { api } from '@/lib/api';
export default function Scanner({ onComplete }: { onComplete: () => void }) {
const [mode, setMode] = useState<'idle' | 'camera' | 'processing' | 'result'>('idle');
const [result, setResult] = useState<any>(null);
const [error, setError] = useState('');
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const stopCamera = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(t => t.stop());
streamRef.current = null;
}
}, []);
useEffect(() => {
return () => stopCamera();
}, [stopCamera]);
const startCamera = async () => {
setError('');
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1920 }, height: { ideal: 1080 } }
});
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
setMode('camera');
} catch (err) {
setError('Não foi possível acessar a câmera. Verifique as permissões.');
}
};
const capturePhoto = () => {
if (!videoRef.current || !canvasRef.current) return;
const video = videoRef.current;
const canvas = canvasRef.current;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(video, 0, 0);
const base64 = canvas.toDataURL('image/jpeg', 0.85);
stopCamera();
processImage(base64);
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const base64 = ev.target?.result as string;
processImage(base64);
};
reader.readAsDataURL(file);
};
const processImage = async (base64: string) => {
setMode('processing');
setError('');
try {
const res = await api.scanDocument(base64);
setResult(res);
setMode('result');
} catch (err: any) {
setError(err.message);
setMode('idle');
}
};
const categoryColors: Record<string, string> = {
contrato: 'bg-blue-500/20 text-blue-400',
nf: 'bg-green-500/20 text-green-400',
receita: 'bg-pink-500/20 text-pink-400',
rg: 'bg-yellow-500/20 text-yellow-400',
cnh: 'bg-orange-500/20 text-orange-400',
certidao: 'bg-purple-500/20 text-purple-400',
boleto: 'bg-red-500/20 text-red-400',
outro: 'bg-gray-500/20 text-gray-400',
};
const categoryLabels: Record<string, string> = {
contrato: '📄 Contrato',
nf: '🧾 Nota Fiscal',
receita: '💊 Receita',
rg: '🪪 RG',
cnh: '🚗 CNH',
certidao: '📋 Certidão',
boleto: '💰 Boleto',
outro: '📎 Outro',
};
if (mode === 'camera') {
return (
<div className="space-y-4">
<div className="relative rounded-2xl overflow-hidden bg-black">
<video ref={videoRef} className="w-full" autoPlay playsInline muted />
{/* Overlay guia */}
<div className="absolute inset-0 pointer-events-none">
<div className="absolute inset-8 border-2 border-primary/40 rounded-xl" />
<div className="absolute top-10 left-10 w-8 h-8 border-t-3 border-l-3 border-primary rounded-tl-lg" />
<div className="absolute top-10 right-10 w-8 h-8 border-t-3 border-r-3 border-primary rounded-tr-lg" />
<div className="absolute bottom-10 left-10 w-8 h-8 border-b-3 border-l-3 border-primary rounded-bl-lg" />
<div className="absolute bottom-10 right-10 w-8 h-8 border-b-3 border-r-3 border-primary rounded-br-lg" />
</div>
</div>
<div className="flex gap-3">
<button onClick={() => { stopCamera(); setMode('idle'); }} className="flex-1 py-3 rounded-xl bg-dark-700 text-gray-300">
Cancelar
</button>
<button onClick={capturePhoto} className="flex-1 btn-primary scan-pulse">
📸 Capturar
</button>
</div>
<canvas ref={canvasRef} className="hidden" />
</div>
);
}
if (mode === 'processing') {
return (
<div className="glass-card p-12 text-center">
<div className="text-5xl mb-4 animate-bounce">🔍</div>
<h3 className="text-lg font-semibold text-primary mb-2">Analisando documento...</h3>
<p className="text-gray-400 text-sm">IA extraindo texto, categorizando e buscando dados relevantes</p>
<div className="mt-6 flex justify-center gap-1">
{[0,1,2].map(i => (
<div key={i} className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{animationDelay: `${i*0.15}s`}} />
))}
</div>
</div>
);
}
if (mode === 'result' && result) {
return (
<div className="space-y-4">
<div className="glass-card p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-lg">{result.title}</h3>
<span className={`category-badge ${categoryColors[result.category] || categoryColors.outro}`}>
{categoryLabels[result.category] || '📎 Outro'}
</span>
</div>
{/* Summary */}
{result.summary && (
<div className="mb-4">
<h4 className="text-sm font-medium text-accent mb-2">📌 Resumo</h4>
<div className="text-sm text-gray-300 whitespace-pre-line bg-dark-800 rounded-xl p-4">
{result.summary}
</div>
</div>
)}
{/* Extracted Data */}
{result.extracted_data && Object.keys(result.extracted_data).length > 0 && (
<div className="mb-4">
<h4 className="text-sm font-medium text-accent mb-2">📊 Dados Extraídos</h4>
<div className="bg-dark-800 rounded-xl p-4 space-y-2">
{Object.entries(result.extracted_data).map(([key, val]) => (
<div key={key} className="flex justify-between text-sm">
<span className="text-gray-400 capitalize">{key.replace(/_/g, ' ')}</span>
<span className="text-gray-200 text-right max-w-[60%]">{String(val)}</span>
</div>
))}
</div>
</div>
)}
{/* Risk Alerts */}
{result.risk_alerts && result.risk_alerts.length > 0 && (
<div className="mb-4">
<h4 className="text-sm font-medium text-red-400 mb-2"> Alertas de Risco</h4>
<div className="space-y-2">
{result.risk_alerts.map((alert: string, i: number) => (
<div key={i} className="bg-red-500/10 border border-red-500/20 rounded-xl p-3 text-sm text-red-300">
{alert}
</div>
))}
</div>
</div>
)}
{/* Tags */}
{result.tags && result.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{result.tags.map((tag: string, i: number) => (
<span key={i} className="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full">
#{tag}
</span>
))}
</div>
)}
</div>
<div className="flex gap-3">
<button onClick={() => { setResult(null); setMode('idle'); }} className="flex-1 py-3 rounded-xl bg-dark-700 text-gray-300">
Novo Scan
</button>
<button onClick={onComplete} className="flex-1 btn-accent">
Ver Histórico
</button>
</div>
</div>
);
}
// Idle state
return (
<div className="space-y-6">
<div className="text-center py-8">
<div className="text-6xl mb-4">📜</div>
<h2 className="text-2xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent mb-2">
Scanner Inteligente
</h2>
<p className="text-gray-400">Aponte para o documento e deixe a IA fazer o resto</p>
</div>
{error && (
<div className="bg-red-400/10 text-red-400 rounded-xl p-3 text-sm text-center">{error}</div>
)}
<div className="grid grid-cols-2 gap-4">
<button onClick={startCamera} className="glass-card p-8 text-center hover:border-primary/30 transition-all active:scale-95">
<div className="text-4xl mb-3">📷</div>
<div className="font-medium">Câmera</div>
<div className="text-xs text-gray-500 mt-1">Capturar ao vivo</div>
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="glass-card p-8 text-center hover:border-primary/30 transition-all active:scale-95"
>
<div className="text-4xl mb-3">📁</div>
<div className="font-medium">Galeria</div>
<div className="text-xs text-gray-500 mt-1">Escolher imagem</div>
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileUpload}
className="hidden"
/>
<div className="glass-card p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Tipos suportados</h3>
<div className="grid grid-cols-4 gap-2 text-center text-xs">
{[
['📄', 'Contrato'], ['🧾', 'NF'], ['💊', 'Receita'], ['🪪', 'RG/CNH'],
['📋', 'Certidão'], ['💰', 'Boleto'], ['📎', 'Outros'], ['🔍', 'OCR'],
].map(([icon, label]) => (
<div key={label} className="py-2">
<div className="text-lg">{icon}</div>
<div className="text-gray-500 mt-1">{label}</div>
</div>
))}
</div>
</div>
</div>
);
}