From 3250ad7537b371956567e2c502d2fceae5f6f8d4 Mon Sep 17 00:00:00 2001 From: farelos <125504745+bashfarelos@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:36:00 -0300 Subject: [PATCH] feat: Implement backend API and basic frontend structure Adds initial backend API endpoints for fetching users and attendances, including basic filtering. Sets up the frontend routing with a layout component and includes placeholder pages for dashboard, users, and login. Refactors the README for local development setup. --- .gitignore | 24 +++ App.tsx | 43 ++++ README.md | 25 ++- backend/db.js | 28 +++ backend/index.js | 130 ++++++++++++ components/DateRangePicker.tsx | 49 +++++ components/KPICard.tsx | 41 ++++ components/Layout.tsx | 171 ++++++++++++++++ components/ProductLists.tsx | 60 ++++++ components/SellersTable.tsx | 148 ++++++++++++++ constants.ts | 173 ++++++++++++++++ index.html | 45 ++++ index.tsx | 15 ++ metadata.json | 5 + package.json | 24 +++ pages/AttendanceDetail.tsx | 222 ++++++++++++++++++++ pages/Dashboard.tsx | 314 ++++++++++++++++++++++++++++ pages/Login.tsx | 190 +++++++++++++++++ pages/SuperAdmin.tsx | 362 +++++++++++++++++++++++++++++++++ pages/TeamManagement.tsx | 285 ++++++++++++++++++++++++++ pages/UserDetail.tsx | 212 +++++++++++++++++++ pages/UserProfile.tsx | 207 +++++++++++++++++++ services/dataService.ts | 68 +++++++ tsconfig.json | 29 +++ types.ts | 62 ++++++ vite.config.ts | 23 +++ 26 files changed, 2947 insertions(+), 8 deletions(-) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 backend/db.js create mode 100644 backend/index.js create mode 100644 components/DateRangePicker.tsx create mode 100644 components/KPICard.tsx create mode 100644 components/Layout.tsx create mode 100644 components/ProductLists.tsx create mode 100644 components/SellersTable.tsx create mode 100644 constants.ts create mode 100644 index.html create mode 100644 index.tsx create mode 100644 metadata.json create mode 100644 package.json create mode 100644 pages/AttendanceDetail.tsx create mode 100644 pages/Dashboard.tsx create mode 100644 pages/Login.tsx create mode 100644 pages/SuperAdmin.tsx create mode 100644 pages/TeamManagement.tsx create mode 100644 pages/UserDetail.tsx create mode 100644 pages/UserProfile.tsx create mode 100644 services/dataService.ts create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..929dd5a --- /dev/null +++ b/App.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { HashRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import { Layout } from './components/Layout'; +import { Dashboard } from './pages/Dashboard'; +import { UserDetail } from './pages/UserDetail'; +import { AttendanceDetail } from './pages/AttendanceDetail'; +import { SuperAdmin } from './pages/SuperAdmin'; +import { TeamManagement } from './pages/TeamManagement'; +import { Login } from './pages/Login'; +import { UserProfile } from './pages/UserProfile'; + +const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const location = useLocation(); + const isLoginPage = location.pathname === '/login'; + + if (isLoginPage) { + return <>{children}; + } + + return {children}; +}; + +const App: React.FC = () => { + return ( + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +}; + +export default App; \ No newline at end of file diff --git a/README.md b/README.md index 2241000..f4889a2 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,20 @@
- GHBanner - -

Built with AI Studio

- -

The fastest path from prompt to production with Gemini.

- - Start building -
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/3aacfdea-56fa-4154-a6b9-09ebdedfa306 + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/backend/db.js b/backend/db.js new file mode 100644 index 0000000..f0dc232 --- /dev/null +++ b/backend/db.js @@ -0,0 +1,28 @@ + +const mysql = require('mysql2/promise'); + +// Configuração da conexão com o banco de dados +// Em produção, estes valores devem vir de variáveis de ambiente (.env) +const pool = mysql.createPool({ + host: '162.240.103.190', + user: 'agenciac_comia', + password: 'Blyzer@2025#', + database: 'agenciac_comia', + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0, + // Opções de SSL podem ser necessárias dependendo do servidor, + // mas vamos tentar sem SSL inicialmente dado o host. +}); + +// Teste de conexão simples ao iniciar +pool.getConnection() + .then(connection => { + console.log('✅ Conectado ao MySQL com sucesso!'); + connection.release(); + }) + .catch(err => { + console.error('❌ Erro ao conectar ao MySQL:', err.message); + }); + +module.exports = pool; diff --git a/backend/index.js b/backend/index.js new file mode 100644 index 0000000..ce70987 --- /dev/null +++ b/backend/index.js @@ -0,0 +1,130 @@ + +const express = require('express'); +const cors = require('cors'); +const pool = require('./db'); + +const app = express(); +const PORT = 3001; // Porta do backend + +app.use(cors()); // Permite que o React (localhost:3000) acesse este servidor +app.use(express.json()); + +// --- Rotas de Usuários --- + +// Listar Usuários (com filtro opcional de tenant) +app.get('/api/users', async (req, res) => { + try { + const { tenantId } = req.query; + let query = 'SELECT * FROM users'; + const params = []; + + if (tenantId && tenantId !== 'all') { + query += ' WHERE tenant_id = ?'; + params.push(tenantId); + } + + const [rows] = await pool.query(query, params); + res.json(rows); + } catch (error) { + console.error('Erro ao buscar usuários:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Detalhe do Usuário +app.get('/api/users/:id', async (req, res) => { + try { + const [rows] = await pool.query('SELECT * FROM users WHERE id = ?', [req.params.id]); + if (rows.length === 0) return res.status(404).json({ message: 'User not found' }); + res.json(rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// --- Rotas de Atendimentos --- + +// Listar Atendimentos (Dashboard) +app.get('/api/attendances', async (req, res) => { + try { + const { tenantId, userId, teamId, startDate, endDate } = req.query; + + let query = ` + SELECT a.*, u.team_id + FROM attendances a + JOIN users u ON a.user_id = u.id + WHERE a.tenant_id = ? + `; + const params = [tenantId]; + + // Filtro de Data + if (startDate && endDate) { + query += ' AND a.created_at BETWEEN ? AND ?'; + params.push(new Date(startDate), new Date(endDate)); + } + + // Filtro de Usuário + if (userId && userId !== 'all') { + query += ' AND a.user_id = ?'; + params.push(userId); + } + + // Filtro de Time (baseado na tabela users ou teams) + if (teamId && teamId !== 'all') { + query += ' AND u.team_id = ?'; + params.push(teamId); + } + + query += ' ORDER BY a.created_at DESC'; + + const [rows] = await pool.query(query, params); + + // Tratamento de campos JSON se o MySQL retornar como string + const processedRows = rows.map(row => ({ + ...row, + attention_points: typeof row.attention_points === 'string' ? JSON.parse(row.attention_points) : row.attention_points, + improvement_points: typeof row.improvement_points === 'string' ? JSON.parse(row.improvement_points) : row.improvement_points, + converted: Boolean(row.converted) // Garantir booleano + })); + + res.json(processedRows); + } catch (error) { + console.error('Erro ao buscar atendimentos:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Detalhe do Atendimento +app.get('/api/attendances/:id', async (req, res) => { + try { + const [rows] = await pool.query('SELECT * FROM attendances WHERE id = ?', [req.params.id]); + if (rows.length === 0) return res.status(404).json({ message: 'Attendance not found' }); + + const row = rows[0]; + const processedRow = { + ...row, + attention_points: typeof row.attention_points === 'string' ? JSON.parse(row.attention_points) : row.attention_points, + improvement_points: typeof row.improvement_points === 'string' ? JSON.parse(row.improvement_points) : row.improvement_points, + converted: Boolean(row.converted) + }; + + res.json(processedRow); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// --- Rotas de Tenants (Super Admin) --- +app.get('/api/tenants', async (req, res) => { + try { + const [rows] = await pool.query('SELECT * FROM tenants'); + res.json(rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + + +app.listen(PORT, () => { + console.log(`🚀 Servidor Backend rodando em http://localhost:${PORT}`); +}); diff --git a/components/DateRangePicker.tsx b/components/DateRangePicker.tsx new file mode 100644 index 0000000..ac820a5 --- /dev/null +++ b/components/DateRangePicker.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Calendar } from 'lucide-react'; +import { DateRange } from '../types'; + +interface DateRangePickerProps { + dateRange: DateRange; + onChange: (range: DateRange) => void; +} + +export const DateRangePicker: React.FC = ({ dateRange, onChange }) => { + const formatDateForInput = (date: Date) => { + return date.toISOString().split('T')[0]; + }; + + const handleStartChange = (e: React.ChangeEvent) => { + const newStart = new Date(e.target.value); + if (!isNaN(newStart.getTime())) { + onChange({ ...dateRange, start: newStart }); + } + }; + + const handleEndChange = (e: React.ChangeEvent) => { + const newEnd = new Date(e.target.value); + if (!isNaN(newEnd.getTime())) { + onChange({ ...dateRange, end: newEnd }); + } + }; + + return ( +
+ +
+ + até + +
+
+ ); +}; \ No newline at end of file diff --git a/components/KPICard.tsx b/components/KPICard.tsx new file mode 100644 index 0000000..4004a10 --- /dev/null +++ b/components/KPICard.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { LucideIcon } from 'lucide-react'; + +interface KPICardProps { + title: string; + value: string | number; + subValue?: string; + trend?: 'up' | 'down' | 'neutral'; + trendValue?: string; + icon: LucideIcon; + colorClass?: string; +} + +export const KPICard: React.FC = ({ title, value, subValue, trend, trendValue, icon: Icon, colorClass = "bg-blue-500" }) => { + return ( +
+
+
+

{title}

+
{value}
+
+
+ {/* Note: In Tailwind bg-opacity works if colorClass is like 'bg-blue-500'. + Here we assume the consumer passes specific utility classes or we construct them. + Simpler approach: Use a wrapper */} +
+ +
+
+
+ + {(trend || subValue) && ( +
+ {trend === 'up' && ▲ {trendValue}} + {trend === 'down' && ▼ {trendValue}} + {subValue && {subValue}} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/components/Layout.tsx b/components/Layout.tsx new file mode 100644 index 0000000..c2d3949 --- /dev/null +++ b/components/Layout.tsx @@ -0,0 +1,171 @@ +import React, { useState, useEffect } from 'react'; +import { NavLink, useLocation, useNavigate } from 'react-router-dom'; +import { LayoutDashboard, Users, UserCircle, Bell, Search, Menu, X, LogOut, Hexagon, Settings, Building2 } from 'lucide-react'; +import { USERS } from '../constants'; +import { User } from '../types'; + +const SidebarItem = ({ to, icon: Icon, label, collapsed }: { to: string, icon: any, label: string, collapsed: boolean }) => ( + + `flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group ${ + isActive + ? 'bg-yellow-400 text-slate-900 font-semibold shadow-md shadow-yellow-400/20' + : 'text-slate-500 hover:bg-slate-100 hover:text-slate-900' + }` + } + > + + {!collapsed && {label}} + +); + +export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const location = useLocation(); + const navigate = useNavigate(); + const [currentUser, setCurrentUser] = useState(USERS[1]); // Default to standard user fallback + + useEffect(() => { + const storedUserId = localStorage.getItem('ctms_user_id'); + if (storedUserId) { + const user = USERS.find(u => u.id === storedUserId); + if (user) { + setCurrentUser(user); + } + } + }, []); + + const handleLogout = () => { + localStorage.removeItem('ctms_user_id'); + navigate('/login'); + }; + + // Simple title mapping based on route + const getPageTitle = () => { + if (location.pathname === '/') return 'Dashboard'; + if (location.pathname.includes('/admin/users')) return 'Gestão de Equipe'; + if (location.pathname.includes('/users/')) return 'Histórico do Usuário'; + if (location.pathname.includes('/attendances')) return 'Detalhes do Atendimento'; + if (location.pathname.includes('/super-admin')) return 'Gestão de Organizações'; + if (location.pathname.includes('/profile')) return 'Meu Perfil'; + return 'CTMS'; + }; + + const isSuperAdmin = currentUser.role === 'super_admin'; + + return ( +
+ {/* Sidebar */} + + + {/* Main Content */} +
+ {/* Header */} +
+
+ +

{getPageTitle()}

+
+ +
+ {/* Search Bar */} +
+ + +
+ + {/* Notifications */} +
+ +
+
+
+ + {/* Scrollable Content Area */} +
+ {children} +
+
+ + {/* Overlay for mobile */} + {isMobileMenuOpen && ( +
setIsMobileMenuOpen(false)} + /> + )} +
+ ); +}; \ No newline at end of file diff --git a/components/ProductLists.tsx b/components/ProductLists.tsx new file mode 100644 index 0000000..8c0dc8b --- /dev/null +++ b/components/ProductLists.tsx @@ -0,0 +1,60 @@ +import React, { useState } from 'react'; +import { ShoppingBag, TrendingUp } from 'lucide-react'; + +interface ProductStat { + name: string; + count: number; + percentage: number; +} + +interface ProductListsProps { + requested: ProductStat[]; + sold: ProductStat[]; +} + +export const ProductLists: React.FC = ({ requested, sold }) => { + const ListSection = ({ title, icon: Icon, data, color }: { title: string, icon: any, data: ProductStat[], color: string }) => ( +
+
+
+ +
+

{title}

+
+
    + {data.map((item, idx) => ( +
  • +
    + + {idx + 1} + + {item.name} +
    +
    + {item.count} + {item.percentage}% +
    +
  • + ))} + {data.length === 0 &&
  • Nenhum dado disponível.
  • } +
+
+ ); + + return ( +
+ + +
+ ); +}; \ No newline at end of file diff --git a/components/SellersTable.tsx b/components/SellersTable.tsx new file mode 100644 index 0000000..adeeb93 --- /dev/null +++ b/components/SellersTable.tsx @@ -0,0 +1,148 @@ +import React, { useState, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ChevronDown, ChevronUp, ChevronsUpDown } from 'lucide-react'; +import { User } from '../types'; + +interface SellerStat { + user: User; + total: number; + avgScore: string; + conversionRate: string; + responseTime: string; +} + +interface SellersTableProps { + data: SellerStat[]; +} + +type SortKey = keyof SellerStat | 'name'; // 'name' is inside user object + +export const SellersTable: React.FC = ({ data }) => { + const navigate = useNavigate(); + const [sortKey, setSortKey] = useState('conversionRate'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + + const handleSort = (key: SortKey) => { + if (sortKey === key) { + setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key); + setSortDirection('desc'); + } + }; + + const sortedData = useMemo(() => { + return [...data].sort((a, b) => { + let aValue: any = a[sortKey as keyof SellerStat]; + let bValue: any = b[sortKey as keyof SellerStat]; + + if (sortKey === 'name') { + aValue = a.user.name; + bValue = b.user.name; + } else { + // Convert strings like "85.5" to numbers for sorting + aValue = parseFloat(aValue as string); + bValue = parseFloat(bValue as string); + } + + if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1; + if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1; + return 0; + }); + }, [data, sortKey, sortDirection]); + + const SortIcon = ({ column }: { column: SortKey }) => { + if (sortKey !== column) return ; + return sortDirection === 'asc' ? : ; + }; + + return ( +
+
+

Ranking de Vendedores

+
+
+ + + + + + + + + + + + {sortedData.map((item, idx) => ( + navigate(`/users/${item.user.id}`)} + > + + + + + + + ))} + {sortedData.length === 0 && ( + + + + )} + +
handleSort('name')} + > +
Usuário
+
handleSort('total')} + > +
Atendimentos
+
handleSort('avgScore')} + > +
Nota Média
+
handleSort('responseTime')} + > +
Tempo Resp.
+
handleSort('conversionRate')} + > +
Conversão
+
+
+ #{idx + 1} + +
+
{item.user.name}
+
{item.user.email}
+
+
+
+ {item.total} + + = 80 ? 'bg-green-100 text-green-700' : parseFloat(item.avgScore) >= 60 ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700'}`}> + {item.avgScore} + + + {item.responseTime} min + +
+
+
+
+ {item.conversionRate}% +
+
Nenhum dado disponível para o período selecionado.
+
+
+ ); +}; \ No newline at end of file diff --git a/constants.ts b/constants.ts new file mode 100644 index 0000000..a8216d8 --- /dev/null +++ b/constants.ts @@ -0,0 +1,173 @@ + +import { Attendance, FunnelStage, Tenant, User } from './types'; + +export const CURRENT_TENANT_ID = 'tenant_123'; + +export const TENANTS: Tenant[] = [ + { + id: 'tenant_123', + name: 'Fasto Corp', + slug: 'fasto', + admin_email: 'admin@fasto.com', + status: 'active', + user_count: 12, + attendance_count: 1450, + created_at: '2023-01-15T10:00:00Z' + }, + { + id: 'tenant_456', + name: 'Acme Inc', + slug: 'acme-inc', + admin_email: 'contact@acme.com', + status: 'trial', + user_count: 5, + attendance_count: 320, + created_at: '2023-06-20T14:30:00Z' + }, + { + id: 'tenant_789', + name: 'Globex Utils', + slug: 'globex', + admin_email: 'sysadmin@globex.com', + status: 'inactive', + user_count: 2, + attendance_count: 45, + created_at: '2022-11-05T09:15:00Z' + }, + { + id: 'tenant_101', + name: 'Soylent Green', + slug: 'soylent', + admin_email: 'admin@soylent.com', + status: 'active', + user_count: 25, + attendance_count: 5600, + created_at: '2023-02-10T11:20:00Z' + }, +]; + +export const USERS: User[] = [ + { + id: 'sa1', + tenant_id: 'system', + name: 'Super Administrator', + email: 'root@system.com', + role: 'super_admin', + team_id: '', + avatar_url: 'https://ui-avatars.com/api/?name=Super+Admin&background=0f172a&color=fff', + bio: 'Administrador do Sistema Global', + status: 'active' + }, + { + id: 'u1', + tenant_id: 'tenant_123', + name: 'Lidya Chan', + email: 'lidya@fasto.com', + role: 'manager', + team_id: 'sales_1', + avatar_url: 'https://picsum.photos/id/1011/200/200', + bio: 'Gerente de Vendas com mais de 10 anos de experiência em SaaS. Apaixonada por construção de equipes e crescimento de receita.', + status: 'active' + }, + { + id: 'u2', + tenant_id: 'tenant_123', + name: 'Alex Noer', + email: 'alex@fasto.com', + role: 'agent', + team_id: 'sales_1', + avatar_url: 'https://picsum.photos/id/1012/200/200', + bio: 'Melhor desempenho no Q3. Focado em clientes corporativos e relacionamentos de longo prazo.', + status: 'active' + }, + { + id: 'u3', + tenant_id: 'tenant_123', + name: 'Angela Moss', + email: 'angela@fasto.com', + role: 'agent', + team_id: 'sales_1', + avatar_url: 'https://picsum.photos/id/1013/200/200', + status: 'inactive' + }, + { + id: 'u4', + tenant_id: 'tenant_123', + name: 'Brian Samuel', + email: 'brian@fasto.com', + role: 'agent', + team_id: 'sales_2', + avatar_url: 'https://picsum.photos/id/1014/200/200', + status: 'active' + }, + { + id: 'u5', + tenant_id: 'tenant_123', + name: 'Benny Chagur', + email: 'benny@fasto.com', + role: 'agent', + team_id: 'sales_2', + avatar_url: 'https://picsum.photos/id/1025/200/200', + status: 'active' + }, +]; + +const generateMockAttendances = (count: number): Attendance[] => { + const origins = ['WhatsApp', 'Instagram', 'Website', 'LinkedIn', 'Referral'] as const; + const stages = Object.values(FunnelStage); + const products = ['Plano Premium', 'Plano Básico', 'Suíte Enterprise', 'Consultoria']; + + return Array.from({ length: count }).map((_, i) => { + const user = USERS.slice(1)[Math.floor(Math.random() * (USERS.length - 1))]; // Skip super admin + const rand = Math.random(); + // Weighted stages for realism + let stage = FunnelStage.IDENTIFICATION; + let isConverted = false; + + if (rand > 0.85) { + stage = FunnelStage.WON; + isConverted = true; + } else if (rand > 0.7) { + stage = FunnelStage.LOST; + } else if (rand > 0.5) { + stage = FunnelStage.NEGOTIATION; + } else if (rand > 0.2) { + stage = FunnelStage.IDENTIFICATION; + } else { + stage = FunnelStage.NO_CONTACT; + } + + // Force won/lost logic consistency + if (stage === FunnelStage.WON) isConverted = true; + + return { + id: `att_${i}`, + tenant_id: 'tenant_123', + user_id: user.id, + created_at: new Date(Date.now() - Math.floor(Math.random() * 60 * 24 * 60 * 60 * 1000)).toISOString(), + summary: "Cliente perguntou sobre detalhes do produto e níveis de preços.", + attention_points: Math.random() > 0.8 ? ["Resposta demorada", "Verificar tom de voz"] : [], + improvement_points: ["Sugerir plano anual", "Fazer follow-up mais cedo"], + score: Math.floor(Math.random() * (100 - 50) + 50), + first_response_time_min: Math.floor(Math.random() * 120), + handling_time_min: Math.floor(Math.random() * 45), + funnel_stage: stage, + origin: origins[Math.floor(Math.random() * origins.length)], + product_requested: products[Math.floor(Math.random() * products.length)], + product_sold: isConverted ? products[Math.floor(Math.random() * products.length)] : undefined, + converted: isConverted, + }; + }); +}; + +export const MOCK_ATTENDANCES = generateMockAttendances(300); + +// Visual Constants +export const COLORS = { + primary: '#facc15', // Yellow-400 + secondary: '#1e293b', // Slate-800 + success: '#22c55e', + warning: '#f59e0b', + danger: '#ef4444', + charts: ['#3b82f6', '#10b981', '#6366f1', '#f59e0b', '#ec4899', '#8b5cf6'], +}; diff --git a/index.html b/index.html new file mode 100644 index 0000000..aa3a4b5 --- /dev/null +++ b/index.html @@ -0,0 +1,45 @@ + + + + + + CTMS | Commercial Team Management + + + + + + + +
+ + + \ No newline at end of file diff --git a/index.tsx b/index.tsx new file mode 100644 index 0000000..6ca5361 --- /dev/null +++ b/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +const rootElement = document.getElementById('root'); +if (!rootElement) { + throw new Error("Could not find root element to mount to"); +} + +const root = ReactDOM.createRoot(rootElement); +root.render( + + + +); \ No newline at end of file diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..169f01b --- /dev/null +++ b/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "CTMS - Commercial Team Management System", + "description": "A multi-tenant dashboard for commercial teams to track sales performance, analyzing attendance scores and funnel metrics.", + "requestFramePermissions": [] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..d5b63ce --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "ctms---commercial-team-management-system", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router-dom": "^7.13.0", + "lucide-react": "^0.574.0", + "recharts": "^3.7.0" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } +} diff --git a/pages/AttendanceDetail.tsx b/pages/AttendanceDetail.tsx new file mode 100644 index 0000000..c91601a --- /dev/null +++ b/pages/AttendanceDetail.tsx @@ -0,0 +1,222 @@ +import React, { useEffect, useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { getAttendanceById, getUserById } from '../services/dataService'; +import { Attendance, User, FunnelStage } from '../types'; +import { ArrowLeft, CheckCircle2, AlertCircle, Clock, Calendar, MessageSquare, ShoppingBag, Award, TrendingUp } from 'lucide-react'; + +export const AttendanceDetail: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const [data, setData] = useState(); + const [agent, setAgent] = useState(); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const loadData = async () => { + if (id) { + setLoading(true); + try { + const att = await getAttendanceById(id); + setData(att); + if (att) { + const u = await getUserById(att.user_id); + setAgent(u); + } + } catch (error) { + console.error("Error loading details", error); + } finally { + setLoading(false); + } + } + }; + loadData(); + }, [id]); + + if (loading) return
Carregando detalhes...
; + if (!data) return
Registro de atendimento não encontrado
; + + const getStageColor = (stage: FunnelStage) => { + switch (stage) { + case FunnelStage.WON: return 'text-green-700 bg-green-50 border-green-200'; + case FunnelStage.LOST: return 'text-red-700 bg-red-50 border-red-200'; + default: return 'text-blue-700 bg-blue-50 border-blue-200'; + } + }; + + const getScoreColor = (score: number) => { + if (score >= 80) return 'text-green-500'; + if (score >= 60) return 'text-yellow-500'; + return 'text-red-500'; + }; + + return ( +
+ {/* Top Nav & Context */} +
+ + Voltar para Histórico + +
ID: {data.id}
+
+ + {/* Hero Header */} +
+
+
+
+ + {data.funnel_stage} + + + {new Date(data.created_at).toLocaleString('pt-BR')} + + + {data.origin} + +
+

+ {data.summary} +

+ {agent && ( +
+ + Agente: {agent.name} +
+ )} +
+ +
+ Nota de Qualidade +
+ {data.score} +
+ de 100 +
+
+
+ + {/* Main Content Grid */} +
+ + {/* Left Column: Analysis */} +
+ + {/* Summary / Transcript Stub */} +
+

+ + Resumo da Interação +

+

+ {data.summary} O cliente perguntou sobre detalhes específicos relacionados ao {data.product_requested}. + As discussões envolveram níveis de preços, prazos de implementação e potenciais descontos por volume. + A interação foi concluída com {data.converted ? 'uma venda realizada' : 'o cliente pedindo mais tempo para decidir'}. +

+
+ + {/* Feedback Section */} +
+ {/* Points of Attention */} +
+
+ +

Pontos de Atenção

+
+
+ {data.attention_points && data.attention_points.length > 0 ? ( +
    + {data.attention_points.map((pt, idx) => ( +
  • + + {pt} +
  • + ))} +
+ ) : ( +

Nenhum problema detectado.

+ )} +
+
+ + {/* Points of Improvement */} +
+
+ +

Dicas de Melhoria

+
+
+ {data.improvement_points && data.improvement_points.length > 0 ? ( +
    + {data.improvement_points.map((pt, idx) => ( +
  • + + {pt} +
  • + ))} +
+ ) : ( +

Continue o bom trabalho!

+ )} +
+
+
+
+ + {/* Right Column: Metadata & Metrics */} +
+
+

Métricas de Performance

+
+
+
+
+ Primeira Resposta +
+ {data.first_response_time_min} min +
+ +
+
+
+ Tempo Atendimento +
+ {data.handling_time_min} min +
+
+
+ +
+

Contexto de Vendas

+
+
+ Produto Solicitado +
+ {data.product_requested} +
+
+ +
+ +
+ Desfecho + {data.converted ? ( +
+ Venda Fechada + {data.product_sold && {data.product_sold}} +
+ ) : ( +
+
Não Convertido +
+ )} +
+
+
+
+ +
+
+ ); +}; \ No newline at end of file diff --git a/pages/Dashboard.tsx b/pages/Dashboard.tsx new file mode 100644 index 0000000..25bd0c2 --- /dev/null +++ b/pages/Dashboard.tsx @@ -0,0 +1,314 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, PieChart, Pie, Legend +} from 'recharts'; +import { + Users, Clock, Phone, TrendingUp, Filter +} from 'lucide-react'; +import { getAttendances, getUsers } from '../services/dataService'; +import { CURRENT_TENANT_ID, COLORS } from '../constants'; +import { Attendance, DashboardFilter, FunnelStage, User } 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 +interface SellerStats { + total: number; + converted: number; + scoreSum: number; + count: number; + timeSum: number; +} + +export const Dashboard: React.FC = () => { + const [loading, setLoading] = useState(true); + const [data, setData] = useState([]); + const [users, setUsers] = useState([]); + + 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', + }); + + useEffect(() => { + const fetchData = async () => { + setLoading(true); + try { + // Fetch users and attendances in parallel + const [fetchedUsers, fetchedData] = await Promise.all([ + getUsers(CURRENT_TENANT_ID), + getAttendances(CURRENT_TENANT_ID, filters) + ]); + + setUsers(fetchedUsers); + setData(fetchedData); + } catch (error) { + console.error("Error loading dashboard data:", error); + } finally { + setLoading(false); + } + }; + fetchData(); + }, [filters]); + + // --- Metrics Calculations --- + const totalLeads = data.length; + const avgScore = data.length > 0 ? (data.reduce((acc, curr) => acc + curr.score, 0) / data.length).toFixed(1) : "0"; + const avgResponseTime = data.length > 0 ? (data.reduce((acc, curr) => acc + curr.first_response_time_min, 0) / data.length).toFixed(0) : "0"; + const avgHandleTime = data.length > 0 ? (data.reduce((acc, curr) => acc + curr.handling_time_min, 0) / data.length).toFixed(0) : "0"; + + // --- Chart Data: Funnel --- + const funnelData = useMemo(() => { + const stagesOrder = [ + FunnelStage.NO_CONTACT, + FunnelStage.IDENTIFICATION, + FunnelStage.NEGOTIATION, + FunnelStage.WON, + FunnelStage.LOST + ]; + + const counts = data.reduce((acc, curr) => { + acc[curr.funnel_stage] = (acc[curr.funnel_stage] || 0) + 1; + return acc; + }, {} as Record); + + return stagesOrder.map(stage => ({ + name: stage, + value: counts[stage] || 0 + })); + }, [data]); + + // --- Chart Data: Origin --- + const originData = useMemo(() => { + const origins = data.reduce((acc, curr) => { + acc[curr.origin] = (acc[curr.origin] || 0) + 1; + return acc; + }, {} as Record); + + // Ensure type safety for value in sort + return (Object.entries(origins) as [string, number][]) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => b.value - a.value); + }, [data]); + + // --- Table Data: Sellers Ranking --- + 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]); + + // --- Lists Data: Products --- + const productStats = useMemo(() => { + const requested: Record = {}; + const sold: Record = {}; + + data.forEach(d => { + if (d.product_requested) requested[d.product_requested] = (requested[d.product_requested] || 0) + 1; + if (d.product_sold) sold[d.product_sold] = (sold[d.product_sold] || 0) + 1; + }); + + const formatList = (record: Record, total: number) => + Object.entries(record) + .map(([name, count]) => ({ name, count, percentage: Math.round((count / (total || 1)) * 100) })) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + + const totalReq = Object.values(requested).reduce((a, b) => a + b, 0); + const totalSold = Object.values(sold).reduce((a, b) => a + b, 0); + + return { + requested: formatList(requested, totalReq), + sold: formatList(sold, totalSold) + }; + }, [data]); + + + const handleFilterChange = (key: keyof DashboardFilter, value: any) => { + setFilters(prev => ({ ...prev, [key]: value })); + }; + + if (loading && data.length === 0) { + return
Carregando Dashboard...
; + } + + return ( +
+ {/* Filters Bar */} +
+
+ + Filtros: +
+ +
+ handleFilterChange('dateRange', range)} + /> + + + + +
+
+ + {/* KPI Cards */} +
+ + 75 ? 'up' : 'down'} + trendValue="2.1" + icon={TrendingUp} + colorClass="text-purple-600" + /> + + +
+ + {/* Charts Section */} +
+ {/* Funnel Chart */} +
+

Funil de Vendas

+
+ + + + + + + + {funnelData.map((entry, index) => ( + + ))} + + + +
+
+ + {/* Origin Pie Chart */} +
+

Origem dos Leads

+
+ + + + {originData.map((entry, index) => ( + + ))} + + + + + +
+
+
+ + {/* Ranking Table */} + + + {/* Product Lists */} + +
+ ); +}; \ No newline at end of file diff --git a/pages/Login.tsx b/pages/Login.tsx new file mode 100644 index 0000000..3884325 --- /dev/null +++ b/pages/Login.tsx @@ -0,0 +1,190 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Hexagon, Lock, Mail, ArrowRight, Loader2, Info } from 'lucide-react'; +import { USERS } from '../constants'; + +export const Login: React.FC = () => { + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(false); + const [email, setEmail] = useState('lidya@fasto.com'); + const [password, setPassword] = useState('password'); + const [error, setError] = useState(''); + + const handleLogin = (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(''); + + // Simulate API call and validation + setTimeout(() => { + const user = USERS.find(u => u.email.toLowerCase() === email.toLowerCase()); + + if (user) { + // Mock Login: Save to local storage + localStorage.setItem('ctms_user_id', user.id); + + setIsLoading(false); + + // Redirect based on role + if (user.role === 'super_admin') { + navigate('/super-admin'); + } else { + navigate('/'); + } + } else { + setIsLoading(false); + setError('Usuário não encontrado. Tente lidya@fasto.com ou root@system.com'); + } + }, 1000); + }; + + const fillCredentials = (type: 'admin' | 'super') => { + if (type === 'admin') { + setEmail('lidya@fasto.com'); + } else { + setEmail('root@system.com'); + } + setPassword('password'); + }; + + return ( +
+
+
+
+ +
+ Fasto. +
+

+ Acesse sua conta +

+

+ Ou inicie seu teste grátis de 14 dias +

+
+ +
+
+ + {/* Demo Helper - Remove in production */} +
+
+ Dicas de Acesso (Demo): +
+
+ + +
+
+ +
+
+ +
+
+ +
+ setEmail(e.target.value)} + className="block w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg leading-5 bg-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500 sm:text-sm transition-all" + placeholder="voce@empresa.com" + /> +
+
+ +
+ +
+
+ +
+ setPassword(e.target.value)} + className="block w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg leading-5 bg-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500 sm:text-sm transition-all" + placeholder="••••••••" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + +
+ + +
+ +
+ +
+
+ +
+
+
+
+
+
+ Protegido por SSO Corporativo +
+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/pages/SuperAdmin.tsx b/pages/SuperAdmin.tsx new file mode 100644 index 0000000..473bc88 --- /dev/null +++ b/pages/SuperAdmin.tsx @@ -0,0 +1,362 @@ +import React, { useState, useMemo } from 'react'; +import { + Building2, Users, MessageSquare, Plus, Search, MoreHorizontal, + Edit, Trash2, Shield, Calendar, ChevronDown, ChevronUp, ChevronsUpDown, X +} from 'lucide-react'; +import { TENANTS, MOCK_ATTENDANCES, USERS } from '../constants'; +import { Tenant } from '../types'; +import { DateRangePicker } from '../components/DateRangePicker'; +import { KPICard } from '../components/KPICard'; + +// Reusing KPICard but simplifying logic for this view if needed +// We can use the existing KPICard component. + +export const SuperAdmin: React.FC = () => { + const [dateRange, setDateRange] = useState({ + start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + end: new Date() + }); + const [selectedTenantId, setSelectedTenantId] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + const [tenants, setTenants] = useState(TENANTS); + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingTenant, setEditingTenant] = useState(null); + + // Sorting State + const [sortKey, setSortKey] = useState('created_at'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + + // --- Metrics --- + const totalTenants = tenants.length; + // Mock aggregation for demo + const totalUsersGlobal = tenants.reduce((acc, t) => acc + (t.user_count || 0), 0); + const totalAttendancesGlobal = tenants.reduce((acc, t) => acc + (t.attendance_count || 0), 0); + + // --- Data Filtering & Sorting --- + const filteredTenants = useMemo(() => { + let data = tenants; + + // Search + if (searchQuery) { + const q = searchQuery.toLowerCase(); + data = data.filter(t => + t.name.toLowerCase().includes(q) || + t.admin_email?.toLowerCase().includes(q) || + t.slug?.toLowerCase().includes(q) + ); + } + + // Tenant Filter (Select) + if (selectedTenantId !== 'all') { + data = data.filter(t => t.id === selectedTenantId); + } + + // Sort + return [...data].sort((a, b) => { + const aVal = a[sortKey]; + const bVal = b[sortKey]; + + if (aVal === undefined) return 1; + if (bVal === undefined) return -1; + + if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1; + if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1; + return 0; + }); + }, [tenants, searchQuery, selectedTenantId, sortKey, sortDirection]); + + // --- Handlers --- + const handleSort = (key: keyof Tenant) => { + if (sortKey === key) { + setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key); + setSortDirection('asc'); + } + }; + + const handleEdit = (tenant: Tenant) => { + setEditingTenant(tenant); + setIsModalOpen(true); + }; + + const handleDelete = (id: string) => { + if (confirm('Tem certeza que deseja excluir esta organização? Esta ação não pode ser desfeita.')) { + setTenants(prev => prev.filter(t => t.id !== id)); + } + }; + + const handleSaveTenant = (e: React.FormEvent) => { + e.preventDefault(); + // Logic to save (mock) + setIsModalOpen(false); + setEditingTenant(null); + alert('Organização salva com sucesso (Mock)'); + }; + + // --- Helper Components --- + const SortIcon = ({ column }: { column: keyof Tenant }) => { + if (sortKey !== column) return ; + return sortDirection === 'asc' ? : ; + }; + + const StatusBadge = ({ status }: { status?: string }) => { + const styles = { + active: 'bg-green-100 text-green-700 border-green-200', + inactive: 'bg-slate-100 text-slate-700 border-slate-200', + trial: 'bg-purple-100 text-purple-700 border-purple-200', + }; + const style = styles[status as keyof typeof styles] || styles.inactive; + + let label = status || 'Desconhecido'; + if (status === 'active') label = 'Ativo'; + if (status === 'inactive') label = 'Inativo'; + if (status === 'trial') label = 'Teste'; + + return ( + + {label} + + ); + }; + + return ( +
+ {/* Header */} +
+
+

Painel Super Admin

+

Gerencie organizações, visualize estatísticas globais e saúde do sistema.

+
+
+ +
+
+ + {/* KPI Cards */} +
+ + + +
+ + {/* Filters & Search */} +
+
+ + +
+ + +
+ +
+ + setSearchQuery(e.target.value)} + className="w-full pl-9 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm outline-none focus:ring-2 focus:ring-blue-100 transition-all" + /> +
+
+ + {/* Tenants Table */} +
+
+ + + + + + + + + + + + + {filteredTenants.map((tenant) => ( + + + + + + + + + ))} + {filteredTenants.length === 0 && ( + + + + )} + +
handleSort('name')}> +
Organização
+
Slug handleSort('status')}> +
Status
+
handleSort('user_count')}> +
Usuários
+
handleSort('attendance_count')}> +
Atendimentos
+
Ações
+
+
+ {tenant.logo_url ? : } +
+
+
{tenant.name}
+
{tenant.admin_email}
+
+
+
+ {tenant.slug} + + + + {tenant.user_count} + + {tenant.attendance_count?.toLocaleString()} + +
+ + +
+
Nenhuma organização encontrada com os filtros atuais.
+
+ + {/* Simple Pagination Footer */} +
+ Mostrando {filteredTenants.length} de {tenants.length} organizações +
+ + +
+
+
+ + {/* Add/Edit Modal */} + {isModalOpen && ( +
+
+
+

{editingTenant ? 'Editar Organização' : 'Adicionar Nova Organização'}

+ +
+ +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/pages/TeamManagement.tsx b/pages/TeamManagement.tsx new file mode 100644 index 0000000..ff314b7 --- /dev/null +++ b/pages/TeamManagement.tsx @@ -0,0 +1,285 @@ +import React, { useState } from 'react'; +import { Users, Plus, MoreHorizontal, Mail, Shield, Search, X, Edit, Trash2, Save } from 'lucide-react'; +import { USERS } from '../constants'; +import { User } from '../types'; + +export const TeamManagement: React.FC = () => { + const [users, setUsers] = useState(USERS.filter(u => u.role !== 'super_admin')); // Default hide super admin from list + const [isModalOpen, setIsModalOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + + // State for handling Add/Edit + const [editingUser, setEditingUser] = useState(null); + const [formData, setFormData] = useState({ + name: '', + email: '', + role: 'agent' as 'super_admin' | 'admin' | 'manager' | 'agent', + team_id: 'sales_1', + status: 'active' as 'active' | 'inactive' + }); + + const filteredUsers = users.filter(u => + u.name.toLowerCase().includes(searchTerm.toLowerCase()) || + u.email.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const getRoleBadge = (role: string) => { + switch (role) { + case 'super_admin': return 'bg-slate-900 text-white border-slate-700'; + case 'admin': return 'bg-purple-100 text-purple-700 border-purple-200'; + case 'manager': return 'bg-blue-100 text-blue-700 border-blue-200'; + default: return 'bg-slate-100 text-slate-700 border-slate-200'; + } + }; + + const getStatusBadge = (status: string) => { + if (status === 'active') { + return 'bg-green-100 text-green-700 border-green-200'; + } + return 'bg-slate-100 text-slate-500 border-slate-200'; + }; + + // Actions + const handleDelete = (userId: string) => { + if (window.confirm('Tem certeza que deseja excluir este usuário? Esta ação não pode ser desfeita.')) { + setUsers(prev => prev.filter(u => u.id !== userId)); + } + }; + + const openAddModal = () => { + setEditingUser(null); + setFormData({ name: '', email: '', role: 'agent', team_id: 'sales_1', status: 'active' }); + setIsModalOpen(true); + }; + + const openEditModal = (user: User) => { + setEditingUser(user); + setFormData({ + name: user.name, + email: user.email, + role: user.role, + team_id: user.team_id, + status: user.status + }); + setIsModalOpen(true); + }; + + const handleSave = (e: React.FormEvent) => { + e.preventDefault(); + + if (editingUser) { + // Update existing user + setUsers(prev => prev.map(u => u.id === editingUser.id ? { ...u, ...formData } : u)); + } else { + // Create new user + const newUser: User = { + id: Date.now().toString(), + tenant_id: 'tenant_123', // Mock default + avatar_url: `https://ui-avatars.com/api/?name=${encodeURIComponent(formData.name)}&background=random`, + ...formData + }; + setUsers(prev => [...prev, newUser]); + } + setIsModalOpen(false); + }; + + return ( +
+
+
+

Gerenciamento de Equipe

+

Gerencie acesso, funções e times de vendas da sua organização.

+
+ +
+ +
+ {/* Toolbar */} +
+
+ + setSearchTerm(e.target.value)} + /> +
+
+ {filteredUsers.length} membros encontrados +
+
+ + {/* Table */} +
+ + + + + + + + + + + + {filteredUsers.map((user) => ( + + + + + + + + ))} + {filteredUsers.length === 0 && ( + + + + )} + +
UsuárioFunçãoTimeStatusAções
+
+
+ + +
+
+
{user.name}
+
+ {user.email} +
+
+
+
+ + {user.role === 'manager' ? 'Gerente' : user.role === 'agent' ? 'Agente' : user.role === 'admin' ? 'Admin' : 'Super Admin'} + + + {user.team_id === 'sales_1' ? 'Vendas Alpha' : user.team_id === 'sales_2' ? 'Vendas Beta' : '-'} + + + {user.status === 'active' ? 'Ativo' : 'Inativo'} + + +
+ + +
+
Nenhum usuário encontrado.
+
+
+ + {/* Add/Edit Modal */} + {isModalOpen && ( +
+
+
+

{editingUser ? 'Editar Usuário' : 'Convidar Novo Membro'}

+ +
+ +
+
+ + setFormData({...formData, name: e.target.value})} + /> +
+ +
+ + setFormData({...formData, email: e.target.value})} + /> +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/pages/UserDetail.tsx b/pages/UserDetail.tsx new file mode 100644 index 0000000..b1028e5 --- /dev/null +++ b/pages/UserDetail.tsx @@ -0,0 +1,212 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { getAttendances, getUserById } from '../services/dataService'; +import { CURRENT_TENANT_ID } from '../constants'; +import { Attendance, User, FunnelStage } from '../types'; +import { ArrowLeft, Mail, Phone, Clock, MessageSquare, ChevronLeft, ChevronRight, Eye } from 'lucide-react'; + +const ITEMS_PER_PAGE = 10; + +export const UserDetail: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const [user, setUser] = useState(); + const [attendances, setAttendances] = useState([]); + const [loading, setLoading] = useState(true); + const [currentPage, setCurrentPage] = useState(1); + + useEffect(() => { + if (id) { + setLoading(true); + + const loadData = async () => { + try { + const u = await getUserById(id); + setUser(u); + + if (u) { + const data = await getAttendances(CURRENT_TENANT_ID, { + userId: id, + dateRange: { start: new Date(0), end: new Date() } // All time + }); + setAttendances(data); + } + } catch (error) { + console.error("Error loading user details", error); + } finally { + setLoading(false); + } + }; + + loadData(); + } + }, [id]); + + const totalPages = Math.ceil(attendances.length / ITEMS_PER_PAGE); + + const currentData = useMemo(() => { + const start = (currentPage - 1) * ITEMS_PER_PAGE; + return attendances.slice(start, start + ITEMS_PER_PAGE); + }, [attendances, currentPage]); + + const handlePageChange = (newPage: number) => { + if (newPage >= 1 && newPage <= totalPages) { + setCurrentPage(newPage); + } + }; + + const getStageBadgeColor = (stage: FunnelStage) => { + switch (stage) { + case FunnelStage.WON: return 'bg-green-100 text-green-700 border-green-200'; + case FunnelStage.LOST: return 'bg-red-100 text-red-700 border-red-200'; + case FunnelStage.NEGOTIATION: return 'bg-blue-100 text-blue-700 border-blue-200'; + case FunnelStage.IDENTIFICATION: return 'bg-yellow-100 text-yellow-700 border-yellow-200'; + default: return 'bg-slate-100 text-slate-700 border-slate-200'; + } + }; + + const getScoreColor = (score: number) => { + if (score >= 80) return 'text-green-600 bg-green-50'; + if (score >= 60) return 'text-yellow-600 bg-yellow-50'; + return 'text-red-600 bg-red-50'; + }; + + if (!loading && !user) return
Usuário não encontrado
; + + return ( +
+ {/* Header Section */} +
+
+ + + + {user && ( +
+ {user.name} +
+

{user.name}

+
+ {user.email} + {user.role} +
+
+
+ )} +
+
+ + {/* KPI Cards */} +
+
+
Total de Interações
+
{attendances.length}
+
+
+
Taxa de Conversão
+
+ {attendances.length ? ((attendances.filter(a => a.converted).length / attendances.length) * 100).toFixed(1) : 0}% +
+
+
+
Nota Média
+
+ {attendances.length ? (attendances.reduce((acc, c) => acc + c.score, 0) / attendances.length).toFixed(1) : 0} +
+
+
+ + {/* Attendance Table */} +
+
+

Histórico de Atendimentos

+ Página {currentPage} de {totalPages || 1} +
+ + {loading ? ( +
Carregando registros...
+ ) : ( +
+ + + + + + + + + + + + + {currentData.map(att => ( + + + + + + + + + ))} + +
Data / HoraResumoEtapaNotaT. Resp.Ação
+
{new Date(att.created_at).toLocaleDateString()}
+
{new Date(att.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
+
+
+ {att.summary} +
+ {att.origin} +
+
+
+ + {att.funnel_stage} + + + + {att.score} + + +
+ + {att.first_response_time_min}m +
+
+ + + +
+
+ )} + + {/* Pagination Footer */} + {totalPages > 1 && ( +
+ +
+ Mostrando {((currentPage - 1) * ITEMS_PER_PAGE) + 1} a {Math.min(currentPage * ITEMS_PER_PAGE, attendances.length)} de {attendances.length} +
+ +
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/pages/UserProfile.tsx b/pages/UserProfile.tsx new file mode 100644 index 0000000..8fc90a6 --- /dev/null +++ b/pages/UserProfile.tsx @@ -0,0 +1,207 @@ +import React, { useState, useEffect } from 'react'; +import { Camera, Save, Mail, User as UserIcon, Building, Shield, Loader2, CheckCircle2 } from 'lucide-react'; +import { USERS } from '../constants'; +import { User } from '../types'; + +export const UserProfile: React.FC = () => { + // Simulating logged-in user state + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + + // Form State + const [name, setName] = useState(''); + const [bio, setBio] = useState(''); + + useEffect(() => { + // Simulate fetching user data + const currentUser = USERS[0]; + setUser(currentUser); + setName(currentUser.name); + setBio(currentUser.bio || ''); + }, []); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setIsSuccess(false); + + // Simulate API call + setTimeout(() => { + setIsLoading(false); + setIsSuccess(true); + // In a real app, we would update the user context/store here + setTimeout(() => setIsSuccess(false), 3000); + }, 1500); + }; + + if (!user) return
Carregando perfil...
; + + return ( +
+
+

Meu Perfil

+

Gerencie suas informações pessoais e preferências.

+
+ +
+ {/* Left Column: Avatar & Basic Info */} +
+
+
+
+ {user.name} +
+
+ +
+ +
+ +

{user.name}

+

{user.email}

+ +
+ + {user.role} + + + {user.team_id === 'sales_1' ? 'Vendas Alpha' : 'Vendas Beta'} + +
+
+ +
+

Status da Conta

+
+
+ Ativo +
+
+ + Fasto Corp (Organização) +
+
+
+ + {/* Right Column: Edit Form */} +
+
+
+

Informações Pessoais

+ {isSuccess && ( + + Salvo com sucesso + + )} +
+ +
+
+
+ +
+
+ +
+ setName(e.target.value)} + className="block w-full pl-10 pr-3 py-2 border border-slate-200 rounded-lg bg-white text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500 sm:text-sm transition-all" + /> +
+
+ +
+ +
+
+ +
+ +
+

Contate o admin para alterar o e-mail.

+
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +