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

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
.next/
venv/
__pycache__/
*.pyc
.env
uploads/

0
backend/app/__init__.py Normal file
View File

17
backend/app/config.py Normal file
View File

@@ -0,0 +1,17 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql+asyncpg://clio:Clio2026!@localhost:5432/clio"
SECRET_KEY: str = "clio-secret-key-2026-musa-da-historia"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7
OPENAI_API_KEY: str = ""
OPENAI_MODEL_TEXT: str = "gpt-4o-mini"
OPENAI_MODEL_VISION: str = "gpt-4o"
FREE_SCAN_LIMIT: int = 5
UPLOAD_DIR: str = "/opt/clio/uploads"
class Config:
env_file = ".env"
settings = Settings()

17
backend/app/database.py Normal file
View File

@@ -0,0 +1,17 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with async_session() as session:
yield session
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

42
backend/app/main.py Normal file
View File

@@ -0,0 +1,42 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from sqlalchemy import select
from app.database import init_db, async_session
from app.models.user import User
from app.routers import auth, documents
from app.utils.security import hash_password
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
# Seed admin user
async with async_session() as db:
result = await db.execute(select(User).where(User.email == "admin@clio.com"))
if not result.scalar_one_or_none():
admin = User(
email="admin@clio.com",
name="Admin CLIO",
password_hash=hash_password("Clio@2026"),
plan="premium"
)
db.add(admin)
await db.commit()
yield
app = FastAPI(title="CLIO API", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(documents.router)
@app.get("/api/health")
async def health():
return {"status": "ok", "app": "CLIO API v1.0 — Scanner Inteligente com IA"}

View File

@@ -0,0 +1,2 @@
from app.models.user import User
from app.models.document import Document

View File

@@ -0,0 +1,18 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON
from datetime import datetime, timezone
from app.database import Base
class Document(Base):
__tablename__ = "documents"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
title = Column(String(500))
category = Column(String(50), index=True)
original_image = Column(Text)
extracted_text = Column(Text)
summary = Column(Text)
extracted_data = Column(JSON)
risk_alerts = Column(JSON)
tags = Column(JSON)
file_size = Column(Integer)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True)

View File

@@ -0,0 +1,13 @@
from sqlalchemy import Column, Integer, String, DateTime
from datetime import datetime, timezone
from app.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, index=True, nullable=False)
name = Column(String(200), nullable=True)
password_hash = Column(String(255), nullable=False)
plan = Column(String(20), default="free")
scan_count_today = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View File

View File

@@ -0,0 +1,37 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.user import User
from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, UserResponse
from app.utils.security import hash_password, verify_password, create_access_token, get_current_user
router = APIRouter(prefix="/api/auth", tags=["auth"])
def user_to_dict(user: User) -> dict:
return {"id": user.id, "email": user.email, "name": user.name, "plan": user.plan}
@router.post("/register", response_model=TokenResponse)
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
existing = await db.execute(select(User).where(User.email == req.email))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email já cadastrado")
user = User(email=req.email, name=req.name or req.email.split("@")[0], password_hash=hash_password(req.password))
db.add(user)
await db.commit()
await db.refresh(user)
token = create_access_token({"sub": str(user.id)})
return TokenResponse(access_token=token, user=UserResponse(**user_to_dict(user)))
@router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == req.email))
user = result.scalar_one_or_none()
if not user or not verify_password(req.password, user.password_hash):
raise HTTPException(status_code=401, detail="Email ou senha incorretos")
token = create_access_token({"sub": str(user.id)})
return TokenResponse(access_token=token, user=UserResponse(**user_to_dict(user)))
@router.get("/me")
async def me(user: User = Depends(get_current_user)):
return user_to_dict(user)

View File

@@ -0,0 +1,143 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from typing import Optional
import base64
import os
from app.database import get_db
from app.models.user import User
from app.models.document import Document
from app.schemas.document import ScanRequest, DocumentResponse, DocumentListResponse
from app.services.ai_service import analyze_document
from app.utils.security import get_current_user
from app.config import settings
router = APIRouter(prefix="/api/documents", tags=["documents"])
@router.post("/scan", response_model=DocumentResponse)
async def scan_document(
req: ScanRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
# Check scan limit for free users
if user.plan == "free" and user.scan_count_today >= settings.FREE_SCAN_LIMIT:
raise HTTPException(status_code=429, detail="Limite de scans diários atingido. Faça upgrade para Premium.")
# Calculate file size
image_data = req.image
if "," in image_data:
image_data_clean = image_data.split(",", 1)[1]
else:
image_data_clean = image_data
file_size = len(base64.b64decode(image_data_clean))
# AI analysis
try:
result = await analyze_document(req.image)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erro na análise IA: {str(e)}")
# Save document
doc = Document(
user_id=user.id,
title=result.get("title", "Documento sem título"),
category=result.get("category", "outro"),
original_image=req.image,
extracted_text=result.get("extracted_text", ""),
summary=result.get("summary", ""),
extracted_data=result.get("extracted_data", {}),
risk_alerts=result.get("risk_alerts", []),
tags=result.get("tags", []),
file_size=file_size
)
db.add(doc)
# Update scan count
user.scan_count_today += 1
await db.commit()
await db.refresh(doc)
return DocumentResponse(
id=doc.id, title=doc.title, category=doc.category,
extracted_text=doc.extracted_text, summary=doc.summary,
extracted_data=doc.extracted_data, risk_alerts=doc.risk_alerts,
tags=doc.tags, file_size=doc.file_size, created_at=doc.created_at
)
@router.get("/", response_model=DocumentListResponse)
async def list_documents(
search: Optional[str] = None,
category: Optional[str] = None,
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
query = select(Document).where(Document.user_id == user.id)
count_query = select(func.count(Document.id)).where(Document.user_id == user.id)
if category:
query = query.where(Document.category == category)
count_query = count_query.where(Document.category == category)
if search:
search_filter = Document.extracted_text.ilike(f"%{search}%")
query = query.where(search_filter)
count_query = count_query.where(search_filter)
total = (await db.execute(count_query)).scalar()
result = await db.execute(query.order_by(desc(Document.created_at)).offset((page-1)*limit).limit(limit))
docs = result.scalars().all()
return DocumentListResponse(
documents=[DocumentResponse(
id=d.id, title=d.title, category=d.category,
extracted_text=d.extracted_text, summary=d.summary,
extracted_data=d.extracted_data, risk_alerts=d.risk_alerts,
tags=d.tags, file_size=d.file_size, created_at=d.created_at
) for d in docs],
total=total
)
@router.get("/{doc_id}", response_model=DocumentResponse)
async def get_document(
doc_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Document).where(Document.id == doc_id, Document.user_id == user.id))
doc = result.scalar_one_or_none()
if not doc:
raise HTTPException(status_code=404, detail="Documento não encontrado")
return DocumentResponse(
id=doc.id, title=doc.title, category=doc.category,
extracted_text=doc.extracted_text, summary=doc.summary,
extracted_data=doc.extracted_data, risk_alerts=doc.risk_alerts,
tags=doc.tags, file_size=doc.file_size, created_at=doc.created_at
)
@router.get("/{doc_id}/image")
async def get_document_image(
doc_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Document).where(Document.id == doc_id, Document.user_id == user.id))
doc = result.scalar_one_or_none()
if not doc:
raise HTTPException(status_code=404, detail="Documento não encontrado")
return {"image": doc.original_image}
@router.delete("/{doc_id}")
async def delete_document(
doc_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Document).where(Document.id == doc_id, Document.user_id == user.id))
doc = result.scalar_one_or_none()
if not doc:
raise HTTPException(status_code=404, detail="Documento não encontrado")
await db.delete(doc)
await db.commit()
return {"message": "Documento excluído"}

View File

View File

@@ -0,0 +1,21 @@
from pydantic import BaseModel
from typing import Optional
class RegisterRequest(BaseModel):
email: str
password: str
name: Optional[str] = None
class LoginRequest(BaseModel):
email: str
password: str
class UserResponse(BaseModel):
id: int
email: str
name: Optional[str]
plan: str
class TokenResponse(BaseModel):
access_token: str
user: UserResponse

View File

@@ -0,0 +1,22 @@
from pydantic import BaseModel
from typing import Optional, List, Any
from datetime import datetime
class ScanRequest(BaseModel):
image: str # base64
class DocumentResponse(BaseModel):
id: int
title: Optional[str]
category: Optional[str]
extracted_text: Optional[str]
summary: Optional[str]
extracted_data: Optional[Any]
risk_alerts: Optional[Any]
tags: Optional[Any]
file_size: Optional[int]
created_at: datetime
class DocumentListResponse(BaseModel):
documents: List[DocumentResponse]
total: int

View File

View File

@@ -0,0 +1,65 @@
import openai
import json
from app.config import settings
client = openai.AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
SYSTEM_PROMPT = """Você é CLIO, assistente de IA especializada em análise de documentos.
Ao receber a imagem de um documento, você deve:
1. Extrair TODO o texto visível (OCR)
2. Identificar a categoria: contrato, nf (nota fiscal), receita (médica), rg, cnh, certidao, boleto, outro
3. Extrair dados estruturados relevantes conforme o tipo:
- CNH: nome, cpf, rg, validade, categoria, registro
- RG: nome, rg, cpf, data_nascimento, naturalidade
- NF: cnpj_emitente, razao_social, valor_total, itens, data_emissao
- Contrato: partes, objeto, valor, prazo, data_assinatura
- Receita: paciente, medico, crm, medicamentos, posologia
- Boleto: beneficiario, valor, vencimento, codigo_barras
- Certidão: tipo, nome, cartorio, data
4. Gerar um resumo em bullets (máx 5 pontos)
5. Identificar alertas de risco (cláusulas abusivas, prazos vencendo, valores suspeitos)
6. Sugerir tags relevantes
Responda SEMPRE em JSON válido com esta estrutura:
{
"title": "título descritivo curto do documento",
"category": "categoria",
"extracted_text": "texto completo extraído",
"extracted_data": { ... dados estruturados ... },
"summary": "• ponto 1\\n• ponto 2\\n• ponto 3",
"risk_alerts": ["alerta 1", "alerta 2"],
"tags": ["tag1", "tag2"]
}"""
async def analyze_document(image_base64: str) -> dict:
"""Analyze a document image using GPT-4o vision."""
# Remove data URL prefix if present
if "," in image_base64:
image_base64 = image_base64.split(",", 1)[1]
response = await client.chat.completions.create(
model=settings.OPENAI_MODEL_VISION,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": [
{"type": "text", "text": "Analise este documento. Extraia todas as informações e retorne o JSON estruturado."},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{image_base64}",
"detail": "high"
}
}
]
}
],
max_tokens=4096,
temperature=0.1,
response_format={"type": "json_object"}
)
result = json.loads(response.choices[0].message.content)
return result

View File

View File

@@ -0,0 +1,42 @@
from passlib.context import CryptContext
from jose import jwt, JWTError
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.config import settings
from app.database import get_db
from app.models.user import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db)
) -> User:
try:
payload = jwt.decode(credentials.credentials, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=401, detail="Token inválido")
except JWTError:
raise HTTPException(status_code=401, detail="Token inválido")
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=401, detail="Usuário não encontrado")
return user

9
backend/requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy[asyncio]==2.0.35
asyncpg==0.30.0
pydantic-settings==2.5.2
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
openai==1.51.0
python-multipart==0.0.12

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"
]
}

4
start-backend.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
cd /opt/clio/backend
source venv/bin/activate
exec uvicorn app.main:app --host 127.0.0.1 --port 8096