CLIO v1.0 — Scanner Inteligente com IA (MVP)
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
uploads/
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
17
backend/app/config.py
Normal file
17
backend/app/config.py
Normal 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
17
backend/app/database.py
Normal 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
42
backend/app/main.py
Normal 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"}
|
||||||
2
backend/app/models/__init__.py
Normal file
2
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from app.models.user import User
|
||||||
|
from app.models.document import Document
|
||||||
18
backend/app/models/document.py
Normal file
18
backend/app/models/document.py
Normal 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)
|
||||||
13
backend/app/models/user.py
Normal file
13
backend/app/models/user.py
Normal 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))
|
||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
37
backend/app/routers/auth.py
Normal file
37
backend/app/routers/auth.py
Normal 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)
|
||||||
143
backend/app/routers/documents.py
Normal file
143
backend/app/routers/documents.py
Normal 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"}
|
||||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
21
backend/app/schemas/auth.py
Normal file
21
backend/app/schemas/auth.py
Normal 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
|
||||||
22
backend/app/schemas/document.py
Normal file
22
backend/app/schemas/document.py
Normal 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
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
65
backend/app/services/ai_service.py
Normal file
65
backend/app/services/ai_service.py
Normal 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
|
||||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
42
backend/app/utils/security.py
Normal file
42
backend/app/utils/security.py
Normal 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
9
backend/requirements.txt
Normal 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
5
frontend/next-env.d.ts
vendored
Normal 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
14
frontend/next.config.js
Normal 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
1624
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
frontend/package.json
Normal file
25
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
6
frontend/public/icon-192.svg
Normal file
6
frontend/public/icon-192.svg
Normal 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 |
6
frontend/public/icon-512.svg
Normal file
6
frontend/public/icon-512.svg
Normal 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 |
14
frontend/public/manifest.json
Normal file
14
frontend/public/manifest.json
Normal 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
24
frontend/public/sw.js
Normal 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))
|
||||||
|
);
|
||||||
|
});
|
||||||
71
frontend/src/app/globals.css
Normal file
71
frontend/src/app/globals.css
Normal 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; }
|
||||||
30
frontend/src/app/layout.tsx
Normal file
30
frontend/src/app/layout.tsx
Normal 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
50
frontend/src/app/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
12
frontend/src/app/register-sw.tsx
Normal file
12
frontend/src/app/register-sw.tsx
Normal 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;
|
||||||
|
}
|
||||||
69
frontend/src/components/Dashboard.tsx
Normal file
69
frontend/src/components/Dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
frontend/src/components/DocumentDetail.tsx
Normal file
150
frontend/src/components/DocumentDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
frontend/src/components/DocumentList.tsx
Normal file
137
frontend/src/components/DocumentList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
frontend/src/components/LoginScreen.tsx
Normal file
106
frontend/src/components/LoginScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
273
frontend/src/components/Scanner.tsx
Normal file
273
frontend/src/components/Scanner.tsx
Normal 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
48
frontend/src/lib/api.ts
Normal 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' }),
|
||||||
|
};
|
||||||
21
frontend/tailwind.config.ts
Normal file
21
frontend/tailwind.config.ts
Normal 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
40
frontend/tsconfig.json
Normal 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
4
start-backend.sh
Executable 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
|
||||||
Reference in New Issue
Block a user