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