CLIO v1.0 — Scanner Inteligente com IA (MVP)
This commit is contained in:
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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user