274 lines
9.9 KiB
TypeScript
274 lines
9.9 KiB
TypeScript
'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>
|
||
);
|
||
}
|