Initial commit
This commit is contained in:
commit
de9bf86d94
41 changed files with 9312 additions and 0 deletions
2
.env
Normal file
2
.env
Normal 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
24
.gitignore
vendored
Normal 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
147
App.tsx
Normal 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
20
README.md
Normal 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`
|
||||
51
components/ErrorBoundary.tsx
Normal file
51
components/ErrorBoundary.tsx
Normal 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
34
components/Logo.tsx
Normal 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;
|
||||
156
components/MarketplaceAnalytic.tsx
Normal file
156
components/MarketplaceAnalytic.tsx
Normal 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
849
context/CRMContext.tsx
Normal 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
49
context/ThemeContext.tsx
Normal 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
164
index.css
Normal 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
83
index.html
Normal 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
16
index.tsx
Normal 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
105
layouts/Header.tsx
Normal 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
109
layouts/Sidebar.tsx
Normal 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
175
layouts/TopBar.tsx
Normal 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
5
metadata.json
Normal 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
45
migration.sql
Normal 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
125
n8n-scraping-workflow.json
Normal 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
3255
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
package.json
Normal file
29
package.json
Normal 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
167
pages/Customers.tsx
Normal 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
164
pages/Dashboard.tsx
Normal 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
287
pages/Financial.tsx
Normal 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
58
pages/Inventory.tsx
Normal 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
106
pages/Login.tsx
Normal 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 • Segurança Criptografada
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
270
pages/Orders.tsx
Normal file
270
pages/Orders.tsx
Normal 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
261
pages/Products.tsx
Normal 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
306
pages/Reports.tsx
Normal 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 há 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
558
pages/Sales.tsx
Normal 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
452
pages/Settings.tsx
Normal 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
234
pages/Sourcing.tsx
Normal 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
143
pages/Suppliers.tsx
Normal 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
167
pages/Users.tsx
Normal 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
BIN
paraguai.rar
Normal file
Binary file not shown.
26
policies.sql
Normal file
26
policies.sql
Normal 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
314
services/geminiService.ts
Normal 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
11
services/supabase.ts
Normal 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
126
supabase_schema.sql
Normal 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
29
tsconfig.json
Normal 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
167
types.ts
Normal 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
23
vite.config.ts
Normal 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, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Loading…
Reference in a new issue