From 1d3315a1d0a31bf264ce18fd5518a062b4f4d5eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Wed, 18 Mar 2026 11:18:30 -0300 Subject: [PATCH] feat: implement relational lead origins with team assignments - Dropped simple origins table in favor of origin_groups and origin_items to match the Funnels architecture. - Added origin_group_id to teams table to assign specific origins to specific teams. - Updated /admin/origins page to support creating origin groups, adding origin items to them, and assigning teams to groups. - Updated Dashboard and UserDetail pages to dynamically load the exact origin items belonging to the active team/user. --- App.tsx | 2 + backend/index.js | 188 ++++++++++++++++++++++++- components/Layout.tsx | 3 +- pages/Dashboard.tsx | 32 +++-- pages/Origins.tsx | 301 ++++++++++++++++++++++++++++++++++++++++ pages/UserDetail.tsx | 32 +++-- services/dataService.ts | 94 +++++++++++++ types.ts | 16 ++- 8 files changed, 641 insertions(+), 27 deletions(-) create mode 100644 pages/Origins.tsx diff --git a/App.tsx b/App.tsx index 0b7479e..a4f6728 100644 --- a/App.tsx +++ b/App.tsx @@ -9,6 +9,7 @@ import { ApiKeys } from './pages/ApiKeys'; import { TeamManagement } from './pages/TeamManagement'; import { Teams } from './pages/Teams'; import { Funnels } from './pages/Funnels'; +import { Origins } from './pages/Origins'; import { Login } from './pages/Login'; import { ForgotPassword } from './pages/ForgotPassword'; import { ResetPassword } from './pages/ResetPassword'; @@ -96,6 +97,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/backend/index.js b/backend/index.js index 631ba3a..8f7a87f 100644 --- a/backend/index.js +++ b/backend/index.js @@ -612,6 +612,128 @@ apiRouter.delete('/notifications/:id', async (req, res) => { } }); +// --- Origin Routes (Groups & Items) --- +apiRouter.get('/origins', async (req, res) => { + try { + const { tenantId } = req.query; + const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; + if (!effectiveTenantId || effectiveTenantId === 'all') return res.json([]); + + const [groups] = await pool.query('SELECT * FROM origin_groups WHERE tenant_id = ? ORDER BY created_at ASC', [effectiveTenantId]); + + // Seed default origin group if none exists + if (groups.length === 0) { + const gid = `origrp_${crypto.randomUUID().split('-')[0]}`; + await pool.query('INSERT INTO origin_groups (id, tenant_id, name) VALUES (?, ?, ?)', [gid, effectiveTenantId, 'Origens Padrão']); + + const defaultOrigins = ['WhatsApp', 'Instagram', 'Website', 'LinkedIn', 'Indicação']; + for (const name of defaultOrigins) { + const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`; + await pool.query( + 'INSERT INTO origin_items (id, origin_group_id, name) VALUES (?, ?, ?)', + [oid, gid, name] + ); + } + + // Update all teams of this tenant to use this origin group if they have none + await pool.query('UPDATE teams SET origin_group_id = ? WHERE tenant_id = ? AND origin_group_id IS NULL', [gid, effectiveTenantId]); + + groups.push({ id: gid, tenant_id: effectiveTenantId, name: 'Origens Padrão' }); + } + + const [items] = await pool.query('SELECT * FROM origin_items WHERE origin_group_id IN (?) ORDER BY created_at ASC', [groups.map(g => g.id)]); + const [teams] = await pool.query('SELECT id, origin_group_id FROM teams WHERE tenant_id = ? AND origin_group_id IS NOT NULL', [effectiveTenantId]); + + const result = groups.map(g => ({ + ...g, + items: items.filter(i => i.origin_group_id === g.id), + teamIds: teams.filter(t => t.origin_group_id === g.id).map(t => t.id) + })); + + res.json(result); + } catch (error) { + console.error("GET /origins error:", error); + res.status(500).json({ error: error.message }); + } +}); + +apiRouter.post('/origins', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { + const { name, tenantId } = req.body; + const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; + try { + const gid = `origrp_${crypto.randomUUID().split('-')[0]}`; + await pool.query('INSERT INTO origin_groups (id, tenant_id, name) VALUES (?, ?, ?)', [gid, effectiveTenantId, name]); + res.status(201).json({ id: gid }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +apiRouter.put('/origins/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { + const { name, teamIds } = req.body; + try { + if (name) { + await pool.query('UPDATE origin_groups SET name = ? WHERE id = ?', [name, req.params.id]); + } + if (teamIds && Array.isArray(teamIds)) { + await pool.query('UPDATE teams SET origin_group_id = NULL WHERE origin_group_id = ?', [req.params.id]); + if (teamIds.length > 0) { + await pool.query('UPDATE teams SET origin_group_id = ? WHERE id IN (?)', [req.params.id, teamIds]); + } + } + res.json({ message: 'Origin group updated.' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +apiRouter.delete('/origins/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { + try { + await pool.query('DELETE FROM origin_items WHERE origin_group_id = ?', [req.params.id]); + await pool.query('UPDATE teams SET origin_group_id = NULL WHERE origin_group_id = ?', [req.params.id]); + await pool.query('DELETE FROM origin_groups WHERE id = ?', [req.params.id]); + res.json({ message: 'Origin group deleted.' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +apiRouter.post('/origins/:id/items', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { + const { name } = req.body; + try { + const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`; + await pool.query( + 'INSERT INTO origin_items (id, origin_group_id, name) VALUES (?, ?, ?)', + [oid, req.params.id, name] + ); + res.status(201).json({ id: oid }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +apiRouter.put('/origin_items/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { + const { name } = req.body; + try { + const [existing] = await pool.query('SELECT * FROM origin_items WHERE id = ?', [req.params.id]); + if (existing.length === 0) return res.status(404).json({ error: 'Origin item not found' }); + + await pool.query('UPDATE origin_items SET name = ? WHERE id = ?', [name || existing[0].name, req.params.id]); + res.json({ message: 'Origin item updated.' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +apiRouter.delete('/origin_items/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { + try { + await pool.query('DELETE FROM origin_items WHERE id = ?', [req.params.id]); + res.json({ message: 'Origin item deleted.' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // --- Funnel Routes --- apiRouter.get('/funnels', async (req, res) => { try { @@ -952,6 +1074,37 @@ apiRouter.get('/integration/users', requireRole(['admin']), async (req, res) => } }); +apiRouter.get('/integration/origins', requireRole(['admin']), async (req, res) => { + if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' }); + try { + const [origins] = await pool.query('SELECT name FROM origins WHERE tenant_id = ? ORDER BY created_at ASC', [req.user.tenant_id]); + res.json(origins.map(o => o.name)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +apiRouter.get('/integration/funnels', requireRole(['admin']), async (req, res) => { + if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' }); + try { + const [funnels] = await pool.query('SELECT id, name FROM funnels WHERE tenant_id = ?', [req.user.tenant_id]); + if (funnels.length === 0) return res.json([]); + + const [stages] = await pool.query('SELECT funnel_id, name, order_index FROM funnel_stages WHERE funnel_id IN (?) ORDER BY order_index ASC', [funnels.map(f => f.id)]); + const [teams] = await pool.query('SELECT id as team_id, name as team_name, funnel_id FROM teams WHERE tenant_id = ? AND funnel_id IS NOT NULL', [req.user.tenant_id]); + + const result = funnels.map(f => ({ + funnel_name: f.name, + stages: stages.filter(s => s.funnel_id === f.id).map(s => s.name), + assigned_teams: teams.filter(t => t.funnel_id === f.id).map(t => ({ id: t.team_id, name: t.team_name })) + })); + + res.json(result); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + apiRouter.post('/integration/attendances', requireRole(['admin']), async (req, res) => { if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' }); @@ -1288,9 +1441,9 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => { if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (sound_enabled):', err.message); } - // Update origin enum + // Update origin to VARCHAR for custom origins try { - await connection.query("ALTER TABLE attendances MODIFY COLUMN origin ENUM('WhatsApp','Instagram','Website','LinkedIn','Indicação') NOT NULL"); + await connection.query("ALTER TABLE attendances MODIFY COLUMN origin VARCHAR(255) NOT NULL"); } catch (err) { console.log('Schema update note (origin):', err.message); } @@ -1309,6 +1462,37 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => { if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (full_summary):', err.message); } + // Create origin_groups table + await connection.query(` + CREATE TABLE IF NOT EXISTS origin_groups ( + id varchar(36) NOT NULL, + tenant_id varchar(36) NOT NULL, + name varchar(255) NOT NULL, + created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY tenant_id (tenant_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + `); + + // Create origin_items table + await connection.query(` + CREATE TABLE IF NOT EXISTS origin_items ( + id varchar(36) NOT NULL, + origin_group_id varchar(36) NOT NULL, + name varchar(255) NOT NULL, + created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY origin_group_id (origin_group_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + `); + + // Add origin_group_id to teams + try { + await connection.query("ALTER TABLE teams ADD COLUMN origin_group_id VARCHAR(36) DEFAULT NULL"); + } catch (err) { + if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (teams.origin_group_id):', err.message); + } + // Rename summary to title try { await connection.query("ALTER TABLE attendances RENAME COLUMN summary TO title"); diff --git a/components/Layout.tsx b/components/Layout.tsx index d13cc01..5761146 100644 --- a/components/Layout.tsx +++ b/components/Layout.tsx @@ -3,7 +3,7 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { LayoutDashboard, Users, UserCircle, Bell, Search, Menu, X, LogOut, Hexagon, Settings, Building2, Sun, Moon, Loader2, Layers, - ChevronLeft, ChevronRight, Key + ChevronLeft, ChevronRight, Key, Target } from 'lucide-react'; import { getAttendances, getUsers, getUserById, logout, searchGlobal, @@ -240,6 +240,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => + )} diff --git a/pages/Dashboard.tsx b/pages/Dashboard.tsx index b45f3ea..8f0473a 100644 --- a/pages/Dashboard.tsx +++ b/pages/Dashboard.tsx @@ -5,9 +5,9 @@ import { import { Users, Clock, Phone, TrendingUp, Filter } from 'lucide-react'; -import { getAttendances, getUsers, getTeams, getUserById, getFunnels } from '../services/dataService'; +import { getAttendances, getUsers, getTeams, getUserById, getFunnels, getOrigins } from '../services/dataService'; import { COLORS } from '../constants'; -import { Attendance, DashboardFilter, FunnelStage, User, FunnelStageDef } from '../types'; +import { Attendance, DashboardFilter, FunnelStage, User, FunnelStageDef, OriginItemDef } from '../types'; import { KPICard } from '../components/KPICard'; import { DateRangePicker } from '../components/DateRangePicker'; import { SellersTable } from '../components/SellersTable'; @@ -29,6 +29,7 @@ export const Dashboard: React.FC = () => { const [users, setUsers] = useState([]); const [teams, setTeams] = useState([]); const [funnelDefs, setFunnelDefs] = useState([]); + const [originDefs, setOriginDefs] = useState([]); const [currentUser, setCurrentUser] = useState(null); const [filters, setFilters] = useState({ @@ -57,12 +58,13 @@ export const Dashboard: React.FC = () => { const prevFilters = { ...filters, dateRange: { start: prevStart, end: prevEnd } }; // Fetch users, attendances, teams, funnels and current user in parallel - const [fetchedUsers, fetchedData, prevFetchedData, fetchedTeams, fetchedFunnels, me] = await Promise.all([ + const [fetchedUsers, fetchedData, prevFetchedData, fetchedTeams, fetchedFunnels, fetchedOrigins, me] = await Promise.all([ getUsers(tenantId), getAttendances(tenantId, filters), getAttendances(tenantId, prevFilters), getTeams(tenantId), getFunnels(tenantId), + getOrigins(tenantId), storedUserId ? getUserById(storedUserId) : null ]); @@ -70,6 +72,7 @@ export const Dashboard: React.FC = () => { setData(fetchedData); setPrevData(prevFetchedData); setTeams(fetchedTeams); + setOriginDefs(fetchedOrigins); if (me) setCurrentUser(me); // Determine which funnel to display @@ -82,6 +85,14 @@ export const Dashboard: React.FC = () => { setFunnelDefs(activeFunnel && activeFunnel.stages ? activeFunnel.stages.sort((a: any, b: any) => a.order_index - b.order_index) : []); + // Determine which origins to display + let activeOriginGroup = fetchedOrigins[0]; + if (targetTeamId) { + const matchedOrigin = fetchedOrigins.find(o => o.teamIds?.includes(targetTeamId)); + if (matchedOrigin) activeOriginGroup = matchedOrigin; + } + setOriginDefs(activeOriginGroup && activeOriginGroup.items ? activeOriginGroup.items : []); + } catch (error) { console.error("Error loading dashboard data:", error); } finally { @@ -299,19 +310,18 @@ export const Dashboard: React.FC = () => { ))} - - + {originDefs.length > 0 ? originDefs.map(o => ( + + )) : ['WhatsApp', 'Instagram', 'Website', 'LinkedIn', 'Indicação'].map(o => ( + + ))} + diff --git a/pages/Origins.tsx b/pages/Origins.tsx new file mode 100644 index 0000000..c35dc62 --- /dev/null +++ b/pages/Origins.tsx @@ -0,0 +1,301 @@ +import React, { useState, useEffect } from 'react'; +import { Target, Plus, Edit, Trash2, Loader2, X, Users } from 'lucide-react'; +import { getOrigins, createOriginGroup, updateOriginGroup, deleteOriginGroup, createOriginItem, updateOriginItem, deleteOriginItem, getTeams } from '../services/dataService'; +import { OriginGroupDef, OriginItemDef } from '../types'; + +export const Origins: React.FC = () => { + const [originGroups, setOriginGroups] = useState([]); + const [teams, setTeams] = useState([]); + const [selectedGroupId, setSelectedGroupId] = useState(null); + + const [loading, setLoading] = useState(true); + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [formData, setFormData] = useState({ name: '' }); + const [isSaving, setIsSaving] = useState(false); + + // Group creation state + const [isGroupModalOpen, setIsGroupModalOpen] = useState(false); + const [groupName, setGroupName] = useState(''); + + const tenantId = localStorage.getItem('ctms_tenant_id') || ''; + + const loadData = async () => { + setLoading(true); + const [fetchedGroups, fetchedTeams] = await Promise.all([ + getOrigins(tenantId), + getTeams(tenantId) + ]); + setOriginGroups(fetchedGroups); + setTeams(fetchedTeams); + if (!selectedGroupId && fetchedGroups.length > 0) { + setSelectedGroupId(fetchedGroups[0].id); + } + setLoading(false); + }; + + useEffect(() => { + loadData(); + }, [tenantId]); + + const selectedGroup = originGroups.find(g => g.id === selectedGroupId); + + // --- Group Handlers --- + const handleCreateGroup = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSaving(true); + try { + const res = await createOriginGroup({ name: groupName, tenantId }); + setSelectedGroupId(res.id); + setIsGroupModalOpen(false); + setGroupName(''); + loadData(); + } catch (err) { + alert("Erro ao criar grupo de origens."); + } finally { + setIsSaving(false); + } + }; + + const handleDeleteGroup = async (id: string) => { + if (originGroups.length <= 1) { + alert("Você precisa ter pelo menos um grupo de origens ativo."); + return; + } + if (confirm('Tem certeza que deseja excluir este grupo e todas as suas origens?')) { + await deleteOriginGroup(id); + setSelectedGroupId(null); + loadData(); + } + }; + + const handleToggleTeam = async (teamId: string) => { + if (!selectedGroup) return; + const currentTeamIds = selectedGroup.teamIds || []; + const newTeamIds = currentTeamIds.includes(teamId) + ? currentTeamIds.filter(id => id !== teamId) + : [...currentTeamIds, teamId]; + + // Optimistic + const newGroups = [...originGroups]; + const idx = newGroups.findIndex(g => g.id === selectedGroup.id); + newGroups[idx].teamIds = newTeamIds; + setOriginGroups(newGroups); + + await updateOriginGroup(selectedGroup.id, { teamIds: newTeamIds }); + }; + + // --- Item Handlers --- + const handleSaveItem = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedGroup) return; + setIsSaving(true); + try { + if (editingItem) { + await updateOriginItem(editingItem.id, formData); + } else { + await createOriginItem(selectedGroup.id, formData); + } + setIsModalOpen(false); + loadData(); + } catch (err: any) { + alert(err.message || "Erro ao salvar origem."); + } finally { + setIsSaving(false); + } + }; + + const handleDeleteItem = async (id: string) => { + if (confirm('Tem certeza que deseja excluir esta origem?')) { + await deleteOriginItem(id); + loadData(); + } + }; + + const openItemModal = (item?: OriginItemDef) => { + if (item) { + setEditingItem(item); + setFormData({ name: item.name }); + } else { + setEditingItem(null); + setFormData({ name: '' }); + } + setIsModalOpen(true); + }; + + if (loading && originGroups.length === 0) return
; + + return ( +
+ + {/* Sidebar: Groups List */} +
+
+

Configurações

+ +
+ +
+ {originGroups.map(g => ( + + ))} +
+
+ + {/* Main Content: Selected Group Details */} +
+ {selectedGroup ? ( + <> +
+
+

{selectedGroup.name}

+

Gerencie as origens deste grupo e quais times as utilizam.

+
+ +
+ + {/* Teams Assignment */} +
+
+

+ Times Atribuídos +

+
+
+ {teams.length === 0 ? ( +

Nenhum time cadastrado na organização.

+ ) : ( +
+ {teams.map(t => { + const isAssigned = selectedGroup.teamIds?.includes(t.id); + return ( + + ); + })} +
+ )} +

Times não atribuídos a um grupo específico usarão o grupo padrão.

+
+
+ + {/* Origin Items */} +
+
+

+ Fontes de Tráfego +

+ +
+ +
+ {selectedGroup.items?.map((o) => ( +
+
{o.name}
+ +
+ + +
+
+ ))} + {(!selectedGroup.items || selectedGroup.items.length === 0) && ( +
Nenhuma origem configurada neste grupo.
+ )} +
+
+ + ) : ( +
Selecione ou crie um grupo de origens.
+ )} +
+ + {/* Group Creation Modal */} + {isGroupModalOpen && ( +
+
+
+

Novo Grupo

+ +
+
+
+ + setGroupName(e.target.value)} + placeholder="Ex: Origens B2B" + className="w-full bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border p-3 rounded-lg text-sm text-zinc-900 dark:text-zinc-100 focus:ring-2 focus:ring-brand-yellow/20 outline-none transition-all" + required + /> +
+
+ + +
+
+
+
+ )} + + {/* Item Modal */} + {isModalOpen && ( +
+
+
+

{editingItem ? 'Editar Origem' : 'Nova Origem'}

+ +
+
+
+ + setFormData({...formData, name: e.target.value})} + placeholder="Ex: Facebook Ads" + className="w-full bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border p-3 rounded-lg text-sm text-zinc-900 dark:text-zinc-100 focus:ring-2 focus:ring-brand-yellow/20 outline-none transition-all" + required + /> +
+ +
+ + +
+
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/pages/UserDetail.tsx b/pages/UserDetail.tsx index ea14266..55fdab2 100644 --- a/pages/UserDetail.tsx +++ b/pages/UserDetail.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState, useMemo } from 'react'; import { useParams, Link } from 'react-router-dom'; -import { getAttendances, getUserById, getFunnels } from '../services/dataService'; -import { Attendance, User, FunnelStage, DashboardFilter, FunnelStageDef } from '../types'; +import { getAttendances, getUserById, getFunnels, getOrigins } from '../services/dataService'; +import { Attendance, User, FunnelStage, DashboardFilter, FunnelStageDef, OriginItemDef } from '../types'; import { ArrowLeft, Mail, Phone, Clock, MessageSquare, ChevronLeft, ChevronRight, Eye, Filter } from 'lucide-react'; import { DateRangePicker } from '../components/DateRangePicker'; @@ -12,6 +12,7 @@ export const UserDetail: React.FC = () => { const [user, setUser] = useState(); const [attendances, setAttendances] = useState([]); const [funnelDefs, setFunnelDefs] = useState([]); + const [originDefs, setOriginDefs] = useState([]); const [loading, setLoading] = useState(true); const [currentPage, setCurrentPage] = useState(1); const [filters, setFilters] = useState({ @@ -32,12 +33,13 @@ export const UserDetail: React.FC = () => { setUser(u); if (u && tenantId) { - const [data, fetchedFunnels] = await Promise.all([ + const [data, fetchedFunnels, fetchedOrigins] = await Promise.all([ getAttendances(tenantId, { ...filters, userId: id }), - getFunnels(tenantId) + getFunnels(tenantId), + getOrigins(tenantId) ]); setAttendances(data); @@ -48,6 +50,13 @@ export const UserDetail: React.FC = () => { if (matchedFunnel) activeFunnel = matchedFunnel; } setFunnelDefs(activeFunnel && activeFunnel.stages ? activeFunnel.stages.sort((a: any, b: any) => a.order_index - b.order_index) : []); + + let activeOriginGroup = fetchedOrigins[0]; + if (targetTeamId) { + const matchedOrigin = fetchedOrigins.find(o => o.teamIds?.includes(targetTeamId)); + if (matchedOrigin) activeOriginGroup = matchedOrigin; + } + setOriginDefs(activeOriginGroup && activeOriginGroup.items ? activeOriginGroup.items : []); } } catch (error) { console.error("Error loading user details", error); @@ -154,19 +163,18 @@ export const UserDetail: React.FC = () => { ))} - - + {originDefs.length > 0 ? originDefs.map(o => ( + + )) : ['WhatsApp', 'Instagram', 'Website', 'LinkedIn', 'Indicação'].map(o => ( + + ))} + {/* KPI Cards */}
diff --git a/services/dataService.ts b/services/dataService.ts index 793ae7f..d194a3e 100644 --- a/services/dataService.ts +++ b/services/dataService.ts @@ -176,6 +176,100 @@ export const deleteFunnelStage = async (id: string): Promise => { } }; +// --- Origins Functions --- +export const getOrigins = async (tenantId: string): Promise => { + try { + const response = await fetch(`${API_URL}/origins?tenantId=${tenantId}`, { + headers: getHeaders() + }); + if (!response.ok) throw new Error('Falha ao buscar origens'); + return await response.json(); + } catch (error) { + console.error("API Error (getOrigins):", error); + return []; + } +}; + +export const createOriginGroup = async (data: { name: string, tenantId: string }): Promise => { + const response = await fetch(`${API_URL}/origins`, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify(data) + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Erro ao criar grupo de origens'); + } + return await response.json(); +}; + +export const updateOriginGroup = async (id: string, data: { name?: string, teamIds?: string[] }): Promise => { + try { + const response = await fetch(`${API_URL}/origins/${id}`, { + method: 'PUT', + headers: getHeaders(), + body: JSON.stringify(data) + }); + return response.ok; + } catch (error) { + console.error("API Error (updateOriginGroup):", error); + return false; + } +}; + +export const deleteOriginGroup = async (id: string): Promise => { + try { + const response = await fetch(`${API_URL}/origins/${id}`, { + method: 'DELETE', + headers: getHeaders() + }); + return response.ok; + } catch (error) { + console.error("API Error (deleteOriginGroup):", error); + return false; + } +}; + +export const createOriginItem = async (groupId: string, data: { name: string }): Promise => { + const response = await fetch(`${API_URL}/origins/${groupId}/items`, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify(data) + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Erro ao criar item de origem'); + } + return await response.json(); +}; + +export const updateOriginItem = async (id: string, data: { name: string }): Promise => { + try { + const response = await fetch(`${API_URL}/origin_items/${id}`, { + method: 'PUT', + headers: getHeaders(), + body: JSON.stringify(data) + }); + return response.ok; + } catch (error) { + console.error("API Error (updateOriginItem):", error); + return false; + } +}; + +export const deleteOriginItem = async (id: string): Promise => { + try { + const response = await fetch(`${API_URL}/origin_items/${id}`, { + method: 'DELETE', + headers: getHeaders() + }); + return response.ok; + } catch (error) { + console.error("API Error (deleteOriginItem):", error); + return false; + } +}; + // --- API Keys Functions --- export const getApiKeys = async (tenantId: string): Promise => { try { diff --git a/types.ts b/types.ts index e971c2d..51d5f74 100644 --- a/types.ts +++ b/types.ts @@ -23,6 +23,20 @@ export interface FunnelDef { teamIds: string[]; } +export interface OriginItemDef { + id: string; + origin_group_id: string; + name: string; +} + +export interface OriginGroupDef { + id: string; + tenant_id: string; + name: string; + items: OriginItemDef[]; + teamIds: string[]; +} + export interface User { id: string; tenant_id: string; @@ -50,7 +64,7 @@ export interface Attendance { first_response_time_min: number; handling_time_min: number; funnel_stage: string; - origin: 'WhatsApp' | 'Instagram' | 'Website' | 'LinkedIn' | 'Indicação'; + origin: string; product_requested: string; product_sold?: string; converted: boolean;