feat: comprehensive dashboard refactor and performance stabilization
All checks were successful
Build and Deploy / build-and-push (push) Successful in 3m33s

This commit is contained in:
Cauê Faleiros
2026-04-24 14:43:56 -03:00
parent 509ed4a0d9
commit be8f056434
40 changed files with 408 additions and 204 deletions

View File

@@ -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); }

184
backend/seed_data.js Normal file
View File

@@ -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();

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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();

View File

@@ -46,6 +46,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View File

@@ -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 = () => {
</AuthGuard>
}
/>
<Route
path="/ranking"
element={
<AuthGuard roles={["super_admin", "admin", "manager"]}>
<Ranking />
</AuthGuard>
}
/>
<Route
path="/admin/teams"
element={

View File

@@ -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, Target
ChevronLeft, ChevronRight, Key, Target, Trophy, MessageSquare
} from 'lucide-react';
import {
getAttendances, getUsers, getUserById, logout, searchGlobal,
@@ -11,7 +11,7 @@ import {
deleteNotification, clearAllNotifications, returnToSuperAdmin
} from '../services/dataService';
import { User } from '../types';
import notificationSound from '../src/assets/audio/notification.mp3';
import notificationSound from '../assets/audio/notification.mp3';
const SidebarItem = ({ to, icon: Icon, label, collapsed }: { to: string, icon: any, label: string, collapsed: boolean }) => (
<NavLink
@@ -235,10 +235,13 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
{!isSuperAdmin && (
<>
<SidebarItem to="/" icon={LayoutDashboard} label="Dashboard" collapsed={isSidebarCollapsed} />
{currentUser.role !== 'agent' && (
<>
<SidebarItem to="/admin/users" icon={Users} label="Membros" collapsed={isSidebarCollapsed} />
<SidebarItem to="/ranking" icon={Trophy} label="Ranking de Vendedores" collapsed={isSidebarCollapsed} />
<SidebarItem to="/admin/teams" icon={Building2} label={currentUser.role === 'manager' ? 'Meu Time' : 'Times'} collapsed={isSidebarCollapsed} />
<SidebarItem to="/admin/users" icon={Users} label="Membros" collapsed={isSidebarCollapsed} />
{currentUser.role !== 'manager' && (
<>
<SidebarItem to="/admin/funnels" icon={Layers} label="Gerenciar Funis" collapsed={isSidebarCollapsed} />
@@ -329,21 +332,28 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
<div className="flex items-center bg-zinc-100 dark:bg-dark-bg rounded-xl px-4 py-2.5 w-full border border-transparent focus-within:bg-white dark:focus-within:bg-dark-card focus-within:border-brand-yellow focus-within:ring-2 focus-within:ring-brand-yellow/20 dark:focus-within:ring-brand-yellow/10 transition-all">
{isSearching ? <Loader2 size={18} className="text-brand-yellow animate-spin" /> : <Search size={18} className="text-zinc-400 dark:text-dark-muted" />}
<input
type="text"
placeholder={getSearchPlaceholder()}
type="text"
placeholder={getSearchPlaceholder()}
value={searchQuery}
onChange={(e) => 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"
/>
</div>
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"
/>
</div>
{/* Search Results Dropdown */}
{showSearchResults && (
<div className="absolute top-full mt-2 left-0 w-full bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
{/* Search Results Dropdown */}
{showSearchResults && (
<div className="absolute top-full mt-2 left-0 w-full bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="max-h-[480px] overflow-y-auto p-2">
{/* Organizations Section (Super Admin only) */}
{searchResults.organizations && searchResults.organizations.length > 0 && (
{Array.isArray(searchResults?.organizations) && searchResults.organizations.length > 0 && (
<div className="mb-4">
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Organizações</div>
{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 && (
<div className="mb-4">
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Membros</div>
{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"
>
<img
src={avatarSrc}
alt={m.name}
<img
src={avatarSrc}
alt={m.name}
className="w-9 h-9 rounded-full border border-zinc-100 dark:border-dark-border object-cover"
onError={(e) => { (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 && (
<div className="mb-4">
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Equipes</div>
{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 && (
<div className="mb-2">
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Atendimentos</div>
{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"
>
<div className="w-9 h-9 rounded-lg bg-zinc-100 dark:bg-dark-bg flex items-center justify-center text-zinc-500 dark:text-dark-muted text-[10px] font-bold border border-zinc-200 dark:border-dark-border">
KPI
<div className="w-9 h-9 rounded-lg bg-zinc-100 dark:bg-dark-bg flex items-center justify-center text-zinc-500 dark:text-dark-muted border border-zinc-200 dark:border-dark-border">
<MessageSquare size={16} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-zinc-900 dark:text-dark-text truncate">{a.title}</div>
@@ -459,7 +469,10 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
</div>
)}
{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) && (
<div className="p-8 text-center text-zinc-500 dark:text-dark-muted text-sm">
Nenhum resultado encontrado para "{searchQuery}"
</div>

View File

@@ -13,12 +13,14 @@ interface SellerStat {
interface SellersTableProps {
data: SellerStat[];
defaultShowAll?: boolean;
}
export const SellersTable: React.FC<SellersTableProps> = ({ data }) => {
export const SellersTable: React.FC<SellersTableProps> = ({ data, defaultShowAll = false }) => {
const navigate = useNavigate();
const [sortKey, setSortKey] = useState<keyof SellerStat>('total');
const [sortKey, setSortKey] = useState<keyof SellerStat>('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<SellersTableProps> = ({ 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<SellersTableProps> = ({ data }) => {
});
}, [data, sortKey, sortDirection]);
const displayedData = showAll ? sortedData : sortedData.slice(0, 5);
const SortIcon = ({ column }: { column: string }) => {
if (sortKey !== column) return <ChevronsUpDown size={14} className="text-zinc-300 dark:text-dark-muted" />;
return sortDirection === 'asc' ? <ChevronUp size={14} className="text-brand-yellow" /> : <ChevronDown size={14} className="text-brand-yellow" />;
@@ -102,7 +111,7 @@ export const SellersTable: React.FC<SellersTableProps> = ({ data }) => {
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
{sortedData.map((item, idx) => (
{displayedData.map((item, idx) => (
<tr
key={item.user.id}
className="hover:bg-yellow-50/10 dark:hover:bg-yellow-400/5 transition-colors cursor-pointer group"
@@ -110,7 +119,7 @@ export const SellersTable: React.FC<SellersTableProps> = ({ data }) => {
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<span className="text-xs text-zinc-400 dark:text-dark-muted font-mono w-4">#{idx + 1}</span>
<span className="text-xs text-zinc-400 dark:text-dark-muted font-mono w-4">#{sortedData.indexOf(item) + 1}</span>
<img
src={item.user.avatar_url
? (item.user.avatar_url.startsWith('http') ? item.user.avatar_url : `${import.meta.env.PROD ? '' : 'http://localhost:3001'}${item.user.avatar_url}`)
@@ -145,7 +154,7 @@ export const SellersTable: React.FC<SellersTableProps> = ({ data }) => {
</td>
</tr>
))}
{sortedData.length === 0 && (
{displayedData.length === 0 && (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-zinc-400 dark:text-dark-muted italic">Nenhum dado disponível para o período selecionado.</td>
</tr>
@@ -153,6 +162,16 @@ export const SellersTable: React.FC<SellersTableProps> = ({ data }) => {
</tbody>
</table>
</div>
{sortedData.length > 5 && (
<div className="px-6 py-4 border-t border-zinc-100 dark:border-dark-border bg-zinc-50/30 dark:bg-dark-bg flex justify-center">
<button
onClick={() => setShowAll(!showAll)}
className="text-sm font-medium text-zinc-600 dark:text-dark-text hover:text-brand-yellow transition-colors"
>
{showAll ? 'Ver menos' : `Ver todos (${sortedData.length})`}
</button>
</div>
)}
</div>
);
};

View File

@@ -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 = () => {
</div>
</div>
{/* Ranking Table */}
<SellersTable data={sellersRanking} />
{/* Product Lists */}
<ProductLists requested={productStats.requested} sold={productStats.sold} />
</div>

147
src/pages/Ranking.tsx Normal file
View File

@@ -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<Attendance[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<any[]>([]);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [filters, setFilters] = useState<DashboardFilter>({
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<Record<string, SellerStats>>((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<typeof item> => item !== null);
}, [data, users]);
const handleFilterChange = (key: keyof DashboardFilter, value: any) => {
setFilters(prev => ({ ...prev, [key]: value }));
};
if (loading && data.length === 0) {
return <div className="flex h-full items-center justify-center text-zinc-400 dark:text-dark-muted p-12">Carregando Ranking...</div>;
}
const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'super_admin' || currentUser?.role === 'manager';
return (
<div className="space-y-6 pb-8 transition-colors duration-300">
<div className="bg-white dark:bg-dark-card p-4 rounded-xl shadow-sm border border-zinc-100 dark:border-dark-border flex flex-col gap-4">
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
<div className="flex items-center gap-2 text-zinc-500 dark:text-dark-muted font-medium">
<Filter size={18} />
<span>Filtros:</span>
</div>
<div className="flex flex-wrap items-center gap-3 w-full md:w-auto">
<DateRangePicker
dateRange={filters.dateRange}
onChange={(range) => handleFilterChange('dateRange', range)}
/>
{isAdmin && (
<>
<select
className="bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg text-sm text-zinc-700 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-brand-yellow/20 cursor-pointer hover:border-zinc-300 dark:hover:border-dark-border transition-all"
value={filters.userId}
onChange={(e) => handleFilterChange('userId', e.target.value)}
>
<option value="all">Todos Usuários</option>
{users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
{currentUser?.role !== 'manager' && (
<select
className="bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg text-sm text-zinc-700 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-brand-yellow/20 cursor-pointer hover:border-zinc-300 dark:hover:border-dark-border transition-all"
value={filters.teamId}
onChange={(e) => handleFilterChange('teamId', e.target.value)}
>
<option value="all">Todas Equipes</option>
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
)}
</>
)}
</div>
</div>
</div>
<SellersTable data={sellersRanking} defaultShowAll={true} />
</div>
);
};

View File

@@ -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));

View File

@@ -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));

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();