Initial commit

This commit is contained in:
Marcio Bevervanso 2026-01-26 11:20:25 -03:00
commit de9bf86d94
41 changed files with 9312 additions and 0 deletions

2
.env Normal file
View file

@ -0,0 +1,2 @@
VITE_SUPABASE_URL=https://cnattjitonpejcviwbdg.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNuYXR0aml0b25wZWpjdml3YmRnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjgzOTY2MDMsImV4cCI6MjA4Mzk3MjYwM30.cLlDq2NowgeN-IN55E5Bq0ZM035DS6Vs4ICvFxMNSG8

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

147
App.tsx Normal file
View file

@ -0,0 +1,147 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import TopBar from './layouts/TopBar';
import Dashboard from './pages/Dashboard';
import Sourcing from './pages/Sourcing';
import Orders from './pages/Orders';
import Inventory from './pages/Inventory';
import Financial from './pages/Financial';
import Customers from './pages/Customers';
import Sales from './pages/Sales';
import Suppliers from './pages/Suppliers';
import Users from './pages/Users';
import Login from './pages/Login';
import Products from './pages/Products'; // New
import Reports from './pages/Reports';
import Settings from './pages/Settings';
import { CRMProvider, useCRM } from './context/CRMContext';
import Sidebar from './layouts/Sidebar';
import Header from './layouts/Header';
import { useTheme } from './context/ThemeContext';
// Simple Layout Wrapper to handle Sidebar/Header display
const AppLayout: React.FC = () => {
const location = useLocation();
const { session, authLoading } = useCRM();
const { theme } = useTheme();
const isLoginPage = location.pathname === '/login';
if (authLoading) {
return (
<div className="h-screen w-full flex items-center justify-center bg-background text-foreground">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
</div>
);
}
if (!session && !isLoginPage) {
return <Navigate to="/login" replace />;
}
if (session && isLoginPage) {
return <Navigate to="/" replace />;
}
if (isLoginPage) {
return (
<div className="flex bg-background text-foreground h-screen w-screen relative overflow-hidden">
<Routes>
<Route path="/login" element={<Login />} />
</Routes>
</div>
);
}
// LAYOUT STRATEGIES
// 1. SIMPLE / CLEAN (Light) -> Uses TopBar (ERP Style)
if (theme === 'light') {
return (
<div className="min-h-screen bg-background flex flex-col font-sans antialiased text-foreground overflow-x-hidden selection:bg-primary selection:text-primary-foreground">
<TopBar />
<main className="flex-grow flex flex-col pt-4">
<div className="w-full max-w-[1600px] mx-auto p-4 md:p-8 flex-grow space-y-4">
<AppRoutes />
</div>
</main>
</div>
);
}
// 2. OCEAN / COMPLEX -> Uses Compact Sidebar (To be refined) or Modified TopBar
if (theme === 'ocean') {
return (
<div className="flex h-screen bg-background text-foreground font-sans antialiased selection:bg-cyan-500/30 selection:text-cyan-200 overflow-hidden">
{/* Sidebar for Ocean */}
<Sidebar />
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-4 md:p-6 scroll-smooth">
<div className="max-w-[1800px] mx-auto space-y-6 pb-20">
<AppRoutes />
</div>
</main>
</div>
</div>
);
}
// 3. DARK / PRO (Default) -> Sidebar + Header (Glassmorphism)
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
return (
<div className="flex h-screen bg-background text-foreground font-sans antialiased selection:bg-indigo-500/30 selection:text-indigo-200 overflow-hidden">
<Sidebar isOpen={isMobileMenuOpen} onClose={() => setIsMobileMenuOpen(false)} />
<div className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
{/* Background Blob Effect for Pro Theme */}
<div className="absolute top-[-20%] left-[-10%] w-[500px] h-[500px] bg-indigo-600/20 rounded-full blur-[120px] pointer-events-none"></div>
<Header onMenuClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)} />
<main className="flex-1 overflow-y-auto p-4 md:p-8 scroll-smooth z-10">
<AppRoutes />
</main>
</div>
</div>
);
};
// Extracted Routes to avoid duplication
const AppRoutes = () => (
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/sourcing" element={<Sourcing />} />
<Route path="/orders" element={<Orders />} />
<Route path="/sales" element={<Sales />} />
<Route path="/products" element={<Products />} />
<Route path="/reports" element={<Reports />} />
<Route path="/settings" element={<Settings />} />
<Route path="/financial" element={<Financial />} />
<Route path="/customers" element={<Customers />} />
<Route path="/inventory" element={<Inventory />} />
<Route path="/suppliers" element={<Suppliers />} />
<Route path="/users" element={<Users />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
import ErrorBoundary from './components/ErrorBoundary';
import { ThemeProvider } from './context/ThemeContext';
const App: React.FC = () => {
return (
<ErrorBoundary>
<ThemeProvider>
<CRMProvider>
{/* Main Router Setup */}
<Router>
<AppLayout />
</Router>
</CRMProvider>
</ThemeProvider>
</ErrorBoundary>
);
};
export default App;

20
README.md Normal file
View file

@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1SvuBB4oPz2uAYIkGNsKO0Y44_Y_khSrI
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View file

@ -0,0 +1,51 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Uncaught error:", error, errorInfo);
}
public render() {
if (this.state.hasError) {
return (
<div className="min-h-screen bg-slate-900 text-white p-10 font-mono">
<h1 className="text-3xl text-rose-500 mb-4">Software Crash Detected</h1>
<div className="bg-black/30 p-6 rounded-xl border border-rose-500/30">
<p className="text-lg font-bold mb-2">{this.state.error?.name}: {this.state.error?.message}</p>
<pre className="text-xs text-slate-400 overflow-auto max-h-[500px]">
{this.state.error?.stack}
</pre>
</div>
<button
onClick={() => window.location.reload()}
className="mt-6 px-6 py-3 bg-white/10 hover:bg-white/20 rounded-lg text-sm font-bold transition-all"
>
Reload Application
</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

34
components/Logo.tsx Normal file
View file

@ -0,0 +1,34 @@
import React from 'react';
interface LogoProps {
className?: string;
showText?: boolean;
size?: 'sm' | 'md' | 'lg';
}
const Logo: React.FC<LogoProps> = ({ className = "", showText = true, size = 'md' }) => {
// Sizes
const dim = size === 'sm' ? 24 : size === 'md' ? 32 : 48;
const textSize = size === 'sm' ? "text-lg" : size === 'md' ? "text-xl" : "text-3xl";
return (
<div className={`flex items-center gap-3 ${className}`}>
{/* GEOMETRIC LOGO MARK - Abstract 'A' / Graph Up */}
<svg width={dim} height={dim} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="8" className="fill-foreground" />
<path d="M10 22L16 10L22 22" stroke="hsl(var(--background))" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
<path d="M12.5 17H19.5" stroke="hsl(var(--background))" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{showText && (
<div className="flex flex-col justify-center">
<h1 className={`font-extrabold tracking-tight leading-none text-foreground ${textSize}`}>ARBITRA</h1>
{size !== 'sm' && <p className="text-[9px] font-bold text-muted-foreground tracking-[0.3em] mt-0.5 ml-0.5">PRO SYSTEM</p>}
</div>
)}
</div>
);
};
export default Logo;

View file

@ -0,0 +1,156 @@
import React from 'react';
import { Product, PLATFORMS, CalculationResult } from '../types';
import { Info, Target, TrendingUp, AlertCircle, CheckCircle2, ExternalLink } from 'lucide-react';
interface Props {
product: Product;
overheadPercent: number;
useOverhead: boolean;
}
const MarketplaceAnalytic: React.FC<Props> = ({ product, overheadPercent, useOverhead }) => {
const costParaguay = product.priceBRL;
const overhead = useOverhead ? (costParaguay * (overheadPercent / 100)) : 0;
const totalCost = costParaguay + overhead;
const results: CalculationResult[] = PLATFORMS.map(platform => {
let platformPrice = product.marketPriceBRL || (totalCost * 1.5);
let platformUrl = '';
if (platform.name === 'Amazon' && product.amazonPrice) {
platformPrice = product.amazonPrice;
platformUrl = product.amazonUrl || '';
} else if (platform.name === 'Mercado Livre' && product.mlPrice) {
platformPrice = product.mlPrice;
platformUrl = product.mlUrl || '';
} else if (platform.name === 'Shopee' && product.shopeePrice) {
platformPrice = product.shopeePrice;
platformUrl = product.shopeeUrl || '';
} else if (platform.name === 'Facebook') {
platformPrice = product.marketPriceBRL || (totalCost * 1.6); // Meta de venda
}
const fees = (platformPrice * platform.commission) + platform.fixedFee;
const netProfit = platformPrice - fees - totalCost;
const margin = (netProfit / platformPrice) * 100;
return {
platform: platform.name,
marketPrice: platformPrice,
totalCost,
fees,
netProfit,
margin,
url: platformUrl
};
});
return (
<div className="space-y-6 animate-in fade-in duration-500">
<div className="bg-white/5 rounded-[32px] border border-white/5 shadow-sm overflow-hidden backdrop-blur-sm">
{/* Header de Info do Produto */}
<div className="p-8 bg-white/5 border-b border-white/5">
<div className="flex items-center justify-between gap-6">
<div className="flex-grow">
<span className="text-[10px] font-bold text-indigo-400 uppercase tracking-widest bg-indigo-500/10 px-3 py-1 rounded-full">Análise de Margem por Canal</span>
<h3 className="text-xl font-bold text-white leading-tight mt-3">{product.name}</h3>
<p className="text-xs text-slate-500 mt-1 font-medium italic">Referência Paraguay: {product.store}</p>
</div>
<div className="text-right">
<span className="text-[10px] font-bold text-slate-500 uppercase block tracking-wider">Média Brasil</span>
<span className="text-2xl font-bold text-white tracking-tighter">R$ {(product.marketPriceBRL || 0).toLocaleString('pt-BR')}</span>
</div>
</div>
</div>
<div className="p-8">
{/* Breakdown de Custos */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
<div className="p-6 bg-white/5 border border-white/5 rounded-[24px] shadow-sm">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Custo PY</p>
<p className="text-xl font-bold text-slate-300">R$ {costParaguay.toLocaleString('pt-BR')}</p>
</div>
<div className="p-6 bg-white/5 border border-white/5 rounded-[24px] shadow-sm">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Custos Log/Fixo ({useOverhead ? `+${overheadPercent}%` : 'OFF'})</p>
<p className={`text-xl font-bold ${useOverhead ? 'text-amber-500' : 'text-slate-500 line-through'}`}>R$ {overhead.toLocaleString('pt-BR')}</p>
</div>
<div className="p-6 bg-slate-800 rounded-[24px] shadow-xl shadow-black/20 border border-slate-700">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1">Custo Final BR</p>
<p className="text-2xl font-bold text-white tracking-tight">R$ {totalCost.toLocaleString('pt-BR')}</p>
</div>
</div>
<div className="flex items-center justify-between mb-6">
<h4 className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">Comparação em Tempo Real</h4>
<span className="text-[10px] font-bold text-indigo-400 bg-indigo-500/10 px-2 py-1 rounded-lg">CLIQUE NO PREÇO PARA VER O ANÚNCIO</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{results.map((res) => (
<div
key={res.platform}
className={`p-6 border border-white/10 rounded-[28px] transition-all relative group overflow-hidden bg-white/5 ${res.url ? 'hover:border-indigo-400/50 hover:shadow-lg hover:bg-white/10' : ''}`}
>
{res.url && (
<a
href={res.url}
target="_blank"
rel="noopener noreferrer"
className="absolute inset-0 z-10"
title={`Ir para ${res.platform}`}
/>
)}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2">
<span className="font-bold text-white tracking-tight">{res.platform}</span>
{res.url && <ExternalLink size={12} className="text-indigo-400 group-hover:text-indigo-300" />}
</div>
<div className={`px-3 py-1 rounded-xl text-[10px] font-bold ${res.margin > 15 ? 'bg-emerald-500/10 text-emerald-400' : 'bg-rose-500/10 text-rose-400'}`}>
MARGEM: {res.margin.toFixed(1)}%
</div>
</div>
<div className="space-y-3">
<div className="flex justify-between items-end">
<span className="text-[10px] font-bold text-slate-500 uppercase">Preço Praticado</span>
<span className="text-lg font-bold text-white leading-none">R$ {res.marketPrice.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</span>
</div>
<div className="flex justify-between text-xs font-semibold">
<span className="text-slate-500 font-bold uppercase text-[9px]">Taxas Plataforma</span>
<span className="text-rose-400">- R$ {res.fees.toFixed(2)}</span>
</div>
<div className="h-[1px] bg-white/5 my-1"></div>
<div className="flex justify-between items-center pt-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">Lucro Líquido</span>
<span className={`text-xl font-bold tracking-tight ${res.netProfit > 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
R$ {res.netProfit.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
</span>
</div>
</div>
{res.url && (
<div className="mt-4 flex items-center justify-center gap-1.5 py-2 bg-white/5 rounded-xl group-hover:bg-indigo-600 group-hover:text-white transition-all">
<span className="text-[9px] font-bold uppercase text-slate-400 group-hover:text-white">Ver Oferta Real</span>
</div>
)}
</div>
))}
</div>
</div>
<div className="p-5 bg-amber-500/10 border-t border-amber-500/20 flex items-center gap-4 text-[10px] font-bold text-amber-500 uppercase">
<div className="w-8 h-8 rounded-full bg-amber-500/10 flex items-center justify-center shrink-0">
<AlertCircle size={14} />
</div>
<p className="leading-relaxed">
As margens exibidas consideram seu custo operacional fixo de {useOverhead ? `${overheadPercent}%` : '0%'}. Os preços dos marketplaces são extraídos em tempo real via busca web.
</p>
</div>
</div>
</div>
);
};
export default MarketplaceAnalytic;

849
context/CRMContext.tsx Normal file
View file

@ -0,0 +1,849 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { Product, ShoppingListItem, Order, InventoryItem, Supplier, User, PlatformFee, PLATFORMS, Customer, Transaction, FinancialSummary, AuctionLot, BiddingTender, Sale, SalesChannel, PaymentMethod, OrderStatus } from '../types';
import { searchProducts } from '../services/geminiService';
import { supabase } from '../services/supabase';
import { Session, User as SupabaseUser } from '@supabase/supabase-js';
interface CRMContextType {
// Auth State
user: User | null;
session: Session | null;
authLoading: boolean;
isAdmin: boolean; // New
signIn: (email: string, password: string) => Promise<{ error: any }>;
signOut: () => Promise<void>;
updateUserRole: (userId: string, role: 'admin' | 'user') => Promise<void>; // New action
// Data State
products: Product[];
shoppingList: ShoppingListItem[];
orders: Order[];
inventory: InventoryItem[];
suppliers: Supplier[];
users: User[];
customers: Customer[];
transactions: Transaction[];
searchTerm: string;
loading: boolean;
error: string | null;
searchLoading: boolean; // New separate loading state for search
searchError: string | null; // New separate error state for search
selectedProduct: Product | null;
searchType: 'specific' | 'opportunity';
setSearchType: (type: 'specific' | 'opportunity') => void;
overheadPercent: number;
exchangeRate: number;
activeOrderId: string | null;
useOverhead: boolean;
setUseOverhead: (use: boolean) => void;
// Actions
setSearchTerm: (term: string) => void;
setSelectedProduct: (product: Product | null) => void;
handleSearch: (e: React.FormEvent) => Promise<void>;
handleOpportunitySearch: (category: string) => Promise<void>;
addToShoppingList: (product: Product) => void;
removeFromShoppingList: (id: string) => void;
updateShoppingItemQuantity: (id: string, delta: number) => void;
updateOrderStatus: (orderId: string, newStatus: OrderStatus) => void;
saveOrderAsQuotation: () => void;
resumeOrder: (orderId: string) => void;
deleteOrder: (orderId: string) => Promise<void>;
calculateShoppingTotals: () => {
totalParaguayBRL: number;
totalCostWithOverhead: number;
totalUSD: number;
totalApproxProfit: number;
};
// New Actions
addTransaction: (tx: Omit<Transaction, 'id'>) => Promise<void>;
updateTransaction: (id: string, updates: Partial<Transaction>) => Promise<void>;
deleteTransaction: (id: string) => Promise<void>;
addCustomer: (customer: Omit<Customer, 'id' | 'totalPurchased'>) => Promise<Customer | null>; // Updated return type
updateCustomer: (id: string, updates: Partial<Customer>) => Promise<void>;
deleteCustomer: (id: string) => void;
getFinancialSummary: () => FinancialSummary;
// Sales Management
sales: Sale[];
importSales: (channel: SalesChannel) => Promise<void>;
updateSale: (id: string, updates: Partial<Sale>) => void;
// Supplier Actions
addSupplier: (supplier: Omit<Supplier, 'id'>) => void;
updateSupplier: (id: string, updates: Partial<Supplier>) => void;
deleteSupplier: (id: string) => void;
registerSale: (items: { id: string, quantity: number, salePrice: number }[], customerId?: string, paymentMethod?: PaymentMethod) => Promise<void>;
// Inventory Management
addProduct: (product: Omit<InventoryItem, 'id'>) => Promise<void>;
updateProduct: (id: string, updates: Partial<InventoryItem>) => Promise<void>;
deleteProduct: (id: string) => Promise<void>;
// Settings
settings: any; // We will define a stronger type later if needed
updateSettings: (newSettings: any) => Promise<void>;
}
const CRMContext = createContext<CRMContextType | undefined>(undefined);
export const CRMProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
// --- AUTH STATE ---
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false); // New State
// --- APP STATE ---
const [searchTerm, setSearchTerm] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchLoading, setSearchLoading] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [products, setProducts] = useState<Product[]>([]);
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [searchType, setSearchType] = useState<'specific' | 'opportunity'>('specific');
const [shoppingList, setShoppingList] = useState<ShoppingListItem[]>([]);
const [activeOrderId, setActiveOrderId] = useState<string | null>(null);
const [useOverhead, setUseOverhead] = useState(true);
// SUPABASE DATA
const [orders, setOrders] = useState<Order[]>([]);
const [inventory, setInventory] = useState<InventoryItem[]>([]);
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [customers, setCustomers] = useState<Customer[]>([]);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [sales, setSales] = useState<Sale[]>([]);
const [settings, setSettings] = useState<any>({
defaultOverhead: 20,
defaultExchange: 5.65,
companyName: 'Arbitra System'
});
// MOCKED DATA
const [users, setUsers] = useState<User[]>([]);
const overheadPercent = settings.defaultOverhead || 20;
const exchangeRate = settings.defaultExchange || 5.65;
// --- AUTH & INITIAL LOAD ---
useEffect(() => {
const handleAuthSession = async (currentSession: Session | null) => {
setSession(currentSession);
if (currentSession?.user) {
// Fetch Role from profiles table
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('role')
.eq('id', currentSession.user.id)
.single();
if (profileError) {
console.error("Error fetching user profile:", profileError);
}
const role = profile?.role || 'user';
setIsAdmin(role === 'admin');
setUser({
id: currentSession.user.id,
email: currentSession.user.email!,
name: currentSession.user.user_metadata?.full_name || currentSession.user.email!,
role: role as 'admin' | 'user',
status: 'Active', // Default or fetch from profile
avatar: currentSession.user.user_metadata?.avatar_url || "https://api.dicebear.com/7.x/avataaars/svg?seed=Felix"
});
fetchData();
} else {
setUser(null);
setIsAdmin(false);
// Optional: Clear state on logout
setOrders([]);
setInventory([]);
}
setAuthLoading(false);
};
// Check active session on mount
supabase.auth.getSession().then(({ data: { session } }) => {
handleAuthSession(session);
});
// Listen for auth changes
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
handleAuthSession(session);
});
return () => subscription.unsubscribe();
}, []);
const signIn = async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({ email, password });
return { error };
};
const signOut = async () => {
await supabase.auth.signOut();
setUser(null);
setIsAdmin(false);
};
const updateUserRole = async (userId: string, role: 'admin' | 'user') => {
if (!isAdmin) {
alert("Você não tem permissão para atualizar funções de usuário.");
return;
}
const { error } = await supabase.from('profiles').update({ role }).eq('id', userId);
if (error) {
console.error("Error updating role:", error);
alert("Erro ao atualizar função do usuário.");
} else {
alert("Função atualizada com sucesso.");
// Update local state immediately
setUsers(prev => prev.map(u => u.id === userId ? { ...u, role } : u));
}
};
// --- SUPABASE FETCH ---
const fetchData = async () => {
setLoading(true);
try {
// Orders
const { data: ordersData } = await supabase.from('orders').select('*').order('created_at', { ascending: false });
if (ordersData) {
const parsedOrders = ordersData.map(o => ({
id: o.id,
date: o.date || o.created_at,
items: typeof o.items === 'string' ? JSON.parse(o.items) : (o.items || []),
totalUSD: o.total_usd || 0,
totalBRL: o.total_brl || 0,
totalCostWithOverhead: o.total_cost_with_overhead || 0,
estimatedProfit: o.estimated_profit || 0,
status: o.status,
supplierName: o.supplier_name || 'Desconhecido'
}));
setOrders(parsedOrders || []);
}
// Inventory
const { data: inventoryData } = await supabase.from('inventory').select('*').order('created_at', { ascending: false });
if (inventoryData) {
setInventory(inventoryData.map((i: any) => ({
id: i.id,
name: i.name,
sku: i.sku,
ean: i.ean,
quantity: i.quantity,
avgCostBRL: i.avg_cost_brl || 0,
marketPriceBRL: i.market_price_brl || 0,
lastSupplier: i.last_supplier
})));
}
// Users (Admin Only) - Moved logic inside check
if (isAdmin) {
const { data: usersData } = await supabase.from('profiles').select('*').order('created_at', { ascending: false });
if (usersData) {
setUsers(usersData.map((u: any) => ({
id: u.id,
name: u.full_name || u.email, // Fallback if full_name is empty
email: u.email,
role: u.role,
status: u.status,
avatar: u.avatar_url || `https://api.dicebear.com/7.x/initials/svg?seed=${u.email}`,
lastAccess: u.last_access
})));
}
}
// Settings
const { data: settingsData } = await supabase.from('settings').select('*').single();
if (settingsData) {
setSettings({
companyName: settingsData.company_name,
cnpj: settingsData.cnpj,
ie: settingsData.ie,
defaultOverhead: settingsData.default_overhead,
defaultExchange: settingsData.default_exchange,
geminiKey: settingsData.gemini_key,
melhorEnvioToken: settingsData.melhor_envio_token,
blingToken: settingsData.bling_token,
tinyToken: settingsData.tiny_token,
certificatePassword: settingsData.certificate_password,
nfeSerie: settingsData.nfe_serie,
nfeNumber: settingsData.nfe_number,
nfeEnvironment: settingsData.nfe_environment,
smtpHost: settingsData.smtp_host,
smtpPort: settingsData.smtp_port,
smtpUser: settingsData.smtp_user,
smtpPass: settingsData.smtp_pass,
autoSyncSales: settingsData.auto_sync_sales,
autoSyncStock: settingsData.auto_sync_stock,
});
}
} catch (err) {
console.error("Error fetching data:", err);
} finally {
setLoading(false);
}
};
const updateSettings = async (newSettings: any) => {
// Determine if we need to insert or update (assuming single row for settings for now per user or global)
// For simplicity, we'll try to update the first row found, or insert if empty.
const dbSettings = {
company_name: newSettings.companyName,
cnpj: newSettings.cnpj,
ie: newSettings.ie,
default_overhead: newSettings.defaultOverhead,
default_exchange: newSettings.defaultExchange,
gemini_key: newSettings.geminiKey,
melhor_envio_token: newSettings.melhorEnvioToken,
bling_token: newSettings.blingToken,
tiny_token: newSettings.tinyToken,
certificate_password: newSettings.certificatePassword,
nfe_serie: newSettings.nfeSerie,
nfe_number: newSettings.nfeNumber,
nfe_environment: newSettings.nfeEnvironment,
smtp_host: newSettings.smtpHost,
smtp_port: newSettings.smtpPort,
smtp_user: newSettings.smtpUser,
smtp_pass: newSettings.smtpPass,
auto_sync_sales: newSettings.autoSyncSales,
auto_sync_stock: newSettings.autoSyncStock
};
// Check if settings exist
const { data } = await supabase.from('settings').select('id').single();
if (data) {
// Update
await supabase.from('settings').update(dbSettings).eq('id', data.id);
} else {
// Insert
await supabase.from('settings').insert([dbSettings]);
}
setSettings(newSettings);
};
// --- ACTIONS ---
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!searchTerm.trim()) return;
setSearchLoading(true);
setSearchError(null);
setSelectedProduct(null);
setProducts([]); // Clear previous results
try {
const { products: result } = await searchProducts(searchTerm);
setProducts(result);
if (result.length === 0) {
setSearchError("Não encontramos nenhum produto com esse nome. Tente termos mais gerais (ex: 'iPhone' em vez de 'iPhone 15 Pro Max 256GB Azul').");
}
} catch (err: any) {
console.error(err);
if (err.message && err.message.includes("VITE_GOOGLE_API_KEY")) {
setSearchError("Erro de Configuração: API Key do Google não encontrada. Verifique o arquivo .env");
} else if (err.message && (err.message.includes("fetch") || err.message.includes("network"))) {
setSearchError("Erro de Conexão: Não foi possível conectar ao servidor. Verifique sua internet.");
} else {
setSearchError("Ocorreu um erro ao consultar a inteligência artificial. Por favor, tente novamente em alguns instantes.");
}
} finally {
setSearchLoading(false);
}
};
const handleOpportunitySearch = async (category: string) => {
if (!category.trim()) return;
setSearchLoading(true);
setSearchError(null);
setSelectedProduct(null);
setProducts([]); // Clear previous results
try {
const { searchOpportunities } = await import('../services/geminiService');
const { products: result } = await searchOpportunities(category, useOverhead);
setProducts(result);
if (result.length === 0) {
setSearchError("Nenhuma oportunidade encontrada com >25% de margem nesta categoria no momento. Tente outra categoria.");
}
} catch (err: any) {
console.error(err);
if (err.message && err.message.includes("VITE_GOOGLE_API_KEY")) {
setSearchError("Erro de Configuração: API Key do Google não encontrada. Verifique o arquivo .env");
} else if (err.message && (err.message.includes("fetch") || err.message.includes("network"))) {
setSearchError("Erro de Conexão: Não foi possível conectar ao servidor. Verifique sua internet.");
} else {
setSearchError("A IA demorou muito para responder ou encontrou um erro. Tente novamente, geralmente funciona na segunda tentativa.");
}
} finally {
setSearchLoading(false);
}
};
const addToShoppingList = (p: Product) => {
setShoppingList(prev => {
const existing = prev.find(item => item.name === p.name && item.store === p.store);
if (existing) return prev.map(item => item.id === existing.id ? { ...item, quantity: item.quantity + 1 } : item);
return [...prev, {
id: crypto.randomUUID(),
name: p.name,
store: p.store,
priceUSD: p.priceUSD,
priceBRL: p.priceBRL,
quantity: 1,
marketPriceBRL: p.marketPriceBRL || (p.priceBRL * 1.6)
}];
});
};
const removeFromShoppingList = (id: string) => {
setShoppingList(prev => prev.filter(i => i.id !== id));
};
const updateShoppingItemQuantity = (id: string, delta: number) => {
setShoppingList(prev => prev.map(i => i.id === id ? { ...i, quantity: Math.max(1, i.quantity + delta) } : i));
};
const updateOrderStatus = async (orderId: string, newStatus: OrderStatus) => {
const order = orders.find(o => o.id === orderId);
if (!order) return;
// Database Update
await supabase.from('orders').update({ status: newStatus }).eq('id', orderId);
// Inventory Logic if Received
if (newStatus === 'Received' && order.status !== 'Received') {
const newInventoryItems: InventoryItem[] = order.items.map(item => ({
id: crypto.randomUUID(),
name: item.name,
sku: `SKU-${Math.random().toString(36).substr(2, 4).toUpperCase()}`,
quantity: item.quantity,
avgCostBRL: item.priceBRL * (1 + overheadPercent / 100),
marketPriceBRL: item.marketPriceBRL,
lastSupplier: item.store
}));
await supabase.from('inventory').insert(newInventoryItems.map(i => ({
id: i.id, name: i.name, sku: i.sku, quantity: i.quantity,
avg_cost_brl: i.avgCostBRL, market_price_brl: i.marketPriceBRL, last_supplier: i.lastSupplier
})));
// Refresh Inventory
const { data } = await supabase.from('inventory').select('*').order('created_at', { ascending: false });
if (data) setInventory(data);
}
// Local Update
setOrders(prev => prev.map(o => o.id === orderId ? { ...o, status: newStatus } : o));
};
const deleteOrder = async (orderId: string) => {
// Database Delete
const { error } = await supabase.from('orders').delete().eq('id', orderId);
if (error) {
console.error("Error deleting order:", error);
alert("Erro ao excluir pedido.");
} else {
// Local Update
setOrders(prev => prev.filter(o => o.id !== orderId));
}
};
const calculateShoppingTotals = () => {
const totalParaguayBRL = shoppingList.reduce((acc, item) => acc + (item.priceBRL * item.quantity), 0);
const overheadMultiplier = useOverhead ? (1 + overheadPercent / 100) : 1;
const totalCostWithOverhead = totalParaguayBRL * overheadMultiplier;
const totalUSD = shoppingList.reduce((acc, item) => acc + (item.priceUSD * item.quantity), 0);
const totalApproxProfit = shoppingList.reduce((acc, item) => {
const costWithOverhead = item.priceBRL * overheadMultiplier;
const profitPerUnit = item.marketPriceBRL - costWithOverhead;
return acc + (profitPerUnit * item.quantity);
}, 0);
return { totalParaguayBRL, totalCostWithOverhead, totalUSD, totalApproxProfit };
};
const resumeOrder = (orderId: string) => {
const order = orders.find(o => o.id === orderId);
if (!order) return;
setShoppingList(order.items);
setActiveOrderId(order.id);
};
const saveOrderAsQuotation = async () => {
if (shoppingList.length === 0) return;
const totals = calculateShoppingTotals();
if (activeOrderId) {
// Update
const updatePayload = {
items: shoppingList, // Helper handles jsonb
total_usd: totals.totalUSD,
total_brl: totals.totalParaguayBRL,
total_cost_with_overhead: totals.totalCostWithOverhead,
estimated_profit: totals.totalApproxProfit,
supplier_name: shoppingList[0]?.store || 'Múltiplos'
};
await supabase.from('orders').update(updatePayload).eq('id', activeOrderId);
setOrders(prev => prev.map(o => o.id === activeOrderId ? {
...o, items: shoppingList, totalUSD: totals.totalUSD, totalBRL: totals.totalParaguayBRL,
totalCostWithOverhead: totals.totalCostWithOverhead, estimatedProfit: totals.totalApproxProfit,
supplierName: updatePayload.supplier_name
} : o));
setActiveOrderId(null);
} else {
// Create
const newOrder: Order = {
id: crypto.randomUUID(),
date: new Date().toISOString(),
items: [...shoppingList],
totalUSD: totals.totalUSD,
totalBRL: totals.totalParaguayBRL,
totalCostWithOverhead: totals.totalCostWithOverhead,
estimatedProfit: totals.totalApproxProfit,
status: 'Pending',
supplierName: shoppingList[0]?.store || 'Múltiplos'
};
// Map to DB columns
const dbOrder = {
id: newOrder.id,
date: newOrder.date,
status: newOrder.status,
total_usd: newOrder.totalUSD,
total_brl: newOrder.totalBRL,
total_cost_with_overhead: newOrder.totalCostWithOverhead,
estimated_profit: newOrder.estimatedProfit,
items: newOrder.items,
supplier_name: newOrder.supplierName
};
const { error } = await supabase.from('orders').insert([dbOrder]);
if (error) {
console.error("Error saving order:", error);
alert(`Erro ao salvar pedido: ${error.message} (${error.details || ''})`);
return;
}
setOrders([newOrder, ...orders]);
alert("Pedido salvo com sucesso!");
}
setShoppingList([]);
};
// --- FINANCIAL ACTIONS ---
const addTransaction = async (tx: Omit<Transaction, 'id'>) => {
const id = crypto.randomUUID();
const newTx = {
...tx,
id,
user_id: user?.id,
status: tx.status || 'Pending',
payment_method: tx.paymentMethod || 'Cash',
due_date: tx.dueDate || new Date().toISOString()
};
// Map camelCase to snake_case for DB if needed, but Supabase JS client handles it if keys match DB columns
// or if we map explicitly. Let's map explicitly to be safe.
const dbTx = {
id: newTx.id,
date: newTx.date,
type: newTx.type,
category: newTx.category,
amount: newTx.amount,
description: newTx.description,
status: newTx.status,
payment_method: newTx.paymentMethod, // DB column: payment_method
due_date: newTx.dueDate, // DB column: due_date
user_id: newTx.user_id
};
const { error } = await supabase.from('transactions').insert([dbTx]);
if (error) console.error("Error adding transaction:", error);
// We update local state with the camelCase version for UI
setTransactions(prev => [{ ...tx, id: newTx.id, status: newTx.status, paymentMethod: newTx.paymentMethod, dueDate: newTx.dueDate }, ...prev]);
};
const updateTransaction = async (id: string, updates: Partial<Transaction>) => {
// Map updates
const dbUpdates: any = { ...updates };
if (updates.paymentMethod) dbUpdates.payment_method = updates.paymentMethod;
if (updates.dueDate) dbUpdates.due_date = updates.dueDate;
const { error } = await supabase.from('transactions').update(dbUpdates).eq('id', id);
if (error) {
console.error("Error updating transaction:", error);
alert("Erro ao atualizar transação");
} else {
setTransactions(prev => prev.map(t => t.id === id ? { ...t, ...updates } : t));
}
};
const deleteTransaction = async (id: string) => {
await supabase.from('transactions').delete().eq('id', id);
setTransactions(prev => prev.filter(t => t.id !== id));
};
const addCustomer = async (customer: Omit<Customer, 'id' | 'totalPurchased'>): Promise<Customer | null> => {
const id = crypto.randomUUID();
const newCustomer = { ...customer, id, totalPurchased: 0 };
const dbCustomer = {
id: newCustomer.id,
name: newCustomer.name,
email: newCustomer.email,
phone: newCustomer.phone,
city: newCustomer.city,
status: newCustomer.status,
total_purchased: 0,
user_id: user?.id
};
const { data, error } = await supabase.from('customers').insert([dbCustomer]).select();
if (error) {
console.error("Error adding customer:", error);
return null;
}
setCustomers(prev => [...prev, newCustomer]);
return newCustomer; // Return the object
};
const updateCustomer = async (id: string, updates: Partial<Customer>) => {
// Map updates to snake_case if necessary, or pass mostly as is if Keys match
const dbUpdates: any = { ...updates };
if (updates.totalPurchased) dbUpdates.total_purchased = updates.totalPurchased;
await supabase.from('customers').update(dbUpdates).eq('id', id);
setCustomers(prev => prev.map(c => c.id === id ? { ...c, ...updates } : c));
};
const deleteCustomer = async (id: string) => {
await supabase.from('customers').delete().eq('id', id);
setCustomers(prev => prev.filter(c => c.id !== id));
};
const getFinancialSummary = () => {
const totalIncome = transactions.filter(t => t.type === 'Income').reduce((acc, t) => acc + t.amount, 0);
const totalExpense = transactions.filter(t => t.type === 'Expense').reduce((acc, t) => acc + t.amount, 0);
return {
totalIncome,
totalExpense,
balance: totalIncome - totalExpense,
recentTransactions: transactions.slice(0, 5)
};
};
// --- SUPPLIER ACTIONS ---
const addSupplier = async (supplier: Omit<Supplier, 'id'>) => {
const id = crypto.randomUUID();
const newSupplier = { ...supplier, id, user_id: user?.id };
await supabase.from('suppliers').insert([newSupplier]);
setSuppliers(prev => [...prev, newSupplier]);
};
const updateSupplier = async (id: string, updates: Partial<Supplier>) => {
await supabase.from('suppliers').update(updates).eq('id', id);
setSuppliers(prev => prev.map(s => s.id === id ? { ...s, ...updates } : s));
};
const deleteSupplier = async (id: string) => {
await supabase.from('suppliers').delete().eq('id', id);
setSuppliers(prev => prev.filter(s => s.id !== id));
};
// PRODUCT ACTIONS
const addProduct = async (product: Omit<InventoryItem, 'id'>) => {
if (!user) {
alert("Erro: Usuário não autenticado. O sistema perdeu a conexão ou você não está logado.");
return;
}
console.log("Attempting to add product:", product);
const id = crypto.randomUUID();
const newProduct = { ...product, id };
// In a real app, 'products' and 'inventory' might be separate tables.
// Here we treat 'inventory' as the master product list for simplicity as per current structure.
const { data, error } = await supabase.from('inventory').insert([{
id: newProduct.id,
name: newProduct.name,
sku: newProduct.sku,
ean: newProduct.ean,
quantity: newProduct.quantity,
avg_cost_brl: newProduct.avgCostBRL,
market_price_brl: newProduct.marketPriceBRL,
last_supplier: newProduct.lastSupplier,
user_id: user?.id
}]).select();
if (error) {
console.error("Supabase Error adding product:", error);
throw error;
}
console.log("Product added successfully, DB response:", data);
setInventory(prev => [newProduct, ...prev]);
};
const updateProduct = async (id: string, updates: Partial<InventoryItem>) => {
// Map to snake_case for DB
const dbUpdates: any = { ...updates };
if (updates.avgCostBRL) dbUpdates.avg_cost_brl = updates.avgCostBRL;
if (updates.marketPriceBRL) dbUpdates.market_price_brl = updates.marketPriceBRL;
if (updates.lastSupplier) dbUpdates.last_supplier = updates.lastSupplier;
await supabase.from('inventory').update(dbUpdates).eq('id', id);
setInventory(prev => prev.map(p => p.id === id ? { ...p, ...updates } : p));
};
const deleteProduct = async (id: string) => {
await supabase.from('inventory').delete().eq('id', id);
setInventory(prev => prev.filter(p => p.id !== id));
};
// SALES LOGIC & MANAGEMENT
const importSales = async (channel: SalesChannel) => {
setLoading(true);
// MOCK DELAY & DATA
await new Promise(r => setTimeout(r, 1500));
const newSales: Sale[] = Array.from({ length: Math.floor(Math.random() * 3) + 2 }).map((_, i) => ({
id: crypto.randomUUID(),
date: new Date().toISOString(),
customerName: `Cliente ${channel} ${Math.floor(Math.random() * 1000)}`,
items: [
{ id: 'mock-item', name: 'Produto Importado ' + (i + 1), quantity: 1, salePrice: Math.random() * 200 + 50 }
],
total: 0, // Calculated below
status: 'Pending' as const,
channel: channel,
externalId: `MLB-${Math.floor(Math.random() * 1000000)}`,
isStockLaunched: false,
isFinancialLaunched: false
})).map(s => ({ ...s, total: s.items.reduce((acc, i) => acc + (i.quantity * i.salePrice), 0) }));
setSales(prev => [...newSales, ...prev]); // Add to top
setLoading(false);
};
const updateSale = (id: string, updates: Partial<Sale>) => {
setSales(prev => prev.map(s => {
if (s.id !== id) return s;
const updatedSale = { ...s, ...updates };
// Sincronização Automática (Mock logic linking checkboxes to actual actions)
if (updates.isStockLaunched && !s.isStockLaunched) {
// Trigger stock deduction logic theoretically
console.log("Stock launched for sale", id);
}
if (updates.isFinancialLaunched && !s.isFinancialLaunched) {
addTransaction({
date: new Date().toISOString(),
type: 'Income',
category: 'Venda Marketplace',
amount: updatedSale.total,
description: `Venda ${updatedSale.channel} #${updatedSale.externalId || id}`,
status: 'Paid',
paymentMethod: 'Other'
});
}
return updatedSale;
}));
};
const registerSale = async (items: { id: string, quantity: number, salePrice: number }[], customerId?: string, paymentMethod: PaymentMethod = 'Cash') => {
// 1. Deduct from Inventory
const updatedInventory = inventory.map(invItem => {
const soldItem = items.find(i => i.id === invItem.id);
if (soldItem) {
return { ...invItem, quantity: Math.max(0, invItem.quantity - soldItem.quantity) };
}
return invItem;
});
setInventory(updatedInventory);
// 2. Add Transaction (Income)
const totalAmount = items.reduce((acc, item) => acc + (item.quantity * item.salePrice), 0);
const cust = customers.find(c => c.id === customerId);
const transaction: Omit<Transaction, 'id'> = {
date: new Date().toISOString(),
type: 'Income',
category: 'Venda de Produtos',
amount: totalAmount,
description: `Venda balcão - Cliente: ${cust?.name || 'Não Id.'}`,
status: 'Paid',
paymentMethod: paymentMethod, // Now correctly using the argument
dueDate: new Date().toISOString()
};
await addTransaction(transaction); // This is good, but Sales.tsx is calling registerSale which handles logic.
// ACTUALLY: registerSale needs to accept payment method or we need to update the transaction logic inside it.
// Modifying registerSale signature to accept PaymentMethod would be best practice.
// 3. Register as a Sale Record
const newSale: Sale = {
id: crypto.randomUUID(),
date: new Date().toISOString(),
customerId: customerId,
customerName: cust ? cust.name : 'Cliente Balcão',
items: items.map(i => {
const inv = inventory.find(inv => inv.id === i.id);
return {
id: i.id,
name: inv?.name || 'Item',
quantity: i.quantity,
salePrice: i.salePrice,
costPrice: inv?.avgCostBRL || 0 // Capture cost at moment of sale
};
}),
total: totalAmount,
status: 'Completed',
channel: 'Local',
isStockLaunched: true, // Auto-launched for POS
isFinancialLaunched: true // Auto-launched for POS
};
setSales(prev => [newSale, ...prev]);
// 4. Update Customer (if exists) - Mocked for now
if (customerId) {
console.log(`Updated customer ${customerId} total purchased`);
}
};
const value = {
// Auth
user, session, authLoading, signIn, signOut, isAdmin, updateUserRole,
// Data
products, shoppingList, orders, inventory, suppliers, users, searchTerm, loading, error, selectedProduct, overheadPercent, exchangeRate, activeOrderId,
searchLoading, searchError, searchType, setSearchType,
customers, transactions,
setSearchTerm, setSelectedProduct, handleSearch, handleOpportunitySearch, addToShoppingList, removeFromShoppingList, updateShoppingItemQuantity, updateOrderStatus, saveOrderAsQuotation, calculateShoppingTotals, resumeOrder, deleteOrder,
addTransaction, deleteTransaction, updateTransaction, addCustomer, updateCustomer, deleteCustomer, getFinancialSummary,
addSupplier, updateSupplier, deleteSupplier, registerSale,
addProduct, updateProduct, deleteProduct, settings, updateSettings,
sales, importSales, updateSale,
useOverhead, setUseOverhead
};
return <CRMContext.Provider value={value}>{children}</CRMContext.Provider>;
};
export const useCRM = () => {
const context = useContext(CRMContext);
if (!context) throw new Error('useCRM must be used within a CRMProvider');
return context;
};

49
context/ThemeContext.tsx Normal file
View file

@ -0,0 +1,49 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'dark' | 'light' | 'ocean';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setTheme] = useState<Theme>(() => {
// Check local storage or system preference
const saved = localStorage.getItem('theme');
if (saved) return saved as Theme;
return 'dark'; // Default
});
useEffect(() => {
const root = window.document.documentElement;
// Remove old classes
root.classList.remove('theme-light', 'theme-ocean', 'dark');
// Add new class
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.add(`theme-${theme}`);
}
localStorage.setItem('theme', theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};

164
index.css Normal file
View file

@ -0,0 +1,164 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
/* Dark Theme (Default) */
:root {
--background: 240 10% 4%;
/* #0a0a0c - Deeper black/zinc */
--foreground: 0 0% 98%;
--card: 240 10% 6%;
/* Slightly lighter than bg */
--card-foreground: 0 0% 98%;
--popover: 240 10% 6%;
--popover-foreground: 0 0% 98%;
--primary: 263 70% 50%;
/* Deep Purple/Indigo */
--primary-foreground: 210 40% 98%;
--secondary: 240 4% 16%;
--secondary-foreground: 0 0% 98%;
--muted: 240 4% 16%;
--muted-foreground: 240 5% 65%;
--accent: 240 4% 16%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 4% 16%;
--input: 240 4% 16%;
--ring: 240 4.9% 83.9%;
--radius: 1rem;
}
/* Light Theme (Simple/Clean) */
.theme-light {
--background: 210 40% 98%;
/* Soft blue-white */
--foreground: 222 47% 11%;
/* Dark Navy Text */
--card: 0 0% 100%;
/* Pure White */
--card-foreground: 222 47% 11%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
--primary: 221 83% 53%;
/* Classic Blue */
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222 47% 11%;
--muted: 210 40% 96.1%;
--muted-foreground: 215 16% 47%;
--accent: 210 40% 96.1%;
--accent-foreground: 222 47% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 221 83% 53%;
}
/* Ocean Theme (Deep Blue) */
.theme-ocean {
--background: 222 47% 11%;
/* Navy BG */
--foreground: 210 40% 98%;
--card: 217 33% 17%;
/* Lighter Navy Card */
--card-foreground: 210 40% 98%;
--popover: 217 33% 17%;
--popover-foreground: 210 40% 98%;
--primary: 199 89% 48%;
/* Cyan/Blue */
--primary-foreground: 210 40% 98%;
--secondary: 217 19% 27%;
--secondary-foreground: 210 40% 98%;
--muted: 217 19% 27%;
--muted-foreground: 215 20% 65%;
--accent: 217 19% 27%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217 19% 27%;
--input: 217 19% 27%;
--ring: 199 89% 48%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* Custom Scrollbar for Premium Feel */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--border));
border-radius: 99px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground));
}
/* Glassmorphism Utilities */
.glass-panel {
background: hsl(var(--background) / 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
}
.glass-card {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
}
/* Light Theme Overrides for Glass */
.theme-light .glass-card {
background: white;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05);
border: 1px solid #e2e8f0;
}
.theme-light .glass-panel {
background: rgba(255, 255, 255, 0.8);
border: 1px solid #e2e8f0;
}

83
index.html Normal file
View file

@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Arbitra System</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['"Plus Jakarta Sans"', 'sans-serif'],
},
colors: {
// Overriding defaults for a Premium Neutral palette
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
}
}
}
</script>
<script type="importmap">
{
"imports": {
"lucide-react": "https://esm.sh/lucide-react@^0.562.0",
"@google/genai": "https://esm.sh/@google/genai@^1.35.0",
"react-dom/": "https://esm.sh/react-dom@^19.2.3/",
"recharts": "https://esm.sh/recharts@^3.6.0",
"react": "https://esm.sh/react@^19.2.3",
"react/": "https://esm.sh/react@^19.2.3/"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-slate-950 text-slate-100 antialiased selection:bg-indigo-500 selection:text-white">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

16
index.tsx Normal file
View file

@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

105
layouts/Header.tsx Normal file
View file

@ -0,0 +1,105 @@
import React from 'react';
import { TrendingUp, Bell, Search, Command, Menu } from 'lucide-react';
import { useCRM } from '../context/CRMContext';
import { useLocation } from 'react-router-dom';
import clsx from 'clsx';
interface HeaderProps {
onMenuClick?: () => void;
}
const Header: React.FC<HeaderProps> = ({ onMenuClick }) => {
const { exchangeRate, orders } = useCRM();
const location = useLocation();
// Mapping English paths to proper titles if needed, or keeping English for 'Premium' feel
const getTitle = () => {
const path = location.pathname.substring(1);
if (!path) return 'Dashboard';
const titles: Record<string, string> = {
'sales': 'Vendas',
'sourcing': 'Sourcing', // or Arbitragem
'products': 'Produtos',
'orders': 'Pedidos',
'financial': 'Financeiro',
'customers': 'Clientes',
'inventory': 'Estoque',
'suppliers': 'Fornecedores',
'reports': 'Relatórios',
'users': 'Usuários',
'settings': 'Configurações'
};
return titles[path] || path.charAt(0).toUpperCase() + path.slice(1);
};
const totalProfit = orders
.filter(o => o.status === 'Received')
.reduce((acc, o) => acc + o.estimatedProfit, 0);
return (
<header className="h-[80px] px-4 md:px-8 flex items-center justify-between sticky top-0 z-40 bg-background/80 backdrop-blur-md border-b border-border/40">
{/* Left: Breadcrumb/Title */}
<div className="flex items-center gap-3">
<button
onClick={onMenuClick}
className="md:hidden p-2 -ml-2 text-muted-foreground hover:text-foreground"
>
<Menu size={24} />
</button>
<div className="flex flex-col justify-center">
<div className="flex items-center gap-2 text-muted-foreground text-[10px] uppercase font-bold tracking-widest">
<span>App</span>
<span className="text-border">/</span>
<span className="text-foreground">{getTitle()}</span>
</div>
<h2 className="text-xl font-bold tracking-tight text-foreground">{getTitle()}</h2>
</div>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-4 md:gap-6">
{/* Search Bar (Visual Only) */}
<div className="hidden md:flex items-center gap-2 bg-secondary/50 border border-border/50 px-3 py-2 rounded-lg text-sm text-muted-foreground w-64 hover:border-border transition-colors">
<Search size={14} />
<span className="flex-1">Buscar...</span>
<div className="flex items-center gap-[1px]">
<div className="bg-border p-1 rounded-[4px]"><Command size={10} className="text-foreground" /></div>
<span className="text-[10px] font-bold">K</span>
</div>
</div>
<div className="h-6 w-[1px] bg-border"></div>
{/* Stats */}
<div className="flex items-center gap-6">
<div className="flex flex-col items-end group cursor-help">
<span className="text-[9px] font-bold text-muted-foreground uppercase tracking-widest mb-0.5 group-hover:text-foreground transition-colors">Dólar Hoje</span>
<span className="text-sm font-bold text-foreground font-mono bg-secondary/50 px-2 rounded">R$ {exchangeRate.toFixed(2)}</span>
</div>
<div className="flex items-center gap-3 pl-6 border-l border-border">
<div className="flex flex-col items-end">
<span className="text-[9px] font-bold text-muted-foreground uppercase tracking-widest mb-0.5">Lucro Total</span>
<span className={clsx(
"text-sm font-bold font-mono px-2 rounded",
totalProfit > 0 ? "text-emerald-400 bg-emerald-400/10" : "text-foreground"
)}>
R$ {totalProfit.toLocaleString('pt-BR')}
</span>
</div>
</div>
</div>
<button className="w-10 h-10 rounded-full border border-border bg-secondary/30 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-secondary transition-all">
<Bell size={18} />
</button>
</div>
</header>
);
};
export default Header;

109
layouts/Sidebar.tsx Normal file
View file

@ -0,0 +1,109 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { LayoutDashboard, Search, History, Boxes, Store, Users, TrendingUp, LogOut, Wallet, ShoppingCart, Package, BarChart3, Settings } from 'lucide-react';
import { useCRM } from '../context/CRMContext';
import clsx from 'clsx';
interface SidebarProps {
isOpen?: boolean;
onClose?: () => void;
}
const Sidebar: React.FC<SidebarProps> = ({ isOpen = false, onClose }) => {
const { user, isAdmin, signOut } = useCRM();
const currentUser = user;
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/sales', icon: ShoppingCart, label: 'Vendas' },
{ to: '/sourcing', icon: Search, label: 'Sourcing' },
{ to: '/products', icon: Package, label: 'Produtos' },
{ to: '/orders', icon: History, label: 'Pedidos' },
{ to: '/financial', icon: Wallet, label: 'Financeiro' },
{ to: '/customers', icon: Users, label: 'Clientes' },
{ to: '/inventory', icon: Boxes, label: 'Estoque' },
{ to: '/suppliers', icon: Store, label: 'Fornecedores' },
{ to: '/reports', icon: BarChart3, label: 'Relatórios' },
{ to: '/users', icon: Users, label: 'Usuários' },
{ to: '/settings', icon: Settings, label: 'Configurações' }
];
return (
<>
{/* Mobile Overlay */}
{isOpen && (
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 md:hidden"
onClick={onClose}
/>
)}
<aside className={clsx(
"fixed inset-y-0 left-0 z-50 w-64 bg-card/95 backdrop-blur-xl border-r border-border flex flex-col justify-between transition-transform duration-300 md:translate-x-0 md:relative md:bg-card/80",
isOpen ? "translate-x-0" : "-translate-x-full"
)}>
<div>
{/* Logo Area */}
<div className="h-20 flex items-center justify-between px-6 md:justify-start md:px-8 border-b border-border">
<div className="flex items-center">
<div className="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-500/20">
<span className="text-white font-bold text-xl">P</span>
</div>
<span className="block ml-3 font-bold text-lg bg-clip-text text-transparent bg-gradient-to-r from-white to-slate-400">
Paraguai
</span>
</div>
{/* Close button for mobile only */}
<button onClick={onClose} className="md:hidden text-muted-foreground hover:text-foreground">
<span className="sr-only">Fechar</span>
{/* Using a simple X approach manually or just relying on overlay */}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18" /><path d="m6 6 18 18" /></svg>
</button>
</div>
</div>
{/* NAV */}
<nav className="flex-grow px-4 space-y-1 overflow-y-auto pt-4">
<div className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest px-4 mb-3 opacity-50">Menu</div>
{navItems.filter(item => item.label !== 'Usuários' || isAdmin).map((item) => (
<NavLink
key={item.to}
to={item.to}
onClick={onClose} // Auto close on mobile nav click
className={({ isActive }) => clsx(
"group flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200 outline-none",
isActive
? 'bg-secondary text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
)}
>
{({ isActive }) => (
<>
<item.icon size={18} className={clsx("transition-transform group-hover:scale-110", isActive ? "text-foreground" : "text-muted-foreground")} />
<span>{item.label}</span>
{isActive && <div className="ml-auto w-1.5 h-1.5 rounded-full bg-foreground shadow-[0_0_8px_rgba(255,255,255,0.5)]"></div>}
</>
)}
</NavLink>
))}
</nav>
{/* USER FOOTER */}
<div className="p-6 border-t border-border mt-auto bg-card/50">
<div onClick={signOut} className="flex items-center gap-3 p-3 rounded-2xl bg-secondary/30 border border-border/50 hover:bg-secondary/50 transition-colors cursor-pointer group">
<div className="w-9 h-9 rounded-full overflow-hidden border border-border ring-2 ring-transparent group-hover:ring-border/50 transition-all">
<img src={currentUser?.avatar || "https://api.dicebear.com/7.x/avataaars/svg?seed=Admin"} alt="User" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-foreground truncate">{currentUser?.name || "Admin"}</p>
<p className="text-[10px] text-muted-foreground truncate">Administrador</p>
</div>
<LogOut size={14} className="text-muted-foreground group-hover:text-destructive transition-colors" />
</div>
</div>
</aside>
</>
);
};
export default Sidebar;

175
layouts/TopBar.tsx Normal file
View file

@ -0,0 +1,175 @@
import React, { useState } from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import {
LayoutDashboard, Search, History, Boxes, Store, Users, TrendingUp, LogOut, Wallet,
ChevronDown, Package, FileText, Factory, Menu, ShoppingCart, Settings, BarChart3
} from 'lucide-react';
import { useCRM } from '../context/CRMContext';
import clsx from 'clsx';
import Logo from '../components/Logo';
const TopBar: React.FC = () => {
const { users, signOut } = useCRM();
const currentUser = users[0];
const navigate = useNavigate();
// Dropdown States
const [openMenu, setOpenMenu] = useState<string | null>(null);
const handleSignOut = async () => {
await signOut();
navigate('/login');
};
const navStructure = [
{
label: 'Dashboard',
to: '/',
icon: LayoutDashboard,
type: 'link'
},
{
label: 'Vendas',
to: '/sales',
icon: ShoppingCart,
type: 'link'
},
{
label: 'Arbitragem',
icon: Search,
type: 'dropdown',
items: [
{ label: 'Sourcing Intel', to: '/sourcing', icon: Search },
{ label: 'Minhas Ordens', to: '/orders', icon: History },
]
},
{
label: 'Cadastros',
icon: FileText,
type: 'dropdown',
items: [
{ label: 'Clientes', to: '/customers', icon: Users },
{ label: 'Fornecedores', to: '/suppliers', icon: Store },
{ label: 'Produtos', to: '/products', icon: Package },
{ label: 'Estoque', to: '/inventory', icon: Boxes },
{ label: 'Usuários', to: '/users', icon: Users },
]
},
{
label: 'Produção',
icon: Factory,
type: 'nav-link', // Future placeholder
to: '#', // Placeholder
items: [], // Empty for now or future items
disabled: true
},
{
label: 'Relatórios',
to: '/reports',
icon: BarChart3,
type: 'link'
},
{
label: 'Financeiro',
to: '/financial',
icon: Wallet,
type: 'link'
}
];
return (
<header className="h-[72px] bg-background/80 backdrop-blur-xl border-b border-border flex items-center px-8 justify-between sticky top-0 z-50">
{/* LOGO */}
<div onClick={() => navigate('/')} className="cursor-pointer">
<Logo />
</div>
{/* NAV MENU CASCATA */}
<nav className="flex-1 flex justify-center items-center gap-2">
{navStructure.map((item, idx) => (
<div
key={idx}
className="relative group"
onMouseEnter={() => item.type === 'dropdown' && setOpenMenu(item.label)}
onMouseLeave={() => setOpenMenu(null)}
>
{item.type === 'link' ? (
<NavLink
to={item.to!}
className={({ isActive }) => clsx(
"px-5 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 transition-all duration-200 hover:bg-accent/50",
isActive ? "text-foreground bg-accent shadow-sm border border-border" : "text-muted-foreground"
)}
>
<item.icon size={16} />
{item.label}
</NavLink>
) : (
<button className={clsx(
"px-5 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 transition-all duration-200 hover:bg-accent/50 group-hover:text-foreground",
openMenu === item.label ? "text-foreground bg-accent" : "text-muted-foreground"
)}>
<item.icon size={16} />
{item.label}
<ChevronDown size={12} className={clsx("transition-transform duration-200", openMenu === item.label && "rotate-180")} />
</button>
)}
{/* DROPDOWN MENU */}
{item.type === 'dropdown' && (
<div className={clsx(
"absolute top-full left-1/2 -translate-x-1/2 pt-4 w-[240px] transition-all duration-200 origin-top z-50",
openMenu === item.label ? "opacity-100 scale-100 visible" : "opacity-0 scale-95 invisible"
)}>
<div className="bg-popover border border-border rounded-2xl shadow-xl p-2 flex flex-col gap-1 backdrop-blur-3xl">
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-popover border-t border-l border-border rotate-45"></div>
{item.items?.map((subItem, subIdx) => (
<NavLink
key={subIdx}
to={subItem.to}
className={({ isActive }) => clsx(
"w-full text-left px-4 py-3 rounded-xl text-xs font-bold flex items-center gap-3 transition-colors hover:bg-accent",
isActive ? "text-foreground bg-accent" : "text-muted-foreground"
)}
>
{({ isActive }) => (
<>
<subItem.icon size={14} className={isActive ? "text-primary" : "text-slate-500"} />
{subItem.label}
</>
)}
</NavLink>
))}
</div>
</div>
)}
</div>
))}
</nav>
{/* USER ACTIONS */}
<div className="flex items-center gap-4 w-[200px] justify-end">
<button
onClick={() => navigate('/settings')}
className="w-10 h-10 rounded-2xl bg-secondary/50 border border-border flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-secondary transition-all"
title="Configurações"
>
<Settings size={20} />
</button>
<div onClick={handleSignOut} className="flex items-center gap-3 pl-4 pr-2 py-1.5 rounded-2xl bg-secondary/50 border border-border hover:bg-destructive/10 hover:border-destructive/20 transition-all cursor-pointer group">
<div className="text-right hidden xl:block">
<p className="text-xs font-bold text-foreground leading-none">{currentUser?.name || 'Admin'}</p>
<p className="text-[9px] text-muted-foreground font-bold uppercase mt-0.5">Online</p>
</div>
<div className="w-8 h-8 rounded-full bg-slate-800 border border-border flex items-center justify-center group-hover:scale-105 transition-transform overflow-hidden">
<img src={currentUser?.avatar || "https://api.dicebear.com/7.x/avataaars/svg?seed=Admin"} alt="Avatar" className="w-full h-full object-cover" />
</div>
</div>
</div>
</header>
);
};
export default TopBar;

5
metadata.json Normal file
View file

@ -0,0 +1,5 @@
{
"name": "Paraguay Arbitrage CRM",
"description": "Professional ERP/CRM solution for Paraguay arbitrage. Features real-time sourcing via Gemini, order tracking, profit history (Facebook-based), and supplier management.",
"requestFramePermissions": []
}

45
migration.sql Normal file
View file

@ -0,0 +1,45 @@
-- MIGRATION SCRIPT
-- Run this to update your existing tables with the new columns
-- 1. Add 'ean' to inventory (This was the error you saw)
alter table public.inventory add column if not exists ean text;
-- 2. Add 'user_id' to all key tables (For the authentication fix I made)
alter table public.inventory add column if not exists user_id uuid references auth.users;
alter table public.suppliers add column if not exists user_id uuid references auth.users;
alter table public.customers add column if not exists user_id uuid references auth.users;
alter table public.orders add column if not exists user_id uuid references auth.users;
alter table public.transactions add column if not exists user_id uuid references auth.users;
-- 3. Ensure Settings table exists (in case it wasn't created yet)
create table if not exists public.settings (
id uuid default uuid_generate_v4() primary key,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
updated_at timestamp with time zone default timezone('utc'::text, now()),
company_name text,
cnpj text,
ie text,
default_overhead numeric default 20,
default_exchange numeric default 5.65,
brazil_api_token text,
melhor_envio_token text,
bling_token text,
tiny_token text,
gemini_key text,
certificate_password text,
nfe_serie text default '1',
nfe_number text,
nfe_environment text default 'homologacao',
smtp_host text,
smtp_port text,
smtp_user text,
smtp_pass text,
auto_sync_sales boolean default true,
auto_sync_stock boolean default true,
user_id uuid references auth.users
);
-- 4. Re-apply RLS policies just in case
alter table public.inventory enable row level security;
drop policy if exists "Enable all for authenticated users" on public.inventory;
create policy "Enable all for authenticated users" on public.inventory for all to authenticated using (true) with check (true);

125
n8n-scraping-workflow.json Normal file
View file

@ -0,0 +1,125 @@
{
"name": "Scrape Compras Paraguai",
"nodes": [
{
"parameters": {
"path": "search",
"responseMode": "lastNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [
0,
0
],
"id": "webhook-trigger",
"name": "Webhook"
},
{
"parameters": {
"url": "=https://www.comprasparaguai.com.br/busca/?q={{ $json.query.query.replace(/ /g, '+') }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [
220,
0
],
"id": "http-request",
"name": "Fetch HTML"
},
{
"parameters": {
"mode": "html",
"dataPropertyName": "data",
"selectors": [
{
"name": "products",
"selector": ".product-item",
"returnArray": true,
"properties": [
{
"name": "name",
"selector": ".product-title",
"value": "text"
},
{
"name": "priceUSD",
"selector": ".price-dolar",
"value": "text"
},
{
"name": "store",
"selector": ".store-name",
"value": "text"
},
{
"name": "link",
"selector": "a",
"attribute": "href"
}
]
}
]
},
"type": "n8n-nodes-base.htmlExtract",
"typeVersion": 1,
"position": [
440,
0
],
"id": "html-extract",
"name": "Extract Data"
},
{
"parameters": {
"jsCode": "const products = $input.all()[0].json.products || [];\n\nconst cleanProducts = products.map(p => ({\n name: p.name.trim(),\n priceUSD: parseFloat(p.priceUSD.replace('US$ ', '').replace(',', '.').trim()),\n priceBRL: parseFloat(p.priceUSD.replace('US$ ', '').replace(',', '.').trim()) * 5.75,\n store: p.store.trim(),\n url: 'https://www.comprasparaguai.com.br' + p.link\n})).filter(p => !isNaN(p.priceUSD));\n\nreturn cleanProducts;"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
0
],
"id": "cleanup-code",
"name": "Clean Data"
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Fetch HTML",
"type": "main",
"index": 0
}
]
]
},
"Fetch HTML": {
"main": [
[
{
"node": "Extract Data",
"type": "main",
"index": 0
}
]
]
},
"Extract Data": {
"main": [
[
{
"node": "Clean Data",
"type": "main",
"index": 0
}
]
]
}
}
}

3255
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

29
package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "paraguay-arbitrage-crm",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@google/genai": "^1.35.0",
"@supabase/supabase-js": "^2.90.1",
"clsx": "^2.1.1",
"framer-motion": "^12.26.1",
"lucide-react": "^0.562.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-router-dom": "^7.12.0",
"recharts": "^3.6.0",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

167
pages/Customers.tsx Normal file
View file

@ -0,0 +1,167 @@
import React, { useState } from 'react';
import { Users, Plus, Edit2, Trash2, Mail, Phone, MapPin } from 'lucide-react';
import { useCRM } from '../context/CRMContext';
import { Customer } from '../types';
const Customers: React.FC = () => {
const { customers, addCustomer, updateCustomer, deleteCustomer } = useCRM();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [formData, setFormData] = useState<Partial<Customer>>({
name: '', email: '', phone: '', city: '', status: 'Active'
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (editingId) {
updateCustomer(editingId, formData);
} else {
addCustomer(formData as any);
}
closeModal();
};
const openModal = (customer?: Customer) => {
if (customer) {
setEditingId(customer.id);
setFormData(customer);
} else {
setEditingId(null);
setFormData({ name: '', email: '', phone: '', city: '', status: 'Active' });
}
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setEditingId(null);
};
return (
<div className="space-y-8 animate-in fade-in duration-500">
{/* HEADER */}
<div className="flex justify-between items-center glass-card p-6 rounded-2xl border border-white/5 shadow-sm">
<div>
<h2 className="text-xl font-bold text-white tracking-tight">Carteira de Clientes</h2>
<p className="text-xs text-slate-500 tracking-wide mt-1">Gestão de Relacionamento (CRM)</p>
</div>
<button
onClick={() => openModal()}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white px-5 py-2.5 rounded-lg font-bold text-sm transition-all shadow-lg shadow-indigo-600/20 active:scale-95"
>
<Plus size={16} /> Novo Cliente
</button>
</div>
{/* LIST */}
<div className="glass-card rounded-2xl border border-white/5 overflow-hidden">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-black/20 text-[10px] font-bold text-slate-500 uppercase tracking-widest border-b border-white/5">
<th className="px-6 py-4">Cliente</th>
<th className="px-6 py-4">Status</th>
<th className="px-6 py-4">Contato</th>
<th className="px-6 py-4 text-right">LTV (Total Pago)</th>
<th className="px-6 py-4 text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{customers.map(c => (
<tr key={c.id} className="hover:bg-white/[0.02] transition-colors group">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-slate-800 rounded-lg flex items-center justify-center text-white font-bold text-xs">
{c.name.charAt(0)}
</div>
<div>
<p className="text-sm font-bold text-slate-200">{c.name}</p>
<p className="text-[10px] text-slate-500">{c.city || 'Localização n/a'}</p>
</div>
</div>
</td>
<td className="px-6 py-4">
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide border ${c.status === 'Active' ? 'text-emerald-500 border-emerald-500/20 bg-emerald-500/5' :
c.status === 'Prospect' ? 'text-amber-500 border-amber-500/20 bg-amber-500/5' :
'text-slate-500 border-slate-600/30 bg-slate-500/5'
}`}>
{c.status === 'Active' ? 'ATIVO' : c.status === 'Prospect' ? 'PROSPECT' : 'INATIVO'}
</span>
</td>
<td className="px-6 py-4">
<div className="space-y-1">
{c.email && (
<div className="flex items-center gap-2 text-slate-400 text-xs">
<Mail size={12} className="text-slate-600" /> {c.email}
</div>
)}
{c.phone && (
<div className="flex items-center gap-2 text-slate-400 text-xs">
<Phone size={12} className="text-slate-600" /> {c.phone}
</div>
)}
</div>
</td>
<td className="px-6 py-4 text-right">
<p className="text-xs font-mono font-bold text-emerald-400">R$ {c.totalPurchased?.toLocaleString('pt-BR') || '0,00'}</p>
</td>
<td className="px-6 py-4 text-right opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex justify-end gap-2">
<button onClick={() => openModal(c)} className="p-1.5 text-slate-500 hover:text-white hover:bg-white/10 rounded transition-all"><Edit2 size={14} /></button>
<button onClick={() => deleteCustomer(c.id)} className="p-1.5 text-slate-500 hover:text-rose-400 hover:bg-rose-500/10 rounded transition-all"><Trash2 size={14} /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{customers.length === 0 && (
<div className="py-20 text-center text-slate-700 font-mono text-xs">NO CUSTOMERS FOUND.</div>
)}
</div>
{/* MODAL */}
{isModalOpen && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-[#0F1115] border border-white/10 w-full max-w-md rounded-2xl p-6 shadow-2xl animate-in zoom-in-95 duration-200">
<h3 className="text-lg font-bold text-white mb-6 border-b border-white/5 pb-4">{editingId ? 'Editar Cliente' : 'Novo Cliente'}</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Nome Completo</label>
<input required type="text" value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:border-indigo-500 outline-none transition-all" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Telefone</label>
<input type="text" value={formData.phone} onChange={e => setFormData({ ...formData, phone: e.target.value })} className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:border-indigo-500 outline-none transition-all" />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Status</label>
<select value={formData.status} onChange={e => setFormData({ ...formData, status: e.target.value as any })} className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:border-indigo-500 outline-none transition-all">
<option value="Active">Ativo</option>
<option value="Prospect">Prospect</option>
<option value="Inactive">Inativo</option>
</select>
</div>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Email</label>
<input type="email" value={formData.email} onChange={e => setFormData({ ...formData, email: e.target.value })} className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:border-indigo-500 outline-none transition-all" />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Cidade / Estado</label>
<input type="text" value={formData.city} onChange={e => setFormData({ ...formData, city: e.target.value })} className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:border-indigo-500 outline-none transition-all" />
</div>
<div className="flex gap-3 mt-8 pt-4 border-t border-white/5">
<button type="button" onClick={closeModal} className="flex-1 py-2 text-xs font-bold text-slate-400 hover:text-white transition-colors uppercase tracking-wide">Cancelar</button>
<button type="submit" className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white py-2 rounded-lg font-bold shadow-lg transition-all text-sm">Salvar</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default Customers;

164
pages/Dashboard.tsx Normal file
View file

@ -0,0 +1,164 @@
import React from 'react';
import { useCRM } from '../context/CRMContext';
import { useNavigate } from 'react-router-dom';
import clsx from 'clsx';
import { TrendingUp, TrendingDown, DollarSign, Activity, ArrowRight, Package } from 'lucide-react';
const Dashboard: React.FC = () => {
const { orders, inventory, suppliers } = useCRM();
const navigate = useNavigate();
// Financial Calculation
const activeOrders = orders.filter(o => o.status === 'Pending' || o.status === 'Paid');
const totalInvested = activeOrders.reduce((acc, o) => acc + o.totalCostWithOverhead, 0);
// Mocking a growth metric for demonstration
const totalProfitHistory = orders.filter(o => o.status === 'Received').reduce((acc, o) => acc + o.estimatedProfit, 0);
const inventoryValue = inventory.reduce((acc, i) => acc + ((Number(i.marketPriceBRL) || 0) * (Number(i.quantity) || 0)), 0);
// "Am I winning?" - Net Result (Mocked relative to investment for delta)
const netResultDelta = totalInvested > 0 ? (totalProfitHistory / totalInvested) * 100 : 0;
const isWinning = netResultDelta >= 0;
return (
<div className="space-y-6 animate-in fade-in duration-500 pb-20">
{/* HERDER: "Am I winning?" Context */}
<div className="flex items-end justify-between border-b border-white/5 pb-4">
<div>
<h2 className="text-xl font-bold text-white tracking-tight">Visão Geral</h2>
<p className="text-sm text-slate-500">Performance financeira e operacional.</p>
</div>
<div className="text-right">
<p className="text-[10px] uppercase font-bold text-slate-500 tracking-widest">Resultado Líquido (YTD)</p>
<div className={clsx("text-2xl font-bold flex items-center justify-end gap-2", isWinning ? "text-emerald-400" : "text-rose-400")}>
{isWinning ? <TrendingUp size={24} /> : <TrendingDown size={24} />}
R$ {totalProfitHistory.toLocaleString('pt-BR')}
</div>
</div>
</div>
{/* HIGH PRECISION METRICS GRID */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Metric 1: Capital Allocation */}
<div className="glass-card p-5 rounded-xl border border-white/10 relative overflow-hidden">
<div className="flex justify-between items-start mb-2">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Capital Alocado</p>
<DollarSign size={14} className="text-slate-600" />
</div>
<h3 className="text-xl font-bold text-white tracking-tight">R$ {totalInvested.toLocaleString('pt-BR')}</h3>
<div className="mt-3 flex items-center gap-2">
<span className="text-[10px] font-bold text-emerald-400 bg-emerald-400/5 px-1.5 py-0.5 rounded border border-emerald-400/10">+12% vs. mês ant.</span>
</div>
</div>
{/* Metric 2: Stock Value (Liquidity) */}
<div className="glass-card p-5 rounded-xl border border-white/10 relative overflow-hidden">
<div className="flex justify-between items-start mb-2">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Valor em Estoque</p>
<Package size={14} className="text-slate-600" />
</div>
<h3 className="text-xl font-bold text-white tracking-tight">R$ {inventoryValue.toLocaleString('pt-BR')}</h3>
<div className="mt-3 flex items-center gap-2">
<span className="text-[10px] font-bold text-slate-400 bg-white/5 px-1.5 py-0.5 rounded border border-white/5">Giro: 15 dias</span>
</div>
</div>
{/* Metric 3: Margin */}
<div className="glass-card p-5 rounded-xl border border-white/10 relative overflow-hidden">
<div className="flex justify-between items-start mb-2">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Margem Média</p>
<Activity size={14} className="text-slate-600" />
</div>
<h3 className="text-xl font-bold text-white tracking-tight">24.5%</h3>
<div className="mt-3 flex items-center gap-2">
<span className="text-[10px] font-bold text-rose-400 bg-rose-400/5 px-1.5 py-0.5 rounded border border-rose-400/10">-2.1% (Pressão de Custo)</span>
</div>
</div>
{/* Metric 4: Active Orders */}
<div className="glass-card p-5 rounded-xl border border-white/10 relative overflow-hidden">
<div className="flex justify-between items-start mb-2">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Ordens Ativas</p>
<Activity size={14} className="text-slate-600" />
</div>
<h3 className="text-xl font-bold text-white tracking-tight">{activeOrders.length}</h3>
<div className="mt-3 flex items-center gap-2">
<button className="text-[10px] font-bold text-indigo-400 hover:text-indigo-300 flex items-center gap-1 transition-colors">
Ver detalhes <ArrowRight size={10} />
</button>
</div>
</div>
</div>
{/* DATA DENSITY TABLE (Bloomberg Style) */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* ORDER FLOW */}
<div className="lg:col-span-2 glass-card rounded-xl border border-white/10 overflow-hidden flex flex-col min-h-[400px]">
<div className="px-6 py-4 border-b border-white/5 flex justify-between items-center bg-white/[0.01]">
<h3 className="text-sm font-bold text-white uppercase tracking-wider">Fluxo de Pedidos</h3>
<button onClick={() => navigate('/orders')} className="text-[10px] text-indigo-400 hover:text-white transition-colors font-bold uppercase tracking-wider">Expandir</button>
</div>
<table className="w-full text-left border-collapse">
<thead className="bg-black/20 text-[10px] font-bold text-slate-500 uppercase tracking-widest border-b border-white/5">
<tr>
<th className="px-6 py-3 font-semibold">ID</th>
<th className="px-6 py-3 font-semibold">Fornecedor</th>
<th className="px-6 py-3 font-semibold text-right">Valor</th>
<th className="px-6 py-3 font-semibold text-right">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5 text-sm">
{orders.length === 0 ? (
<tr><td colSpan={4} className="p-8 text-center text-slate-600 italic text-xs">Sem dados recentes.</td></tr>
) : (
orders.slice(0, 8).map(o => (
<tr key={o.id} className="hover:bg-white/[0.02] transition-colors">
<td className="px-6 py-3 font-mono text-slate-400 text-xs">{o.id.substring(0, 8)}...</td>
<td className="px-6 py-3 font-medium text-slate-200">{o.supplierName}</td>
<td className="px-6 py-3 text-right font-mono text-slate-300">R$ {o.totalCostWithOverhead.toLocaleString('pt-BR')}</td>
<td className="px-6 py-3 text-right">
<span className={clsx(
"text-[10px] font-bold px-1.5 py-0.5 rounded border",
o.status === 'Received' ? "text-emerald-400 border-emerald-400/20 bg-emerald-400/5" :
o.status === 'Pending' ? "text-amber-400 border-amber-400/20 bg-amber-400/5" :
"text-slate-500 border-slate-500/20 bg-slate-500/5"
)}>
{o.status.toUpperCase()}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* SUPPLIER PERFORMANCE (Compact List) */}
<div className="glass-card rounded-xl border border-white/10 overflow-hidden h-full">
<div className="px-6 py-4 border-b border-white/5 bg-white/[0.01]">
<h3 className="text-sm font-bold text-white uppercase tracking-wider">Top Fornecedores</h3>
</div>
<div className="divide-y divide-white/5">
{suppliers.slice(0, 6).map((s, i) => (
<div key={s.id} className="px-6 py-3 flex items-center justify-between hover:bg-white/[0.02] transition-colors group cursor-pointer">
<div className="flex items-center gap-3">
<span className="text-xs font-mono text-slate-600 w-4">{i + 1}</span>
<div>
<p className="text-sm font-bold text-slate-200 group-hover:text-white transition-colors">{s.name}</p>
<p className="text-[10px] text-slate-500">Eletrônicos PY</p>
</div>
</div>
<div className="text-right">
<p className="text-xs font-mono text-emerald-400">+ R$ 45k</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
};
export default Dashboard;

287
pages/Financial.tsx Normal file
View file

@ -0,0 +1,287 @@
import React, { useState } from 'react';
import { useCRM } from '../context/CRMContext';
import { ArrowUpRight, ArrowDownRight, TrendingUp, DollarSign, Calendar, CheckCircle, AlertCircle, Plus } from 'lucide-react';
import clsx from 'clsx';
import { PaymentMethod, Transaction } from '../types';
const Financial: React.FC = () => {
const { transactions, getFinancialSummary, addTransaction, updateTransaction, loading } = useCRM();
const { totalIncome, totalExpense, balance } = getFinancialSummary();
const [activeTab, setActiveTab] = useState<'payable' | 'receivable' | 'dashboard'>('dashboard');
const [showAddModal, setShowAddModal] = useState(false);
// New Bill State
const [newBill, setNewBill] = useState<Partial<Transaction>>({
type: 'Expense',
status: 'Pending',
paymentMethod: 'Boleto',
date: new Date().toISOString().split('T')[0],
dueDate: new Date().toISOString().split('T')[0]
});
const pendingPayables = transactions.filter(t => t.type === 'Expense' && t.status === 'Pending').sort((a, b) => new Date(a.dueDate || '').getTime() - new Date(b.dueDate || '').getTime());
const paidPayables = transactions.filter(t => t.type === 'Expense' && t.status === 'Paid');
// Sort Receivables by date desc
const receivables = transactions.filter(t => t.type === 'Income').sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());;
const handleSaveBill = async () => {
if (!newBill.description || !newBill.amount) return alert("Preencha descrição e valor");
await addTransaction({
type: 'Expense',
category: 'Contas',
description: newBill.description,
amount: Number(newBill.amount),
date: new Date(newBill.date!).toISOString(),
dueDate: new Date(newBill.dueDate!).toISOString(),
status: 'Pending', // Force pending
paymentMethod: newBill.paymentMethod as PaymentMethod || 'Boleto'
});
setShowAddModal(false);
setNewBill({ type: 'Expense', status: 'Pending', paymentMethod: 'Boleto', date: new Date().toISOString().split('T')[0], dueDate: new Date().toISOString().split('T')[0] });
};
const togglePaid = async (t: Transaction) => {
const newStatus = t.status === 'Paid' ? 'Pending' : 'Paid';
await updateTransaction(t.id, { status: newStatus });
}
const renderDashboard = () => (
<div className="space-y-6 animate-in fade-in duration-500">
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="glass-card p-6 rounded-[32px] border border-white/5 relative overflow-hidden group">
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<ArrowUpRight size={80} className="text-emerald-500" />
</div>
<div className="relative z-10">
<p className="text-sm font-bold text-slate-500 uppercase tracking-widest mb-1">Entradas (Total)</p>
<h3 className="text-3xl font-bold text-white tracking-tight">R$ {totalIncome.toLocaleString('pt-BR')}</h3>
<div className="mt-4 flex items-center gap-2 text-emerald-400 text-xs font-bold bg-emerald-400/10 w-fit px-2 py-1 rounded-lg">
<TrendingUp size={14} /> +12% este mês
</div>
</div>
</div>
<div className="glass-card p-6 rounded-[32px] border border-white/5 relative overflow-hidden group">
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<ArrowDownRight size={80} className="text-rose-500" />
</div>
<div className="relative z-10">
<p className="text-sm font-bold text-slate-500 uppercase tracking-widest mb-1">Saídas (Total)</p>
<h3 className="text-3xl font-bold text-white tracking-tight">R$ {totalExpense.toLocaleString('pt-BR')}</h3>
<div className="mt-4 flex items-center gap-2 text-rose-400 text-xs font-bold bg-rose-400/10 w-fit px-2 py-1 rounded-lg">
<TrendingUp size={14} /> Estável
</div>
</div>
</div>
<div className="glass-card p-6 rounded-[32px] border border-white/5 relative overflow-hidden group">
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<DollarSign size={80} className="text-indigo-500" />
</div>
<div className="relative z-10">
<p className="text-sm font-bold text-slate-500 uppercase tracking-widest mb-1">Saldo Atual</p>
<h3 className={clsx("text-3xl font-bold tracking-tight", balance >= 0 ? "text-emerald-400" : "text-rose-400")}>
R$ {balance.toLocaleString('pt-BR')}
</h3>
<div className="mt-4 flex items-center gap-2 text-indigo-400 text-xs font-bold bg-indigo-400/10 w-fit px-2 py-1 rounded-lg">
Balanço Geral
</div>
</div>
</div>
</div>
{/* Upcoming Bills Alert */}
{pendingPayables.length > 0 && (
<div className="bg-amber-500/10 border border-amber-500/20 rounded-2xl p-4 flex items-center gap-4">
<div className="bg-amber-500/20 p-2 rounded-xl text-amber-500">
<AlertCircle size={24} />
</div>
<div>
<h4 className="text-white font-bold text-sm">Contas a Pagar Pendentes</h4>
<p className="text-slate-400 text-xs">Você tem {pendingPayables.length} contas pendentes. Verifique a aba "A Pagar".</p>
</div>
<button onClick={() => setActiveTab('payable')} className="ml-auto text-xs font-bold text-amber-400 hover:text-white underline">Ver Contas</button>
</div>
)}
</div>
);
const renderPayables = () => (
<div className="animate-in fade-in duration-500 space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-xl font-bold text-white tracking-tight">Contas a Pagar</h2>
<button onClick={() => setShowAddModal(true)} className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-xl text-xs font-bold flex items-center gap-2 shadow-lg">
<Plus size={16} /> Nova Conta
</button>
</div>
<div className="glass-card rounded-2xl border border-white/5 overflow-hidden">
<table className="w-full text-left">
<thead className="bg-black/20 text-[10px] font-bold text-slate-500 uppercase tracking-widest border-b border-white/5">
<tr>
<th className="px-6 py-4">Vencimento</th>
<th className="px-6 py-4">Descrição</th>
<th className="px-6 py-4">Valor</th>
<th className="px-6 py-4 text-center">Via</th>
<th className="px-6 py-4 text-center">Status</th>
<th className="px-6 py-4 text-center">Ação</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{pendingPayables.concat(paidPayables).map(t => {
const isOverdue = new Date(t.dueDate || '') < new Date() && t.status !== 'Paid';
return (
<tr key={t.id} className="hover:bg-white/[0.02] transition-colors">
<td className="px-6 py-4">
<div className={clsx("flex items-center gap-2 font-mono text-xs", isOverdue ? "text-rose-400 font-bold" : "text-slate-400")}>
<Calendar size={12} />
{t.dueDate ? new Date(t.dueDate).toLocaleDateString() : '-'}
</div>
</td>
<td className="px-6 py-4 text-sm font-medium text-slate-200">{t.description}</td>
<td className="px-6 py-4 text-sm font-mono text-rose-400">- R$ {t.amount.toLocaleString('pt-BR')}</td>
<td className="px-6 py-4 text-center text-xs text-slate-500">{t.paymentMethod}</td>
<td className="px-6 py-4 text-center">
<span className={clsx("px-2 py-1 rounded-full text-[10px] font-bold uppercase",
t.status === 'Paid' ? "bg-emerald-500/10 text-emerald-400" :
isOverdue ? "bg-rose-500/10 text-rose-400" :
"bg-amber-500/10 text-amber-400")}>
{isOverdue ? 'Vencido' : t.status === 'Paid' ? 'Pago' : 'Pendente'}
</span>
</td>
<td className="px-6 py-4 text-center">
<button
onClick={() => togglePaid(t)}
className={clsx("p-2 rounded-lg transition-colors", t.status === 'Paid' ? "text-emerald-500 bg-emerald-500/10" : "text-slate-500 hover:text-emerald-400 hover:bg-emerald-400/10")}
title={t.status === 'Paid' ? "Marcar como Pendente" : "Marcar como Pago"}
>
<CheckCircle size={18} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
{transactions.filter(t => t.type === 'Expense').length === 0 && (
<div className="p-8 text-center text-slate-500 text-xs">Nenhuma conta registrada.</div>
)}
</div>
</div>
);
const renderReceivables = () => (
<div className="animate-in fade-in duration-500 space-y-6">
<h2 className="text-xl font-bold text-white tracking-tight">Contas a Receber (Vendas)</h2>
<div className="glass-card rounded-2xl border border-white/5 overflow-hidden">
<table className="w-full text-left">
<thead className="bg-black/20 text-[10px] font-bold text-slate-500 uppercase tracking-widest border-b border-white/5">
<tr>
<th className="px-6 py-4">Data</th>
<th className="px-6 py-4">Origem</th>
<th className="px-6 py-4">Valor</th>
<th className="px-6 py-4 text-center">Forma Pagto</th>
<th className="px-6 py-4 text-center">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{receivables.map(t => (
<tr key={t.id} className="hover:bg-white/[0.02] transition-colors">
<td className="px-6 py-4 font-mono text-xs text-slate-400">{new Date(t.date).toLocaleDateString()}</td>
<td className="px-6 py-4 text-sm font-medium text-slate-200">{t.description}</td>
<td className="px-6 py-4 text-sm font-mono text-emerald-400 font-bold">+ R$ {t.amount.toLocaleString('pt-BR')}</td>
<td className="px-6 py-4 text-center text-xs text-slate-500">{t.paymentMethod}</td>
<td className="px-6 py-4 text-center">
<span className="px-2 py-1 rounded-full text-[10px] font-bold uppercase bg-emerald-500/10 text-emerald-400">
{t.status || 'Paid'}
</span>
</td>
</tr>
))}
</tbody>
</table>
{receivables.length === 0 && (
<div className="p-8 text-center text-slate-500 text-xs">Nenhuma venda registrada.</div>
)}
</div>
</div>
);
return (
<div className="space-y-8">
{/* Tabs */}
<div className="flex gap-4 border-b border-white/5 pb-1">
<button onClick={() => setActiveTab('dashboard')} className={clsx("pb-3 text-sm font-bold transition-all relative", activeTab === 'dashboard' ? "text-white" : "text-slate-500 hover:text-slate-300")}>
Dashboard
{activeTab === 'dashboard' && <div className="absolute bottom-[-1px] left-0 w-full h-0.5 bg-indigo-500 rounded-full"></div>}
</button>
<button onClick={() => setActiveTab('payable')} className={clsx("pb-3 text-sm font-bold transition-all relative", activeTab === 'payable' ? "text-white" : "text-slate-500 hover:text-slate-300")}>
A Pagar
{activeTab === 'payable' && <div className="absolute bottom-[-1px] left-0 w-full h-0.5 bg-indigo-500 rounded-full"></div>}
</button>
<button onClick={() => setActiveTab('receivable')} className={clsx("pb-3 text-sm font-bold transition-all relative", activeTab === 'receivable' ? "text-white" : "text-slate-500 hover:text-slate-300")}>
A Receber
{activeTab === 'receivable' && <div className="absolute bottom-[-1px] left-0 w-full h-0.5 bg-indigo-500 rounded-full"></div>}
</button>
</div>
{activeTab === 'dashboard' && renderDashboard()}
{activeTab === 'payable' && renderPayables()}
{activeTab === 'receivable' && renderReceivables()}
{/* Add Bill Modal */}
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-[#0F1115] border border-white/10 rounded-[32px] w-full max-w-sm shadow-2xl p-8 relative">
<h3 className="text-xl font-bold text-white mb-6">Agendar Pagamento</h3>
<div className="space-y-4">
<input
type="text" placeholder="Descrição (ex: Aluguel)"
value={newBill.description || ''} onChange={e => setNewBill({ ...newBill, description: e.target.value })}
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-sm text-white focus:border-indigo-500 outline-none"
/>
<input
type="number" placeholder="Valor (R$)"
value={newBill.amount || ''} onChange={e => setNewBill({ ...newBill, amount: e.target.value })}
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-sm text-white focus:border-indigo-500 outline-none"
/>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase mb-1 block">Vencimento</label>
<input
type="date"
value={newBill.dueDate || ''} onChange={e => setNewBill({ ...newBill, dueDate: e.target.value })}
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-sm text-white focus:border-indigo-500 outline-none"
/>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase mb-1 block">Forma Pagto</label>
<select
value={newBill.paymentMethod || 'Boleto'} onChange={e => setNewBill({ ...newBill, paymentMethod: e.target.value as PaymentMethod })}
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-sm text-white focus:border-indigo-500 outline-none"
>
<option value="Boleto">Boleto</option>
<option value="Pix">Pix</option>
<option value="Cash">Dinheiro</option>
<option value="Transfer">TED/DOC</option>
</select>
</div>
</div>
<button onClick={handleSaveBill} className="w-full mt-4 bg-rose-600 hover:bg-rose-500 text-white font-bold py-3 rounded-xl shadow-lg transition-all">
Agendar Conta
</button>
<button onClick={() => setShowAddModal(false)} className="w-full text-xs text-slate-500 hover:text-white py-2">Cancelar</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Financial;

58
pages/Inventory.tsx Normal file
View file

@ -0,0 +1,58 @@
import React from 'react';
import { Boxes } from 'lucide-react';
import { useCRM } from '../context/CRMContext';
const Inventory: React.FC = () => {
const { inventory } = useCRM();
return (
<div className="glass-card rounded-2xl border border-white/5 shadow-sm overflow-hidden animate-in fade-in duration-500">
<div className="p-6 bg-white/[0.02] border-b border-white/5 flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-white tracking-tight">Estoque & Armazenamento</h2>
<p className="text-xs text-slate-500 tracking-wide mt-1">Visão geral dos ativos em custódia.</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 p-6">
{inventory.length > 0 ? inventory.map((item, idx) => (
<div key={idx} className="p-5 rounded-xl border border-white/5 bg-black/20 group hover:bg-[#0F1115] hover:border-indigo-500/30 transition-all relative overflow-hidden">
<div className="absolute top-0 right-0 p-2 opacity-50">
<Boxes size={48} className="text-white/5" strokeWidth={1} />
</div>
<div className="flex justify-between items-start mb-3 relative z-10">
<span className="text-[10px] font-mono text-slate-500 border border-white/10 px-1.5 py-0.5 rounded">{item.sku}</span>
<span className="bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-2 py-0.5 rounded text-[10px] font-bold tracking-tight">QTD: {item.quantity}</span>
</div>
<h4 className="text-sm font-bold text-white mb-4 leading-tight h-10 overflow-hidden line-clamp-2 relative z-10" title={item.name}>{item.name}</h4>
<div className="space-y-2 mb-4 relative z-10 bg-black/20 p-3 rounded-lg border border-white/5">
<div className="flex justify-between text-[10px] font-bold uppercase text-slate-500">
<span>Custo Base</span>
<span className="text-slate-300 font-mono">R$ {item.avgCostBRL.toFixed(2)}</span>
</div>
<div className="flex justify-between text-[10px] font-bold uppercase text-slate-500">
<span>Preço Alvo</span>
<span className="text-emerald-400 font-mono">R$ {item.marketPriceBRL.toFixed(2)}</span>
</div>
</div>
<button className="w-full py-2 bg-white/5 border border-white/10 rounded-lg text-[10px] font-bold text-slate-400 group-hover:bg-indigo-600 group-hover:text-white group-hover:border-indigo-500 transition-all uppercase tracking-wider relative z-10">
Lançar Venda
</button>
</div>
)) : (
<div className="col-span-full py-40 text-center opacity-30">
<Boxes size={64} className="mx-auto mb-4 text-white" strokeWidth={1} />
<p className="text-xs font-mono uppercase tracking-widest text-slate-500">Nenhum item em estoque</p>
</div>
)}
</div>
</div>
);
};
export default Inventory;

106
pages/Login.tsx Normal file
View file

@ -0,0 +1,106 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Hexagon, ArrowRight, Lock, Mail } from 'lucide-react';
import clsx from 'clsx';
import { useCRM } from '../context/CRMContext';
import Logo from '../components/Logo';
const Login: React.FC = () => {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const { signIn } = useCRM();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
const { error } = await signIn(email, password);
if (error) {
alert("Erro ao entrar: " + error.message);
setLoading(false);
} else {
// Context logic handles redirect via Session state or we can force it here
navigate('/');
}
};
return (
<div className="min-h-screen w-full bg-background flex items-center justify-center p-4 relative overflow-hidden">
{/* Background Ambient Effects */}
<div className="absolute top-[-20%] left-[-10%] w-[600px] h-[600px] bg-indigo-500/10 rounded-full blur-[120px] animate-pulse duration-10000" />
<div className="absolute bottom-[-20%] right-[-10%] w-[600px] h-[600px] bg-emerald-500/5 rounded-full blur-[120px] animate-pulse duration-7000" />
{/* Login Card */}
<div className="w-full max-w-md animate-in fade-in zoom-in-95 duration-700">
<div className="glass-card p-10 rounded-[32px] border border-white/5 backdrop-blur-2xl shadow-2xl relative z-10">
{/* Header */}
<div className="text-center mb-10 flex flex-col items-center">
<Logo size="lg" />
</div>
{/* Form */}
<form onSubmit={handleLogin} className="space-y-5">
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest ml-1">Email Corporativo</label>
<div className="relative group">
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-foreground transition-colors">
<Mail size={18} />
</div>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full bg-secondary/30 border border-white/5 hover:border-white/10 focus:border-white/20 rounded-2xl py-4 pl-12 pr-4 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none transition-all"
placeholder="admin@arbitra.com"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest ml-1">Senha de Acesso</label>
<div className="relative group">
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-foreground transition-colors">
<Lock size={18} />
</div>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full bg-secondary/30 border border-white/5 hover:border-white/10 focus:border-white/20 rounded-2xl py-4 pl-12 pr-4 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none transition-all"
placeholder="••••••••"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-foreground text-background hover:bg-white/90 py-4 rounded-2xl font-bold text-sm shadow-xl shadow-white/5 flex items-center justify-center gap-2 transition-all active:scale-[0.98] mt-4 group"
>
{loading ? (
<span className="w-5 h-5 border-2 border-background/30 border-t-background rounded-full animate-spin" />
) : (
<>
Acessar Sistema <ArrowRight size={16} className="group-hover:translate-x-0.5 transition-transform" />
</>
)}
</button>
</form>
{/* Footer */}
<div className="mt-8 text-center">
<p className="text-[10px] text-muted-foreground/60 font-medium">
Acesso Restrito &bull; Segurança Criptografada
</p>
</div>
</div>
</div>
</div>
);
};
export default Login;

270
pages/Orders.tsx Normal file
View file

@ -0,0 +1,270 @@
import React from 'react';
import { Store, Trash2, FileEdit, FileCheck, Printer } from 'lucide-react';
import { useCRM } from '../context/CRMContext';
import { OrderStatus } from '../types';
const Orders: React.FC = () => {
const { orders, updateOrderStatus, resumeOrder, deleteOrder } = useCRM();
const handleDelete = async (id: string) => {
if (window.confirm("Tem certeza que deseja excluir esse pedido?")) {
await deleteOrder(id);
}
};
const handlePrint = (order: any) => {
const printWindow = window.open('', '_blank');
if (printWindow) {
printWindow.document.write(`
<html>
<head>
<title>Pedido #${order.id.substring(0, 8)}</title>
<style>
body { font-family: monospace; padding: 20px; color: #000; }
h1 { font-size: 24px; margin-bottom: 10px; border-bottom: 2px solid #000; padding-bottom: 10px; }
.header { margin-bottom: 30px; display: flex; justify-content: space-between; }
.info { margin-bottom: 15px; font-size: 14px; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 12px; }
th, td { border-bottom: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #f5f5f5; text-transform: uppercase; }
.totals { margin-top: 30px; text-align: right; font-size: 14px; }
.footer { margin-top: 50px; text-align: center; font-size: 10px; color: #666; border-top: 1px dashed #ccc; padding-top: 20px; }
</style>
</head>
<body>
<div class="header">
<div>
<h1>Paraguai Imports</h1>
<p>Relatório de Pedido</p>
</div>
<div style="text-align: right;">
<p><strong>Data:</strong> ${new Date(order.date).toLocaleDateString()}</p>
<p><strong>ID:</strong> #${order.id}</p>
<p><strong>Status:</strong> ${order.status}</p>
</div>
</div>
<div class="info">
<p><strong>Fornecedor:</strong> ${order.supplierName}</p>
</div>
<table>
<thead>
<tr>
<th>Produto</th>
<th style="text-align: center;">Qtd</th>
<th style="text-align: right;">Preço Unit. (USD)</th>
<th style="text-align: right;">Total (USD)</th>
<th style="text-align: right;">Total (BRL)</th>
</tr>
</thead>
<tbody>
${order.items.map((item: any) => `
<tr>
<td>${item.name}</td>
<td style="text-align: center;">${item.quantity}</td>
<td style="text-align: right;">$ ${item.priceUSD.toFixed(2)}</td>
<td style="text-align: right;">$ ${(item.priceUSD * item.quantity).toFixed(2)}</td>
<td style="text-align: right;">R$ ${(item.priceBRL * item.quantity).toFixed(2)}</td>
</tr>
`).join('')}
</tbody>
</table>
<div class="totals">
<p><strong>Total USD:</strong> $ ${order.totalUSD.toFixed(2)}</p>
<p><strong>Total BRL (Sem Taxas):</strong> R$ ${order.totalBRL.toFixed(2)}</p>
<p style="font-size: 16px; margin-top: 10px;"><strong>Custo Final Estimado:</strong> R$ ${order.totalCostWithOverhead.toLocaleString('pt-BR')}</p>
<p style="color: green;"><strong>Lucro Estimado:</strong> R$ ${order.estimatedProfit.toLocaleString('pt-BR')}</p>
</div>
<div class="footer">
<p>Gerado automaticamente pelo sistema.</p>
</div>
</body>
</html>
`);
printWindow.document.close();
printWindow.print();
}
};
return (
<div className="glass-card rounded-2xl border border-white/5 shadow-sm overflow-hidden animate-in fade-in duration-500">
<div className="p-6 bg-white/[0.02] border-b border-white/5 flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-white tracking-tight">Fluxo de Pedidos (Order Flow)</h2>
<p className="text-xs text-slate-500 tracking-wide mt-1">Gestão de ciclo de vida e conciliação.</p>
</div>
</div>
{/* Desktop Table */}
<div className="hidden md:block overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-black/20 text-[10px] font-bold text-slate-500 uppercase tracking-widest border-b border-white/5">
<th className="px-6 py-4">ID / Data</th>
<th className="px-6 py-4">Fornecedor</th>
<th className="px-6 py-4 text-right">Compra (USD)</th>
<th className="px-6 py-4 text-right">Custo BR (R$)</th>
<th className="px-6 py-4 text-right">Delta ($)</th>
<th className="px-6 py-4 text-center">Status</th>
<th className="px-6 py-4 text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{orders.map(o => (
<tr key={o.id} className="hover:bg-white/[0.02] transition-colors group">
<td className="px-6 py-4">
<p className="text-xs font-mono text-slate-400">{o.id.substring(0, 8)}...</p>
<p className="text-[10px] text-slate-600 font-medium mt-0.5">{new Date(o.date).toLocaleDateString()}</p>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<Store size={14} className="text-slate-600" />
<span className="text-sm font-medium text-slate-300">{o.supplierName}</span>
</div>
</td>
<td className="px-6 py-4 text-xs font-mono text-slate-400 text-right">US$ {o.totalUSD.toFixed(2)}</td>
<td className="px-6 py-4 text-xs font-mono text-slate-200 text-right">R$ {o.totalCostWithOverhead.toLocaleString('pt-BR')}</td>
<td className="px-6 py-4 text-right">
<span className="text-xs font-mono font-bold text-emerald-400">+ {o.estimatedProfit.toLocaleString('pt-BR')}</span>
</td>
<td className="px-6 py-4 text-center">
<select
value={o.status}
onChange={(e) => updateOrderStatus(o.id, e.target.value as OrderStatus)}
className={`px-2 py-1 rounded text-[10px] font-bold uppercase outline-none transition-all cursor-pointer bg-transparent border-0 text-center w-full appearance-none ${o.status === 'Pending' ? 'text-amber-500' :
o.status === 'Received' ? 'text-emerald-500' :
'text-slate-500'
}`}
style={{ textAlignLast: 'center' }}
>
<option value="Pending" className="text-amber-500 bg-[#0F1115]">PENDENTE</option>
<option value="Paid" className="text-blue-500 bg-[#0F1115]">PAGO</option>
<option value="Received" className="text-emerald-500 bg-[#0F1115]">RECEBIDO</option>
<option value="Cancelled" className="text-rose-500 bg-[#0F1115]">CANCELADO</option>
</select>
</td>
<td className="px-6 py-4 text-right opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center justify-end gap-2">
<button
className="p-1.5 text-slate-500 hover:text-emerald-400 hover:bg-white/5 rounded transition-colors"
title="Emitir Nota Fiscal"
onClick={() => alert('Emissão de NFe iniciada (Simulação)')}
>
<FileCheck size={14} />
</button>
<button
onClick={() => handlePrint(o)}
className="p-1.5 text-slate-500 hover:text-sky-400 hover:bg-white/5 rounded transition-colors"
title="Imprimir"
>
<Printer size={14} />
</button>
<button
onClick={() => {
resumeOrder(o.id);
window.location.hash = '#/sourcing'; // Rough navigation fix
alert(`Ordem ${o.id} carregada.`);
}}
className="p-1.5 text-slate-500 hover:text-indigo-400 hover:bg-white/5 rounded transition-colors"
title="Retomar"
>
<FileEdit size={14} />
</button>
<button
onClick={() => handleDelete(o.id)}
className="p-1.5 text-slate-500 hover:text-rose-400 hover:bg-white/5 rounded transition-colors">
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile Card View */}
<div className="md:hidden flex flex-col gap-4 p-4">
{orders.map(o => (
<div key={o.id} className="bg-white/[0.03] border border-white/5 rounded-xl p-4 space-y-4">
<div className="flex justify-between items-start">
<div>
<p className="text-[10px] uppercase tracking-widest text-slate-500 font-bold mb-1">Pedido</p>
<p className="text-xs font-mono text-slate-300">{o.id.substring(0, 8)}...</p>
<p className="text-[10px] text-slate-600 mt-1">{new Date(o.date).toLocaleDateString()}</p>
</div>
<div className={`px-2 py-1 rounded text-[10px] font-bold uppercase ${o.status === 'Pending' ? 'text-amber-500 bg-amber-500/10' :
o.status === 'Received' ? 'text-emerald-500 bg-emerald-500/10' :
'text-slate-500 bg-slate-500/10'
}`}>
{o.status}
</div>
</div>
<div className="grid grid-cols-2 gap-4 border-t border-white/5 pt-4">
<div>
<p className="text-[10px] uppercase tracking-widest text-slate-500 font-bold mb-1">Fornecedor</p>
<div className="flex items-center gap-2">
<Store size={12} className="text-slate-600" />
<span className="text-sm font-medium text-slate-300 truncate">{o.supplierName}</span>
</div>
</div>
<div>
<p className="text-[10px] uppercase tracking-widest text-slate-500 font-bold mb-1">Total (BRL)</p>
<p className="text-sm font-mono text-slate-200">R$ {o.totalCostWithOverhead.toLocaleString('pt-BR')}</p>
</div>
</div>
<div className="border-t border-white/5 pt-4 flex items-center justify-between">
<select
value={o.status}
onChange={(e) => updateOrderStatus(o.id, e.target.value as OrderStatus)}
className="bg-black/20 border border-white/10 rounded px-3 py-1.5 text-xs text-slate-300 outline-none"
>
<option value="Pending">PENDENTE</option>
<option value="Paid">PAGO</option>
<option value="Received">RECEBIDO</option>
<option value="Cancelled">CANCELADO</option>
</select>
<div className="flex items-center gap-2">
<button
onClick={() => handlePrint(o)}
className="p-2 text-slate-400 bg-white/5 rounded-lg hover:bg-sky-500/20 hover:text-sky-400 transition-colors"
>
<Printer size={16} />
</button>
<button
onClick={() => {
resumeOrder(o.id);
window.location.hash = '#/sourcing';
alert(`Ordem ${o.id} carregada.`);
}}
className="p-2 text-slate-400 bg-white/5 rounded-lg hover:bg-indigo-500/20 hover:text-indigo-400 transition-colors"
>
<FileEdit size={16} />
</button>
<button
onClick={() => handleDelete(o.id)}
className="p-2 text-slate-400 bg-white/5 rounded-lg hover:bg-rose-500/20 hover:text-rose-400 transition-colors"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
))}
</div>
{orders.length === 0 && (
<div className="py-20 text-center text-slate-700 font-mono text-xs">NO ACTIVE ORDERS.</div>
)}
</div>
);
};
export default Orders;

261
pages/Products.tsx Normal file
View file

@ -0,0 +1,261 @@
import React, { useState } from 'react';
import { useCRM } from '../context/CRMContext';
import { Plus, Search, Edit2, Trash2, Save, X, ScanBarcode, Package } from 'lucide-react';
import { InventoryItem } from '../types';
import clsx from 'clsx';
const Products: React.FC = () => {
const { inventory, addProduct, updateProduct, deleteProduct } = useCRM();
const [searchTerm, setSearchTerm] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<InventoryItem | null>(null);
// Form State
const [formData, setFormData] = useState<Partial<InventoryItem>>({
name: '',
sku: '',
ean: '',
quantity: 0,
avgCostBRL: 0,
marketPriceBRL: 0, // This is Sale Price
lastSupplier: 'Manual'
});
const filteredProducts = inventory.filter(p =>
p.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
p.sku.toLowerCase().includes(searchTerm.toLowerCase()) ||
(p.ean && p.ean.includes(searchTerm))
);
const handleOpenModal = (product?: InventoryItem) => {
if (product) {
setEditingProduct(product);
setFormData(product);
} else {
setEditingProduct(null);
setFormData({
name: '',
sku: '',
ean: '',
quantity: 0,
avgCostBRL: 0,
marketPriceBRL: 0,
lastSupplier: 'Manual'
});
}
setIsModalOpen(true);
};
const handleSave = async () => {
if (!formData.name || !formData.sku) {
alert('Nome e SKU são obrigatórios.');
return;
}
try {
if (editingProduct) {
await updateProduct(editingProduct.id, formData);
} else {
await addProduct(formData as Omit<InventoryItem, 'id'>);
}
setIsModalOpen(false);
} catch (error: any) {
console.error(error);
alert(`Erro ao salvar produto: ${error.message || JSON.stringify(error)}`);
}
};
const handleDelete = async (id: string) => {
if (confirm('Tem certeza que deseja excluir este produto?')) {
await deleteProduct(id);
}
};
return (
<div className="space-y-6 animate-in fade-in duration-500">
{/* Validating EAN logic exists in types from previous turn? Yes if applied. */}
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight text-white mb-1">Produtos</h1>
<p className="text-slate-400">Gerencie o catálogo de produtos, SKUs e EANs.</p>
</div>
<button
onClick={() => handleOpenModal()}
className="bg-indigo-600 hover:bg-indigo-500 text-white px-6 py-3 rounded-2xl font-bold text-sm shadow-lg shadow-indigo-500/20 transition-all flex items-center gap-2"
>
<Plus size={18} /> Novo Produto
</button>
</div>
{/* Search */}
<div className="bg-white/5 p-1 rounded-2xl border border-white/5 flex items-center w-full md:w-96">
<Search className="ml-4 text-slate-500" size={18} />
<input
type="text"
placeholder="Buscar por nome, SKU, EAN..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="bg-transparent border-none text-white text-sm focus:ring-0 w-full p-3 placeholder:text-slate-600"
/>
</div>
{/* Table */}
<div className="bg-black/20 border border-white/5 rounded-[32px] overflow-hidden backdrop-blur-sm">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-white/5 bg-white/5">
<th className="p-6 text-xs font-bold text-slate-400 uppercase tracking-wider">Produto</th>
<th className="p-6 text-xs font-bold text-slate-400 uppercase tracking-wider">Códigos</th>
<th className="p-6 text-xs font-bold text-slate-400 uppercase tracking-wider text-right">Estoque</th>
<th className="p-6 text-xs font-bold text-slate-400 uppercase tracking-wider text-right">Preço Venda (BRL)</th>
<th className="p-6 text-xs font-bold text-slate-400 uppercase tracking-wider text-center">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{filteredProducts.map((p) => (
<tr key={p.id} className="hover:bg-white/5 transition-colors group">
<td className="p-6">
<h4 className="font-bold text-slate-200">{p.name}</h4>
<p className="text-xs text-slate-500">{p.lastSupplier}</p>
</td>
<td className="p-6">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-[10px] bg-indigo-500/10 text-indigo-400 px-1.5 py-0.5 rounded border border-indigo-500/20 font-mono">SKU</span>
<span className="text-xs text-slate-300 font-mono">{p.sku}</span>
</div>
{p.ean && (
<div className="flex items-center gap-2">
<span className="text-[10px] bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20 font-mono">EAN</span>
<span className="text-xs text-slate-300 font-mono">{p.ean}</span>
</div>
)}
</div>
</td>
<td className={clsx("p-6 text-right font-bold font-mono", p.quantity === 0 ? "text-rose-500" : "text-slate-300")}>
{p.quantity}
</td>
<td className="p-6 text-right font-bold text-emerald-400 font-mono">
R$ {p.marketPriceBRL?.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
</td>
<td className="p-6">
<div className="flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => handleOpenModal(p)} className="p-2 bg-white/5 rounded-lg text-slate-400 hover:text-white hover:bg-indigo-500 transition-all"><Edit2 size={14} /></button>
<button onClick={() => handleDelete(p.id)} className="p-2 bg-white/5 rounded-lg text-slate-400 hover:text-white hover:bg-rose-500 transition-all"><Trash2 size={14} /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{filteredProducts.length === 0 && (
<div className="p-12 flex flex-col items-center justify-center text-slate-500 opacity-50">
<Package size={48} className="mb-4" />
<p>Nenhum produto encontrado.</p>
</div>
)}
</div>
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in">
<div className="bg-[#0F1115] border border-white/10 rounded-[32px] w-full max-w-2xl shadow-2xl p-8 max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-bold text-white">{editingProduct ? 'Editar Produto' : 'Novo Produto'}</h2>
<button onClick={() => setIsModalOpen(false)} className="text-slate-500 hover:text-white transition-colors"><X size={24} /></button>
</div>
<div className="space-y-6">
{/* Basic Info */}
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-2">Nome do Produto</label>
<input
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-slate-200 focus:border-indigo-500 outline-none transition-all"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
placeholder="Ex: iPhone 15 Pro Max 256GB"
/>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-2">SKU</label>
<input
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-slate-200 focus:border-indigo-500 outline-none transition-all font-mono"
value={formData.sku}
onChange={e => setFormData({ ...formData, sku: e.target.value })}
placeholder="Ex: IPH-15-PM-256"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-2">EAN (Código de Barras)</label>
<div className="relative">
<ScanBarcode className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
<input
className="w-full bg-black/20 border border-white/10 rounded-xl px-12 py-3 text-slate-200 focus:border-indigo-500 outline-none transition-all font-mono"
value={formData.ean || ''}
onChange={e => setFormData({ ...formData, ean: e.target.value })}
placeholder="Ex: 789..."
/>
</div>
</div>
</div>
{/* Prices & Stock */}
<div className="grid grid-cols-3 gap-6">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-2">Estoque Inicial</label>
<input
type="number"
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-slate-200 focus:border-indigo-500 outline-none transition-all"
value={formData.quantity}
onChange={e => setFormData({ ...formData, quantity: parseInt(e.target.value) || 0 })}
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-2">Custo Médio (BRL)</label>
<input
type="number"
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-slate-200 focus:border-indigo-500 outline-none transition-all"
value={formData.avgCostBRL}
onChange={e => setFormData({ ...formData, avgCostBRL: parseFloat(e.target.value) || 0 })}
/>
</div>
<div>
<label className="block text-xs font-bold text-emerald-500 uppercase mb-2">Preço de Venda (BRL)</label>
<input
type="number"
className="w-full bg-emerald-500/10 border border-emerald-500/20 rounded-xl px-4 py-3 text-emerald-400 focus:border-emerald-500 outline-none transition-all font-bold"
value={formData.marketPriceBRL}
onChange={e => setFormData({ ...formData, marketPriceBRL: parseFloat(e.target.value) || 0 })}
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-2">Fornecedor (Opcional)</label>
<input
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-slate-200 focus:border-indigo-500 outline-none transition-all"
value={formData.lastSupplier || ''}
onChange={e => setFormData({ ...formData, lastSupplier: e.target.value })}
placeholder="Ex: Apple Store PY"
/>
</div>
<button
onClick={handleSave}
className="w-full py-4 bg-indigo-600 hover:bg-indigo-500 text-white rounded-2xl font-bold text-sm shadow-xl shadow-indigo-900/40 transition-all flex items-center justify-center gap-3 mt-4 active:scale-95"
>
<Save size={18} /> SALVAR PRODUTO
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Products;

306
pages/Reports.tsx Normal file
View file

@ -0,0 +1,306 @@
import React, { useState, useMemo } from 'react';
import { useCRM } from '../context/CRMContext';
import {
BarChart3, TrendingUp, PieChart, Package, ArrowUpRight, ArrowDownRight,
Download, Calendar, Filter, DollarSign, Activity, AlertCircle, Boxes
} from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
LineChart, Line, AreaChart, Area
} from 'recharts';
import clsx from 'clsx';
const Reports: React.FC = () => {
const { orders, inventory, transactions, sales } = useCRM();
const [period, setPeriod] = useState<'30d' | '3m' | '6m' | 'ytd' | '1y' | 'all'>('30d');
const [activeTab, setActiveTab] = useState<'financial' | 'products' | 'inventory' | 'sales'>('financial');
// --- FILTERING LOGIC ---
const filteredSales = useMemo(() => {
const now = new Date();
const cutoff = new Date();
if (period === '30d') cutoff.setDate(now.getDate() - 30);
else if (period === '3m') cutoff.setMonth(now.getMonth() - 3);
else if (period === '6m') cutoff.setMonth(now.getMonth() - 6);
else if (period === 'ytd') cutoff.setMonth(0, 1); // Jan 1st of current year
else if (period === '1y') cutoff.setFullYear(now.getFullYear() - 1);
else if (period === 'all') return sales; // No filter
return sales.filter(s => new Date(s.date) >= cutoff);
}, [sales, period]);
// 1. Financial Metrics
const financialStats = useMemo(() => {
// Revenue
const totalRevenue = filteredSales.reduce((acc, s) => acc + s.total, 0);
// Cost of Goods Sold (COGS) based on captured costPrice at time of sale
const totalCost = filteredSales.reduce((acc, s) => {
const saleCost = s.items.reduce((iAcc, item) => iAcc + ((item.costPrice || 0) * item.quantity), 0);
return acc + saleCost;
}, 0);
const totalProfit = totalRevenue - totalCost;
const margin = totalRevenue > 0 ? (totalProfit / totalRevenue) * 100 : 0;
return { totalRevenue, totalCost, totalProfit, margin };
}, [filteredSales]);
// 2. Product/Arbitrage Metrics
const productStats = useMemo(() => {
const productPerformance: Record<string, { profit: number, revenue: number, quantity: number }> = {};
filteredSales.forEach(s => {
s.items.forEach(item => {
if (!productPerformance[item.name]) {
productPerformance[item.name] = { profit: 0, revenue: 0, quantity: 0 };
}
const itemRevenue = item.salePrice;
const itemCost = item.costPrice || 0;
productPerformance[item.name].profit += (itemRevenue - itemCost) * item.quantity;
productPerformance[item.name].revenue += itemRevenue * item.quantity;
productPerformance[item.name].quantity += item.quantity;
});
});
const sortedProducts = Object.entries(productPerformance)
.map(([name, stats]) => ({ name, ...stats }))
.sort((a, b) => b.profit - a.profit);
return {
topProducts: sortedProducts.slice(0, 5),
lowMarginProducts: sortedProducts.filter(p => p.revenue > 0 && (p.profit / p.revenue) < 0.10).slice(0, 5)
};
}, [filteredSales]);
// 3. Inventory Stats (Unchanged)
const inventoryStats = useMemo(() => {
const totalValue = inventory.reduce((acc, i) => acc + (i.avgCostBRL * i.quantity), 0);
const totalItems = inventory.reduce((acc, i) => acc + i.quantity, 0);
const deadStock = inventory.filter(i => i.quantity > 0).slice(0, 3);
return { totalValue, totalItems, deadStock };
}, [inventory]);
// 4. Chart Data - Responsive to Period
const chartData = useMemo(() => {
const dataMap: Record<string, { receita: number, lucro: number, date: number }> = {};
filteredSales.forEach(sale => {
if (sale.status === 'Cancelled' || sale.status === 'Returned') return;
const d = new Date(sale.date);
// Dynamic grouping key
let key = '';
if (period === '30d') {
key = d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' }); // Daily for 30d
} else {
key = d.toLocaleDateString('pt-BR', { month: 'short', year: '2-digit' }); // Monthly for others
}
if (!dataMap[key]) dataMap[key] = { receita: 0, lucro: 0, date: d.getTime() };
const saleCost = sale.items.reduce((acc, item) => acc + ((item.costPrice || 0) * item.quantity), 0);
dataMap[key].receita += sale.total;
dataMap[key].lucro += (sale.total - saleCost);
});
return Object.entries(dataMap)
.map(([name, vals]) => ({ name, ...vals }))
.sort((a, b) => a.date - b.date); // Sort chronologically
}, [filteredSales, period]); // Recalc on filter change
// --- EXPORT ---
const handleExport = () => {
const csvContent = "data:text/csv;charset=utf-8,"
+ "Categoria,Valor\n"
+ `Receita Total,${financialStats.totalRevenue}\n`
+ `Custo Total,${financialStats.totalCost}\n`
+ `Lucro Liquido,${financialStats.totalProfit}\n`;
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", "relatorio_arbitra.csv");
document.body.appendChild(link);
link.click();
};
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
{/* HEADER */}
<div className="flex flex-col md:flex-row justify-between items-end border-b border-white/5 pb-6 gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight text-white mb-1">Relatórios & Analytics</h1>
<p className="text-sm text-slate-400">Visão geral de performance, financeiro e estoque.</p>
</div>
<div className="flex items-center gap-3">
<div className="bg-black/20 border border-white/10 rounded-lg p-1 flex">
{[
{ id: '30d', label: '30D' },
{ id: '3m', label: '3M' },
{ id: '6m', label: '6M' },
{ id: 'ytd', label: 'YTD' },
{ id: '1y', label: '1 Ano' },
{ id: 'all', label: 'Tudo' }
].map((p) => (
<button
key={p.id}
onClick={() => setPeriod(p.id as any)}
className={clsx(
"px-3 py-1.5 rounded text-[10px] font-bold uppercase tracking-wider transition-all",
period === p.id ? "bg-indigo-600 text-white shadow-sm" : "text-slate-500 hover:text-white"
)}
>
{p.label}
</button>
))}
</div>
<button
onClick={handleExport}
className="bg-emerald-600 hover:bg-emerald-500 text-white px-4 py-2.5 rounded-lg font-bold text-sm shadow-lg shadow-emerald-900/20 active:scale-95 transition-all flex items-center gap-2"
>
<Download size={16} /> Exportar CSV
</button>
</div>
</div>
{/* KPI CARDS */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: 'Receita Total', value: `R$ ${financialStats.totalRevenue.toLocaleString('pt-BR')}`, icon: DollarSign, color: 'text-emerald-400', sub: '+12% vs. mês anterior' },
{ label: 'Lucro Líquido', value: `R$ ${financialStats.totalProfit.toLocaleString('pt-BR')}`, icon: TrendingUp, color: 'text-indigo-400', sub: `${financialStats.margin.toFixed(1)}% Margem` },
{ label: 'Valor em Estoque', value: `R$ ${inventoryStats.totalValue.toLocaleString('pt-BR')}`, icon: Package, color: 'text-blue-400', sub: `${inventoryStats.totalItems} iten(s)` },
{ label: 'Pedidos Realizados', value: orders.length, icon: Activity, color: 'text-amber-400', sub: 'Volume total' },
].map((stat, idx) => (
<div key={idx} className="glass-card p-6 rounded-2xl border border-white/5 relative overflow-hidden group">
<div className="flex justify-between items-start mb-4">
<div className="p-3 bg-white/5 rounded-xl border border-white/5">
<stat.icon size={20} className={stat.color} />
</div>
<span className="text-[10px] font-bold text-emerald-500 bg-emerald-500/10 px-2 py-1 rounded-full flex items-center gap-1">
<ArrowUpRight size={10} /> +2.5%
</span>
</div>
<h3 className="text-2xl font-bold text-white tracking-tight">{stat.value}</h3>
<p className="text-xs font-medium text-slate-500 uppercase tracking-widest mt-1">{stat.label}</p>
<p className="text-[10px] text-slate-600 mt-4 border-t border-white/5 pt-2">{stat.sub}</p>
</div>
))}
</div>
{/* TABS */}
<div className="flex gap-6 border-b border-white/5">
{[
{ id: 'financial', label: 'Financeiro', icon: DollarSign },
{ id: 'products', label: 'Produtos', icon: Package },
{ id: 'inventory', label: 'Estoque', icon: Boxes },
{ id: 'sales', label: 'Vendas', icon: BarChart3 }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={clsx(
"pb-4 flex items-center gap-2 text-sm font-bold transition-all relative",
activeTab === tab.id ? "text-white" : "text-slate-500 hover:text-slate-300"
)}
>
<tab.icon size={16} />
{tab.label}
{activeTab === tab.id && <div className="absolute bottom-0 left-0 w-full h-0.5 bg-indigo-500 shadow-[0_0_10px_rgba(99,102,241,0.5)]"></div>}
</button>
))}
</div>
{/* CONTENT AREA */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* LEFT: MAIN CHART using Recharts */}
<div className="lg:col-span-2 glass-card p-6 rounded-2xl border border-white/5 min-h-[400px]">
<h3 className="text-lg font-bold text-white mb-6">Performance Financeira</h3>
<div className="h-[350px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="colorLucro" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorReceita" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#ffffff10" />
<XAxis dataKey="name" stroke="#64748b" fontSize={12} tickLine={false} axisLine={false} />
<YAxis stroke="#64748b" fontSize={12} tickLine={false} axisLine={false} tickFormatter={(value) => `R$${value / 1000}k`} />
<Tooltip
contentStyle={{ backgroundColor: '#0F1115', borderColor: '#ffffff20', borderRadius: '12px' }}
itemStyle={{ color: '#e2e8f0', fontSize: '12px' }}
/>
<Area type="monotone" dataKey="receita" stroke="#10b981" fillOpacity={1} fill="url(#colorReceita)" strokeWidth={2} />
<Area type="monotone" dataKey="lucro" stroke="#6366f1" fillOpacity={1} fill="url(#colorLucro)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* RIGHT: METRIC LISTS */}
<div className="space-y-6">
{/* TOP PRODUCTS */}
<div className="glass-card p-6 rounded-2xl border border-white/5">
<h3 className="text-sm font-bold text-slate-400 uppercase tracking-widest mb-4">Produtos + Lucrativos</h3>
<div className="space-y-4">
{productStats.topProducts.length > 0 ? productStats.topProducts.map((p, i) => (
<div key={i} className="flex items-center justify-between group">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 text-indigo-400 flex items-center justify-center text-xs font-bold border border-indigo-500/20">
{i + 1}
</div>
<div>
<p className="text-sm font-bold text-slate-200 truncate max-w-[120px]">{p.name}</p>
<p className="text-[10px] text-slate-500">{p.quantity} vendidos</p>
</div>
</div>
<span className="text-emerald-400 font-mono text-xs font-bold">R$ {p.profit.toLocaleString('pt-BR', { maximumFractionDigits: 0 })}</span>
</div>
)) : (
<p className="text-xs text-slate-500 italic">Sem dados de venda.</p>
)}
</div>
</div>
{/* ALERTS */}
<div className="glass-card p-6 rounded-2xl border border-white/5">
<h3 className="text-sm font-bold text-slate-400 uppercase tracking-widest mb-4">Alertas de Estoque</h3>
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl">
<AlertCircle size={16} className="text-rose-400 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-bold text-rose-200">Baixo Giro</p>
<p className="text-[10px] text-rose-400/80 mt-1">3 produtos não vendem 30 dias.</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl">
<AlertCircle size={16} className="text-amber-400 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-bold text-amber-200">Reabastecer</p>
<p className="text-[10px] text-amber-400/80 mt-1">5 SKUs abaixo do estoque mínimo.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default Reports;

558
pages/Sales.tsx Normal file
View file

@ -0,0 +1,558 @@
import React, { useState } from 'react';
import { useCRM } from '../context/CRMContext';
import { Search, ShoppingCart, Trash2, Plus, Minus, CreditCard, ChevronRight, Calculator, Package, AlertTriangle, Download, History, Store, CheckSquare, Square, UserPlus, Pencil, X, Banknote, CreditCard as CardIcon } from 'lucide-react';
import { InventoryItem, Sale, SalesChannel, Customer, PaymentMethod } from '../types';
import clsx from 'clsx';
const Sales: React.FC = () => {
const { inventory, customers, registerSale, sales, importSales, updateSale, loading, addCustomer, updateProduct, isAdmin } = useCRM();
// State
const [viewMode, setViewMode] = useState<'list' | 'pos'>('list'); // 'list' = Pedidos, 'pos' = Frente de Caixa
// POS State
const [searchTerm, setSearchTerm] = useState('');
const [cart, setCart] = useState<{ item: InventoryItem; quantity: number; price: number }[]>([]);
const [selectedCustomer, setSelectedCustomer] = useState<string>('');
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>('Cash'); // New state
const [isFinalizing, setIsFinalizing] = useState(false);
// Import Modal State
const [showImportModal, setShowImportModal] = useState(false);
const [importChannel, setImportChannel] = useState<SalesChannel>('Mercado Livre');
// Quick Customer Modal
const [showCustomerModal, setShowCustomerModal] = useState(false);
const [newCustomer, setNewCustomer] = useState({ name: '', email: '', phone: '' });
// Quick Edit Product Modal
const [editingProduct, setEditingProduct] = useState<InventoryItem | null>(null);
const [editForm, setEditForm] = useState({ price: 0, stock: 0 });
// Filter Inventory
const filteredInventory = inventory.filter(i =>
i.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
i.sku.toLowerCase().includes(searchTerm.toLowerCase())
);
// Cart Actions
const addToCart = (item: InventoryItem) => {
const existing = cart.find(c => c.item.id === item.id);
if (existing) {
if (existing.quantity >= item.quantity) return; // Max stock reached
setCart(cart.map(c => c.item.id === item.id ? { ...c, quantity: c.quantity + 1 } : c));
} else {
if (item.quantity <= 0) return;
setCart([...cart, { item, quantity: 1, price: Number(item.marketPriceBRL) || 0 }]);
}
};
const updateQuantity = (id: string, delta: number) => {
setCart(cart.map(c => {
if (c.item.id === id) {
const newQty = c.quantity + delta;
if (newQty <= 0) return c; // Don't remove here, need explicit remove
if (newQty > c.item.quantity) return c;
return { ...c, quantity: newQty };
}
return c;
}));
};
const removeFromCart = (id: string) => {
setCart(cart.filter(c => c.item.id !== id));
};
const updatePrice = (id: string, newPrice: number) => {
setCart(cart.map(c => c.item.id === id ? { ...c, price: newPrice } : c));
};
// Totals
const totalAmount = cart.reduce((acc, c) => acc + (c.price * c.quantity), 0);
const totalItems = cart.reduce((acc, c) => acc + c.quantity, 0);
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [lastSaleTotal, setLastSaleTotal] = useState(0);
const handleFinalize = async () => {
if (cart.length === 0) return;
setIsFinalizing(true);
const saleTotal = cart.reduce((acc, c) => acc + (c.price * c.quantity), 0);
setLastSaleTotal(saleTotal);
await registerSale(
cart.map(c => ({ id: c.item.id, quantity: c.quantity, salePrice: c.price })),
selectedCustomer || undefined,
paymentMethod // Pass new arg
);
setCart([]);
setSelectedCustomer('');
setPaymentMethod('Cash'); // Reset
setIsFinalizing(false);
setShowSuccessModal(true);
};
const handleNewSale = () => {
setShowSuccessModal(false);
};
const handlePrintReceipt = () => {
alert("Impressão de Recibo iniciada... (Mock)");
// window.print();
};
const handleSendEmail = () => {
const email = customers.find(c => c.id === selectedCustomer)?.email || "cliente@exemplo.com";
alert(`Comprovante enviado para ${email} (Mock)`);
};
// --- IMPORT HANDLERS ---
const handleImport = async () => {
await importSales(importChannel);
setShowImportModal(false);
};
// --- QUICK ACTIONS ---
const handleSaveCustomer = async () => {
if (!newCustomer.name) return alert("Nome é obrigatório");
try {
const created = await addCustomer({ ...newCustomer, status: 'Active', city: 'Foz do Iguaçu' } as any);
if (created) {
setNewCustomer({ name: '', email: '', phone: '' });
setShowCustomerModal(false);
setSelectedCustomer(created.id); // Auto-select
}
} catch (error) {
console.error(error);
alert("Erro ao cadastrar cliente. Verifique o console.");
}
};
const handleSaveProductEdit = async () => {
if (!editingProduct) return;
await updateProduct(editingProduct.id, {
marketPriceBRL: editForm.price,
quantity: editForm.stock
});
setEditingProduct(null);
};
// --- UI RENDERERS ---
// 1. SALES LIST VIEW
const renderSalesList = () => (
<div className="space-y-6">
<div className="flex justify-between items-center bg-white/5 p-6 rounded-[32px] border border-white/5">
<div>
<h2 className="text-xl font-bold text-white tracking-tight">Histórico de Pedidos</h2>
<p className="text-sm text-slate-400">Gerencie vendas locais e importadas.</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowImportModal(true)}
className="bg-slate-800 hover:bg-slate-700 text-white px-5 py-3 rounded-2xl font-bold text-sm transition-all flex items-center gap-2 border border-white/5"
>
<Download size={18} /> Importar Pedidos
</button>
<button
onClick={() => setViewMode('pos')}
className="bg-indigo-600 hover:bg-indigo-500 text-white px-5 py-3 rounded-2xl font-bold text-sm shadow-lg shadow-indigo-900/20 transition-all flex items-center gap-2"
>
<Plus size={18} /> Nova Venda (POS)
</button>
</div>
</div>
<div className="glass-card rounded-3xl border border-white/5 overflow-hidden">
<table className="w-full text-left">
<thead className="bg-black/20 text-[10px] font-bold text-slate-500 uppercase tracking-widest border-b border-white/5">
<tr>
<th className="px-6 py-4">Data / ID</th>
<th className="px-6 py-4">Canal</th>
<th className="px-6 py-4">Cliente</th>
<th className="px-6 py-4 text-right">Total</th>
<th className="px-6 py-4 text-center">Status</th>
<th className="px-6 py-4 text-center">Baixar Estoque</th>
<th className="px-6 py-4 text-center">Lançar Financeiro</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{sales.map(sale => (
<tr key={sale.id} className="hover:bg-white/[0.02] transition-colors">
<td className="px-6 py-4">
<div className="font-mono text-xs text-slate-400">{new Date(sale.date).toLocaleDateString()}</div>
<div className="text-[9px] text-slate-600">#{sale.externalId || sale.id.substring(0, 8)}</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-sm text-slate-300">
{sale.channel === 'Mercado Livre' && <span className="text-yellow-400 font-bold text-xs bg-yellow-400/10 px-2 py-0.5 rounded">MeLi</span>}
{sale.channel === 'Shopee' && <span className="text-orange-400 font-bold text-xs bg-orange-400/10 px-2 py-0.5 rounded">Shopee</span>}
{sale.channel === 'Amazon' && <span className="text-white font-bold text-xs bg-slate-700 px-2 py-0.5 rounded">Amazon</span>}
{sale.channel === 'Local' && <span className="text-emerald-400 font-bold text-xs bg-emerald-400/10 px-2 py-0.5 rounded">Balcão</span>}
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-300 font-medium">
{sale.customerName || 'Cliente Consumidor'}
</td>
<td className="px-6 py-4 text-right text-sm font-bold text-slate-200">
R$ {sale.total.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
</td>
<td className="px-6 py-4 text-center">
<span className={`text-[10px] font-bold uppercase px-2 py-1 rounded-full ${sale.status === 'Completed' ? 'bg-emerald-500/10 text-emerald-400' :
sale.status === 'Pending' ? 'bg-amber-500/10 text-amber-400' :
'bg-slate-500/10 text-slate-400'
}`}>
{sale.status}
</span>
</td>
<td className="px-6 py-4 text-center">
<button
onClick={() => updateSale(sale.id, { isStockLaunched: !sale.isStockLaunched })}
disabled={sale.isStockLaunched}
className={`p-2 rounded-lg transition-all ${sale.isStockLaunched ? 'text-emerald-500 opacity-50 cursor-not-allowed' : 'text-slate-500 hover:bg-white/10 hover:text-white'}`}
>
{sale.isStockLaunched ? <CheckSquare size={18} /> : <Square size={18} />}
</button>
</td>
<td className="px-6 py-4 text-center">
<button
onClick={() => updateSale(sale.id, { isFinancialLaunched: !sale.isFinancialLaunched })}
disabled={sale.isFinancialLaunched}
className={`p-2 rounded-lg transition-all ${sale.isFinancialLaunched ? 'text-emerald-500 opacity-50 cursor-not-allowed' : 'text-slate-500 hover:bg-white/10 hover:text-white'}`}
>
{sale.isFinancialLaunched ? <CheckSquare size={18} /> : <Square size={18} />}
</button>
</td>
</tr>
))}
</tbody>
</table>
{sales.length === 0 && (
<div className="py-20 text-center opacity-30">
<History size={48} className="mx-auto mb-4 text-slate-400" />
<p className="text-sm font-bold text-slate-400 uppercase tracking-widest">Nenhuma venda registrada</p>
</div>
)}
</div>
{/* IMPORT MODAL */}
{showImportModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-[#0F1115] border border-white/10 rounded-[32px] w-full max-w-sm shadow-2xl p-8">
<h3 className="text-xl font-bold text-white mb-6">Importar Pedidos</h3>
<div className="space-y-4 mb-8">
<div>
<label className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 block">Marketplace</label>
<select
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-sm text-slate-300 outline-none focus:border-indigo-500"
value={importChannel}
onChange={(e) => setImportChannel(e.target.value as SalesChannel)}
>
<option value="Mercado Livre">Mercado Livre</option>
<option value="Shopee">Shopee</option>
<option value="Amazon">Amazon</option>
</select>
</div>
<div className="flex items-center gap-3 p-4 bg-white/5 rounded-xl border border-white/5">
<div className="p-2 bg-indigo-500/20 rounded-lg text-indigo-400">
<Store size={16} />
</div>
<div className="text-xs text-slate-400 leading-relaxed">
Simularemos a conexão com a API do <strong>{importChannel}</strong> para buscar novos pedidos pendentes.
</div>
</div>
</div>
<div className="flex gap-3">
<button onClick={() => setShowImportModal(false)} className="flex-1 py-3 text-sm font-bold text-slate-400 hover:text-white transition-colors">Cancelar</button>
<button
onClick={handleImport}
disabled={loading}
className="flex-[2] bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl font-bold text-sm shadow-lg shadow-indigo-900/20 transition-all flex items-center justify-center gap-2"
>
{loading ? (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
) : 'Importar Agora'}
</button>
</div>
</div>
</div>
)}
</div>
);
return (
<div className="animate-in fade-in duration-500 h-[calc(100vh-140px)]">
{viewMode === 'list' ? renderSalesList() : (
<div className="h-full flex flex-col">
<button onClick={() => setViewMode('list')} className="self-start mb-4 text-xs font-bold text-slate-500 hover:text-white flex items-center gap-1 uppercase tracking-wider">
<ChevronRight className="rotate-180" size={14} /> Voltar para Histórico
</button>
<div className="grid grid-cols-1 xl:grid-cols-12 gap-8 h-full">
{/* LEFT: POS / INVENTORY */}
<div className="xl:col-span-8 flex flex-col h-full space-y-6">
{/* ... Search Bar and Grid kept as children ... */}
{/* RE-INSERTING ORIGINAL POS CONTENT WRAPPED IN FRAGMENT OR DIV IF NEEDED */}
{/* Search Bar */}
<div className="bg-white/5 p-6 rounded-[32px] border border-white/5 flex gap-4 items-center shadow-sm">
<div className="relative flex-grow">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={20} />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Buscar produto por nome ou SKU..."
className="w-full bg-slate-900/50 border border-white/10 rounded-2xl py-4 pl-12 pr-4 text-white focus:border-indigo-500 outline-none transition-all placeholder:text-slate-600"
/>
</div>
</div>
{/* Grid */}
<div className="flex-grow overflow-y-auto pr-2 custom-scrollbar">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredInventory.map(item => (
<div
key={item.id}
onClick={() => addToCart(item)}
className={clsx(
"p-5 rounded-[24px] border transition-all cursor-pointer group relative overflow-hidden",
item.quantity === 0 ? "opacity-50 grayscale border-white/5 bg-white/5 cursor-not-allowed" : "bg-white/5 border-white/5 hover:border-indigo-500/50 hover:bg-white/10 active:scale-95"
)}
>
<div className="flex justify-between items-start mb-3">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest bg-black/20 px-2 py-1 rounded-lg">{item.sku}</span>
{item.quantity <= 3 && item.quantity > 0 && (
<div className="flex items-center gap-1 text-amber-400 text-[10px] font-bold bg-amber-400/10 px-2 py-1 rounded-lg">
<AlertTriangle size={10} /> Baixo Estoque
</div>
)}
{isAdmin && (
<button
onClick={(e) => {
e.stopPropagation();
setEditingProduct(item);
setEditForm({ price: item.marketPriceBRL || 0, stock: item.quantity });
}}
className="text-slate-500 hover:text-white p-1 rounded hover:bg-white/10 transition-colors"
>
<Pencil size={12} />
</button>
)}
</div>
<h4 className="text-sm font-bold text-slate-200 leading-tight mb-4 min-h-[40px] line-clamp-2">{item.name}</h4>
<div className="flex justify-between items-end">
<div>
<p className="text-[10px] font-bold text-slate-500 uppercase">Preço Venda</p>
<p className="text-lg font-bold text-emerald-400">R$ {(Number(item.marketPriceBRL) || 0).toLocaleString('pt-BR')}</p>
</div>
<div className="text-right">
<p className="text-[10px] font-bold text-slate-500 uppercase">Estoque</p>
<p className={clsx("text-lg font-bold", item.quantity === 0 ? "text-rose-500" : "text-white")}>{item.quantity}</p>
</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* RIGHT: CART / CHECKOUT */}
<div className="xl:col-span-4 flex flex-col h-full">
<div className="bg-white/5 rounded-[40px] border border-white/5 shadow-2xl flex flex-col h-full overflow-hidden backdrop-blur-xl">
{/* Header */}
<div className="p-8 border-b border-white/5 bg-slate-900/50 flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-indigo-600 flex items-center justify-center text-white shadow-lg shadow-indigo-500/20">
<ShoppingCart size={24} />
</div>
<div>
<h2 className="text-xl font-bold text-white tracking-tight">Carrinho</h2>
<p className="text-xs font-bold text-slate-500 uppercase tracking-widest">Nova Venda</p>
</div>
</div>
<span className="bg-white/10 text-white px-3 py-1 rounded-xl text-xs font-bold">{totalItems} itens</span>
</div>
{/* Customer Select (Optional) */}
<div className="p-6 border-b border-white/5 flex items-center gap-3">
<select
value={selectedCustomer}
onChange={(e) => setSelectedCustomer(e.target.value)}
className="flex-grow bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-sm text-slate-300 focus:border-indigo-500 outline-none cursor-pointer appearance-none"
>
<option value="">Cliente Não Identificado (Balcão)</option>
{customers.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<button
onClick={() => setShowCustomerModal(true)}
className="bg-indigo-600 hover:bg-indigo-500 text-white p-3 rounded-xl transition-colors shadow-lg shadow-indigo-900/20"
title="Novo Cliente"
>
<UserPlus size={18} />
</button>
</div>
{/* Cart Items */}
<div className="flex-grow overflow-y-auto p-6 space-y-4 custom-scrollbar bg-black/10">
{cart.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center opacity-20 text-center">
<Package size={64} className="mb-4 text-slate-400" />
<p className="text-sm font-bold text-slate-400 uppercase tracking-widest">Carrinho Vazio</p>
</div>
) : (
cart.map((c, idx) => (
<div key={idx} className="bg-slate-800/50 p-4 rounded-2xl border border-white/5 group hover:border-indigo-500/30 transition-all">
<div className="flex justify-between items-start mb-2">
<h4 className="text-xs font-bold text-slate-200 line-clamp-1 pr-4">{c.item.name}</h4>
<button onClick={() => removeFromCart(c.item.id)} className="text-slate-600 hover:text-rose-500 transition-colors"><Trash2 size={14} /></button>
</div>
<div className="flex items-center justify-between gap-4 mt-3">
<div className="flex items-center gap-2 bg-black/30 rounded-lg p-1 border border-white/5">
<button onClick={() => updateQuantity(c.item.id, -1)} className="p-1.5 hover:bg-white/10 rounded-md text-slate-400"><Minus size={12} /></button>
<span className="text-xs font-bold text-white w-6 text-center">{c.quantity}</span>
<button onClick={() => updateQuantity(c.item.id, 1)} className="p-1.5 hover:bg-white/10 rounded-md text-slate-400"><Plus size={12} /></button>
</div>
<div className="flex-grow">
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-[10px] mobile:text-[10px] text-slate-500 font-bold">R$</span>
<input
type="number"
value={c.price}
onChange={(e) => updatePrice(c.item.id, parseFloat(e.target.value))}
className="w-full bg-black/30 border border-white/10 rounded-lg py-1.5 pl-8 pr-2 text-right text-xs font-bold text-white focus:border-indigo-500 outline-none"
/>
</div>
</div>
</div>
<div className="text-right mt-2">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">Subtotal:</span>
<span className="text-xs font-bold text-indigo-400 ml-2">R$ {(c.price * c.quantity).toLocaleString('pt-BR')}</span>
</div>
</div>
))
)}
</div>
{/* Summary & Action */}
<div className="p-8 bg-slate-900 border-t border-white/10 space-y-6">
<div className="space-y-2">
<div className="flex justify-between text-xs text-slate-500 font-medium">
<span>Itens</span>
<span>{totalItems} UN</span>
</div>
<div className="flex justify-between items-end">
<span className="text-sm font-bold text-white uppercase tracking-widest">Total Geral</span>
<span className="text-3xl font-bold text-emerald-400 tracking-tight">R$ {totalAmount.toLocaleString('pt-BR')}</span>
</div>
</div>
{/* Payment Method Selector */}
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-3">Forma de Pagamento</p>
<div className="grid grid-cols-3 gap-2">
{['Cash', 'Pix', 'Credit Card'].map(method => (
<button
key={method}
onClick={() => setPaymentMethod(method as PaymentMethod)}
className={clsx(
"py-2 px-1 rounded-lg text-xs font-bold transition-all flex flex-col items-center gap-1",
paymentMethod === method
? "bg-indigo-600 text-white shadow-lg"
: "bg-black/20 text-slate-400 hover:bg-white/10 hover:text-white"
)}
>
{method === 'Cash' && <Banknote size={14} />}
{method === 'Pix' && <img src="https://icongr.am/entypo/flash.svg?size=14&color=currentColor" className={paymentMethod === 'Pix' ? "invert" : "opacity-50"} alt="Pix" />}
{method === 'Credit Card' && <CardIcon size={14} />}
<span>{method === 'Credit Card' ? 'Cartão' : method}</span>
</button>
))}
</div>
</div>
<button
onClick={handleFinalize}
disabled={cart.length === 0 || isFinalizing}
className="w-full py-4 bg-emerald-600 hover:bg-emerald-500 text-white rounded-2xl font-bold text-sm shadow-xl shadow-emerald-900/40 transition-all flex items-center justify-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed active:scale-95"
>
{isFinalizing ? (
<div className="animate-spin w-5 h-5 border-2 border-white/30 border-t-white rounded-full"></div>
) : (
<>
<CreditCard size={18} /> FINALIZAR VENDA
</>
)}
</button>
</div>
</div>
</div>
</div>
</div>
)}
{/* SUCCESS MODAL */}
{showSuccessModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-300">
<div className="bg-[#0F1115] border border-white/10 rounded-[32px] w-full max-w-md shadow-2xl p-8 relative overflow-hidden">
{/* Confetti or success decoration could go here */}
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-emerald-500 to-indigo-500"></div>
<div className="flex flex-col items-center text-center mb-8">
<div className="w-20 h-20 bg-emerald-500/10 rounded-full flex items-center justify-center mb-6 border border-emerald-500/20 shadow-[0_0_30px_rgba(16,185,129,0.2)]">
<CreditCard size={40} className="text-emerald-400" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">Venda Realizada!</h2>
<p className="text-slate-400">Total processado com sucesso.</p>
<div className="mt-4 text-3xl font-bold text-emerald-400 font-mono tracking-tight">R$ {lastSaleTotal.toLocaleString('pt-BR')}</div>
</div>
<div className="space-y-3">
<button
onClick={handlePrintReceipt}
className="w-full py-4 bg-white/5 hover:bg-white/10 border border-white/10 rounded-2xl flex items-center justify-between px-6 text-slate-300 hover:text-white transition-all group"
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-black/40 text-indigo-400"><Calculator size={20} /></div> {/* Printer Icon replacement */}
<span className="font-bold text-sm">Imprimir Recibo</span>
</div>
<ChevronRight size={16} className="text-slate-600 group-hover:text-white" />
</button>
<button
onClick={handleSendEmail}
className="w-full py-4 bg-white/5 hover:bg-white/10 border border-white/10 rounded-2xl flex items-center justify-between px-6 text-slate-300 hover:text-white transition-all group"
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-black/40 text-blue-400"><AlertTriangle size={20} /></div> {/* Mail Icon replacement */}
<span className="font-bold text-sm">Enviar por Email</span>
</div>
<ChevronRight size={16} className="text-slate-600 group-hover:text-white" />
</button>
<button
onClick={handleNewSale}
className="w-full py-4 bg-indigo-600 hover:bg-indigo-500 text-white rounded-2xl font-bold text-sm shadow-xl shadow-indigo-900/20 transition-all flex items-center justify-center gap-2 mt-6 active:scale-95"
>
<Plus size={18} /> NOVA VENDA
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Sales;

452
pages/Settings.tsx Normal file
View file

@ -0,0 +1,452 @@
import React, { useState } from 'react';
import { Settings as SettingsIcon, Truck, Key, Building, Users, Save, Globe, Smartphone, Bell, Shield, FileCheck, ShoppingBag, RefreshCw, AlertCircle, Upload, Palette } from 'lucide-react';
import clsx from 'clsx';
import { useCRM } from '../context/CRMContext';
import { useTheme } from '../context/ThemeContext';
const ThemeSelector = () => {
const { theme, setTheme } = useTheme();
return (
<div>
<h3 className="text-sm font-bold text-white uppercase tracking-wider mb-4 border-b border-white/5 pb-2">Tema do Sistema</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{ id: 'light', name: 'Simples (Clean)', color: 'bg-slate-50 border-2 border-slate-200', accent: 'bg-blue-600', text: 'Visual limpo e direto.' },
{ id: 'ocean', name: 'Complexo (Ocean)', color: 'bg-[#0f172a]', accent: 'bg-cyan-500', text: 'Alta densidade e contraste.' },
{ id: 'dark', name: 'Completo (Pro)', color: 'bg-[#0a0a0c]', accent: 'bg-indigo-600', text: 'Interface imersiva padrão.' },
].map(t => (
<button
key={t.id}
onClick={() => setTheme(t.id as any)}
className={clsx(
"group relative rounded-2xl overflow-hidden transition-all duration-300 border-2 flex flex-col items-start text-left",
theme === t.id ? "border-indigo-500 ring-2 ring-indigo-500/20 scale-[1.02]" : "border-white/5 hover:border-white/10"
)}
>
{/* Preview Header */}
<div className={clsx("h-24 w-full relative", t.color)}>
<div className="absolute top-3 left-3 flex gap-1.5">
<div className="w-2 h-2 rounded-full bg-red-400/50"></div>
<div className="w-2 h-2 rounded-full bg-amber-400/50"></div>
<div className="w-2 h-2 rounded-full bg-emerald-400/50"></div>
</div>
<div className={clsx("absolute bottom-3 right-3 px-2 py-1 rounded text-[10px] font-bold text-white shadow-lg", t.accent)}>
Aa
</div>
{/* Fake UI Lines */}
<div className="absolute top-8 left-3 w-3/4 h-2 bg-white/5 rounded-full"></div>
<div className="absolute top-12 left-3 w-1/2 h-2 bg-white/5 rounded-full"></div>
</div>
{/* Content */}
<div className="p-5 bg-card w-full">
<div className="flex justify-between items-center mb-1">
<span className="text-sm font-bold text-foreground">{t.name}</span>
{theme === t.id && <div className="w-2 h-2 rounded-full bg-emerald-500 shadow-emerald-500/50 shadow-lg"></div>}
</div>
<p className="text-xs text-muted-foreground">{t.text}</p>
</div>
</button>
))}
</div>
</div>
);
};
const Settings: React.FC = () => {
const { overheadPercent, exchangeRate, settings, updateSettings } = useCRM();
const [activeTab, setActiveTab] = useState<'general' | 'fiscal' | 'marketplaces' | 'logistics' | 'email' | 'notifications'>('general');
// Local state for form, initialized with context settings
const [config, setConfig] = useState(settings || {
companyName: 'Arbitra System',
cnpj: '',
ie: '',
defaultOverhead: 20,
defaultExchange: 5.65,
certificateName: '',
certificatePassword: '',
geminiKey: 'sk-....................',
melhorEnvioToken: '',
blingToken: '',
tinyToken: '',
whatsappNumber: '',
nfeSerie: '1',
nfeNumber: '159',
autoSyncSales: true,
autoSyncStock: true,
smtpHost: '',
smtpPort: '587',
smtpUser: '',
smtpPass: '',
});
// Sync state when settings load from DB
React.useEffect(() => {
if (settings) {
setConfig(prev => ({ ...prev, ...settings }));
}
}, [settings]);
const handleSave = async () => {
try {
await updateSettings(config);
alert('Configurações salvas com sucesso!');
} catch (error) {
console.error(error);
alert('Erro ao salvar configurações.');
}
};
const tabs = [
{ id: 'general', label: 'Geral', icon: Building, description: 'Dados cadastrais' },
{ id: 'fiscal', label: 'Fiscal & NFe', icon: FileCheck, description: 'Certificado e tributação' },
{ id: 'appearance', label: 'Aparência', icon: Palette, description: 'Temas e Cores' },
{ id: 'marketplaces', label: 'Marketplaces', icon: ShoppingBag, description: 'Integrações de vendas' },
{ id: 'logistics', label: 'Logística', icon: Truck, description: 'Fretes e entregas' },
{ id: 'email', label: 'Email SMTP', icon: Globe, description: 'Configuração de envio' },
{ id: 'notifications', label: 'Notificações', icon: Bell, description: 'Alertas e avisos' },
];
return (
<div className="animate-in fade-in duration-500 container mx-auto max-w-[1600px] pb-20">
<div className="flex flex-col md:flex-row justify-between items-end mb-6 border-b border-white/5 pb-6">
<div>
<h1 className="text-2xl font-bold tracking-tight text-white mb-1">Configurações do Sistema</h1>
<p className="text-sm text-slate-400">Gerencie todos os parâmetros fiscais, integrações e regras de negócio.</p>
</div>
<button
onClick={handleSave}
className="bg-indigo-600 hover:bg-indigo-500 text-white px-6 py-2.5 rounded-lg font-bold text-sm shadow-lg shadow-indigo-900/20 active:scale-95 transition-all flex items-center gap-2"
>
<Save size={16} /> Salvar Alterações
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Denser Sidebar */}
<div className="lg:col-span-2 flex flex-col gap-1">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={clsx(
"text-left px-4 py-3 rounded-lg transition-all border group flex items-center gap-3",
activeTab === tab.id
? "bg-indigo-600/10 border-indigo-500/30 text-white"
: "bg-transparent border-transparent text-slate-400 hover:bg-white/5 hover:text-slate-200"
)}
>
<tab.icon size={16} className={activeTab === tab.id ? "text-indigo-400" : "text-slate-500"} />
<div className="leading-tight">
<p className="font-semibold text-xs uppercase tracking-wide">{tab.label}</p>
</div>
</button>
))}
</div>
{/* Content Area - Denser Forms */}
<div className="lg:col-span-10">
<div className="bg-[#0F1115] border border-white/10 rounded-xl p-6 shadow-sm min-h-[600px]">
{activeTab === 'general' && (
<div className="space-y-8 animate-in fade-in duration-300">
{/* Section */}
<div>
<h3 className="text-sm font-bold text-white uppercase tracking-wider mb-4 border-b border-white/5 pb-2">Identificação da Empresa</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="md:col-span-1 space-y-1.5">
<label className="text-[10px] font-bold text-slate-500 uppercase">CNPJ</label>
<input
value={config.cnpj}
onChange={e => setConfig({ ...config, cnpj: e.target.value })}
placeholder="00.000.000/0000-00"
className="w-full bg-black/20 border border-white/10 rounded-lg px-3 py-2 text-sm text-slate-200 focus:border-indigo-500 outline-none transition-all font-mono"
/>
</div>
<div className="md:col-span-1 space-y-1.5">
<label className="text-[10px] font-bold text-slate-500 uppercase">Inscrição Estadual</label>
<input
value={config.ie}
onChange={e => setConfig({ ...config, ie: e.target.value })}
className="w-full bg-black/20 border border-white/10 rounded-lg px-3 py-2 text-sm text-slate-200 focus:border-indigo-500 outline-none transition-all"
/>
</div>
<div className="md:col-span-1 space-y-1.5">
<label className="text-[10px] font-bold text-slate-500 uppercase">Nome Fantasia</label>
<input
value={config.companyName}
onChange={e => setConfig({ ...config, companyName: e.target.value })}
className="w-full bg-black/20 border border-white/10 rounded-lg px-3 py-2 text-sm text-slate-200 focus:border-indigo-500 outline-none transition-all"
/>
</div>
</div>
</div>
{/* Section */}
<div>
<h3 className="text-sm font-bold text-white uppercase tracking-wider mb-4 border-b border-white/5 pb-2">Parâmetros Financeiros</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-500 uppercase">Cotação Dólar (Base)</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-xs font-bold">R$</span>
<input
type="number"
value={config.defaultExchange}
onChange={e => setConfig({ ...config, defaultExchange: parseFloat(e.target.value) })}
className="w-full bg-black/20 border border-white/10 rounded-lg pl-8 pr-3 py-2 text-sm text-slate-200 focus:border-emerald-500 outline-none transition-all font-mono"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-500 uppercase">Overhead Padrão (%)</label>
<div className="relative">
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 text-xs font-bold">%</span>
<input
type="number"
value={config.defaultOverhead}
onChange={e => setConfig({ ...config, defaultOverhead: parseFloat(e.target.value) })}
className="w-full bg-black/20 border border-white/10 rounded-lg px-3 py-2 text-sm text-slate-200 focus:border-indigo-500 outline-none transition-all font-mono"
/>
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === 'appearance' && (
<div className="space-y-8 animate-in fade-in duration-300">
<ThemeSelector />
</div>
)}
{activeTab === 'fiscal' && (
<div className="space-y-8 animate-in fade-in duration-300">
{/* Certificate */}
<div>
<h3 className="text-sm font-bold text-white uppercase tracking-wider mb-4 border-b border-white/5 pb-2">Certificado Digital (A1)</h3>
<div className="bg-white/5 border border-white/5 rounded-lg p-6 flex flex-col items-center justify-center border-dashed group hover:border-indigo-500/50 transition-colors cursor-pointer">
<Upload className="text-slate-500 mb-2 group-hover:text-indigo-400" size={32} />
<p className="text-sm font-bold text-slate-300 group-hover:text-white">Clique para selecionar o Certificado .PFX</p>
<p className="text-xs text-slate-500 mt-1">Sua senha será solicitada após o upload</p>
</div>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-500 uppercase">Senha do Certificado</label>
<input
type="password"
value={config.certificatePassword}
onChange={e => setConfig({ ...config, certificatePassword: e.target.value })}
className="w-full bg-black/20 border border-white/10 rounded-lg px-3 py-2 text-sm text-slate-200 focus:border-indigo-500 outline-none transition-all"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-500 uppercase">Validade</label>
<div className="w-full bg-black/20 border border-white/10 rounded-lg px-3 py-2 text-sm text-slate-500 cursor-not-allowed flex items-center justify-between">
<span>--/--/----</span>
<AlertCircle size={14} className="text-amber-500" />
</div>
</div>
</div>
</div>
{/* NFe Config */}
<div>
<h3 className="text-sm font-bold text-white uppercase tracking-wider mb-4 border-b border-white/5 pb-2">Configuração de Emissão (NFe 4.0)</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-500 uppercase">Próximo Número NFe</label>
<input
type="number"
value={config.nfeNumber}
onChange={e => setConfig({ ...config, nfeNumber: e.target.value })}
className="w-full bg-black/20 border border-white/10 rounded-lg px-3 py-2 text-sm text-slate-200 focus:border-indigo-500 outline-none transition-all font-mono"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-500 uppercase">Série</label>
<input
type="number"
value={config.nfeSerie}
onChange={e => setConfig({ ...config, nfeSerie: e.target.value })}
className="w-full bg-black/20 border border-white/10 rounded-lg px-3 py-2 text-sm text-slate-200 focus:border-indigo-500 outline-none transition-all font-mono"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-500 uppercase">Ambiente</label>
<select className="w-full bg-black/20 border border-white/10 rounded-lg px-3 py-2 text-sm text-slate-200 focus:border-indigo-500 outline-none transition-all">
<option value="homologacao">Homologação (Teste)</option>
<option value="producao">Produção</option>
</select>
</div>
</div>
</div>
</div>
)}
{activeTab === 'marketplaces' && (
<div className="space-y-8 animate-in fade-in duration-300">
{/* Major Marketplaces */}
<div>
<h3 className="text-sm font-bold text-white uppercase tracking-wider mb-4 border-b border-white/5 pb-2">Hub de Integração (Mais de 30 canais)</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[
{ name: 'Mercado Livre', color: 'bg-yellow-500', status: 'connected', sales: 1240 },
{ name: 'Shopee', color: 'bg-orange-600', status: 'error', sales: 0 },
{ name: 'Amazon Seller', color: 'bg-slate-800', status: 'disconnected', sales: 0 },
{ name: 'Magalu', color: 'bg-blue-600', status: 'disconnected', sales: 0 },
{ name: 'Via Varejo', color: 'bg-green-600', status: 'disconnected', sales: 0 },
{ name: 'B2W (Americanas)', color: 'bg-red-600', status: 'disconnected', sales: 0 },
{ name: 'Shein', color: 'bg-black', status: 'disconnected', sales: 0 },
{ name: 'AliExpress', color: 'bg-red-500', status: 'disconnected', sales: 0 },
].map(mk => (
<div key={mk.name} className="flex flex-col justify-between p-4 bg-white/5 border border-white/5 rounded-lg hover:bg-white/10 transition-colors h-[140px]">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={clsx("w-10 h-10 rounded-lg flex items-center justify-center text-white font-bold text-xs shadow-lg", mk.color)}>
{mk.name.substring(0, 2)}
</div>
<div>
<p className="text-sm font-bold text-white leading-tight">{mk.name}</p>
{mk.status === 'connected' && <p className="text-[10px] text-emerald-400"> Online</p>}
{mk.status === 'error' && <p className="text-[10px] text-rose-400"> Erro</p>}
{mk.status === 'disconnected' && <p className="text-[10px] text-slate-500"> Offline</p>}
</div>
</div>
{/* Toggle Switch Mockup */}
<div className={clsx("w-8 h-4 rounded-full relative transition-colors", mk.status === 'connected' ? "bg-emerald-500/20" : "bg-white/10")}>
<div className={clsx("absolute top-0.5 w-3 h-3 rounded-full transition-all", mk.status === 'connected' ? "left-4 bg-emerald-500" : "left-0.5 bg-slate-500")}></div>
</div>
</div>
<div className="mt-4 pt-3 border-t border-white/5 flex items-center justify-between">
<div>
{mk.status === 'connected' ? (
<>
<p className="text-[10px] text-slate-500 uppercase font-bold">Vendas Hoje</p>
<p className="text-sm font-bold text-white">R$ {mk.sales.toLocaleString('pt-BR')}</p>
</>
) : (
<p className="text-[10px] text-slate-600 italic">Clique para configurar</p>
)}
</div>
<button className="px-3 py-1.5 rounded bg-white/5 text-[10px] font-bold text-slate-300 hover:bg-white/20 transition-colors uppercase tracking-wide">
{mk.status === 'disconnected' ? 'Configurar' : 'Gerenciar'}
</button>
</div>
</div>
))}
</div>
</div>
{/* Custom Sites / E-commerce Platforms */}
<div>
<h3 className="text-sm font-bold text-white uppercase tracking-wider mb-4 border-b border-white/5 pb-2">Lojas Virtuais & Sites Próprios</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[
{ name: 'WooCommerce', color: 'bg-purple-600' },
{ name: 'Shopify', color: 'bg-emerald-500' },
{ name: 'Nuvemshop', color: 'bg-blue-500' },
{ name: 'Vtex', color: 'bg-pink-600' },
{ name: 'API Personalizada', color: 'bg-slate-700', isCustom: true },
].map(platform => (
<div key={platform.name} className="flex items-center justify-between p-3 bg-white/5 border border-white/5 rounded-lg hover:border-indigo-500/30 transition-all cursor-pointer group">
<div className="flex items-center gap-3">
<div className={clsx("w-8 h-8 rounded-lg flex items-center justify-center text-white font-bold text-[10px]", platform.color)}>
{platform.isCustom ? <Key size={14} /> : platform.name.substring(0, 1)}
</div>
<span className="text-sm font-medium text-slate-300 group-hover:text-white transition-colors">{platform.name}</span>
</div>
<div className="w-6 h-6 rounded-full border border-white/10 flex items-center justify-center group-hover:bg-indigo-500 group-hover:border-indigo-500 transition-all">
<div className="w-1.5 h-1.5 bg-white rounded-full opacity-0 group-hover:opacity-100"></div>
</div>
</div>
))}
</div>
<p className="text-[10px] text-slate-500 mt-2">* Para API Personalizada, consulte a documentação /docs/api-v1</p>
</div>
{/* Automation Settings */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="text-sm font-bold text-white uppercase tracking-wider mb-4 border-b border-white/5 pb-2">Regras de Sincronização</h3>
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-black/20 rounded-lg border border-white/5">
<div>
<p className="text-xs font-bold text-slate-200">Importar Pedidos</p>
<p className="text-[10px] text-slate-500">Baixar vendas novas a cada 5 min.</p>
</div>
<input type="checkbox" checked={config.autoSyncSales} onChange={() => setConfig({ ...config, autoSyncSales: !config.autoSyncSales })} className="accent-emerald-500 w-4 h-4" />
</div>
<div className="flex items-center justify-between p-3 bg-black/20 rounded-lg border border-white/5">
<div>
<p className="text-xs font-bold text-slate-200">Atualizar Estoque</p>
<p className="text-[10px] text-slate-500">Enviar saldo local para os canais.</p>
</div>
<input type="checkbox" checked={config.autoSyncStock} onChange={() => setConfig({ ...config, autoSyncStock: !config.autoSyncStock })} className="accent-emerald-500 w-4 h-4" />
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === 'logistics' && (
<div className="space-y-8 animate-in fade-in duration-300">
<div>
<h3 className="text-sm font-bold text-white uppercase tracking-wider mb-4 border-b border-white/5 pb-2">Hubs de Frete</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-white/5 border border-white/5 space-y-3">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Truck size={18} className="text-blue-400" />
<span className="font-bold text-sm text-slate-200">Melhor Envio</span>
</div>
<span className="text-[10px] font-bold text-emerald-400 bg-emerald-400/10 px-2 py-0.5 rounded">Ativo</span>
</div>
<input
value={config.melhorEnvioToken}
onChange={e => setConfig({ ...config, melhorEnvioToken: e.target.value })}
placeholder="Token de Produção"
type="password"
className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-slate-400 text-xs focus:border-indigo-500 outline-none transition-all font-mono"
/>
</div>
<div className="p-4 rounded-lg bg-white/5 border border-white/5 space-y-3 opacity-50">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Truck size={18} className="text-orange-400" />
<span className="font-bold text-sm text-slate-200">Frenet</span>
</div>
<span className="text-[10px] font-bold text-slate-500 bg-white/5 px-2 py-0.5 rounded">Inativo</span>
</div>
<input
placeholder="Token de Acesso"
disabled
className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-slate-400 text-xs focus:border-indigo-500 outline-none transition-all font-mono"
/>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default Settings;

234
pages/Sourcing.tsx Normal file
View file

@ -0,0 +1,234 @@
import React from 'react';
import { Search, Package, Plus, Calculator, Trash2, Minus, ShoppingCart, CheckCircle2, Facebook, Tag, ToggleLeft, ToggleRight } from 'lucide-react';
import { useCRM } from '../context/CRMContext';
import MarketplaceAnalytic from '../components/MarketplaceAnalytic';
const Sourcing: React.FC = () => {
const {
searchTerm, setSearchTerm, handleSearch, handleOpportunitySearch, products,
selectedProduct, setSelectedProduct, addToShoppingList,
shoppingList, removeFromShoppingList,
updateShoppingItemQuantity, saveOrderAsQuotation,
calculateShoppingTotals, overheadPercent,
useOverhead, setUseOverhead,
searchLoading, searchError, searchType, setSearchType
} = useCRM();
const onSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (searchType === 'specific') {
handleSearch(e);
} else {
handleOpportunitySearch(searchTerm);
}
};
const handleUpdateQuantity = (item: any, delta: number) => {
updateShoppingItemQuantity(item.id, delta);
};
const handleRemoveItem = (id: string) => {
removeFromShoppingList(id);
};
return (
<div className="grid grid-cols-1 xl:grid-cols-12 gap-8 items-start animate-in fade-in duration-500">
{/* COLUNA 1: BUSCA */}
<div className="xl:col-span-3 space-y-6">
<div className="bg-white/5 rounded-[32px] p-6 border border-white/5 shadow-sm backdrop-blur-sm">
{/* TABS */}
<div className="flex bg-slate-900/50 rounded-2xl p-1 mb-6 border border-white/5">
<button
onClick={() => setSearchType('specific')}
className={`flex-1 py-2 text-[10px] font-bold uppercase tracking-wider rounded-xl transition-all ${searchType === 'specific' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'}`}
>
Busca Específica
</button>
<button
onClick={() => setSearchType('opportunity')}
className={`flex-1 py-2 text-[10px] font-bold uppercase tracking-wider rounded-xl transition-all ${searchType === 'opportunity' ? 'bg-emerald-600 text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'}`}
>
Oportunidades
</button>
</div>
<div className="flex items-center gap-2 mb-6">
{searchType === 'specific' ? <Search size={16} className="text-indigo-400" /> : <Tag size={16} className="text-emerald-400" />}
<h2 className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">
{searchType === 'specific' ? 'Sourcing Real-Time' : 'Caçador de Margem (>25%)'}
</h2>
</div>
<form onSubmit={onSearchSubmit} className="relative group mb-8">
<input
type="text" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)}
placeholder={searchType === 'specific' ? "Ex: iPhone 16 Pro Max" : "Ex: Celulares, Drones, Games"}
className={`w-full bg-slate-900/50 border border-white/10 rounded-2xl py-4 pl-12 pr-4 text-sm font-semibold outline-none transition-all text-white placeholder-slate-600 shadow-inner ${searchType === 'specific' ? 'focus:border-indigo-500' : 'focus:border-emerald-500'}`}
/>
<Search className={`absolute left-4 top-1/2 -translate-y-1/2 transition-colors ${searchType === 'specific' ? 'text-slate-500 group-focus-within:text-indigo-500' : 'text-slate-500 group-focus-within:text-emerald-500'}`} size={20} />
</form>
{searchError && (
<div className="mb-6 p-4 bg-rose-500/10 border border-rose-500/20 rounded-2xl flex items-center gap-3 text-rose-400">
<div className="w-2 h-2 bg-rose-500 rounded-full animate-pulse"></div>
<p className="text-xs font-bold uppercase tracking-wide">{searchError}</p>
</div>
)}
<div className="space-y-4 max-h-[600px] overflow-y-auto pr-2 custom-scrollbar">
{searchLoading ? (
<div className="py-20 text-center animate-pulse">
<div className="w-10 h-10 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-[10px] font-bold text-slate-500 uppercase">Consultando Compras Paraguai...</p>
</div>
) : products.map((p, idx) => (
<div
key={idx}
onClick={() => setSelectedProduct(p)}
className={`p-5 rounded-3xl border-2 cursor-pointer transition-all hover:scale-[1.02] active:scale-95 ${selectedProduct?.name === p.name
? 'border-indigo-500 bg-indigo-500/10 shadow-md'
: 'border-white/5 bg-white/5 hover:border-white/10 hover:bg-white/10'
}`}
>
<div className="flex justify-between items-start">
<span className="text-[8px] font-bold uppercase bg-slate-800 border border-slate-700 px-2 py-0.5 rounded-lg text-slate-400">{p.store}</span>
</div>
<h4 className="text-sm font-bold text-slate-200 leading-tight mt-3 mb-4 line-clamp-2">{p.name}</h4>
<div className="flex items-end justify-between">
<div>
<p className="text-xl font-bold text-white tracking-tight">US$ {p.priceUSD.toFixed(2)}</p>
<p className="text-[10px] font-bold text-slate-500 uppercase">R$ {p.priceBRL.toLocaleString('pt-BR')}</p>
</div>
<div className="flex flex-col items-end gap-2">
{p.salesVolume && (
<span className="text-[9px] font-bold uppercase tracking-widest bg-emerald-500/10 text-emerald-400 px-2 py-1 rounded-lg border border-emerald-500/20 shadow-[0_0_10px_rgba(16,185,129,0.1)]">
{p.salesVolume}
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); addToShoppingList(p); }}
className="p-3 bg-indigo-600 text-white rounded-2xl hover:bg-indigo-500 transition-all shadow-lg active:scale-90"
>
<Plus size={18} />
</button>
</div>
</div>
</div>
))}
{!searchLoading && products.length === 0 && (
<div className="py-24 text-center opacity-20">
<Package size={48} strokeWidth={1.5} className="mx-auto mb-4 text-slate-400" />
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Aguardando seu input</p>
</div>
)}
</div>
</div>
</div>
{/* COLUNA 2: ANÁLISE DETALHADA */}
<div className="xl:col-span-6">
{selectedProduct ? (
<MarketplaceAnalytic product={selectedProduct} overheadPercent={overheadPercent} useOverhead={useOverhead} />
) : (
<div className="bg-white/5 rounded-[40px] border border-white/5 shadow-sm min-h-[600px] flex flex-col items-center justify-center p-20 text-center backdrop-blur-sm">
<div className="w-24 h-24 bg-slate-800/50 rounded-[32px] flex items-center justify-center mb-8 border border-white/5 shadow-inner">
<Calculator size={40} className="text-slate-600" />
</div>
<h3 className="text-2xl font-bold text-white mb-4 tracking-tight">Analítica de Arbitragem</h3>
<p className="text-slate-500 max-w-sm text-sm font-medium leading-relaxed">Selecione um produto para ver a comparação de margem real entre os marketplaces do Brasil e sua venda direta no Facebook.</p>
</div>
)}
</div>
{/* COLUNA 3: COTAÇÃO / CRM CHECKOUT */}
<div className="xl:col-span-3">
<div className="bg-white/5 rounded-[40px] border border-white/5 shadow-sm flex flex-col min-h-[700px] sticky top-[120px] overflow-hidden backdrop-blur-sm">
<div className="p-8 border-b border-white/5 flex justify-between items-center bg-white/5">
<div>
<h2 className="text-sm font-bold text-white uppercase tracking-widest leading-none">Minha Cotação</h2>
<p className="text-[9px] font-bold text-indigo-400 uppercase mt-1">Sourcing Ativo</p>
</div>
<span className="w-9 h-9 bg-indigo-600 text-white text-xs font-bold rounded-xl flex items-center justify-center shadow-lg shadow-indigo-500/30">{shoppingList.length}</span>
</div>
<div className="flex-grow p-6 space-y-4 overflow-y-auto max-h-[450px] custom-scrollbar">
{shoppingList.length > 0 ? (
shoppingList.map(item => (
<div key={item.id} className="p-5 bg-slate-900/50 rounded-3xl border border-white/5 group transition-all hover:border-indigo-500/30 hover:shadow-md">
<div className="flex justify-between items-start mb-4">
<div className="flex-grow pr-4">
<span className="text-[9px] font-bold text-slate-500 uppercase tracking-wider">{item.store}</span>
<h4 className="text-xs font-bold text-slate-200 leading-tight mt-1">{item.name}</h4>
</div>
<button onClick={() => handleRemoveItem(item.id)} className="text-slate-600 hover:text-rose-500 transition-colors">
<Trash2 size={16} />
</button>
</div>
<div className="flex items-center justify-between">
<p className="text-sm font-bold text-white">US$ {item.priceUSD.toFixed(2)}</p>
<div className="flex items-center gap-3 bg-black/20 rounded-xl p-1 border border-white/5">
<button onClick={() => handleUpdateQuantity(item, -1)} className="p-1.5 hover:bg-white/10 rounded-lg text-slate-400"><Minus size={12} /></button>
<span className="text-xs font-bold text-slate-300 w-5 text-center">{item.quantity}</span>
<button onClick={() => handleUpdateQuantity(item, 1)} className="p-1.5 hover:bg-white/10 rounded-lg text-slate-400"><Plus size={12} /></button>
</div>
</div>
</div>
))
) : (
<div className="py-24 text-center opacity-10">
<ShoppingCart size={64} strokeWidth={1.5} className="mx-auto text-white" />
<p className="text-[10px] font-bold text-white uppercase mt-4 tracking-widest">Carrinho Vazio</p>
</div>
)}
</div>
<div className="p-8 bg-slate-900 text-white rounded-t-[40px] space-y-5 border-t border-white/5">
<div className="space-y-3">
<div className="flex justify-between items-center opacity-80 hover:opacity-100 transition-opacity">
<button
onClick={() => setUseOverhead(!useOverhead)}
className="flex items-center gap-2 cursor-pointer group"
title={useOverhead ? "Desativar Taxas (20%)" : "Ativar Taxas (20%)"}
>
{useOverhead ? (
<ToggleRight size={20} className="text-indigo-400 group-hover:text-indigo-300 transition-colors" />
) : (
<ToggleLeft size={20} className="text-slate-500 group-hover:text-slate-400 transition-colors" />
)}
<span className={`text-[10px] font-bold uppercase tracking-widest ${useOverhead ? 'text-indigo-300' : 'text-slate-500'} transition-colors`}>
Custo Final Est. ({useOverhead ? 'c/ Taxas' : 's/ Taxas'})
</span>
</button>
<span className={`text-sm font-bold ${useOverhead ? 'text-indigo-100' : 'text-slate-500'} transition-colors`}>
R$ {calculateShoppingTotals().totalCostWithOverhead.toLocaleString('pt-BR')}
</span>
</div>
<div className="flex justify-between items-center opacity-60">
<span className="text-[10px] font-bold uppercase tracking-widest">Subtotal Paraguai</span>
<span className="text-sm font-bold">US$ {calculateShoppingTotals().totalUSD.toLocaleString('en-US')}</span>
</div>
<div className="pt-4 border-t border-white/10 flex justify-between items-center">
<div className="flex items-center gap-2">
<span className="text-[11px] font-bold text-emerald-400 uppercase tracking-widest">Lucro Est. Final</span>
<Facebook size={12} className="text-emerald-400" />
</div>
<span className="text-2xl font-bold text-emerald-400">R$ {calculateShoppingTotals().totalApproxProfit.toLocaleString('pt-BR')}</span>
</div>
</div>
<button
onClick={saveOrderAsQuotation}
disabled={shoppingList.length === 0}
className="w-full bg-indigo-600 text-white font-bold py-5 rounded-[24px] hover:bg-indigo-500 transition-all flex items-center justify-center gap-2 disabled:opacity-20 active:scale-95 shadow-xl shadow-indigo-900/40"
>
<CheckCircle2 size={20} />
SALVAR NO CRM
</button>
</div>
</div>
</div>
</div>
);
};
export default Sourcing;

143
pages/Suppliers.tsx Normal file
View file

@ -0,0 +1,143 @@
import React, { useState } from 'react';
import { Store, Plus, Edit2, Trash2, Truck, Phone, Star } from 'lucide-react';
import { useCRM } from '../context/CRMContext';
import { Supplier } from '../types';
const Suppliers: React.FC = () => {
const { suppliers, addSupplier, updateSupplier, deleteSupplier } = useCRM();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [formData, setFormData] = useState<Partial<Supplier>>({
name: '', contact: '', rating: 5
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (editingId) {
updateSupplier(editingId, formData);
} else {
addSupplier(formData as any);
}
closeModal();
};
const openModal = (supplier?: Supplier) => {
if (supplier) {
setEditingId(supplier.id);
setFormData(supplier);
} else {
setEditingId(null);
setFormData({ name: '', contact: '', rating: 5 });
}
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setEditingId(null);
};
return (
<div className="space-y-8 animate-in fade-in duration-500">
{/* HEADER */}
<div className="flex justify-between items-center glass-card p-6 rounded-2xl border border-white/5 shadow-sm">
<div>
<h2 className="text-xl font-bold text-white tracking-tight">Parceiros Logísticos</h2>
<p className="text-xs text-slate-500 tracking-wide mt-1">Gestão de Transporte e Fornecedores</p>
</div>
<button
onClick={() => openModal()}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white px-5 py-2.5 rounded-lg font-bold text-sm transition-all shadow-lg shadow-indigo-600/20 active:scale-95"
>
<Plus size={16} /> Novo Parceiro
</button>
</div>
{/* LIST */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{suppliers.map(s => (
<div key={s.id} className="glass-card p-6 rounded-2xl border border-white/5 shadow-sm flex flex-col justify-between group hover:border-indigo-500/50 transition-all min-h-[160px] relative overflow-hidden">
{/* Watermark Icon */}
<div className="absolute -bottom-4 -right-4 text-white/[0.02] transform -rotate-12 group-hover:scale-110 transition-transform duration-500">
<Truck size={100} />
</div>
<div className="flex items-start justify-between mb-4 relative z-10">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-indigo-500/10 text-indigo-400 rounded-xl flex items-center justify-center border border-indigo-500/20 group-hover:bg-indigo-600 group-hover:text-white transition-all shadow-lg">
<Truck size={20} />
</div>
<div>
<h4 className="text-base font-bold text-white max-w-[150px] truncate leading-tight" title={s.name}>{s.name}</h4>
<p className="text-[10px] text-slate-500 font-bold uppercase tracking-widest mt-0.5">Parceiro</p>
</div>
</div>
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map(star => (
<div key={star} className={`w-1 h-1 rounded-full ${star <= s.rating ? 'bg-amber-400' : 'bg-slate-700/50'}`}></div>
))}
</div>
</div>
<div className="space-y-2 mb-4 relative z-10">
{s.contact && (
<div className="flex items-center gap-2 text-slate-400 text-xs font-mono bg-black/20 p-2 rounded-lg border border-white/5">
<Phone size={12} className="text-emerald-400" />
{s.contact}
</div>
)}
</div>
<div className="pt-3 border-t border-white/5 flex justify-end gap-2 relative z-10">
<button onClick={() => openModal(s)} className="p-1.5 text-slate-500 hover:text-white hover:bg-white/10 rounded transition-all"><Edit2 size={14} /></button>
<button onClick={() => deleteSupplier(s.id)} className="p-1.5 text-slate-500 hover:text-rose-400 hover:bg-rose-500/10 rounded transition-all"><Trash2 size={14} /></button>
</div>
</div>
))}
</div>
{/* MODAL */}
{isModalOpen && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-[#0F1115] border border-white/10 w-full max-w-md rounded-2xl p-6 shadow-2xl animate-in zoom-in-95 duration-200">
<h3 className="text-lg font-bold text-white mb-6 border-b border-white/5 pb-4">{editingId ? 'Editar Parceiro' : 'Novo Parceiro'}</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Nome / Apelido</label>
<input required type="text" value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:border-indigo-500 outline-none transition-all" placeholder="Ex: João Freteiro" />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Contato (WhatsApp)</label>
<input type="text" value={formData.contact} onChange={e => setFormData({ ...formData, contact: e.target.value })} className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:border-indigo-500 outline-none transition-all" placeholder="+55 45 9..." />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Avaliação / Confiança (1-5)</label>
<div className="flex gap-4 mt-2">
{[1, 2, 3, 4, 5].map(star => (
<button
type="button"
key={star}
onClick={() => setFormData({ ...formData, rating: star })}
className={`p-2 rounded-lg transition-all ${star <= (formData.rating || 0) ? 'text-amber-400 bg-amber-400/10' : 'text-slate-600 bg-slate-800'}`}
>
<Star size={16} fill={star <= (formData.rating || 0) ? "currentColor" : "none"} />
</button>
))}
</div>
</div>
<div className="flex gap-3 mt-8 pt-4 border-t border-white/5">
<button type="button" onClick={closeModal} className="flex-1 py-2 text-xs font-bold text-slate-400 hover:text-white transition-colors uppercase tracking-wide">Cancelar</button>
<button type="submit" className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white py-2 rounded-lg font-bold shadow-lg transition-all text-sm">Salvar</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default Suppliers;

167
pages/Users.tsx Normal file
View file

@ -0,0 +1,167 @@
import React, { useState } from 'react';
import { useCRM } from '../context/CRMContext';
import { Shield, ShieldAlert, User as UserIcon, Check, X, MoreVertical, Edit2 } from 'lucide-react';
import { User } from '../types';
const Users: React.FC = () => {
const { users, isAdmin, updateUserRole } = useCRM();
const [searchTerm, setSearchTerm] = useState('');
const [showRoleModal, setShowRoleModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [newRole, setNewRole] = useState<'admin' | 'user'>('user');
// Filter users
const filteredUsers = users.filter(user =>
(user.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(searchTerm.toLowerCase()))
);
const handleRoleUpdateClick = (user: User) => {
setSelectedUser(user);
setNewRole(user.role === 'admin' ? 'user' : 'admin');
setShowRoleModal(true);
};
const confirmRoleUpdate = async () => {
if (selectedUser) {
await updateUserRole(selectedUser.id, newRole);
setShowRoleModal(false);
setSelectedUser(null);
}
};
if (!isAdmin) {
return (
<div className="flex flex-col items-center justify-center h-[calc(100vh-100px)] text-center animate-in fade-in zoom-in duration-500">
<div className="w-24 h-24 bg-rose-500/10 rounded-full flex items-center justify-center mb-6 border border-rose-500/20 shadow-[0_0_30px_rgba(244,63,94,0.2)]">
<ShieldAlert size={48} className="text-rose-500" />
</div>
<h2 className="text-3xl font-bold text-white mb-3 tracking-tight">Acesso Restrito</h2>
<p className="text-slate-400 max-w-md leading-relaxed">
Você precisa de privilégios de administrador para gerenciar usuários e permissões do sistema.
</p>
</div>
);
}
return (
<div className="animate-in fade-in duration-500 space-y-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 bg-white/5 p-8 rounded-[32px] border border-white/5 backdrop-blur-xl">
<div>
<h2 className="text-2xl font-bold text-white tracking-tight mb-1">Gerenciamento de Usuários</h2>
<p className="text-slate-400">Controle total sobre acesso e permissões da equipe.</p>
</div>
<div className="relative w-full md:w-auto">
<input
type="text"
placeholder="Buscar por nome ou email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full md:w-80 bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-sm text-slate-300 focus:border-indigo-500 outline-none transition-all placeholder:text-slate-600"
/>
</div>
</div>
<div className="glass-card rounded-[32px] border border-white/5 overflow-hidden shadow-2xl">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-black/40 text-[10px] font-bold text-slate-500 uppercase tracking-widest border-b border-white/5">
<tr>
<th className="px-8 py-5">Usuário</th>
<th className="px-8 py-5">Email</th>
<th className="px-8 py-5">Status</th>
<th className="px-8 py-5 text-center">Permissão</th>
<th className="px-8 py-5 text-center">Último Acesso</th>
<th className="px-8 py-5 text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{filteredUsers.length > 0 ? filteredUsers.map(user => (
<tr key={user.id} className="hover:bg-white/[0.02] transition-colors group">
<td className="px-8 py-5">
<div className="flex items-center gap-4">
{user.avatar ? (
<img src={user.avatar} alt={user.name} className="w-10 h-10 rounded-full border border-white/10 object-cover" />
) : (
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center text-slate-400 border border-white/5">
<UserIcon size={18} />
</div>
)}
<div>
<div className="font-bold text-slate-200">{user.name || 'Sem Nome'}</div>
<div className="text-[10px] text-slate-500 font-mono hidden sm:block">ID: {user.id.substring(0, 8)}</div>
</div>
</div>
</td>
<td className="px-8 py-5 text-slate-400 font-mono text-xs">{user.email}</td>
<td className="px-8 py-5">
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider border ${user.status === 'Active' ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' : 'bg-slate-500/10 text-slate-400 border-slate-500/20'
}`}>
{user.status || 'Active'}
</span>
</td>
<td className="px-8 py-5 text-center">
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider border ${user.role === 'admin' ? 'bg-indigo-500/10 text-indigo-400 border-indigo-500/20' : 'bg-slate-500/10 text-slate-400 border-slate-500/20'
}`}>
{user.role === 'admin' && <Shield size={10} />}
{user.role || 'User'}
</span>
</td>
<td className="px-8 py-5 text-center text-xs text-slate-500">
{user.lastAccess ? new Date(user.lastAccess).toLocaleDateString() : '-'}
</td>
<td className="px-8 py-5 text-right">
<button
onClick={() => handleRoleUpdateClick(user)}
className="text-xs font-bold text-slate-500 hover:text-white px-4 py-2 rounded-lg hover:bg-white/10 transition-colors border border-transparent hover:border-white/5"
>
{user.role === 'admin' ? 'Rebaixar' : 'Promover'}
</button>
</td>
</tr>
)) : (
<tr>
<td colSpan={6} className="py-20 text-center opacity-30">
<UserIcon size={48} className="mx-auto mb-4 text-slate-400" />
<p className="text-sm font-bold text-slate-400 uppercase tracking-widest">Nenhum usuário encontrado</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Role Update Modal */}
{showRoleModal && selectedUser && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-[#0F1115] border border-white/10 rounded-[32px] w-full max-w-sm shadow-2xl p-8 relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
<h3 className="text-xl font-bold text-white mb-2">Alterar Permissão</h3>
<p className="text-sm text-slate-400 mb-6 leading-relaxed">
Você tem certeza que deseja alterar o nível de acesso de <strong className="text-white">{selectedUser.name}</strong> para <strong className="text-indigo-400 uppercase">{newRole}</strong>?
</p>
<div className="flex gap-3">
<button
onClick={() => setShowRoleModal(false)}
className="flex-1 py-3 text-sm font-bold text-slate-400 hover:text-white transition-colors hover:bg-white/5 rounded-xl"
>
Cancelar
</button>
<button
onClick={confirmRoleUpdate}
className="flex-[2] bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl font-bold text-sm shadow-lg shadow-indigo-900/20 transition-all flex items-center justify-center gap-2 active:scale-95"
>
Confirmar Alteração
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Users;

BIN
paraguai.rar Normal file

Binary file not shown.

26
policies.sql Normal file
View file

@ -0,0 +1,26 @@
-- 1. Ensure RLS is enabled (consistency)
alter table public.inventory enable row level security;
alter table public.suppliers enable row level security;
alter table public.customers enable row level security;
alter table public.orders enable row level security;
alter table public.transactions enable row level security;
alter table public.settings enable row level security;
-- 2. Drop existing policies to avoid conflicts if re-run
drop policy if exists "Enable all for authenticated users" on public.inventory;
drop policy if exists "Enable all for authenticated users" on public.suppliers;
drop policy if exists "Enable all for authenticated users" on public.customers;
drop policy if exists "Enable all for authenticated users" on public.orders;
drop policy if exists "Enable all for authenticated users" on public.transactions;
drop policy if exists "Enable all for authenticated users" on public.settings;
-- 3. Create Permissive Policies for Authenticated Users
-- This allows any logged-in user to Select, Insert, Update, Delete ONLY if they are authenticated.
-- In a SaaS, you would restrict 'using (user_id = auth.uid())', but for this internal tool, we allow all authenticated access.
create policy "Enable all for authenticated users" on public.inventory for all to authenticated using (true) with check (true);
create policy "Enable all for authenticated users" on public.suppliers for all to authenticated using (true) with check (true);
create policy "Enable all for authenticated users" on public.customers for all to authenticated using (true) with check (true);
create policy "Enable all for authenticated users" on public.orders for all to authenticated using (true) with check (true);
create policy "Enable all for authenticated users" on public.transactions for all to authenticated using (true) with check (true);
create policy "Enable all for authenticated users" on public.settings for all to authenticated using (true) with check (true);

314
services/geminiService.ts Normal file
View file

@ -0,0 +1,314 @@
import { GoogleGenAI, Type } from "@google/genai";
import { Product, AuctionLot, BiddingTender } from "../types";
// Vite: variáveis precisam começar com VITE_
const API_KEY = import.meta.env.VITE_GOOGLE_API_KEY as string | undefined;
if (!API_KEY) {
// Erro explícito pra não “falhar silencioso”
throw new Error(
"Missing VITE_GOOGLE_API_KEY. Add it to your .env.local (VITE_GOOGLE_API_KEY=...) and restart the dev server."
);
}
const ai = new GoogleGenAI({ apiKey: API_KEY });
/**
* Helper: converte File em Part (inlineData) para o Gemini
*/
async function fileToPart(file: File) {
return new Promise<{ inlineData: { data: string; mimeType: string } }>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error("Failed to read file"));
reader.onloadend = () => {
const result = reader.result as string | null;
if (!result) return reject(new Error("Empty file result"));
const base64Data = result.split(",")[1] || "";
resolve({
inlineData: {
data: base64Data,
mimeType: file.type || "application/octet-stream",
},
});
};
reader.readAsDataURL(file);
});
}
/**
* Sourcing Otimizado: Foco no MENOR PREÇO de compra (PY)
* e MENOR PREÇO de venda competitiva (BR - "Mais Vendidos")
*/
export async function searchProducts(
query: string
): Promise<{ products: Product[]; sources: any[] }> {
// try { // Removed to propagate errors
const prompt = `OBJETIVO: Análise de Arbitragem Profissional.
1. BUSCA REAL OBRIGATÓRIA:
Você DEVE usar a tool googleSearch para buscar dados REAIS e ATUAIS no site www.comprasparaguai.com.br.
Busque os 20 itens com o MENOR PREÇO ABSOLUTO para "${query}".
Foque em lojas de grande renome (Nissei, Cellshop, Atacado Games, Mega Eletronicos).
2. BUSCA BRASIL (REFERÊNCIA):
Para cada item, encontre o MENOR PREÇO de venda no Mercado Livre e Amazon Brasil.
3. REGRAS CRÍTICAS (ANTI-ALUCINAÇÃO):
- JAMAIS invente produtos ou preços.
- JAMAIS retorne itens com "(Exemplo)", "(Example)" ou dados fictícios.
- Se não encontrar dados REAIS, retorne lista vazia.
- Use a cotação de dólar turismo/paralelo atual (~5.75 BRL/USD).
4. RETORNO:
Apenas JSON conforme o schema.`;
const response = await ai.models.generateContent({
model: "gemini-3-pro-preview", // Stable fast model
contents: prompt,
config: {
tools: [{ googleSearch: {} }],
responseMimeType: "application/json",
responseSchema: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
name: { type: Type.STRING, description: "Nome técnico do produto" },
priceUSD: { type: Type.NUMBER, description: "Menor preço em USD no Paraguai" },
priceBRL: { type: Type.NUMBER, description: "Preço USD convertido para BRL" },
store: { type: Type.STRING, description: "Loja do Paraguai (Nissei, Cellshop, etc)" },
url: { type: Type.STRING, description: "Link da oferta no Paraguai" },
marketPriceBRL: {
type: Type.NUMBER,
description: "Menor preço encontrado entre os MAIS VENDIDOS no Brasil",
},
amazonPrice: { type: Type.NUMBER, description: "Menor preço Amazon (Best Seller)" },
amazonUrl: { type: Type.STRING },
mlPrice: { type: Type.NUMBER, description: "Menor preço Mercado Livre (Mais Vendido)" },
mlUrl: { type: Type.STRING },
},
required: ["name", "priceUSD", "priceBRL", "store", "url", "marketPriceBRL"],
},
},
},
});
const text = response.text || "[]";
const products = JSON.parse(text);
const sources = response.candidates?.[0]?.groundingMetadata?.groundingChunks || [];
// Ordena para mostrar primeiro as melhores oportunidades (menor custo em BRL)
const sortedProducts = Array.isArray(products)
? products.sort((a: any, b: any) => (a.priceBRL ?? 0) - (b.priceBRL ?? 0))
: [];
return { products: sortedProducts as Product[], sources };
// } catch (error) {
// console.error("Erro na busca de arbitrage:", error);
// return { products: [], sources: [] };
// }
}
/**
* Busca Oportunidades (>25% Margem) por Categoria
*/
export async function searchOpportunities(category: string, includeOverhead: boolean = true): Promise<{ products: Product[]; sources: any[] }> {
// try {
const prompt = `OBJETIVO: Encontrar Oportunidades de Arbitragem (>25% Lucro) na categoria "${category}".
1. BUSCA PARAGUAI:
Vasculhe as principais lojas do Paraguai (Nissei, Cellshop, Mega, Atacado Games) em busca de produtos da categoria "${category}" que estejam com preços muito atrativos ou em oferta.
Identifique pelo menos 20 produtos promissores.
2. CÁLCULO DE MARGEM:
Para cada produto, estime o custo total em BRL (Preço PY * 5.75 * ${includeOverhead ? '1.20' : '1.00'} de taxas).
Compare com o MENOR preço de venda real no Brasil (Mercado Livre/Amazon - Filtro "Mais Vendidos").
Margem = (Preço Venda BR - Custo Total) / Preço Venda BR.
3. FILTRO RIGOROSO:
Retorne APENAS produtos onde a Margem estimada seja SUPERIOR a 20-25%.
Se não encontrar nada com 25%, mostre os que tiverem a maior margem possível.
4. RETORNO:
Apenas JSON conforme o schema.`;
const response = await ai.models.generateContent({
model: "gemini-3-pro-preview",
contents: prompt,
config: {
tools: [{ googleSearch: {} }],
responseMimeType: "application/json",
responseSchema: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
name: { type: Type.STRING, description: "Nome técnico do produto" },
priceUSD: { type: Type.NUMBER, description: "Menor preço em USD no Paraguai" },
priceBRL: { type: Type.NUMBER, description: "Preço USD convertido para BRL" },
store: { type: Type.STRING, description: "Loja do Paraguai (Nissei, Cellshop, etc)" },
url: { type: Type.STRING, description: "Link da oferta no Paraguai" },
marketPriceBRL: {
type: Type.NUMBER,
description: "Menor preço encontrado entre os MAIS VENDIDOS no Brasil",
},
amazonPrice: { type: Type.NUMBER, description: "Menor preço Amazon (Best Seller)" },
amazonUrl: { type: Type.STRING },
mlPrice: { type: Type.NUMBER, description: "Menor preço Mercado Livre (Mais Vendido)" },
mlUrl: { type: Type.STRING },
},
required: ["name", "priceUSD", "priceBRL", "store", "url", "marketPriceBRL"],
},
},
},
});
const text = response.text || "[]";
const products = JSON.parse(text);
const sources = response.candidates?.[0]?.groundingMetadata?.groundingChunks || [];
// Ordena por maior margem (diferença entre venda BR e custo PY)
const sortedProducts = Array.isArray(products)
? products.sort((a: any, b: any) => {
const marginA = (a.marketPriceBRL - a.priceBRL) / a.marketPriceBRL;
const marginB = (b.marketPriceBRL - b.priceBRL) / b.marketPriceBRL;
return marginB - marginA; // Maior margem primeiro
})
: [];
return { products: sortedProducts as Product[], sources };
// } catch (error) {
// console.error("Erro na busca de oportunidades:", error);
// return { products: [], sources: [] };
// }
}
/**
* Leilões: Analisa texto, link ou ARQUIVO PDF
*/
export async function analyzeAuctionData(
input: string | File
): Promise<{ lot: AuctionLot | null; sources: any[] }> {
try {
const promptPrefix = "Analise este lote de leilão da Receita Federal.";
const parts: any[] = [];
if (input instanceof File) {
const filePart = await fileToPart(input);
parts.push(filePart);
parts.push({
text: `${promptPrefix}
Leia o documento PDF anexo e extraia TODOS os itens da tabela de mercadorias.
REGRAS:
1. Identifique os itens reais.
2. Descubra o MENOR valor real de mercado no Brasil (baseado nos itens mais vendidos) via Google Search.
3. Use URLs REAIS dos sites de busca.`,
});
} else {
parts.push({
text: `${promptPrefix}
Analise este conteúdo: "${input}".
REGRAS:
1. Identifique os itens reais.
2. Descubra o MENOR valor real de mercado no Brasil (baseado nos itens mais vendidos) via Google Search.
3. Use URLs REAIS dos sites de busca.`,
});
}
const analysisResponse = await ai.models.generateContent({
model: "gemini-3-pro-preview",
contents: { parts }, // padronizado
config: { tools: [{ googleSearch: {} }] },
});
const analysisText = analysisResponse.text || "";
const sources = analysisResponse.candidates?.[0]?.groundingMetadata?.groundingChunks || [];
// Segunda passada: força JSON limpo
const extraction = await ai.models.generateContent({
model: "gemini-3-pro-preview",
contents: `Transforme em JSON conforme o schema. Texto base: ${analysisText}`,
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: {
id: { type: Type.STRING },
title: { type: Type.STRING },
location: { type: Type.STRING },
items: { type: Type.ARRAY, items: { type: Type.STRING } },
currentBid: { type: Type.NUMBER },
minBid: { type: Type.NUMBER },
mlMarketPrice: { type: Type.NUMBER },
},
required: ["id", "title", "items", "minBid", "mlMarketPrice"],
},
},
});
const lotData = JSON.parse(extraction.text || "{}");
return {
lot: {
...lotData,
status: "Analyzing" as any,
url: typeof input === "string" && input.startsWith("http") ? input : "",
},
sources,
};
} catch (error) {
console.error("Erro ao analisar leilão:", error);
return { lot: null, sources: [] };
}
}
/**
* Licitações Otimizadas
*/
export async function searchBiddings(
query: string
): Promise<{ tenders: BiddingTender[]; sources: any[] }> {
try {
const prompt = `Pesquise licitações ativas para "${query}" no Brasil.
Priorize PNCP e Compras.gov.br.
Retorne apenas JSON conforme o schema.`;
const searchResponse = await ai.models.generateContent({
model: "gemini-3-pro-preview",
contents: prompt,
config: {
tools: [{ googleSearch: {} }],
responseMimeType: "application/json",
responseSchema: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
id: { type: Type.STRING },
org: { type: Type.STRING },
object: { type: Type.STRING },
estimatedValue: { type: Type.NUMBER },
deadline: { type: Type.STRING },
items: { type: Type.ARRAY, items: { type: Type.STRING } },
requirements: { type: Type.ARRAY, items: { type: Type.STRING } },
marketReferencePrice: { type: Type.NUMBER },
url: { type: Type.STRING },
},
required: ["id", "org", "object", "url"],
},
},
},
});
const tenders = JSON.parse(searchResponse.text || "[]");
const sources = searchResponse.candidates?.[0]?.groundingMetadata?.groundingChunks || [];
return { tenders: (Array.isArray(tenders) ? tenders : []) as BiddingTender[], sources };
} catch (error) {
console.error("Erro na busca de licitações:", error);
return { tenders: [], sources: [] };
}
}

11
services/supabase.ts Normal file
View file

@ -0,0 +1,11 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
console.error('Missing Supabase environment variables');
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey);

126
supabase_schema.sql Normal file
View file

@ -0,0 +1,126 @@
-- Enable UUID extension
create extension if not exists "uuid-ossp";
-- 1. USERS & PROFILES (Managed by Supabase Auth, but we can have a public profile)
create table if not exists public.profiles (
id uuid references auth.users not null primary key,
email text,
full_name text,
role text default 'Buyer',
created_at timestamp with time zone default timezone('utc'::text, now()) not null
);
-- 2. INVENTORY (Products)
create table if not exists public.inventory (
id uuid default uuid_generate_v4() primary key,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
name text not null,
sku text,
ean text,
quantity integer default 0,
avg_cost_brl numeric default 0,
market_price_brl numeric default 0,
last_supplier text,
user_id uuid references auth.users
);
-- 3. SUPPLIERS
create table if not exists public.suppliers (
id uuid default uuid_generate_v4() primary key,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
name text not null,
contact text,
rating integer default 0,
last_purchase timestamp with time zone,
user_id uuid references auth.users
);
-- 4. CUSTOMERS
create table if not exists public.customers (
id uuid default uuid_generate_v4() primary key,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
name text not null,
email text,
phone text,
city text,
status text default 'Active', -- 'Active', 'Inactive', 'Prospect'
total_purchased numeric default 0,
user_id uuid references auth.users
);
-- 5. ORDERS
create table if not exists public.orders (
id uuid default uuid_generate_v4() primary key,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
date timestamp with time zone,
status text default 'Pending', -- 'Pending', 'Paid', 'Received', 'Cancelled'
total_usd numeric default 0,
total_brl numeric default 0,
total_cost_with_overhead numeric default 0,
estimated_profit numeric default 0,
items jsonb, -- Stores the JSON array of items
supplier_name text,
user_id uuid references auth.users
);
-- 6. TRANSACTIONS (Financial)
create table if not exists public.transactions (
id uuid default uuid_generate_v4() primary key,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
date timestamp with time zone,
type text, -- 'Income', 'Expense'
category text,
amount numeric default 0,
description text,
user_id uuid references auth.users
);
-- 7. SETTINGS (Global Config)
create table if not exists public.settings (
id uuid default uuid_generate_v4() primary key,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
updated_at timestamp with time zone default timezone('utc'::text, now()),
-- Company Info
company_name text,
cnpj text,
ie text,
-- Financial Defaults
default_overhead numeric default 20,
default_exchange numeric default 5.65,
-- Integration Tokens
brazil_api_token text,
melhor_envio_token text,
bling_token text,
tiny_token text,
gemini_key text,
-- Fiscal / NFe
certificate_password text, -- CAUTION: Storing plain text/base64? Ideally encrypted.
nfe_serie text default '1',
nfe_number text,
nfe_environment text default 'homologacao',
-- Email SMTP
smtp_host text,
smtp_port text,
smtp_user text,
smtp_pass text,
-- Automation
auto_sync_sales boolean default true,
auto_sync_stock boolean default true,
user_id uuid references auth.users
);
-- RLS POLICIES (Optional but recommended)
alter table public.inventory enable row level security;
alter table public.suppliers enable row level security;
alter table public.customers enable row level security;
alter table public.orders enable row level security;
alter table public.transactions enable row level security;
alter table public.settings enable row level security;
-- (Policies would need to be added to allow read/write for authenticated users)

29
tsconfig.json Normal file
View file

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

167
types.ts Normal file
View file

@ -0,0 +1,167 @@
export interface Product {
name: string;
priceUSD: number;
priceBRL: number;
store: string;
url: string;
marketPriceBRL?: number;
salesVolume?: string; // New: e.g. "10k+ vendidos"
rating?: number; // New: 0-5
amazonPrice?: number;
amazonUrl?: string;
mlPrice?: number;
mlUrl?: string;
shopeePrice?: number;
shopeeUrl?: string;
}
export interface ShoppingListItem {
id: string;
name: string;
store: string;
priceUSD: number;
priceBRL: number;
quantity: number;
marketPriceBRL: number;
}
export type OrderStatus = 'Quotation' | 'Pending' | 'Paid' | 'Received' | 'Cancelled';
export type SalesChannel = 'Local' | 'Mercado Livre' | 'Shopee' | 'Amazon' | 'Facebook';
export interface Sale {
id: string;
date: string;
customerId?: string; // Optional linkage
customerName?: string; // Flattened for easier display
items: { id: string; name: string; quantity: number; salePrice: number; costPrice?: number }[];
total: number;
status: 'Pending' | 'Paid' | 'Shipped' | 'Completed' | 'Cancelled' | 'Returned';
channel: SalesChannel;
externalId?: string; // Mercado Livre ID, etc.
isStockLaunched: boolean;
isFinancialLaunched: boolean;
}
export interface Order {
id: string;
date: string;
items: ShoppingListItem[];
totalUSD: number;
totalBRL: number;
totalCostWithOverhead: number;
estimatedProfit: number;
status: OrderStatus;
supplierName: string;
}
export interface InventoryItem {
id: string;
name: string;
sku: string;
ean?: string;
quantity: number;
avgCostBRL: number;
marketPriceBRL: number;
lastSupplier: string;
}
export interface Supplier {
id: string;
name: string;
contact?: string;
rating: number;
lastPurchase?: string;
}
export interface User {
id: string;
name?: string;
email: string;
phone?: string;
role?: 'admin' | 'user'; // New
status: 'Active' | 'Inactive';
lastAccess?: string;
avatar: string;
}
export interface PlatformFee {
name: string;
commission: number;
fixedFee: number;
}
export interface CalculationResult {
platform: string;
marketPrice: number;
totalCost: number;
fees: number;
netProfit: number;
margin: number;
url?: string;
}
export const PLATFORMS: PlatformFee[] = [
{ name: 'Amazon', commission: 0.15, fixedFee: 0 },
{ name: 'Mercado Livre', commission: 0.18, fixedFee: 6 },
{ name: 'Shopee', commission: 0.20, fixedFee: 3 },
{ name: 'Facebook', commission: 0.0, fixedFee: 0 },
];
export interface Customer {
id: string;
name: string;
email?: string;
phone?: string;
city?: string;
status: 'Active' | 'Inactive' | 'Prospect';
totalPurchased: number;
}
export type TransactionType = 'Income' | 'Expense';
export interface Transaction {
id: string;
date: string;
type: TransactionType;
category: string; // Hotel, Combustível, Alimentação, Venda, etc.
amount: number;
description: string;
status: 'Pending' | 'Paid' | 'Overdue' | 'Cancelled';
paymentMethod: PaymentMethod;
dueDate?: string;
relatedSaleId?: string;
}
export type PaymentMethod = 'Pix' | 'Credit Card' | 'Debit Card' | 'Cash' | 'Boleto' | 'Transfer' | 'Other';
export interface FinancialSummary {
totalIncome: number;
totalExpense: number;
balance: number;
recentTransactions: Transaction[];
}
export interface AuctionLot {
id: string;
title: string;
location: string;
items: string[];
currentBid: number;
minBid: number;
mlMarketPrice: number;
status: 'Analyzing' | 'Open' | 'Closed';
url: string;
}
export interface BiddingTender {
id: string;
org: string;
object: string;
estimatedValue: number;
deadline: string;
items: string[];
requirements: string[];
marketReferencePrice: number;
url: string;
}

23
vite.config.ts Normal file
View file

@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.API_KEY || env.VITE_GOOGLE_API_KEY || env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});