// === Supabase client (single-tenant · Casa do José) === // O anon key é público por design — a segurança real é feita pelas // Row-Level Security policies definidas em supabase/schema.sql. const SUPABASE_URL = 'https://hknvcwhewutnnmdxrivq.supabase.co'; const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImhrbnZjd2hld3V0bm5tZHhyaXZxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzkwNDQ3NTMsImV4cCI6MjA5NDYyMDc1M30.vlI6C0bx0oMKLy7-FzSbpWzJorHgyUuFNOv0Ty7H4Bo'; const sb = window.supabase.createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: true, }, }); // Cliente auxiliar usado APENAS para criar novos usuários (signUp) // sem afetar a sessão do admin que está logado. const sbAdminInvite = window.supabase.createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false, storageKey: 'sb-invite-only', }, }); // Convenience helpers used across screens. const scpAuth = { async signIn(email, password) { return sb.auth.signInWithPassword({ email, password }); }, async signOut() { return sb.auth.signOut(); }, async resetPassword(email) { return sb.auth.resetPasswordForEmail(email, { redirectTo: window.location.origin, }); }, async getSession() { const { data } = await sb.auth.getSession(); return data.session; }, async getProfile() { const session = await this.getSession(); if (!session?.user) return null; const { data, error } = await sb .from('profiles') .select('*') .eq('id', session.user.id) .single(); if (error) { console.warn('Falha ao carregar profile:', error.message); return null; } return data; }, onChange(cb) { return sb.auth.onAuthStateChange((_event, session) => cb(session)); }, }; // === Data services === // Cada tela importa via window.scpDb.. const scpDb = { products: { async list() { const { data, error } = await sb .from('products') .select('*, preferred_supplier:suppliers(id, name, city, score, avg_delivery, whatsapp)') .eq('active', true) .order('name'); if (error) throw error; return data || []; }, async create(payload) { const { data, error } = await sb .from('products') .insert(payload) .select('*, preferred_supplier:suppliers(id, name, city, score, avg_delivery, whatsapp)') .single(); if (error) throw error; return data; }, async update(id, payload) { const { data, error } = await sb .from('products') .update(payload) .eq('id', id) .select('*, preferred_supplier:suppliers(id, name, city, score, avg_delivery, whatsapp)') .single(); if (error) throw error; return data; }, async remove(id) { // soft delete — mantém histórico const { error } = await sb.from('products').update({ active: false }).eq('id', id); if (error) throw error; }, async uploadPhoto(file, productCode) { const ext = file.name.split('.').pop()?.toLowerCase() || 'jpg'; const slug = (productCode || crypto.randomUUID()).replace(/[^a-z0-9-]/gi, ''); const path = `${slug}-${Date.now()}.${ext}`; const { error: upErr } = await sb.storage .from('product-photos') .upload(path, file, { upsert: true, contentType: file.type }); if (upErr) throw upErr; const { data: pub } = sb.storage.from('product-photos').getPublicUrl(path); return pub.publicUrl; }, }, stats: { // Contadores usados pelos badges da sidebar + card "Assistente IA" async sidebar() { const [q, o, p] = await Promise.all([ sb.from('quotations').select('id', { count: 'exact', head: true }).in('status', ['aberta','analise']), sb.from('orders').select('id', { count: 'exact', head: true }).in('status', ['confirmado','em_transito']), sb.from('products').select('id, stock, min_stock').eq('active', true), ]); const lowStock = (p.data || []).filter(r => Number(r.stock) < Number(r.min_stock)).length; // Economia acumulada este ano const yearStart = new Date(new Date().getFullYear(), 0, 1).toISOString(); const { data: saved } = await sb .from('quotations') .select('saving_value') .eq('status', 'fechada') .gte('closed_at', yearStart); const totalSavings = (saved || []).reduce((s, r) => s + Number(r.saving_value || 0), 0); return { cotacoes: q.count || 0, pedidos: o.count || 0, estoque: lowStock, savingsThisYear: totalSavings, }; }, }, ai: { // Pega a análise mais recente da cotação (cache). async getLatestAnalysis(quotationId) { const { data, error } = await sb .from('quotation_analyses') .select('*') .eq('quotation_id', quotationId) .order('created_at', { ascending: false }) .limit(1) .maybeSingle(); if (error) return null; return data; }, // Chama a Edge Function para gerar nova análise via OpenAI. async analyzeQuotation(quotationId) { const { data, error } = await sb.functions.invoke('analyze-quotation', { body: { quotation_id: quotationId }, }); if (error) throw error; if (data?.error) throw new Error(data.detail || data.error); return data; }, }, profiles: { async list() { // Junta com auth.users via view? Como não temos acesso direto a auth schema do cliente, // pegamos só de profiles. Para obter o email, usamos uma view ou fazemos via RPC. // Por simplicidade, lemos só de profiles e pegamos o email do auth.users via select com join. const { data, error } = await sb .from('profiles') .select('*') .order('created_at', { ascending: true }); if (error) throw error; // Para emails, fazemos uma chamada complementar (admin pode ver via RPC) const ids = (data || []).map(p => p.id); let emailMap = {}; if (ids.length) { // Usa rpc para buscar emails — vamos criar essa função no schema const { data: emails } = await sb.rpc('get_user_emails', { user_ids: ids }); if (emails) { emails.forEach(e => { emailMap[e.id] = e.email; }); } } return (data || []).map(p => ({ ...p, email: emailMap[p.id] || '—' })); }, async update(id, patch) { const { data, error } = await sb .from('profiles') .update(patch) .eq('id', id) .select('*') .single(); if (error) throw error; return data; }, async deactivate(id) { // soft delete: marca inativo. Para excluir completamente precisa admin API. const { error } = await sb.from('profiles').update({ active: false }).eq('id', id); if (error) throw error; }, async reactivate(id) { const { error } = await sb.from('profiles').update({ active: true }).eq('id', id); if (error) throw error; }, // Convida um novo usuário criando uma conta auth + atualizando o profile. // Usa um cliente auxiliar pra não bagunçar a sessão do admin. async invite({ name, email, password, role }) { const { data, error } = await sbAdminInvite.auth.signUp({ email, password, options: { data: { name }, emailRedirectTo: window.location.origin, }, }); if (error) throw error; if (!data.user) throw new Error('Falha ao criar usuário'); // O trigger handle_new_user cria o profile automaticamente. // Esperamos um pouco e atualizamos o profile com nome e role corretos. await new Promise(r => setTimeout(r, 600)); const { error: updErr } = await sb .from('profiles') .update({ name, role, active: true }) .eq('id', data.user.id); if (updErr) console.warn('Profile atualizado falhou:', updErr.message); return { user: data.user, needsConfirmation: !data.session, // se session é null, email precisa ser confirmado }; }, }, notifications: { async list(limit = 20) { const { data, error } = await sb .from('notifications') .select('*') .order('created_at', { ascending: false }) .limit(limit); if (error) throw error; return data || []; }, async unreadCount() { const { count, error } = await sb .from('notifications') .select('id', { count: 'exact', head: true }) .eq('unread', true); if (error) return 0; return count || 0; }, async markRead(id) { const { error } = await sb.from('notifications').update({ unread: false }).eq('id', id); if (error) throw error; }, async markAllRead() { const session = await scpAuth.getSession(); if (!session?.user) return; const { error } = await sb .from('notifications') .update({ unread: false }) .eq('user_id', session.user.id) .eq('unread', true); if (error) throw error; }, async create({ user_id, icon, title, sub, tone }) { const { data, error } = await sb .from('notifications') .insert({ user_id, icon, title, sub, tone, unread: true }) .select('*') .single(); if (error) throw error; return data; }, // Subscribe to realtime changes for the current user. cb is called with the new/updated row. subscribe(userId, cb) { const channel = sb .channel('notif-' + userId) .on('postgres_changes', { event: '*', schema: 'public', table: 'notifications', filter: `user_id=eq.${userId}` }, (payload) => cb(payload)) .subscribe(); return () => { sb.removeChannel(channel); }; }, }, dashboard: { // Carrega todos os dados necessários do dashboard em paralelo async load() { const [q, o, p, r, cats] = await Promise.all([ sb.from('quotations').select(` id, code, title, status, total_value, saving_value, deadline, closed_at, created_at, items:quotation_items(id), invited:quotation_suppliers(supplier_id, status) `).order('created_at', { ascending: false }), sb.from('orders').select(` id, code, total, status, emitted_at, eta, delivered_at, supplier:suppliers(id, name), items:order_items(id, product_id, qty, unit_price, product:products(cat)) `).order('emitted_at', { ascending: false }), sb.from('products').select('id, name, stock, min_stock, last_price, cat').eq('active', true), sb.from('receivings').select('id, code, status, divergence_count, received_at, order:orders(supplier:suppliers(name))').order('received_at', { ascending: false }).limit(20), sb.from('categories').select('*').order('sort_order'), ]); if (q.error) throw q.error; if (o.error) throw o.error; if (p.error) throw p.error; if (r.error) throw r.error; return { quotations: q.data || [], orders: o.data || [], products: p.data || [], receivings: r.data || [], categories: cats.data || [], }; }, }, receivings: { async list() { const { data, error } = await sb .from('receivings') .select(` id, code, status, divergence_count, received_at, approved_at, notes, order:orders(id, code, total, supplier:suppliers(id, name)) `) .order('received_at', { ascending: false }); if (error) throw error; return data || []; }, async getById(id) { const { data, error } = await sb .from('receivings') .select(` *, order:orders( id, code, total, supplier:suppliers(id, name, cnpj, whatsapp), items:order_items(id, product_id, name_snapshot, unit_snapshot, qty, unit_price) ), items:receiving_items(*) `) .eq('id', id) .single(); if (error) throw error; return data; }, async create({ order_id, notes }) { const code = await this._nextCode(); const { data: rec, error } = await sb .from('receivings') .insert({ code, order_id, status: 'pendente', notes: notes || null }) .select('*') .single(); if (error) throw error; // Cria 1 item de conferência por order_item (qty_received começa em 0) const { data: orderItems } = await sb .from('order_items') .select('id, product_id, qty') .eq('order_id', order_id); if (orderItems?.length) { const rows = orderItems.map(it => ({ receiving_id: rec.id, product_id: it.product_id, qty_ordered: it.qty, qty_received: 0, ok: false, })); await sb.from('receiving_items').insert(rows); } return rec; }, // Aprova recebimento + cria movimentações de entrada + atualiza estoque async approve(receivingId, opts = {}) { const session = await scpAuth.getSession(); const { data: full, error: gErr } = await sb .from('receivings') .select('id, status, items:receiving_items(*), order:orders(code)') .eq('id', receivingId) .single(); if (gErr) throw gErr; if (full.status === 'aprovado') return full; // 1) movimentações de entrada (1 por item recebido com qty > 0) const movs = (full.items || []) .filter(it => Number(it.qty_received) > 0 || (opts.fallbackToOrdered && Number(it.qty_ordered) > 0)) .map(it => ({ product_id: it.product_id, kind: 'entrada', qty: Number(it.qty_received) > 0 ? Number(it.qty_received) : Number(it.qty_ordered), reason: `Recebimento ${full.order?.code || ''}`.trim(), ref_table: 'receivings', ref_id: receivingId, created_by: session?.user?.id || null, })); if (movs.length) { const { error: mErr } = await sb.from('stock_movements').insert(movs); if (mErr) throw mErr; } // 2) Incrementa estoque dos produtos (via RPC ou updates individuais) for (const m of movs) { const { data: prod } = await sb.from('products').select('stock').eq('id', m.product_id).single(); if (prod) { await sb.from('products').update({ stock: Number(prod.stock) + m.qty }).eq('id', m.product_id); } } // 3) Marca recebimento como aprovado const { data: upd, error: uErr } = await sb .from('receivings') .update({ status: 'aprovado', approved_at: new Date().toISOString(), approved_by: session?.user?.id || null }) .eq('id', receivingId) .select('*') .single(); if (uErr) throw uErr; return upd; }, async reject(receivingId, reason) { const session = await scpAuth.getSession(); const { data, error } = await sb .from('receivings') .update({ status: 'recusado', approved_at: new Date().toISOString(), approved_by: session?.user?.id || null, notes: reason || null }) .eq('id', receivingId) .select('*') .single(); if (error) throw error; return data; }, async updateItem(itemId, patch) { const { error } = await sb.from('receiving_items').update(patch).eq('id', itemId); if (error) throw error; }, async _nextCode() { const { data } = await sb .from('receivings') .select('code') .like('code', 'REC-%') .order('code', { ascending: false }) .limit(1); const last = data?.[0]?.code; const lastNum = last ? parseInt(last.split('-').pop()) : 0; return `REC-${String(lastNum + 1).padStart(4, '0')}`; }, }, stockMovements: { async listByProduct(productId, limit = 20) { const { data, error } = await sb .from('stock_movements') .select('*') .eq('product_id', productId) .order('created_at', { ascending: false }) .limit(limit); if (error) throw error; return data || []; }, }, orders: { async list() { const { data, error } = await sb .from('orders') .select(` id, code, total, status, emitted_at, confirmed_at, eta, delivered_at, created_at, supplier:suppliers(id, name, cnpj, city, whatsapp, email, score), quotation:quotations(id, code, title), items:order_items(id, name_snapshot, qty, unit_price) `) .order('emitted_at', { ascending: false }); if (error) throw error; return (data || []).map(o => ({ ...o, itemsCount: o.items?.length || 0, })); }, async getById(id) { const { data, error } = await sb .from('orders') .select(` *, supplier:suppliers(*), quotation:quotations(*), items:order_items(*, product:products(id, code, name, brand, unit, photo_url)) `) .eq('id', id) .single(); if (error) throw error; return data; }, async updateStatus(id, status) { const patch = { status }; if (status === 'confirmado') patch.confirmed_at = new Date().toISOString(); if (status === 'entregue') patch.delivered_at = new Date().toISOString(); const { data, error } = await sb.from('orders').update(patch).eq('id', id).select('*').single(); if (error) throw error; return data; }, async _nextCode() { const year = new Date().getFullYear(); const { data } = await sb .from('orders') .select('code') .like('code', `PC-${year}-%`) .order('code', { ascending: false }) .limit(1); const last = data?.[0]?.code; const lastNum = last ? parseInt(last.split('-').pop()) : 0; return `PC-${year}-${String(lastNum + 1).padStart(4, '0')}`; }, // Cria múltiplos pedidos a partir de uma cotação aprovada. // groupedBids: { [supplier_id]: [{ item, best }] } — mesma estrutura usada no detalhe. async createFromQuotation(quotation, groupedBids) { const created = []; const eta = (() => { const d = new Date(); d.setDate(d.getDate() + 3); return d.toISOString().slice(0, 10); })(); for (const [supplierId, items] of Object.entries(groupedBids)) { const code = await this._nextCode(); const total = items.reduce((s, it) => s + Number(it.best.bid.price) * Number(it.item.qty), 0); const { data: order, error } = await sb .from('orders') .insert({ code, supplier_id: supplierId, quotation_id: quotation.id, total, status: 'emitido', eta, }) .select('*') .single(); if (error) throw error; const itemRows = items.map(it => ({ order_id: order.id, product_id: it.item.product_id, name_snapshot: it.item.name, unit_snapshot: it.item.unit, qty: Number(it.item.qty), unit_price: Number(it.best.bid.price), })); if (itemRows.length) { const { error: itErr } = await sb.from('order_items').insert(itemRows); if (itErr) throw itErr; } created.push(order); } return created; }, }, quotations: { async list() { const { data, error } = await sb .from('quotations') .select(` id, code, title, status, obs, deadline, closed_at, total_value, saving_value, created_at, items:quotation_items(id), invited:quotation_suppliers(supplier_id, status) `) .order('created_at', { ascending: false }); if (error) throw error; return (data || []).map(q => ({ ...q, itemsCount: q.items?.length || 0, suppliersCount: q.invited?.length || 0, respondedCount: (q.invited || []).filter(i => i.status === 'respondida').length, })); }, async getByIdOrCode(idOrCode) { const col = idOrCode && idOrCode.startsWith('COT-') ? 'code' : 'id'; const { data, error } = await sb .from('quotations') .select(` *, items:quotation_items(*, product:products(id, name, code, unit, brand, photo_url)), invited:quotation_suppliers(supplier_id, status, responded_at, access_token, supplier:suppliers(id, name, cnpj, city, whatsapp, email, score, avg_delivery, cats)), bids:bids(*) `) .eq(col, idOrCode) .single(); if (error) throw error; return data; }, async create({ title, deadline, obs, items, supplier_ids }) { const session = await scpAuth.getSession(); const code = await this._nextCode(); const { data: q, error } = await sb .from('quotations') .insert({ code, title, status: 'aberta', obs, deadline, created_by: session?.user?.id }) .select('*') .single(); if (error) throw error; if (items?.length) { const itemRows = items.map((it, i) => ({ quotation_id: q.id, product_id: it.product_id, name_snapshot: it.name, unit_snapshot: it.unit, qty: it.qty, last_price: it.last_price, position: i, })); const { error: itErr } = await sb.from('quotation_items').insert(itemRows); if (itErr) throw itErr; } if (supplier_ids?.length) { const supRows = supplier_ids.map(sid => ({ quotation_id: q.id, supplier_id: sid, status: 'pendente' })); const { error: sErr } = await sb.from('quotation_suppliers').insert(supRows); if (sErr) throw sErr; } return q; }, async updateStatus(id, status) { const patch = { status }; if (status === 'fechada') patch.closed_at = new Date().toISOString(); const { data, error } = await sb.from('quotations').update(patch).eq('id', id).select('*').single(); if (error) throw error; return data; }, async remove(id) { // Cascade já limpa items, suppliers convidados, bids e analyses. // orders ficam preservados (FK set null). const { error } = await sb.from('quotations').delete().eq('id', id); if (error) throw error; }, async _nextCode() { const year = new Date().getFullYear(); const { data } = await sb .from('quotations') .select('code') .like('code', `COT-${year}-%`) .order('code', { ascending: false }) .limit(1); const last = data?.[0]?.code; const lastNum = last ? parseInt(last.split('-').pop()) : 0; return `COT-${year}-${String(lastNum + 1).padStart(4, '0')}`; }, }, suppliers: { async list({ includeInactive = false } = {}) { let q = sb.from('suppliers').select('*').order('created_at', { ascending: false }); if (!includeInactive) q = q.neq('status', 'inativo'); const { data, error } = await q; if (error) throw error; return data || []; }, async create(payload) { const { data, error } = await sb .from('suppliers') .insert(payload) .select('*') .single(); if (error) throw error; return data; }, async update(id, payload) { const { data, error } = await sb .from('suppliers') .update(payload) .eq('id', id) .select('*') .single(); if (error) throw error; return data; }, async remove(id) { // Soft delete: marca como 'inativo' para preservar histórico (cotações, pedidos, bids). // Hard delete só é possível se o fornecedor nunca foi convidado/usado em nada. const { error } = await sb.from('suppliers').update({ status: 'inativo' }).eq('id', id); if (error) throw error; }, async ordersBy(supplierId) { const { data, error } = await sb .from('orders') .select('*') .eq('supplier_id', supplierId) .order('emitted_at', { ascending: false }) .limit(4); if (error) return []; return data || []; }, async productsBy(supplierId) { const { data, error } = await sb .from('products') .select('*') .eq('preferred_supplier_id', supplierId) .order('name') .limit(6); if (error) return []; return data || []; }, }, }; window.sb = sb; window.scpAuth = scpAuth; window.scpDb = scpDb;