CLIO v1.0 — Scanner Inteligente com IA (MVP)

This commit is contained in:
Jarvis Deploy
2026-02-10 23:05:41 +00:00
commit 8e903d9222
41 changed files with 3190 additions and 0 deletions

5
frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

14
frontend/next.config.js Normal file
View File

@@ -0,0 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://127.0.0.1:8096/api/:path*',
},
];
},
};
module.exports = nextConfig;

1624
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
frontend/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "clio-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3086",
"build": "next build",
"start": "next start -p 3086"
},
"dependencies": {
"next": "14.2.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"html5-qrcode": "^2.3.8",
"lucide-react": "^0.441.0"
},
"devDependencies": {
"@types/node": "^20.16.10",
"@types/react": "^18.3.11",
"typescript": "^5.6.2",
"tailwindcss": "^3.4.13",
"postcss": "^8.4.47",
"autoprefixer": "^10.4.20"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 512 512">
<rect width="512" height="512" rx="80" fill="#0A0E17"/>
<rect x="20" y="20" width="472" height="472" rx="60" fill="none" stroke="#6C63FF" stroke-width="4" opacity="0.3"/>
<text x="256" y="280" font-size="200" text-anchor="middle" dominant-baseline="middle">📜</text>
<text x="256" y="420" font-family="Arial,sans-serif" font-size="72" font-weight="bold" fill="#6C63FF" text-anchor="middle">CLIO</text>
</svg>

After

Width:  |  Height:  |  Size: 506 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="80" fill="#0A0E17"/>
<rect x="20" y="20" width="472" height="472" rx="60" fill="none" stroke="#6C63FF" stroke-width="4" opacity="0.3"/>
<text x="256" y="280" font-size="200" text-anchor="middle" dominant-baseline="middle">📜</text>
<text x="256" y="420" font-family="Arial,sans-serif" font-size="72" font-weight="bold" fill="#6C63FF" text-anchor="middle">CLIO</text>
</svg>

After

Width:  |  Height:  |  Size: 506 B

View File

@@ -0,0 +1,14 @@
{
"name": "CLIO — Scanner Inteligente com IA",
"short_name": "CLIO",
"description": "Escaneie documentos com IA. OCR, categorização e extração automática.",
"start_url": "/",
"display": "standalone",
"background_color": "#0A0E17",
"theme_color": "#6C63FF",
"orientation": "portrait",
"icons": [
{ "src": "/icon-192.svg", "sizes": "192x192", "type": "image/svg+xml" },
{ "src": "/icon-512.svg", "sizes": "512x512", "type": "image/svg+xml" }
]
}

24
frontend/public/sw.js Normal file
View File

@@ -0,0 +1,24 @@
const CACHE_NAME = 'clio-v1';
const STATIC_ASSETS = ['/', '/manifest.json'];
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE_NAME).then(c => c.addAll(STATIC_ASSETS)));
self.skipWaiting();
});
self.addEventListener('activate', e => {
e.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))));
self.clients.claim();
});
self.addEventListener('fetch', e => {
if (e.request.method !== 'GET') return;
if (e.request.url.includes('/api/')) return;
e.respondWith(
fetch(e.request).then(res => {
const clone = res.clone();
caches.open(CACHE_NAME).then(c => c.put(e.request, clone));
return res;
}).catch(() => caches.match(e.request))
);
});

View File

@@ -0,0 +1,71 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
background: #0A0E17;
color: #e5e7eb;
font-family: 'Inter', system-ui, sans-serif;
}
.glass {
background: rgba(17, 24, 39, 0.7);
backdrop-filter: blur(20px);
border: 1px solid rgba(108, 99, 255, 0.15);
}
.glass-card {
background: rgba(17, 24, 39, 0.6);
backdrop-filter: blur(16px);
border: 1px solid rgba(108, 99, 255, 0.1);
border-radius: 16px;
}
.gradient-border {
border: 1px solid transparent;
background-clip: padding-box;
position: relative;
}
.gradient-border::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, #6C63FF, #00D4AA);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
.btn-primary {
@apply bg-primary hover:bg-primary/80 text-white font-medium py-3 px-6 rounded-xl transition-all duration-200;
}
.btn-accent {
@apply bg-accent hover:bg-accent/80 text-dark-900 font-medium py-3 px-6 rounded-xl transition-all duration-200;
}
.input-field {
@apply w-full bg-dark-800 border border-dark-600 rounded-xl px-4 py-3 text-gray-200 placeholder-gray-500 focus:outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/30 transition-all;
}
.category-badge {
@apply inline-flex items-center px-3 py-1 rounded-full text-xs font-medium;
}
.scan-pulse {
animation: pulse-ring 2s ease-out infinite;
}
@keyframes pulse-ring {
0% { box-shadow: 0 0 0 0 rgba(108, 99, 255, 0.4); }
70% { box-shadow: 0 0 0 20px rgba(108, 99, 255, 0); }
100% { box-shadow: 0 0 0 0 rgba(108, 99, 255, 0); }
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0A0E17; }
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #6C63FF; }

View File

@@ -0,0 +1,30 @@
import type { Metadata, Viewport } from 'next';
import RegisterSW from './register-sw';
import './globals.css';
export const metadata: Metadata = {
title: 'CLIO — Scanner Inteligente com IA',
description: 'Escaneie documentos com IA. OCR inteligente, categorização automática, extração de dados e alertas de risco.',
manifest: '/manifest.json',
icons: { icon: '/icon-192.svg', apple: '/icon-512.svg' },
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
themeColor: '#0A0E17',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-BR">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body className="min-h-screen"><RegisterSW />{children}</body>
</html>
);
}

50
frontend/src/app/page.tsx Normal file
View File

@@ -0,0 +1,50 @@
'use client';
import { useState, useEffect } from 'react';
import { api } from '@/lib/api';
import LoginScreen from '@/components/LoginScreen';
import Dashboard from '@/components/Dashboard';
export default function Home() {
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem('clio_token');
const savedUser = localStorage.getItem('clio_user');
if (token && savedUser) {
setUser(JSON.parse(savedUser));
}
setLoading(false);
}, []);
const handleLogin = (token: string, userData: any) => {
localStorage.setItem('clio_token', token);
localStorage.setItem('clio_user', JSON.stringify(userData));
setUser(userData);
};
const handleLogout = () => {
localStorage.removeItem('clio_token');
localStorage.removeItem('clio_user');
setUser(null);
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="text-5xl mb-4">📜</div>
<div className="text-primary font-bold text-xl">CLIO</div>
<div className="text-gray-500 text-sm mt-1">Carregando...</div>
</div>
</div>
);
}
if (!user) {
return <LoginScreen onLogin={handleLogin} />;
}
return <Dashboard user={user} onLogout={handleLogout} />;
}

View File

@@ -0,0 +1,12 @@
'use client';
import { useEffect } from 'react';
export default function RegisterSW() {
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
}, []);
return null;
}

View File

@@ -0,0 +1,69 @@
'use client';
import { useState } from 'react';
import Scanner from '@/components/Scanner';
import DocumentList from '@/components/DocumentList';
import DocumentDetail from '@/components/DocumentDetail';
type View = 'scan' | 'history' | 'detail';
export default function Dashboard({ user, onLogout }: { user: any; onLogout: () => void }) {
const [view, setView] = useState<View>('scan');
const [selectedDoc, setSelectedDoc] = useState<number | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const openDoc = (id: number) => { setSelectedDoc(id); setView('detail'); };
const onScanComplete = () => { setRefreshKey(k => k + 1); setView('history'); };
return (
<div className="min-h-screen pb-20">
{/* Header */}
<header className="glass sticky top-0 z-50 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-2xl">📜</span>
<span className="font-bold text-lg bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
CLIO
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-400">{user.name || user.email}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${user.plan === 'premium' ? 'bg-accent/20 text-accent' : 'bg-dark-600 text-gray-400'}`}>
{user.plan === 'premium' ? '⭐ Premium' : 'Free'}
</span>
<button onClick={onLogout} className="text-gray-500 hover:text-red-400 text-sm">Sair</button>
</div>
</header>
{/* Content */}
<main className="max-w-2xl mx-auto px-4 py-4">
{view === 'scan' && <Scanner onComplete={onScanComplete} />}
{view === 'history' && <DocumentList key={refreshKey} onSelect={openDoc} />}
{view === 'detail' && selectedDoc && (
<DocumentDetail
docId={selectedDoc}
onBack={() => { setView('history'); }}
onDelete={() => { setRefreshKey(k => k + 1); setView('history'); }}
/>
)}
</main>
{/* Bottom Nav */}
<nav className="fixed bottom-0 left-0 right-0 glass border-t border-dark-600 flex z-50">
<button
onClick={() => setView('scan')}
className={`flex-1 py-3 text-center transition-colors ${view === 'scan' ? 'text-primary' : 'text-gray-500'}`}
>
<div className="text-xl">📷</div>
<div className="text-xs mt-0.5">Scan</div>
</button>
<button
onClick={() => setView('history')}
className={`flex-1 py-3 text-center transition-colors ${view === 'history' || view === 'detail' ? 'text-primary' : 'text-gray-500'}`}
>
<div className="text-xl">📋</div>
<div className="text-xs mt-0.5">Histórico</div>
</button>
</nav>
</div>
);
}

View File

@@ -0,0 +1,150 @@
'use client';
import { useState, useEffect } from 'react';
import { api } from '@/lib/api';
export default function DocumentDetail({
docId, onBack, onDelete
}: { docId: number; onBack: () => void; onDelete: () => void }) {
const [doc, setDoc] = useState<any>(null);
const [showImage, setShowImage] = useState(false);
const [imageData, setImageData] = useState('');
const [showText, setShowText] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.getDocument(docId).then(setDoc).finally(() => setLoading(false));
}, [docId]);
const loadImage = async () => {
if (imageData) { setShowImage(!showImage); return; }
const res = await api.getDocumentImage(docId);
setImageData(res.image);
setShowImage(true);
};
const handleDelete = async () => {
if (!confirm('Excluir este documento?')) return;
await api.deleteDocument(docId);
onDelete();
};
const downloadImage = () => {
if (!imageData) return;
const link = document.createElement('a');
link.href = imageData.startsWith('data:') ? imageData : `data:image/jpeg;base64,${imageData}`;
link.download = `${doc?.title || 'documento'}.jpg`;
link.click();
};
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', outro: 'bg-gray-500/20 text-gray-400',
};
if (loading) return <div className="text-center py-12 text-gray-500">Carregando...</div>;
if (!doc) return <div className="text-center py-12 text-red-400">Documento não encontrado</div>;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<button onClick={onBack} className="text-gray-400 hover:text-white text-xl"></button>
<div className="flex-1">
<h2 className="font-semibold text-lg">{doc.title}</h2>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className={`category-badge ${categoryColors[doc.category] || categoryColors.outro}`}>
{doc.category?.toUpperCase()}
</span>
<span>{new Date(doc.created_at).toLocaleDateString('pt-BR')}</span>
</div>
</div>
</div>
{/* Summary */}
{doc.summary && (
<div className="glass-card p-4">
<h3 className="text-sm font-medium text-accent mb-2">📌 Resumo</h3>
<div className="text-sm text-gray-300 whitespace-pre-line">{doc.summary}</div>
</div>
)}
{/* Extracted Data */}
{doc.extracted_data && Object.keys(doc.extracted_data).length > 0 && (
<div className="glass-card p-4">
<h3 className="text-sm font-medium text-accent mb-3">📊 Dados Extraídos</h3>
<div className="space-y-2">
{Object.entries(doc.extracted_data).map(([key, val]) => (
<div key={key} className="flex justify-between text-sm border-b border-dark-700 pb-2 last:border-0">
<span className="text-gray-400 capitalize">{key.replace(/_/g, ' ')}</span>
<span className="text-gray-200 text-right max-w-[60%]">
{typeof val === 'object' ? JSON.stringify(val) : String(val)}
</span>
</div>
))}
</div>
</div>
)}
{/* Risk Alerts */}
{doc.risk_alerts && doc.risk_alerts.length > 0 && (
<div className="glass-card p-4">
<h3 className="text-sm font-medium text-red-400 mb-2"> Alertas de Risco</h3>
<div className="space-y-2">
{doc.risk_alerts.map((alert: string, i: number) => (
<div key={i} className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 text-sm text-red-300">
{alert}
</div>
))}
</div>
</div>
)}
{/* Tags */}
{doc.tags && doc.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{doc.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>
)}
{/* Full text toggle */}
<button onClick={() => setShowText(!showText)} className="glass-card p-3 w-full text-left text-sm text-gray-400 hover:text-gray-200">
📝 {showText ? 'Ocultar' : 'Ver'} texto completo extraído
</button>
{showText && doc.extracted_text && (
<div className="glass-card p-4 text-sm text-gray-300 whitespace-pre-wrap max-h-60 overflow-y-auto">
{doc.extracted_text}
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button onClick={loadImage} className="flex-1 py-3 rounded-xl bg-dark-700 text-gray-300 text-sm">
🖼 {showImage ? 'Ocultar' : 'Ver'} Imagem
</button>
{imageData && (
<button onClick={downloadImage} className="flex-1 btn-accent text-sm">
Download JPG
</button>
)}
</div>
{showImage && imageData && (
<div className="glass-card p-2 rounded-xl overflow-hidden">
<img
src={imageData.startsWith('data:') ? imageData : `data:image/jpeg;base64,${imageData}`}
alt={doc.title}
className="w-full rounded-lg"
/>
</div>
)}
<button onClick={handleDelete} className="w-full py-3 rounded-xl bg-red-500/10 text-red-400 text-sm hover:bg-red-500/20">
🗑 Excluir documento
</button>
</div>
);
}

View File

@@ -0,0 +1,137 @@
'use client';
import { useState, useEffect } from 'react';
import { api } from '@/lib/api';
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 categoryEmojis: Record<string, string> = {
contrato: '📄', nf: '🧾', receita: '💊', rg: '🪪', cnh: '🚗',
certidao: '📋', boleto: '💰', outro: '📎',
};
export default function DocumentList({ onSelect }: { onSelect: (id: number) => void }) {
const [docs, setDocs] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState('');
const [category, setCategory] = useState('');
const [loading, setLoading] = useState(true);
const load = async () => {
setLoading(true);
try {
const res = await api.getDocuments({ search: search || undefined, category: category || undefined });
setDocs(res.documents);
setTotal(res.total);
} catch (err) {}
setLoading(false);
};
useEffect(() => { load(); }, [search, category]);
const formatDate = (d: string) => {
const date = new Date(d);
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
};
const formatSize = (bytes: number) => {
if (!bytes) return '';
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024*1024) return `${(bytes/1024).toFixed(0)}KB`;
return `${(bytes/1024/1024).toFixed(1)}MB`;
};
return (
<div className="space-y-4">
<h2 className="text-xl font-bold">📋 Histórico</h2>
{/* Search */}
<input
type="text"
placeholder="🔍 Buscar documentos..."
value={search}
onChange={e => setSearch(e.target.value)}
className="input-field"
/>
{/* Category filter */}
<div className="flex gap-2 overflow-x-auto pb-2">
<button
onClick={() => setCategory('')}
className={`px-3 py-1.5 rounded-full text-xs whitespace-nowrap transition-colors ${!category ? 'bg-primary text-white' : 'bg-dark-700 text-gray-400'}`}
>
Todos ({total})
</button>
{['contrato','nf','receita','rg','cnh','boleto','outro'].map(cat => (
<button
key={cat}
onClick={() => setCategory(cat === category ? '' : cat)}
className={`px-3 py-1.5 rounded-full text-xs whitespace-nowrap transition-colors ${category === cat ? 'bg-primary text-white' : 'bg-dark-700 text-gray-400'}`}
>
{categoryEmojis[cat]} {cat.toUpperCase()}
</button>
))}
</div>
{/* List */}
{loading ? (
<div className="text-center py-8 text-gray-500">Carregando...</div>
) : docs.length === 0 ? (
<div className="glass-card p-8 text-center">
<div className="text-4xl mb-3">📭</div>
<p className="text-gray-400">Nenhum documento encontrado</p>
<p className="text-gray-600 text-sm mt-1">Escaneie seu primeiro documento!</p>
</div>
) : (
<div className="space-y-3">
{docs.map(doc => (
<button
key={doc.id}
onClick={() => onSelect(doc.id)}
className="glass-card p-4 w-full text-left hover:border-primary/30 transition-all active:scale-[0.98]"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{categoryEmojis[doc.category] || '📎'}</span>
<span className="font-medium truncate">{doc.title}</span>
</div>
<div className="flex items-center gap-3 text-xs text-gray-500">
<span>{formatDate(doc.created_at)}</span>
{doc.file_size && <span>{formatSize(doc.file_size)}</span>}
</div>
{doc.tags && doc.tags.length > 0 && (
<div className="flex gap-1 mt-2 flex-wrap">
{doc.tags.slice(0, 3).map((tag: string, i: number) => (
<span key={i} className="text-[10px] bg-primary/10 text-primary/70 px-1.5 py-0.5 rounded-full">
#{tag}
</span>
))}
</div>
)}
</div>
<span className={`category-badge ml-2 shrink-0 ${categoryColors[doc.category] || categoryColors.outro}`}>
{doc.category?.toUpperCase()}
</span>
</div>
{doc.risk_alerts && doc.risk_alerts.length > 0 && (
<div className="mt-2 text-xs text-red-400 flex items-center gap-1">
{doc.risk_alerts.length} alerta{doc.risk_alerts.length > 1 ? 's' : ''} de risco
</div>
)}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import { useState } from 'react';
import { api } from '@/lib/api';
export default function LoginScreen({ onLogin }: { onLogin: (token: string, user: any) => void }) {
const [isRegister, setIsRegister] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = isRegister
? await api.register(email, password, name)
: await api.login(email, password);
onLogin(res.access_token, res.user);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<div className="text-6xl mb-3">📜</div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
CLIO
</h1>
<p className="text-gray-400 mt-2">Scanner Inteligente com IA</p>
</div>
{/* Form */}
<div className="glass-card p-8">
<h2 className="text-xl font-semibold text-center mb-6">
{isRegister ? 'Criar conta' : 'Entrar'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{isRegister && (
<input
type="text"
placeholder="Nome"
value={name}
onChange={e => setName(e.target.value)}
className="input-field"
/>
)}
<input
type="email"
placeholder="Email"
value={email}
onChange={e => setEmail(e.target.value)}
className="input-field"
required
/>
<input
type="password"
placeholder="Senha"
value={password}
onChange={e => setPassword(e.target.value)}
className="input-field"
required
/>
{error && (
<div className="text-red-400 text-sm text-center bg-red-400/10 rounded-lg p-2">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="btn-primary w-full disabled:opacity-50"
>
{loading ? '...' : isRegister ? 'Criar conta' : 'Entrar'}
</button>
</form>
<div className="text-center mt-4">
<button
onClick={() => { setIsRegister(!isRegister); setError(''); }}
className="text-primary text-sm hover:underline"
>
{isRegister ? 'Já tenho conta' : 'Criar conta'}
</button>
</div>
</div>
<p className="text-center text-gray-600 text-xs mt-6">
Musa da História AI Vertice
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,273 @@
'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>
);
}

48
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,48 @@
const API_BASE = '/api';
function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('clio_token');
}
async function apiFetch(path: string, options: RequestInit = {}) {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> || {}),
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (res.status === 401) {
localStorage.removeItem('clio_token');
localStorage.removeItem('clio_user');
window.location.href = '/';
throw new Error('Não autorizado');
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Erro desconhecido' }));
throw new Error(err.detail || 'Erro na requisição');
}
return res.json();
}
export const api = {
login: (email: string, password: string) =>
apiFetch('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
register: (email: string, password: string, name: string) =>
apiFetch('/auth/register', { method: 'POST', body: JSON.stringify({ email, password, name }) }),
me: () => apiFetch('/auth/me'),
scanDocument: (image: string) =>
apiFetch('/documents/scan', { method: 'POST', body: JSON.stringify({ image }) }),
getDocuments: (params?: { search?: string; category?: string; page?: number }) => {
const q = new URLSearchParams();
if (params?.search) q.set('search', params.search);
if (params?.category) q.set('category', params.category);
if (params?.page) q.set('page', String(params.page));
return apiFetch(`/documents/?${q.toString()}`);
},
getDocument: (id: number) => apiFetch(`/documents/${id}`),
getDocumentImage: (id: number) => apiFetch(`/documents/${id}/image`),
deleteDocument: (id: number) => apiFetch(`/documents/${id}`, { method: 'DELETE' }),
};

View File

@@ -0,0 +1,21 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
theme: {
extend: {
colors: {
primary: '#6C63FF',
accent: '#00D4AA',
dark: {
900: '#0A0E17',
800: '#111827',
700: '#1F2937',
600: '#374151',
},
},
},
},
plugins: [],
};
export default config;

40
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,40 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": [
"./src/*"
]
},
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}