851 lines
32 KiB
TypeScript
851 lines
32 KiB
TypeScript
|
|
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)
|
|
.maybeSingle();
|
|
|
|
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,
|
|
sourcingWebhook: settingsData.sourcing_webhook,
|
|
electricityCostKwh: settingsData.electricity_cost_kwh,
|
|
});
|
|
}
|
|
} 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, exchangeRate);
|
|
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, exchangeRate);
|
|
|
|
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;
|
|
};
|