diff --git a/backend/index.js b/backend/index.js index db523c7..8c5ac19 100644 --- a/backend/index.js +++ b/backend/index.js @@ -993,7 +993,7 @@ apiRouter.get('/search', async (req, res) => { // 3. Search Organizations (only for super_admin) if (req.user.role === 'super_admin') { - const [orgs] = await pool.query('SELECT id, name, slug, status FROM tenants WHERE name LIKE ? OR slug LIKE ?', [queryStr, queryStr]); + const [orgs] = await pool.query('SELECT id, name, slug, status FROM tenants WHERE name LIKE ? OR slug LIKE ? LIMIT 5', [queryStr, queryStr]); results.organizations = orgs; } @@ -1013,6 +1013,7 @@ apiRouter.get('/search', async (req, res) => { attendancesQ += ' AND a.user_id = ?'; attendancesParams.push(req.user.id); } + attendancesQ += ' LIMIT 10'; const [attendances] = await pool.query(attendancesQ, attendancesParams); results.attendances = attendances; @@ -1047,17 +1048,16 @@ apiRouter.get('/attendances', async (req, res) => { params.push(teamId); } - if (userId && userId !== 'all') { + if (userId && userId !== 'all') { // check if it's a slug or id - if (userId.startsWith('u_')) { - q += ' AND a.user_id = ?'; - params.push(userId); + if (userId.startsWith('u_') || userId.length === 36) { + q += ' AND a.user_id = ?'; + params.push(userId); } else { q += ' AND u.slug = ?'; params.push(userId); } - } - } + } } if (funnelStage && funnelStage !== 'all') { q += ' AND a.funnel_stage = ?'; params.push(funnelStage); } if (origin && origin !== 'all') { q += ' AND a.origin = ?'; params.push(origin); } diff --git a/backend/seed_data.js b/backend/seed_data.js new file mode 100644 index 0000000..ed88c8e --- /dev/null +++ b/backend/seed_data.js @@ -0,0 +1,184 @@ +const pool = require('./db.js'); +const crypto = require('crypto'); + +const PRODUCTS = [ + "Pneu Aro 13 175/70R13", + "Pneu Aro 14 175/70R14", + "Pneu Aro 15 195/60R15", + "Pneu Aro 16 205/55R16", + "Pneu Aro 17 225/45R17", + "Alinhamento e Balanceamento", + "Revisão do Sistema de Arrefecimento", + "Manutenção de Freios", + "Troca de Óleo e Filtros", + "Limpeza de Bico" +]; + +const DEFAULT_ORIGINS = ['WhatsApp', 'Instagram', 'Website', 'LinkedIn', 'Indicação']; +const DEFAULT_FUNNELS = ['Sem atendimento', 'Identificação', 'Negociação', 'Ganhos', 'Perdidos']; + +const FIRST_NAMES = ["Ana", "Bruno", "Carlos", "Daniela", "Eduardo", "Fernanda", "Gabriel", "Helena", "Igor", "Julia", "Lucas", "Mariana", "Nicolas", "Olivia", "Pedro", "Quintino", "Rafael", "Sofia", "Thiago", "Ursula", "Victor", "Wagner", "Xuxa", "Yuri", "Zeca", "Amanda", "Beto", "Camila", "Diogo", "Elisa", "Fabio", "Gisele", "Henrique", "Isabela", "Joao", "Karla"]; +const LAST_NAMES = ["Silva", "Santos", "Oliveira", "Souza", "Rodrigues", "Ferreira", "Alves", "Pereira", "Lima", "Gomes", "Costa", "Ribeiro", "Martins", "Carvalho", "Almeida", "Lopes", "Soares", "Fernandes", "Vieira", "Barbosa"]; + +function getRandomItem(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function getRandomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function generateUniqueNames(count) { + const names = new Set(); + while (names.size < count) { + names.add(`${getRandomItem(FIRST_NAMES)} ${getRandomItem(LAST_NAMES)}`); + } + return Array.from(names); +} + +async function run() { + try { + console.log('🔄 Iniciando geração de dados...'); + + // 1. Encontrar o tenant "teste" + const [tenants] = await pool.query(`SELECT id FROM tenants WHERE slug = 'teste' OR name LIKE '%teste%' LIMIT 1`); + if (tenants.length === 0) { + console.log('❌ Tenant "teste" não encontrado.'); + process.exit(1); + } + const tenantId = tenants[0].id; + console.log(`✅ Tenant "teste" encontrado: ${tenantId}`); + + // Pegar origens e funis dinâmicos se existirem + let origins = [...DEFAULT_ORIGINS]; + let funnels = [...DEFAULT_FUNNELS]; + let originGroupId = null; + let funnelId = null; + + try { + const [originGroups] = await pool.query(`SELECT id FROM origin_groups WHERE tenant_id = ? LIMIT 1`, [tenantId]); + if (originGroups.length > 0) { + originGroupId = originGroups[0].id; + const [originItems] = await pool.query(`SELECT name FROM origin_items WHERE origin_group_id = ?`, [originGroupId]); + if (originItems.length > 0) origins = originItems.map(o => o.name); + } + const [funnelGroups] = await pool.query(`SELECT id FROM funnels WHERE tenant_id = ? LIMIT 1`, [tenantId]); + if (funnelGroups.length > 0) { + funnelId = funnelGroups[0].id; + const [funnelStages] = await pool.query(`SELECT name FROM funnel_stages WHERE funnel_id = ?`, [funnelId]); + if (funnelStages.length > 0) funnels = funnelStages.map(f => f.name); + } + } catch (e) { + console.log('Aviso: Usando origens/funis padrão devido a erro na busca de dinâmicos.'); + } + + // 2. Limpar dados existentes do tenant (exceto admin e tenant em si) + console.log('🧹 Limpando attendances antigas...'); + await pool.query(`DELETE FROM attendances WHERE tenant_id = ?`, [tenantId]); + + console.log('🧹 Limpando usuários antigos (exceto admin)...'); + await pool.query(`DELETE FROM users WHERE tenant_id = ? AND role != 'admin'`, [tenantId]); + + console.log('🧹 Limpando times antigos...'); + await pool.query(`DELETE FROM teams WHERE tenant_id = ?`, [tenantId]); + + // 3. Criar 5 times + const teams = []; + for (let i = 1; i <= 5; i++) { + const teamId = crypto.randomUUID(); + await pool.query(`INSERT INTO teams (id, tenant_id, name, description, origin_group_id, funnel_id) VALUES (?, ?, ?, ?, ?, ?)`, + [teamId, tenantId, `Equipe Vendas ${i}`, `Equipe responsável pela região ${i}`, originGroupId, funnelId]); + teams.push(teamId); + } + console.log('✅ 5 times criados.'); + + // 4. Criar 36 usuários (1 manager por time = 5 managers, 31 agents) + const users = []; + const roles = ['manager', 'manager', 'manager', 'manager', 'manager', ...Array(31).fill('agent')]; + const uniqueNames = generateUniqueNames(36); + + // Distribuir usuários entre os times + for (let i = 0; i < 36; i++) { + const userId = crypto.randomUUID(); + const role = roles[i]; + const teamId = teams[i % 5]; + const name = uniqueNames[i]; + const email = `${name.split(' ')[0].toLowerCase()}.${name.split(' ')[1].toLowerCase()}.${i}@teste.com`; + + await pool.query( + `INSERT INTO users (id, tenant_id, team_id, name, email, password_hash, role, status) VALUES (?, ?, ?, ?, ?, ?, ?, 'active')`, + [userId, tenantId, teamId, name, email, 'dummy_hash_not_for_login', role] + ); + users.push(userId); + } + console.log('✅ 36 usuários criados (5 managers, 31 agents).'); + + // 5. Gerar attendances (01/01/2026 até 22/04/2026) + // 112 dias totais + const startDate = new Date('2026-01-01T08:00:00Z'); + const endDate = new Date('2026-04-22T18:00:00Z'); + + let currentDay = new Date(startDate); + const attendancesToInsert = []; + + console.log('⏳ Gerando dados de attendances em memória...'); + while (currentDay <= endDate) { + // Pular domingos para dar mais realismo, ou deixar todos os dias? Vamos deixar todos os dias. + for (const userId of users) { + const numAttendances = getRandomInt(3, 5); // 3 a 5 por dia por usuário + for (let a = 0; a < numAttendances; a++) { + const createdAt = new Date(currentDay); + createdAt.setHours(getRandomInt(8, 17), getRandomInt(0, 59), getRandomInt(0, 59)); + + const isConverted = Math.random() > 0.7; // 30% conversão + const reqProduct = getRandomItem(PRODUCTS); + const soldProduct = isConverted ? reqProduct : null; + + let score = getRandomInt(40, 100); + if (isConverted) score = getRandomInt(85, 100); + + attendancesToInsert.push([ + crypto.randomUUID(), + tenantId, + userId, + isConverted ? "Venda efetuada com sucesso" : "Cliente não finalizou a compra", + isConverted ? "Cliente comprou com sucesso. Excelente atendimento." : "Cliente achou o valor alto e desistiu.", + score, + getRandomInt(1, 45), // first_response_time_min + getRandomInt(10, 120), // handling_time_min + isConverted ? 'Ganhos' : (Math.random() > 0.5 ? 'Perdidos' : getRandomItem(funnels)), // funnel_stage + getRandomItem(origins), // origin + reqProduct, + soldProduct, + isConverted ? 1 : 0, + JSON.stringify(isConverted ? [] : ["Faltou oferecer desconto", "Demora no primeiro contato"]), + JSON.stringify(["Melhorar rapport inicial"]), + createdAt + ]); + } + } + currentDay.setDate(currentDay.getDate() + 1); + } + + // 6. Inserir em lotes + console.log(`⏳ Inserindo ${attendancesToInsert.length} attendances no banco em lotes...`); + const batchSize = 1000; + for (let i = 0; i < attendancesToInsert.length; i += batchSize) { + const batch = attendancesToInsert.slice(i, i + batchSize); + await pool.query( + `INSERT INTO attendances + (id, tenant_id, user_id, title, full_summary, score, first_response_time_min, handling_time_min, funnel_stage, origin, product_requested, product_sold, converted, attention_points, improvement_points, created_at) + VALUES ?`, + [batch] + ); + process.stdout.write(`\r✅ Inseridos ${Math.min(i + batchSize, attendancesToInsert.length)} / ${attendancesToInsert.length}`); + } + console.log('\n🎉 Todos os dados foram gerados com sucesso!'); + process.exit(0); + } catch (error) { + console.error('❌ Erro:', error); + process.exit(1); + } +} + +run(); diff --git a/debug.txt b/debug.txt deleted file mode 100644 index 8cf10a2..0000000 --- a/debug.txt +++ /dev/null @@ -1,27 +0,0 @@ -Look at `playNotificationSound`: -```javascript - const playNotificationSound = () => { - if (currentUser?.sound_enabled !== false && audioRef.current) { - // Reset time to 0 to allow rapid replays - audioRef.current.currentTime = 0; - const playPromise = audioRef.current.play(); -``` -Is `currentUser` loaded when `loadNotifications` fires for the first time after `isInitialLoadRef` is false? -Yes, `useEffect` calls `fetchCurrentUser()`, which sets `currentUser`. - -Wait. `setInterval` uses a closure over the state! -```javascript - useEffect(() => { - fetchCurrentUser(); - loadNotifications(); - const interval = setInterval(loadNotifications, 10000); - return () => clearInterval(interval); - }, [navigate]); -``` -Oh my god. The `setInterval` callback `loadNotifications` captures the *initial* state variables, including `currentUser`, which is `null` on the first render! -If `currentUser` is `null` inside the closure, `currentUser?.sound_enabled !== false` evaluates to `true !== false` which is `true`. So that's not blocking it. -BUT `audioRef.current` might not have been rendered yet? No, `audioRef` is a ref, so it mutates in place. The closure always sees the latest `audioRef.current`. - -So why does it fail or not play? -Is the browser policy blocking it silently without logging? -Let's add a robust, standalone Audio approach that doesn't rely on the DOM tag if it fails, or maybe just force a click handler to "unlock" the audio context. diff --git a/debug2.txt b/debug2.txt deleted file mode 100644 index 9c14958..0000000 --- a/debug2.txt +++ /dev/null @@ -1,36 +0,0 @@ -Let's see what happens during returnToSuperAdmin: -1. Decode superAdminToken -2. localStorage.setItem('ctms_token', superAdminToken) -3. localStorage.setItem('ctms_user_id', payload.id) <-- u_71657ec7 -4. localStorage.setItem('ctms_tenant_id', payload.tenant_id || 'system') <-- 'system' -5. window.location.reload() - -Then the app reloads. -1. AuthGuard mounts -2. const storedUserId = localStorage.getItem('ctms_user_id') <-- u_71657ec7 -3. const fetchedUser = await getUserById(storedUserId) - -> GET /api/users/u_71657ec7 - -Backend handles GET /api/users/u_71657ec7: -req.user is derived from jwt.verify(superAdminToken) -So req.user.role === 'super_admin' -req.user.tenant_id === 'system' (or null) - -const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [req.params.idOrSlug, req.params.idOrSlug]); -if (rows.length === 0) return res.status(404).json({ error: 'Not found' }); -if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) { ... } -res.json(rows[0]); - -Wait. If this is a 500 error, why does it crash? -What if `req.user` is undefined? But `authenticateToken` middleware ensures `req.user` exists. -Maybe `pool.query` fails? -No, wait. You said: -"now its sending me to login page, congrats" -Then I fixed the Base64 pad. -Then you said: -"i am still having this problem, when i go to impersionate the pages blink and i go to the tenant pages, good, but when i press to go back and stop impersionate it redirects me to the goddam login page" -Then you said: -"ok, its working but its giving some errors in dev tools and this errors is showing our users id and path ... XHRGET https://fasto.blyzer.com.br/api/users/u_71657ec7 [HTTP/2 500 14ms]" - -Why 500? -Let's add a console.log in the catch block of `/users/:idOrSlug` to see the actual error. diff --git a/debug3.txt b/debug3.txt deleted file mode 100644 index 00dd556..0000000 --- a/debug3.txt +++ /dev/null @@ -1,20 +0,0 @@ -Wait, let's look at `index.js` line 354 BEFORE my fix: -```javascript - const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [req.params.idOrSlug, req.params.idOrSlug]); - if (rows.length === 0) return res.status(404).json({ error: 'Not found' }); - if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) { -``` -The ONLY way this throws 500 is if: -1. `pool.query` fails (e.g. database disconnected, which isn't the case). -2. `req.user` is somehow null or undefined (but `authenticateToken` guarantees it exists). -3. `rows[0]` is undefined (but `rows.length === 0` handles that). - -Wait, what if `req.user.tenant_id` is null? `null !== 'tenant_xyz'` is true, so it returns 403, not 500. - -What if the 500 is coming from `GET /api/users/u_71657ec7` but it's not actually hitting `/users/:idOrSlug`? -Is there a middleware or something? No. - -Ah! What if the user you are impersonating was deleted from the database? `rows.length === 0` -> returns 404, not 500. - -Let's check the local logs AGAIN after my recent rebuild. I added `console.error('Error in GET /users/:idOrSlug:', error);` -Let's deliberately trigger the error locally to see it. But I don't have the browser. diff --git a/docker-compose.yml b/docker-compose.yml index eb48de5..aa5cc63 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,7 +57,7 @@ services: MAX_BACKUPS: 3 # Mantém apenas os 3 últimos dias INIT_BACKUP: "1" # Faz um backup imediatamente ao ligar o container volumes: - - /opt/backups_db/fasto:/backup + - /opt/backups_db/fastogemi:/backup networks: - fasto-net diff --git a/fix_db.js b/fix_db.js deleted file mode 100644 index e393b97..0000000 --- a/fix_db.js +++ /dev/null @@ -1,10 +0,0 @@ -const mysql = require('mysql2/promise'); -async function run() { - const pool = mysql.createPool({ host: '127.0.0.1', user: 'root', password: 'secret_pass', database: 'fasto_db', port: 3306 }); - try { - await pool.query("ALTER TABLE attendances MODIFY COLUMN funnel_stage VARCHAR(255) NOT NULL DEFAULT 'Novo'"); - console.log("Success"); - } catch(e) { console.error(e); } - pool.end(); -} -run(); diff --git a/index.html b/index.html index c871628..ec0e148 100644 --- a/index.html +++ b/index.html @@ -46,6 +46,6 @@
- + diff --git a/App.tsx b/src/App.tsx similarity index 95% rename from App.tsx rename to src/App.tsx index 70acd7a..e01ea9e 100644 --- a/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import { ForgotPassword } from "./pages/ForgotPassword"; import { ResetPassword } from "./pages/ResetPassword"; import { SetupAccount } from "./pages/SetupAccount"; import { UserProfile } from "./pages/UserProfile"; +import { Ranking } from "./pages/Ranking"; import { getUserById, logout } from "./services/dataService"; import { User } from "./types"; @@ -127,6 +128,14 @@ const App: React.FC = () => { } /> + + + + } + /> ( = ({ children }) => {!isSuperAdmin && ( <> + {currentUser.role !== 'agent' && ( <> - + + + {currentUser.role !== 'manager' && ( <> @@ -329,21 +332,28 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
{isSearching ? : } setSearchQuery(e.target.value)} onFocus={() => searchQuery.length >= 2 && setShowSearchResults(true)} - className="bg-transparent border-none outline-none text-sm ml-3 w-full text-zinc-700 dark:text-dark-text placeholder-zinc-400 dark:placeholder-dark-muted" - /> -
+ onKeyDown={(e) => { + if (e.key === 'Escape') { + setShowSearchResults(false); + setSearchQuery(''); + e.currentTarget.blur(); + } + }} + className="bg-transparent border-none outline-none text-sm ml-3 w-full text-zinc-700 dark:text-dark-text placeholder-zinc-400 dark:placeholder-dark-muted" + /> + - {/* Search Results Dropdown */} - {showSearchResults && ( -
+ {/* Search Results Dropdown */} + {showSearchResults && ( +
{/* Organizations Section (Super Admin only) */} - {searchResults.organizations && searchResults.organizations.length > 0 && ( + {Array.isArray(searchResults?.organizations) && searchResults.organizations.length > 0 && (
Organizações
{searchResults.organizations.map(o => ( @@ -369,12 +379,12 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => )} {/* Members Section */} - {searchResults.members.length > 0 && ( + {Array.isArray(searchResults?.members) && searchResults.members.length > 0 && (
Membros
{searchResults.members.map(m => { const backendUrl = import.meta.env.PROD ? '' : 'http://localhost:3001'; - const avatarSrc = m.avatar_url + const avatarSrc = m.avatar_url ? (m.avatar_url.startsWith('http') ? m.avatar_url : `${backendUrl}${m.avatar_url}`) : `https://ui-avatars.com/api/?name=${encodeURIComponent(m.name)}&background=random`; @@ -388,9 +398,9 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => }} className="w-full flex items-center gap-3 p-2 hover:bg-zinc-50 dark:hover:bg-dark-border rounded-xl transition-colors text-left" > - {m.name} { (e.target as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${encodeURIComponent(m.name)}&background=random`; }} /> @@ -405,7 +415,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => )} {/* Teams Section */} - {searchResults.teams.length > 0 && ( + {Array.isArray(searchResults?.teams) && searchResults.teams.length > 0 && (
Equipes
{searchResults.teams.map(t => ( @@ -431,7 +441,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => )} {/* Attendances Section */} - {searchResults.attendances.length > 0 && ( + {Array.isArray(searchResults?.attendances) && searchResults.attendances.length > 0 && (
Atendimentos
{searchResults.attendances.map(a => ( @@ -444,8 +454,8 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => }} className="w-full flex items-center gap-3 p-2 hover:bg-zinc-50 dark:hover:bg-dark-border rounded-xl transition-colors text-left" > -
- KPI +
+
{a.title}
@@ -459,7 +469,10 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
)} - {searchResults.members.length === 0 && searchResults.teams.length === 0 && searchResults.attendances.length === 0 && (!searchResults.organizations || searchResults.organizations.length === 0) && ( + {(!Array.isArray(searchResults?.members) || searchResults.members.length === 0) && + (!Array.isArray(searchResults?.teams) || searchResults.teams.length === 0) && + (!Array.isArray(searchResults?.attendances) || searchResults.attendances.length === 0) && + (!Array.isArray(searchResults?.organizations) || searchResults.organizations.length === 0) && (
Nenhum resultado encontrado para "{searchQuery}"
diff --git a/components/ProductLists.tsx b/src/components/ProductLists.tsx similarity index 100% rename from components/ProductLists.tsx rename to src/components/ProductLists.tsx diff --git a/components/SellersTable.tsx b/src/components/SellersTable.tsx similarity index 86% rename from components/SellersTable.tsx rename to src/components/SellersTable.tsx index 48a1684..2ccf7d3 100644 --- a/components/SellersTable.tsx +++ b/src/components/SellersTable.tsx @@ -13,12 +13,14 @@ interface SellerStat { interface SellersTableProps { data: SellerStat[]; + defaultShowAll?: boolean; } -export const SellersTable: React.FC = ({ data }) => { +export const SellersTable: React.FC = ({ data, defaultShowAll = false }) => { const navigate = useNavigate(); - const [sortKey, setSortKey] = useState('total'); + const [sortKey, setSortKey] = useState('conversionRate'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + const [showAll, setShowAll] = useState(defaultShowAll); const handleSort = (key: keyof SellerStat) => { if (sortKey === key) { @@ -35,6 +37,11 @@ export const SellersTable: React.FC = ({ data }) => { const bVal = b[sortKey]; if (typeof aVal === 'string' && typeof bVal === 'string') { + if (sortKey === 'avgScore' || sortKey === 'conversionRate' || sortKey === 'responseTime') { + const numA = parseFloat(aVal) || 0; + const numB = parseFloat(bVal) || 0; + return sortDirection === 'asc' ? numA - numB : numB - numA; + } return sortDirection === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal); @@ -55,6 +62,8 @@ export const SellersTable: React.FC = ({ data }) => { }); }, [data, sortKey, sortDirection]); + const displayedData = showAll ? sortedData : sortedData.slice(0, 5); + const SortIcon = ({ column }: { column: string }) => { if (sortKey !== column) return ; return sortDirection === 'asc' ? : ; @@ -102,7 +111,7 @@ export const SellersTable: React.FC = ({ data }) => { - {sortedData.map((item, idx) => ( + {displayedData.map((item, idx) => ( = ({ data }) => { >
- #{idx + 1} + #{sortedData.indexOf(item) + 1} = ({ data }) => { ))} - {sortedData.length === 0 && ( + {displayedData.length === 0 && ( Nenhum dado disponível para o período selecionado. @@ -153,6 +162,16 @@ export const SellersTable: React.FC = ({ data }) => {
+ {sortedData.length > 5 && ( +
+ +
+ )}
); }; \ No newline at end of file diff --git a/constants.ts b/src/constants.ts similarity index 100% rename from constants.ts rename to src/constants.ts diff --git a/index.tsx b/src/index.tsx similarity index 100% rename from index.tsx rename to src/index.tsx diff --git a/pages/ApiKeys.tsx b/src/pages/ApiKeys.tsx similarity index 100% rename from pages/ApiKeys.tsx rename to src/pages/ApiKeys.tsx diff --git a/pages/AttendanceDetail.tsx b/src/pages/AttendanceDetail.tsx similarity index 100% rename from pages/AttendanceDetail.tsx rename to src/pages/AttendanceDetail.tsx diff --git a/pages/Dashboard.tsx b/src/pages/Dashboard.tsx similarity index 99% rename from pages/Dashboard.tsx rename to src/pages/Dashboard.tsx index 54a8fcb..ad2f508 100644 --- a/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -10,7 +10,6 @@ import { COLORS } from '../constants'; import { Attendance, DashboardFilter, FunnelStage, User, FunnelStageDef, OriginItemDef } from '../types'; import { KPICard } from '../components/KPICard'; import { DateRangePicker } from '../components/DateRangePicker'; -import { SellersTable } from '../components/SellersTable'; import { ProductLists } from '../components/ProductLists'; // Interface for seller statistics accumulator @@ -496,9 +495,6 @@ export const Dashboard: React.FC = () => {
- {/* Ranking Table */} - - {/* Product Lists */}
diff --git a/pages/ForgotPassword.tsx b/src/pages/ForgotPassword.tsx similarity index 100% rename from pages/ForgotPassword.tsx rename to src/pages/ForgotPassword.tsx diff --git a/pages/Funnels.tsx b/src/pages/Funnels.tsx similarity index 100% rename from pages/Funnels.tsx rename to src/pages/Funnels.tsx diff --git a/pages/Login.tsx b/src/pages/Login.tsx similarity index 100% rename from pages/Login.tsx rename to src/pages/Login.tsx diff --git a/pages/Origins.tsx b/src/pages/Origins.tsx similarity index 100% rename from pages/Origins.tsx rename to src/pages/Origins.tsx diff --git a/src/pages/Ranking.tsx b/src/pages/Ranking.tsx new file mode 100644 index 0000000..4697a64 --- /dev/null +++ b/src/pages/Ranking.tsx @@ -0,0 +1,147 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { Filter, Users, Building2 } from 'lucide-react'; +import { getAttendances, getUsers, getUserById, getTeams } from '../services/dataService'; +import { Attendance, DashboardFilter, User } from '../types'; +import { DateRangePicker } from '../components/DateRangePicker'; +import { SellersTable } from '../components/SellersTable'; + +interface SellerStats { + total: number; + converted: number; + scoreSum: number; + count: number; + timeSum: number; +} + +export const Ranking: React.FC = () => { + const [loading, setLoading] = useState(true); + const [data, setData] = useState([]); + const [users, setUsers] = useState([]); + const [teams, setTeams] = useState([]); + const [currentUser, setCurrentUser] = useState(null); + + const [filters, setFilters] = useState({ + dateRange: { + start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), // Last 30 days + end: new Date(), + }, + userId: 'all', + teamId: 'all', + funnelStage: 'all', + origin: 'all', + }); + + useEffect(() => { + const fetchData = async () => { + setLoading(true); + try { + const tenantId = localStorage.getItem('ctms_tenant_id'); + const uid = localStorage.getItem('ctms_user_id'); + + if (tenantId && uid) { + const u = await getUserById(uid); + setCurrentUser(u || null); + + const [fetchedAttendances, fetchedUsers, fetchedTeams] = await Promise.all([ + getAttendances(tenantId, filters), + getUsers(tenantId), + getTeams(tenantId) + ]); + setData(fetchedAttendances); + setUsers(fetchedUsers); + setTeams(fetchedTeams); + } + } catch (error) { + console.error("Error loading ranking data:", error); + } finally { + setLoading(false); + } + }; + fetchData(); + }, [filters]); + + const sellersRanking = useMemo(() => { + const stats = data.reduce>((acc, curr) => { + if (!acc[curr.user_id]) { + acc[curr.user_id] = { total: 0, converted: 0, scoreSum: 0, count: 0, timeSum: 0 }; + } + acc[curr.user_id].total += 1; + if (curr.converted) acc[curr.user_id].converted += 1; + acc[curr.user_id].scoreSum += curr.score; + acc[curr.user_id].timeSum += curr.first_response_time_min; + acc[curr.user_id].count += 1; + return acc; + }, {}); + + return Object.entries(stats) + .map(([userId, s]) => { + const stat = s as SellerStats; + const user = users.find(u => u.id === userId); + if (!user) return null; + return { + user, + total: stat.total, + avgScore: (stat.scoreSum / stat.count).toFixed(1), + conversionRate: ((stat.converted / stat.total) * 100).toFixed(1), + responseTime: (stat.timeSum / stat.count).toFixed(0) + }; + }) + .filter((item): item is NonNullable => item !== null); + }, [data, users]); + + const handleFilterChange = (key: keyof DashboardFilter, value: any) => { + setFilters(prev => ({ ...prev, [key]: value })); + }; + + if (loading && data.length === 0) { + return
Carregando Ranking...
; + } + + const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'super_admin' || currentUser?.role === 'manager'; + + return ( +
+
+
+
+ + Filtros: +
+ +
+ handleFilterChange('dateRange', range)} + /> + + {isAdmin && ( + <> + + + {currentUser?.role !== 'manager' && ( + + )} + + )} +
+
+
+ + +
+ ); +}; diff --git a/pages/Register.tsx b/src/pages/Register.tsx similarity index 100% rename from pages/Register.tsx rename to src/pages/Register.tsx diff --git a/pages/ResetPassword.tsx b/src/pages/ResetPassword.tsx similarity index 100% rename from pages/ResetPassword.tsx rename to src/pages/ResetPassword.tsx diff --git a/pages/SetupAccount.tsx b/src/pages/SetupAccount.tsx similarity index 100% rename from pages/SetupAccount.tsx rename to src/pages/SetupAccount.tsx diff --git a/pages/SuperAdmin.tsx b/src/pages/SuperAdmin.tsx similarity index 100% rename from pages/SuperAdmin.tsx rename to src/pages/SuperAdmin.tsx diff --git a/pages/TeamManagement.tsx b/src/pages/TeamManagement.tsx similarity index 100% rename from pages/TeamManagement.tsx rename to src/pages/TeamManagement.tsx diff --git a/pages/Teams.tsx b/src/pages/Teams.tsx similarity index 100% rename from pages/Teams.tsx rename to src/pages/Teams.tsx diff --git a/pages/UserDetail.tsx b/src/pages/UserDetail.tsx similarity index 100% rename from pages/UserDetail.tsx rename to src/pages/UserDetail.tsx diff --git a/pages/UserProfile.tsx b/src/pages/UserProfile.tsx similarity index 100% rename from pages/UserProfile.tsx rename to src/pages/UserProfile.tsx diff --git a/pages/VerifyCode.tsx b/src/pages/VerifyCode.tsx similarity index 100% rename from pages/VerifyCode.tsx rename to src/pages/VerifyCode.tsx diff --git a/services/dataService.ts b/src/services/dataService.ts similarity index 100% rename from services/dataService.ts rename to src/services/dataService.ts diff --git a/types.ts b/src/types.ts similarity index 100% rename from types.ts rename to src/types.ts diff --git a/test-b64.js b/test-b64.js deleted file mode 100644 index 8de754f..0000000 --- a/test-b64.js +++ /dev/null @@ -1,8 +0,0 @@ -const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVfMTIzNDUiLCJ0ZW5hbnRfaWQiOiJzeXN0ZW0ifQ.XYZ"; -const base64Url = token.split('.')[1]; -const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); -console.log(base64); -const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) { - return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); -}).join('')); -console.log(JSON.parse(jsonPayload)); diff --git a/test-jwt.js b/test-jwt.js deleted file mode 100644 index e3bc7aa..0000000 --- a/test-jwt.js +++ /dev/null @@ -1,11 +0,0 @@ -const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVfMTIzNDUiLCJ0ZW5hbnRfaWQiOiJzeXN0ZW0ifQ.XYZ"; -const base64Url = token.split('.')[1]; -let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); -const pad = base64.length % 4; -if (pad) { - base64 += '='.repeat(4 - pad); -} -const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) { - return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); -}).join('')); -console.log(JSON.parse(jsonPayload)); diff --git a/test-mysql-error.cjs b/test-mysql-error.cjs deleted file mode 100644 index 12254ac..0000000 --- a/test-mysql-error.cjs +++ /dev/null @@ -1,15 +0,0 @@ -const mysql = require('mysql2/promise'); -const pool = mysql.createPool({ host: 'localhost', user: 'root', password: 'secret_pass', database: 'fasto_db', port: 3306 }); - -async function run() { - try { - const id = 'u_71657ec7'; // or your ID - const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [id, id]); - console.log(rows); - } catch (err) { - console.error(err); - } finally { - pool.end(); - } -} -run(); diff --git a/test-mysql.cjs b/test-mysql.cjs deleted file mode 100644 index cfd5f91..0000000 --- a/test-mysql.cjs +++ /dev/null @@ -1,14 +0,0 @@ -const mysql = require('mysql2/promise'); - -async function test() { - const pool = mysql.createPool({ host: '127.0.0.1', user: 'root', password: 'secret_pass', database: 'fasto_db', port: 3306 }); - try { - const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', ['u_71657ec7', 'u_71657ec7']); - console.log("ROWS:", rows); - } catch (err) { - console.error("ERROR:", err); - } finally { - await pool.end(); - } -} -test(); diff --git a/test-mysql.js b/test-mysql.js deleted file mode 100644 index 6e99794..0000000 --- a/test-mysql.js +++ /dev/null @@ -1,23 +0,0 @@ -const mysql = require('mysql2/promise'); - -async function test() { - const pool = mysql.createPool({ host: '127.0.0.1', user: 'root', password: 'secret_pass', database: 'fasto_db', port: 3306 }); - try { - const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', ['u_71657ec7', 'u_71657ec7']); - console.log("ROWS:", rows); - // Simulate req.user - const req = { user: { role: 'super_admin', tenant_id: 'system' } }; - - if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) { - console.log("Access Denied"); - } else { - console.log("Access Granted"); - } - - } catch (err) { - console.error("ERROR:", err); - } finally { - await pool.end(); - } -} -test();